# REPRO-2026-00272: EGroupware contains an authorization bypass in SmallPartMediaRecorder::ajax_upload combined with arbitrary file write and file read primitives, enabling authenticated (or self-registered) attackers to overwrite header.inc.php and achieve remote code execution. ## Summary Status: published Severity: critical Type: security Confidence: high ## Identifiers REPRO ID: REPRO-2026-00272 GHSA: GHSA-H9QX-V5XP-PH8P CVE: CVE-2026-27823 ## Package Name: egroupware/egroupware Ecosystem: Composer Affected: <=26.2.20260216, <=23.1.20260131 Fixed: 26.2.20260224, 23.1.20260224 ## Root Cause # 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 (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":,"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 `; `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: ``` (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 curl http:///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 `