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.
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).
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 — the most severe class — typically remotely exploitable with severe impact. Treat as an emergency.
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 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 Proof of Reproduction for CVE-2026-27823
- 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
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…
- POST /egroupware/json.php?menuaction=EGroupware\SmallParT\Widgets\SmallPartMediaRecorder::ajax_upload (nginx
- php-fpm)
- isTeacher() bypass via request-supplied participants/role
- videoPath() path traversal in video_hash
- copy() writes attacker PHP to /usr/share/egroupware/header.inc.php (symlink
- /var/lib/egroupware/header.inc.php)
- injected header executes on next HTTP request after…
How the agent worked
Root Cause and Exploit Chain for CVE-2026-27823
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 bundledsmallpartapp,src/Widgets/SmallPartMediaRecorder.phpandsrc/Bo.php). A secondary read primitive exists in theimportexportapp (importexport_export_ui::download, user-controlled_filename). - Affected versions: EGroupware
26.0.20251208through< 26.2.20260224(and the23.1branch< 23.1.20260224). Fixed in26.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, overwritingheader.inc.php). - Reproduced impact from this run: Full RCE parity. A non-teacher,
non-admin authenticated user (account
attacker, account_id 7, member of theDefaultgroup only) sent a single craftedPOSTto the realajax_uploadendpoint overnginx → php-fpmand:- bypassed
isTeacher()(the no-bypass form of the same request is denied withNoPermission); - wrote attacker PHP into
header.inc.php(the upload returnedstatus:0and the file on disk then contained the injected code); - after a container restart to clear OPcache
(
opcache.validate_timestamps=0, exactly the "after server restart / OPcache expiry" condition stated in the advisory), an HTTPGET /egroupware/index.phpexecuted the injected code, which created/var/lib/egroupware/pruva_rce_marker.txtcontainingPRUVA_RCE_CONFIRMED 8.5.3 <run-timestamp> (e.g. 2026-07-07T22:42:21+00:00 from the verified run).
- bypassed
- Parity:
full. - Not demonstrated: The advisory also describes an arbitrary-file-read via
importexport_export_ui::download(used to harvest a validheader.inc.phptemplate). 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
- Reference script:
bundle/repro/reproduction_steps.sh(self-contained; uses onlysudo docker+python3+curl/jq-style tooling present in the sandbox). - 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+egroupwarephp-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
attackerdirectly inegw_accounts(bcrypt{crypt}hash, member of theDefaultgroup only — explicitly not the-2group that carries admin ACL). - Logs in as
attackerviaPOST /egroupware/login.phpand captures thesessionid/kp3cookies. - No-bypass control: sends
ajax_uploadwith a plain integercourse_id→ expects denial (non-teacher must not passisTeacher). - Bypass + write: builds a "valid
header.inc.php+ injectedfile_put_contents(...)marker" payload (so the instance stays functional), sendsajax_uploadwith the craftedcourse_idarray (participant_role=3) andvideo_hashtraversal targeting.../usr/share/egroupware/header.inc.php, verifies the upload returnsstatus:0andheader.inc.phpon 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 includesheader.inc.php) and reads the marker file. - Negative control: repeats the same bypass request against the fixed
image and verifies
header.inc.phpis byte-for-byte unchanged (sha256 equal) and no marker is produced.
- Pulls
- Expected evidence:
bundle/logs/vuln_bypass_response.jsonshows"status":0;bundle/logs/vuln_header.inc.php.modifiedcontains the injectedPRUVA RCE PROOFblock;bundle/logs/vuln_rce_marker.txtcontainsPRUVA_RCE_CONFIRMED <phpversion> <timestamp>;bundle/logs/fixed_*shows the fixed image denies the request and leavesheader.inc.phpunchanged.
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 theparticipant_role=3bypass →{"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 theNoPermissionerror raised atSmallPartMediaRecorder.php (22)(the patchedisTeacher((int)…)/readVideo()line).bundle/logs/fixed_header_before.sha256/fixed_header_after.sha256— identical sha256 ofheader.inc.phpon 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 realnginx → php-fpmboundary (docker exec <egw> curl http://<nginx>/egroupware/...).
Recommendations / Next Steps
- Upgrade to EGroupware
>= 26.2.20260224(or>= 23.1.20260224), which contains theisTeacher((int)$course_id)+readVideo()+ DB-sourcedvideoPath()fix. - Defense in depth: normalize/
basename()thevideoPath()components (video_hash,video_type) and reject any containing..or starting with/; validatevideo_typeagainst the existingVIDEO_MIME_TYPESallow-list in the upload path (not only inaddVideo); and makeisParticipant()never honor a request-suppliedparticipantsarray for authorization decisions (only DB-sourced ACL). - Read primitive: sanitize
_filenameinimportexport_export_ui::download(it currentlyreadfile()s and thenunlink()stemp_dir/<_filename>with no traversal filter — itself an arbitrary-read/delete hazard). - Operational: disable PHP
disable_functionscannot mitigate this (the proof uses onlyfile_put_contents/phpversion/date, no shell functions); ensureheader.inc.phpis stored outside the webroot or is not a symlink into a writable data directory.
Additional Notes
- Idempotency:
reproduction_steps.shremoves 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 modifiedheader.inc.phpis 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.phpconfig (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
Defaultgroup (account_id 1), never in the-2group that carries theadminapp ACL, and is not asmallpartcourse member — so without the bypassisTeacher()is genuinely false (confirmed by the no-bypass denial). The EGroupware super-admin (sysop) is not used for the exploit becauseBo::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.
Artifacts and Evidence for CVE-2026-27823
Scripts, logs, diffs, and output captured during the reproduction.
How to Fix CVE-2026-27823
Upgrade egroupware/egroupware · Composer to 26.2.20260224, 23.1.20260224 or later.
FAQ: CVE-2026-27823
How does the CVE-2026-27823 exploit chain work?
Which EGroupware versions are affected by CVE-2026-27823, and where is it fixed?
How severe is CVE-2026-27823?
How can I reproduce CVE-2026-27823?
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.