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).
480 lines
17 KiB
Python
480 lines
17 KiB
Python
"""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())
|