{"repro_id":"REPRO-2026-00272","version":6,"title":"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.","repro_type":"security","status":"published","severity":"critical","cvss_score":9.3,"description":"A critical vulnerability in EGroupware allows remote code execution by chaining an authorization bypass in `EGroupware\\SmallParT\\Widgets\\SmallPartMediaRecorder::ajax_upload()` with an arbitrary file write and an arbitrary file read. An attacker can bypass the teacher-role check by supplying a user-controlled `participant_role` value, then write to a controllable path (e.g., `./header.inc.php`). A separate path traversal in `importexport_export_ui::download` allows reading arbitrary files, enabling the attacker to retrieve and then overwrite a valid `header.inc.php` with injected PHP code while preserving file structure. RCE occurs after server restart or OPcache expiry. If self-registration is enabled, exploitation may be possible without prior credentials.","root_cause":"# RCA Report — GHSA-h9qx-v5xp-ph8p (EGroupware SmallPartMediaRecorder RCE)\n\n## Summary\n\nEGroupware (Composer package `egroupware/egroupware`, bundled `smallpart` app)\ncontains an authorization-bypass + arbitrary-file-write chain in\n`EGroupware\\SmallParT\\Widgets\\SmallPartMediaRecorder::ajax_upload()`. The\n`isTeacher()` ACL check receives a fully attacker-controlled `course_id` value\ntaken verbatim from the JSON request body (`$data['video']['course_id']`). By\nsending `course_id` as a PHP **array** that contains a `participants` list with\nthe attacker's own `account_id` and `participant_role = 3` (`ROLE_TEACHER`), a\nlow-privileged authenticated (non-teacher, non-admin) user makes\n`Bo::isParticipant()` cache an attacker-supplied role of \"teacher\" and pass the\ncheck. Because the same request-controlled `$data['video']` is then passed\nunsanitized to `Bo::videoPath()`, the unsanitized `video_hash` / `video_type`\nfields enable a **path-traversal** write: `copy($_FILES['file']['tmp_name'],\n$filePath)` writes attacker-supplied PHP to an arbitrary path. The only\nwww-data-writable location inside the nginx-served webroot is `header.inc.php`\n(a symlink to the writable data file `/var/lib/egroupware/header.inc.php`), so\nan attacker overwrites `header.inc.php` with a PHP payload (preserving the\nvalid config). Because `header.inc.php` is included by every EGroupware\nrequest, the injected code executes on the next HTTP request after OPcache is\ncleared (server restart / OPcache expiry) — yielding **remote code execution**\nas the web user (`www-data`).\n\n## Impact\n\n- **Package / component affected:** `egroupware/egroupware` (the bundled\n  `smallpart` app, `src/Widgets/SmallPartMediaRecorder.php` and\n  `src/Bo.php`). A secondary read primitive exists in the `importexport` app\n  (`importexport_export_ui::download`, user-controlled `_filename`).\n- **Affected versions:** EGroupware `26.0.20251208` through `< 26.2.20260224`\n  (and the `23.1` branch `< 23.1.20260224`). Fixed in `26.2.20260224` /\n  `23.1.20260224`.\n- **Risk level:** Critical. Authenticated attacker (self-registration, if\n  enabled, removes even the credential prerequisite) obtains arbitrary PHP code\n  execution on the EGroupware server.\n\n## Impact Parity\n\n- **Disclosed / claimed maximum impact:** Remote code execution (via\n  authorization bypass in `SmallPartMediaRecorder::ajax_upload` + arbitrary\n  file write + arbitrary file read, overwriting `header.inc.php`).\n- **Reproduced impact from this run:** **Full RCE parity.** A non-teacher,\n  non-admin authenticated user (account `attacker`, account_id 7, member of the\n  `Default` group only) sent a single crafted `POST` to the real\n  `ajax_upload` endpoint over `nginx → php-fpm` and:\n  1. bypassed `isTeacher()` (the no-bypass form of the same request is denied\n     with `NoPermission`);\n  2. wrote attacker PHP into `header.inc.php` (the upload returned `status:0`\n     and the file on disk then contained the injected code);\n  3. after a container restart to clear OPcache\n     (`opcache.validate_timestamps=0`, exactly the \"after server restart / OPcache\n     expiry\" condition stated in the advisory), an HTTP `GET\n     /egroupware/index.php` executed the injected code, which created\n     `/var/lib/egroupware/pruva_rce_marker.txt` containing\n     `PRUVA_RCE_CONFIRMED 8.5.3 <run-timestamp> (e.g. 2026-07-07T22:42:21+00:00 from the verified run)`.\n- **Parity:** `full`.\n- **Not demonstrated:** The advisory also describes an arbitrary-file-read via\n  `importexport_export_ui::download` (used to harvest a valid `header.inc.php`\n  template). The RCE proof here constructs a valid header from the on-disk\n  original instead, so the read primitive is documented in code but not\n  exercised as the primary proof.\n\n## Root Cause\n\n`SmallPartMediaRecorder::ajax_upload()` (vulnerable revision,\n`smallpart/src/Widgets/SmallPartMediaRecorder.php`) was:\n\n```php\n$data = json_decode($_POST['data'], true);\nif (!$bo->isTeacher($data['video']['course_id']))            // <-- attacker-controlled array\n    throw new Api\\Exception\\NoPermission();\n...\nif ($data['video']['video_hash']) {\n    $filePath = $bo->videoPath($data['video'], true);        // <-- attacker-controlled path\n    if ($data['offset'] == 0)\n        $success = copy($_FILES['file']['tmp_name'], $filePath) ? 0 : false;\n}\n```\n\n`Bo::isTeacher()` → `Bo::isParticipant($course, ROLE_TEACHER)` trusts request\ndata when `$course` is an array with a `participants` key:\n\n```php\nif (is_array($course) && isset($course['participants'])) {\n    $participants = array_filter($course['participants'], static function($p) use ($user) {\n        return is_array($p) && $p['account_id'] == $user && !isset($p['participant_unsubscribed']);\n    });\n    $this->course_acl[$course['course_id']] = $participants ? current($participants)['participant_role'] : null;\n}\n```\n\nSo a fabricated `course_id` array\n`{\"participants\":[{\"account_id\":<me>,\"participant_role\":3}],\"course_id\":\"1\"}`\nmakes `isTeacher()` return true for **any** authenticated user, regardless of\nreal course membership. `Bo::videoPath()` then concatenates the request's\n`video_hash` and `video_type` into the destination path with no normalization:\n\n```php\n$dir = $GLOBALS['egw_info']['server']['files_dir'] . '/smallpart/Video/' . (int)$video['course_id'];\nreturn $dir . '/' . $video['video_hash'] . '.' . $video['video_type'];\n```\n\n`(int)$video['course_id']` is `1` for the array (PHP casts a non-empty array to\n`1`), so the directory is created normally; the traversal lives entirely in\n`video_hash`/`video_type`. With `video_hash = \"../../../../../../../../../../../../usr/share/egroupware/header.inc`\nand `video_type = \"php\"`, `copy()` writes attacker content to\n`/usr/share/egroupware/header.inc.php`, which is a symlink to the\nwww-data-writable `/var/lib/egroupware/header.inc.php`.\n\n**Fix (tag `26.2.20260224`):** the check now casts to int and re-reads the\nvideo from the database, and the path is derived from the DB record, not the\nrequest:\n\n```php\nif (!$bo->isTeacher((int)$data['video']['course_id']) ||\n    !($video = $bo->readVideo((int)$data['video']['video_id'])))\n    throw new Api\\Exception\\NoPermission();\n...\nif ($video['video_hash']) {\n    $filePath = $bo->videoPath($video, true);   // DB-sourced $video\n```\n\n`(int)` on the array yields `1`, defeating the `participants`-array bypass;\n`readVideo()` requires a real DB video row (none exists for the fabricated\nrequest), and `videoPath()` receives the DB record so `video_hash`/`video_type`\nare no longer attacker-controlled. Confirmed in the deployed fixed image\n`egroupware/egroupware:26.2.20260224`.\n\n## Reproduction Steps\n\n1. **Reference script:** `bundle/repro/reproduction_steps.sh`\n   (self-contained; uses only `sudo docker` + `python3` + `curl`/`jq`-style\n   tooling present in the sandbox).\n2. **What the script does:**\n   - Pulls `egroupware/egroupware:26.2.20260216` (vulnerable) and\n     `:26.2.20260224` (fixed), `mariadb:11.8`, `nginx:stable-alpine`,\n     `alpine:latest`.\n   - For each version, deploys a real 3-container stack\n     (`mariadb` + `egroupware` php-fpm + `nginx`) on an isolated Docker network,\n     lets the EGroupware entrypoint auto-install (DB schema, `header.inc.php`,\n     default apps incl. `smallpart`), and waits until php-fpm is serving.\n   - Creates a **low-privileged, non-teacher, non-admin** user `attacker`\n     directly in `egw_accounts` (bcrypt `{crypt}` hash, member of the `Default`\n     group only — explicitly *not* the `-2` group that carries admin ACL).\n   - Logs in as `attacker` via `POST /egroupware/login.php` and captures the\n     `sessionid`/`kp3` cookies.\n   - **No-bypass control:** sends `ajax_upload` with a plain integer\n     `course_id` → expects denial (non-teacher must not pass `isTeacher`).\n   - **Bypass + write:** builds a \"valid `header.inc.php` + injected\n     `file_put_contents(...)` marker\" payload (so the instance stays\n     functional), sends `ajax_upload` with the crafted `course_id` array\n     (`participant_role=3`) and `video_hash` traversal targeting\n     `.../usr/share/egroupware/header.inc.php`, verifies the upload returns\n     `status:0` and `header.inc.php` on disk now contains the injected code.\n   - **RCE:** restarts the egroupware container to clear OPcache (matching the\n     advisory's \"after server restart / OPcache expiry\"), confirms the injected\n     header survived the restart, then `GET /egroupware/index.php` (which\n     includes `header.inc.php`) and reads the marker file.\n   - **Negative control:** repeats the same bypass request against the fixed\n     image and verifies `header.inc.php` is byte-for-byte unchanged (sha256\n     equal) and no marker is produced.\n3. **Expected evidence:** `bundle/logs/vuln_bypass_response.json` shows\n   `\"status\":0`; `bundle/logs/vuln_header.inc.php.modified` contains the\n   injected `PRUVA RCE PROOF` block; `bundle/logs/vuln_rce_marker.txt` contains\n   `PRUVA_RCE_CONFIRMED <phpversion> <timestamp>`; `bundle/logs/fixed_*` shows\n   the fixed image denies the request and leaves `header.inc.php` unchanged.\n\n## Evidence\n\n- `bundle/logs/reproduction_steps.log` — full transcript.\n- `bundle/logs/vuln_nobypass_response.json` — non-teacher, no bypass →\n  `NoPermission`-class error (request denied, no write).\n- `bundle/logs/vuln_bypass_response.json` — same user *with* the\n  `participant_role=3` bypass → `{\"response\":[{\"type\":\"data\",\"data\":{\"status\":0,\"offset\":0}}]}`.\n- `bundle/logs/vuln_header.inc.php.modified` — the overwritten header, first\n  lines:\n  ```\n  <?php\n  /* PRUVA RCE PROOF GHSA-h9qx-v5xp-ph8p */\n  file_put_contents(\"/var/lib/egroupware/pruva_rce_marker.txt\", \"PRUVA_RCE_CONFIRMED \".phpversion().\" \".date(\"c\"));\n  /**  ...original valid header preserved...  */\n  ```\n- `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)`\n  (created by the injected code executing through the real nginx→php-fpm path).\n- `bundle/logs/fixed_bypass_response.json` — fixed image returns the\n  `NoPermission` error raised at `SmallPartMediaRecorder.php (22)` (the patched\n  `isTeacher((int)…)/readVideo()` line).\n- `bundle/logs/fixed_header_before.sha256` / `fixed_header_after.sha256` —\n  identical sha256 of `header.inc.php` on the fixed image (no write).\n- `bundle/artifacts/data_vuln_bypass.json` — the exact crafted request body.\n- **Environment:** Docker; `egroupware/egroupware:26.2.20260216` (PHP 8.5.3\n  fpm, `opcache.validate_timestamps=0`), `mariadb:11.8`, `nginx:stable-alpine`;\n  `files_dir=/var/lib/egroupware/default/files`, `temp_dir=/tmp`,\n  `webserver_url=/egroupware`. All HTTP exercised through the real\n  `nginx → php-fpm` boundary (`docker exec <egw> curl http://<nginx>/egroupware/...`).\n\n## Recommendations / Next Steps\n\n- **Upgrade** to EGroupware `>= 26.2.20260224` (or `>= 23.1.20260224`), which\n  contains the `isTeacher((int)$course_id)` + `readVideo()` + DB-sourced\n  `videoPath()` fix.\n- **Defense in depth:** normalize/`basename()` the `videoPath()` components\n  (`video_hash`, `video_type`) and reject any containing `..` or starting with\n  `/`; validate `video_type` against the existing `VIDEO_MIME_TYPES` allow-list\n  in the upload path (not only in `addVideo`); and make `isParticipant()` never\n  honor a request-supplied `participants` array for authorization decisions\n  (only DB-sourced ACL).\n- **Read primitive:** sanitize `_filename` in\n  `importexport_export_ui::download` (it currently `readfile()`s and then\n  `unlink()`s `temp_dir/<_filename>` with no traversal filter — itself an\n  arbitrary-read/delete hazard).\n- **Operational:** disable PHP `disable_functions` cannot mitigate this (the\n  proof uses only `file_put_contents`/`phpversion`/`date`, no shell functions);\n  ensure `header.inc.php` is stored outside the webroot or is not a symlink\n  into a writable data directory.\n\n## Additional Notes\n\n- **Idempotency:** `reproduction_steps.sh` removes the per-stack containers and\n  named volumes (`docker rm -f` + `docker volume rm -f`) at the start of each\n  deploy, so consecutive runs start from a clean install. The script was\n  verified to pass end-to-end (vulnerable RCE + fixed negative control).\n- **Why a restart is required for RCE:** the production image sets\n  `opcache.validate_timestamps=0`, so a modified `header.inc.php` is not\n  recompiled until OPcache is cleared. The script restarts the egroupware\n  container to clear OPcache (the entrypoint then performs only an \"update\" and\n  preserves the valid, injected header). This precisely matches the advisory's\n  \"RCE occurs after server restart or OPcache expiry\".\n- **Non-destructive proof:** the injected payload preserves the original\n  `header.inc.php` config (the RCE marker is prepended after `<?php`), so the\n  instance remains functional and the RCE is observable via the marker file\n  rather than by breaking the installation.\n- **Account model:** the attacker is intentionally placed only in the `Default`\n  group (account_id 1), never in the `-2` group that carries the `admin` app\n  ACL, and is not a `smallpart` course member — so without the bypass\n  `isTeacher()` is genuinely false (confirmed by the no-bypass denial). The\n  EGroupware super-admin (`sysop`) is *not* used for the exploit because\n  `Bo::isAdmin()` returns true for super-admins (`isSuperAdmin()`), which would\n  mask the bypass.\n","ghsa_id":"GHSA-H9QX-V5XP-PH8P","cve_id":"CVE-2026-27823","cwe_id":"CWE-22","source_url":"https://github.com/advisories/GHSA-h9qx-v5xp-ph8p","package":{"name":"egroupware/egroupware","ecosystem":"Composer","affected_versions":"<=26.2.20260216, <=23.1.20260131","fixed_version":"26.2.20260224, 23.1.20260224"},"reproduced_at":"2026-07-08T04:51:38.086478+00:00","duration_secs":4684.0,"tool_calls":371,"handoffs":2,"total_cost_usd":8.695600959999995,"agent_costs":{"judge":0.02136835,"repro":4.668335159999998,"support":0.11229144,"vuln_variant":3.893606010000001},"cost_breakdown":{"judge":{"gpt-5.4-mini":0.02136835},"repro":{"accounts/fireworks/routers/glm-5p2-fast":4.668335159999998},"support":{"accounts/fireworks/routers/glm-5p2-fast":0.11229144},"vuln_variant":{"accounts/fireworks/routers/glm-5p2-fast":3.893606010000001}},"quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-07-08T04:52:05.729407+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":17463,"category":"reproduction_script"},{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":13407,"category":"analysis"},{"path":"bundle/vuln_variant/reproduction_steps.sh","filename":"reproduction_steps.sh","size":15621,"category":"reproduction_script"},{"path":"bundle/vuln_variant/rca_report.md","filename":"rca_report.md","size":16788,"category":"analysis"},{"path":"bundle/artifact_promotion_manifest.json","filename":"artifact_promotion_manifest.json","size":13693,"category":"other"},{"path":"bundle/artifact_promotion_report.json","filename":"artifact_promotion_report.json","size":18929,"category":"other"},{"path":"bundle/vuln_variant/source_identity.json","filename":"source_identity.json","size":1217,"category":"other"},{"path":"bundle/vuln_variant/root_cause_equivalence.json","filename":"root_cause_equivalence.json","size":2119,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":1335,"category":"other"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":981,"category":"other"},{"path":"bundle/logs/reproduction_steps.log","filename":"reproduction_steps.log","size":5537,"category":"log"},{"path":"bundle/logs/vuln_rce_marker.txt","filename":"vuln_rce_marker.txt","size":52,"category":"other"},{"path":"bundle/logs/vuln_bypass_response.json","filename":"vuln_bypass_response.json","size":122,"category":"other"},{"path":"bundle/logs/vuln_header.inc.php.modified","filename":"vuln_header.inc.php.modified","size":3698,"category":"other"},{"path":"bundle/logs/fixed_bypass_response.json","filename":"fixed_bypass_response.json","size":289,"category":"other"},{"path":"bundle/logs/fixed_header_before.sha256","filename":"fixed_header_before.sha256","size":65,"category":"other"},{"path":"bundle/logs/fixed_header_after.sha256","filename":"fixed_header_after.sha256","size":65,"category":"other"},{"path":"bundle/logs/vuln_nobypass_response.json","filename":"vuln_nobypass_response.json","size":289,"category":"other"},{"path":"bundle/logs/vuln_header_head.txt","filename":"vuln_header_head.txt","size":166,"category":"other"},{"path":"bundle/logs/fixed_overlay_write_bypass.json","filename":"fixed_overlay_write_bypass.json","size":448,"category":"other"},{"path":"bundle/logs/fixed_overlay_write_control.json","filename":"fixed_overlay_write_control.json","size":119,"category":"other"},{"path":"bundle/logs/vuln_overlay_write_bypass.json","filename":"vuln_overlay_write_bypass.json","size":448,"category":"other"},{"path":"bundle/vuln_variant/patch_analysis.md","filename":"patch_analysis.md","size":8267,"category":"documentation"},{"path":"bundle/vuln_variant/variant_manifest.json","filename":"variant_manifest.json","size":4039,"category":"other"},{"path":"bundle/vuln_variant/validation_verdict.json","filename":"validation_verdict.json","size":2800,"category":"other"},{"path":"bundle/vuln_variant/runtime_manifest.json","filename":"runtime_manifest.json","size":1036,"category":"other"},{"path":"bundle/logs/vuln_variant_reproduction.log","filename":"vuln_variant_reproduction.log","size":6125,"category":"log"},{"path":"bundle/logs/vuln_overlay_write_control.json","filename":"vuln_overlay_write_control.json","size":119,"category":"other"},{"path":"bundle/logs/vuln_variant_summary.txt","filename":"vuln_variant_summary.txt","size":77,"category":"other"},{"path":"bundle/logs/fixed_variant_summary.txt","filename":"fixed_variant_summary.txt","size":77,"category":"other"}]}