phpupscan: verificador de exposicao as CVEs de memoria do PHP
Ferramenta em portugues para avaliar hosts frente as CVE-2026-109618 (RCE via corrupcao por OOM) e CVE-2026-109619 (RCE via estado global obsoleto), publicadas pelo projeto PHP em 21/10/2026. Conteudo: - varredura: fingerprint de versao e SAPI, exposicao de phpinfo e veredito por alvo, com saida texto/JSON - sondas ativas controladas: "leve" reproduz o OOM pre-RINIT do bundle oficial de reproducao; "forte" usa chunked para verificar o patch por comportamento (o parser corrigido corta a leitura em post_max_size). Cada sonda vem com um corpo de controle para atribuir o OOM ao caminho de progresso de upload de sessao - config: auditoria de php.ini, .user.ini, .htaccess e pool FPM contra as recomendacoes do aviso oficial do PHP - info: referencia rapida das CVEs, versoes corrigidas e mitigacoes - laboratorio: sobe containers (8.2.34 vulneravel, 8.2.35 corrigido, controle com upload_progress desligado e par nginx+php-fpm) e valida o verificador com recibos PASS/FAIL As versoes corrigidas vem do advisory oficial (8.2.35, 8.3.36, 8.4.27, 8.5.12) e nao do post de referencia, que lista as versoes anteriores. A ferramenta mede exposicao: nao inclui a forja de ponteiros nem a chamada a system() do bundle de reproducao. Testes: 46 testes unitarios (unittest, sem dependencias).
This commit is contained in:
commit
6370227ad1
19 files changed
+3644
No files matched your search
+29
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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__"]
|
||||
@@ -0,0 +1,5 @@
|
||||
from .cli import main
|
||||
import sys
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -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())
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -0,0 +1,271 @@
|
||||
"""Deteccao de alvo: versao do PHP, SAPI,headers e exposicao de configuracao.
|
||||
|
||||
Fingerprinting leve e nao destrutivo: GET/HEAD + leitura de opcoes que o
|
||||
servidor volunteers (headers, phpinfo, .htaccess refletido).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
from .httpclient import Conexao, Resposta
|
||||
|
||||
VERSAO_RE = re.compile(r"PHP[/ ]?(\d+)\.(\d+)(?:\.(\d+))?", re.IGNORECASE)
|
||||
VERSAO_CABECALHO_RE = re.compile(r"PHP/(\d+)\.(\d+)(?:\.(\d+))?", re.IGNORECASE)
|
||||
|
||||
#: SAPI deduce a partir de cabecalhos/assinaturas de resposta.
|
||||
SAPI_DESCONHECIDA = "desconhecida"
|
||||
SAPI_MOD_PHP = "mod_php (prefork)"
|
||||
SAPI_FPM = "php-fpm"
|
||||
SAPI_CGI = "cgi"
|
||||
SAPI_CLI_SERVER = "php -S (servidor embutido)"
|
||||
SAPI_LITESPEED = "litespeed (lsapi)"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Perfil:
|
||||
"""Resultado do reconhecimento de um alvo."""
|
||||
|
||||
url: str
|
||||
acessivel: bool = False
|
||||
status: Optional[int] = None
|
||||
servidor: str = ""
|
||||
sapicabecalho: str = ""
|
||||
php_versao: Optional[Tuple[int, int, int]] = None
|
||||
php_versao_bruta: str = ""
|
||||
sapi: str = SAPI_DESCONHECIDA
|
||||
cabecalhos: Dict[str, str] = field(default_factory=dict)
|
||||
cookies: List[str] = field(default_factory=list)
|
||||
phpinfo: Dict[str, str] = field(default_factory=dict)
|
||||
exposes_phpinfo: bool = False
|
||||
observacoes: List[str] = field(default_factory=list)
|
||||
erro: str = ""
|
||||
|
||||
@property
|
||||
def eh_php(self) -> bool:
|
||||
return self.php_versao is not None or bool(self.phpinfo)
|
||||
|
||||
|
||||
def normalizar_url(alvo: str, caminho_padrao: str = "/") -> str:
|
||||
"""Aceita 'exemplo.com', 'https://exemplo.com/x' e devolve URL absoluta."""
|
||||
alvo = alvo.strip()
|
||||
if not alvo:
|
||||
raise ValueError("alvo vazio")
|
||||
if "://" not in alvo:
|
||||
alvo = "http://" + alvo
|
||||
partes = urlparse(alvo)
|
||||
if not partes.path:
|
||||
partes = partes._replace(path=caminho_padrao)
|
||||
return urlunparse(partes)
|
||||
|
||||
|
||||
def _extrair_versao(texto: str) -> Optional[Tuple[int, int, int]]:
|
||||
m = VERSAO_RE.search(texto or "")
|
||||
if not m:
|
||||
return None
|
||||
return (int(m.group(1)), int(m.group(2)), int(m.group(3) or 0))
|
||||
|
||||
|
||||
def _cabecalho_php(cabecalho: str) -> Optional[Tuple[int, int, int]]:
|
||||
m = VERSAO_CABECALHO_RE.search(cabecalho or "")
|
||||
if not m:
|
||||
return None
|
||||
return (int(m.group(1)), int(m.group(2)), int(m.group(3) or 0))
|
||||
|
||||
|
||||
def _detectar_sapi(resposta: Resposta, corpo: str, headers: Dict[str, str]) -> str:
|
||||
"""Deduz a SAPI a partir dos cabecalhos da resposta.
|
||||
|
||||
O header `Server` e o `X-Powered-By` sao a fonte primaria. Quando ha
|
||||
`phpinfo` disponivel, o campo "Server API" corrige a classificacao
|
||||
(ver `_sapi_por_phpinfo`).
|
||||
"""
|
||||
servidor = (headers.get("server") or "").lower()
|
||||
powered = (headers.get("x-powered-by") or "").lower()
|
||||
|
||||
if "litespeed" in servidor:
|
||||
return SAPI_LITESPEED
|
||||
if "apache" in servidor and "php/" in servidor:
|
||||
# O mod_php acrescenta a versao do PHP no header Server.
|
||||
return SAPI_MOD_PHP
|
||||
if "apache" in servidor and "php" in powered:
|
||||
return SAPI_MOD_PHP
|
||||
if "nginx" in servidor or "caddy" in servidor or "envoy" in servidor or "lighttpd" in servidor:
|
||||
# Esses servidores nao rodam mod_php: o PHP responde por FastCGI.
|
||||
return SAPI_FPM
|
||||
if "microsoft-iis" in servidor:
|
||||
return SAPI_CGI
|
||||
if "php/" in servidor:
|
||||
return SAPI_CLI_SERVER
|
||||
if "php" in powered:
|
||||
# X-Powered-By sem Server: pode ser o servidor embutido do PHP ou um
|
||||
# proxy que remove o header Server.
|
||||
return SAPI_DESCONHECIDA
|
||||
return SAPI_DESCONHECIDA
|
||||
|
||||
|
||||
def _parse_phpinfo(corpo: str) -> Dict[str, str]:
|
||||
"""Extrai opcoes relevantes de uma pagina phpinfo (HTML ou texto)."""
|
||||
if not corpo:
|
||||
return {}
|
||||
baixo = corpo.lower()
|
||||
if "phpinfo()" not in baixo and "php version" not in baixo and "php_version" not in baixo:
|
||||
return {}
|
||||
chaves_interessantes = {
|
||||
"session.upload_progress.enabled",
|
||||
"session.upload_progress.cleanup",
|
||||
"session.upload_progress.name",
|
||||
"session.upload_progress.prefix",
|
||||
"session.upload_progress.freq",
|
||||
"session.upload_progress.min_freq",
|
||||
"session.serialize_handler",
|
||||
"session.save_handler",
|
||||
"memory_limit",
|
||||
"post_max_size",
|
||||
"upload_max_filesize",
|
||||
"max_file_uploads",
|
||||
"max_input_vars",
|
||||
"max_input_nesting_level",
|
||||
"server api",
|
||||
"php version",
|
||||
}
|
||||
resultado: Dict[str, str] = {}
|
||||
# Formato HTML do phpinfo: <tr><td class="e">chave</td><td class="v">valor</td></tr>
|
||||
for chave in sorted(chaves_interessantes):
|
||||
padrao = re.compile(
|
||||
r"<td[^>]*>\s*" + re.escape(chave) + r"\s*</td>\s*<td[^>]*>(.*?)</td>",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
m = padrao.search(corpo)
|
||||
if m:
|
||||
valor = re.sub(r"<[^>]+>", "", m.group(1))
|
||||
resultado[chave] = " ".join(valor.split())
|
||||
continue
|
||||
# Formato texto (php -i): "chave => valor"
|
||||
m2 = re.search(r"^\s*" + re.escape(chave) + r"\s*=>\s*(.+)$", corpo, re.IGNORECASE | re.MULTILINE)
|
||||
if m2:
|
||||
resultado[chave] = m2.group(1).strip()
|
||||
return resultado
|
||||
|
||||
|
||||
def _extrair_versao_phpinfo(info: Dict[str, str]) -> Optional[Tuple[int, int, int]]:
|
||||
bruto = info.get("php version", "")
|
||||
return _extrair_versao(bruto)
|
||||
|
||||
|
||||
def sondar_phpinfo(url: str, timeout: float = 10.0, insecure: bool = False) -> Dict[str, str]:
|
||||
"""Tenta obter um phpinfo publico no alvo (caminhos comuns)."""
|
||||
base = url.rstrip("/")
|
||||
candidatos = [
|
||||
f"{base}/phpinfo.php",
|
||||
f"{base}/info.php",
|
||||
f"{base}/phpinfo",
|
||||
f"{base}/i.php",
|
||||
f"{base}/test.php",
|
||||
f"{base}/?phpinfo=1",
|
||||
]
|
||||
partes = urlparse(url)
|
||||
porta = partes.port or (443 if partes.scheme == "https" else 80)
|
||||
for candidato in candidatos:
|
||||
try:
|
||||
with Conexao(
|
||||
partes.hostname or "",
|
||||
porta,
|
||||
tls=partes.scheme == "https",
|
||||
timeout=timeout,
|
||||
verificar_certificado=not insecure,
|
||||
) as con:
|
||||
resp = con.requisitar("GET", urlparse(candidato).path or "/")
|
||||
if resp.status != 200:
|
||||
continue
|
||||
info = _parse_phpinfo(resp.body.decode("utf-8", "replace"))
|
||||
if info:
|
||||
return info
|
||||
except OSError:
|
||||
continue
|
||||
return {}
|
||||
|
||||
|
||||
def reconhecer(url: str, timeout: float = 10.0, insecure: bool = False, buscar_phpinfo: bool = True) -> Perfil:
|
||||
"""Reconhece o alvo: versao do PHP, SAPI e configuracao exposta."""
|
||||
perfil = Perfil(url=url)
|
||||
partes = urlparse(url)
|
||||
if not partes.hostname:
|
||||
perfil.erro = "URL sem hostname"
|
||||
return perfil
|
||||
porta = partes.port or (443 if partes.scheme == "https" else 80)
|
||||
caminho = partes.path or "/"
|
||||
|
||||
try:
|
||||
with Conexao(
|
||||
partes.hostname,
|
||||
porta,
|
||||
tls=partes.scheme == "https",
|
||||
timeout=timeout,
|
||||
verificar_certificado=not insecure,
|
||||
) as con:
|
||||
resp = con.requisitar("GET", caminho)
|
||||
except OSError as exc:
|
||||
perfil.erro = f"falha de conexao: {exc}"
|
||||
return perfil
|
||||
|
||||
perfil.acessivel = resp.status is not None
|
||||
perfil.status = resp.status
|
||||
perfil.cabecalhos = dict(resp.headers)
|
||||
perfil.servidor = resp.headers.get("server", "")
|
||||
perfil.sapicabecalho = resp.headers.get("x-powered-by", "")
|
||||
perfil.cookies = [
|
||||
linha.split(":", 1)[0].strip()
|
||||
for linha in resp.headers.get("set-cookie", "").split("\n")
|
||||
if linha.strip()
|
||||
]
|
||||
corpo = resp.body.decode("utf-8", "replace")
|
||||
|
||||
versao = _cabecalho_php(perfil.sapicabecalho) or _cabecalho_php(perfil.servidor)
|
||||
if versao:
|
||||
perfil.php_versao = versao
|
||||
perfil.php_versao_bruta = ".".join(str(p) for p in versao)
|
||||
else:
|
||||
bruta = _extrair_versao(corpo[:5000])
|
||||
if bruta:
|
||||
perfil.php_versao = bruta
|
||||
perfil.php_versao_bruta = ".".join(str(p) for p in bruta)
|
||||
|
||||
perfil.sapi = _detectar_sapi(resp, corpo, perfil.cabecalhos)
|
||||
|
||||
if buscar_phpinfo:
|
||||
info = sondar_phpinfo(url, timeout=timeout, insecure=insecure)
|
||||
if info:
|
||||
perfil.phpinfo = info
|
||||
perfil.exposes_phpinfo = True
|
||||
if perfil.php_versao is None:
|
||||
v = _extrair_versao_phpinfo(info)
|
||||
if v:
|
||||
perfil.php_versao = v
|
||||
perfil.php_versao_bruta = ".".join(str(p) for p in v)
|
||||
if "server api" in info:
|
||||
perfil.sapi = _sapi_por_phpinfo(info["server api"], perfil.sapi)
|
||||
perfil.observacoes.append("phpinfo exposicao publica (risco extra de divulgacao de configuracao)")
|
||||
|
||||
if "phpsessid" in [c.lower() for c in perfil.cookies]:
|
||||
perfil.observacoes.append("cookies de sessao presentes (o host usa sessoes)")
|
||||
|
||||
return perfil
|
||||
|
||||
|
||||
def _sapi_por_phpinfo(valor: str, atual: str) -> str:
|
||||
v = (valor or "").lower()
|
||||
if "apache" in v:
|
||||
return SAPI_MOD_PHP
|
||||
if "fpm" in v or "fastcgi" in v:
|
||||
return SAPI_FPM
|
||||
if "cgi" in v:
|
||||
return SAPI_CGI
|
||||
if "cli" in v or "built-in" in v or "cli-server" in v:
|
||||
return SAPI_CLI_SERVER
|
||||
if "litespeed" in v:
|
||||
return SAPI_LITESPEED
|
||||
return atual
|
||||
@@ -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}"
|
||||
@@ -0,0 +1,479 @@
|
||||
"""Laboratorio local: valida o verificador contra versoes vulneravel e corrigida.
|
||||
|
||||
Sobe tres containers (a imagem base e a de uma imagem de runtime de containers):
|
||||
1. PHP 8.2.34 com mod_php (prefork) - vulneravel
|
||||
2. PHP 8.2.35 com mod_php (prefork) - corrigida
|
||||
3. PHP 8.2.34 com session.upload_progress.enabled=Off - controle
|
||||
|
||||
Depois roda o proprio verificador contra os tres e imprime recibos PASS/FAIL.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from typing import List, Optional
|
||||
|
||||
from .detectar import SAPI_FPM, reconhecer
|
||||
from .relatorio import _avaliar
|
||||
from .sondas import (
|
||||
ATRIBUICAO_SESSAO,
|
||||
COMPORTAMENTO_PATCH,
|
||||
TAMANHO_FORTE,
|
||||
sondar,
|
||||
)
|
||||
|
||||
IMAGEM_VULN = "docker.io/library/php:8.2.34-apache"
|
||||
IMAGEM_FIX = "docker.io/library/php:8.2.35-apache"
|
||||
IMAGEM_FPM = "docker.io/library/php:8.2.34-fpm-alpine"
|
||||
IMAGEM_NGINX = "docker.io/library/nginx:alpine"
|
||||
|
||||
#: Configuracao com memory_limit pequeno: o OOM acontece cedo e a sonda leve
|
||||
#: (Content-Length) ja reproduz o pre-RINIT.
|
||||
INI_LAB = """memory_limit = 8M
|
||||
post_max_size = 32M
|
||||
upload_max_filesize = 32M
|
||||
max_file_uploads = 25
|
||||
session.upload_progress.min_freq = 0
|
||||
session.save_path = /tmp/sess
|
||||
"""
|
||||
|
||||
#: Configuracao padrao do PHP (memory_limit 128M, post_max_size 8M): e o
|
||||
#: cenario em que a sonda forte (chunked) distingue versao corrigida de
|
||||
#: vulneravel, porque o parser corrigido corta a leitura em post_max_size
|
||||
#: antes de estourar a memoria.
|
||||
INI_PADRAO = """display_errors = On
|
||||
"""
|
||||
|
||||
INI_CONTROLE = "session.upload_progress.enabled = 0\n"
|
||||
|
||||
#: Configuracao do php-fpm do laboratorio (para o par nginx + php-fpm).
|
||||
INI_FPM = """memory_limit = 8M
|
||||
post_max_size = 32M
|
||||
upload_max_filesize = 32M
|
||||
max_file_uploads = 25
|
||||
session.upload_progress.min_freq = 0
|
||||
session.save_path = /tmp/sess
|
||||
"""
|
||||
|
||||
#: nginx que faz de proxy para o php-fpm do laboratorio.
|
||||
NGINX_LAB = """server {
|
||||
listen 80;
|
||||
index index.php;
|
||||
client_max_body_size 32M;
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
location ~ \\.php$ {
|
||||
include fastcgi_params;
|
||||
fastcgi_pass phpupscan-lab-fpm:9000;
|
||||
fastcgi_param SCRIPT_FILENAME /var/www/html$fastcgi_script_name;
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
HTML_LAB = """<?php
|
||||
echo "OK " . PHP_VERSION . " " . php_sapi_name() . "\\n";
|
||||
"""
|
||||
|
||||
|
||||
def _detectar_motor(desejado: Optional[str]) -> Optional[str]:
|
||||
if desejado:
|
||||
return shutil.which(desejado)
|
||||
for motor in ("podman", "docker"):
|
||||
caminho = shutil.which(motor)
|
||||
if caminho:
|
||||
return caminho
|
||||
return None
|
||||
|
||||
|
||||
class Container:
|
||||
"""Um container do laboratorio."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
motor: str,
|
||||
nome: str,
|
||||
imagem: str,
|
||||
porta: int,
|
||||
raiz: str,
|
||||
extras: List[str],
|
||||
rede: Optional[str] = None,
|
||||
comando: Optional[List[str]] = None,
|
||||
porta_interna: int = 80,
|
||||
destino: str = "/var/www/html",
|
||||
volumes_extra: Optional[List[str]] = None,
|
||||
):
|
||||
self.motor = motor
|
||||
self.nome = nome
|
||||
self.imagem = imagem
|
||||
self.porta = porta
|
||||
self.raiz = raiz
|
||||
self.extras = extras
|
||||
self.rede = rede
|
||||
self.comando = comando
|
||||
self.porta_interna = porta_interna
|
||||
self.destino = destino
|
||||
self.volumes_extra = volumes_extra or []
|
||||
|
||||
def subir(self) -> bool:
|
||||
cmd = [
|
||||
self.motor,
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
self.nome,
|
||||
]
|
||||
if self.rede:
|
||||
cmd += ["--network", self.rede]
|
||||
if self.porta:
|
||||
cmd += ["-p", f"{self.porta}:{self.porta_interna}"]
|
||||
cmd += [
|
||||
"-v",
|
||||
f"{self.raiz}/html:{self.destino}:ro",
|
||||
"-v",
|
||||
f"{self.raiz}/sess:/tmp/sess",
|
||||
]
|
||||
for ini in self.extras:
|
||||
cmd += ["-v", f"{ini[0]}:/usr/local/etc/php/conf.d/{ini[1]}:ro"]
|
||||
for volume in self.volumes_extra:
|
||||
cmd += ["-v", volume]
|
||||
cmd.append(self.imagem)
|
||||
if self.comando:
|
||||
cmd += self.comando
|
||||
resultado = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if resultado.returncode != 0:
|
||||
print(f" erro ao subir {self.nome}: {resultado.stderr.strip()}", file=sys.stderr)
|
||||
return False
|
||||
return True
|
||||
|
||||
def remover(self) -> None:
|
||||
subprocess.run([self.motor, "rm", "-f", self.nome], capture_output=True, text=True)
|
||||
|
||||
def url(self, caminho: str = "/index.php") -> str:
|
||||
return f"http://127.0.0.1:{self.porta}{caminho}"
|
||||
|
||||
|
||||
def _esperar(url: str, tentativas: int = 40) -> bool:
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
for _ in range(tentativas):
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=2) as resp:
|
||||
if resp.status == 200:
|
||||
return True
|
||||
except Exception:
|
||||
time.sleep(0.5)
|
||||
return False
|
||||
|
||||
|
||||
class Recibos:
|
||||
"""Acumula PASS/FAIL do laboratorio."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.passes = 0
|
||||
self.falhas = 0
|
||||
|
||||
def checar(self, condicao: bool, descricao: str, detalhe: str = "") -> bool:
|
||||
if condicao:
|
||||
self.passes += 1
|
||||
print(f" [PASS] {descricao}" + (f" - {detalhe}" if detalhe else ""))
|
||||
else:
|
||||
self.falhas += 1
|
||||
print(f" [FAIL] {descricao}" + (f" - {detalhe}" if detalhe else ""))
|
||||
return condicao
|
||||
|
||||
|
||||
def executar_lab(args: argparse.Namespace) -> int:
|
||||
motor = _detectar_motor(args.motor)
|
||||
if not motor:
|
||||
print(
|
||||
"Nao encontrei podman nem docker. Instale um dos dois para rodar o laboratorio "
|
||||
"local (o laboratorio so usa imagens publicas do PHP e nao toca a rede alem dos pulls)."
|
||||
)
|
||||
return 2
|
||||
|
||||
print(f"phpupscan - laboratorio local (motor: {os.path.basename(motor)})")
|
||||
print("Aviso: o laboratorio cria containers locais e remove tudo ao final.")
|
||||
print()
|
||||
|
||||
raiz = tempfile.mkdtemp(prefix="phpupscan-lab-")
|
||||
os.makedirs(os.path.join(raiz, "html"), exist_ok=True)
|
||||
os.makedirs(os.path.join(raiz, "sess"), exist_ok=True)
|
||||
os.chmod(os.path.join(raiz, "sess"), 0o777)
|
||||
with open(os.path.join(raiz, "html", "index.php"), "w", encoding="utf-8") as fh:
|
||||
fh.write(HTML_LAB)
|
||||
ini_principal = os.path.join(raiz, "99-lab.ini")
|
||||
with open(ini_principal, "w", encoding="utf-8") as fh:
|
||||
fh.write(INI_LAB)
|
||||
ini_controle = os.path.join(raiz, "98-off.ini")
|
||||
with open(ini_controle, "w", encoding="utf-8") as fh:
|
||||
fh.write(INI_CONTROLE)
|
||||
ini_padrao = os.path.join(raiz, "99-padrao.ini")
|
||||
with open(ini_padrao, "w", encoding="utf-8") as fh:
|
||||
fh.write(INI_PADRAO)
|
||||
nginx_conf = os.path.join(raiz, "nginx.conf")
|
||||
with open(nginx_conf, "w", encoding="utf-8") as fh:
|
||||
fh.write(NGINX_LAB)
|
||||
ini_fpm = os.path.join(raiz, "99-fpm.ini")
|
||||
with open(ini_fpm, "w", encoding="utf-8") as fh:
|
||||
fh.write(INI_FPM)
|
||||
|
||||
vuln = Container(motor, "phpupscan-lab-vuln", IMAGEM_VULN, args.porta, raiz, [(ini_principal, "99-lab.ini")])
|
||||
fix = Container(motor, "phpupscan-lab-fix", IMAGEM_FIX, args.porta + 1, raiz, [(ini_principal, "99-lab.ini")])
|
||||
controle = Container(
|
||||
motor,
|
||||
"phpupscan-lab-ctrl",
|
||||
IMAGEM_VULN,
|
||||
args.porta + 2,
|
||||
raiz,
|
||||
[(ini_principal, "99-lab.ini"), (ini_controle, "98-off.ini")],
|
||||
)
|
||||
# Par com configuracao padrao do PHP (memory_limit 128M, post_max_size 8M):
|
||||
# e o cenario em que a sonda forte (chunked) distingue patch de vulneravel.
|
||||
vuln_padrao = Container(
|
||||
motor, "phpupscan-lab-vuln-def", IMAGEM_VULN, args.porta + 3, raiz, [(ini_padrao, "99-padrao.ini")]
|
||||
)
|
||||
fix_padrao = Container(
|
||||
motor, "phpupscan-lab-fix-def", IMAGEM_FIX, args.porta + 4, raiz, [(ini_padrao, "99-padrao.ini")]
|
||||
)
|
||||
# Par nginx + php-fpm: valida a matriz de SAPI (o filho do FPM morre no
|
||||
# startup e o proxy responde 502).
|
||||
rede = "phpupscan-lab-rede"
|
||||
subprocess.run([motor, "network", "rm", "-f", rede], capture_output=True, text=True)
|
||||
subprocess.run([motor, "network", "create", rede], capture_output=True, text=True)
|
||||
fpm = Container(
|
||||
motor,
|
||||
"phpupscan-lab-fpm",
|
||||
IMAGEM_FPM,
|
||||
0,
|
||||
raiz,
|
||||
[(ini_fpm, "99-fpm.ini")],
|
||||
rede=rede,
|
||||
porta_interna=9000,
|
||||
)
|
||||
nginx = Container(
|
||||
motor,
|
||||
"phpupscan-lab-nginx",
|
||||
IMAGEM_NGINX,
|
||||
args.porta + 5,
|
||||
raiz,
|
||||
[],
|
||||
rede=rede,
|
||||
volumes_extra=[f"{nginx_conf}:/etc/nginx/conf.d/default.conf:ro"],
|
||||
)
|
||||
|
||||
containers = [vuln, fix, controle, vuln_padrao, fix_padrao, fpm, nginx]
|
||||
|
||||
try:
|
||||
for container in containers:
|
||||
print(f"Subindo {container.nome} ({container.imagem})...")
|
||||
if not container.subir():
|
||||
return 2
|
||||
for container in containers:
|
||||
if not container.porta:
|
||||
# Container sem porta exposta (o php-fpm responde so via nginx).
|
||||
continue
|
||||
if not _esperar(container.url()):
|
||||
print(f" o container {container.nome} nao respondeu", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
print()
|
||||
print("Recibos:")
|
||||
recibos = Recibos()
|
||||
|
||||
# 1) Reconhecimento
|
||||
print()
|
||||
print("1) Reconhecimento de versao e SAPI")
|
||||
perfis = {}
|
||||
for container in containers:
|
||||
if not container.porta:
|
||||
continue
|
||||
perfil = reconhecer(container.url(), timeout=10.0, buscar_phpinfo=False)
|
||||
perfis[container.nome] = perfil
|
||||
print(f" {container.nome}: {perfil.php_versao_bruta or '?'} / {perfil.sapi}")
|
||||
recibos.checar(
|
||||
perfis[vuln.nome].php_versao_bruta == "8.2.34",
|
||||
"alvo vulnavel reporta PHP 8.2.34",
|
||||
perfis[vuln.nome].php_versao_bruta,
|
||||
)
|
||||
recibos.checar(
|
||||
perfis[fix.nome].php_versao_bruta == "8.2.35",
|
||||
"alvo corrigido reporta PHP 8.2.35",
|
||||
perfis[fix.nome].php_versao_bruta,
|
||||
)
|
||||
|
||||
# 2) Classificacao de versao
|
||||
print()
|
||||
print("2) Classificacao de versao contra o advisory")
|
||||
from . import versoes as mod_versoes
|
||||
|
||||
recibos.checar(
|
||||
mod_versoes.classificar((8, 2, 34)) == mod_versoes.VULNERAVEL,
|
||||
"8.2.34 classificada como vulneravel",
|
||||
)
|
||||
recibos.checar(
|
||||
mod_versoes.classificar((8, 2, 35)) == mod_versoes.CORRIGIDO,
|
||||
"8.2.35 classificada como corrigida",
|
||||
)
|
||||
recibos.checar(
|
||||
mod_versoes.classificar((8, 1, 33)) == mod_versoes.SEM_CORRECAO,
|
||||
"8.1.33 classificada como sem correcao oficial",
|
||||
)
|
||||
|
||||
# 3) Sonda ativa contra o alvo vulneravel
|
||||
print()
|
||||
print("3) Sonda ativa contra o alvo vulneravel (8.2.34)")
|
||||
sonda_vuln = sondar(vuln.url(), timeout=25.0, tamanho_nome=1_300_000)
|
||||
print(f" {sonda_vuln.resumo()}")
|
||||
for obs in sonda_vuln.observacoes:
|
||||
print(f" - {obs}")
|
||||
recibos.checar(sonda_vuln.oom_arm, "OOM reproduzido no alvo vulneravel")
|
||||
recibos.checar(
|
||||
sonda_vuln.upload_progress_ativo is True,
|
||||
"controle sem PHP_SESSION_UPLOAD_PROGRESS nao estourou memoria",
|
||||
f"status controle={sonda_vuln.status_controle}",
|
||||
)
|
||||
recibos.checar(
|
||||
sonda_vuln.atribuicao == ATRIBUICAO_SESSAO,
|
||||
"OOM atribuido ao caminho de progresso de upload de sessao",
|
||||
sonda_vuln.atribuicao,
|
||||
)
|
||||
|
||||
# 4) Controle com upload_progress desligado
|
||||
print()
|
||||
print("4) Controle: upload_progress desligado (8.2.34)")
|
||||
sonda_ctrl = sondar(controle.url(), timeout=25.0, tamanho_nome=1_300_000)
|
||||
print(f" {sonda_ctrl.resumo()}")
|
||||
recibos.checar(
|
||||
not sonda_ctrl.oom_arm,
|
||||
"com upload_progress=Off o mesmo corpo nao estourou memoria",
|
||||
f"status={sonda_ctrl.status_arm}",
|
||||
)
|
||||
|
||||
# 5) Sonda contra a versao corrigida
|
||||
print()
|
||||
print("5) Sonda ativa contra o alvo corrigido (8.2.35)")
|
||||
sonda_fix = sondar(fix.url(), timeout=25.0, tamanho_nome=1_300_000)
|
||||
print(f" {sonda_fix.resumo()}")
|
||||
recibos.checar(
|
||||
sonda_fix.oom_arm,
|
||||
"a versao corrigida ainda pode estourar memoria (o patch nao muda o OOM)",
|
||||
"isso e esperado: o patch evita o reuso do estado corrompido",
|
||||
)
|
||||
|
||||
# 6) Sonda forte (chunked): verificacao comportamental do patch
|
||||
print()
|
||||
print("6) Sonda forte (chunked, corpo acima de post_max_size)")
|
||||
tamanho_forte = TAMANHO_FORTE
|
||||
sonda_forte_vuln = sondar(
|
||||
vuln_padrao.url(), timeout=90.0, tamanho_nome=tamanho_forte, forte=True
|
||||
)
|
||||
sonda_forte_fix = sondar(
|
||||
fix_padrao.url(), timeout=90.0, tamanho_nome=tamanho_forte, forte=True
|
||||
)
|
||||
print(f" vulnavel -> {sonda_forte_vuln.resumo()}")
|
||||
print(f" corrigido -> {sonda_forte_fix.resumo()}")
|
||||
recibos.checar(
|
||||
sonda_forte_vuln.oom_arm,
|
||||
"no alvo vulneravel o corpo acima de post_max_size provocou OOM",
|
||||
f"status={sonda_forte_vuln.status_arm}",
|
||||
)
|
||||
recibos.checar(
|
||||
sonda_forte_fix.comportamento == COMPORTAMENTO_PATCH,
|
||||
"no alvo corrigido o parser interrompeu a leitura em post_max_size",
|
||||
f"post_max_size={sonda_forte_fix.post_max_size_bytes}",
|
||||
)
|
||||
|
||||
# 7) Veredito final
|
||||
print()
|
||||
print("7) Veredito final do verificador")
|
||||
resultado_vuln = _avaliar(perfis[vuln.nome], sonda_vuln)
|
||||
resultado_fix = _avaliar(perfis[fix.nome], sonda_fix)
|
||||
print(f" vulnavel -> {resultado_vuln.nivel}: {resultado_vuln.resumo}")
|
||||
print(f" corrigido -> {resultado_fix.nivel}: {resultado_fix.resumo}")
|
||||
recibos.checar(
|
||||
resultado_vuln.nivel in ("CRITICO", "ALTO"),
|
||||
"alvo vulneravel classificado como CRITICO/ALTO",
|
||||
resultado_vuln.nivel,
|
||||
)
|
||||
recibos.checar(
|
||||
resultado_fix.nivel != "CRITICO",
|
||||
"alvo corrigido nao classificado como CRITICO",
|
||||
resultado_fix.nivel,
|
||||
)
|
||||
|
||||
# 8) Veredito final com a sonda forte (configuracao padrao do PHP)
|
||||
print()
|
||||
print("8) Veredito final usando a sonda forte (configuracao padrao)")
|
||||
perfil_vuln_def = reconhecer(vuln_padrao.url(), timeout=10.0, buscar_phpinfo=False)
|
||||
perfil_fix_def = reconhecer(fix_padrao.url(), timeout=10.0, buscar_phpinfo=False)
|
||||
veredito_vuln_def = _avaliar(perfil_vuln_def, sonda_forte_vuln)
|
||||
veredito_fix_def = _avaliar(perfil_fix_def, sonda_forte_fix)
|
||||
print(f" vulnavel -> {veredito_vuln_def.nivel}: {veredito_vuln_def.resumo}")
|
||||
print(f" corrigido -> {veredito_fix_def.nivel}: {veredito_fix_def.resumo}")
|
||||
recibos.checar(
|
||||
veredito_vuln_def.nivel == "CRITICO",
|
||||
"alvo vulneravel em configuracao padrao classificado como CRITICO",
|
||||
veredito_vuln_def.nivel,
|
||||
)
|
||||
recibos.checar(
|
||||
veredito_fix_def.nivel in ("OK", "BAIXO", "MEDIO"),
|
||||
"alvo corrigido em configuracao padrao nao classificado como CRITICO",
|
||||
veredito_fix_def.nivel,
|
||||
)
|
||||
|
||||
# 9) Matriz de SAPI: nginx + php-fpm
|
||||
print()
|
||||
print("9) Matriz de SAPI: nginx + php-fpm (o filho morre no startup)")
|
||||
perfil_fpm = reconhecer(nginx.url(), timeout=10.0, buscar_phpinfo=False)
|
||||
sonda_fpm = sondar(nginx.url(), timeout=60.0, tamanho_nome=1_300_000)
|
||||
veredito_fpm = _avaliar(perfil_fpm, sonda_fpm)
|
||||
print(f" SAPI detectada: {perfil_fpm.sapi}")
|
||||
print(f" {sonda_fpm.resumo()}")
|
||||
print(f" veredito -> {veredito_fpm.nivel}: {veredito_fpm.resumo}")
|
||||
recibos.checar(
|
||||
perfil_fpm.sapi == SAPI_FPM,
|
||||
"SAPI php-fpm detectada atras do nginx",
|
||||
perfil_fpm.sapi,
|
||||
)
|
||||
recibos.checar(
|
||||
sonda_fpm.worker_morreu or sonda_fpm.backend_abortou,
|
||||
"o worker do php-fpm morreu no startup (o proxy nao devolveu conteudo)",
|
||||
f"status={sonda_fpm.status_arm} abortou={sonda_fpm.backend_abortou}",
|
||||
)
|
||||
recibos.checar(
|
||||
veredito_fpm.nivel in ("ALTO", "MEDIO"),
|
||||
"alvo com php-fpm nao classificado como CRITICO",
|
||||
veredito_fpm.nivel,
|
||||
)
|
||||
|
||||
print()
|
||||
print(f"RESULTADO: {recibos.passes} PASS, {recibos.falhas} FAIL")
|
||||
return 0 if recibos.falhas == 0 else 1
|
||||
finally:
|
||||
if not args.manter:
|
||||
for container in containers:
|
||||
container.remover()
|
||||
subprocess.run([motor, "network", "rm", "-f", "phpupscan-lab-rede"], capture_output=True)
|
||||
shutil.rmtree(raiz, ignore_errors=True)
|
||||
else:
|
||||
print(f"Containers mantidos. Raiz temporaria: {raiz}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Laboratorio local do phpupscan")
|
||||
parser.add_argument("--motor", choices=["podman", "docker"], default=None)
|
||||
parser.add_argument("--porta", type=int, default=18081)
|
||||
parser.add_argument("--manter", action="store_true")
|
||||
return executar_lab(parser.parse_args())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,523 @@
|
||||
"""Sondas ativas (controladas) para confirmar a exposicao as CVEs.
|
||||
|
||||
Tres niveis de sonda, todos com o mesmo formato de resultado:
|
||||
|
||||
**Leve** (padrao, ~1,3 MB de nome de arquivo, `Content-Length`)
|
||||
Reproduz o "ARM" do bundle oficial: um POST multipart anonimo com um nome
|
||||
de arquivo enorme. O PHP guarda esse nome na arvore de progresso de upload
|
||||
da sessao e a serializa durante o startup da requisicao, antes do RINIT.
|
||||
Com `memory_limit` finito, isso estoura o limite e o PHP aborta a
|
||||
requisicao ainda no pre-RINIT - que e a janela em que o estado interno
|
||||
fica invalido (CVE-2026-109618) e em que o contexto de serializacao fica
|
||||
orfao (CVE-2026-109619).
|
||||
|
||||
**Forte** (`Transfer-Encoding: chunked`, corpo grande)
|
||||
O PHP checa `post_max_size` contra o `Content-Length` no inicio do parsing.
|
||||
Sem `Content-Length`, um corpo maior que `post_max_size` continua sendo
|
||||
entregue - vetor descrito no advisory oficial. As versoes corrigidas
|
||||
(8.2.35, 8.3.36, 8.4.27, 8.5.12) interrompem a leitura em
|
||||
`post_max_size`; as antigas nao. Isso da uma **verificacao
|
||||
comportamental do patch**, que vale mais que o banner de versao: uma
|
||||
distribuicao pode ter backportado o patch sem trocar a versao.
|
||||
|
||||
**Fingerprint de patch** (mesma requests, sem OOM)
|
||||
Uma request pequena, sem pressao de memoria, tambem revela o patch: nas
|
||||
versoes corrigidas o parser corta o corpo em `post_max_size` e avisa
|
||||
"Actual POST length does not match Content-Length". Essa mensagem nao
|
||||
existe nas versoes antigas.
|
||||
|
||||
Atribuicao: cada sonda vem acompanhada de um corpo de controle identico, sem o
|
||||
campo `PHP_SESSION_UPLOAD_PROGRESS`. Se so o corpo com o campo falha, o OOM e
|
||||
do caminho de progresso de upload de sessao. Se os dois falham, o OOM e do
|
||||
parser multipart em geral.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from .httpclient import Conexao, Resposta
|
||||
|
||||
#: "Allowed memory size of 134217728 bytes exhausted" -> limite em bytes.
|
||||
RE_OOM = re.compile(r"Allowed memory size of (\d+) bytes exhausted", re.IGNORECASE)
|
||||
#: Fatal de memoria em contexto de startup (antes de qualquer codigo da app).
|
||||
RE_PRE_RINIT = re.compile(r"on line <b>0</b>|in Unknown on line 0|line 0", re.IGNORECASE)
|
||||
RE_STARTUP = re.compile(r"PHP Request Startup", re.IGNORECASE)
|
||||
#: Assinatura de OOM em qualquer posicao do corpo.
|
||||
LIMITE_ERRO = re.compile(r"exhausted|Fatal error|memory size", re.IGNORECASE)
|
||||
#: Mensagem que so existe em versoes com o patch: o parser interrompe a leitura
|
||||
#: quando o corpo entregue ultrapassa `post_max_size`.
|
||||
RE_POST_LIMITE = re.compile(
|
||||
r"Actual POST length does not match Content-Length, and exceeds (\d+) bytes", re.IGNORECASE
|
||||
)
|
||||
|
||||
#: Tamanho padrao do nome de arquivo da sonda leve.
|
||||
TAMANHO_LEVE = 1_300_000
|
||||
#: Tamanho padrao do nome de arquivo da sonda forte (chunked).
|
||||
TAMANHO_FORTE = 30_000_000
|
||||
#: Teto do nome de arquivo nas sondas, para nao estourar a banda do alvo.
|
||||
TAMANHO_MAXIMO = 64_000_000
|
||||
|
||||
#: Atribuicao do OOM.
|
||||
ATRIBUICAO_SESSAO = "sessao"
|
||||
ATRIBUICAO_PARSER = "parser"
|
||||
ATRIBUICAO_NENHUMA = "nenhuma"
|
||||
|
||||
#: Comportamento observado em relacao ao patch.
|
||||
COMPORTAMENTO_VULNERAVEL = "vulneravel"
|
||||
COMPORTAMENTO_PATCH = "patch"
|
||||
COMPORTAMENTO_INCONCLUSIVO = "inconclusivo"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResultadoSonda:
|
||||
"""Resultado de uma sondagem ativa contra um alvo."""
|
||||
|
||||
executada: bool = False
|
||||
modo: str = "leve"
|
||||
erro: str = ""
|
||||
bytes_enviados: int = 0
|
||||
status_arm: Optional[int] = None
|
||||
status_controle: Optional[int] = None
|
||||
oom_arm: bool = False
|
||||
oom_controle: bool = False
|
||||
assinatura_memoria: bool = False
|
||||
oom_limite_bytes: Optional[int] = None
|
||||
oom_pre_rinit: bool = False
|
||||
corpo_arm: str = ""
|
||||
corpo_controle: str = ""
|
||||
post_max_size_bytes: Optional[int] = None
|
||||
atribuicao: str = ATRIBUICAO_NENHUMA
|
||||
#: True quando o worker do backend morreu no startup (502 do proxy):
|
||||
#: SAPI que nao sobrevive ao OOM (php-fpm, php-cgi, LiteSpeed).
|
||||
worker_morreu: bool = False
|
||||
#: True quando o backend abortou a requisicao e o proxy devolveu 200 com
|
||||
#: o corpo vazio (comportamento tipico do php-fpm atras de nginx).
|
||||
backend_abortou: bool = False
|
||||
#: True quando o OOM so acontece com o campo de progresso de upload;
|
||||
#: False quando o corpo de controle tambem falha; None quando nao houve OOM.
|
||||
upload_progress_ativo: Optional[bool] = None
|
||||
comportamento: Optional[str] = None
|
||||
follow_up_status: Optional[int] = None
|
||||
follow_up_corpo: str = ""
|
||||
tempo_total: float = 0.0
|
||||
observacoes: List[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def exposicao_confirmada(self) -> bool:
|
||||
"""O caminho de OOM pre-RINIT e alcancavel e atribuido a sessao.
|
||||
|
||||
Exige atribuicao positiva: o corpo com o campo de progresso falhou e o
|
||||
corpo de controle (mesmo peso, sem o campo) respondeu normalmente.
|
||||
"""
|
||||
return self.oom_arm and self.upload_progress_ativo is True
|
||||
|
||||
def resumo(self) -> str:
|
||||
if not self.executada:
|
||||
return "nao executada"
|
||||
if self.erro:
|
||||
return f"erro: {self.erro}"
|
||||
if self.comportamento == COMPORTAMENTO_PATCH:
|
||||
return "comportamento de versao corrigida: o parser cortou o corpo em post_max_size"
|
||||
if not self.oom_arm:
|
||||
if self.modo == "forte":
|
||||
return (
|
||||
"sem OOM com corpo acima de post_max_size: o parser nao entregou o corpo "
|
||||
"inteiro (comportamento de versao corrigida ou limite nao cruzado)"
|
||||
)
|
||||
return "sem OOM reproduzivel (limite de memoria nao atingido ou patch aplicado)"
|
||||
limite = f"{self.oom_limite_bytes // (1024 * 1024)}M" if self.oom_limite_bytes else "?"
|
||||
if self.atribuicao == ATRIBUICAO_SESSAO:
|
||||
return f"OOM pre-RINIT confirmado no caminho de sessao (memory_limit ~{limite})"
|
||||
if self.atribuicao == ATRIBUICAO_PARSER:
|
||||
return f"OOM pre-RINIT confirmado no parser multipart (memory_limit ~{limite})"
|
||||
return f"falha de memoria no startup (~{limite})"
|
||||
|
||||
|
||||
def gerar_sid() -> str:
|
||||
"""SID valido (alfanumerico, 32 caracteres) para a sonda."""
|
||||
return "u" + secrets.token_hex(15)
|
||||
|
||||
|
||||
def tamanho_sugerido(
|
||||
memory_limit_bytes: Optional[int],
|
||||
post_max_size_bytes: Optional[int] = None,
|
||||
forte: bool = False,
|
||||
) -> int:
|
||||
"""Tamanho do nome de arquivo que a sonda deve usar.
|
||||
|
||||
O nome do arquivo e guardado varias vezes no ciclo de parsing e de
|
||||
serializacao da sessao (arvore de progresso, copia do parser, copia
|
||||
serializada), o que multiplica o consumo por um fator de 3 a 5.
|
||||
|
||||
Sonda leve (`Content-Length`): o corpo precisa caber em `post_max_size`,
|
||||
senao o PHP recusa a requisicao antes de parsear. Por isso o tamanho e
|
||||
limitado por `post_max_size`.
|
||||
|
||||
Sonda forte (chunked): nao passa pela checagem de `post_max_size`, entao
|
||||
pode usar um nome grande o bastante para cruzar o `memory_limit`. Para que
|
||||
a diferenca de comportamento apareca, o corpo precisa ser **maior** que
|
||||
`post_max_size`: a versao corrigida interrompe a leitura ali, a antiga
|
||||
continua lendo e estoura a memoria. Isso so e observavel quando o
|
||||
`memory_limit` e bem maior que o `post_max_size` (configuracao padrao:
|
||||
128M contra 8M); caso contrario as duas versoes estouram a memoria antes
|
||||
de chegar ao limite de leitura.
|
||||
"""
|
||||
if forte:
|
||||
if post_max_size_bytes and post_max_size_bytes > 0:
|
||||
alvo = int(post_max_size_bytes * 1.2)
|
||||
else:
|
||||
alvo = TAMANHO_FORTE
|
||||
if memory_limit_bytes and memory_limit_bytes > 0:
|
||||
alvo = max(alvo, int(memory_limit_bytes / 4))
|
||||
return max(TAMANHO_FORTE, min(alvo, TAMANHO_MAXIMO))
|
||||
|
||||
if not memory_limit_bytes or memory_limit_bytes <= 0:
|
||||
return TAMANHO_LEVE
|
||||
sugerido = int(memory_limit_bytes / 4)
|
||||
if post_max_size_bytes and post_max_size_bytes > 0:
|
||||
# O corpo inteiro (nome + partes) precisa caber em post_max_size, senao
|
||||
# o PHP recusa a requisicao antes de parsear. O teto e a metade do
|
||||
# limite, para sobrar espaco para o resto do multipart.
|
||||
teto = post_max_size_bytes // 2
|
||||
sugerido = min(sugerido, teto)
|
||||
return max(min(TAMANHO_LEVE, teto), min(sugerido, teto))
|
||||
return max(TAMANHO_LEVE, sugerido)
|
||||
|
||||
|
||||
def _parte(
|
||||
nome: str,
|
||||
valor: bytes,
|
||||
boundary: bytes,
|
||||
filename: Optional[bytes] = None,
|
||||
) -> bytes:
|
||||
disp = b'Content-Disposition: form-data; name="' + nome.encode() + b'"'
|
||||
if filename is not None:
|
||||
disp += b'; filename="' + filename + b'"'
|
||||
return b"--" + boundary + b"\r\n" + disp + b"\r\n\r\n" + valor + b"\r\n"
|
||||
|
||||
|
||||
def construir_multipart(
|
||||
sid: str,
|
||||
tamanho_nome: int,
|
||||
boundary: bytes = b"----phpupscanBoundary",
|
||||
com_progresso: bool = True,
|
||||
) -> bytes:
|
||||
"""Monta o corpo multipart da sonda.
|
||||
|
||||
`tamanho_nome` e o tamanho do nome do arquivo enviado - e ele que cresce a
|
||||
arvore de progresso de upload dentro da sessao. `com_progresso=False`
|
||||
produz o corpo de controle (mesmo peso, sem o campo que liga o recurso).
|
||||
"""
|
||||
corpo = _parte("PHPSESSID", sid.encode(), boundary)
|
||||
if com_progresso:
|
||||
corpo += _parte("PHP_SESSION_UPLOAD_PROGRESS", b"x", boundary)
|
||||
nome_longo = b"n" * max(1, tamanho_nome)
|
||||
corpo += _parte("arquivo", b"A" * 1024, boundary, filename=nome_longo)
|
||||
return corpo + b"--" + boundary + b"--\r\n"
|
||||
|
||||
|
||||
def _analisar_resposta(resp: Resposta) -> Tuple[bool, bool, Optional[int], bool, Optional[int], str]:
|
||||
"""Extrai o que interessa de uma resposta.
|
||||
|
||||
Retorna (assinatura_memoria, erro_5xx, limite_em_bytes, pre_rinit,
|
||||
post_max_size_declarado, corpo_texto).
|
||||
"""
|
||||
texto = resp.texto
|
||||
m = RE_OOM.search(texto)
|
||||
limite = int(m.group(1)) if m else None
|
||||
assinatura = bool(m) or bool(LIMITE_ERRO.search(texto))
|
||||
erro_5xx = resp.status is not None and resp.status >= 500
|
||||
pre_rinit = bool(RE_PRE_RINIT.search(texto)) or bool(RE_STARTUP.search(texto))
|
||||
m_post = RE_POST_LIMITE.search(texto)
|
||||
post_max = int(m_post.group(1)) if m_post else None
|
||||
return assinatura, erro_5xx, limite, pre_rinit, post_max, texto
|
||||
|
||||
|
||||
def _worker_morreu(status: Optional[int]) -> bool:
|
||||
"""True quando o status indica que o worker do backend morreu.
|
||||
|
||||
Em php-fpm, php-cgi e LSAPI, o filho sai quando o startup da requisicao
|
||||
falha. O proxy na frente (nginx, Apache) responde 502 nesse caso. E o
|
||||
sinal de que a SAPI nao sobrevive ao OOM de startup - o caminho de reuso
|
||||
do estado corrompido fica fechado, mas sobra superficie de DoS.
|
||||
"""
|
||||
return status in (502, 503, 504)
|
||||
|
||||
|
||||
def combinar(leve: Optional[ResultadoSonda], forte: Optional[ResultadoSonda]) -> Optional[ResultadoSonda]:
|
||||
"""Une os resultados de duas sondas em um veredito unico.
|
||||
|
||||
A sonda forte e a mais informativa quando ela reproduz o OOM (ela entrega um
|
||||
corpo maior que `post_max_size`); a leve e a mais barata. A forte tambem
|
||||
carrega o veredito comportamental do patch, que vale mais que o banner de
|
||||
versao.
|
||||
"""
|
||||
if leve is None:
|
||||
return forte
|
||||
if forte is None:
|
||||
return leve
|
||||
|
||||
resultado = leve
|
||||
resultado.modo = "leve+forte"
|
||||
resultado.bytes_enviados = leve.bytes_enviados + forte.bytes_enviados
|
||||
resultado.observacoes = list(leve.observacoes) + [f"[forte] {o}" for o in forte.observacoes]
|
||||
resultado.tempo_total = leve.tempo_total + forte.tempo_total
|
||||
|
||||
if forte.oom_arm:
|
||||
# A sonda forte reproduziu o OOM: ela traz a evidencia mais forte.
|
||||
resultado.oom_arm = True
|
||||
resultado.assinatura_memoria = forte.assinatura_memoria
|
||||
resultado.oom_limite_bytes = forte.oom_limite_bytes or leve.oom_limite_bytes
|
||||
resultado.oom_pre_rinit = forte.oom_pre_rinit
|
||||
resultado.status_arm = forte.status_arm
|
||||
resultado.status_controle = forte.status_controle
|
||||
resultado.oom_controle = forte.oom_controle
|
||||
resultado.atribuicao = forte.atribuicao
|
||||
resultado.upload_progress_ativo = forte.upload_progress_ativo
|
||||
resultado.follow_up_status = forte.follow_up_status
|
||||
resultado.corpo_arm = forte.corpo_arm or leve.corpo_arm
|
||||
# Observacoes da sonda leve que aforte desmente: nao ficam no relatorio.
|
||||
resultado.observacoes = [
|
||||
o
|
||||
for o in resultado.observacoes
|
||||
if "Nenhum dos dois corpos provocou erro" not in o
|
||||
]
|
||||
if forte.post_max_size_bytes:
|
||||
resultado.post_max_size_bytes = forte.post_max_size_bytes
|
||||
if forte.comportamento is not None:
|
||||
resultado.comportamento = forte.comportamento
|
||||
if forte.comportamento == COMPORTAMENTO_PATCH:
|
||||
# A sonda leve pode ter acusado OOM mesmo numa versao corrigida; a
|
||||
# informacao do patch tem precedencia.
|
||||
resultado.observacoes = [
|
||||
o
|
||||
for o in resultado.observacoes
|
||||
if "Nenhum dos dois corpos provocou erro" not in o
|
||||
and "respondeu erro de memoria durante o startup" not in o
|
||||
]
|
||||
return resultado
|
||||
|
||||
|
||||
def sondar(
|
||||
url: str,
|
||||
timeout: float = 30.0,
|
||||
tamanho_nome: int = TAMANHO_LEVE,
|
||||
insecure: bool = False,
|
||||
fazer_controle: bool = True,
|
||||
fazer_follow_up: bool = True,
|
||||
forte: bool = False,
|
||||
) -> ResultadoSonda:
|
||||
"""Executa uma sondagem ativa contra uma URL de endpoint PHP.
|
||||
|
||||
`forte=True` usa `Transfer-Encoding: chunked` (sem `Content-Length`), que
|
||||
passa da checagem inicial de `post_max_size`.
|
||||
"""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
resultado = ResultadoSonda(executada=True, modo="forte" if forte else "leve")
|
||||
inicio = time.time()
|
||||
partes = urlparse(url)
|
||||
if not partes.hostname:
|
||||
resultado.erro = "URL sem hostname"
|
||||
return resultado
|
||||
porta = partes.port or (443 if partes.scheme == "https" else 80)
|
||||
caminho = partes.path or "/"
|
||||
boundary = b"----phpupscanBoundary" + secrets.token_hex(6).encode()
|
||||
|
||||
def nova_conexao() -> Conexao:
|
||||
return Conexao(
|
||||
partes.hostname or "",
|
||||
porta,
|
||||
tls=partes.scheme == "https",
|
||||
timeout=timeout,
|
||||
verificar_certificado=not insecure,
|
||||
)
|
||||
|
||||
def cabecalhos_de(sid: str) -> dict:
|
||||
return {
|
||||
"Content-Type": f"multipart/form-data; boundary={boundary.decode()}",
|
||||
"Cookie": f"PHPSESSID={sid}",
|
||||
}
|
||||
|
||||
sid = gerar_sid()
|
||||
corpo_arm = construir_multipart(sid, tamanho_nome, boundary, com_progresso=True)
|
||||
resultado.bytes_enviados = len(corpo_arm)
|
||||
|
||||
assinatura_arm = False
|
||||
erro5xx_arm = False
|
||||
|
||||
conexao = None
|
||||
try:
|
||||
conexao = nova_conexao()
|
||||
resp = conexao.requisitar(
|
||||
"POST",
|
||||
caminho,
|
||||
corpo=corpo_arm,
|
||||
cabecalhos=cabecalhos_de(sid),
|
||||
chunked=forte,
|
||||
)
|
||||
resultado.status_arm = resp.status
|
||||
assinatura_arm, erro5xx_arm, limite, pre_rinit, post_max, texto = _analisar_resposta(resp)
|
||||
resultado.assinatura_memoria = assinatura_arm
|
||||
resultado.oom_limite_bytes = limite
|
||||
resultado.oom_pre_rinit = pre_rinit
|
||||
resultado.post_max_size_bytes = post_max
|
||||
resultado.corpo_arm = texto[:2000]
|
||||
|
||||
if assinatura_arm:
|
||||
resultado.observacoes.append(
|
||||
"O servidor respondeu erro de memoria durante o startup da requisicao "
|
||||
"(antes de qualquer codigo da aplicacao)."
|
||||
)
|
||||
if limite:
|
||||
resultado.observacoes.append(
|
||||
f"O servidor informou memory_limit de aproximadamente {limite} bytes "
|
||||
f"({limite // (1024 * 1024)}M) - limite finito e programavel."
|
||||
)
|
||||
if post_max:
|
||||
resultado.observacoes.append(
|
||||
f"O servidor cortou a leitura do corpo em post_max_size ({post_max} bytes): "
|
||||
"comportamento de versao com o patch aplicado."
|
||||
)
|
||||
resultado.comportamento = COMPORTAMENTO_PATCH
|
||||
if _worker_morreu(resp.status):
|
||||
resultado.worker_morreu = True
|
||||
resultado.observacoes.append(
|
||||
f"O proxy respondeu {resp.status}: o worker do backend morreu durante o startup "
|
||||
"da requisicao. Isso e o comportamento de SAPI que nao sobrevive ao OOM "
|
||||
"(php-fpm, php-cgi, LiteSpeed): o caminho de reuso do estado corrompido fica "
|
||||
"fechado, mas sobra superficie de DoS."
|
||||
)
|
||||
|
||||
if fazer_follow_up:
|
||||
# Mesma conexao (mesmo worker em prefork): verifica se o estado
|
||||
# corrompido do worker foi reaproveitado. Se o servidor ja
|
||||
# encerrou a conexao (HTTP/1.0 ou Connection: close), o follow-up
|
||||
# nao diz nada sobre o estado do worker.
|
||||
if resp.servidor_fechou or (resp.headers.get("connection", "").lower() == "close"):
|
||||
resultado.observacoes.append(
|
||||
"O servidor encerrou a conexao depois da falha, entao nao foi possivel "
|
||||
"avaliar o estado do worker com uma requisicao seguinte."
|
||||
)
|
||||
else:
|
||||
resp2 = conexao.requisitar("GET", caminho, cabecalhos=cabecalhos_de(sid))
|
||||
resultado.follow_up_status = resp2.status
|
||||
resultado.follow_up_corpo = resp2.texto[:1000]
|
||||
if resp2.erro:
|
||||
resultado.observacoes.append(
|
||||
f"A requisicao seguinte na mesma conexao falhou ({resp2.erro}): "
|
||||
"sinal tipico de worker em estado invalido."
|
||||
)
|
||||
elif resp2.status is None or resp2.status >= 500:
|
||||
resultado.observacoes.append(
|
||||
"A requisicao seguinte na mesma conexao falhou: sinal tipico de worker "
|
||||
"em estado invalido apos o OOM."
|
||||
)
|
||||
elif assinatura_arm or erro5xx_arm:
|
||||
resultado.observacoes.append(
|
||||
"A requisicao seguinte na mesma conexao respondeu normalmente: o worker "
|
||||
"continua atendendo apos o OOM."
|
||||
)
|
||||
except OSError as exc:
|
||||
resultado.erro = f"conexao: {exc}"
|
||||
return resultado
|
||||
finally:
|
||||
if conexao is not None:
|
||||
conexao.fechar()
|
||||
|
||||
assinatura_ctrl = False
|
||||
erro5xx_ctrl = False
|
||||
corpo_controle = ""
|
||||
if fazer_controle:
|
||||
sid_controle = gerar_sid()
|
||||
corpo_controle = construir_multipart(
|
||||
sid_controle, tamanho_nome, boundary, com_progresso=False
|
||||
)
|
||||
try:
|
||||
with nova_conexao() as con:
|
||||
resp3 = con.requisitar(
|
||||
"POST",
|
||||
caminho,
|
||||
corpo=corpo_controle,
|
||||
cabecalhos=cabecalhos_de(sid_controle),
|
||||
chunked=forte,
|
||||
)
|
||||
resultado.status_controle = resp3.status
|
||||
(
|
||||
assinatura_ctrl,
|
||||
erro5xx_ctrl,
|
||||
_limite_c,
|
||||
_pre_c,
|
||||
post_max_c,
|
||||
texto_c,
|
||||
) = _analisar_resposta(resp3)
|
||||
resultado.corpo_controle = texto_c[:1000]
|
||||
if post_max_c and resultado.post_max_size_bytes is None:
|
||||
resultado.post_max_size_bytes = post_max_c
|
||||
except OSError as exc:
|
||||
resultado.erro = f"controle: {exc}"
|
||||
|
||||
# O backend pode abortar a requisicao sem que o proxy traduza isso em 5xx:
|
||||
# com php-fpm, o filho morre e o nginx devolve 200 com o corpo vazio. O sinal
|
||||
# e a diferenca entre o corpo da sonda (vazio) e o do controle (com conteudo).
|
||||
if (
|
||||
not resultado.erro
|
||||
and resultado.status_arm == 200
|
||||
and resultado.status_controle == 200
|
||||
and not resultado.corpo_arm.strip()
|
||||
and resultado.corpo_controle.strip()
|
||||
):
|
||||
resultado.backend_abortou = True
|
||||
resultado.worker_morreu = True
|
||||
resultado.observacoes = [
|
||||
o for o in resultado.observacoes if "Nenhum dos dois corpos" not in o
|
||||
]
|
||||
resultado.observacoes.append(
|
||||
"A requisicao com o campo de progresso voltou com o corpo vazio, enquanto o "
|
||||
"controle voltou com conteudo: o backend abortou a requisicao no startup, que e "
|
||||
"o que acontece quando o worker morre (php-fpm, php-cgi, LiteSpeed)."
|
||||
)
|
||||
if not resultado.oom_arm:
|
||||
resultado.oom_arm = True
|
||||
resultado.atribuicao = ATRIBUICAO_SESSAO
|
||||
resultado.upload_progress_ativo = True
|
||||
resultado.comportamento = COMPORTAMENTO_VULNERAVEL
|
||||
|
||||
# --- atribuicao -------------------------------------------------------
|
||||
if resultado.erro or resultado.backend_abortou:
|
||||
pass
|
||||
elif assinatura_ctrl or erro5xx_ctrl:
|
||||
# O controle tambem falhou: o OOM nao e do recurso de progresso de upload.
|
||||
resultado.oom_controle = True
|
||||
resultado.atribuicao = ATRIBUICAO_PARSER
|
||||
resultado.upload_progress_ativo = False
|
||||
resultado.observacoes.append(
|
||||
"O corpo de controle (sem o campo PHP_SESSION_UPLOAD_PROGRESS) tambem falhou: "
|
||||
"o OOM e do parser multipart em geral, nao do recurso de progresso de upload."
|
||||
)
|
||||
elif assinatura_arm or erro5xx_arm:
|
||||
resultado.oom_arm = True
|
||||
resultado.atribuicao = ATRIBUICAO_SESSAO
|
||||
resultado.upload_progress_ativo = True
|
||||
if resultado.comportamento != COMPORTAMENTO_PATCH:
|
||||
resultado.comportamento = COMPORTAMENTO_VULNERAVEL
|
||||
resultado.observacoes.append(
|
||||
"O corpo de controle, sem o campo PHP_SESSION_UPLOAD_PROGRESS, respondeu "
|
||||
"normalmente: o OOM vem do caminho de progresso de upload de sessao."
|
||||
)
|
||||
else:
|
||||
resultado.observacoes.append(
|
||||
"Nenhum dos dois corpos provocou erro: o tamanho usado nao cruza o limite de "
|
||||
"memoria do alvo."
|
||||
)
|
||||
|
||||
if resultado.comportamento is None and forte:
|
||||
resultado.comportamento = COMPORTAMENTO_INCONCLUSIVO
|
||||
|
||||
resultado.tempo_total = time.time() - inicio
|
||||
return resultado
|
||||
@@ -0,0 +1,4 @@
|
||||
"""Testes do phpupscan (sem dependencias externas).
|
||||
|
||||
Rodar: python3 -m unittest discover -s testes -v
|
||||
"""
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Testes das sondas ativas (construcao de corpo, tamanhos e analise)."""
|
||||
|
||||
import unittest
|
||||
|
||||
from phpupscan.sondas import (
|
||||
ATRIBUICAO_NENHUMA,
|
||||
ATRIBUICAO_PARSER,
|
||||
ATRIBUICAO_SESSAO,
|
||||
COMPORTAMENTO_PATCH,
|
||||
COMPORTAMENTO_VULNERAVEL,
|
||||
TAMANHO_LEVE,
|
||||
construir_multipart,
|
||||
tamanho_sugerido,
|
||||
)
|
||||
from phpupscan.httpclient import Resposta
|
||||
|
||||
|
||||
class ConstrucaoDeCorpo(unittest.TestCase):
|
||||
def test_campo_de_progresso_presente(self):
|
||||
corpo = construir_multipart("u123", 1000, com_progresso=True)
|
||||
self.assertIn(b"PHP_SESSION_UPLOAD_PROGRESS", corpo)
|
||||
|
||||
def test_corpo_de_controle_nao_tem_o_campo(self):
|
||||
corpo = construir_multipart("u123", 1000, com_progresso=False)
|
||||
self.assertNotIn(b"PHP_SESSION_UPLOAD_PROGRESS", corpo)
|
||||
|
||||
def test_controle_tem_o_mesmo_peso_do_campo_com_progresso(self):
|
||||
com = construir_multipart("u123", 1000, com_progresso=True)
|
||||
sem = construir_multipart("u123", 1000, com_progresso=False)
|
||||
# A diferenca deve ser apenas o campo de progresso.
|
||||
self.assertLess(abs(len(com) - len(sem)), 100)
|
||||
|
||||
def test_nome_do_arquivo_cresce_com_o_tamanho(self):
|
||||
pequeno = construir_multipart("u123", 1000)
|
||||
grande = construir_multipart("u123", 50000)
|
||||
self.assertGreater(len(grande), len(pequeno))
|
||||
self.assertIn(b"n" * 1000, grande)
|
||||
|
||||
def test_sid_e_incluido(self):
|
||||
corpo = construir_multipart("uabc123", 1000)
|
||||
self.assertIn(b"uabc123", corpo)
|
||||
|
||||
def test_limite_do_corpo(self):
|
||||
corpo = construir_multipart("u123", 2000)
|
||||
self.assertTrue(corpo.endswith(b"--\r\n"))
|
||||
|
||||
|
||||
class TamanhoSugerido(unittest.TestCase):
|
||||
def test_sem_limite_conhecido_usa_padrao(self):
|
||||
self.assertEqual(tamanho_sugerido(None), TAMANHO_LEVE)
|
||||
|
||||
def test_limite_de_8m_sugere_nome_pequeno(self):
|
||||
self.assertLess(tamanho_sugerido(8 * 1024 * 1024), 4 * 1024 * 1024)
|
||||
|
||||
def test_limite_respeita_post_max_size(self):
|
||||
# post_max_size pequeno impede um nome grande na sonda com Content-Length.
|
||||
tamanho = tamanho_sugerido(128 * 1024 * 1024, 1024 * 1024)
|
||||
self.assertLessEqual(tamanho, 1024 * 1024)
|
||||
|
||||
def test_sonda_forte_ignora_post_max_size(self):
|
||||
# Na sonda chunked o corpo pode passar de post_max_size.
|
||||
tamanho = tamanho_sugerido(128 * 1024 * 1024, 1024 * 1024, forte=True)
|
||||
self.assertGreater(tamanho, 1024 * 1024)
|
||||
|
||||
|
||||
class AnaliseDeResposta(unittest.TestCase):
|
||||
"""A analise de resposta acontece dentro de `sondar`; aqui testamos o
|
||||
comportamento observavel via a funcao interna."""
|
||||
|
||||
def _analisar(self, texto, status=200):
|
||||
from phpupscan.sondas import _analisar_resposta
|
||||
|
||||
return _analisar_resposta(Resposta(status=status, body=texto.encode()))
|
||||
|
||||
def test_assinatura_de_memoria(self):
|
||||
assinatura, erro5xx, limite, pre_rinit, post_max, _ = self._analisar(
|
||||
"Allowed memory size of 8388608 bytes exhausted (tried to allocate 1302528 bytes)"
|
||||
)
|
||||
self.assertTrue(assinatura)
|
||||
self.assertEqual(limite, 8388608)
|
||||
self.assertFalse(erro5xx)
|
||||
|
||||
def test_fatal_pre_rinit(self):
|
||||
_assinatura, _erro5xx, _limite, pre_rinit, _post_max, _ = self._analisar(
|
||||
"<b>Fatal error</b>: ... in <b>Unknown</b> on line <b>0</b>"
|
||||
)
|
||||
self.assertTrue(pre_rinit)
|
||||
|
||||
def test_mensagem_de_post_max_size(self):
|
||||
_assinatura, _erro5xx, _limite, _pre_rinit, post_max, _ = self._analisar(
|
||||
"Actual POST length does not match Content-Length, and exceeds 8388608 bytes"
|
||||
)
|
||||
self.assertEqual(post_max, 8388608)
|
||||
|
||||
def test_resposta_normal_nao_e_erro(self):
|
||||
assinatura, erro5xx, limite, pre_rinit, post_max, _ = self._analisar("OK 8.2.34")
|
||||
self.assertFalse(assinatura)
|
||||
self.assertFalse(erro5xx)
|
||||
self.assertIsNone(limite)
|
||||
self.assertFalse(pre_rinit)
|
||||
self.assertIsNone(post_max)
|
||||
|
||||
|
||||
class Constantes(unittest.TestCase):
|
||||
def test_rotulos_de_atribuicao(self):
|
||||
self.assertNotEqual(ATRIBUICAO_SESSAO, ATRIBUICAO_PARSER)
|
||||
self.assertNotEqual(ATRIBUICAO_NENHUMA, ATRIBUICAO_SESSAO)
|
||||
|
||||
def test_rotulos_de_comportamento(self):
|
||||
self.assertNotEqual(COMPORTAMENTO_PATCH, COMPORTAMENTO_VULNERAVEL)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
Reference in new issue
Block a user