#!/usr/bin/env python3 #php2shell scanner import argparse import csv import random import re import sys import time import urllib3 import requests from concurrent.futures import ThreadPoolExecutor, as_completed from urllib.parse import urljoin urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) # ============================================================ # KONFIGURASI # ============================================================ FIXED = { (8, 2): 35, (8, 3): 36, (8, 4): 27, (8, 5): 12, } # Path phpinfo umum (GET only, read-only) PHPINFO_PATHS = [ "/phpinfo.php", "/info.php", "/test.php", "/php.php", "/phpinfo", "/i.php", "/p.php", ] USER_AGENTS = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0", "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Mobile/15E148 Safari/604.1", ] PHP_VER_RE = re.compile(r"PHP/(\d+)\.(\d+)\.(\d+)", re.I) PHPINFO_RE = re.compile(r"PHP\s+Version\s*\s*]*>\s*(\d+)\.(\d+)\.(\d+)", re.I | re.S) GENERIC_RE = re.compile(r"\bPHP\s+(\d+)\.(\d+)\.(\d+)", re.I) def headers(): return { "User-Agent": random.choice(USER_AGENTS), "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "en-US,en;q=0.9", "Connection": "keep-alive", } def fetch(session, url, timeout, verify, method="GET", **kwargs): try: time.sleep(random.uniform(0.15, 0.4)) if method == "POST": return session.post(url, headers=headers(), timeout=timeout, verify=verify, allow_redirects=True, **kwargs) return session.get(url, headers=headers(), timeout=timeout, verify=verify, allow_redirects=True, **kwargs) except Exception: return None def parse_ver(text): for rx in (PHP_VER_RE, PHPINFO_RE, GENERIC_RE): m = rx.search(text or "") if m: return tuple(int(x) for x in m.groups()) return None def verdict(v): if not v: return "UNKNOWN", "versi tidak terdeteksi" maj, mnr, pat = v br = (maj, mnr) if br <= (8, 1): return "EOL_NO_FIX", f"PHP {maj}.{mnr}.{pat} — branch EOL, tidak ada patch upstream" fixed = FIXED.get(br) if fixed is None: return "UNKNOWN", f"PHP {maj}.{mnr}.{pat} — branch tidak dikenal" if pat >= fixed: return "PATCHED", f"PHP {maj}.{mnr}.{pat} >= {maj}.{mnr}.{fixed}" return "VULNERABLE_VERSION", f"PHP {maj}.{mnr}.{pat} < {maj}.{mnr}.{fixed}" def fingerprint(session, base, timeout, verify): """Kumpulkan kandidat versi dari header + phpinfo.""" cands = [] r = fetch(session, base, timeout, verify) if r is None: return cands, None for h in ("X-Powered-By", "Server"): val = r.headers.get(h, "") if "PHP" in val.upper(): v = parse_ver(val) if v: cands.append((f"header:{h}", v, val)) v = parse_ver(r.text) if v: cands.append(("body", v, "generator/meta")) for p in PHPINFO_PATHS: u = urljoin(base.rstrip("/") + "/", p.lstrip("/")) rr = fetch(session, u, timeout, verify) if rr is None or rr.status_code >= 400: continue v = parse_ver(rr.text) if v: cands.append((f"phpinfo:{p}", v, u)) break return cands, r def probe_upload_progress(session, base, timeout, verify): """ Kirim multipart POST kecil dengan field PHP_SESSION_UPLOAD_PROGRESS. Tidak mengirim file, tidak mengirim payload heap-groom. Jika server membalas Set-Cookie: PHPSESSID, fitur aktif. """ url = base.rstrip("/") + "/index.php" try: time.sleep(random.uniform(0.2, 0.5)) r = session.post( url, headers=headers(), files={"PHP_SESSION_UPLOAD_PROGRESS": (None, "probe")}, timeout=timeout, verify=verify, allow_redirects=False, ) except Exception: return None if r is None: return None # Cek Set-Cookie PHPSESSID for cookie in r.headers.get("Set-Cookie", "").split(","): if "PHPSESSID" in cookie: return "ACTIVE (Set-Cookie PHPSESSID)" # Cek Set-Cookie umum (beberapa framework pakai nama lain) if "Set-Cookie" in r.headers: return f"COOKIE_SET ({r.headers['Set-Cookie'][:60]}...)" return "NO_COOKIE" def scan_one(raw, args): if not raw.startswith(("http://", "https://")): raw = "https://" + raw base = raw.rstrip("/") res = { "target": raw, "base": base, "reachable": False, "php_version": "", "verdict": "UNKNOWN", "note": "", "upload_progress": "", "sources": "", } s = requests.Session() s.headers.update(headers()) cands, root = fingerprint(s, base, args.timeout, not args.no_verify) res["reachable"] = root is not None if cands: # Prioritas: header X-Powered-By > phpinfo > header Server > body def prio(c): if c[0] == "header:X-Powered-By": return 0 if c[0].startswith("phpinfo"): return 1 if c[0] == "header:Server": return 2 return 3 cands.sort(key=prio) src, ver, rawv = cands[0] res["php_version"] = ".".join(map(str, ver)) res["sources"] = "; ".join(f"{c[0]}={'.'.join(map(str,c[1]))}" for c in cands) v, note = verdict(ver) res["verdict"] = v res["note"] = note elif res["reachable"]: res["verdict"] = "UNKNOWN" res["note"] = "PHP terdeteksi tapi versi tidak terbaca (expose_php mungkin Off)" if args.probe_upload and res["reachable"]: res["upload_progress"] = probe_upload_progress( s, base, args.timeout, not args.no_verify ) or "PROBE_FAILED" return res def print_row(r): icon = { "PATCHED": "✅", "VULNERABLE_VERSION": "🚨", "EOL_NO_FIX": "⛔", "UNKNOWN": "❓", }.get(r["verdict"], "•") print(f"\n{'='*72}") print(f"TARGET : {r['target']}") print(f"AKSES : {'OK' if r['reachable'] else 'GAGAL'}") if r["php_version"]: print(f"PHP : {r['php_version']} ({r['sources']})") print(f"VERDICT: {icon} {r['verdict']}") print(f" {r['note']}") else: print(f"PHP : tidak terdeteksi") print(f"VERDICT: {icon} UNKNOWN — {r['note']}") if r["upload_progress"]: print(f"UPLOAD : {r['upload_progress']}") # Peringatan backport Ubuntu if r["verdict"] == "VULNERABLE_VERSION" and r["php_version"].startswith("8.3.6"): print(" ⚠️ Peringatan: ini mungkin false positive. Ubuntu 24.04 backport patch") print(" tanpa menaikkan versi. Cek `dpkg -l | grep php8.3` di server.") def save_csv(rows, path): with open(path, "w", newline="", encoding="utf-8") as f: w = csv.writer(f) w.writerow(["target", "base", "reachable", "php_version", "verdict", "upload_progress", "note", "sources"]) for r in rows: w.writerow([r["target"], r["base"], r["reachable"], r["php_version"], r["verdict"], r["upload_progress"], r["note"], r["sources"]]) def main(): ap = argparse.ArgumentParser() ap.add_argument("-l", "--list", default="targets.txt") ap.add_argument("-t", "--threads", type=int, default=6) ap.add_argument("--timeout", type=int, default=12) ap.add_argument("--no-verify", action="store_true") ap.add_argument("--probe-upload", action="store_true", help="kirim multipart POST kecil untuk cek upload_progress") ap.add_argument("-o", "--output", default="php2shell_report.csv") args = ap.parse_args() try: with open(args.list, "r", encoding="utf-8") as f: targets = [l.strip() for l in f if l.strip() and not l.strip().startswith("#")] except FileNotFoundError: print(f"[!] File tidak ditemukan: {args.list}") sys.exit(1) print(f"[*] Target : {len(targets)}") print(f"[*] Threads : {args.threads}") print(f"[*] Timeout : {args.timeout}s") print(f"[*] Probe : {'ON (multipart upload_progress)' if args.probe_upload else 'OFF'}") print("[!] Fingerprint versi TIDAK bisa membedakan backport Ubuntu.\n") rows = [] with ThreadPoolExecutor(max_workers=args.threads) as ex: fut = {ex.submit(scan_one, t, args): t for t in targets} for f in as_completed(fut): try: r = f.result() except Exception as e: print(f"[!] {fut[f]}: error {e}") continue rows.append(r) print_row(r) save_csv(rows, args.output) print("\n" + "=" * 72) print("SUMMARY") print("=" * 72) buckets = {} for r in rows: buckets.setdefault(r["verdict"], []).append(r["target"]) for k in ("VULNERABLE_VERSION", "EOL_NO_FIX", "UNKNOWN", "PATCHED"): lst = buckets.get(k, []) if lst: print(f" {k:<20}: {len(lst)}") for t in lst: print(f" - {t}") print(f"\n[✓] CSV: {args.output}") print("\n[!] Untuk kepastian di Ubuntu, jalankan di tiap server:") print(" dpkg -l | grep php8.3") print(" (patched jika versi paket >= 8.3.6-0ubuntu0.24.04.13)") if __name__ == "__main__": main()