vulnerabilities rfc1867 callback uaf

Dentro do Core PHP UAF: Como o Callback RFC1867 Cria Dangling Pointers (CVE-2026-109619)

php2shell.run Research Lab •

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

  1. Invalidação da sessão: session_reread() ou zend_hash_update() substitui a entrada existente.
  2. Liberação sem notificação: zval_ptr_dtor devolve o chunk ao Zend MM.
  3. Ponteiro global permanece: cached_post_bytes e cached_file_bytes não são zerados.
  4. Write UAF: o próximo chunk multipart adiciona chunk_len ao 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() quando memory_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 longjmp em 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.