Neutron, our AI engine, scored 97.1% on UC Berkeley's CyberGym benchmark. Learn more

Deep Dive

Deep Dive

OpenEMR IDOR Exposes Any Patient Document (CVE-2026-102940)

Any signed-in OpenEMR user can move another patient's document to a record they can read, bypassing document access controls. Fixed in 8.4.0.

TL;DR

  • A signed-in OpenEMR user of any role can move another patient's document to a patient or category they can access, then download it through the normal read path. The advisory was published on 2026-09-13.
  • OpenEMR before 8.4.0 is affected. The document controller's write actions never checked the caller's access to the document they were changing.
  • The advisory rates it CVSS v3.1 9.6 Critical and includes a Python proof of concept. CISA records a public proof of concept and no exploitation; the CVE is not in CISA KEV as of 2026-11-06.
  • Upgrade to OpenEMR 8.4.0 or later. If you cannot, restrict the document controller and audit document moves; see "How do I fix it?".

Am I affected?

You are exposed only when all of these hold:

  • The installation runs OpenEMR before 8.4.0. The advisory lists every release before 8.4.0 as affected.
  • The attacker holds a valid account on the instance. Any role works, including reception and front-desk accounts that can open patient documents.
  • The document controller answers requests at /controller.php with the document controller. This is part of the standard OpenEMR web interface, so no optional module is needed.
  • The attacker can name a document id. Document ids are integers, and the advisory's proof of concept takes an existing id as an argument.

No special configuration is required. The flaw is in the core document controller, not in a contributed module. The read path already blocks a document the user cannot access; the write path is what moves the document into a record the user can read.

Branch Affected versions Fixed version
OpenEMR every release before 8.4.0 8.4.0 (released 2026-09-13)

Check the installed version from the OpenEMR web root:

grep -E "^\$v_(major|minor|patch)" version.php

The three variables print the installed version, for example 8.3.0. A version below 8.4.0 is affected.

How do I fix it?

  1. Upgrade to OpenEMR 8.4.0 or later. The 8.4.0 release is the first release that carries the document-write guard. Follow the project's upgrade instructions for the database and file migration.
  2. If you cannot upgrade now, restrict access to the document controller to staff who need it, and review the application log for denied document writes. The fix logs the warning "An attempt was made to modify a document without authorization" when it blocks a request, so that warning appears only after the upgrade.
  3. Treat document moves made before the upgrade as suspect. The documents and categories_to_documents tables store the current patient and category assignments. A move overwrites those assignments without retaining their previous values. A successful patient move also removes the document from the original patient's record, which can be noticed as a missing document rather than as a read. A category-only move re-categorizes the document in place, so it appears under a different category.

The vendor advisory names no workaround, and no configuration setting closes the missing check. Restricting the accounts that can reach the document controller reduces exposure but does not fix the code.

What is the root cause?

The document controller guards its read actions but not its write actions, so a caller can change a document's owner before reading it. The read action in controllers/C_Document.class.php checks that the document belongs to the requested patient with an explicit comment about preventing IDOR:

// Verify the document belongs to the requested patient to prevent IDOR.
$doc_pid = $d->get_foreign_id();
if ($patient_id !== null && (int)$doc_pid !== (int)$patient_id) {
    AccessDeniedHelper::deny("Unauthorized attempt to retrieve document $document_id belonging to pid $doc_pid");
}

The move action reads the document id and the destination ids from the request and checks only that they are numeric:

public function move_action_process(?string $patient_id, $document_id)
{
    if ($_POST['process'] != "true") {
        return;
    }

    $messages = '';

    $new_category_id = $_POST['new_category_id'];
    $new_patient_id = $_POST['new_patient_id'];

    //move to new category
    if (is_numeric($new_category_id) && is_numeric($document_id)) {
        $sql = "UPDATE categories_to_documents set category_id = ? where document_id = ?";
        $messages .= sprintf("%s '%s' %s\n", xl('Document moved to new category'), $this->tree->_id_name[$new_category_id]['name'], xl('successfully.'));
        $this->tree->_db->Execute($sql, [$new_category_id, $document_id]);
    }

    //move to new patient
    if (is_numeric($new_patient_id) && is_numeric($document_id)) {
        $d = new Document($document_id);
        $sql = "SELECT pid from patient_data where pid = ?";
        $result = QueryUtils::querySingleRow($sql, [$new_patient_id]);
        if ($result === false) {
            //patient id does not exist
        } else {
            $changefailed = !$d->change_patient($new_patient_id);
            // ...
            return $this->list_action($patient_id);
        }
    }
}

Neither $document_id nor $patient_id is checked against the caller's access. move_action_process() is reached through the controller with the patient id and document id as positional arguments. The controller also carried no blanket ACL entry for document: Controller::CONTROLLER_ACL_MAP had entries for practice_settings and prescription, but not for the document controller.

One operational detail makes patient moves harder to notice. The move runs first, and then the method calls list_action($patient_id) to render the source patient's document list. That rendering step is access-checked. A tester who reads only the HTTP status can therefore see a 403 after a request that already moved the document.

How is it exploited?

  1. The attacker signs in with any account that can open the document controller. The advisory's proof of concept uses a front-desk account.
  2. The attacker chooses a document id and the patient id the document currently belongs to. Both values are integers. The proof of concept takes an existing document id as an argument.
  3. The attacker sends a POST to the document controller with these fields:
    • process=true in the POST body
    • the source patient_id
    • the document_id
    • a target new_patient_id or new_category_id
  4. The controller runs move_action_process(). The method updates categories_to_documents for a category move and calls Document::change_patient() for a patient move, without checking the caller's access to the document.
  5. The document now belongs to the attacker's patient or category. The attacker opens it through the normal read path, which passes because the recorded owner is now a record the attacker can access.
  6. A patient move removes the document from the original patient's record; a category-only move re-categorizes it within that record. The attacker can repeat the step for other document ids.

The advisory's proof of concept sends the request with the modern controller routing. Adapted from that proof of concept, the minimal request is:

POST /controller.php?controller=document&action=move&patient_id=<source-pid>&document_id=<document-id>
Content-Type: application/x-www-form-urlencoded

process=true&new_patient_id=<attacker-visible-pid>&new_category_id=<attacker-visible-category-id>

The patient_id in the query string must be the document's current patient for the controller to route the request correctly. The write path never checks that the caller may access that patient.

How does the attack flow?

A low-privilege account moves a document it cannot read into a patient record it can read, and then downloads it through the normal path.

flowchart LR A["Any signed-in user"] --> B["POST document move with a target document id"] B --> C["move_action_process reads ids, checks only is_numeric"] C --> D["No access check on the source document"] D --> E["Document reassigned to the attacker's patient or category"] E --> F["Read path now authorizes the document"] F --> G["Attacker downloads another patient's document"]

What can an attacker do?

An attacker with any valid account can read any document in the instance, including documents in categories and patient records the account cannot open directly. The impact is a full bypass of document access controls. Every condition is listed here in full:

  • The installation runs OpenEMR before 8.4.0.
  • The attacker has a valid account of any role.
  • The attacker can reach the document controller.
  • The attacker knows a document id and the patient id it currently belongs to.
  • The attacker can access some patient or category to use as the destination.

The confidentiality impact is high because the document can be downloaded after the move. The integrity impact is high because the move changes document assignments. A patient move removes the document from the original patient's record and adds it to the destination record. A category-only move changes the category while leaving the patient assignment intact.

The advisory scores availability as none. The vulnerability does not execute code and does not require user interaction. It changes scope because the attacker reaches data outside the account's own security scope.

The advisory says "any authenticated user of any role". It does not demonstrate a path for an unauthenticated attacker, and the proof of concept starts from a login.

Why does the fix work?

The released fix adds a guard that loads the document and requires the caller to access it in its current state. The write actions call that guard. The relevant change in move_action_process():

     public function move_action_process(?string $patient_id, $document_id)
     {
         if ($_POST['process'] != "true") {
             return;
         }

+        if (!is_numeric($document_id)) {
+            $this->throwAccessDenied("Invalid document id for move", xl("Documents"));
+        }
+
+        if (!AclMain::aclCheckCore('patients', 'docs', '', ['write', 'addonly'])) {
+            $this->throwAccessDenied("ACL check failed for patients/docs write|addonly: Documents", xl("Documents"));
+        }
+
+        // Anti-IDOR: the caller must be able to access the document in its
+        // current state before they may move it.
+        $d = $this->authorizeDocumentWrite($patient_id, $document_id);
+
         $messages = '';

         $new_category_id = $_POST['new_category_id'];
         $new_patient_id = $_POST['new_patient_id'];

         //move to new category
-        if (is_numeric($new_category_id) && is_numeric($document_id)) {
+        if (is_numeric($new_category_id)) {
+            if (!$this->canAccessDestinationCategory($new_category_id)) {
+                $this->throwAccessDenied(
+                    "Unauthorized attempt to move document " . $document_id . " to category " . $new_category_id,
+                    xl("Not authorized to move the document to the selected category")
+                );
+            }
             $sql = "UPDATE categories_to_documents set category_id = ? where document_id = ?";
             $messages .= sprintf("%s '%s' %s\n", xl('Document moved to new category'), $this->tree->_id_name[$new_category_id]['name'], xl('successfully.'));

The document-write guard, with the logging block shortened:

private function authorizeDocumentWrite(?string $patient_id, $document_id): Document
{
    $d = new Document($document_id);
    if ($this->isSkipAclCheck()) {
        return $d;
    }
    $docPid = $d->get_foreign_id();
    $patientMismatch = $patient_id === null
        || !is_numeric($docPid)
        || (int) $docPid !== (int) $patient_id;
    if (!$d->can_access() || $patientMismatch) {
        $documentIdLabel = is_scalar($document_id) ? (string) $document_id : '';
        $docPidLabel = is_scalar($docPid) ? (string) $docPid : '';
        // logging omitted
        $this->throwAccessDenied(
            "Unauthorized attempt to modify document " . $documentIdLabel . " belonging to pid " . $docPidLabel,
            xl("Not authorized to modify the requested document")
        );
    }
    return $d;
}

The guard fails closed. A missing or empty patient_id is treated as a mismatch, so a caller cannot drop the patient context to pass the category check. Document::can_access() checks the ACL of every category the document currently belongs to, and the patient-context comparison repeats the read-path rule. The destination-category check mirrors the upload path.

The same guard is called from the write actions the advisory names:

  • move_action_process()
  • update_action_process()
  • tag_action_process()
  • validate_action_process()
  • the procedure-tag actions
  • the note-and-email path

Controller::CONTROLLER_ACL_MAP also gains a document entry for patients/docs, so the controller now has a blanket gate in addition to the per-document check. Service and CLI callers that explicitly skip ACL enforcement remain exempt, as they are for the read path.

How do I check if I was compromised?

The vendor publishes no indicators of compromise. The useful checks are on the application data and the web server log:

# Patient moves change documents.foreign_id; compare with your own inventory.
# OpenEMR stores the current patient only.
mysql -u <user> -p <database> -e "SELECT id, foreign_id, name, date FROM documents ORDER BY date DESC LIMIT 50;"

# Category moves change categories_to_documents; compare with your own inventory.
# OpenEMR stores current category mappings, not their previous values.
mysql -u <user> -p <database> -e "SELECT cd.document_id, cd.category_id, d.foreign_id, d.name FROM categories_to_documents cd JOIN documents d ON d.id = cd.document_id ORDER BY d.date DESC LIMIT 50;"

# Requests to the document move action in the web server log.
grep -Ei 'controller\.php\?[^ ]*(document|action=move)' /var/log/apache2/*access*.log /var/log/nginx/*access*.log 2>/dev/null

The advisory's proof of concept uses a POST to /controller.php with document and move in the query string, and the patient and document ids as parameters. A hit is a lead, not proof: legitimate staff also move documents. Look for moves by accounts that should not have accessed the source patient, and for moves into a patient record followed by a download of the same document.

The fix logs "An attempt was made to modify a document without authorization" with the user id, requested patient id, document patient id, and document id. That warning exists only on 8.4.0 and later, so it will not appear on an unpatched instance. An attempt that the flawed code allowed leaves no warning, and the move may leave no trace beyond the changed record.

Timeline

  • 2026-08-11: the first fix commit, 312b5d042f7fa49cc78ebed041155fba339a991c, is merged as pull request #13353.
  • 2026-09-13: OpenEMR 8.4.0 is released with the document-write guard, and advisory GHSA-92rh-g949-4mvv is published the same day.
  • 2026-11-05: CVE-2026-102940 is published with CVSS v3.1 9.6 and CWE-639.
  • 2026-11-06: CISA's SSVC assessment records a public proof of concept and no exploitation in the wild. The CVE is not in CISA KEV.

The fix shipped in the 8.4.0 release 33 days after the first fix commit, and the CVE record followed 53 days after the release.
Figure 1: From the first fix commit to the 8.4.0 release and the CVE record. Source: OpenEMR commit 312b5d0, the 8.4.0 release and the CVE record, checked November 6, 2026

FAQ

Does the attacker need an administrator account?

No. The advisory says any authenticated user of any role can reassign a document, and its proof of concept uses a front-desk account. An administrator account makes the attack easier because it can reach more destination records, but it is not required.

Can the attacker read the document without moving it?

No, through the read path. The read actions check the document's patient and category access. The attack works by changing the record the read path trusts, then reading through that path.

Is OpenEMR 8.3.x affected?

Yes. The advisory and the CVE record list every release before 8.4.0 as affected, and 8.4.0 is the first release that carries the document-write guard. Upgrade to 8.4.0 or later.

Was this exploited in the wild?

No exploitation in the wild is reported as of 2026-11-06, and the CVE is not in CISA KEV. CISA's SSVC assessment records a public proof of concept, which the advisory itself carries.

Does a 403 response mean the move failed?

No. For a patient move, the reassignment runs before the source patient's list is rendered, and that rendering step is separately access-checked. A 403 can therefore arrive after the document has already moved. Check the document record, not only the status code.

Is there a workaround if I cannot upgrade?

No vendor workaround closes the missing check. Restrict the accounts that can reach the document controller. Review document moves. Upgrade as soon as you can.

References

  • OpenEMR security advisory GHSA-92rh-g949-4mvv, published 2026-09-13, with the affected range, the vulnerable code, the proof of concept and the fix: https://github.com/openemr/openemr/security/advisories/GHSA-92rh-g949-4mvv (checked November 6, 2026)
  • CVE-2026-102940 record, published 2026-11-05, CVSS v3.1 9.6, CWE-639 and the affected range: https://www.cve.org/CVERecord?id=CVE-2026-102940 (checked November 6, 2026)
  • OpenEMR commit 312b5d042f7fa49cc78ebed041155fba339a991c, "fix(acl): add several staff-write checks (#13353)", 2026-08-11: https://github.com/openemr/openemr/commit/312b5d042f7fa49cc78ebed041155fba339a991c (checked November 6, 2026)
  • OpenEMR pull request #13353, the first fix: https://github.com/openemr/openemr/pull/13353 (checked November 6, 2026)
  • OpenEMR 8.4.0 release, 2026-09-13, listing the critical document-reassignment fix: https://github.com/openemr/openemr/releases/tag/v8_4_0 (checked November 6, 2026)
  • OpenEMR controllers/C_Document.class.php at v8_4_0, the authorizeDocumentWrite() guard: https://github.com/openemr/openemr/blob/v8_4_0/controllers/C_Document.class.php (checked November 6, 2026)
  • OpenEMR controllers/C_Document.class.php at commit 61f48a2a1d27b9d37039dfac9a2f1c8732bbb467, the vulnerable move_action_process(): https://github.com/openemr/openemr/blob/61f48a2a1d27b9d37039dfac9a2f1c8732bbb467/controllers/C_Document.class.php (checked November 6, 2026)
  • OpenEMR library/classes/Controller.class.php at v8_4_0, the document entry in CONTROLLER_ACL_MAP: https://github.com/openemr/openemr/blob/v8_4_0/library/classes/Controller.class.php (checked November 6, 2026)
  • CISA ADP SSVC assessment for CVE-2026-102940, Exploitation: poc, timestamp 2026-11-05: https://cveawg.mitre.org/api/cve/CVE-2026-102940 (checked November 6, 2026)
  • CISA Known Exploited Vulnerabilities catalog JSON feed, checked for CVE-2026-102940 on November 6, 2026 (not listed): https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json (checked November 6, 2026)
  • Docker Hub, openemr/openemr, 10,067,383 pulls and 74 stars, the container image reach figure: https://hub.docker.com/r/openemr/openemr (checked November 6, 2026)
  • GitHub, openemr/openemr, 5,584 stars and 3,128 forks: https://github.com/openemr/openemr (checked November 6, 2026)