# Variant RCA Report — CVE-2026-57518 (Pagekit CMS 1.0.18)

## Summary

A distinct **alternate RCE sink** was found and confirmed at runtime. The parent
CVE-2026-57518 demonstrates that an authenticated low-privileged user holding
`user: manage users` can self-assign an arbitrary **custom** role via
`UserApiController::saveAction()` (which only blocks the built-in Administrator
role id=3) and then RCE through the **package installer**
(`POST /admin/system/package/upload|install`, gated by
`system: manage packages`). This variant shows that the **same** `saveAction`
escalation grants a **different** trusted permission —
`system: software updates` — which unlocks a **second, independent RCE sink**:
`UpdateController::downloadAction` + `updateAction`
(`POST /admin/system/update/download` + `POST /admin/system/update/update`).
`downloadAction` fetches an attacker-controlled URL (a `file://` URL copies a
locally planted archive with no network needed) into a server-side temp file,
and `updateAction` runs `SelfUpdater::update()`, which extracts the archive over
the Pagekit root (`App::path()`). A webshell placed at the archive root is
written to `/var/www/html/<name>.php` and executed directly by Apache. RCE as
`www-data` was confirmed (`uid=33(www-data)`). This is an **alternate trigger /
sink**, not a bypass of a fix — no patched version exists (project archived).

## Fix Coverage / Assumptions

- **Invariant the (would-be) fix relies on:** the `saveAction` check assumes
  that **only** the built-in Administrator role (id=3) is privileged, and that
  blocking add/remove of id=3 for non-administrators is sufficient to prevent
  privilege escalation.
- **Code path it explicitly covers:** adding or removing `Role::ROLE_ADMINISTRATOR`
  (id=3) on any target user by a non-administrator caller.
- **What it does NOT cover:** (a) assignment of **any** custom role (id ≥ 4),
  including roles carrying `trusted` permissions such as
  `system: manage packages`, `system: software updates`,
  `user: manage user permissions`, `system: access system settings`; (b) the
  `trusted` permission flag, which is **advisory UI-only** and never enforced
  server-side; (c) the **second RCE sink** (`UpdateController` / `SelfUpdater`)
  that becomes reachable once `system: software updates` is obtained — the
  parent reproduction only exercises the package-installer sink.

## Variant / Alternate Trigger

**Entry points (distinct from the parent):**

1. `POST /api/user/{id}` — `UserApiController::saveAction()`
   (`app/system/modules/user/src/Controller/UserApiController.php`, lines
   ~178–183). Same escalation root cause as the parent, but the self-assigned
   custom role carries `system: software updates` + `system: access admin area`
   instead of `system: manage packages`.
2. `POST /admin/system/update/download` — `UpdateController::downloadAction()`
   (`app/installer/src/Controller/UpdateController.php`). Attacker-controlled
   `url` parameter; `file_put_contents(tempnam(path.temp,'update_'),
   fopen($url,'r'))`. The server copies the crafted "update" ZIP into a temp
   file whose path is stored in the session as `system.update`.
3. `POST /admin/system/update/update` — `UpdateController::updateAction()`.
   Reads `system.update` from the session and invokes
   `SelfUpdater::update($file)`
   (`app/installer/src/SelfUpdater.php`), which extracts the ZIP over
   `App::path()` (the Pagekit web root).
4. `GET /rce_variant.php?cmd=id` — the extracted webshell, served directly by
   Apache (root `.htaccess` only rewrites non-existent paths:
   `RewriteCond %{REQUEST_FILENAME} !-f`).

**Why this is materially distinct from the parent (not a relabel):**

| Dimension | Parent (CVE-2026-57518) | This variant |
|-----------|--------------------------|--------------|
| Admin endpoint | `/admin/system/package` | `/admin/system/update` |
| Required permission | `system: manage packages` | `system: software updates` |
| Payload format | `pagekit-extension` ZIP with `composer.json` | "self-update" ZIP with `app/installer/requirements.php` |
| Sink primitive | `PackageManager` extracts into `packages/` | `SelfUpdater::extract()` overwrites core files at web root |
| Network need | local upload (multipart) | `file://` URL → server-side copy (no outbound network) |

The **escalation root cause is shared** (the `saveAction` id=3-only check),
which is exactly why a complete fix must block self-assignment of **all**
trusted/elevated permissions and treat **both** admin sinks as exposed.

## Impact

- **Package/component affected:** `pagekit/pagekit`
  - Escalation: `app/system/modules/user/src/Controller/UserApiController.php`
    (`saveAction`)
  - Variant sink: `app/installer/src/Controller/UpdateController.php`
    (`downloadAction`, `updateAction`) and `app/installer/src/SelfUpdater.php`
    (`update`, `extract`)
- **Affected versions (as tested):** Pagekit 1.0.18, commit
  `95446c5f08fc43993fad5e2b581743ffcce50c35` (only tag / latest commit; project
  archived 2023-12-01; no patched version exists).
- **Risk level:** High (CVSS 8.8 — same as parent:
  CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H).
- **Consequences:** Full server compromise as the web server user (`www-data`).
  The self-updater sink is arguably **more** powerful than the package-installer
  sink: it can overwrite **any** file under the Pagekit root (not just
  `packages/`), limited only by `www-data` write permissions and the
  `packages/`/`storage/`/`.htaccess ignore list.

## Impact Parity

- **Disclosed/claimed maximum impact (parent):** Privilege escalation leading to
  remote code execution (`code_execution`).
- **Reproduced impact from this variant run:** Full privilege escalation to
  `system: software updates`, followed by `UpdateController` download + update
  driving `SelfUpdater` to extract an attacker webshell to the web root,
  followed by confirmed RCE (`uid=33(www-data) gid=33(www-data)
  groups=33(www-data)`, `whoami=www-data`).
- **Parity:** `full`.
- **Not demonstrated:** Root access (RCE is as `www-data`, same as the parent).
  Persistence/defense-evasion beyond a single webshell was not attempted.

## Root Cause

`UserApiController::saveAction()` authorizes role changes by searching the
submitted `roles` array for **only** `Role::ROLE_ADMINISTRATOR` (constant `3`):

```php
$key    = array_search(Role::ROLE_ADMINISTRATOR, @$data['roles'] ?: []);
$add    = false !== $key && !$user->isAdministrator();
$remove = false === $key && $user->isAdministrator();
if (($self && $remove) || !App::user()->isAdministrator() && ($remove || $add)) {
    App::abort(403, 'Cannot add/remove Admin Role.');
}
```

Because `$add`/`$remove` are derived solely from the presence/absence of id=3,
any custom role (id ≥ 4) produces `$add = $remove = false`, so the guard never
fires for a non-administrator caller. The submitted `roles` are then persisted
unchanged via `$user->save($data)`. A custom role created by an administrator
that carries `system: software updates` (a `trusted` permission that is
**advisory-only** server-side) can therefore be self-assigned by any user with
`user: manage users`. Once held, `UpdateController` — gated only by
`@Access("system: software updates", admin=true)` — exposes an arbitrary-URL
download and a `SelfUpdater` that extracts the downloaded archive over the web
root, yielding RCE.

**Fix commit:** None. Pagekit 1.0.18 is the final release; the repository is
archived.

## Reproduction Steps

1. **Script:** `bundle/vuln_variant/reproduction_steps.sh`
2. **What the script does:**
   - Reuses the cached Pagekit 1.0.18 release and the `pagekit-vuln-1018:repro`
     Docker image (`php:7.4-apache` + SQLite), starts an isolated container
     `pagekit-variant`, and installs Pagekit via `POST /installer/install`.
   - As administrator, creates an **Updater** role (id=4) with
     `system: software updates` + `system: access admin area`, a **User
     Manager** role (id=5) with `user: manage users`, and a low-privileged
     **editor** user (id=2) holding only the User Manager role.
   - As editor, calls `POST /api/user/2` with `roles=[2,4]` — self-assigning the
     Updater role. `saveAction` only blocks id=3, so id=4 passes through
     unchecked. **Escalation confirmed** (roles become `[2,4]`).
   - Crafts a malicious "self-update" ZIP (`app/installer/requirements.php`
     returning an empty `getFailedRequirements()` + a root-level webshell
     `rce_variant.php`) and plants it at
     `/var/www/html/storage/evil_update.zip`.
   - As the escalated editor, calls
     `POST /admin/system/update/download` with
     `url=file:///var/www/html/storage/evil_update.zip` (HTTP 200), then
     `POST /admin/system/update/update` (HTTP 200; `SelfUpdater` runs:
     "Extracting files...done", "Removing old files...done").
   - Accesses `GET /rce_variant.php?cmd=id` and verifies `uid=33(www-data)`.
   - Performs the mandatory fixed-version check (none exists) and writes the
     runtime manifest + evidence.
3. **Expected evidence of reproduction:**
   - `bundle/logs/vuln_variant/reproduction_steps.log` — full execution log
   - `bundle/logs/vuln_variant/exploit_evidence.txt` — structured evidence
   - `bundle/logs/vuln_variant/rce_id_output.txt` —
     `uid=33(www-data) gid=33(www-data) groups=33(www-data)`
   - `bundle/logs/vuln_variant/rce_whoami_output.txt` — `www-data`
   - `bundle/logs/vuln_variant/fixed_version.txt` — no-fixed-version notice
   - `bundle/vuln_variant/runtime_manifest.json` — runtime evidence manifest

## Evidence

### Key excerpts

**Privilege escalation (Step 3):**
```
Editor BEFORE escalation: ...,"roles":[2,5],...
Escalation response: {"message":"success","user":{"id":2,...,"roles":[2,4],...}}
PRIVILEGE ESCALATION CONFIRMED — editor now has 'system: software updates'
```

**Variant sink (Steps 5–6):**
```
Download response: []  HTTP:200   (POST /admin/system/update/download, file:// URL)
Update response (tail): Removing old files...done.  Deactivating update mode...done.  HTTP:200
Webshell on disk (/var/www/html/rce_variant.php): yes
```

**RCE (Step 7):**
```
RCE output (id): uid=33(www-data) gid=33(www-data) groups=33(www-data)
RCE output (whoami): www-data
=== VARIANT RCE CONFIRMED ===
```

### Environment details

- **OS:** Ubuntu 26.04 LTS (host); Debian Bullseye (container)
- **PHP:** 7.4.33 (Apache, inside Docker container)
- **Database:** SQLite (`pagekit.db`)
- **Pagekit:** 1.0.18 (official release; commit
  `95446c5f08fc43993fad5e2b581743ffcce50c35`)
- **Web server:** Apache 2.4.54 with mod_rewrite

### Sandbox adaptations (non-security-relevant)

1. **Docker networking:** all HTTP requests to the running service are made via
   `docker exec` (curl inside the container); this does not alter request
   handling.
2. **Session bootstrap:** a PHP helper creates a valid authenticated session
   directly in the database (same approach as the parent repro, to bypass a
   PHP 7.4 / Symfony 3.0 session-storage interaction that prevents curl login).
   The resulting auth state is identical to a successful web login.
3. **`file://` download URL:** the variant uses `file://` for
   `downloadAction`'s `$url` so the server-side copy works without outbound
   network. This is strictly weaker than the real-world attack (where an
   attacker can host the archive on any HTTP server they control and pass an
   `http://` URL); it does not change any security-relevant code path.

## Recommendations / Next Steps

1. **Fix `saveAction` to validate every role**, not just id=3. A
   non-administrator caller must be forbidden from assigning any role whose
   permissions are `trusted`/elevated, or (more conservatively) any role the
   caller does not already hold / any role granting a permission the caller
   lacks. Apply the same logic in `bulkSaveAction` (which delegates to
   `saveAction`).
2. **Enforce the `trusted` permission flag server-side.** Today it is UI-only.
   The role-editor (`RoleApiController`) and the user-role assignment
   (`UserApiController`) should reject granting `trusted` permissions to roles
   that non-administrators can hold, and reject assigning such roles to
   non-administrator users.
3. **Harden `UpdateController::downloadAction`**: validate/restrict the `url`
   scheme and host (no `file://`, no SSRF to internal hosts), and require
   signature/verification of update archives before `SelfUpdater::extract()`
   overwrites core files. Treat the self-updater as a sensitive RCE-equivalent
   sink equal to the package installer.
4. **Defense in depth:** restrict `SelfUpdater::extract()` to a manifest of
   expected paths and refuse to write executable files outside an allow-list;
   do not blindly `$zip->extractTo(App::path(), $fileList)`.

## Additional Notes

### Bypass vs. alternate trigger

This is an **alternate trigger/sink**, not a bypass of a released fix — no
patched version exists (project archived 2023-12-01; 1.0.18 is the only tag and
latest commit). The variant is confirmed on the only available (vulnerable)
target and demonstrates that a complete remediation must cover **both** the
package-installer and self-updater sinks and **all** trusted permissions.

### Idempotency confirmation

`bundle/vuln_variant/reproduction_steps.sh` was executed three times
consecutively. Every run produced the same result: privilege escalation to
`system: software updates` confirmed, `UpdateController` download + update
succeeded (HTTP 200), webshell written to the web root, and RCE confirmed
(`uid=33(www-data)`). Each run starts a fresh container with a clean Pagekit
installation, so no state carries over. Exit code 0 on every run.

### Limitations

- RCE executes as `www-data` (Apache user), not root — same as the parent.
- The `SelfUpdater` cleanup step deletes `app/` files absent from the update
  archive, which breaks the Pagekit application after extraction; the standalone
  root-level webshell (`rce_variant.php`) remains independently executable by
  Apache regardless, so RCE is still confirmed. A real attacker would include
  the full `app/` tree in the archive to preserve a functional site.
- The best-effort session-path probe (`check_update_path.php`) reported
  `<not found>` because Pagekit's exact session serialization layout for the
  `system.update` value differs from the naive regex; this is cosmetic only —
  the update step itself returned HTTP 200 with the `SelfUpdater`'s "done"
  output and the webshell was verified on disk, which is the authoritative
  confirmation.
