Dentro do Core PHP UAF: Como o Callback RFC1867 Cria Dangling Pointers (CVE-2026-109619)
Dentro do Core PHP UAF: Como o Callback RFC1867 Cria Dangling Pointers (CVE-2026-109619)
<time datetime="2026-10-23">Verificado em 23 de outubro de 2026</time> · Arquivos: ext/session/session.c, main/rfc1867.c
CVE-2026-109619 é uma das falhas Use-After-Free mais sutis da Zend Engine. A causa é uma discrepância de lifetime entre o processo worker de longa duração e as alocações de heap vinculadas à requisição ou à sessão. Um worker PHP-FPM processa milhares de requisições; suas variáveis globais sobrevivem a cada uma. A HashTable de sessão e suas entradas zval, porém, são liberadas no reset de sessão. Um ponteiro que sobrevive a um lifetime e aponta para o outro inevitavelmente fica dangling.
1. Anatomia do tracking RFC1867
O parser multipart em main/rfc1867.c invoca, ao detectar um campo de progresso de upload, um callback registrado em ext/session/session.c. Esse callback cria uma entrada de progresso na HashTable de sessão e armazena o endereço do zval interno em uma variável estática de processo.
/* ext/session/session.c — modelo simplificado */
static zval *cached_post_bytes;
static zval *cached_file_bytes;
static int php_session_rfc1867_callback(unsigned int event,
void *event_data,
void **extra)
{
switch (event) {
case MULTIPART_EVENT_START: {
zval *progress = php_session_get_progress_entry();
ZVAL_LONG(&progress->value.bytes_processed, 0);
/* FALHA: endereço bruto de zval interno cacheado globalmente */
cached_post_bytes = &progress->value.bytes_processed;
cached_file_bytes = &progress->value.files[0].bytes_processed;
break;
}
case MULTIPART_EVENT_FILE_DATA:
Z_LVAL_P(cached_post_bytes) += chunk_len;
break;
}
return SUCCESS;
}
Faltam todos os mecanismos de proteção esperados de uma API C segura: sem Z_TRY_ADDREF, sem número de geração, sem callback de invalidação, sem weak reference. O ponteiro não é possuído nem rastreado pelo modelo de memória do Zend. Essa é a raiz do defeito.
2. Máquina de estados da memória
[ FASE 1: ALOCAÇÃO E CACHE DO PONTEIRO ]
┌─────────────────────────────────────────────────────────┐
│ Bucket de heap do Zend MM │
│ ┌──────────────────────┐ │
│ │ Entrada session_ht │ <─── cached_post_bytes (global)│
│ │ [bytes_processed] │ aponta para zval válido │
│ └──────────────────────┘ │
└─────────────────────────────────────────────────────────┘
│
│ Reset de sessão / nova requisição (session_reread)
▼
[ FASE 2: LIBERAÇÃO SEM INVALIDAÇÃO ]
┌─────────────────────────────────────────────────────────┐
│ Bucket de heap do Zend MM │
│ ┌──────────────────────┐ │
│ │ [ LIBERADO ] │ <─── cached_post_bytes (STALE) │
│ │ de volta ao FreeBin │ dangling pointer criado │
│ └──────────────────────┘ │
└─────────────────────────────────────────────────────────┘
│
│ Atacante reocupa o bucket
▼
[ FASE 3: OCUPAÇÃO DO HEAP E WRITE UAF ]
┌─────────────────────────────────────────────────────────┐
│ Bucket de heap do Zend MM │
│ ┌──────────────────────┐ │
│ │ Dados do atacante │ <─── *cached_post_bytes += len │
│ │ Estrutura fake zval │ corrupção intencional │
│ └──────────────────────┘ │
└─────────────────────────────────────────────────────────┘
O Zend MM gerencia memória livre em classes de tamanho (bins) e devolve chunks em ordem LIFO. Para um atacante, esse determinismo é uma vantagem: o chunk recém-liberado é o próximo a ser reutilizado. Se ele puder disparar a liberação e imediatamente forçar uma alocação do mesmo tamanho com dados controlados, seu conteúdo cai exatamente onde o ponteiro global ainda aponta.
3. Sequência de disparo do dangling pointer
- Invalidação da sessão:
session_reread()ouzend_hash_update()substitui a entrada existente. - Liberação sem notificação:
zval_ptr_dtordevolve o chunk ao Zend MM. - Ponteiro global permanece:
cached_post_bytesecached_file_bytesnão são zerados. - Write UAF: o próximo chunk multipart adiciona
chunk_lenao endereço liberado.
*cached_post_bytes += chunk_len; /* escrita em memória liberada */
À primeira vista é apenas uma operação aditiva. Mas se o atacante preencheu o slot de 16 bytes com uma estrutura zval forjada, a adição altera type_info e possivelmente value.ptr. Quando a VM Zend interpreta esse slot como objeto, ela dereferencia ponteiros de função via handlers — a transição de corrupção de memória para sequestro de fluxo de controle.
4. CVE-2026-109618: o caminho paralelo de OOM
CVE-2026-109618 não usa ponteiro global, mas alcança o mesmo estado via tratamento de erro por exaustão de memória.
- O PHP usa bailout baseado em
longjmp()quandomemory_limité excedido. - Durante o streaming RFC1867, estruturas intermediárias são inicializadas incrementalmente; alocação e inicialização não são atômicas.
- Se um OOM ocorrer em estrutura parcialmente inicializada, a rotina de cleanup libera campos ainda não totalmente configurados.
- Os metadados dos bins do ZMM tornam-se inconsistentes. Alocações subsequentes podem se sobrepor ou receber classes de tamanho incorretas.
O que ambas as CVE compartilham é um caminho de escrita válido em memória liberada. A diferença está no gatilho: reset de sessão (UAF) versus exaustão de memória (corrupção OOM).
5. Por que session_start() não importa
Muitos operadores argumentam que a aplicação não usa sessões e, portanto, não é afetada. Isso é um engano. O callback é registrado assim que a SAPI parseia o corpo da requisição — independentemente de o código da aplicação chamar session_start() depois. Com session.upload_progress.enabled=On, o PHP prepara o tracking de progresso já na chegada da requisição. Páginas estáticas, endpoints de API e handlers 404 são igualmente expostos.
6. Padrões seguros e o fix upstream
O fix upstream elimina o cache global e vincula o lifetime a um contexto de requisição com verificação de geração.
/* direção conceitual do fix */
typedef struct {
zend_string *session_id;
zend_ulong generation;
zval *progress;
} php_rfc1867_ctx;
static void php_rfc1867_write(php_rfc1867_ctx *ctx, size_t chunk_len)
{
if (!ctx || !ctx->progress ||
ctx->generation != php_session_generation()) {
return; /* contexto invalidado: sem escrita */
}
Z_LVAL(ctx->progress->value.bytes_processed) += chunk_len;
}
Para operadores que não podem patchear imediatamente, resta a desativação:
session.upload_progress.enabled = Off
php -v # deve ser 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35 ou mais recente
php-fpm -t
7. Observabilidade: como detectar tentativa de UAF
Um UAF raramente deixa logs limpos. Sinais confiáveis surgem da combinação de várias fontes.
# POSTs multipart pequenos e repetidos
sudo tail -f /var/log/nginx/access.log | \
grep -E '"POST .*HTTP/(1\.1|2)" (200|404|413)' | \
awk '{print $1, $4, $7}' | sort | uniq -c | sort -rn | head
# término anormal de workers
journalctl -u php8.3-fpm --since "1 hour ago" | \
grep -E 'segfault|SIGSEGV|SIGABRT|signal'
# core dumps
coredumpctl list php-fpm --since "1 hour ago" 2>/dev/null
No nível de rede, regras Suricata em PHP_SESSION_UPLOAD_PROGRESS; no nível de host, Falco detecta quando um filho php-fpm inicia sh, curl ou wget. A assinatura sozinha é fraca — a correlação com crashes de worker e conexões de saída a torna robusta.
8. Detalhes do alocador: por que a classe de tamanho importa
O Zend MM distingue alocações pequenas (Small Bins), médias (Large) e muito grandes (Huge). Os Small Bins são organizados em passos de 30 bytes; um zval de 16 bytes cai na menor classe. Para o atacante isso é favorável, pois esses slots são facilmente reocupados por strings, entradas de array ou valores de sessão. O tamanho exato é decisivo: se a nova alocação diferir em poucos bytes, ela cai em outra classe e o slot liberado permanece intocado.
Daí duas observações práticas para defesa. Primeiro, um heap fortemente fragmentado aumenta a probabilidade de sucesso, pois o atacante encontra muitos buracos do mesmo tamanho. Workers de longa duração com alta fragmentação são mais arriscados que os de vida curta. Segundo, pm.max_requests reduz a probabilidade de que um atacante construa uma ocupação estável ao longo de muitas requisições. Um valor entre 300 e 500 é um compromisso sensato entre performance e superfície de ataque. Em aplicações muito intensivas em memória, um valor menor pode causar reinícios frequentes e segmentos frios de OPcache.
Outro aspecto é o papel de session.save_path. Se o diretório de sessão está em filesystem de rede com alta latência, a janela entre liberação e reocupação se alonga. Isso não torna a exploração impossível, mas aumenta a dispersão e o número de tentativas necessárias. Para ambientes de alta segurança, um backend de sessão local e volátil (Redis com TTL curto, tmpfs) pode reduzir a previsibilidade. Importante também é rotacionar o ID de sessão a cada login e definir timeouts absolutos, para que um atacante não use uma sessão de longa duração como âncora.
Por fim, não subestime a monitoração do alocador. O Zend MM oferece, em builds de debug, verificações adicionais que preenchem chunks liberados com padrão e detectam double-free. Em builds de produção essas verificações estão desativadas por custo de performance. Para ambientes de staging onde se testam ataques semelhantes ao PHP2Shell, um build de debug é valioso: transforma um UAF silencioso em um aborto ruidoso com ponto reproduzível. Combine com USE_ZEND_ALLOC=0 e Valgrind para atribuir a escrita com clareza. Essas ferramentas não substituem o patch, mas fornecem evidência sólida para relatórios de incidente e priorização de contramedidas.
9. Conclusão
- A causa é um
zval*global que sobrevive à liberação da entrada de sessão. - O bailout
longjmpem OOM destrói metadados dos bins e cria chunks sobrepostos. - Ambas as CVE agem antes da execução do código da aplicação; filtros de aplicação são impotentes.
- Eficazes são apenas o patch (8.5.12 / 8.4.27 / 8.3.36 / 8.2.35) ou
session.upload_progress.enabled=Off, além da observação via rede, runtime e kernel.
Quem entende a causa também entende por que regras puras de WAF e limites de tamanho de body falham: o ataque ocorre abaixo da camada em que esses controles operam.