Skip to content

CVE-2026-27823: Verified Reproduction

CVE-2026-27823: 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.

CVE-2026-27823 is verified against egroupware/egroupware · Composer. Affected versions: <=26.2.20260216, <=23.1.20260131. Fixed in 26.2.20260224, 23.1.20260224. Vulnerability class: RCE. This critical reproduction includes runnable sandbox proof, artifacts, and a plain-text agent view under REPRO-2026-00272.

REPRO-2026-00272 egroupware/egroupware · Composer RCE Jul 8, 2026 CVE entry .txt
Severity
CRITICAL
CVSS
9.3
Confidence
HIGH
Reproduced in
78m 4s
Tool calls
371
Spend
$8.70
01 · Overview

What Is CVE-2026-27823?

CVE-2026-27823 is a critical remote code execution vulnerability in EGroupware, caused by chaining an authorization bypass in EGroupware\SmallParT\Widgets\SmallPartMediaRecorder::ajax_upload() with arbitrary file write and file read primitives. Pruva reproduced it (reproduction REPRO-2026-00272).

02 · Severity & CVSS

CVE-2026-27823 Severity & CVSS Score

CVE-2026-27823 is rated critical severity, with a CVSS base score of 9.3 out of 10.

CRITICAL threat level
9.3 / 10 CVSS base
Weakness CWE-22 — Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')

Critical — the most severe class — typically remotely exploitable with severe impact. Treat as an emergency.

03 · Affected Versions

Affected egroupware/egroupware Versions

egroupware/egroupware · Composer versions <=26.2.20260216, <=23.1.20260131 are affected.

How to Reproduce CVE-2026-27823

$ pruva-verify REPRO-2026-00272
or curl -O https://www.pruva.dev/api/v1/reproductions/REPRO-2026-00272/artifacts/bundle/repro/reproduction_steps.sh && chmod +x reproduction_steps.sh && ./reproduction_steps.sh
Run in a VM or disposable container. This exploits a real vulnerability.
06 · Proof of Reproduction

Proof of Reproduction for CVE-2026-27823

Remote code execution — reproduced
  • reached the target end-to-end
  • full exploit chain demonstrated
  • on the real production code path
  • high confidence
  • the upstream fix blocks the same trigger
Trigger

multipart POST to SmallPartMediaRecorder::ajax_upload: JSON 'data' field with course_id as an array containing participants[0].participant_role=3 (authz bypass) plus video_hash path-traversal ('../../../../../../../../../../../../usr/share/egroupware/header.inc' with video_type='php'); uploaded 'file' field = attacker…

Attack chain
  1. POST /egroupware/json.php?menuaction=EGroupware\SmallParT\Widgets\SmallPartMediaRecorder::ajax_upload (nginx
  2. php-fpm)
  3. isTeacher() bypass via request-supplied participants/role
  4. videoPath() path traversal in video_hash
  5. copy() writes attacker PHP to /usr/share/egroupware/header.inc.php (symlink
  6. /var/lib/egroupware/header.inc.php)
  7. injected header executes on next HTTP request after…
How the agent worked 875 events · 371 tool calls · 1h 18m
1h 18mDuration
371Tool calls
239Reasoning steps
875Events
4Dead-ends
Agent activity over 1h 18m
Support
25
Repro
416
Judge
42
Variant
388
0:0078:04

Root Cause and Exploit Chain for CVE-2026-27823

Versions: EGroupware 26.0.20251208 through < 26.2.20260224

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).

  • 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:

$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:

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:

$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:

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.txtPRUVA_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.

CVE-2026-27823 Reproduction Transcript

The agent's step-by-step process — every tool call, every handoff, the moment the exploit fired.

Full session Replay every step — scrub the timeline or play it back.

Event 1/40
0:000:56
0:00
session startedaccounts/fireworks/routers/glm-5p2-fast · GHSA-H9QX-V5XP-PH8P · REPRO-20
0:02
0:04
web search
0:06
web search
0:10
0:12
web search
0:13
web search
0:17
0:19
0:21
web search
0:26
0:26
0:34
0:35
web search
0:36
0:50
0:50
extract_facts
no facts extracted
0:51
0:51
0:51
supportrepro
0:53
0:53
0:53
0:54
0:54
0:54
0:54
0:56
0:56
0:56
08 · How to Fix

How to Fix CVE-2026-27823

Upgrade egroupware/egroupware · Composer to 26.2.20260224, 23.1.20260224 or later.

Coming soon

Step-by-step mitigation and hardening guidance for CVE-2026-27823 — configuration checks, workarounds where no patch exists, and how to verify you're protected — is on the way.

10 · FAQ

FAQ: CVE-2026-27823

How does the CVE-2026-27823 exploit chain work?

After bypassing the teacher check, the same request-controlled $data['video'] fields (video_hash/video_type) flow unsanitized into Bo::videoPath(), enabling a path-traversal file write via copy($_FILES['file']['tmp_name'], $filePath). A separate path traversal in importexport_export_ui::download lets the attacker read the valid header.inc.php, then overwrite it with injected PHP while preserving the file's structure; the injected code executes on the next request after a server restart or OPcache expiry.

Which EGroupware versions are affected by CVE-2026-27823, and where is it fixed?

Versions <=26.2.20260216 and <=23.1.20260131 of egroupware/egroupware are affected. It is fixed in 26.2.20260224 and 23.1.20260224.

How severe is CVE-2026-27823?

Critical, CVSS 9.3 — the chain leads to full remote code execution as the www-data web user, and if self-registration is enabled it may be exploitable without prior credentials.

How can I reproduce CVE-2026-27823?

Download the verified script from this page and run it in an isolated environment against an affected EGroupware build (<=26.2.20260216 / <=23.1.20260131). It authenticates as a low-privileged user, forges the teacher-role bypass, overwrites header.inc.php with injected PHP via the file-write/read primitives, and confirms code execution after the application reloads.
11 · References

References for CVE-2026-27823

Authoritative sources for CVE-2026-27823 — official vulnerability databases and the upstream advisory. Pruva's reproduction verifies the issue firsthand; these are the primary records to corroborate it.