"""Laboratorio local: valida o verificador contra versoes vulneravel e corrigida. Sobe tres containers (a imagem base e a de uma imagem de runtime de containers): 1. PHP 8.2.34 com mod_php (prefork) - vulneravel 2. PHP 8.2.35 com mod_php (prefork) - corrigida 3. PHP 8.2.34 com session.upload_progress.enabled=Off - controle Depois roda o proprio verificador contra os tres e imprime recibos PASS/FAIL. """ from __future__ import annotations import argparse import os import shutil import subprocess import sys import tempfile import time from typing import List, Optional from .detectar import SAPI_FPM, reconhecer from .relatorio import _avaliar from .sondas import ( ATRIBUICAO_SESSAO, COMPORTAMENTO_PATCH, TAMANHO_FORTE, sondar, ) IMAGEM_VULN = "docker.io/library/php:8.2.34-apache" IMAGEM_FIX = "docker.io/library/php:8.2.35-apache" IMAGEM_FPM = "docker.io/library/php:8.2.34-fpm-alpine" IMAGEM_NGINX = "docker.io/library/nginx:alpine" #: Configuracao com memory_limit pequeno: o OOM acontece cedo e a sonda leve #: (Content-Length) ja reproduz o pre-RINIT. INI_LAB = """memory_limit = 8M post_max_size = 32M upload_max_filesize = 32M max_file_uploads = 25 session.upload_progress.min_freq = 0 session.save_path = /tmp/sess """ #: Configuracao padrao do PHP (memory_limit 128M, post_max_size 8M): e o #: cenario em que a sonda forte (chunked) distingue versao corrigida de #: vulneravel, porque o parser corrigido corta a leitura em post_max_size #: antes de estourar a memoria. INI_PADRAO = """display_errors = On """ INI_CONTROLE = "session.upload_progress.enabled = 0\n" #: Configuracao do php-fpm do laboratorio (para o par nginx + php-fpm). INI_FPM = """memory_limit = 8M post_max_size = 32M upload_max_filesize = 32M max_file_uploads = 25 session.upload_progress.min_freq = 0 session.save_path = /tmp/sess """ #: nginx que faz de proxy para o php-fpm do laboratorio. NGINX_LAB = """server { listen 80; index index.php; client_max_body_size 32M; location / { try_files $uri $uri/ =404; } location ~ \\.php$ { include fastcgi_params; fastcgi_pass phpupscan-lab-fpm:9000; fastcgi_param SCRIPT_FILENAME /var/www/html$fastcgi_script_name; } } """ HTML_LAB = """ Optional[str]: if desejado: return shutil.which(desejado) for motor in ("podman", "docker"): caminho = shutil.which(motor) if caminho: return caminho return None class Container: """Um container do laboratorio.""" def __init__( self, motor: str, nome: str, imagem: str, porta: int, raiz: str, extras: List[str], rede: Optional[str] = None, comando: Optional[List[str]] = None, porta_interna: int = 80, destino: str = "/var/www/html", volumes_extra: Optional[List[str]] = None, ): self.motor = motor self.nome = nome self.imagem = imagem self.porta = porta self.raiz = raiz self.extras = extras self.rede = rede self.comando = comando self.porta_interna = porta_interna self.destino = destino self.volumes_extra = volumes_extra or [] def subir(self) -> bool: cmd = [ self.motor, "run", "-d", "--name", self.nome, ] if self.rede: cmd += ["--network", self.rede] if self.porta: cmd += ["-p", f"{self.porta}:{self.porta_interna}"] cmd += [ "-v", f"{self.raiz}/html:{self.destino}:ro", "-v", f"{self.raiz}/sess:/tmp/sess", ] for ini in self.extras: cmd += ["-v", f"{ini[0]}:/usr/local/etc/php/conf.d/{ini[1]}:ro"] for volume in self.volumes_extra: cmd += ["-v", volume] cmd.append(self.imagem) if self.comando: cmd += self.comando resultado = subprocess.run(cmd, capture_output=True, text=True) if resultado.returncode != 0: print(f" erro ao subir {self.nome}: {resultado.stderr.strip()}", file=sys.stderr) return False return True def remover(self) -> None: subprocess.run([self.motor, "rm", "-f", self.nome], capture_output=True, text=True) def url(self, caminho: str = "/index.php") -> str: return f"http://127.0.0.1:{self.porta}{caminho}" def _esperar(url: str, tentativas: int = 40) -> bool: import urllib.error import urllib.request for _ in range(tentativas): try: with urllib.request.urlopen(url, timeout=2) as resp: if resp.status == 200: return True except Exception: time.sleep(0.5) return False class Recibos: """Acumula PASS/FAIL do laboratorio.""" def __init__(self) -> None: self.passes = 0 self.falhas = 0 def checar(self, condicao: bool, descricao: str, detalhe: str = "") -> bool: if condicao: self.passes += 1 print(f" [PASS] {descricao}" + (f" - {detalhe}" if detalhe else "")) else: self.falhas += 1 print(f" [FAIL] {descricao}" + (f" - {detalhe}" if detalhe else "")) return condicao def executar_lab(args: argparse.Namespace) -> int: motor = _detectar_motor(args.motor) if not motor: print( "Nao encontrei podman nem docker. Instale um dos dois para rodar o laboratorio " "local (o laboratorio so usa imagens publicas do PHP e nao toca a rede alem dos pulls)." ) return 2 print(f"phpupscan - laboratorio local (motor: {os.path.basename(motor)})") print("Aviso: o laboratorio cria containers locais e remove tudo ao final.") print() raiz = tempfile.mkdtemp(prefix="phpupscan-lab-") os.makedirs(os.path.join(raiz, "html"), exist_ok=True) os.makedirs(os.path.join(raiz, "sess"), exist_ok=True) os.chmod(os.path.join(raiz, "sess"), 0o777) with open(os.path.join(raiz, "html", "index.php"), "w", encoding="utf-8") as fh: fh.write(HTML_LAB) ini_principal = os.path.join(raiz, "99-lab.ini") with open(ini_principal, "w", encoding="utf-8") as fh: fh.write(INI_LAB) ini_controle = os.path.join(raiz, "98-off.ini") with open(ini_controle, "w", encoding="utf-8") as fh: fh.write(INI_CONTROLE) ini_padrao = os.path.join(raiz, "99-padrao.ini") with open(ini_padrao, "w", encoding="utf-8") as fh: fh.write(INI_PADRAO) nginx_conf = os.path.join(raiz, "nginx.conf") with open(nginx_conf, "w", encoding="utf-8") as fh: fh.write(NGINX_LAB) ini_fpm = os.path.join(raiz, "99-fpm.ini") with open(ini_fpm, "w", encoding="utf-8") as fh: fh.write(INI_FPM) vuln = Container(motor, "phpupscan-lab-vuln", IMAGEM_VULN, args.porta, raiz, [(ini_principal, "99-lab.ini")]) fix = Container(motor, "phpupscan-lab-fix", IMAGEM_FIX, args.porta + 1, raiz, [(ini_principal, "99-lab.ini")]) controle = Container( motor, "phpupscan-lab-ctrl", IMAGEM_VULN, args.porta + 2, raiz, [(ini_principal, "99-lab.ini"), (ini_controle, "98-off.ini")], ) # Par com configuracao padrao do PHP (memory_limit 128M, post_max_size 8M): # e o cenario em que a sonda forte (chunked) distingue patch de vulneravel. vuln_padrao = Container( motor, "phpupscan-lab-vuln-def", IMAGEM_VULN, args.porta + 3, raiz, [(ini_padrao, "99-padrao.ini")] ) fix_padrao = Container( motor, "phpupscan-lab-fix-def", IMAGEM_FIX, args.porta + 4, raiz, [(ini_padrao, "99-padrao.ini")] ) # Par nginx + php-fpm: valida a matriz de SAPI (o filho do FPM morre no # startup e o proxy responde 502). rede = "phpupscan-lab-rede" subprocess.run([motor, "network", "rm", "-f", rede], capture_output=True, text=True) subprocess.run([motor, "network", "create", rede], capture_output=True, text=True) fpm = Container( motor, "phpupscan-lab-fpm", IMAGEM_FPM, 0, raiz, [(ini_fpm, "99-fpm.ini")], rede=rede, porta_interna=9000, ) nginx = Container( motor, "phpupscan-lab-nginx", IMAGEM_NGINX, args.porta + 5, raiz, [], rede=rede, volumes_extra=[f"{nginx_conf}:/etc/nginx/conf.d/default.conf:ro"], ) containers = [vuln, fix, controle, vuln_padrao, fix_padrao, fpm, nginx] try: for container in containers: print(f"Subindo {container.nome} ({container.imagem})...") if not container.subir(): return 2 for container in containers: if not container.porta: # Container sem porta exposta (o php-fpm responde so via nginx). continue if not _esperar(container.url()): print(f" o container {container.nome} nao respondeu", file=sys.stderr) return 2 print() print("Recibos:") recibos = Recibos() # 1) Reconhecimento print() print("1) Reconhecimento de versao e SAPI") perfis = {} for container in containers: if not container.porta: continue perfil = reconhecer(container.url(), timeout=10.0, buscar_phpinfo=False) perfis[container.nome] = perfil print(f" {container.nome}: {perfil.php_versao_bruta or '?'} / {perfil.sapi}") recibos.checar( perfis[vuln.nome].php_versao_bruta == "8.2.34", "alvo vulnavel reporta PHP 8.2.34", perfis[vuln.nome].php_versao_bruta, ) recibos.checar( perfis[fix.nome].php_versao_bruta == "8.2.35", "alvo corrigido reporta PHP 8.2.35", perfis[fix.nome].php_versao_bruta, ) # 2) Classificacao de versao print() print("2) Classificacao de versao contra o advisory") from . import versoes as mod_versoes recibos.checar( mod_versoes.classificar((8, 2, 34)) == mod_versoes.VULNERAVEL, "8.2.34 classificada como vulneravel", ) recibos.checar( mod_versoes.classificar((8, 2, 35)) == mod_versoes.CORRIGIDO, "8.2.35 classificada como corrigida", ) recibos.checar( mod_versoes.classificar((8, 1, 33)) == mod_versoes.SEM_CORRECAO, "8.1.33 classificada como sem correcao oficial", ) # 3) Sonda ativa contra o alvo vulneravel print() print("3) Sonda ativa contra o alvo vulneravel (8.2.34)") sonda_vuln = sondar(vuln.url(), timeout=25.0, tamanho_nome=1_300_000) print(f" {sonda_vuln.resumo()}") for obs in sonda_vuln.observacoes: print(f" - {obs}") recibos.checar(sonda_vuln.oom_arm, "OOM reproduzido no alvo vulneravel") recibos.checar( sonda_vuln.upload_progress_ativo is True, "controle sem PHP_SESSION_UPLOAD_PROGRESS nao estourou memoria", f"status controle={sonda_vuln.status_controle}", ) recibos.checar( sonda_vuln.atribuicao == ATRIBUICAO_SESSAO, "OOM atribuido ao caminho de progresso de upload de sessao", sonda_vuln.atribuicao, ) # 4) Controle com upload_progress desligado print() print("4) Controle: upload_progress desligado (8.2.34)") sonda_ctrl = sondar(controle.url(), timeout=25.0, tamanho_nome=1_300_000) print(f" {sonda_ctrl.resumo()}") recibos.checar( not sonda_ctrl.oom_arm, "com upload_progress=Off o mesmo corpo nao estourou memoria", f"status={sonda_ctrl.status_arm}", ) # 5) Sonda contra a versao corrigida print() print("5) Sonda ativa contra o alvo corrigido (8.2.35)") sonda_fix = sondar(fix.url(), timeout=25.0, tamanho_nome=1_300_000) print(f" {sonda_fix.resumo()}") recibos.checar( sonda_fix.oom_arm, "a versao corrigida ainda pode estourar memoria (o patch nao muda o OOM)", "isso e esperado: o patch evita o reuso do estado corrompido", ) # 6) Sonda forte (chunked): verificacao comportamental do patch print() print("6) Sonda forte (chunked, corpo acima de post_max_size)") tamanho_forte = TAMANHO_FORTE sonda_forte_vuln = sondar( vuln_padrao.url(), timeout=90.0, tamanho_nome=tamanho_forte, forte=True ) sonda_forte_fix = sondar( fix_padrao.url(), timeout=90.0, tamanho_nome=tamanho_forte, forte=True ) print(f" vulnavel -> {sonda_forte_vuln.resumo()}") print(f" corrigido -> {sonda_forte_fix.resumo()}") recibos.checar( sonda_forte_vuln.oom_arm, "no alvo vulneravel o corpo acima de post_max_size provocou OOM", f"status={sonda_forte_vuln.status_arm}", ) recibos.checar( sonda_forte_fix.comportamento == COMPORTAMENTO_PATCH, "no alvo corrigido o parser interrompeu a leitura em post_max_size", f"post_max_size={sonda_forte_fix.post_max_size_bytes}", ) # 7) Veredito final print() print("7) Veredito final do verificador") resultado_vuln = _avaliar(perfis[vuln.nome], sonda_vuln) resultado_fix = _avaliar(perfis[fix.nome], sonda_fix) print(f" vulnavel -> {resultado_vuln.nivel}: {resultado_vuln.resumo}") print(f" corrigido -> {resultado_fix.nivel}: {resultado_fix.resumo}") recibos.checar( resultado_vuln.nivel in ("CRITICO", "ALTO"), "alvo vulneravel classificado como CRITICO/ALTO", resultado_vuln.nivel, ) recibos.checar( resultado_fix.nivel != "CRITICO", "alvo corrigido nao classificado como CRITICO", resultado_fix.nivel, ) # 8) Veredito final com a sonda forte (configuracao padrao do PHP) print() print("8) Veredito final usando a sonda forte (configuracao padrao)") perfil_vuln_def = reconhecer(vuln_padrao.url(), timeout=10.0, buscar_phpinfo=False) perfil_fix_def = reconhecer(fix_padrao.url(), timeout=10.0, buscar_phpinfo=False) veredito_vuln_def = _avaliar(perfil_vuln_def, sonda_forte_vuln) veredito_fix_def = _avaliar(perfil_fix_def, sonda_forte_fix) print(f" vulnavel -> {veredito_vuln_def.nivel}: {veredito_vuln_def.resumo}") print(f" corrigido -> {veredito_fix_def.nivel}: {veredito_fix_def.resumo}") recibos.checar( veredito_vuln_def.nivel == "CRITICO", "alvo vulneravel em configuracao padrao classificado como CRITICO", veredito_vuln_def.nivel, ) recibos.checar( veredito_fix_def.nivel in ("OK", "BAIXO", "MEDIO"), "alvo corrigido em configuracao padrao nao classificado como CRITICO", veredito_fix_def.nivel, ) # 9) Matriz de SAPI: nginx + php-fpm print() print("9) Matriz de SAPI: nginx + php-fpm (o filho morre no startup)") perfil_fpm = reconhecer(nginx.url(), timeout=10.0, buscar_phpinfo=False) sonda_fpm = sondar(nginx.url(), timeout=60.0, tamanho_nome=1_300_000) veredito_fpm = _avaliar(perfil_fpm, sonda_fpm) print(f" SAPI detectada: {perfil_fpm.sapi}") print(f" {sonda_fpm.resumo()}") print(f" veredito -> {veredito_fpm.nivel}: {veredito_fpm.resumo}") recibos.checar( perfil_fpm.sapi == SAPI_FPM, "SAPI php-fpm detectada atras do nginx", perfil_fpm.sapi, ) recibos.checar( sonda_fpm.worker_morreu or sonda_fpm.backend_abortou, "o worker do php-fpm morreu no startup (o proxy nao devolveu conteudo)", f"status={sonda_fpm.status_arm} abortou={sonda_fpm.backend_abortou}", ) recibos.checar( veredito_fpm.nivel in ("ALTO", "MEDIO"), "alvo com php-fpm nao classificado como CRITICO", veredito_fpm.nivel, ) print() print(f"RESULTADO: {recibos.passes} PASS, {recibos.falhas} FAIL") return 0 if recibos.falhas == 0 else 1 finally: if not args.manter: for container in containers: container.remover() subprocess.run([motor, "network", "rm", "-f", "phpupscan-lab-rede"], capture_output=True) shutil.rmtree(raiz, ignore_errors=True) else: print(f"Containers mantidos. Raiz temporaria: {raiz}") def main() -> int: parser = argparse.ArgumentParser(description="Laboratorio local do phpupscan") parser.add_argument("--motor", choices=["podman", "docker"], default=None) parser.add_argument("--porta", type=int, default=18081) parser.add_argument("--manter", action="store_true") return executar_lab(parser.parse_args()) if __name__ == "__main__": sys.exit(main())