Desafio HTB - Secure Notes
A Linha de Raciocínio por Trás de um Bypass de IP via Prototype Pollution
O Cenário Inicial: Caixa-Branca e o Objetivo
Durante a resolução de um laboratório de segurança (CTF), nos foi disponibilizado o cenário clássico de teste caixa-branca (white-box): tínhamos acesso completo ao código-fonte da aplicação Node.js e seus arquivos de configuração.
Nosso objetivo era direto, porém desafiador: capturar a flag protegida por uma validação de IP na rota restrita /flag. Ter acesso ao código-fonte e ao arquivo package.json foi o fator decisivo para que pudéssemos cruzar a lógica de rede da aplicação com as dependências instaladas.
A Descoberta: O Alvo Protegido
Tudo começou com uma clássica barreira de entrada. Ao analisar as rotas da aplicação Express, encontramos o seguinte trecho de código protegendo a rota /flag:
javascriptapp.get('/flag', (req, res) => { const remoteAddress = req.connection.remoteAddress; if (remoteAddress === '127.0.0.1' || remoteAddress === '::1' || remoteAddress === '::ffff:127.0.0.1') { res.send(process.env.FLAG ?? 'HTB{f4k3_fl4g_f0r_t3st1ng}'); } else { res.status(403).json({ Message: 'Access denied' }); } });
A primeira pergunta que nos fizemos foi: Como podemos simular que a nossa requisição está partindo da própria máquina local (127.0.0.1), se a rede física nos identifica com o nosso IP externo real?
O Elo Fraco: Entrada Sem Sanitização
Procurando por caminhos para interagir com o sistema, analisamos a rota /update, responsável por atualizar notas no banco de dados MongoDB:
javascriptapp.post('/update', async (req, res) => { try { const { noteId } = req.body; await Note.findByIdAndUpdate(noteId, req.body); let result = await Note.find({ _id: noteId }); res.json(result); } catch (error) { console.error(error); res.status(500).json({ Message: "An error occurred" }); } });
O método findByIdAndUpdate recebe o objeto req.body inteiro. Isso significa que qualquer operador de banco de dados que enviarmos (como $set ou $rename) será executado cegamente pelo banco.
Ao inspecionar o arquivo package.json, notamos que o projeto utilizava o Mongoose na versão 7.2.4. Sabendo que o Mongoose interpreta caminhos complexos para mapear dados e possui um histórico de vulnerabilidades de atribuição de protótipo (Prototype Pollution), o que aconteceria se renomeássemos um campo do banco de dados para injetar propriedades especiais do JavaScript?
O Insight: A "Linha do Tempo" e o Protótipo do JS
Como o Mongoose e o Node.js compartilham o mesmo espaço de memória na mesma engine de execução do JavaScript (V8), qualquer alteração nos objetos base afeta todo o comportamento do servidor.
Lembramos que no JavaScript, a herança funciona de maneira dinâmica através de referências diretas (e não por cópias rígidas de classes). Cada objeto novo herda do ancestral comum de todos: o Object.prototype.
mermaidgraph TD Socket["Socket (Conexão do Usuário)"] --> netSocket["net.Socket.prototype"] netSocket --> ObjectProto["Object.prototype (O ancestral 'Adão e Eva')"]
Nossa linha de raciocínio lógico foi a seguinte:
- O Node.js precisa verificar o IP do cliente através do getter
req.connection.remoteAddress. - Para evitar consultar o sistema operacional a cada leitura, o Node.js lê um cache interno no socket chamado
_peername. - Se o cache
_peernameestiver definido, o Node.js confia nele e simplesmente lê_peername.address. - Como
_peernamenão é definido no socket na inicialização, a busca por essa propriedade sobe pela cadeia de protótipos atéObject.prototype. - Se conseguirmos usar a injeção NoSQL no Mongoose para poluir o
Object.prototype._peernameglobal com o valor{ address: "127.0.0.1" }, todos os sockets criados a partir daí herdarão dinamicamente esse "DNA" alterado.
Executando o Plano de Ataque
Com o plano traçado, executamos o bypass em três etapas:
1. Criar o Objeto de Payload
Primeiro, criamos uma nota comum no banco de dados com a propriedade title contendo a string correspondente ao IP local que desejamos simular:
httpPOST /create HTTP/1.1 Host: localhost:3000 Content-Type: application/json { "title": "127.0.0.1", "content": "Payload Cache" }
Esta requisição nos devolveu o ID da nota recém-criada.
2. Disparar a Poluição via $rename
Em seguida, enviamos uma atualização para a rota vulnerável. Usamos o operador $rename do MongoDB para renomear o campo title da nossa nota para "__proto__._peername.address".
httpPOST /update HTTP/1.1 Host: localhost:3000 Content-Type: application/json { "noteId": "ID_DA_NOTA_GERADA", "$rename": { "title": "__proto__._peername.address" } }
Quando o Mongoose processou o retorno desta operação e tentou mapear a nova estrutura com o caminho "__proto__._peername.address", ele acessou o protótipo global de objetos, inicializou o objeto _peername e injetou a propriedade address com o valor de IP estático diretamente nele.
3. Capturando a Flag
Por fim, fizemos a requisição para a rota restrita /flag. O getter do Node.js leu req.connection._peername, encontrou o valor herdado do protótipo poluído e assumiu que a conexão pertencia ao localhost (127.0.0.1), liberando o acesso.
httpGET /flag HTTP/1.1 Host: localhost:3000
Como Prevenir?
Para blindar a aplicação contra esse cenário de manipulação de protótipo:
- Validação de Entrada Estrita: Nunca passe objetos de requisição diretos para o banco de dados. Use esquemas com bibliotecas de validação rígidas (ex: Zod) para garantir que apenas chaves explícitas sejam permitidas.
- Congelamento de Protótipo: Ao inicializar a aplicação Node.js, você pode desativar a mutação do protótipo chamando:
Isso garante que mesmo se uma biblioteca tentar injetar chaves emjavascriptObject.freeze(Object.prototype);__proto__, a linha do tempo e o DNA global dos objetos estarão protegidos contra modificações.