phpupscan: verificador de exposicao as CVEs de memoria do PHP

Ferramenta em portugues para avaliar hosts frente as CVE-2026-109618
(RCE via corrupcao por OOM) e CVE-2026-109619 (RCE via estado global
obsoleto), publicadas pelo projeto PHP em 21/10/2026.

Conteudo:
- varredura: fingerprint de versao e SAPI, exposicao de phpinfo e
  veredito por alvo, com saida texto/JSON
- sondas ativas controladas: "leve" reproduz o OOM pre-RINIT do bundle
  oficial de reproducao; "forte" usa chunked para verificar o patch por
  comportamento (o parser corrigido corta a leitura em post_max_size).
  Cada sonda vem com um corpo de controle para atribuir o OOM ao
  caminho de progresso de upload de sessao
- config: auditoria de php.ini, .user.ini, .htaccess e pool FPM contra
  as recomendacoes do aviso oficial do PHP
- info: referencia rapida das CVEs, versoes corrigidas e mitigacoes
- laboratorio: sobe containers (8.2.34 vulneravel, 8.2.35 corrigido,
  controle com upload_progress desligado e par nginx+php-fpm) e valida o
  verificador com recibos PASS/FAIL

As versoes corrigidas vem do advisory oficial (8.2.35, 8.3.36, 8.4.27,
8.5.12) e nao do post de referencia, que lista as versoes anteriores.

A ferramenta mede exposicao: nao inclui a forja de ponteiros nem a
chamada a system() do bundle de reproducao.

Testes: 46 testes unitarios (unittest, sem dependencias).
This commit is contained in:
lgcosta committed 2026-10-22 19:44:46 -03:00
commit 6370227ad1
19 files changed
+3644

No files matched your search

+29
View File
@@ -0,0 +1,29 @@
# Byte-compiled / cache
__pycache__/
*.py[cod]
*$py.class
# Distribuicao / build
build/
dist/
*.egg-info/
.eggs/
# Ambientes
.venv/
venv/
# Testes e cobertura
.pytest_cache/
.coverage
htmlcov/
# Relatorios gerados
*.json
!phpupscan/**/*.json
# Editores e SO
.idea/
.vscode/
*.swp
.DS_Store
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Luiz Gustavo Costa
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+304
View File
@@ -0,0 +1,304 @@
# README - phpupscan
Verificador de exposicao as duas CVEs de memoria do PHP publicadas em
21/10/2026 pelo projeto PHP:
- **CVE-2026-109618** (GHSA-w8hw-w9fx-j79w) - RCE via corrupcao por OOM.
- **CVE-2026-109619** (GHSA-6wmf-4rv5-p3rp) - RCE via estado global obsoleto.
Ambas vivem no mesmo lugar: o tratamento de multipart com
`session.upload_progress`, que roda **antes** do RINIT, ou seja, antes de
qualquer codigo da aplicacao. Um pedido multipart anonimo consegue estourar o
`memory_limit` do PHP nesse ponto, e o estado interno corrompido pode ser
reaproveitado na requisicao seguinte servida pelo mesmo worker.
A ferramenta foi escrita a partir do material de referencia
(https://jackalsyn.com/posts/php-up-2026.html), do bundle oficial de
reproducao (`php2shell-reup.zip`, sha256
`74adf2c4eb82776553837d185bae165494288747b43437fd452021a6ff6a93f0`), do
codigo-fonte do php-src (tags `php-8.2.34` vs `php-8.2.35`, onde esta o patch
`01a40e89fb "Avoid state corruption via bailout"`) e do aviso oficial do PHP em
https://www.php.net/index.php#2026-10-21-5.
> Use apenas em hosts que voce administra ou tem autorizacao expressa para
> testar. A varredura ativa pode deixar o worker do servidor em estado
> invalido e derrubar um worker.
## Instalacao
Sem dependencias externas, so Python 3.9+:
```bash
cd /home/lgcosta/lab/tools
python3 -m phpupscan info
```
Para instalar como comando global:
```bash
pip install -e .
```
## Uso
### 1. Reconhecimento (nao invasivo)
```bash
python3 -m phpupscan varredura https://meu-site/index.php
```
O scanner identifica a versao do PHP, a SAPI em uso, a exposicao de `phpinfo`
e classifica a versao contra as series corrigidas. Nenhum pedido grande e
enviado neste modo.
### 2. Varios alvos
```bash
python3 -m phpupscan varredura -f hosts.txt --threads 8 --json relatorio.json
```
`hosts.txt` aceita uma URL por linha, com `#` para comentarios.
### 3. Modo ativo
```bash
python3 -m phpupscan varredura --sonda leve https://meu-site/index.php
python3 -m phpupscan varredura --sonda forte https://meu-site/index.php
python3 -m phpupscan varredura --sonda ambas https://meu-site/index.php
```
**`leve`** reproduz o "ARM" do bundle oficial: um `POST multipart/form-data`
anonimo com um nome de arquivo grande (1,3 MB por padrao, ajustado a partir
do `memory_limit` quando o `phpinfo` e publico). No PHP vulneravel isso faz o
`session.upload_progress` serializar a arvore de upload durante o startup da
requisicao e estourar o `memory_limit` ainda no pre-RINIT.
**`forte`** usa `Transfer-Encoding: chunked`, sem `Content-Length`. O PHP usa
o `Content-Length` para checar `post_max_size` no inicio do parsing; sem ele,
um corpo maior que `post_max_size` continua sendo entregue (vetor descrito no
advisory oficial). Isso produz uma **verificacao comportamental do patch**:
nas versoes corrigidas o parser interrompe a leitura em `post_max_size` e avisa
`Actual POST length does not match Content-Length`; nas antigas ele continua
lendo e estoura a memoria. Vale mais que o banner de versao, porque uma
distribuicao pode ter backportado o patch sem trocar o numero da versao.
**`ambas`** roda as duas e usa o resultado mais forte.
Em qualquer modo, cada sonda vem acompanhada de um **corpo de controle**
identico, sem o campo `PHP_SESSION_UPLOAD_PROGRESS`:
| Corpo | Resultado esperado em alvo vulneravel |
| --- | --- |
| Com `PHP_SESSION_UPLOAD_PROGRESS` | OOM no startup (500 / "Allowed memory size ... exhausted") |
| Sem o campo (controle) | Resposta normal (200) |
Se o controle tambem estourar a memoria, o OOM nao e atribuivel ao recurso de
progresso de upload e o scanner diz isso no relatorio, em vez de afirmar
exposicao.
### 4. Auditoria de configuracao local
```bash
python3 -m phpupscan config /etc/php/8.2/apache2/php.ini
python3 -m phpupscan config /etc/php/8.2/fpm/pool.d/www.conf
python3 -m phpupscan config /var/www/meu-site
```
O modo `config` le `php.ini`, `.user.ini`, `.htaccess` e pools do FPM, e
compara com o que o aviso oficial do PHP exige. Ele detecta em particular
configuracoes que nao funcionam:
- `session.upload_progress.enabled` ligado (ou ausente, que significa ligado
por padrao);
- `session.upload_progress.enabled` em `.user.ini`, local que o aviso oficial
desconsidera por nao ser aplicado antes do parsing da requisicao;
- `memory_limit` finito, que e exatamente o que o atacante precisa controla;
- `upload_max_filesize` acima de `post_max_size`;
- `fatal_error_backtraces` ligado no PHP 8.5.
### 5. Referencias rapidas
```bash
python3 -m phpupscan info
```
## Como o veredito e construido
| Achado | Peso |
| --- | --- |
| Versao fora das series corrigidas | critico (8.2/8.3/8.4/8.5), alto (8.1 e anteriores, sem correcao oficial) |
| SAPI que sobrevive ao OOM (mod_php, prefork, `php -S`) | alto, mas so pesa se a versao ainda for vulneravel |
| SAPI que morre no OOM (php-fpm, php-cgi, LiteSpeed) | medio, tambem so se a versao for vulneravel |
| `phpinfo` publico | medio |
| `session.upload_progress.enabled` ligado (via phpinfo) | alto |
| `memory_limit` finito (via phpinfo) | alto |
| Sonda ativa reproduzindo OOM no startup | critico, ou medio se a versao ja estiver corrigida |
| Comportamento de patch confirmado por sonda | ok (e o unico achado que **reduz** o nivel) |
Duas observacoes importantes sobre a sonda ativa:
1. **A sonda leve nao distingue versao corrigida de vulneravel.** O patch nao
impede o estouro de memoria - ele impede que o estado corrompido seja
reaproveitado. Uma serie corrigida ainda pode estourar o `memory_limit` com
o mesmo corpo. Por isso o veredito de "corrigido" vem da checagem de
versao, e a sonda aparece como alerta de configuracao, nao como falha.
2. **A sonda forte distingue, por comportamento.** Quando o alvo e uma serie
corrigida, o parser corta a leitura em `post_max_size` e o veredito passa a
"OK" mesmo que o banner de versao ainda diga 8.2.34. Nesse caso o relatorio
avisa que a versao e anterior ao patch oficial e provavelmente um backport
da distribuicao.
O que a ferramenta **nao** faz: o bundle de reproducao acrescenta, depois do
ARM, a forja de ponteiros, o spray de reclaim e a chamada a `system()`. Nada
disso esta aqui. Esta ferramenta mede exposicao; ela nao executa codigo no
servidor.
## Laboratorio de validacao
```bash
python3 -m phpupscan laboratorio
```
O comando sobe sete containers (com `podman` ou `docker`, se houver):
| Container | Imagem | Papel |
| --- | --- | --- |
| `phpupscan-lab-vuln` | `php:8.2.34-apache` | versao vulneravel, `memory_limit=8M` |
| `phpupscan-lab-fix` | `php:8.2.35-apache` | versao com o patch, `memory_limit=8M` |
| `phpupscan-lab-ctrl` | `php:8.2.34-apache` | mesmo alvo, mas com `session.upload_progress.enabled=Off` |
| `phpupscan-lab-vuln-def` | `php:8.2.34-apache` | versao vulneravel com configuracao padrao (128M/8M) |
| `phpupscan-lab-fix-def` | `php:8.2.35-apache` | versao corrigida com configuracao padrao (128M/8M) |
| `phpupscan-lab-fpm` | `php:8.2.34-fpm-alpine` | backend php-fpm, atras de nginx |
| `phpupscan-lab-nginx` | `nginx:alpine` | proxy para o php-fpm |
E emite recibos PASS/FAIL para: deteccao de versao, classificacao contra o
advisory, reproducao do OOM no alvo vulneravel, ausencia de OOM no controle com
o recurso desligado, discriminacao comportamental do patch (sonda forte), morte
do worker no php-fpm e o veredito final de cada alvo. Os containers, a rede e o
diretorio temporario sao removidos ao final (use `--manter` para inspecionar).
Testes unitarios:
```bash
python3 -m unittest discover -s phpupscan/testes -t .
```
## O que o laboratorio mediu (e o que mede na sua maquina)
Medido com `php:8.2.34-apache` (vulneravel) contra `php:8.2.35-apache`
(corrigido):
**Sonda leve, `memory_limit=8M`, `post_max_size=32M`**
- O corpo com nome de arquivo de ~1,3 MB produz `Allowed memory size of
8388608 bytes exhausted ... in Unknown on line 0` **nas duas versoes**. Isso
confirma que o patch nao muda o OOM.
- O mesmo corpo **sem** `PHP_SESSION_UPLOAD_PROGRESS` nao estoura a memoria
**em nenhuma das duas**. Essa e a atribuicao correta do OOM ao recurso de
progresso de upload de sessao.
- Com `session.upload_progress.enabled=Off`, o corpo com o campo tambem nao
estourou a memoria. O `Off` fecha o caminho.
- Com nomes maiores (~1,8 MB), a versao vulneravel passa a emitir
`PHP Request Startup: Failed to decode session object. Session has been
destroyed` - o `cancel_decode` do `php_session_decode`, que e o caminho do
`zend_catch` que deixa o contexto de serializacao orfao. A versao corrigida
nao emite essa linha.
Ou seja: a diferenca observavel entre as versoes esta no **caminho de
decodificacao da sessao**, e nao no estouro de memoria em si.
**Sonda forte, configuracao padrao (128M/8M), corpo em chunked acima de
`post_max_size`**
| Alvo | Resultado |
| --- | --- |
| 8.2.34 (vulneravel) | OOM no startup: `Allowed memory size of 134217728 bytes exhausted (tried to allocate 30000032 bytes) in Unknown on line 0` |
| 8.2.35 (corrigido) | `Warning: Actual POST length does not match Content-Length, and exceeds 8388608 bytes`, resposta 200, sem OOM |
Esse e o teste que separa as duas series sem depender do banner de versao.
**Sonda forte contra php-fpm atras de nginx**
Com `php-fpm`, o filho morre no startup (`child N exited with code 70` no log
do FPM) e o nginx devolve a requisicao sem conteudo - corpo vazio com status
200, ou 502, dependendo do proxy. O scanner compara o corpo da sonda com o do
controle para identificar essa morte, e classifica o alvo como `ALTO` (e nao
`CRITICO`): o OOM e alcancavel, mas o caminho de reuso do estado corrompido
fica fechado porque o worker nao sobrevive.
## Matriz de SAPI (por que a SAPI importa)
| SAPI | O worker sobrevive ao OOM de startup | Consequencia |
| --- | --- | --- |
| Apache prefork + mod_php | sim | o estado corrompido pode ser reaproveitado na requisicao seguinte |
| `php -S` (servidor embutido) | sim | idem |
| FrankenPHP | provavel | o aviso oficial trata como equivalente ao mod_php |
| php-fpm | nao | o filho sai no startup; o caminho de reuso e cortado, mas sobra superficie de DoS/OOM |
| php-cgi | nao | idem |
| LiteSpeed (LSAPI) | nao | idem |
O scanner detecta essa diferenca em tempo de execucao, sem depender do
banner de SAPI:
- **mod_php**: a requisicao volta com o erro de memoria no corpo e o worker
continua atendendo a requisicao seguinte na mesma conexao.
- **php-fpm atras de nginx**: o filho do FPM morre (`child N exited with code
70` no log) e o nginx devolve a requisicao **sem conteudo** (corpo vazio com
status 200, ou 502 dependendo da configuracao do proxy). O scanner compara o
corpo da sonda com o do controle e reporta a morte do worker.
Por isso, um alvo com php-fpm classificado como `ALTO` (e nao `CRITICO`): o
OOM e alcancavel, mas o caminho de reuso do estado corrompido fica fechado -
o impacto observavel e de disponibilidade, com o worker recriado.
## Mitigacoes (do aviso oficial do PHP, 21/10/2026)
1. **Atualizar o PHP.** 8.2.35, 8.3.36, 8.4.27, 8.5.12 (e 8.6.0 a partir do
RC4). Esta e a unica correcao completa. 8.1 e anteriores nao recebem
atualizacoes do projeto, apenas backports minimos de codigo-fonte.
2. `memory_limit = -1` aplicado administrativamente (`php_admin_value` no pool
FPM ou no `httpd.conf`), de forma que a aplicacao nao consiga restaurar um
limite finito.
3. `post_max_size` conservador: 8M sem upload, 32M com upload, sempre com
`upload_max_filesize` abaixo. E limite equivalente no proxy: tamanho do
corpo decodado, quantidade de campos, de partes multipart, de cabecalhos
por parte e de uploads.
4. `session.upload_progress.enabled = 0` no `php.ini` ou no pool FPM (antes do
parsing da requisicao) e reinicio de todos os workers. Nao confiar em
`ini_set()` nem em `.user.ini` do FPM.
5. No PHP 8.5, `fatal_error_backtraces = 0` e reinicio dos workers.
6. Nao usar `RLIMIT_AS` nem `RLIMIT_DATA` como limite de container: eles
fazem o `mmap()` falhar e disparam o caminho vulneravel do `longjmp()`.
## Estrutura
```
phpupscan/
cli.py - interface de linha de comando (PT-BR)
detectar.py - fingerprint: versao, SAPI, phpinfo
versoes.py - series corrigidas e classificacao
sondas.py - sondas multipart (leve e forte) e corpo de controle
config_audit.py - auditoria de php.ini/.user.ini/.htaccess/pool FPM
relatorio.py - veredito, nivel de risco, saida texto/JSON
httpclient.py - cliente HTTP minimo (stdlib) com keep-alive e chunked
lab.py - laboratorio com containers
testes/ - testes unitarios (unittest, sem dependencias)
```
## Limites conhecidos
- A SAPI e inferida de cabecalhos e phpinfo. Um `nginx` na frente de
`mod_php` (configuracao incomum) pode ser classificado como `php-fpm`; se
houver phpinfo, o campo "Server API" corrige a classificacao.
- A versao pode estar escondida (`expose_php=Off` e sem phpinfo). Nesse caso
o veredito fica `DESCONHECIDO` em vez de chutar.
- A sonda assume um endpoint que processa multipart. Se o endpoint escolhido
nao for um script PHP, a sonda nao reproduz o OOM e o relatorio diz isso.
- A sonda forte so discrimina patch de vulnerabilidade quando o `memory_limit`
e bem maior que o `post_max_size` (configuracao padrao: 128M contra 8M). Com
limites proximos, as duas versoes estouram a memoria antes de o parser
corrigido conseguir cortar a leitura, e o resultado fica inconclusivo.
- O flood de cabecalhos multipart descrito no aviso oficial (quantidade de
cabecalhos sem limite por parte) e uma segunda via de OOM, independente do
`session.upload_progress`. O scanner nao a usa: ela e mais pesada e afeta
versoes corrigidas tambem, o que geraria falso positivo.
+11
View File
@@ -0,0 +1,11 @@
"""phpupscan - verificador de exposicao as CVEs de memoria do PHP.
CVE-2026-109618 (RCE via corrupcao por OOM) e CVE-2026-109619 (RCE via estado
global obsoleto), ambas no tratamento de multipart com
`session.upload_progress` do PHP. Ver o aviso oficial do projeto PHP de
21/10/2026 e o post de referencia em jackalsyn.com/posts/php-up-2026.html.
"""
__version__ = "1.0.0"
__all__ = ["__version__"]
+5
View File
@@ -0,0 +1,5 @@
from .cli import main
import sys
if __name__ == "__main__":
sys.exit(main())
+377
View File
@@ -0,0 +1,377 @@
"""Interface de linha de comando do phpupscan (em portugues)."""
from __future__ import annotations
import argparse
import os
import re
import sys
from concurrent.futures import ThreadPoolExecutor
from typing import List, Optional
from urllib.parse import urlparse
from . import __version__, versoes
from .config_audit import auditar as auditar_config
from .detectar import normalizar_url, reconhecer
from .relatorio import (
NIVEL_CRITICO,
NIVEL_OK,
AZUL,
CINZA,
NEGRITO,
VERDE,
VERMELHO,
ResultadoAlvo,
_avaliar,
imprimir_alvo,
imprimir_tabela,
para_json,
)
from .sondas import TAMANHO_FORTE, combinar, sondar, tamanho_sugerido
AVISO_AUTORIZACAO = (
"Use apenas em hosts que voce administra ou tem autorizacao expressa para testar. "
"A varredura ativa pode deixar o worker do servidor em estado invalido."
)
def _cabecalho() -> None:
print(NEGRITO("phpupscan") + CINZA(f" v{__version__}") + " - exposicao as CVEs de memoria do PHP")
print(CINZA("CVE-2026-109618 (OOM) e CVE-2026-109619 (estado obsoleto) - session.upload_progress"))
print()
# --------------------------------------------------------------------------
# comandos
# --------------------------------------------------------------------------
def _bytes_de_opcao(perfil, chave: str) -> Optional[int]:
"""Converte um valor de opcao do phpinfo (ex.: '128M') em bytes."""
valor = perfil.phpinfo.get(chave, "").strip()
if not valor:
return None
m = re.match(r"^(\d+)\s*([KMG]?)B?$", valor.upper())
if not m:
return None
fator = {"": 1, "K": 1024, "M": 1024**2, "G": 1024**3}[m.group(2)]
return int(m.group(1)) * fator
def cmd_varredura(args: argparse.Namespace) -> int:
alvos: List[str] = list(args.alvo or [])
if args.arquivo:
with open(args.arquivo, "r", encoding="utf-8") as fh:
for linha in fh:
linha = linha.strip()
if linha and not linha.startswith("#"):
alvos.append(linha)
if not alvos:
print(VERMELHO("Informe ao menos um alvo (URL) ou use --arquivo lista.txt."))
return 2
_cabecalho()
print(CINZA(AVISO_AUTORIZACAO))
if args.sonda != "nenhuma":
print(
VERMELHO(
"MODO ATIVO: sera enviada uma requisicao multipart grande que pode estourar o "
"memory_limit do servidor alvo e derrubar um worker."
)
)
print()
normalizados = []
for alvo in alvos:
try:
normalizados.append(normalizar_url(alvo))
except ValueError as exc:
print(VERMELHO(f"Alvo invalido: {alvo} ({exc})"))
def processar(url: str) -> ResultadoAlvo:
perfil = reconhecer(
url, timeout=args.timeout, insecure=args.insecure, buscar_phpinfo=not args.sem_phpinfo
)
resultado_sonda = None
if args.sonda != "nenhuma" and perfil.acessivel:
caminho = args.caminho or (urlparse(url).path or "/")
alvo_php = caminho if caminho.startswith("/") else "/" + caminho
url_sonda = urlparse(url)._replace(path=alvo_php).geturl()
if args.tamanho_nome:
tamanho = args.tamanho_nome
else:
limite = _bytes_de_opcao(perfil, "memory_limit")
post_max = _bytes_de_opcao(perfil, "post_max_size")
tamanho = tamanho_sugerido(limite, post_max)
resultado_leve = None
resultado_forte = None
if args.sonda in ("leve", "ambas"):
resultado_leve = sondar(
url_sonda,
timeout=args.timeout,
tamanho_nome=tamanho,
insecure=args.insecure,
fazer_controle=not args.sem_controle,
)
if args.sonda in ("forte", "ambas"):
if args.tamanho_forte:
tamanho_forte = args.tamanho_forte
else:
limite = _bytes_de_opcao(perfil, "memory_limit")
post_max = _bytes_de_opcao(perfil, "post_max_size")
tamanho_forte = tamanho_sugerido(limite, post_max, forte=True)
resultado_forte = sondar(
url_sonda,
timeout=max(args.timeout, 60.0),
tamanho_nome=tamanho_forte,
insecure=args.insecure,
fazer_controle=not args.sem_controle,
forte=True,
)
resultado_sonda = combinar(resultado_leve, resultado_forte)
return _avaliar(perfil, resultado_sonda)
if args.threads > 1:
with ThreadPoolExecutor(max_workers=args.threads) as pool:
resultados = list(pool.map(processar, normalizados))
else:
resultados = [processar(url) for url in normalizados]
for resultado in resultados:
imprimir_alvo(resultado, verbose=args.verbose)
imprimir_tabela(resultados)
criticos = [r for r in resultados if r.nivel == NIVEL_CRITICO]
print()
if criticos:
print(VERMELHO(f"{len(criticos)} alvo(s) com exposicao confirmada."))
elif any(r.nivel == NIVEL_OK for r in resultados) and len(resultados) == 1:
print(VERDE("Nenhuma exposicao identificada."))
else:
print(CINZA("Nenhum alvo em nivel CRITICO."))
if args.json:
with open(args.json, "w", encoding="utf-8") as fh:
fh.write(para_json(resultados))
print(CINZA(f"Relatorio JSON gravado em {args.json}"))
return 1 if criticos else 0
def cmd_config(args: argparse.Namespace) -> int:
"""Audita arquivos de configuracao locais do PHP."""
_cabecalho()
print(NEGRITO("Auditoria de configuracao local"))
print()
arquivos: List[str] = []
for caminho in args.arquivo:
if os.path.isdir(caminho):
for raiz, _dirs, nomes in os.walk(caminho):
for nome in nomes:
if nome in ("php.ini", ".user.ini", ".htaccess") or nome.endswith(".conf"):
arquivos.append(os.path.join(raiz, nome))
else:
arquivos.append(caminho)
if not arquivos:
print(VERMELHO("Nenhum arquivo de configuracao informado."))
return 2
try:
relatorio = auditar_config(arquivos)
except OSError as exc:
print(VERMELHO(str(exc)))
return 2
print(CINZA("Arquivos analisados:"))
for arquivo in relatorio.arquivos:
print(f" - {arquivo}")
print()
if relatorio.achados:
print(NEGRITO("Opcoes encontradas:"))
for chave, achados in sorted(relatorio.achados.items()):
print(f" {AZUL(chave)}")
for achado in achados:
escopo = f" [{achado.escopo}]" if achado.escopo else ""
print(
f" {CINZA(achado.origem)}{escopo} linha {achado.linha}: "
f"{NEGRITO(achado.valor)}"
)
print()
if relatorio.problemas:
print(NEGRITO("Problemas:"))
for problema in relatorio.problemas:
print(f" {VERMELHO('x')} {problema}")
print()
if relatorio.avisos:
print(NEGRITO("Avisos:"))
for aviso in relatorio.avisos:
print(f" {CINZA('!')} {aviso}")
print()
if relatorio.recomendacoes:
print(NEGRITO("Recomendacoes:"))
for i, rec in enumerate(relatorio.recomendacoes, 1):
print(f" {VERDE(str(i) + '.')} {rec}")
if args.json:
import json
with open(args.json, "w", encoding="utf-8") as fh:
json.dump(
{
"arquivos": relatorio.arquivos,
"achados": {
chave: [
{
"valor": a.valor,
"origem": a.origem,
"linha": a.linha,
"escopo": a.escopo,
}
for a in achados
]
for chave, achados in relatorio.achados.items()
},
"problemas": relatorio.problemas,
"recomendacoes": relatorio.recomendacoes,
"avisos": relatorio.avisos,
},
fh,
indent=2,
ensure_ascii=False,
)
print(CINZA(f"\nRelatorio JSON gravado em {args.json}"))
return 0
def cmd_info(args: argparse.Namespace) -> int:
"""Mostra o resumo do advisory e das versoes corrigidas."""
_cabecalho()
print(NEGRITO("O que sao estas CVEs"))
print(
" CVE-2026-109618: o PHP usa longjmp() para abortar a requisicao quando o limite de\n"
" memoria estoura. Em alguns pontos esse abort acontece enquanto a memoria da\n"
" requisicao ainda esta parcialmente inicializada, e o encerramento da requisicao\n"
" acaba tratando estado invalido como valido. O resultado pode ser execucao remota\n"
" de codigo, sem autenticacao e antes de qualquer codigo da aplicacao rodar."
)
print()
print(
" CVE-2026-109619: estado interno pertencente a uma requisicao sobrevive depois que a\n"
" memoria de backing foi liberada. Uma requisicao seguinte servida pelo mesmo worker\n"
" pode usar esse estado obsoleto antes da inicializacao normal limpa-lo."
)
print()
print(NEGRITO("Versoes corrigidas"))
for serie, minima in versoes.VERSAO_CORRIGIDA.items():
texto = ".".join(str(p) for p in minima)
print(f" {serie[0]}.{serie[1]}.x -> {texto} (a serie esta vulneravel ate a versao anterior)")
print(" 8.6.x -> 8.6.0 (a partir do RC4)")
print(" 8.1 e anteriores: fora de suporte; o projeto publicou backports minimos de codigo.")
print()
print(NEGRITO("Fatores de explorabilidade"))
print(" - session.upload_progress.enabled = On fecha/abre o caminho de sessao (CVE-2026-109619).")
print(" - memory_limit finito e programavel e a base do OOM controlado (CVE-2026-109618).")
print(" - Apache com mod_php e FrankenPHP: o worker sobrevive ao erro de startup, e o pior caso.")
print(" - php-fpm, php-cgi e LiteSpeed: o filho morre no startup, o que corta o caminho de reuso.")
print()
print(NEGRITO("Mitigacoes (do aviso oficial do PHP, 21/10/2026)"))
print(" 1. Atualizar o PHP. E a unica correcao completa.")
print(" 2. memory_limit = -1 aplicado administrativamente, sem possibilidade de a aplicacao sobrescrever.")
print(" 3. post_max_size conservador (8M sem upload, 32M com upload) e limites no proxy.")
print(" 4. session.upload_progress.enabled = 0 no php.ini/pool, e reiniciar os workers.")
print(" 5. No PHP 8.5, fatal_error_backtraces = 0 e reiniciar os workers.")
return 0
def cmd_lab(args: argparse.Namespace) -> int:
"""Sobe containers locais e valida o verificador contra versoes vuln e corrigidas."""
from .lab import executar_lab
return executar_lab(args)
# --------------------------------------------------------------------------
def construir_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="phpupscan",
description=(
"Verificador de exposicao as CVEs de memoria do PHP "
"(CVE-2026-109618 e CVE-2026-109619), em portugues."
),
epilog=AVISO_AUTORIZACAO,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--version", action="version", version=f"phpupscan {__version__}")
sub = parser.add_subparsers(dest="comando", required=True)
p_var = sub.add_parser("varredura", help="verifica hosts (URLs) e emite veredito")
p_var.add_argument("alvo", nargs="*", help="URLs a verificar (ex.: https://exemplo.com/index.php)")
p_var.add_argument("-f", "--arquivo", help="arquivo com uma URL por linha")
p_var.add_argument(
"--sonda",
choices=["nenhuma", "leve", "forte", "ambas"],
default="nenhuma",
help=(
"modo ativo: 'leve' reproduz o OOM no startup com um multipart grande; "
"'forte' usa chunked para verificar o comportamento do patch; "
"'ambas' roda as duas"
),
)
p_var.add_argument("--caminho", help="caminho do endpoint PHP a sondar (padrao: o caminho da URL)")
p_var.add_argument(
"--tamanho-nome",
type=int,
default=0,
help="tamanho do nome de arquivo da sonda leve (padrao: calculado a partir do memory_limit)",
)
p_var.add_argument(
"--tamanho-forte",
type=int,
default=TAMANHO_FORTE,
help=f"tamanho do nome de arquivo da sonda forte (padrao: {TAMANHO_FORTE})",
)
p_var.add_argument("--sem-controle", action="store_true", help="nao envia o corpo de controle")
p_var.add_argument("--sem-phpinfo", action="store_true", help="nao procura paginas phpinfo")
p_var.add_argument("--timeout", type=float, default=20.0, help="tempo limite em segundos")
p_var.add_argument("--threads", type=int, default=1, help="alvos em paralelo")
p_var.add_argument("--insecure", action="store_true", help="nao validar certificado TLS")
p_var.add_argument("-v", "--verbose", action="store_true", help="mostrar a fonte de cada achado")
p_var.add_argument("--json", help="gravar o relatorio em JSON neste caminho")
p_var.set_defaults(func=cmd_varredura)
p_conf = sub.add_parser("config", help="audita php.ini/.user.ini/.htaccess/pool FPM locais")
p_conf.add_argument("arquivo", nargs="+", help="arquivos ou diretorios a auditar")
p_conf.add_argument("--json", help="gravar o relatorio em JSON neste caminho")
p_conf.set_defaults(func=cmd_config)
p_info = sub.add_parser("info", help="explica as CVEs, versoes corrigidas e mitigacoes")
p_info.set_defaults(func=cmd_info)
p_lab = sub.add_parser("laboratorio", help="valida o verificador com containers locais")
p_lab.add_argument("--motor", choices=["podman", "docker"], default=None, help="runtime de containers")
p_lab.add_argument("--porta", type=int, default=18081, help="porta base dos containers")
p_lab.add_argument("--manter", action="store_true", help="nao remover os containers ao final")
p_lab.set_defaults(func=cmd_lab)
return parser
def main(argv: Optional[List[str]] = None) -> int:
parser = construir_parser()
args = parser.parse_args(argv)
try:
return int(args.func(args))
except KeyboardInterrupt:
print("\nInterrompido.")
return 130
if __name__ == "__main__":
sys.exit(main())
+318
View File
@@ -0,0 +1,318 @@
"""Auditoria de configuracao local (php.ini, .user.ini, .htaccess, pool FPM).
Analisa os arquivos de configuracao que o usuario administra e compara com as
recomendacoes oficiais do aviso de seguranca do PHP (21/10/2026) para
CVE-2026-109618 e CVE-2026-109619.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Tuple
#: Opcoes que o aviso oficial do PHP manda ajustar.
CHAVES_RELEVANTES = [
"session.upload_progress.enabled",
"session.upload_progress.cleanup",
"session.upload_progress.name",
"memory_limit",
"post_max_size",
"upload_max_filesize",
"max_file_uploads",
"max_input_vars",
"max_input_nesting_level",
"fatal_error_backtraces",
]
INICIAR_RE = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_.\-]*)\s*=\s*(.*?)\s*$")
SECAO_RE = re.compile(r"^\s*\[([^\]]+)\]\s*$")
COMENTARIO_RE = re.compile(r"^\s*[;#]")
HTACCESS_RE = re.compile(
r"^\s*(php_value|php_admin_value|php_flag|php_admin_flag)\s+([A-Za-z0-9_.\-]+)\s+(.+?)\s*$",
re.IGNORECASE,
)
#: Pool FPM: php_admin_value[memory_limit] = 8M
POOL_RE = re.compile(
r"^\s*php_(admin_)?value\s*\[\s*([A-Za-z0-9_.\-]+)\s*\]\s*=\s*(.+?)\s*$",
re.IGNORECASE,
)
#: Ordem de precedencia (o ultimo aplicado vence): php.ini, depois o
#: per-diretorio (.htaccess e .user.ini), e por fim o que o administrador
#: impõe no pool FPM ou no vhost.
PRECEDENCIA = {
"php.ini": 1,
".htaccess": 2,
".user.ini": 3,
"pool FPM": 4,
}
#: Valores de booleanos do PHP.
VERDADEIRO = {"1", "on", "true", "yes"}
FALSO = {"0", "off", "false", "no", "none"}
@dataclass
class Achado:
"""Uma opcao de configuracao encontrada em um arquivo."""
chave: str
valor: str
origem: str
linha: int = 0
escopo: str = ""
def como_booleano(self) -> Optional[bool]:
v = self.valor.strip().lower()
if v in VERDADEIRO:
return True
if v in FALSO:
return False
return None
def como_bytes(self) -> Optional[int]:
"""Converte valores tipo '128M' em bytes (-1 = ilimitado)."""
v = self.valor.strip().upper()
if v == "-1":
return -1
m = re.match(r"^(\d+)\s*([KMG]?)B?$", v)
if not m:
return None
n = int(m.group(1))
fator = {"": 1, "K": 1024, "M": 1024**2, "G": 1024**3}[m.group(2)]
return n * fator
@dataclass
class RelatorioConfig:
"""Resultado da auditoria de um conjunto de arquivos."""
arquivos: List[str] = field(default_factory=list)
achados: Dict[str, List[Achado]] = field(default_factory=dict)
problemas: List[str] = field(default_factory=list)
recomendacoes: List[str] = field(default_factory=list)
avisos: List[str] = field(default_factory=list)
def valor_efetivo(self, chave: str) -> Optional[Achado]:
"""Ultimo valor encontrado para a chave, respeitando precedencia."""
candidatos = self.achados.get(chave, [])
if not candidatos:
return None
return max(candidatos, key=lambda a: PRECEDENCIA.get(a.origem, 0))
def linhas_de(self, chave: str) -> List[Achado]:
return self.achados.get(chave, [])
def _origem_por_caminho(caminho: str) -> Tuple[str, str]:
nome = caminho.rsplit("/", 1)[-1].lower()
if "pool" in nome or nome.endswith(".conf"):
return "pool FPM", ""
if nome == ".htaccess":
return ".htaccess", ""
if nome == ".user.ini":
return ".user.ini", ""
if nome in ("php.ini", "php.ini-development", "php.ini-production"):
return "php.ini", ""
return "php.ini", ""
def _parse_ini(caminho: str) -> List[Achado]:
achados: List[Achado] = []
origem, escopo = _origem_por_caminho(caminho)
try:
with open(caminho, "r", encoding="utf-8", errors="replace") as fh:
linhas = fh.readlines()
except OSError as exc:
raise OSError(f"nao foi possivel ler {caminho}: {exc}") from exc
for numero, linha in enumerate(linhas, 1):
if COMENTARIO_RE.match(linha):
continue
m = HTACCESS_RE.match(linha)
if m:
chave, valor = m.group(2), m.group(3)
if chave in CHAVES_RELEVANTES:
achados.append(
Achado(
chave=chave,
valor=valor.strip('"'),
origem=origem,
linha=numero,
escopo="htaccess",
)
)
continue
m = SECAO_RE.match(linha)
if m:
escopo = m.group(1)
continue
m = INICIAR_RE.match(linha)
if m:
chave, valor = m.group(1), m.group(2)
if chave in CHAVES_RELEVANTES:
achados.append(
Achado(chave=chave, valor=valor, origem=origem, linha=numero, escopo=escopo)
)
continue
m = POOL_RE.match(linha)
if m:
chave, valor = m.group(2), m.group(3)
if chave in CHAVES_RELEVANTES:
achados.append(
Achado(
chave=chave,
valor=valor,
origem="pool FPM",
linha=numero,
escopo=escopo,
)
)
return achados
def auditar(caminhos: List[str]) -> RelatorioConfig:
"""Audita uma lista de arquivos de configuracao."""
rel = RelatorioConfig()
for caminho in caminhos:
achados = _parse_ini(caminho)
rel.arquivos.append(caminho)
for achado in achados:
rel.achados.setdefault(achado.chave, []).append(achado)
_avaliar(rel)
return rel
def _avaliar(rel: RelatorioConfig) -> None:
"""Aplica as regras de avaliacao e monta problemas/recomendacoes."""
problemas = rel.problemas
recomendacoes = rel.recomendacoes
# 1) session.upload_progress.enabled
prog = rel.valor_efetivo("session.upload_progress.enabled")
if prog is None:
problemas.append(
"session.upload_progress.enabled nao encontrado: o padrao do PHP e On (ativo), "
"entao a cadeia de sessao esta habilitada por padrao."
)
recomendacoes.append(
"Defina session.upload_progress.enabled = Off em php.ini do sistema (nao em .user.ini) "
"e reinicie os workers."
)
elif prog.como_booleano() is False:
if prog.origem == ".user.ini":
problemas.append(
f"session.upload_progress.enabled = {prog.valor} em .user.ini (linha {prog.linha}): "
"o aviso oficial do PHP nao considera .user.ini um local confiavel para esta "
"diretiva, porque ela e aplicada por diretorio e pode ser sobrescrita em tempo de "
"requisicao. Trate o caminho de sessao como ainda aberto."
)
recomendacoes.append(
"Repita a diretiva em php.ini/pool FPM (antes do parsing da requisicao) e reinicie "
"os workers; so assim o caminho de sessao fica realmente fechado."
)
else:
recomendacoes.append(
f"session.upload_progress.enabled = {prog.valor} em {prog.origem} (linha {prog.linha}): "
"o caminho de sessao das CVEs esta fechado."
)
else:
problemas.append(
f"session.upload_progress.enabled = {prog.valor} em {prog.origem} (linha {prog.linha}): "
"caminho de sessao das CVEs ativo."
)
if prog.origem == ".user.ini":
problemas.append(
"A diretiva esta em .user.ini: o aviso oficial do PHP nao considera esse local "
"confiavel, porque a aplicacao pode sobrescrever em tempo de requisicao."
)
recomendacoes.append(
"Mova session.upload_progress.enabled = Off para php.ini/pool FPM (antes do parsing da "
"requisicao) e reinicie todos os workers."
)
# 2) memory_limit
mem = rel.valor_efetivo("memory_limit")
if mem is None:
problemas.append(
"memory_limit nao encontrado: com o padrao (128M) o OOM programavel fica mais dificil, "
"mas ainda existe."
)
else:
limite = mem.como_bytes()
if limite is not None and limite > 0:
problemas.append(
f"memory_limit = {mem.valor} em {mem.origem} (linha {mem.linha}): limite finito e "
"programavel pelo atacante (CVE-2026-109618)."
)
recomendacoes.append(
"Use memory_limit = -1 aplicado administrativamente (php_admin_value no pool FPM ou "
"php.ini), de forma que a aplicacao nao consiga restaurar um limite finito."
)
elif limite == -1:
recomendacoes.append(
f"memory_limit = {mem.valor} em {mem.origem} (linha {mem.linha}): sem limite "
"programavel, isso remove o vetor de OOM controlado."
)
# 3) post_max_size
post = rel.valor_efetivo("post_max_size")
if post is not None:
limite = post.como_bytes()
if limite is not None and limite > 32 * 1024 * 1024:
problemas.append(
f"post_max_size = {post.valor} em {post.origem} (linha {post.linha}): permite corpo "
"grande demais para a pressao de memoria necessária."
)
recomendacoes.append(
"Reduza post_max_size (8M sem upload; 32M com upload) e faca o limite valer tambem no "
"proxy/fronteira."
)
# 4) upload_max_filesize
up = rel.valor_efetivo("upload_max_filesize")
if post is not None and up is not None:
limite_post = post.como_bytes() or 0
limite_up = up.como_bytes() or 0
if limite_up > limite_post:
problemas.append(
f"upload_max_filesize ({up.valor}) e maior que post_max_size ({post.valor})."
)
recomendacoes.append("Mantenha upload_max_filesize abaixo de post_max_size.")
# 5) max_file_uploads / max_input_nesting_level
for chave, texto in (
("max_file_uploads", "numero de arquivos por requisicao"),
("max_input_nesting_level", "profundidade de aninhamento de campos"),
):
achado = rel.valor_efetivo(chave)
if achado is not None:
problemas.append(
f"{chave} = {achado.valor} em {achado.origem} (linha {achado.linha}): "
f"aumenta a superficie de trabalho do parser multipart ({texto})."
)
recomendacoes.append(
"Limite no proxy: tamanho do corpo decodado, quantidade de campos, de partes multipart, "
"de cabecalhos por parte e de uploads."
)
# 6) fatal_error_backtraces (PHP 8.5)
fbt = rel.valor_efetivo("fatal_error_backtraces")
if fbt is not None and fbt.como_booleano() is not False:
problemas.append(
f"fatal_error_backtraces = {fbt.valor} em {fbt.origem} (linha {fbt.linha}): no PHP 8.5 "
"esse e um dos caminhos confirmados da CVE-2026-109619."
)
recomendacoes.append("No PHP 8.5, defina fatal_error_backtraces = 0 e reinicie os workers.")
# 7) Conflitos entre arquivos
for chave, lista in rel.achados.items():
origens = {a.origem for a in lista}
if len(origens) > 1:
valores = {a.origem: a.valor for a in lista}
if len(set(valores.values())) > 1:
rel.avisos.append(
f"{chave} tem valores conflitantes entre {', '.join(sorted(origens))}: {valores}"
)
+271
View File
@@ -0,0 +1,271 @@
"""Deteccao de alvo: versao do PHP, SAPI,headers e exposicao de configuracao.
Fingerprinting leve e nao destrutivo: GET/HEAD + leitura de opcoes que o
servidor volunteers (headers, phpinfo, .htaccess refletido).
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Tuple
from urllib.parse import urlparse, urlunparse
from .httpclient import Conexao, Resposta
VERSAO_RE = re.compile(r"PHP[/ ]?(\d+)\.(\d+)(?:\.(\d+))?", re.IGNORECASE)
VERSAO_CABECALHO_RE = re.compile(r"PHP/(\d+)\.(\d+)(?:\.(\d+))?", re.IGNORECASE)
#: SAPI deduce a partir de cabecalhos/assinaturas de resposta.
SAPI_DESCONHECIDA = "desconhecida"
SAPI_MOD_PHP = "mod_php (prefork)"
SAPI_FPM = "php-fpm"
SAPI_CGI = "cgi"
SAPI_CLI_SERVER = "php -S (servidor embutido)"
SAPI_LITESPEED = "litespeed (lsapi)"
@dataclass
class Perfil:
"""Resultado do reconhecimento de um alvo."""
url: str
acessivel: bool = False
status: Optional[int] = None
servidor: str = ""
sapicabecalho: str = ""
php_versao: Optional[Tuple[int, int, int]] = None
php_versao_bruta: str = ""
sapi: str = SAPI_DESCONHECIDA
cabecalhos: Dict[str, str] = field(default_factory=dict)
cookies: List[str] = field(default_factory=list)
phpinfo: Dict[str, str] = field(default_factory=dict)
exposes_phpinfo: bool = False
observacoes: List[str] = field(default_factory=list)
erro: str = ""
@property
def eh_php(self) -> bool:
return self.php_versao is not None or bool(self.phpinfo)
def normalizar_url(alvo: str, caminho_padrao: str = "/") -> str:
"""Aceita 'exemplo.com', 'https://exemplo.com/x' e devolve URL absoluta."""
alvo = alvo.strip()
if not alvo:
raise ValueError("alvo vazio")
if "://" not in alvo:
alvo = "http://" + alvo
partes = urlparse(alvo)
if not partes.path:
partes = partes._replace(path=caminho_padrao)
return urlunparse(partes)
def _extrair_versao(texto: str) -> Optional[Tuple[int, int, int]]:
m = VERSAO_RE.search(texto or "")
if not m:
return None
return (int(m.group(1)), int(m.group(2)), int(m.group(3) or 0))
def _cabecalho_php(cabecalho: str) -> Optional[Tuple[int, int, int]]:
m = VERSAO_CABECALHO_RE.search(cabecalho or "")
if not m:
return None
return (int(m.group(1)), int(m.group(2)), int(m.group(3) or 0))
def _detectar_sapi(resposta: Resposta, corpo: str, headers: Dict[str, str]) -> str:
"""Deduz a SAPI a partir dos cabecalhos da resposta.
O header `Server` e o `X-Powered-By` sao a fonte primaria. Quando ha
`phpinfo` disponivel, o campo "Server API" corrige a classificacao
(ver `_sapi_por_phpinfo`).
"""
servidor = (headers.get("server") or "").lower()
powered = (headers.get("x-powered-by") or "").lower()
if "litespeed" in servidor:
return SAPI_LITESPEED
if "apache" in servidor and "php/" in servidor:
# O mod_php acrescenta a versao do PHP no header Server.
return SAPI_MOD_PHP
if "apache" in servidor and "php" in powered:
return SAPI_MOD_PHP
if "nginx" in servidor or "caddy" in servidor or "envoy" in servidor or "lighttpd" in servidor:
# Esses servidores nao rodam mod_php: o PHP responde por FastCGI.
return SAPI_FPM
if "microsoft-iis" in servidor:
return SAPI_CGI
if "php/" in servidor:
return SAPI_CLI_SERVER
if "php" in powered:
# X-Powered-By sem Server: pode ser o servidor embutido do PHP ou um
# proxy que remove o header Server.
return SAPI_DESCONHECIDA
return SAPI_DESCONHECIDA
def _parse_phpinfo(corpo: str) -> Dict[str, str]:
"""Extrai opcoes relevantes de uma pagina phpinfo (HTML ou texto)."""
if not corpo:
return {}
baixo = corpo.lower()
if "phpinfo()" not in baixo and "php version" not in baixo and "php_version" not in baixo:
return {}
chaves_interessantes = {
"session.upload_progress.enabled",
"session.upload_progress.cleanup",
"session.upload_progress.name",
"session.upload_progress.prefix",
"session.upload_progress.freq",
"session.upload_progress.min_freq",
"session.serialize_handler",
"session.save_handler",
"memory_limit",
"post_max_size",
"upload_max_filesize",
"max_file_uploads",
"max_input_vars",
"max_input_nesting_level",
"server api",
"php version",
}
resultado: Dict[str, str] = {}
# Formato HTML do phpinfo: <tr><td class="e">chave</td><td class="v">valor</td></tr>
for chave in sorted(chaves_interessantes):
padrao = re.compile(
r"<td[^>]*>\s*" + re.escape(chave) + r"\s*</td>\s*<td[^>]*>(.*?)</td>",
re.IGNORECASE | re.DOTALL,
)
m = padrao.search(corpo)
if m:
valor = re.sub(r"<[^>]+>", "", m.group(1))
resultado[chave] = " ".join(valor.split())
continue
# Formato texto (php -i): "chave => valor"
m2 = re.search(r"^\s*" + re.escape(chave) + r"\s*=>\s*(.+)$", corpo, re.IGNORECASE | re.MULTILINE)
if m2:
resultado[chave] = m2.group(1).strip()
return resultado
def _extrair_versao_phpinfo(info: Dict[str, str]) -> Optional[Tuple[int, int, int]]:
bruto = info.get("php version", "")
return _extrair_versao(bruto)
def sondar_phpinfo(url: str, timeout: float = 10.0, insecure: bool = False) -> Dict[str, str]:
"""Tenta obter um phpinfo publico no alvo (caminhos comuns)."""
base = url.rstrip("/")
candidatos = [
f"{base}/phpinfo.php",
f"{base}/info.php",
f"{base}/phpinfo",
f"{base}/i.php",
f"{base}/test.php",
f"{base}/?phpinfo=1",
]
partes = urlparse(url)
porta = partes.port or (443 if partes.scheme == "https" else 80)
for candidato in candidatos:
try:
with Conexao(
partes.hostname or "",
porta,
tls=partes.scheme == "https",
timeout=timeout,
verificar_certificado=not insecure,
) as con:
resp = con.requisitar("GET", urlparse(candidato).path or "/")
if resp.status != 200:
continue
info = _parse_phpinfo(resp.body.decode("utf-8", "replace"))
if info:
return info
except OSError:
continue
return {}
def reconhecer(url: str, timeout: float = 10.0, insecure: bool = False, buscar_phpinfo: bool = True) -> Perfil:
"""Reconhece o alvo: versao do PHP, SAPI e configuracao exposta."""
perfil = Perfil(url=url)
partes = urlparse(url)
if not partes.hostname:
perfil.erro = "URL sem hostname"
return perfil
porta = partes.port or (443 if partes.scheme == "https" else 80)
caminho = partes.path or "/"
try:
with Conexao(
partes.hostname,
porta,
tls=partes.scheme == "https",
timeout=timeout,
verificar_certificado=not insecure,
) as con:
resp = con.requisitar("GET", caminho)
except OSError as exc:
perfil.erro = f"falha de conexao: {exc}"
return perfil
perfil.acessivel = resp.status is not None
perfil.status = resp.status
perfil.cabecalhos = dict(resp.headers)
perfil.servidor = resp.headers.get("server", "")
perfil.sapicabecalho = resp.headers.get("x-powered-by", "")
perfil.cookies = [
linha.split(":", 1)[0].strip()
for linha in resp.headers.get("set-cookie", "").split("\n")
if linha.strip()
]
corpo = resp.body.decode("utf-8", "replace")
versao = _cabecalho_php(perfil.sapicabecalho) or _cabecalho_php(perfil.servidor)
if versao:
perfil.php_versao = versao
perfil.php_versao_bruta = ".".join(str(p) for p in versao)
else:
bruta = _extrair_versao(corpo[:5000])
if bruta:
perfil.php_versao = bruta
perfil.php_versao_bruta = ".".join(str(p) for p in bruta)
perfil.sapi = _detectar_sapi(resp, corpo, perfil.cabecalhos)
if buscar_phpinfo:
info = sondar_phpinfo(url, timeout=timeout, insecure=insecure)
if info:
perfil.phpinfo = info
perfil.exposes_phpinfo = True
if perfil.php_versao is None:
v = _extrair_versao_phpinfo(info)
if v:
perfil.php_versao = v
perfil.php_versao_bruta = ".".join(str(p) for p in v)
if "server api" in info:
perfil.sapi = _sapi_por_phpinfo(info["server api"], perfil.sapi)
perfil.observacoes.append("phpinfo exposicao publica (risco extra de divulgacao de configuracao)")
if "phpsessid" in [c.lower() for c in perfil.cookies]:
perfil.observacoes.append("cookies de sessao presentes (o host usa sessoes)")
return perfil
def _sapi_por_phpinfo(valor: str, atual: str) -> str:
v = (valor or "").lower()
if "apache" in v:
return SAPI_MOD_PHP
if "fpm" in v or "fastcgi" in v:
return SAPI_FPM
if "cgi" in v:
return SAPI_CGI
if "cli" in v or "built-in" in v or "cli-server" in v:
return SAPI_CLI_SERVER
if "litespeed" in v:
return SAPI_LITESPEED
return atual
+271
View File
@@ -0,0 +1,271 @@
"""Cliente HTTP mínimo (somente stdlib) com suporte a keep-alive, TLS e leitura de corpo.
Existe para o scanner precisar de controle fino sobre o socket: as sondas de
upload_progress precisam que o ARM e o FIRE caiam no *mesmo* processo do servidor
(keep-alive), senao o teste mede outra coisa.
"""
from __future__ import annotations
import socket
import ssl
import time
from dataclasses import dataclass, field
from typing import Dict, Optional
@dataclass
class Resposta:
"""Resposta HTTP parcialmente parseada."""
status: Optional[int] = None
reason: str = ""
headers: Dict[str, str] = field(default_factory=dict)
body: bytes = b""
erro: Optional[str] = None
tempo: float = 0.0
servidor_fechou: bool = False
def cabecalho(self, nome: str, padrao: Optional[str] = None) -> Optional[str]:
return self.headers.get(nome.lower(), padrao)
@property
def texto(self) -> str:
return self.body.decode("utf-8", "replace")
class Conexao:
"""Uma conexao TCP/TLS com suporte a varias requisicoes (keep-alive)."""
def __init__(
self,
host: str,
porta: int = 80,
tls: bool = False,
timeout: float = 15.0,
verificar_certificado: bool = True,
) -> None:
self.host = host
self.porta = porta
self.tls = tls
self.timeout = timeout
self.verificar_certificado = verificar_certificado
self._sock: Optional[socket.socket] = None
self._buf = b""
# ------------------------------------------------------------------ setup
def abrir(self) -> None:
if self._sock is not None:
return
bruto = socket.create_connection((self.host, self.porta), timeout=self.timeout)
bruto.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
if self.tls:
ctx = ssl.create_default_context()
if not self.verificar_certificado:
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
bruto = ctx.wrap_socket(bruto, server_hostname=self.host)
self._sock = bruto
self._buf = b""
def fechar(self) -> None:
if self._sock is not None:
try:
self._sock.close()
except OSError:
pass
self._sock = None
def __enter__(self) -> "Conexao":
self.abrir()
return self
def __exit__(self, *_exc) -> None:
self.fechar()
@property
def viva(self) -> bool:
return self._sock is not None
# ------------------------------------------------------------- requisicao
def requisitar(
self,
metodo: str,
caminho: str,
corpo: bytes = b"",
cabecalhos: Optional[Dict[str, str]] = None,
atraso: float = 0.0,
fatia: int = 0,
ler_corpo: bool = True,
chunked: bool = False,
) -> Resposta:
"""Envia uma requisicao e le a resposta.
`atraso`/`fatia` permitem enviar o corpo em duas partes com uma pausa no
meio (tecnica do PoC original para forcar varias rodadas de update() do
upload_progress quando session.upload_progress.min_freq > 0).
`chunked` envia o corpo com `Transfer-Encoding: chunked` e sem
`Content-Length`. O PHP usa o Content-Length para checar `post_max_size`
no inicio do parsing; sem ele, um corpo maior que `post_max_size`
continua sendo entregue (vetor descrito no advisory das CVEs).
"""
inicio = time.time()
try:
self.abrir()
except OSError as exc:
return Resposta(erro=f"conexao: {exc}", tempo=time.time() - inicio)
cab = {
"Host": self._host_header(),
"Connection": "keep-alive",
"User-Agent": "phpupscan/1.0 (verificacao de exposicao - autorizado)",
}
if cabecalhos:
cab.update(cabecalhos)
if chunked:
cab.pop("Content-Length", None)
cab["Transfer-Encoding"] = "chunked"
else:
cab["Content-Length"] = str(len(corpo))
cabecalho_bytes = f"{metodo.upper()} {caminho} HTTP/1.1\r\n".encode()
for chave, valor in cab.items():
cabecalho_bytes += f"{chave}: {valor}\r\n".encode()
cabecalho_bytes += b"\r\n"
try:
assert self._sock is not None
self._sock.settimeout(self.timeout)
if chunked:
self._sock.sendall(cabecalho_bytes)
for inicio_bloco in range(0, len(corpo), 65536):
bloco = corpo[inicio_bloco : inicio_bloco + 65536]
self._sock.sendall(b"%x\r\n" % len(bloco) + bloco + b"\r\n")
self._sock.sendall(b"0\r\n\r\n")
elif atraso > 0 and fatia > 0 and corpo:
self._sock.sendall(cabecalho_bytes + corpo[:fatia])
time.sleep(atraso)
self._sock.sendall(corpo[fatia:])
else:
self._sock.sendall(cabecalho_bytes + corpo)
except (OSError, ssl.SSLError) as exc:
return Resposta(erro=f"envio: {exc}", tempo=time.time() - inicio)
return self._ler_resposta(inicio, ler_corpo)
# ------------------------------------------------------------- leitura
def _ler_resposta(self, inicio: float, ler_corpo: bool) -> Resposta:
assert self._sock is not None
try:
cab_bruto = self._ler_ate(b"\r\n\r\n")
except (OSError, ssl.SSLError) as exc:
return Resposta(
erro=f"leitura: {type(exc).__name__}", tempo=time.time() - inicio, servidor_fechou=True
)
if not cab_bruto:
return Resposta(
erro="sem resposta (conexao encerrada)", tempo=time.time() - inicio, servidor_fechou=True
)
linhas = cab_bruto.split(b"\r\n")
primeira = linhas[0].decode("latin-1", "replace")
partes = primeira.split(" ", 2)
status: Optional[int] = None
motivo = ""
if len(partes) >= 2 and partes[1].isdigit():
status = int(partes[1])
motivo = partes[2] if len(partes) > 2 else ""
cabecalhos: Dict[str, str] = {}
for linha in linhas[1:]:
if not linha or b":" not in linha:
continue
chave, _, valor = linha.partition(b":")
cabecalhos[chave.decode("latin-1").strip().lower()] = valor.decode("latin-1").strip()
# 100 Continue e responses intermediarios
if status == 100:
return self._ler_resposta(inicio, ler_corpo)
corpo = b""
if ler_corpo:
if cabecalhos.get("transfer-encoding", "").lower().find("chunked") >= 0:
corpo = self._ler_chunked()
elif "content-length" in cabecalhos:
try:
tamanho = int(cabecalhos["content-length"])
except ValueError:
tamanho = 0
corpo = self._ler_exatos(tamanho)
else:
corpo = self._ler_ate_eof()
return Resposta(
status=status,
reason=motivo,
headers=cabecalhos,
body=corpo,
tempo=time.time() - inicio,
servidor_fechou=cabecalhos.get("connection", "").lower() == "close",
)
def _ler_ate(self, marcador: bytes) -> bytes:
assert self._sock is not None
while marcador not in self._buf:
pedaco = self._sock.recv(65536)
if not pedaco:
break
self._buf += pedaco
if marcador in self._buf:
idx = self._buf.index(marcador) + len(marcador)
cab, self._buf = self._buf[:idx], self._buf[idx:]
return cab
dados, self._buf = self._buf, b""
return dados
def _ler_exatos(self, tamanho: int) -> bytes:
assert self._sock is not None
while len(self._buf) < tamanho:
pedaco = self._sock.recv(65536)
if not pedaco:
break
self._buf += pedaco
corpo, self._buf = self._buf[:tamanho], self._buf[tamanho:]
return corpo
def _ler_ate_eof(self) -> bytes:
assert self._sock is not None
while True:
try:
pedaco = self._sock.recv(65536)
except (socket.timeout, ssl.SSLError, OSError):
break
if not pedaco:
break
self._buf += pedaco
corpo, self._buf = self._buf, b""
return corpo
def _ler_chunked(self) -> bytes:
corpo = b""
while True:
linha = self._ler_ate(b"\r\n").strip()
if not linha:
break
try:
tamanho = int(linha.split(b";")[0], 16)
except ValueError:
break
if tamanho == 0:
self._ler_ate(b"\r\n")
break
corpo += self._ler_exatos(tamanho)
self._ler_ate(b"\r\n")
return corpo
def _host_header(self) -> str:
if self.porta in (80, 443):
return self.host
return f"{self.host}:{self.porta}"
+479
View File
@@ -0,0 +1,479 @@
"""Laboratorio local: valida o verificador contra versoes vulneravel e corrigida.
Sobe tres containers (a imagem base e a de uma imagem de runtime de containers):
1. PHP 8.2.34 com mod_php (prefork) - vulneravel
2. PHP 8.2.35 com mod_php (prefork) - corrigida
3. PHP 8.2.34 com session.upload_progress.enabled=Off - controle
Depois roda o proprio verificador contra os tres e imprime recibos PASS/FAIL.
"""
from __future__ import annotations
import argparse
import os
import shutil
import subprocess
import sys
import tempfile
import time
from typing import List, Optional
from .detectar import SAPI_FPM, reconhecer
from .relatorio import _avaliar
from .sondas import (
ATRIBUICAO_SESSAO,
COMPORTAMENTO_PATCH,
TAMANHO_FORTE,
sondar,
)
IMAGEM_VULN = "docker.io/library/php:8.2.34-apache"
IMAGEM_FIX = "docker.io/library/php:8.2.35-apache"
IMAGEM_FPM = "docker.io/library/php:8.2.34-fpm-alpine"
IMAGEM_NGINX = "docker.io/library/nginx:alpine"
#: Configuracao com memory_limit pequeno: o OOM acontece cedo e a sonda leve
#: (Content-Length) ja reproduz o pre-RINIT.
INI_LAB = """memory_limit = 8M
post_max_size = 32M
upload_max_filesize = 32M
max_file_uploads = 25
session.upload_progress.min_freq = 0
session.save_path = /tmp/sess
"""
#: Configuracao padrao do PHP (memory_limit 128M, post_max_size 8M): e o
#: cenario em que a sonda forte (chunked) distingue versao corrigida de
#: vulneravel, porque o parser corrigido corta a leitura em post_max_size
#: antes de estourar a memoria.
INI_PADRAO = """display_errors = On
"""
INI_CONTROLE = "session.upload_progress.enabled = 0\n"
#: Configuracao do php-fpm do laboratorio (para o par nginx + php-fpm).
INI_FPM = """memory_limit = 8M
post_max_size = 32M
upload_max_filesize = 32M
max_file_uploads = 25
session.upload_progress.min_freq = 0
session.save_path = /tmp/sess
"""
#: nginx que faz de proxy para o php-fpm do laboratorio.
NGINX_LAB = """server {
listen 80;
index index.php;
client_max_body_size 32M;
location / {
try_files $uri $uri/ =404;
}
location ~ \\.php$ {
include fastcgi_params;
fastcgi_pass phpupscan-lab-fpm:9000;
fastcgi_param SCRIPT_FILENAME /var/www/html$fastcgi_script_name;
}
}
"""
HTML_LAB = """<?php
echo "OK " . PHP_VERSION . " " . php_sapi_name() . "\\n";
"""
def _detectar_motor(desejado: Optional[str]) -> Optional[str]:
if desejado:
return shutil.which(desejado)
for motor in ("podman", "docker"):
caminho = shutil.which(motor)
if caminho:
return caminho
return None
class Container:
"""Um container do laboratorio."""
def __init__(
self,
motor: str,
nome: str,
imagem: str,
porta: int,
raiz: str,
extras: List[str],
rede: Optional[str] = None,
comando: Optional[List[str]] = None,
porta_interna: int = 80,
destino: str = "/var/www/html",
volumes_extra: Optional[List[str]] = None,
):
self.motor = motor
self.nome = nome
self.imagem = imagem
self.porta = porta
self.raiz = raiz
self.extras = extras
self.rede = rede
self.comando = comando
self.porta_interna = porta_interna
self.destino = destino
self.volumes_extra = volumes_extra or []
def subir(self) -> bool:
cmd = [
self.motor,
"run",
"-d",
"--name",
self.nome,
]
if self.rede:
cmd += ["--network", self.rede]
if self.porta:
cmd += ["-p", f"{self.porta}:{self.porta_interna}"]
cmd += [
"-v",
f"{self.raiz}/html:{self.destino}:ro",
"-v",
f"{self.raiz}/sess:/tmp/sess",
]
for ini in self.extras:
cmd += ["-v", f"{ini[0]}:/usr/local/etc/php/conf.d/{ini[1]}:ro"]
for volume in self.volumes_extra:
cmd += ["-v", volume]
cmd.append(self.imagem)
if self.comando:
cmd += self.comando
resultado = subprocess.run(cmd, capture_output=True, text=True)
if resultado.returncode != 0:
print(f" erro ao subir {self.nome}: {resultado.stderr.strip()}", file=sys.stderr)
return False
return True
def remover(self) -> None:
subprocess.run([self.motor, "rm", "-f", self.nome], capture_output=True, text=True)
def url(self, caminho: str = "/index.php") -> str:
return f"http://127.0.0.1:{self.porta}{caminho}"
def _esperar(url: str, tentativas: int = 40) -> bool:
import urllib.error
import urllib.request
for _ in range(tentativas):
try:
with urllib.request.urlopen(url, timeout=2) as resp:
if resp.status == 200:
return True
except Exception:
time.sleep(0.5)
return False
class Recibos:
"""Acumula PASS/FAIL do laboratorio."""
def __init__(self) -> None:
self.passes = 0
self.falhas = 0
def checar(self, condicao: bool, descricao: str, detalhe: str = "") -> bool:
if condicao:
self.passes += 1
print(f" [PASS] {descricao}" + (f" - {detalhe}" if detalhe else ""))
else:
self.falhas += 1
print(f" [FAIL] {descricao}" + (f" - {detalhe}" if detalhe else ""))
return condicao
def executar_lab(args: argparse.Namespace) -> int:
motor = _detectar_motor(args.motor)
if not motor:
print(
"Nao encontrei podman nem docker. Instale um dos dois para rodar o laboratorio "
"local (o laboratorio so usa imagens publicas do PHP e nao toca a rede alem dos pulls)."
)
return 2
print(f"phpupscan - laboratorio local (motor: {os.path.basename(motor)})")
print("Aviso: o laboratorio cria containers locais e remove tudo ao final.")
print()
raiz = tempfile.mkdtemp(prefix="phpupscan-lab-")
os.makedirs(os.path.join(raiz, "html"), exist_ok=True)
os.makedirs(os.path.join(raiz, "sess"), exist_ok=True)
os.chmod(os.path.join(raiz, "sess"), 0o777)
with open(os.path.join(raiz, "html", "index.php"), "w", encoding="utf-8") as fh:
fh.write(HTML_LAB)
ini_principal = os.path.join(raiz, "99-lab.ini")
with open(ini_principal, "w", encoding="utf-8") as fh:
fh.write(INI_LAB)
ini_controle = os.path.join(raiz, "98-off.ini")
with open(ini_controle, "w", encoding="utf-8") as fh:
fh.write(INI_CONTROLE)
ini_padrao = os.path.join(raiz, "99-padrao.ini")
with open(ini_padrao, "w", encoding="utf-8") as fh:
fh.write(INI_PADRAO)
nginx_conf = os.path.join(raiz, "nginx.conf")
with open(nginx_conf, "w", encoding="utf-8") as fh:
fh.write(NGINX_LAB)
ini_fpm = os.path.join(raiz, "99-fpm.ini")
with open(ini_fpm, "w", encoding="utf-8") as fh:
fh.write(INI_FPM)
vuln = Container(motor, "phpupscan-lab-vuln", IMAGEM_VULN, args.porta, raiz, [(ini_principal, "99-lab.ini")])
fix = Container(motor, "phpupscan-lab-fix", IMAGEM_FIX, args.porta + 1, raiz, [(ini_principal, "99-lab.ini")])
controle = Container(
motor,
"phpupscan-lab-ctrl",
IMAGEM_VULN,
args.porta + 2,
raiz,
[(ini_principal, "99-lab.ini"), (ini_controle, "98-off.ini")],
)
# Par com configuracao padrao do PHP (memory_limit 128M, post_max_size 8M):
# e o cenario em que a sonda forte (chunked) distingue patch de vulneravel.
vuln_padrao = Container(
motor, "phpupscan-lab-vuln-def", IMAGEM_VULN, args.porta + 3, raiz, [(ini_padrao, "99-padrao.ini")]
)
fix_padrao = Container(
motor, "phpupscan-lab-fix-def", IMAGEM_FIX, args.porta + 4, raiz, [(ini_padrao, "99-padrao.ini")]
)
# Par nginx + php-fpm: valida a matriz de SAPI (o filho do FPM morre no
# startup e o proxy responde 502).
rede = "phpupscan-lab-rede"
subprocess.run([motor, "network", "rm", "-f", rede], capture_output=True, text=True)
subprocess.run([motor, "network", "create", rede], capture_output=True, text=True)
fpm = Container(
motor,
"phpupscan-lab-fpm",
IMAGEM_FPM,
0,
raiz,
[(ini_fpm, "99-fpm.ini")],
rede=rede,
porta_interna=9000,
)
nginx = Container(
motor,
"phpupscan-lab-nginx",
IMAGEM_NGINX,
args.porta + 5,
raiz,
[],
rede=rede,
volumes_extra=[f"{nginx_conf}:/etc/nginx/conf.d/default.conf:ro"],
)
containers = [vuln, fix, controle, vuln_padrao, fix_padrao, fpm, nginx]
try:
for container in containers:
print(f"Subindo {container.nome} ({container.imagem})...")
if not container.subir():
return 2
for container in containers:
if not container.porta:
# Container sem porta exposta (o php-fpm responde so via nginx).
continue
if not _esperar(container.url()):
print(f" o container {container.nome} nao respondeu", file=sys.stderr)
return 2
print()
print("Recibos:")
recibos = Recibos()
# 1) Reconhecimento
print()
print("1) Reconhecimento de versao e SAPI")
perfis = {}
for container in containers:
if not container.porta:
continue
perfil = reconhecer(container.url(), timeout=10.0, buscar_phpinfo=False)
perfis[container.nome] = perfil
print(f" {container.nome}: {perfil.php_versao_bruta or '?'} / {perfil.sapi}")
recibos.checar(
perfis[vuln.nome].php_versao_bruta == "8.2.34",
"alvo vulnavel reporta PHP 8.2.34",
perfis[vuln.nome].php_versao_bruta,
)
recibos.checar(
perfis[fix.nome].php_versao_bruta == "8.2.35",
"alvo corrigido reporta PHP 8.2.35",
perfis[fix.nome].php_versao_bruta,
)
# 2) Classificacao de versao
print()
print("2) Classificacao de versao contra o advisory")
from . import versoes as mod_versoes
recibos.checar(
mod_versoes.classificar((8, 2, 34)) == mod_versoes.VULNERAVEL,
"8.2.34 classificada como vulneravel",
)
recibos.checar(
mod_versoes.classificar((8, 2, 35)) == mod_versoes.CORRIGIDO,
"8.2.35 classificada como corrigida",
)
recibos.checar(
mod_versoes.classificar((8, 1, 33)) == mod_versoes.SEM_CORRECAO,
"8.1.33 classificada como sem correcao oficial",
)
# 3) Sonda ativa contra o alvo vulneravel
print()
print("3) Sonda ativa contra o alvo vulneravel (8.2.34)")
sonda_vuln = sondar(vuln.url(), timeout=25.0, tamanho_nome=1_300_000)
print(f" {sonda_vuln.resumo()}")
for obs in sonda_vuln.observacoes:
print(f" - {obs}")
recibos.checar(sonda_vuln.oom_arm, "OOM reproduzido no alvo vulneravel")
recibos.checar(
sonda_vuln.upload_progress_ativo is True,
"controle sem PHP_SESSION_UPLOAD_PROGRESS nao estourou memoria",
f"status controle={sonda_vuln.status_controle}",
)
recibos.checar(
sonda_vuln.atribuicao == ATRIBUICAO_SESSAO,
"OOM atribuido ao caminho de progresso de upload de sessao",
sonda_vuln.atribuicao,
)
# 4) Controle com upload_progress desligado
print()
print("4) Controle: upload_progress desligado (8.2.34)")
sonda_ctrl = sondar(controle.url(), timeout=25.0, tamanho_nome=1_300_000)
print(f" {sonda_ctrl.resumo()}")
recibos.checar(
not sonda_ctrl.oom_arm,
"com upload_progress=Off o mesmo corpo nao estourou memoria",
f"status={sonda_ctrl.status_arm}",
)
# 5) Sonda contra a versao corrigida
print()
print("5) Sonda ativa contra o alvo corrigido (8.2.35)")
sonda_fix = sondar(fix.url(), timeout=25.0, tamanho_nome=1_300_000)
print(f" {sonda_fix.resumo()}")
recibos.checar(
sonda_fix.oom_arm,
"a versao corrigida ainda pode estourar memoria (o patch nao muda o OOM)",
"isso e esperado: o patch evita o reuso do estado corrompido",
)
# 6) Sonda forte (chunked): verificacao comportamental do patch
print()
print("6) Sonda forte (chunked, corpo acima de post_max_size)")
tamanho_forte = TAMANHO_FORTE
sonda_forte_vuln = sondar(
vuln_padrao.url(), timeout=90.0, tamanho_nome=tamanho_forte, forte=True
)
sonda_forte_fix = sondar(
fix_padrao.url(), timeout=90.0, tamanho_nome=tamanho_forte, forte=True
)
print(f" vulnavel -> {sonda_forte_vuln.resumo()}")
print(f" corrigido -> {sonda_forte_fix.resumo()}")
recibos.checar(
sonda_forte_vuln.oom_arm,
"no alvo vulneravel o corpo acima de post_max_size provocou OOM",
f"status={sonda_forte_vuln.status_arm}",
)
recibos.checar(
sonda_forte_fix.comportamento == COMPORTAMENTO_PATCH,
"no alvo corrigido o parser interrompeu a leitura em post_max_size",
f"post_max_size={sonda_forte_fix.post_max_size_bytes}",
)
# 7) Veredito final
print()
print("7) Veredito final do verificador")
resultado_vuln = _avaliar(perfis[vuln.nome], sonda_vuln)
resultado_fix = _avaliar(perfis[fix.nome], sonda_fix)
print(f" vulnavel -> {resultado_vuln.nivel}: {resultado_vuln.resumo}")
print(f" corrigido -> {resultado_fix.nivel}: {resultado_fix.resumo}")
recibos.checar(
resultado_vuln.nivel in ("CRITICO", "ALTO"),
"alvo vulneravel classificado como CRITICO/ALTO",
resultado_vuln.nivel,
)
recibos.checar(
resultado_fix.nivel != "CRITICO",
"alvo corrigido nao classificado como CRITICO",
resultado_fix.nivel,
)
# 8) Veredito final com a sonda forte (configuracao padrao do PHP)
print()
print("8) Veredito final usando a sonda forte (configuracao padrao)")
perfil_vuln_def = reconhecer(vuln_padrao.url(), timeout=10.0, buscar_phpinfo=False)
perfil_fix_def = reconhecer(fix_padrao.url(), timeout=10.0, buscar_phpinfo=False)
veredito_vuln_def = _avaliar(perfil_vuln_def, sonda_forte_vuln)
veredito_fix_def = _avaliar(perfil_fix_def, sonda_forte_fix)
print(f" vulnavel -> {veredito_vuln_def.nivel}: {veredito_vuln_def.resumo}")
print(f" corrigido -> {veredito_fix_def.nivel}: {veredito_fix_def.resumo}")
recibos.checar(
veredito_vuln_def.nivel == "CRITICO",
"alvo vulneravel em configuracao padrao classificado como CRITICO",
veredito_vuln_def.nivel,
)
recibos.checar(
veredito_fix_def.nivel in ("OK", "BAIXO", "MEDIO"),
"alvo corrigido em configuracao padrao nao classificado como CRITICO",
veredito_fix_def.nivel,
)
# 9) Matriz de SAPI: nginx + php-fpm
print()
print("9) Matriz de SAPI: nginx + php-fpm (o filho morre no startup)")
perfil_fpm = reconhecer(nginx.url(), timeout=10.0, buscar_phpinfo=False)
sonda_fpm = sondar(nginx.url(), timeout=60.0, tamanho_nome=1_300_000)
veredito_fpm = _avaliar(perfil_fpm, sonda_fpm)
print(f" SAPI detectada: {perfil_fpm.sapi}")
print(f" {sonda_fpm.resumo()}")
print(f" veredito -> {veredito_fpm.nivel}: {veredito_fpm.resumo}")
recibos.checar(
perfil_fpm.sapi == SAPI_FPM,
"SAPI php-fpm detectada atras do nginx",
perfil_fpm.sapi,
)
recibos.checar(
sonda_fpm.worker_morreu or sonda_fpm.backend_abortou,
"o worker do php-fpm morreu no startup (o proxy nao devolveu conteudo)",
f"status={sonda_fpm.status_arm} abortou={sonda_fpm.backend_abortou}",
)
recibos.checar(
veredito_fpm.nivel in ("ALTO", "MEDIO"),
"alvo com php-fpm nao classificado como CRITICO",
veredito_fpm.nivel,
)
print()
print(f"RESULTADO: {recibos.passes} PASS, {recibos.falhas} FAIL")
return 0 if recibos.falhas == 0 else 1
finally:
if not args.manter:
for container in containers:
container.remover()
subprocess.run([motor, "network", "rm", "-f", "phpupscan-lab-rede"], capture_output=True)
shutil.rmtree(raiz, ignore_errors=True)
else:
print(f"Containers mantidos. Raiz temporaria: {raiz}")
def main() -> int:
parser = argparse.ArgumentParser(description="Laboratorio local do phpupscan")
parser.add_argument("--motor", choices=["podman", "docker"], default=None)
parser.add_argument("--porta", type=int, default=18081)
parser.add_argument("--manter", action="store_true")
return executar_lab(parser.parse_args())
if __name__ == "__main__":
sys.exit(main())
+557
View File
@@ -0,0 +1,557 @@
"""Relatorio em PT-BR: veredito, nivel de risco e plano de acao.
Formatos: texto (colorido, para terminal) e JSON (para integracao).
"""
from __future__ import annotations
import json
import os
import sys
from dataclasses import asdict, dataclass, field
from typing import Dict, List, Optional
from . import versoes
from .detectar import (
Perfil,
SAPI_CLI_SERVER,
SAPI_FPM,
SAPI_LITESPEED,
SAPI_MOD_PHP,
)
from .sondas import (
ATRIBUICAO_PARSER,
COMPORTAMENTO_PATCH,
ResultadoSonda,
)
# --- cores -----------------------------------------------------------------
SEM_COR = not sys.stdout.isatty() or os.environ.get("NO_COLOR") is not None
def _c(texto: str, codigo: str) -> str:
if SEM_COR:
return texto
return f"\033[{codigo}m{texto}\033[0m"
VERMELHO = lambda t: _c(t, "1;31") # noqa: E731
AMARELO = lambda t: _c(t, "1;33") # noqa: E731
VERDE = lambda t: _c(t, "1;32") # noqa: E731
AZUL = lambda t: _c(t, "1;34") # noqa: E731
CINZA = lambda t: _c(t, "2") # noqa: E731
NEGRITO = lambda t: _c(t, "1") # noqa: E731
# --- niveis de risco -------------------------------------------------------
NIVEL_CRITICO = "CRITICO"
NIVEL_ALTO = "ALTO"
NIVEL_MEDIO = "MEDIO"
NIVEL_BAIXO = "BAIXO"
NIVEL_OK = "OK"
NIVEL_DESCONHECIDO = "DESCONHECIDO"
NIVEL_INFO = "INFO"
#: Ordem de severidade. `INFO` nao participa: e contexto, nao um achado.
_RANK = {
NIVEL_OK: 0,
NIVEL_BAIXO: 1,
NIVEL_DESCONHECIDO: 2,
NIVEL_MEDIO: 3,
NIVEL_ALTO: 4,
NIVEL_CRITICO: 5,
}
_CORES_NIVEL = {
NIVEL_CRITICO: VERMELHO,
NIVEL_ALTO: VERMELHO,
NIVEL_MEDIO: AMARELO,
NIVEL_BAIXO: AMARELO,
NIVEL_OK: VERDE,
NIVEL_DESCONHECIDO: CINZA,
}
@dataclass
class Achado:
"""Um item do relatorio por alvo."""
titulo: str
detalhe: str
nivel: str = NIVEL_DESCONHECIDO
fonte: str = ""
@dataclass
class ResultadoAlvo:
"""Veredito completo de um alvo."""
url: str
perfil: Perfil
sonda: Optional[ResultadoSonda] = None
nivel: str = NIVEL_DESCONHECIDO
resumo: str = ""
achados: List[Achado] = field(default_factory=list)
acoes: List[str] = field(default_factory=list)
def para_dict(self) -> Dict:
return {
"url": self.url,
"nivel": self.nivel,
"resumo": self.resumo,
"perfil": {
"acessivel": self.perfil.acessivel,
"status": self.perfil.status,
"servidor": self.perfil.servidor,
"x_powered_by": self.perfil.sapicabecalho,
"php_versao": self.perfil.php_versao_bruta or None,
"sapi": self.perfil.sapi,
"exposes_phpinfo": self.perfil.exposes_phpinfo,
"phpinfo": self.perfil.phpinfo,
"observacoes": self.perfil.observacoes,
"erro": self.perfil.erro,
},
"sonda": (
{
"executada": self.sonda.executada,
"modo": self.sonda.modo,
"bytes_enviados": self.sonda.bytes_enviados,
"status_arm": self.sonda.status_arm,
"status_controle": self.sonda.status_controle,
"oom_arm": self.sonda.oom_arm,
"oom_controle": self.sonda.oom_controle,
"assinatura_memoria": self.sonda.assinatura_memoria,
"oom_limite_bytes": self.sonda.oom_limite_bytes,
"oom_pre_rinit": self.sonda.oom_pre_rinit,
"post_max_size_bytes": self.sonda.post_max_size_bytes,
"atribuicao": self.sonda.atribuicao,
"comportamento": self.sonda.comportamento,
"upload_progress_ativo": self.sonda.upload_progress_ativo,
"follow_up_status": self.sonda.follow_up_status,
"exposicao_confirmada": self.sonda.exposicao_confirmada,
"observacoes": self.sonda.observacoes,
"erro": self.sonda.erro,
}
if self.sonda
else None
),
"achados": [asdict(a) for a in self.achados],
"acoes": self.acoes,
}
def _avaliar(perfil: Perfil, sonda: Optional[ResultadoSonda]) -> ResultadoAlvo:
"""Combina fingerprint, versao e sonda em um veredito."""
resultado = ResultadoAlvo(url=perfil.url, perfil=perfil, sonda=sonda)
if not perfil.acessivel:
resultado.nivel = NIVEL_DESCONHECIDO
resultado.resumo = perfil.erro or "alvo inacessivel"
resultado.achados.append(
Achado("Acesso", resultado.resumo, NIVEL_DESCONHECIDO, "reconhecimento")
)
return resultado
# 1) Eh PHP?
if not perfil.eh_php:
resultado.nivel = NIVEL_OK
resultado.resumo = "Sem evidencia de PHP na resposta principal."
resultado.achados.append(
Achado(
"PHP",
"Nenhuma assinatura de PHP (X-Powered-By, header Server ou phpinfo) encontrada "
f"em {perfil.url}.",
NIVEL_OK,
"reconhecimento",
)
)
resultado.acoes.append(
"Se o site roda PHP em outras rotas, repita a varredura apontando para elas."
)
return resultado
# 2) Versao
estado = versoes.classificar(perfil.php_versao)
# A severidade da versao depende de a cadeia ser alcancavel: o aviso
# oficial trata mod_php/FrankenPHP como o pior caso (o worker sobrevive ao
# OOM de startup e pode reaproveitar o estado corrompido), enquanto em
# php-fpm/php-cgi/LiteSpeed o filho morre e o caminho de reuso e cortado.
sapi = perfil.sapi
if estado == versoes.CORRIGIDO:
resultado.achados.append(
Achado(
"Versao do PHP",
versoes.descricao(perfil.php_versao),
NIVEL_OK,
"advisory php.net (21/10/2026)",
)
)
elif estado in (versoes.VULNERAVEL, versoes.SEM_CORRECAO):
nivel_versao = NIVEL_CRITICO
if sapi in (SAPI_FPM, SAPI_LITESPEED):
nivel_versao = NIVEL_ALTO
elif sonda is not None and sonda.worker_morreu:
nivel_versao = NIVEL_ALTO
resultado.achados.append(
Achado(
"Versao do PHP",
versoes.descricao(perfil.php_versao),
nivel_versao,
"advisory php.net (21/10/2026)",
)
)
else:
resultado.achados.append(
Achado(
"Versao do PHP",
versoes.descricao(perfil.php_versao),
NIVEL_DESCONHECIDO,
"reconhecimento",
)
)
# 3) SAPI e matriz de sobrevivencia.
# A severidade da SAPI so pesa quando a versao ainda e vulneravel: em uma
# serie corrigida, o fator SAPI deixa de ser um problema. E se a sonda
# observou o worker do backend morrendo (502 do proxy), ja sabemos que a
# SAPI nao sobrevive ao OOM - o que e melhor que mod_php, e o achado
# reflete isso.
nivel_sapi = NIVEL_ALTO if estado in (versoes.VULNERAVEL, versoes.SEM_CORRECAO) else NIVEL_OK
sufixo_sapi = "" if nivel_sapi == NIVEL_ALTO else " (sem efeito em uma serie ja corrigida)"
if sonda is not None and sonda.worker_morreu:
nivel_sapi = NIVEL_MEDIO
sufixo_sapi = (
" (a sonda ativa observou o worker morrer no startup, o que confirma que esta "
"SAPI nao sobrevive ao OOM)"
)
if sapi == SAPI_MOD_PHP:
resultado.achados.append(
Achado(
"SAPI",
"mod_php (Apache prefork): e a SAPI com maior exposicao. O worker sobrevive ao "
"erro de memoria no startup, entao o estado corrompido pode ser reaproveitado na "
"requisicao seguinte." + sufixo_sapi,
nivel_sapi,
"matriz de SAPI",
)
)
elif sapi == SAPI_FPM:
resultado.achados.append(
Achado(
"SAPI",
"php-fpm: o filho sai quando o startup falha, o que impede o reaproveitamento do "
"estado corrompido pelo mesmo worker. Ainda ha superficie de DoS e de OOM." + sufixo_sapi,
NIVEL_MEDIO if nivel_sapi == NIVEL_ALTO else NIVEL_OK,
"matriz de SAPI",
)
)
elif sapi == SAPI_LITESPEED:
resultado.achados.append(
Achado(
"SAPI",
"LiteSpeed/LSAPI: modela o FPM, o filho morre no startup. Trate como FPM." + sufixo_sapi,
NIVEL_MEDIO if nivel_sapi == NIVEL_ALTO else NIVEL_OK,
"matriz de SAPI",
)
)
elif sapi == SAPI_CLI_SERVER:
resultado.achados.append(
Achado(
"SAPI",
"php -S (servidor embutido): sobrevive ao erro de startup, como o prefork." + sufixo_sapi,
nivel_sapi,
"matriz de SAPI",
)
)
else:
resultado.achados.append(
Achado(
"SAPI",
f"Nao foi possivel determinar a SAPI ({sapi}). Se for Apache com mod_php ou "
"FrankenPHP, trate como exposicao alta.",
NIVEL_DESCONHECIDO if estado in (versoes.VULNERAVEL, versoes.SEM_CORRECAO) else NIVEL_OK,
"reconhecimento",
)
)
# 4) Configuracao exposta
if perfil.exposes_phpinfo:
resultado.achados.append(
Achado(
"phpinfo publico",
"Ha uma pagina phpinfo acessivel: ela revela versao, SAPI e limites. "
"Isso e um problema por si so - bloqueie o acesso.",
NIVEL_MEDIO,
"reconhecimento",
)
)
upload = perfil.phpinfo.get("session.upload_progress.enabled", "").strip().lower()
if upload in ("off", "0", "false"):
resultado.achados.append(
Achado(
"session.upload_progress.enabled",
f"Valor exposto: {perfil.phpinfo.get('session.upload_progress.enabled')} "
"(desligado): o caminho de sessao das CVEs esta fechado.",
NIVEL_OK,
"phpinfo",
)
)
elif upload:
resultado.achados.append(
Achado(
"session.upload_progress.enabled",
f"Valor exposto: {perfil.phpinfo.get('session.upload_progress.enabled')} "
"(ligado): o caminho de sessao das CVEs esta aberto.",
NIVEL_ALTO,
"phpinfo",
)
)
memoria = perfil.phpinfo.get("memory_limit", "").strip()
if memoria and memoria not in ("-1",):
resultado.achados.append(
Achado(
"memory_limit",
f"Valor exposto: {memoria} (finito). O limite finito e programavel pelo "
"atacante e a base do CVE-2026-109618.",
NIVEL_ALTO,
"phpinfo",
)
)
# 5) Sonda ativa
if sonda is not None and sonda.executada:
# Em uma serie ja corrigida, reproduzir o OOM nao e exposicao as CVEs:
# o patch nao impede o estouro de memoria, ele impede que o estado
# corrompido seja reaproveitado. Entao o achado da sonda nao sobe a
# severidade final.
corrigido = estado == versoes.CORRIGIDO
if sonda.comportamento == COMPORTAMENTO_PATCH:
detalhe = (
"O servidor interrompeu a leitura do corpo no limite de post_max_size: "
"comportamento de versao com o patch aplicado, verificado por comportamento "
"e nao so pelo banner de versao."
)
if not corrigido:
detalhe += (
" A versao annoncée, porem, e anterior ao patch oficial: provavelmente e um "
"backport da distribuicao. Confirme com o fornecedor do pacote."
)
resultado.achados.append(
Achado("Sonda ativa", detalhe, NIVEL_OK, "sonda ativa")
)
elif sonda.exposicao_confirmada:
detalhe = (
"O caminho de OOM no startup da requisicao (antes do codigo da aplicacao) foi "
"reproduzido: o alvo aceita um multipart anonimo que estoura o memory_limit "
"durante o progresso de upload da sessao."
)
if corrigido:
detalhe += (
" Como a versao ja contem o patch, o OOM em si nao e exploravel: o patch "
"impede que o estado corrompido seja reaproveitado. Fica como alerta de "
"configuracao (memory_limit programavel)."
)
nivel_sonda = NIVEL_MEDIO
elif sonda.worker_morreu:
detalhe += (
" O worker morreu no startup, o que fecha o caminho de reuso do estado "
"corrompido: neste SAPI o impacto observavel e de disponibilidade (o worker "
"e recriado), nao de execucao remota."
)
nivel_sonda = NIVEL_ALTO
else:
nivel_sonda = NIVEL_CRITICO
resultado.achados.append(
Achado(
"Sonda ativa",
detalhe,
nivel_sonda,
"sonda ativa",
)
)
elif sonda.atribuicao == ATRIBUICAO_PARSER:
resultado.achados.append(
Achado(
"Sonda ativa",
"O alvo respondeu com erro de memoria no startup, mas o corpo de controle "
"tambem falhou: o OOM e do parser multipart em geral, nao do recurso de "
"progresso de upload.",
NIVEL_MEDIO,
"sonda ativa",
)
)
else:
resultado.achados.append(
Achado(
"Sonda ativa",
"Nao foi possivel reproduzir o OOM com o tamanho usado. Pode ser memory_limit "
"alto/-1, post_max_size pequeno, patch aplicado ou o endpoint escolhido nao "
"processa upload.",
NIVEL_BAIXO,
"sonda ativa",
)
)
for obs in sonda.observacoes:
resultado.achados.append(Achado("Detalhe da sonda", obs, NIVEL_INFO, "sonda ativa"))
# 6) Nivel final
resultado.nivel = _nivel_final(resultado, perfil, sonda)
# 7) Acoes
resultado.acoes = _acoes(resultado, perfil, sonda)
resultado.resumo = _resumo(resultado.nivel, perfil, sonda)
return resultado
def _nivel_final(resultado: ResultadoAlvo, perfil: Perfil, sonda: Optional[ResultadoSonda]) -> str:
"""Nivel final: o achado mais severo, com teto quando o patch esta presente."""
niveis = [a.nivel for a in resultado.achados if a.nivel != NIVEL_INFO]
if not niveis:
return NIVEL_DESCONHECIDO
nivel = max(niveis, key=lambda n: _RANK.get(n, 0))
# Comportamento de versao corrigida (verificado por sonda, nao por banner)
# reduz o teto: o patch impede o reaproveitamento do estado corrompido.
if sonda is not None and sonda.comportamento == COMPORTAMENTO_PATCH:
teto = NIVEL_MEDIO
if _RANK.get(nivel, 0) > _RANK.get(teto, 0):
nivel = teto
return nivel
def _resumo(nivel: str, perfil: Perfil, sonda: Optional[ResultadoSonda]) -> str:
estado = versoes.classificar(perfil.php_versao)
patch_comportamental = sonda is not None and sonda.comportamento == COMPORTAMENTO_PATCH
if nivel == NIVEL_CRITICO:
if sonda and sonda.exposicao_confirmada:
return "Exposicao confirmada: o alvo e vulneravel as CVEs de memoria do PHP."
return "Versao do PHP vulneravel as CVEs de memoria (CVE-2026-109618/109619)."
if nivel == NIVEL_ALTO:
if sonda is not None and sonda.worker_morreu:
return (
"O OOM pre-RINIT e alcancavel, mas o worker morre no startup (SAPI que nao "
"sobrevive): o impacto observavel e de disponibilidade, com o worker recriado."
)
return "Alto risco: combinacao de fatores que ampliam a explorabilidade."
if nivel == NIVEL_MEDIO:
if patch_comportamental:
return (
"O comportamento indica patch aplicado (o parser corta o corpo em "
"post_max_size), mas a configuracao ainda permite OOM controlado."
)
if estado == versoes.CORRIGIDO:
return (
"Sem exposicao as CVEs (versao corrigida), mas a configuracao permite OOM "
"controlado - vale endurecer os limites."
)
return "Risco medio: exposicao parcial, com fatores que limitam a explorabilidade."
if nivel == NIVEL_BAIXO:
if patch_comportamental:
return "Patch aplicado (verificado por comportamento) e sem exposicao identificada."
return "Risco baixo: nao foi possivel reproduzir o OOM com a sonda usada."
if nivel == NIVEL_OK:
return "Sem exposicao identificada."
return "Veredito inconclusivo: colete mais dados (phpinfo, versao, SAPI)."
def _acoes(resultado: ResultadoAlvo, perfil: Perfil, sonda: Optional[ResultadoSonda]) -> List[str]:
acoes: List[str] = []
estado = versoes.classificar(perfil.php_versao)
patch_comportamental = sonda is not None and sonda.comportamento == COMPORTAMENTO_PATCH
if patch_comportamental and estado in (versoes.VULNERAVEL, versoes.SEM_CORRECAO):
acoes.append(
"A versao anunciada e anterior ao patch oficial, mas o comportamento do parser "
"indica que o patch foi aplicado (provavelmente backport da distribuicao). "
"Confirme com o fornecedor do pacote e mantenha o PHP atualizado."
)
elif estado in (versoes.VULNERAVEL, versoes.SEM_CORRECAO):
acoes.append(
"Atualize o PHP para 8.2.35, 8.3.36, 8.4.27 ou 8.5.12 (ou superior). "
"Esta e a unica correcao completa."
)
acoes.append(
"Como imediato: session.upload_progress.enabled = Off no php.ini/pool FPM "
"(antes do parsing da requisicao) e reinicie todos os workers."
)
if perfil.sapi == SAPI_MOD_PHP:
acoes.append(
"Como imediato: defina memory_limit = -1 de forma administrativa "
"(php_admin_value no pool FPM ou no httpd.conf), de modo que a aplicacao "
"nao possa restaurar um limite finito."
)
acoes.append(
"Limite o tamanho do corpo decodado, a quantidade de campos, de partes multipart, "
"de cabecalhos por parte e de uploads no proxy/fronteira."
)
if perfil.exposes_phpinfo:
acoes.append("Remova ou proteja a pagina phpinfo publica.")
if not acoes:
acoes.append("Mantenha o PHP atualizado e monitorar os advisories do projeto PHP.")
return acoes
# --- apresentacao ----------------------------------------------------------
def _cor_nivel(nivel: str) -> str:
return _CORES_NIVEL.get(nivel, CINZA)(f"[{nivel}]")
def imprimir_alvo(resultado: ResultadoAlvo, verbose: bool = False) -> None:
"""Imprime o relatorio de um alvo no terminal."""
perfil = resultado.perfil
print()
print(NEGRITO(f"Alvo: {resultado.url}"))
print(f" {_cor_nivel(resultado.nivel)} {resultado.resumo}")
if perfil.erro:
print(f" {CINZA('Erro:')} {perfil.erro}")
return
print(f" {CINZA('Servidor:')} {perfil.servidor or '(nao informado)'}")
print(f" {CINZA('X-Powered-By:')} {perfil.sapicabecalho or '(nao informado)'}")
print(f" {CINZA('PHP:')} {perfil.php_versao_bruta or '(versao nao determinada)'}")
print(f" {CINZA('SAPI:')} {perfil.sapi}")
if perfil.phpinfo:
print(f" {CINZA('phpinfo:')} exposto em {len(perfil.phpinfo)} opcoes relevantes")
for obs in perfil.observacoes:
print(f" {CINZA('*')} {obs}")
if resultado.achados:
print()
print(NEGRITO(" Achados:"))
for achado in resultado.achados:
print(f" {AZUL('->')} {NEGRITO(achado.titulo)}: {achado.detalhe}")
if verbose and achado.fonte:
print(f" {CINZA('fonte:')} {achado.fonte}")
if resultado.acoes:
print()
print(NEGRITO(" O que fazer:"))
for i, acao in enumerate(resultado.acoes, 1):
print(f" {i}. {acao}")
def imprimir_tabela(resultados: List[ResultadoAlvo]) -> None:
"""Imprime um resumo tabular de varios alvos."""
if not resultados:
return
print()
print(NEGRITO("Resumo"))
cabecalho = f" {'ALVO':<44} {'NIVEL':<13} {'PHP':<12} SAPI"
print(cabecalho)
print(" " + "-" * (len(cabecalho) - 2))
for r in resultados:
alvo = r.url if len(r.url) <= 44 else r.url[:41] + "..."
php = r.perfil.php_versao_bruta or "?"
print(
f" {alvo:<44} {_cor_nivel(r.nivel):<13} {php:<12} {r.perfil.sapi}"
)
def para_json(resultados: List[ResultadoAlvo]) -> str:
return json.dumps(
{
"ferramenta": "phpupscan",
"cves": ["CVE-2026-109618", "CVE-2026-109619"],
"alvos": [r.para_dict() for r in resultados],
},
indent=2,
ensure_ascii=False,
)
+523
View File
@@ -0,0 +1,523 @@
"""Sondas ativas (controladas) para confirmar a exposicao as CVEs.
Tres niveis de sonda, todos com o mesmo formato de resultado:
**Leve** (padrao, ~1,3 MB de nome de arquivo, `Content-Length`)
Reproduz o "ARM" do bundle oficial: um POST multipart anonimo com um nome
de arquivo enorme. O PHP guarda esse nome na arvore de progresso de upload
da sessao e a serializa durante o startup da requisicao, antes do RINIT.
Com `memory_limit` finito, isso estoura o limite e o PHP aborta a
requisicao ainda no pre-RINIT - que e a janela em que o estado interno
fica invalido (CVE-2026-109618) e em que o contexto de serializacao fica
orfao (CVE-2026-109619).
**Forte** (`Transfer-Encoding: chunked`, corpo grande)
O PHP checa `post_max_size` contra o `Content-Length` no inicio do parsing.
Sem `Content-Length`, um corpo maior que `post_max_size` continua sendo
entregue - vetor descrito no advisory oficial. As versoes corrigidas
(8.2.35, 8.3.36, 8.4.27, 8.5.12) interrompem a leitura em
`post_max_size`; as antigas nao. Isso da uma **verificacao
comportamental do patch**, que vale mais que o banner de versao: uma
distribuicao pode ter backportado o patch sem trocar a versao.
**Fingerprint de patch** (mesma requests, sem OOM)
Uma request pequena, sem pressao de memoria, tambem revela o patch: nas
versoes corrigidas o parser corta o corpo em `post_max_size` e avisa
"Actual POST length does not match Content-Length". Essa mensagem nao
existe nas versoes antigas.
Atribuicao: cada sonda vem acompanhada de um corpo de controle identico, sem o
campo `PHP_SESSION_UPLOAD_PROGRESS`. Se so o corpo com o campo falha, o OOM e
do caminho de progresso de upload de sessao. Se os dois falham, o OOM e do
parser multipart em geral.
"""
from __future__ import annotations
import re
import secrets
import time
from dataclasses import dataclass, field
from typing import List, Optional, Tuple
from .httpclient import Conexao, Resposta
#: "Allowed memory size of 134217728 bytes exhausted" -> limite em bytes.
RE_OOM = re.compile(r"Allowed memory size of (\d+) bytes exhausted", re.IGNORECASE)
#: Fatal de memoria em contexto de startup (antes de qualquer codigo da app).
RE_PRE_RINIT = re.compile(r"on line <b>0</b>|in Unknown on line 0|line 0", re.IGNORECASE)
RE_STARTUP = re.compile(r"PHP Request Startup", re.IGNORECASE)
#: Assinatura de OOM em qualquer posicao do corpo.
LIMITE_ERRO = re.compile(r"exhausted|Fatal error|memory size", re.IGNORECASE)
#: Mensagem que so existe em versoes com o patch: o parser interrompe a leitura
#: quando o corpo entregue ultrapassa `post_max_size`.
RE_POST_LIMITE = re.compile(
r"Actual POST length does not match Content-Length, and exceeds (\d+) bytes", re.IGNORECASE
)
#: Tamanho padrao do nome de arquivo da sonda leve.
TAMANHO_LEVE = 1_300_000
#: Tamanho padrao do nome de arquivo da sonda forte (chunked).
TAMANHO_FORTE = 30_000_000
#: Teto do nome de arquivo nas sondas, para nao estourar a banda do alvo.
TAMANHO_MAXIMO = 64_000_000
#: Atribuicao do OOM.
ATRIBUICAO_SESSAO = "sessao"
ATRIBUICAO_PARSER = "parser"
ATRIBUICAO_NENHUMA = "nenhuma"
#: Comportamento observado em relacao ao patch.
COMPORTAMENTO_VULNERAVEL = "vulneravel"
COMPORTAMENTO_PATCH = "patch"
COMPORTAMENTO_INCONCLUSIVO = "inconclusivo"
@dataclass
class ResultadoSonda:
"""Resultado de uma sondagem ativa contra um alvo."""
executada: bool = False
modo: str = "leve"
erro: str = ""
bytes_enviados: int = 0
status_arm: Optional[int] = None
status_controle: Optional[int] = None
oom_arm: bool = False
oom_controle: bool = False
assinatura_memoria: bool = False
oom_limite_bytes: Optional[int] = None
oom_pre_rinit: bool = False
corpo_arm: str = ""
corpo_controle: str = ""
post_max_size_bytes: Optional[int] = None
atribuicao: str = ATRIBUICAO_NENHUMA
#: True quando o worker do backend morreu no startup (502 do proxy):
#: SAPI que nao sobrevive ao OOM (php-fpm, php-cgi, LiteSpeed).
worker_morreu: bool = False
#: True quando o backend abortou a requisicao e o proxy devolveu 200 com
#: o corpo vazio (comportamento tipico do php-fpm atras de nginx).
backend_abortou: bool = False
#: True quando o OOM so acontece com o campo de progresso de upload;
#: False quando o corpo de controle tambem falha; None quando nao houve OOM.
upload_progress_ativo: Optional[bool] = None
comportamento: Optional[str] = None
follow_up_status: Optional[int] = None
follow_up_corpo: str = ""
tempo_total: float = 0.0
observacoes: List[str] = field(default_factory=list)
@property
def exposicao_confirmada(self) -> bool:
"""O caminho de OOM pre-RINIT e alcancavel e atribuido a sessao.
Exige atribuicao positiva: o corpo com o campo de progresso falhou e o
corpo de controle (mesmo peso, sem o campo) respondeu normalmente.
"""
return self.oom_arm and self.upload_progress_ativo is True
def resumo(self) -> str:
if not self.executada:
return "nao executada"
if self.erro:
return f"erro: {self.erro}"
if self.comportamento == COMPORTAMENTO_PATCH:
return "comportamento de versao corrigida: o parser cortou o corpo em post_max_size"
if not self.oom_arm:
if self.modo == "forte":
return (
"sem OOM com corpo acima de post_max_size: o parser nao entregou o corpo "
"inteiro (comportamento de versao corrigida ou limite nao cruzado)"
)
return "sem OOM reproduzivel (limite de memoria nao atingido ou patch aplicado)"
limite = f"{self.oom_limite_bytes // (1024 * 1024)}M" if self.oom_limite_bytes else "?"
if self.atribuicao == ATRIBUICAO_SESSAO:
return f"OOM pre-RINIT confirmado no caminho de sessao (memory_limit ~{limite})"
if self.atribuicao == ATRIBUICAO_PARSER:
return f"OOM pre-RINIT confirmado no parser multipart (memory_limit ~{limite})"
return f"falha de memoria no startup (~{limite})"
def gerar_sid() -> str:
"""SID valido (alfanumerico, 32 caracteres) para a sonda."""
return "u" + secrets.token_hex(15)
def tamanho_sugerido(
memory_limit_bytes: Optional[int],
post_max_size_bytes: Optional[int] = None,
forte: bool = False,
) -> int:
"""Tamanho do nome de arquivo que a sonda deve usar.
O nome do arquivo e guardado varias vezes no ciclo de parsing e de
serializacao da sessao (arvore de progresso, copia do parser, copia
serializada), o que multiplica o consumo por um fator de 3 a 5.
Sonda leve (`Content-Length`): o corpo precisa caber em `post_max_size`,
senao o PHP recusa a requisicao antes de parsear. Por isso o tamanho e
limitado por `post_max_size`.
Sonda forte (chunked): nao passa pela checagem de `post_max_size`, entao
pode usar um nome grande o bastante para cruzar o `memory_limit`. Para que
a diferenca de comportamento apareca, o corpo precisa ser **maior** que
`post_max_size`: a versao corrigida interrompe a leitura ali, a antiga
continua lendo e estoura a memoria. Isso so e observavel quando o
`memory_limit` e bem maior que o `post_max_size` (configuracao padrao:
128M contra 8M); caso contrario as duas versoes estouram a memoria antes
de chegar ao limite de leitura.
"""
if forte:
if post_max_size_bytes and post_max_size_bytes > 0:
alvo = int(post_max_size_bytes * 1.2)
else:
alvo = TAMANHO_FORTE
if memory_limit_bytes and memory_limit_bytes > 0:
alvo = max(alvo, int(memory_limit_bytes / 4))
return max(TAMANHO_FORTE, min(alvo, TAMANHO_MAXIMO))
if not memory_limit_bytes or memory_limit_bytes <= 0:
return TAMANHO_LEVE
sugerido = int(memory_limit_bytes / 4)
if post_max_size_bytes and post_max_size_bytes > 0:
# O corpo inteiro (nome + partes) precisa caber em post_max_size, senao
# o PHP recusa a requisicao antes de parsear. O teto e a metade do
# limite, para sobrar espaco para o resto do multipart.
teto = post_max_size_bytes // 2
sugerido = min(sugerido, teto)
return max(min(TAMANHO_LEVE, teto), min(sugerido, teto))
return max(TAMANHO_LEVE, sugerido)
def _parte(
nome: str,
valor: bytes,
boundary: bytes,
filename: Optional[bytes] = None,
) -> bytes:
disp = b'Content-Disposition: form-data; name="' + nome.encode() + b'"'
if filename is not None:
disp += b'; filename="' + filename + b'"'
return b"--" + boundary + b"\r\n" + disp + b"\r\n\r\n" + valor + b"\r\n"
def construir_multipart(
sid: str,
tamanho_nome: int,
boundary: bytes = b"----phpupscanBoundary",
com_progresso: bool = True,
) -> bytes:
"""Monta o corpo multipart da sonda.
`tamanho_nome` e o tamanho do nome do arquivo enviado - e ele que cresce a
arvore de progresso de upload dentro da sessao. `com_progresso=False`
produz o corpo de controle (mesmo peso, sem o campo que liga o recurso).
"""
corpo = _parte("PHPSESSID", sid.encode(), boundary)
if com_progresso:
corpo += _parte("PHP_SESSION_UPLOAD_PROGRESS", b"x", boundary)
nome_longo = b"n" * max(1, tamanho_nome)
corpo += _parte("arquivo", b"A" * 1024, boundary, filename=nome_longo)
return corpo + b"--" + boundary + b"--\r\n"
def _analisar_resposta(resp: Resposta) -> Tuple[bool, bool, Optional[int], bool, Optional[int], str]:
"""Extrai o que interessa de uma resposta.
Retorna (assinatura_memoria, erro_5xx, limite_em_bytes, pre_rinit,
post_max_size_declarado, corpo_texto).
"""
texto = resp.texto
m = RE_OOM.search(texto)
limite = int(m.group(1)) if m else None
assinatura = bool(m) or bool(LIMITE_ERRO.search(texto))
erro_5xx = resp.status is not None and resp.status >= 500
pre_rinit = bool(RE_PRE_RINIT.search(texto)) or bool(RE_STARTUP.search(texto))
m_post = RE_POST_LIMITE.search(texto)
post_max = int(m_post.group(1)) if m_post else None
return assinatura, erro_5xx, limite, pre_rinit, post_max, texto
def _worker_morreu(status: Optional[int]) -> bool:
"""True quando o status indica que o worker do backend morreu.
Em php-fpm, php-cgi e LSAPI, o filho sai quando o startup da requisicao
falha. O proxy na frente (nginx, Apache) responde 502 nesse caso. E o
sinal de que a SAPI nao sobrevive ao OOM de startup - o caminho de reuso
do estado corrompido fica fechado, mas sobra superficie de DoS.
"""
return status in (502, 503, 504)
def combinar(leve: Optional[ResultadoSonda], forte: Optional[ResultadoSonda]) -> Optional[ResultadoSonda]:
"""Une os resultados de duas sondas em um veredito unico.
A sonda forte e a mais informativa quando ela reproduz o OOM (ela entrega um
corpo maior que `post_max_size`); a leve e a mais barata. A forte tambem
carrega o veredito comportamental do patch, que vale mais que o banner de
versao.
"""
if leve is None:
return forte
if forte is None:
return leve
resultado = leve
resultado.modo = "leve+forte"
resultado.bytes_enviados = leve.bytes_enviados + forte.bytes_enviados
resultado.observacoes = list(leve.observacoes) + [f"[forte] {o}" for o in forte.observacoes]
resultado.tempo_total = leve.tempo_total + forte.tempo_total
if forte.oom_arm:
# A sonda forte reproduziu o OOM: ela traz a evidencia mais forte.
resultado.oom_arm = True
resultado.assinatura_memoria = forte.assinatura_memoria
resultado.oom_limite_bytes = forte.oom_limite_bytes or leve.oom_limite_bytes
resultado.oom_pre_rinit = forte.oom_pre_rinit
resultado.status_arm = forte.status_arm
resultado.status_controle = forte.status_controle
resultado.oom_controle = forte.oom_controle
resultado.atribuicao = forte.atribuicao
resultado.upload_progress_ativo = forte.upload_progress_ativo
resultado.follow_up_status = forte.follow_up_status
resultado.corpo_arm = forte.corpo_arm or leve.corpo_arm
# Observacoes da sonda leve que aforte desmente: nao ficam no relatorio.
resultado.observacoes = [
o
for o in resultado.observacoes
if "Nenhum dos dois corpos provocou erro" not in o
]
if forte.post_max_size_bytes:
resultado.post_max_size_bytes = forte.post_max_size_bytes
if forte.comportamento is not None:
resultado.comportamento = forte.comportamento
if forte.comportamento == COMPORTAMENTO_PATCH:
# A sonda leve pode ter acusado OOM mesmo numa versao corrigida; a
# informacao do patch tem precedencia.
resultado.observacoes = [
o
for o in resultado.observacoes
if "Nenhum dos dois corpos provocou erro" not in o
and "respondeu erro de memoria durante o startup" not in o
]
return resultado
def sondar(
url: str,
timeout: float = 30.0,
tamanho_nome: int = TAMANHO_LEVE,
insecure: bool = False,
fazer_controle: bool = True,
fazer_follow_up: bool = True,
forte: bool = False,
) -> ResultadoSonda:
"""Executa uma sondagem ativa contra uma URL de endpoint PHP.
`forte=True` usa `Transfer-Encoding: chunked` (sem `Content-Length`), que
passa da checagem inicial de `post_max_size`.
"""
from urllib.parse import urlparse
resultado = ResultadoSonda(executada=True, modo="forte" if forte else "leve")
inicio = time.time()
partes = urlparse(url)
if not partes.hostname:
resultado.erro = "URL sem hostname"
return resultado
porta = partes.port or (443 if partes.scheme == "https" else 80)
caminho = partes.path or "/"
boundary = b"----phpupscanBoundary" + secrets.token_hex(6).encode()
def nova_conexao() -> Conexao:
return Conexao(
partes.hostname or "",
porta,
tls=partes.scheme == "https",
timeout=timeout,
verificar_certificado=not insecure,
)
def cabecalhos_de(sid: str) -> dict:
return {
"Content-Type": f"multipart/form-data; boundary={boundary.decode()}",
"Cookie": f"PHPSESSID={sid}",
}
sid = gerar_sid()
corpo_arm = construir_multipart(sid, tamanho_nome, boundary, com_progresso=True)
resultado.bytes_enviados = len(corpo_arm)
assinatura_arm = False
erro5xx_arm = False
conexao = None
try:
conexao = nova_conexao()
resp = conexao.requisitar(
"POST",
caminho,
corpo=corpo_arm,
cabecalhos=cabecalhos_de(sid),
chunked=forte,
)
resultado.status_arm = resp.status
assinatura_arm, erro5xx_arm, limite, pre_rinit, post_max, texto = _analisar_resposta(resp)
resultado.assinatura_memoria = assinatura_arm
resultado.oom_limite_bytes = limite
resultado.oom_pre_rinit = pre_rinit
resultado.post_max_size_bytes = post_max
resultado.corpo_arm = texto[:2000]
if assinatura_arm:
resultado.observacoes.append(
"O servidor respondeu erro de memoria durante o startup da requisicao "
"(antes de qualquer codigo da aplicacao)."
)
if limite:
resultado.observacoes.append(
f"O servidor informou memory_limit de aproximadamente {limite} bytes "
f"({limite // (1024 * 1024)}M) - limite finito e programavel."
)
if post_max:
resultado.observacoes.append(
f"O servidor cortou a leitura do corpo em post_max_size ({post_max} bytes): "
"comportamento de versao com o patch aplicado."
)
resultado.comportamento = COMPORTAMENTO_PATCH
if _worker_morreu(resp.status):
resultado.worker_morreu = True
resultado.observacoes.append(
f"O proxy respondeu {resp.status}: o worker do backend morreu durante o startup "
"da requisicao. Isso e o comportamento de SAPI que nao sobrevive ao OOM "
"(php-fpm, php-cgi, LiteSpeed): o caminho de reuso do estado corrompido fica "
"fechado, mas sobra superficie de DoS."
)
if fazer_follow_up:
# Mesma conexao (mesmo worker em prefork): verifica se o estado
# corrompido do worker foi reaproveitado. Se o servidor ja
# encerrou a conexao (HTTP/1.0 ou Connection: close), o follow-up
# nao diz nada sobre o estado do worker.
if resp.servidor_fechou or (resp.headers.get("connection", "").lower() == "close"):
resultado.observacoes.append(
"O servidor encerrou a conexao depois da falha, entao nao foi possivel "
"avaliar o estado do worker com uma requisicao seguinte."
)
else:
resp2 = conexao.requisitar("GET", caminho, cabecalhos=cabecalhos_de(sid))
resultado.follow_up_status = resp2.status
resultado.follow_up_corpo = resp2.texto[:1000]
if resp2.erro:
resultado.observacoes.append(
f"A requisicao seguinte na mesma conexao falhou ({resp2.erro}): "
"sinal tipico de worker em estado invalido."
)
elif resp2.status is None or resp2.status >= 500:
resultado.observacoes.append(
"A requisicao seguinte na mesma conexao falhou: sinal tipico de worker "
"em estado invalido apos o OOM."
)
elif assinatura_arm or erro5xx_arm:
resultado.observacoes.append(
"A requisicao seguinte na mesma conexao respondeu normalmente: o worker "
"continua atendendo apos o OOM."
)
except OSError as exc:
resultado.erro = f"conexao: {exc}"
return resultado
finally:
if conexao is not None:
conexao.fechar()
assinatura_ctrl = False
erro5xx_ctrl = False
corpo_controle = ""
if fazer_controle:
sid_controle = gerar_sid()
corpo_controle = construir_multipart(
sid_controle, tamanho_nome, boundary, com_progresso=False
)
try:
with nova_conexao() as con:
resp3 = con.requisitar(
"POST",
caminho,
corpo=corpo_controle,
cabecalhos=cabecalhos_de(sid_controle),
chunked=forte,
)
resultado.status_controle = resp3.status
(
assinatura_ctrl,
erro5xx_ctrl,
_limite_c,
_pre_c,
post_max_c,
texto_c,
) = _analisar_resposta(resp3)
resultado.corpo_controle = texto_c[:1000]
if post_max_c and resultado.post_max_size_bytes is None:
resultado.post_max_size_bytes = post_max_c
except OSError as exc:
resultado.erro = f"controle: {exc}"
# O backend pode abortar a requisicao sem que o proxy traduza isso em 5xx:
# com php-fpm, o filho morre e o nginx devolve 200 com o corpo vazio. O sinal
# e a diferenca entre o corpo da sonda (vazio) e o do controle (com conteudo).
if (
not resultado.erro
and resultado.status_arm == 200
and resultado.status_controle == 200
and not resultado.corpo_arm.strip()
and resultado.corpo_controle.strip()
):
resultado.backend_abortou = True
resultado.worker_morreu = True
resultado.observacoes = [
o for o in resultado.observacoes if "Nenhum dos dois corpos" not in o
]
resultado.observacoes.append(
"A requisicao com o campo de progresso voltou com o corpo vazio, enquanto o "
"controle voltou com conteudo: o backend abortou a requisicao no startup, que e "
"o que acontece quando o worker morre (php-fpm, php-cgi, LiteSpeed)."
)
if not resultado.oom_arm:
resultado.oom_arm = True
resultado.atribuicao = ATRIBUICAO_SESSAO
resultado.upload_progress_ativo = True
resultado.comportamento = COMPORTAMENTO_VULNERAVEL
# --- atribuicao -------------------------------------------------------
if resultado.erro or resultado.backend_abortou:
pass
elif assinatura_ctrl or erro5xx_ctrl:
# O controle tambem falhou: o OOM nao e do recurso de progresso de upload.
resultado.oom_controle = True
resultado.atribuicao = ATRIBUICAO_PARSER
resultado.upload_progress_ativo = False
resultado.observacoes.append(
"O corpo de controle (sem o campo PHP_SESSION_UPLOAD_PROGRESS) tambem falhou: "
"o OOM e do parser multipart em geral, nao do recurso de progresso de upload."
)
elif assinatura_arm or erro5xx_arm:
resultado.oom_arm = True
resultado.atribuicao = ATRIBUICAO_SESSAO
resultado.upload_progress_ativo = True
if resultado.comportamento != COMPORTAMENTO_PATCH:
resultado.comportamento = COMPORTAMENTO_VULNERAVEL
resultado.observacoes.append(
"O corpo de controle, sem o campo PHP_SESSION_UPLOAD_PROGRESS, respondeu "
"normalmente: o OOM vem do caminho de progresso de upload de sessao."
)
else:
resultado.observacoes.append(
"Nenhum dos dois corpos provocou erro: o tamanho usado nao cruza o limite de "
"memoria do alvo."
)
if resultado.comportamento is None and forte:
resultado.comportamento = COMPORTAMENTO_INCONCLUSIVO
resultado.tempo_total = time.time() - inicio
return resultado
+4
View File
@@ -0,0 +1,4 @@
"""Testes do phpupscan (sem dependencias externas).
Rodar: python3 -m unittest discover -s testes -v
"""
+94
View File
@@ -0,0 +1,94 @@
"""Testes da auditoria de configuracao local."""
import os
import tempfile
import unittest
from phpupscan.config_audit import auditar
class AuditoriaDeConfig(unittest.TestCase):
def setUp(self):
self.dir = tempfile.mkdtemp(prefix="phpupscan-teste-")
def tearDown(self):
for nome in os.listdir(self.dir):
os.unlink(os.path.join(self.dir, nome))
os.rmdir(self.dir)
def _escrever(self, nome, conteudo):
caminho = os.path.join(self.dir, nome)
with open(caminho, "w", encoding="utf-8") as fh:
fh.write(conteudo)
return caminho
def test_php_ini_com_progresso_ligado(self):
caminho = self._escrever(
"php.ini",
"[PHP]\nmemory_limit = 128M\npost_max_size = 8M\n"
"session.upload_progress.enabled = On\n",
)
rel = auditar([caminho])
problemas = " ".join(rel.problemas)
self.assertIn("session.upload_progress.enabled", problemas)
self.assertTrue(any("session.upload_progress.enabled" in r for r in rel.recomendacoes))
def test_php_ini_com_progresso_desligado(self):
caminho = self._escrever(
"php.ini", "[PHP]\nsession.upload_progress.enabled = Off\nmemory_limit = -1\n"
)
rel = auditar([caminho])
self.assertFalse(any("caminho de sessao das CVEs ativo" in p for p in rel.problemas))
self.assertTrue(any("memory_limit = -1" in r for r in rel.recomendacoes))
def test_user_ini_nao_e_confiavel(self):
caminho = self._escrever(".user.ini", "session.upload_progress.enabled = Off\n")
rel = auditar([caminho])
problemas = " ".join(rel.problemas)
self.assertIn(".user.ini", problemas)
self.assertIn("nao considera .user.ini um local confiavel", problemas)
def test_htaccess_e_lido(self):
caminho = self._escrever(
".htaccess", "php_value memory_limit 64M\nphp_flag session.upload_progress.enabled On\n"
)
rel = auditar([caminho])
self.assertEqual(len(rel.linhas_de("memory_limit")), 1)
self.assertEqual(rel.linhas_de("memory_limit")[0].valor, "64M")
self.assertEqual(len(rel.linhas_de("session.upload_progress.enabled")), 1)
def test_pool_fpm_e_lido(self):
caminho = self._escrever(
"www.conf", "[www]\nphp_admin_value[memory_limit] = 8M\nphp_admin_value[post_max_size] = 8M\n"
)
rel = auditar([caminho])
self.assertEqual(rel.valor_efetivo("memory_limit").valor, "8M")
self.assertEqual(rel.valor_efetivo("memory_limit").origem, "pool FPM")
def test_precedencia_pool_vence_php_ini(self):
ini = self._escrever("php.ini", "[PHP]\nmemory_limit = 128M\n")
pool = self._escrever("www.conf", "[www]\nphp_admin_value[memory_limit] = 8M\n")
rel = auditar([ini, pool])
self.assertEqual(rel.valor_efetivo("memory_limit").valor, "8M")
def test_upload_maior_que_post_max_size(self):
caminho = self._escrever(
"php.ini", "[PHP]\npost_max_size = 8M\nupload_max_filesize = 32M\n"
)
rel = auditar([caminho])
self.assertTrue(any("upload_max_filesize" in p for p in rel.problemas))
def test_valores_conflitantes_geram_aviso(self):
ini = self._escrever("php.ini", "[PHP]\nmemory_limit = 128M\n")
ht = self._escrever(".htaccess", "php_value memory_limit 64M\n")
rel = auditar([ini, ht])
self.assertTrue(any("conflitantes" in a for a in rel.avisos))
def test_ausencia_de_memory_limit_e_sinalizada(self):
caminho = self._escrever("php.ini", "[PHP]\nmax_input_vars = 1000\n")
rel = auditar([caminho])
self.assertTrue(any("memory_limit nao encontrado" in p for p in rel.problemas))
if __name__ == "__main__":
unittest.main()
+130
View File
@@ -0,0 +1,130 @@
"""Testes do relatorio: nivel de risco, veredito e acoes."""
import unittest
from phpupscan.detectar import Perfil, SAPI_FPM, SAPI_MOD_PHP
from phpupscan.relatorio import (
NIVEL_ALTO,
NIVEL_BAIXO,
NIVEL_CRITICO,
NIVEL_MEDIO,
NIVEL_OK,
_avaliar,
)
from phpupscan.sondas import (
ATRIBUICAO_PARSER,
ATRIBUICAO_SESSAO,
COMPORTAMENTO_PATCH,
COMPORTAMENTO_VULNERAVEL,
ResultadoSonda,
)
def perfil(versao=(8, 2, 34), sapi=SAPI_MOD_PHP, phpinfo=None) -> Perfil:
p = Perfil(url="http://exemplo/index.php", acessivel=True, status=200)
p.php_versao = versao
p.php_versao_bruta = ".".join(str(x) for x in versao) if versao else ""
p.sapi = sapi
if phpinfo:
p.phpinfo = phpinfo
p.exposes_phpinfo = True
return p
class VereditoPorVersao(unittest.TestCase):
def test_versao_vulneravel_e_critica(self):
r = _avaliar(perfil(), None)
self.assertEqual(r.nivel, NIVEL_CRITICO)
def test_versao_corrigida_fica_ok(self):
r = _avaliar(perfil((8, 2, 35)), None)
self.assertEqual(r.nivel, NIVEL_OK)
def test_sapi_fpm_reduz_para_alto(self):
# Em php-fpm o filho morre no startup, o que corta o caminho de reuso do
# estado corrompido: o veredito nao chega a critico.
r = _avaliar(perfil(sapi=SAPI_FPM), None)
self.assertEqual(r.nivel, NIVEL_ALTO)
achado_sapi = [a for a in r.achados if a.titulo == "SAPI"][0]
self.assertIn("php-fpm", achado_sapi.detalhe)
def test_phpinfo_com_upload_progress_ligado(self):
r = _avaliar(perfil(phpinfo={"session.upload_progress.enabled": "On"}), None)
self.assertIn(r.nivel, (NIVEL_ALTO, NIVEL_CRITICO))
def test_phpinfo_com_upload_progress_desligado(self):
r = _avaliar(
perfil((8, 2, 35), phpinfo={"session.upload_progress.enabled": "Off"}), None
)
# Sem exposicao as CVEs; o phpinfo publico segue sendo um achado menor.
self.assertNotEqual(r.nivel, NIVEL_CRITICO)
self.assertNotEqual(r.nivel, NIVEL_ALTO)
class VereditoPorSonda(unittest.TestCase):
def _sonda(self, **kwargs):
base = dict(
executada=True,
oom_arm=True,
upload_progress_ativo=True,
atribuicao=ATRIBUICAO_SESSAO,
comportamento=COMPORTAMENTO_VULNERAVEL,
)
base.update(kwargs)
return ResultadoSonda(**base)
def test_exposicao_confirmada_e_critica(self):
r = _avaliar(perfil(), self._sonda())
self.assertEqual(r.nivel, NIVEL_CRITICO)
self.assertIn("Exposicao confirmada", r.resumo)
def test_versao_corrigida_com_oom_nao_sobe_para_critico(self):
r = _avaliar(perfil((8, 2, 35)), self._sonda())
self.assertNotEqual(r.nivel, NIVEL_CRITICO)
def test_oom_do_parser_nao_e_atribuido_a_sessao(self):
# O OOM do parser em geral nao prova o caminho de sessao: o achado da
# sonda fica em nivel medio, e a exposicao as CVEs nao e confirmada.
sonda = self._sonda(
upload_progress_ativo=False, atribuicao=ATRIBUICAO_PARSER
)
r = _avaliar(perfil((8, 2, 35)), sonda)
achado = [a for a in r.achados if a.titulo == "Sonda ativa"][0]
self.assertEqual(achado.nivel, NIVEL_MEDIO)
self.assertFalse(sonda.exposicao_confirmada)
def test_comportamento_de_patch_limita_o_nivel(self):
r = _avaliar(
perfil(),
self._sonda(comportamento=COMPORTAMENTO_PATCH, oom_arm=False, upload_progress_ativo=None),
)
self.assertNotEqual(r.nivel, NIVEL_CRITICO)
def test_sonda_sem_oom_fica_baixa(self):
r = _avaliar(
perfil((8, 2, 35)),
ResultadoSonda(executada=True, oom_arm=False, upload_progress_ativo=None),
)
self.assertEqual(r.nivel, NIVEL_BAIXO)
class Acoes(unittest.TestCase):
def test_versao_vulneravel_recomenda_atualizar(self):
r = _avaliar(perfil(), None)
self.assertTrue(any("Atualize o PHP" in a for a in r.acoes))
def test_versao_corrigida_nao_recomenda_atualizar(self):
r = _avaliar(perfil((8, 2, 35)), None)
self.assertFalse(any("Atualize o PHP" in a for a in r.acoes))
def test_sempre_recomenda_desligar_upload_progress(self):
r = _avaliar(perfil(), None)
self.assertTrue(any("session.upload_progress.enabled = Off" in a for a in r.acoes))
def test_phpinfo_publico_e_recomendado(self):
r = _avaliar(perfil(phpinfo={"memory_limit": "128M"}), None)
self.assertTrue(any("phpinfo" in a for a in r.acoes))
if __name__ == "__main__":
unittest.main()
+114
View File
@@ -0,0 +1,114 @@
"""Testes das sondas ativas (construcao de corpo, tamanhos e analise)."""
import unittest
from phpupscan.sondas import (
ATRIBUICAO_NENHUMA,
ATRIBUICAO_PARSER,
ATRIBUICAO_SESSAO,
COMPORTAMENTO_PATCH,
COMPORTAMENTO_VULNERAVEL,
TAMANHO_LEVE,
construir_multipart,
tamanho_sugerido,
)
from phpupscan.httpclient import Resposta
class ConstrucaoDeCorpo(unittest.TestCase):
def test_campo_de_progresso_presente(self):
corpo = construir_multipart("u123", 1000, com_progresso=True)
self.assertIn(b"PHP_SESSION_UPLOAD_PROGRESS", corpo)
def test_corpo_de_controle_nao_tem_o_campo(self):
corpo = construir_multipart("u123", 1000, com_progresso=False)
self.assertNotIn(b"PHP_SESSION_UPLOAD_PROGRESS", corpo)
def test_controle_tem_o_mesmo_peso_do_campo_com_progresso(self):
com = construir_multipart("u123", 1000, com_progresso=True)
sem = construir_multipart("u123", 1000, com_progresso=False)
# A diferenca deve ser apenas o campo de progresso.
self.assertLess(abs(len(com) - len(sem)), 100)
def test_nome_do_arquivo_cresce_com_o_tamanho(self):
pequeno = construir_multipart("u123", 1000)
grande = construir_multipart("u123", 50000)
self.assertGreater(len(grande), len(pequeno))
self.assertIn(b"n" * 1000, grande)
def test_sid_e_incluido(self):
corpo = construir_multipart("uabc123", 1000)
self.assertIn(b"uabc123", corpo)
def test_limite_do_corpo(self):
corpo = construir_multipart("u123", 2000)
self.assertTrue(corpo.endswith(b"--\r\n"))
class TamanhoSugerido(unittest.TestCase):
def test_sem_limite_conhecido_usa_padrao(self):
self.assertEqual(tamanho_sugerido(None), TAMANHO_LEVE)
def test_limite_de_8m_sugere_nome_pequeno(self):
self.assertLess(tamanho_sugerido(8 * 1024 * 1024), 4 * 1024 * 1024)
def test_limite_respeita_post_max_size(self):
# post_max_size pequeno impede um nome grande na sonda com Content-Length.
tamanho = tamanho_sugerido(128 * 1024 * 1024, 1024 * 1024)
self.assertLessEqual(tamanho, 1024 * 1024)
def test_sonda_forte_ignora_post_max_size(self):
# Na sonda chunked o corpo pode passar de post_max_size.
tamanho = tamanho_sugerido(128 * 1024 * 1024, 1024 * 1024, forte=True)
self.assertGreater(tamanho, 1024 * 1024)
class AnaliseDeResposta(unittest.TestCase):
"""A analise de resposta acontece dentro de `sondar`; aqui testamos o
comportamento observavel via a funcao interna."""
def _analisar(self, texto, status=200):
from phpupscan.sondas import _analisar_resposta
return _analisar_resposta(Resposta(status=status, body=texto.encode()))
def test_assinatura_de_memoria(self):
assinatura, erro5xx, limite, pre_rinit, post_max, _ = self._analisar(
"Allowed memory size of 8388608 bytes exhausted (tried to allocate 1302528 bytes)"
)
self.assertTrue(assinatura)
self.assertEqual(limite, 8388608)
self.assertFalse(erro5xx)
def test_fatal_pre_rinit(self):
_assinatura, _erro5xx, _limite, pre_rinit, _post_max, _ = self._analisar(
"<b>Fatal error</b>: ... in <b>Unknown</b> on line <b>0</b>"
)
self.assertTrue(pre_rinit)
def test_mensagem_de_post_max_size(self):
_assinatura, _erro5xx, _limite, _pre_rinit, post_max, _ = self._analisar(
"Actual POST length does not match Content-Length, and exceeds 8388608 bytes"
)
self.assertEqual(post_max, 8388608)
def test_resposta_normal_nao_e_erro(self):
assinatura, erro5xx, limite, pre_rinit, post_max, _ = self._analisar("OK 8.2.34")
self.assertFalse(assinatura)
self.assertFalse(erro5xx)
self.assertIsNone(limite)
self.assertFalse(pre_rinit)
self.assertIsNone(post_max)
class Constantes(unittest.TestCase):
def test_rotulos_de_atribuicao(self):
self.assertNotEqual(ATRIBUICAO_SESSAO, ATRIBUICAO_PARSER)
self.assertNotEqual(ATRIBUICAO_NENHUMA, ATRIBUICAO_SESSAO)
def test_rotulos_de_comportamento(self):
self.assertNotEqual(COMPORTAMENTO_PATCH, COMPORTAMENTO_VULNERAVEL)
if __name__ == "__main__":
unittest.main()
+47
View File
@@ -0,0 +1,47 @@
"""Testes de classificacao de versao e do texto de descricao."""
import unittest
from phpupscan import versoes
class ClassificacaoDeVersao(unittest.TestCase):
def test_series_corrigidas(self):
self.assertEqual(versoes.classificar((8, 2, 35)), versoes.CORRIGIDO)
self.assertEqual(versoes.classificar((8, 3, 36)), versoes.CORRIGIDO)
self.assertEqual(versoes.classificar((8, 4, 27)), versoes.CORRIGIDO)
self.assertEqual(versoes.classificar((8, 5, 12)), versoes.CORRIGIDO)
self.assertEqual(versoes.classificar((8, 6, 0)), versoes.CORRIGIDO)
def test_series_vulneraveis(self):
self.assertEqual(versoes.classificar((8, 2, 34)), versoes.VULNERAVEL)
self.assertEqual(versoes.classificar((8, 3, 35)), versoes.VULNERAVEL)
self.assertEqual(versoes.classificar((8, 4, 26)), versoes.VULNERAVEL)
self.assertEqual(versoes.classificar((8, 5, 11)), versoes.VULNERAVEL)
self.assertEqual(versoes.classificar((8, 2, 0)), versoes.VULNERAVEL)
def test_series_sem_correcao_oficial(self):
self.assertEqual(versoes.classificar((8, 1, 33)), versoes.SEM_CORRECAO)
self.assertEqual(versoes.classificar((8, 0, 30)), versoes.SEM_CORRECAO)
self.assertEqual(versoes.classificar((7, 4, 33)), versoes.SEM_CORRECAO)
self.assertEqual(versoes.classificar((5, 6, 40)), versoes.SEM_CORRECAO)
def test_versao_desconhecida(self):
self.assertEqual(versoes.classificar(None), versoes.INDETERMINADO)
def test_descricao_mencao_da_versao_corrigida(self):
texto = versoes.descricao((8, 2, 34))
self.assertIn("VULNERAVEL", texto)
self.assertIn("8.2.35", texto)
def test_descricao_de_versao_corrigida_nao_alerta(self):
texto = versoes.descricao((8, 2, 35))
self.assertNotIn("VULNERAVEL", texto)
def test_suporte_por_serie(self):
self.assertTrue(versoes.serie_em_suporte((8, 2, 0)))
self.assertFalse(versoes.serie_em_suporte((8, 1, 0)))
if __name__ == "__main__":
unittest.main()
+72
View File
@@ -0,0 +1,72 @@
"""Versoes do PHP e a janela de vulnerabilidade (CVE-2026-109618 / CVE-2026-109619).
Fonte: PHP Security Advisory de 21/10/2026 e osNEWS do proprio php-src.
"""
from __future__ import annotations
from typing import Optional, Tuple
#: Versao minima corrigida de cada serie ainda mantida pelo projeto PHP.
VERSAO_CORRIGIDA = {
(8, 2): (8, 2, 35),
(8, 3): (8, 3, 36),
(8, 4): (8, 4, 27),
(8, 5): (8, 5, 12),
}
#: 8.6.0RC4 ja nasce com o patch (RC4 de 21/10/2026).
PRIMEIRA_SERIE_CORRIGIDA = (8, 6, 0)
#: Series sem correcao oficial (fora de suporte); o projeto PHP publicou
#: apenas backports minimos de codigo-fonte (ramais *-CVE-2026-109618-109619).
SEM_CORRECAO_OFICIAL = {(8, 1), (8, 0), (7, 4), (7, 3), (7, 2), (7, 1), (7, 0)}
# --- rotulos de veredito ---------------------------------------------------
VULNERAVEL = "vulneravel"
CORRIGIDO = "corrigido"
SEM_CORRECAO = "sem-correcao-oficial"
INDETERMINADO = "indeterminado"
def classificar(versao: Optional[Tuple[int, int, int]]) -> str:
"""Classifica uma versao do PHP frente as duas CVEs."""
if versao is None:
return INDETERMINADO
maior, menor, _ = versao
if (maior, menor) >= (8, 6):
return CORRIGIDO
if (maior, menor) in VERSAO_CORRIGIDA:
return CORRIGIDO if versao >= VERSAO_CORRIGIDA[(maior, menor)] else VULNERAVEL
if (maior, menor) in SEM_CORRECAO_OFICIAL or maior < 7:
return SEM_CORRECAO
return INDETERMINADO
def descricao(versao: Optional[Tuple[int, int, int]]) -> str:
"""Texto amigavel (PT-BR) do veredito de versao."""
if versao is None:
return "Versao do PHP nao determinada (nao e possivel classificar pela versao)."
texto = ".".join(str(p) for p in versao)
estado = classificar(versao)
if estado == CORRIGIDO:
return f"PHP {texto}: contem o patch oficial (CVE-2026-109618/109619 corrigidas)."
if estado == VULNERAVEL:
minima = VERSAO_CORRIGIDA[(versao[0], versao[1])]
return (
f"PHP {texto}: VULNERAVEL - a serie {versao[0]}.{versao[1]} so foi corrigida em "
f"{'.'.join(str(p) for p in minima)}."
)
if estado == SEM_CORRECAO:
return (
f"PHP {texto}: serie fora de suporte, sem correcao oficial do projeto PHP. "
"Vulnerable por padrao; verifique se o fornecedor/LTS aplicou o backport."
)
return f"PHP {texto}: serie nao classificada pelo scanner."
def serie_em_suporte(versao: Optional[Tuple[int, int, int]]) -> Optional[bool]:
"""True se a serie ainda recebe seguranca do projeto PHP."""
if versao is None:
return None
return (versao[0], versao[1]) >= (8, 2)
+17
View File
@@ -0,0 +1,17 @@
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
[project]
name = "phpupscan"
version = "1.0.0"
description = "Verificador de exposicao as CVEs de memoria do PHP (CVE-2026-109618 / CVE-2026-109619)"
readme = "README.md"
requires-python = ">=3.9"
license = { text = "MIT" }
[project.scripts]
phpupscan = "phpupscan.cli:main"
[tool.setuptools]
packages = ["phpupscan", "phpupscan.testes"]