Em 20 de outubro de 2026 vazaram, antes da divulgação coordenada, detalhes de duas vulnerabilidades críticas no core do PHP. No dia seguinte, 21 de outubro, o time de segurança do PHP publicou uma release emergencial, fora do ciclo normal, corrigindo as duas falhas. A comunidade apelidou o par de PHP2Shell, nome que, vale registrar, não veio do projeto PHP.
As falhas são a CVE-2026-109618 e a CVE-2026-109619, ambas com CVSS v4.0 9.2 (crítico) segundo o próprio PHP, e a watchTowr chegou a classificar como 10.0. O resumo operacional é desconfortável: um atacante não autenticado, capaz apenas de enviar uma requisição de upload, consegue execução remota de código em praticamente qualquer instância PHP exposta.
Este post é uma análise técnica das duas falhas: o que cada uma é de verdade, por que existem duas CVEs para o mesmo recurso, e como as cadeias de exploração se encaixam. Não é um writeup para rodar PoC. É o que você precisa entender para avaliar exposição e priorizar correção.
Por que isso é grave em escala
PHP é o motor por trás de WordPress, Magento, Drupal, Laravel e de incontáveis aplicações sob medida. No dia 20 de outubro, o Shodan mostrava cerca de 884 mil hosts expostos à internet rodando PHP 8.1 ou anterior, aproximadamente 59% do ~1,5 milhão que reportava versão. São banners anunciados, não hosts confirmadamente vulneráveis, mas uma falha no core do PHP cai, de uma vez, sobre uma fatia enorme da internet.
Dois detalhes agravam o cenário. O primeiro: o score permanece em CVSS v4.0 9.2, com ataque pela rede, sem autenticação e alta complexidade. O segundo, e mais importante do ponto de vista de defesa, vem de uma observação dos pesquisadores da Wiz: as falhas subjacentes existiam há décadas, mas demonstrar a exploração exigia uma cadeia de passos excepcionalmente complexa, no limite do que pesquisadores humanos conseguiam fazer na prática. Modelos de IA de fronteira reduziram substancialmente essa barreira, transformando uma falha antiga em exploit. Esse é o ponto que mais me preocupa: bugs que ficaram adormecidos por serem "difíceis demais de explorar" deixaram de estar protegidos pela dificuldade.
Background: o que o session.upload_progress faz
Toda a análise gira em torno de um recurso específico. Quando um navegador envia um POST multipart/form-data e um dos campos se chama PHP_SESSION_UPLOAD_PROGRESS, o parser multipart rfc1867 do PHP invoca um callback de sessão para cada evento de parsing (MULTIPART_EVENT_START/FORMDATA/FILE_START/FILE_DATA/FILE_END/END). O callback escreve um array de progresso no armazenamento de sessão para que a aplicação possa consultar o status do upload.
Duas propriedades desse mecanismo são o que transformam um recurso de conveniência em superfície de ataque.
A primeira: ele roda pré-script. O callback dispara durante o parsing do corpo da requisição, antes do arquivo .php alvo executar, então funciona mesmo em endpoints que nunca chamam session_start(). A segunda: está ligado por padrão. O session.upload_progress.enabled tem default On, e o recurso existe inalterado desde a versão 5.4.0.
Ou seja: o código vulnerável roda antes da aplicação, sem precisar de nenhuma linha cooperante na app, em configuração padrão, há mais de uma década.
Por que duas CVEs?
As duas falhas nascem do mesmo recurso, mas são defeitos independentes, e por isso receberam identificadores separados.
A CVE-2026-109618 é um defeito de corrupção de memória do tipo Use-After-Free (UAF) dentro do desserializador. A CVE-2026-109619 é uma falha lógica no handler de upload-progress envolvendo fixação de session ID via corpo do POST e nomes de arquivo não validados, permitindo que atacantes plantem bytes arbitrários em arquivos de sessão escolhidos. Como cada falha pode ser explorada de forma independente, dois CVE IDs foram atribuídos.
Nos próprios termos do PHP: a 109618 é "RCE via OOM corruption" e a 109619 é "RCE via stale global state". Vamos detalhar os dois ângulos.
Os defeitos de raiz
Todos os defeitos abaixo vivem em ext/session/session.c (e um em ext/standard/var_unserializer.re).
1. Fixação de sessão a partir do corpo do POST
O MULTIPART_EVENT_FORMDATA aceita um valor PHPSESSID direto do corpo da requisição e o usa como session id, ignorando session.use_only_cookies. O atacante, portanto, controla qual arquivo de sessão é escrito, sem cookie e sem autenticação.
Esse é o primeiro primitivo: controle do destino. Eu decido o nome do arquivo de sessão que vai ser gravado no disco.
2. Nome de arquivo não validado gravado na sessão
O MULTIPART_EVENT_FILE_START armazena o nome do arquivo enviado verbatim no array de progresso, sem sanitização, sem encoding, sem limite de tamanho. Um nome de arquivo pode conter código-fonte PHP, objetos serializados e o delimitador | usado pelo serializador de sessão. Essa única checagem ausente é o pivô para as Chains A e C.
Segundo primitivo: controle do conteúdo. Combinado com o anterior, eu escrevo bytes que escolho, em um caminho que escolho.
3. var_destroy dispara __destruct sem serialize_lock, o UAF
Aqui mora a 109618. Em ext/standard/var_unserializer.re, o var_destroy() primeiro libera os chunks de extensão de back-reference, depois percorre a cadeia de destrutores e chama i_zval_ptr_dtor, sem BG(serialize_lock), então um __destruct definido pelo usuário roda no meio do teardown. Se esse destrutor dispara um unserialize() aninhado usando uma back-reference (R:N), a referência resolve para o chunk já liberado.
É o padrão clássico de use-after-free: liberar na fase 1, usar na fase 2 via um caminho que o código não esperava que fosse alcançável durante a destruição.
4. __destruct dispara mesmo quando a decodificação falha
Esse detalhe é o que torna uma das cadeias viável apesar de erro aparente. O serializador de sessão php roda os destrutores durante a limpeza PHP_VAR_UNSERIALIZE_DESTROY antes de retornar FAILURE. Então, mesmo que session_start() reporte "Failed to decode session object" e destrua $_SESSION, o __destruct do objeto injetado já executou.
Em outras palavras: o warning de "falha ao decodificar sessão" nos logs não significa que nada aconteceu. O destrutor já rodou antes do PHP desistir.
As cadeias de exploração
A pesquisa da ox.security reproduziu as falhas em laboratório isolado e mapeou três cadeias. Vale entender cada uma porque elas têm pré-condições diferentes, e isso muda totalmente a avaliação de risco de um alvo.
Chain A: injeção de sessão + LFI = RCE
A mais direta de entender. Envia-se um POST multipart que (a) define PHPSESSID para um valor escolhido, decidindo o nome do arquivo de sessão, e (b) envia um arquivo cujo nome é um payload PHP. O handler de upload-progress armazena esse nome verbatim e, como também escreve a sessão em disco, o resultado é um arquivo real em um caminho conhecido de antemão.
O detalhe que fecha a conta: o session.save_path não é segredo. É um conjunto pequeno e bem conhecido (/tmp, /var/lib/php/sessions, /tmp/sessions no lab). Depois de uma única requisição, existe no servidor um arquivo, em local que controlo, com conteúdo que controlo, contendo algo como s:4:"name";s:32:"<?php system('id');echo 'RCE';?>"; no meio do texto serializado.
Mas nada executou ainda. O LFI não está no core do PHP. É um bug da aplicação, e é a pré-condição listada para essa cadeia. Quando o atacante aponta o LFI para o arquivo de sessão forjado, o include() carrega /tmp/sessions/sess_system('id') aparece cuspida no meio do texto da sessão, rodando como o usuário web.
Resumo da Chain A: o PHP te dá o primitivo de escrever arquivo arbitrário; você ainda precisa de um Local File Inclusion na aplicação para transformar isso em execução.
Chain B: o use-after-free do var_destroy
Essa é a metade memory-corruption, e a mais perigosa por um motivo específico: é a que sobrevive ao upload_progress.enabled = Off.
O gatilho é um payload unserialize() criado de forma especial: ~2000 entradas inteiras duplicadas transbordam o VAR_ENTRIES_MAX (1018), fazendo a tabela de back-reference vazar para os chunks de extensão no heap, e uma chave duplicada move um objeto para a lista de destrutores. Durante o var_destroy, esses chunks são liberados (fase 1) e então um __destruct de objeto roda (fase 2) e realiza um unserialize('R:1500;') aninhado, uma leitura de back-reference direto na memória liberada.
O caminho de trigger, pelo relatório ASAN, passa por var_destroy:308 → i_zval_ptr_dtor → zend_objects_destroy_object → zend_call_function (o __destruct) → unserialize aninhado → leitura UAF.
E aqui vem a parte que transforma leitura inválida em execução: aquele chunk liberado de 8160 bytes é recuperável com um array empacotado controlado pelo atacante; uma vez que o bucket liberado contém dados do atacante, a leitura/escrita subsequente vira um ponteiro controlado, que pode ser direcionado para uma chamada forjada através de zend_call_function, system().
O mais crítico: alcançado pré-autenticação, o decodificador de sessão (Chains A/C) alimenta bytes do atacante nesse mesmo php_var_unserialize, então nenhuma chamada unserialize() em userland é necessária. Ou seja: a Chain B não depende da aplicação chamar unserialize() em dado do usuário. O próprio mecanismo de sessão entrega o payload ao desserializador vulnerável.
Chain C: mismatch de serialize_handler = __destruct = RCE
Essa explora uma inconsistência comum de configuração. Muitos deploys reais definem session.serialize_handler diferente por diretório (via .user.ini). Essa inconsistência é explorável: uma sessão escrita com php_serialize mas lida com o handler php é mal-interpretada, e o | que o atacante contrabandeou pelo nome do arquivo é tratado como delimitador chave/valor do serializador.
O fluxo é: fazer upload para um diretório configurado com php_serialize, com um nome de arquivo contendo |O:4:"Evil":1:{s:3:"cmd";s:2:"id";}. Depois, acertar um diretório que usa o handler php padrão com o mesmo SID. Seu decodificador procura por |, divide, e desserializa o objeto injetado. Os bytes finais fazem a decodificação falhar, mas o __destruct do objeto já disparou.
É o defeito 2.4 em ação. A saída mostra [Evil::__destruct] executing: id, uid=33(www-data), imediatamente seguido pelo próprio warning do PHP "Failed to decode session object". No mundo real, Evil é substituído por um gadget POP de qualquer framework que o alvo rode.
Validação de impacto
Não é teoria de laboratório contida. Segundo a análise da Huntress sobre o aviso do PHP: o impacto é execução remota de código não autenticada com a conta do web worker; a RCE completa foi validada por HTTP contra mod_php com os limites padrão de input e memória do PHP, e configurações prefork normais com múltiplos workers.
Descrevendo as duas falhas em termos de causa: a CVE-2026-109618 é uma falha de RCE no tratamento de erro de out-of-memory do PHP; se o PHP fica sem memória enquanto monta os dados da requisição, é interrompido e esses dados ficam só parcialmente inicializados. Durante a limpeza da requisição, esses dados parcialmente inicializados são tratados como válidos, criando um caminho para execução de código. E a CVE-2026-109619 é causada pelo PHP reter estado específico da requisição depois que a memória de suporte foi liberada; se o mesmo worker processa uma requisição posterior, o PHP pode acessar esse estado obsoleto antes da inicialização normal resetá-lo.
Versões afetadas e correções
O componente afetado é o PHP Core (ext/session, ext/standard), em todas as versões que suportam session.upload_progress, PHP 5.4.0 até as releases não corrigidas.
As versões corrigidas, lançadas em 21 de outubro, são PHP 8.5.12, 8.4.27, 8.3.36 e 8.2.35. O ponto cego óbvio: o projeto PHP não dá mais suporte ao PHP 8.1 e versões anteriores. Ou seja, quem roda 8.1, 8.0, 7.x ou 5.x não recebe patch oficial e precisa tratar isolamento ou migração como prioridade.
Para rastreamento, os advisories oficiais são o GHSA-w8hw-w9fx-j79w para a CVE-2026-109618 e o GHSA-6wmf-4rv5-p3rp para a CVE-2026-109619.
Por que desligar o upload_progress não é o suficiente
Essa é a pegadinha que vai derrubar muita gente que achar que mitigou. Desligar session.upload_progress.enabled é tentador como medida paliativa, mas é incompleto.
O aviso do PHP para a CVE-2026-109619 diz que definir session.upload_progress.enabled=0 e reiniciar todos os workers bloqueia o caminho de sessão confirmado, mas não outras variantes. E como vimos na Chain B, é justamente a metade de corrupção de memória que sobrevive ao upload_progress.enabled = Off.
Traduzindo para a prática: a flag fecha a porta lógica (109619, Chains A e C), mas não a porta de memória (109618, Chain B). A única correção real é atualizar a versão do PHP.
O que fazer, na ordem certa
Minha recomendação, com prioridade:
-
Atualize já. Para 8.5.12, 8.4.27, 8.3.36 ou 8.2.35, conforme sua branch. É a única correção que fecha as duas CVEs.
-
Inventarie seus runtimes PHP. Imagens de container, workloads de cloud, instâncias esquecidas fora do processo padrão. Você não corrige o que não enxerga, e aqui o ativo-fantasma é a regra, não a exceção, porque PHP se esconde atrás de WordPress, Magento, Drupal e afins.
-
Isole o que não dá pra corrigir agora. Rodando branch EOL (8.1 ou anterior) sem patch? Restrinja ou remova acesso público da instância até migrar.
-
Mitigação paliativa, com ressalva.
session.upload_progress.enabled=0mais reinício de todos os workers reduz superfície da 109619, mas não cobre a 109618. Trate como ganho de tempo, não como solução. -
Cuidado redobrado com PoC de terceiros. Esse é o momento de ouro para engenharia social de time de segurança.
Sobre esse último ponto, um alerta que vale repetir. Como a própria ox.security observou, no vácuo de informação entre o vazamento e o patch oficial, os feeds se encheram de mitigações meio-testadas, correções alucinadas por IA e scripts de detecção duvidosos. Atores de ameaça rotineiramente exploram o pânico inicial publicando PoCs falsos, scripts de detecção com backdoor ou patches não testados mirando diretamente em enganar engenheiros de segurança para que comprometam suas próprias redes. Não clone nem rode PoC não verificado. Confie em advisory validado pelo fornecedor e em commits do repositório oficial.
Fechando
PHP2Shell é um caso de estudo de várias coisas que a gente discute em segurança ofensiva: um recurso de conveniência ligado por padrão por mais de uma década, dois defeitos independentes se reforçando, e a diferença crucial entre uma mitigação que parece fechar o buraco e uma que fecha. A assimetria entre as três cadeias (uma que precisa de LFI na app, uma que depende de config de serialize_handler, e uma que não precisa de nada além do mecanismo de sessão) é exatamente o tipo de nuance que separa uma avaliação de exposição correta de uma falsa sensação de segurança.
E tem o pano de fundo que não dá pra ignorar: um bug que era "difícil demais de explorar na prática" deixou de estar protegido pela dificuldade no momento em que modelos de IA baixaram o custo de montar a cadeia. Esse é o novo normal, e a janela entre disclosure e exploração em massa só encurta.
Atualize o PHP. Hoje.
Referências
- PHP Security Advisory, GHSA-w8hw-w9fx-j79w (CVE-2026-109618, "RCE via OOM corruption")
- PHP Security Advisory, GHSA-6wmf-4rv5-p3rp (CVE-2026-109619, "RCE via stale global state")
- OX Security, "CVE-2026-109618 & CVE-2026-109619: Two Vulnerabilities Allow Unauthenticated RCE in PHP" (deep dive técnico, Nir Zadok e Moshe Siman Tov Bustan): https://www.ox.security/blog/cve-2026-109618-cve-2026-109619-two-vulnerabilities-allow-unauthenticated-rce-in-php/
- Huntress, "Rapid Response: php2shell, Leaked Critical PHP Vulnerabilities (CVE-2026-109618 & CVE-2026-109619)": https://www.huntress.com/blog/php2shell-leaked-critical-php-vulnerabilities
- Help Net Security, "PHP2Shell: Critical PHP vulnerabilities patched, update now!": https://www.helpnetsecurity.com/2026/10/21/php2shell-php-vulnerabilities-patched-cve-2026-109618-cve-2026-109619/
- SecurityWeek, "PHP2Shell: Mass Exploitation Looms for Leaked Critical PHP Flaws": https://www.securityweek.com/php2shell-mass-exploitation-looms-for-leaked-critical-php-flaws/
- Brandefense, "PHP2Shell: PHP RCE CVE-2026-109618, 109619": https://brandefense.io/blog/php2shell-php-rce-cve-2026-109618-109619/
- watchTowr, "PHP RCE FAQ: PHP2Shell CVE-2026-109618 & CVE-2026-109619": https://watchtowr.com/intelligence/php-cve-2026-109618-faq/



