From 6370227ad17c4099a9998c365b6f6216a218ea2c Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Costa Date: Thu, 22 Oct 2026 19:44:46 -0300 Subject: [PATCH] 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). --- .gitignore | 29 ++ LICENSE | 21 + phpupscan/README.md | 304 ++++++++++++++ phpupscan/__init__.py | 11 + phpupscan/__main__.py | 5 + phpupscan/cli.py | 377 +++++++++++++++++ phpupscan/config_audit.py | 318 +++++++++++++++ phpupscan/detectar.py | 271 +++++++++++++ phpupscan/httpclient.py | 271 +++++++++++++ phpupscan/lab.py | 479 ++++++++++++++++++++++ phpupscan/relatorio.py | 557 ++++++++++++++++++++++++++ phpupscan/sondas.py | 523 ++++++++++++++++++++++++ phpupscan/testes/__init__.py | 4 + phpupscan/testes/test_config_audit.py | 94 +++++ phpupscan/testes/test_relatorio.py | 130 ++++++ phpupscan/testes/test_sondas.py | 114 ++++++ phpupscan/testes/test_versoes.py | 47 +++ phpupscan/versoes.py | 72 ++++ pyproject.toml | 17 + 19 files changed, 3644 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 phpupscan/README.md create mode 100644 phpupscan/__init__.py create mode 100644 phpupscan/__main__.py create mode 100644 phpupscan/cli.py create mode 100644 phpupscan/config_audit.py create mode 100644 phpupscan/detectar.py create mode 100644 phpupscan/httpclient.py create mode 100644 phpupscan/lab.py create mode 100644 phpupscan/relatorio.py create mode 100644 phpupscan/sondas.py create mode 100644 phpupscan/testes/__init__.py create mode 100644 phpupscan/testes/test_config_audit.py create mode 100644 phpupscan/testes/test_relatorio.py create mode 100644 phpupscan/testes/test_sondas.py create mode 100644 phpupscan/testes/test_versoes.py create mode 100644 phpupscan/versoes.py create mode 100644 pyproject.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7c77f9a --- /dev/null +++ b/.gitignore @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7fa5716 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/phpupscan/README.md b/phpupscan/README.md new file mode 100644 index 0000000..b40177e --- /dev/null +++ b/phpupscan/README.md @@ -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. diff --git a/phpupscan/__init__.py b/phpupscan/__init__.py new file mode 100644 index 0000000..09b0d3e --- /dev/null +++ b/phpupscan/__init__.py @@ -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__"] diff --git a/phpupscan/__main__.py b/phpupscan/__main__.py new file mode 100644 index 0000000..d621a25 --- /dev/null +++ b/phpupscan/__main__.py @@ -0,0 +1,5 @@ +from .cli import main +import sys + +if __name__ == "__main__": + sys.exit(main()) diff --git a/phpupscan/cli.py b/phpupscan/cli.py new file mode 100644 index 0000000..d0ec09c --- /dev/null +++ b/phpupscan/cli.py @@ -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()) diff --git a/phpupscan/config_audit.py b/phpupscan/config_audit.py new file mode 100644 index 0000000..9b2fa5b --- /dev/null +++ b/phpupscan/config_audit.py @@ -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}" + ) diff --git a/phpupscan/detectar.py b/phpupscan/detectar.py new file mode 100644 index 0000000..bd50584 --- /dev/null +++ b/phpupscan/detectar.py @@ -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: chavevalor + for chave in sorted(chaves_interessantes): + padrao = re.compile( + r"]*>\s*" + re.escape(chave) + r"\s*\s*]*>(.*?)", + 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 diff --git a/phpupscan/httpclient.py b/phpupscan/httpclient.py new file mode 100644 index 0000000..e4a6006 --- /dev/null +++ b/phpupscan/httpclient.py @@ -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}" diff --git a/phpupscan/lab.py b/phpupscan/lab.py new file mode 100644 index 0000000..c08ac7e --- /dev/null +++ b/phpupscan/lab.py @@ -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 = """ 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()) diff --git a/phpupscan/relatorio.py b/phpupscan/relatorio.py new file mode 100644 index 0000000..4ed359f --- /dev/null +++ b/phpupscan/relatorio.py @@ -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, + ) diff --git a/phpupscan/sondas.py b/phpupscan/sondas.py new file mode 100644 index 0000000..48acab7 --- /dev/null +++ b/phpupscan/sondas.py @@ -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 0|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 diff --git a/phpupscan/testes/__init__.py b/phpupscan/testes/__init__.py new file mode 100644 index 0000000..aa1ff83 --- /dev/null +++ b/phpupscan/testes/__init__.py @@ -0,0 +1,4 @@ +"""Testes do phpupscan (sem dependencias externas). + +Rodar: python3 -m unittest discover -s testes -v +""" diff --git a/phpupscan/testes/test_config_audit.py b/phpupscan/testes/test_config_audit.py new file mode 100644 index 0000000..63292d7 --- /dev/null +++ b/phpupscan/testes/test_config_audit.py @@ -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() diff --git a/phpupscan/testes/test_relatorio.py b/phpupscan/testes/test_relatorio.py new file mode 100644 index 0000000..bfffa50 --- /dev/null +++ b/phpupscan/testes/test_relatorio.py @@ -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() diff --git a/phpupscan/testes/test_sondas.py b/phpupscan/testes/test_sondas.py new file mode 100644 index 0000000..e0e2c7a --- /dev/null +++ b/phpupscan/testes/test_sondas.py @@ -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( + "Fatal error: ... in Unknown on line 0" + ) + 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() diff --git a/phpupscan/testes/test_versoes.py b/phpupscan/testes/test_versoes.py new file mode 100644 index 0000000..d98e9ca --- /dev/null +++ b/phpupscan/testes/test_versoes.py @@ -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() diff --git a/phpupscan/versoes.py b/phpupscan/versoes.py new file mode 100644 index 0000000..768d80c --- /dev/null +++ b/phpupscan/versoes.py @@ -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) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..eeaefe5 --- /dev/null +++ b/pyproject.toml @@ -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"]