Skip to content

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.

REPRO-2026-00272 egroupware/egroupware · Composer RCE Variant found 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://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…
Variants tested

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

Variant Analysis & Alternative Triggers for CVE-2026-27823

Versions: 26.2.20260216 through latest 26.7.20260702

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
  1. SmallPartMediaRecorder::ajax_upload() (src/Widgets/SmallPartMediaRecorder.php):

    • $bo->isTeacher((int)$data['video']['course_id']) — casts course_id to int, defeating the participants-array bypass (a non-empty array casts to 1, and isTeacher(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.
  2. Bo::videoPath() (src/Bo.php):

    • basename($video['video_hash']) !== $video['video_hash'] — rejects path traversal in video_hash.
    • basename($video['video_type']) !== $video['video_type'] — rejects path traversal in video_type.
  3. importexport_export_ui::download() (egroupware core, importexport/inc/class.importexport_export_ui.inc.php):

    • basename($tmpfname) !== $tmpfname — rejects path traversal in the _filename parameter (the arbitrary-file-read primitive).
What the fix does NOT cover
  • Bo::isParticipant() itself — the function still honors a request-supplied participants array 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 from Overlay::ajax_write() and Overlay::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 (via Courses::ajax_action).
  • The latest tag 26.7.20260702 also does not fix isParticipant().

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:

  1. Overlay::ajax_write(array $data)self::aclCheck($data['course_id'], true)
  2. Overlay::aclCheck($course_id, $update=true):
    • Bo::getInstance()->isParticipant($course_id)$course_id is the array. isParticipant() sees is_array($course) && isset($course['participants']), filters for the attacker's account_id, caches $this->course_acl["1"] = 3 (ROLE_TEACHER), and returns true.
    • $update && !Bo::getInstance()->isTeacher($course_id)isTeacher() calls isParticipant($course_id, ROLE_TEACHER), finds the just-cached value 3 & 3 === 3, returns true. The aclCheck passes.
  3. Overlay::write($data) — has a pre-existing type guard is_int($data['course_id']) || is_numeric($data['course_id']) that rejects the array with InvalidArgumentException("Invalid argument...").

Observable proof:

  • Control (plain int course_id=1, non-teacher user): response = {"message":"Permission denied!","type":"error"}aclCheck correctly denies.
  • Bypass (array course_id with participants): response = {"message":"Invalid argument EGroupware\\SmallParT\\Overlay::write({...})", "type":"error"}aclCheck passed (the request reached write()'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 (bundled smallpart app, src/Bo.php isParticipant() function). The root cause is in the shared ACL function, affecting all smallpart AJAX endpoints that call isTeacher() / isParticipant() / isAdmin() with request-controlled data.
  • Affected versions: 26.2.20260216 through latest 26.7.20260702 (and 23.1 branch). The isParticipant() 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 downstream write()/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() via Courses::edit(): would allow a student to modify a course (change password, add themselves as teacher/admin) — a privilege escalation. Requires etemplate exec_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 because Bo::videoPath() now has basename() guards and the ajax_upload caller 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-supplied participants arrays 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 downstream write() 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 because videoPath() now has basename() 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 the basename() 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 on course_id + readVideo() for DB-sourced path
  • src/Bo.phpbasename() guards in videoPath()

It did not modify isParticipant(), isTeacher(), isAdmin(), or any other caller. The isParticipant() function is identical across 26.2.2026021626.2.2026022426.7.20260702 (verified via git diff).

Reproduction Steps

  1. Reference script: bundle/vuln_variant/reproduction_steps.sh
  2. 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 attacker directly in egw_accounts (member of the Default group only, with smallpart app run ACL).
    • Logs in as attacker via POST /egroupware/login.php.
    • Control: sends Overlay::ajax_write with a plain integer course_id → expects "Permission denied!" (aclCheck denies non-teacher).
    • Bypass: sends Overlay::ajax_write with course_id as a PHP array containing participants[].participant_role=3 → expects NOT "Permission denied!" (proves the authz check was bypassed; the response is "Invalid argument" from write()'s type guard).
    • Compares results across both versions.
  3. 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, since isParticipant() 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:
    {"response":[{"type":"message","data":{"message":"Invalid argument EGroupware\\SmallParT\\Overlay::write({\"course_id\":{\"participants\":[{\"account_id\":7,...\"participant_role\":3}],...})","type":"error"}}],...}
    
    The "Invalid argument" error (from Overlay::write() line 361) proves the request passed aclCheck() and reached write(). The aclCheck calls isParticipant($course_id) and isTeacher($course_id) with the raw request array — both returned true via the participants-array bypass.
  • bundle/logs/vuln_overlay_write_bypass.json — identical behavior on the vulnerable version, confirming isParticipant() was never changed.
  • bundle/logs/vuln_variant_summary.txt / fixed_variant_summary.txt — structured summaries.
  • Environment: Docker; egroupware/egroupware:26.2.20260216 and :26.2.20260224 (PHP 8.5 fpm), mariadb:11.8, nginx:stable-alpine. All HTTP exercised through the real nginx → php-fpm boundary.
  • Source verification: isParticipant() is byte-identical across 26.2.2026021626.2.2026022426.7.20260702 (verified via git diff and git show on the smallpart repo).

Recommendations / Next Steps

  1. Fix the root cause in Bo::isParticipant(): Remove the code path that honors a request-supplied participants array for authorization decisions. The participants array in the $course parameter is intended for performance optimization (avoiding a DB read when participant data is already available from a prior read() 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 participants array for internal calls.
  2. Harden all callers: Cast course_id to (int) before passing to isTeacher() / isParticipant() / isAdmin() / isTutor() / isStaff() in ALL AJAX handlers and etemplate callbacks, not just ajax_upload(). Specifically:

    • Overlay::aclCheck() — cast $course_id to (int).
    • Student\Ui::ajax_listComments() — cast $where components to (int).
    • Bo::save() — cast $keys['course_id'] to (int) before isTeacher().
    • Bo::close() — cast $id to (int) before isAdmin().
  3. Defense in depth: Add type checks (is_int or is_numeric) at all AJAX entry points that receive course_id parameters, 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 via videoPath() traversal) with basename() guards, which properly prevents RCE even if the authz bypass works. However, the authz bypass itself enables other impacts (privilege escalation via Bo::save(), information disclosure via comment visibility, course state modification via Bo::close()) that the fix does not address.
  • Pre-existing type guards: Overlay::write() and Overlay::delete() have is_int/is_numeric checks that predate the fix (present in 26.2.20260216). These guards block the array course_id at the write/delete level, which is why the Overlay::ajax_write variant 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 tag 26.7.20260702 (verified via git 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.

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.