a broken-access-control and path-traversal flaw in the bundled fax/sms module lets any authenticated low-privilege user read and write arbitrary files on the server as the web user. the disposeDocument action of the RingCentral and SignalWire clients takes an unvalidated file_path and neither authenticates the caller nor confines the path. a user holding only the broad patients/demo ACL (most clinical and clerical roles) can read sites/default/sqlconf.php and recover the database credentials, which is the entire patient record set, and can write attacker content to any path the web user owns. i confirmed both primitives over https as a non-admin Front Office account.
CVSS 4.0 scores 9.3 / critical ( CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:L/SC:H/SI:L/SA:N ).
component: interface/modules/custom_modules/oe-module-faxsms . introduced by the 8.2.0 fax/sms refactor ( #7859 ), which split disposeDocument into per-vendor subclasses. this is the module family previously hit by CVE-2026-24848 (file write to RCE) and CVE-2026-33305 (authz bypass).
the front controller AppDispatch::routeAction ( src/Controller/AppDispatch.php ) enforces only two things for disposeDocument : presence on the ROUTABLE_ACTIONS allowlist, and a valid CSRF token (its entry is csrf => true , around line 138). it does not authenticate. by the base-class contract each action method must call $this->authenticate() itself, which is where the patients/docs ACL is checked.
the only auth barrier actually reached is the construction gate in AppDispatch::__construct , and verifyAcl() defaults to aclCheckCore('patients', 'demo') (around line 755). that is "view demographics", not the patients/docs documents permission a fax-document handler should require.
the three vendor implementations diverged in the refactor:
EtherFaxActions::disposeDocument was hardened after CVE-2026-24848. the two other siblings got none of that. both take $where = $this->getRequest('file_path') with no validation and then, for action=download , call sendFile($where) which runs FaxUploadStaging::decryptFileBytes($where) -> file_get_contents($where) -> crypto->decryptFromFilesystem(...) ; and for action=setup with content , run file_put_contents($where, encryptForFilesystem(base64_decode($content))) .
the read returns any file, not just fax artifacts, because ContextualEncryptionTrait::decryptFromFilesystem ( src/BC/Crypto/ContextualEncryptionTrait.php:57 ) passes non-encrypted input straight through:
so sqlconf.php , /etc/passwd , php source and key material stream back verbatim. the concrete vendor client is chosen by the configured vendor global ( ServiceFactory::create , src/Service/ServiceFactory.php ): oefax_enable_fax=1 (RingCentral) and oefax_enable_sms=1 route to RCFaxClient , oefax_enable_fax=6 routes to SignalWireClient .
i confirmed RCFaxClient live. SignalWireClient has the identical missing guards by code review and i did not run it separately.
environment, a normal site that uses a fax vendor: (1) the fax/sms module ( oe-module-faxsms ) is installed and active under Modules > Manage Modules; (2) the active fax vendor is RingCentral, i.e. the global oefax_enable_fax = 1 , set in the Fax/SMS module setup; (3) an authenticated session for any user whose role has patients/demo . i used a fresh Front Office user ( clerk ) with no admin rights and no documents access.
routing params ( _ACTION_COMMAND , type ) are read from $_GET ( resolveRoute -> getQuery ), so they must be in the query string. the download branch accepts GET.
step 1, mint a -form CSRF token (any faxsms page emits it) with an authenticated GET:
the response contains let csrfToken=" " . take that value.
step 2, arbitrary read of the database credentials:
the server responds 200 with Content-Disposition: attachment; filename=sqlconf.php and the file body, including $login , $pass , $host , $dbase . any absolute path works ( /etc/passwd confirmed too).
step 3, arbitrary write. routing stays in the query string, the rest goes in the POST body:
file_put_contents writes to with no path or extension check. content is encrypted at rest when the drive_encryption global is on (integrity/DoS: overwrite or corrupt any web-writable file, plant files anywhere), and written verbatim when it is off, which yields a webshell to RCE where the target directory is web-served and runs php.
a copy-paste browser-console PoC for both branches, plus screenshots of the live read of sqlconf.php and the write round-trip performed as the clerk account, are attached ( poc_faxsms_disposeDocument_console.js , poc_faxsms_disposeDocument_write.js )
poc_faxsms_disposeDocument_console.js poc_faxsms_disposeDocument_write.js .
broken access control plus path traversal, with an arbitrary file read primitive and an arbitrary file write primitive.
impacted: any OpenEMR site that has the fax/sms module active with RingCentral or SignalWire as the fax/sms vendor. exploitable by any authenticated user whose role carries patients/demo , which is the common case for clinical and clerical staff. no admin rights, no documents permission, no user interaction.
an attacker with a low-privilege account can read sites/default/sqlconf.php and obtain the database credentials, i.e. the full patient database and all PHI, not only the caller's own patients; read any other file the web user can reach (site configs, keys, /etc/passwd ); and write or overwrite arbitrary files the web user owns, which is integrity loss and, where drive_encryption is off and the path is web-executable, remote code execution. this crosses every intended access boundary in the product. a front-desk account should not be able to recover the database password.
remediation (suggested)
give RCFaxClient::disposeDocument and SignalWireClient::disposeDocument the guards EtherFaxActions already has: an authenticate() call enforcing patients/docs , realpath containment of file_path inside the module baseDir , an extension allowlist, and the executable-content check.
the per-vendor divergence is the real defect. move authentication and file_path containment into AppDispatch (base class) or the router so every action runs them before dispatch and a new vendor cannot reintroduce this by forgetting a call.
the divergence is not unique to disposeDocument . enumerating every routable action against every client, several more skip authenticate() and warrant the same review (i did not exploit these): faxProcessUploads (all three fax clients), forwardFax ( EtherFaxActions , SignalWireClient ), sendEmail ( EmailClient ), sendSMS ( TwilioSMSClient ), and read endpoints with no per-action check such as apiFetchPatientDetails , fetchTextMessage , getCallLogs , getNotificationLog , getUser , fetchSMSList , fetchEmailList . fixing this per-method will keep missing one; enforce it centrally per item 2.
reporting this privately and i want to coordinate, not surprise anyone.
timeline: i will hold all public detail for 90 days from this report, or until a patched release is out, whichever comes first. if you need more time to ship a fix safely, say so and we extend. i am not going to drop this on a deadline for its own sake.
until then: nothing public. no blog, no talk, no gist. the working PoC scripts, the screenshots and the reproduction notes stay with you and me only.
after a fixed release is available and users have had a reasonable window to update, i would like to publish a writeup and the full toolbag (PoCs and repro), on a date we agree on together. happy to let you pick it.
CVE: please assign one, or tell me if you would rather i request it through the advisory.
i am glad to verify the fix against my repro before you release, if that helps.
for anything out of band: [email protected]
The full story
This article is one source in a clustered incident — the cluster page carries the summary, timeline and every other outlet covering it.
