Files
phpupscan/phpupscan/lab.py
T
lgcosta 6370227ad1 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).
2026-10-22 19:44:46 -03:00

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())