# RCA Report — GHSA-h9qx-v5xp-ph8p (EGroupware SmallPartMediaRecorder RCE)

## Summary

EGroupware (Composer package `egroupware/egroupware`, bundled `smallpart` app)
contains an authorization-bypass + arbitrary-file-write chain in
`EGroupware\SmallParT\Widgets\SmallPartMediaRecorder::ajax_upload()`. The
`isTeacher()` ACL check receives a fully attacker-controlled `course_id` value
taken verbatim from the JSON request body (`$data['video']['course_id']`). By
sending `course_id` as a PHP **array** that contains a `participants` list with
the attacker's own `account_id` and `participant_role = 3` (`ROLE_TEACHER`), a
low-privileged authenticated (non-teacher, non-admin) user makes
`Bo::isParticipant()` cache an attacker-supplied role of "teacher" and pass the
check. Because the same request-controlled `$data['video']` is then passed
unsanitized to `Bo::videoPath()`, the unsanitized `video_hash` / `video_type`
fields enable a **path-traversal** write: `copy($_FILES['file']['tmp_name'],
$filePath)` writes attacker-supplied PHP to an arbitrary path. The only
www-data-writable location inside the nginx-served webroot is `header.inc.php`
(a symlink to the writable data file `/var/lib/egroupware/header.inc.php`), so
an attacker overwrites `header.inc.php` with a PHP payload (preserving the
valid config). Because `header.inc.php` is included by every EGroupware
request, the injected code executes on the next HTTP request after OPcache is
cleared (server restart / OPcache expiry) — yielding **remote code execution**
as the web user (`www-data`).

## Impact

- **Package / component affected:** `egroupware/egroupware` (the bundled
  `smallpart` app, `src/Widgets/SmallPartMediaRecorder.php` and
  `src/Bo.php`). A secondary read primitive exists in the `importexport` app
  (`importexport_export_ui::download`, user-controlled `_filename`).
- **Affected versions:** EGroupware `26.0.20251208` through `< 26.2.20260224`
  (and the `23.1` branch `< 23.1.20260224`). Fixed in `26.2.20260224` /
  `23.1.20260224`.
- **Risk level:** Critical. Authenticated attacker (self-registration, if
  enabled, removes even the credential prerequisite) obtains arbitrary PHP code
  execution on the EGroupware server.

## Impact Parity

- **Disclosed / claimed maximum impact:** Remote code execution (via
  authorization bypass in `SmallPartMediaRecorder::ajax_upload` + arbitrary
  file write + arbitrary file read, overwriting `header.inc.php`).
- **Reproduced impact from this run:** **Full RCE parity.** A non-teacher,
  non-admin authenticated user (account `attacker`, account_id 7, member of the
  `Default` group only) sent a single crafted `POST` to the real
  `ajax_upload` endpoint over `nginx → php-fpm` and:
  1. bypassed `isTeacher()` (the no-bypass form of the same request is denied
     with `NoPermission`);
  2. wrote attacker PHP into `header.inc.php` (the upload returned `status:0`
     and the file on disk then contained the injected code);
  3. after a container restart to clear OPcache
     (`opcache.validate_timestamps=0`, exactly the "after server restart / OPcache
     expiry" condition stated in the advisory), an HTTP `GET
     /egroupware/index.php` executed the injected code, which created
     `/var/lib/egroupware/pruva_rce_marker.txt` containing
     `PRUVA_RCE_CONFIRMED 8.5.3 <run-timestamp> (e.g. 2026-07-07T22:42:21+00:00 from the verified run)`.
- **Parity:** `full`.
- **Not demonstrated:** The advisory also describes an arbitrary-file-read via
  `importexport_export_ui::download` (used to harvest a valid `header.inc.php`
  template). The RCE proof here constructs a valid header from the on-disk
  original instead, so the read primitive is documented in code but not
  exercised as the primary proof.

## Root Cause

`SmallPartMediaRecorder::ajax_upload()` (vulnerable revision,
`smallpart/src/Widgets/SmallPartMediaRecorder.php`) was:

```php
$data = json_decode($_POST['data'], true);
if (!$bo->isTeacher($data['video']['course_id']))            // <-- attacker-controlled array
    throw new Api\Exception\NoPermission();
...
if ($data['video']['video_hash']) {
    $filePath = $bo->videoPath($data['video'], true);        // <-- attacker-controlled path
    if ($data['offset'] == 0)
        $success = copy($_FILES['file']['tmp_name'], $filePath) ? 0 : false;
}
```

`Bo::isTeacher()` → `Bo::isParticipant($course, ROLE_TEACHER)` trusts request
data when `$course` is an array with a `participants` key:

```php
if (is_array($course) && isset($course['participants'])) {
    $participants = array_filter($course['participants'], static function($p) use ($user) {
        return is_array($p) && $p['account_id'] == $user && !isset($p['participant_unsubscribed']);
    });
    $this->course_acl[$course['course_id']] = $participants ? current($participants)['participant_role'] : null;
}
```

So a fabricated `course_id` array
`{"participants":[{"account_id":<me>,"participant_role":3}],"course_id":"1"}`
makes `isTeacher()` return true for **any** authenticated user, regardless of
real course membership. `Bo::videoPath()` then concatenates the request's
`video_hash` and `video_type` into the destination path with no normalization:

```php
$dir = $GLOBALS['egw_info']['server']['files_dir'] . '/smallpart/Video/' . (int)$video['course_id'];
return $dir . '/' . $video['video_hash'] . '.' . $video['video_type'];
```

`(int)$video['course_id']` is `1` for the array (PHP casts a non-empty array to
`1`), so the directory is created normally; the traversal lives entirely in
`video_hash`/`video_type`. With `video_hash = "../../../../../../../../../../../../usr/share/egroupware/header.inc`
and `video_type = "php"`, `copy()` writes attacker content to
`/usr/share/egroupware/header.inc.php`, which is a symlink to the
www-data-writable `/var/lib/egroupware/header.inc.php`.

**Fix (tag `26.2.20260224`):** the check now casts to int and re-reads the
video from the database, and the path is derived from the DB record, not the
request:

```php
if (!$bo->isTeacher((int)$data['video']['course_id']) ||
    !($video = $bo->readVideo((int)$data['video']['video_id'])))
    throw new Api\Exception\NoPermission();
...
if ($video['video_hash']) {
    $filePath = $bo->videoPath($video, true);   // DB-sourced $video
```

`(int)` on the array yields `1`, defeating the `participants`-array bypass;
`readVideo()` requires a real DB video row (none exists for the fabricated
request), and `videoPath()` receives the DB record so `video_hash`/`video_type`
are no longer attacker-controlled. Confirmed in the deployed fixed image
`egroupware/egroupware:26.2.20260224`.

## Reproduction Steps

1. **Reference script:** `bundle/repro/reproduction_steps.sh`
   (self-contained; uses only `sudo docker` + `python3` + `curl`/`jq`-style
   tooling present in the sandbox).
2. **What the script does:**
   - Pulls `egroupware/egroupware:26.2.20260216` (vulnerable) and
     `:26.2.20260224` (fixed), `mariadb:11.8`, `nginx:stable-alpine`,
     `alpine:latest`.
   - For each version, deploys a real 3-container stack
     (`mariadb` + `egroupware` php-fpm + `nginx`) on an isolated Docker network,
     lets the EGroupware entrypoint auto-install (DB schema, `header.inc.php`,
     default apps incl. `smallpart`), and waits until php-fpm is serving.
   - Creates a **low-privileged, non-teacher, non-admin** user `attacker`
     directly in `egw_accounts` (bcrypt `{crypt}` hash, member of the `Default`
     group only — explicitly *not* the `-2` group that carries admin ACL).
   - Logs in as `attacker` via `POST /egroupware/login.php` and captures the
     `sessionid`/`kp3` cookies.
   - **No-bypass control:** sends `ajax_upload` with a plain integer
     `course_id` → expects denial (non-teacher must not pass `isTeacher`).
   - **Bypass + write:** builds a "valid `header.inc.php` + injected
     `file_put_contents(...)` marker" payload (so the instance stays
     functional), sends `ajax_upload` with the crafted `course_id` array
     (`participant_role=3`) and `video_hash` traversal targeting
     `.../usr/share/egroupware/header.inc.php`, verifies the upload returns
     `status:0` and `header.inc.php` on disk now contains the injected code.
   - **RCE:** restarts the egroupware container to clear OPcache (matching the
     advisory's "after server restart / OPcache expiry"), confirms the injected
     header survived the restart, then `GET /egroupware/index.php` (which
     includes `header.inc.php`) and reads the marker file.
   - **Negative control:** repeats the same bypass request against the fixed
     image and verifies `header.inc.php` is byte-for-byte unchanged (sha256
     equal) and no marker is produced.
3. **Expected evidence:** `bundle/logs/vuln_bypass_response.json` shows
   `"status":0`; `bundle/logs/vuln_header.inc.php.modified` contains the
   injected `PRUVA RCE PROOF` block; `bundle/logs/vuln_rce_marker.txt` contains
   `PRUVA_RCE_CONFIRMED <phpversion> <timestamp>`; `bundle/logs/fixed_*` shows
   the fixed image denies the request and leaves `header.inc.php` unchanged.

## Evidence

- `bundle/logs/reproduction_steps.log` — full transcript.
- `bundle/logs/vuln_nobypass_response.json` — non-teacher, no bypass →
  `NoPermission`-class error (request denied, no write).
- `bundle/logs/vuln_bypass_response.json` — same user *with* the
  `participant_role=3` bypass → `{"response":[{"type":"data","data":{"status":0,"offset":0}}]}`.
- `bundle/logs/vuln_header.inc.php.modified` — the overwritten header, first
  lines:
  ```
  <?php
  /* PRUVA RCE PROOF GHSA-h9qx-v5xp-ph8p */
  file_put_contents("/var/lib/egroupware/pruva_rce_marker.txt", "PRUVA_RCE_CONFIRMED ".phpversion()." ".date("c"));
  /**  ...original valid header preserved...  */
  ```
- `bundle/logs/vuln_rce_marker.txt` — `PRUVA_RCE_CONFIRMED 8.5.3 <run-timestamp> (e.g. 2026-07-07T22:42:21+00:00 from the verified run)`
  (created by the injected code executing through the real nginx→php-fpm path).
- `bundle/logs/fixed_bypass_response.json` — fixed image returns the
  `NoPermission` error raised at `SmallPartMediaRecorder.php (22)` (the patched
  `isTeacher((int)…)/readVideo()` line).
- `bundle/logs/fixed_header_before.sha256` / `fixed_header_after.sha256` —
  identical sha256 of `header.inc.php` on the fixed image (no write).
- `bundle/artifacts/data_vuln_bypass.json` — the exact crafted request body.
- **Environment:** Docker; `egroupware/egroupware:26.2.20260216` (PHP 8.5.3
  fpm, `opcache.validate_timestamps=0`), `mariadb:11.8`, `nginx:stable-alpine`;
  `files_dir=/var/lib/egroupware/default/files`, `temp_dir=/tmp`,
  `webserver_url=/egroupware`. All HTTP exercised through the real
  `nginx → php-fpm` boundary (`docker exec <egw> curl http://<nginx>/egroupware/...`).

## Recommendations / Next Steps

- **Upgrade** to EGroupware `>= 26.2.20260224` (or `>= 23.1.20260224`), which
  contains the `isTeacher((int)$course_id)` + `readVideo()` + DB-sourced
  `videoPath()` fix.
- **Defense in depth:** normalize/`basename()` the `videoPath()` components
  (`video_hash`, `video_type`) and reject any containing `..` or starting with
  `/`; validate `video_type` against the existing `VIDEO_MIME_TYPES` allow-list
  in the upload path (not only in `addVideo`); and make `isParticipant()` never
  honor a request-supplied `participants` array for authorization decisions
  (only DB-sourced ACL).
- **Read primitive:** sanitize `_filename` in
  `importexport_export_ui::download` (it currently `readfile()`s and then
  `unlink()`s `temp_dir/<_filename>` with no traversal filter — itself an
  arbitrary-read/delete hazard).
- **Operational:** disable PHP `disable_functions` cannot mitigate this (the
  proof uses only `file_put_contents`/`phpversion`/`date`, no shell functions);
  ensure `header.inc.php` is stored outside the webroot or is not a symlink
  into a writable data directory.

## Additional Notes

- **Idempotency:** `reproduction_steps.sh` removes the per-stack containers and
  named volumes (`docker rm -f` + `docker volume rm -f`) at the start of each
  deploy, so consecutive runs start from a clean install. The script was
  verified to pass end-to-end (vulnerable RCE + fixed negative control).
- **Why a restart is required for RCE:** the production image sets
  `opcache.validate_timestamps=0`, so a modified `header.inc.php` is not
  recompiled until OPcache is cleared. The script restarts the egroupware
  container to clear OPcache (the entrypoint then performs only an "update" and
  preserves the valid, injected header). This precisely matches the advisory's
  "RCE occurs after server restart or OPcache expiry".
- **Non-destructive proof:** the injected payload preserves the original
  `header.inc.php` config (the RCE marker is prepended after `<?php`), so the
  instance remains functional and the RCE is observable via the marker file
  rather than by breaking the installation.
- **Account model:** the attacker is intentionally placed only in the `Default`
  group (account_id 1), never in the `-2` group that carries the `admin` app
  ACL, and is not a `smallpart` course member — so without the bypass
  `isTeacher()` is genuinely false (confirmed by the no-bypass denial). The
  EGroupware super-admin (`sysop`) is *not* used for the exploit because
  `Bo::isAdmin()` returns true for super-admins (`isSuperAdmin()`), which would
  mask the bypass.
