OpenEMR: un IDOR expone cualquier documento de paciente (CVE-2026-102940)
En OpenEMR anterior a 8.4.0, cualquier usuario con sesión puede mover el documento de otro paciente y luego leerlo. Corregido en 8.4.0.
Resumen
- Un usuario de OpenEMR con sesión, sea cual sea su rol, puede mover el documento de otro paciente a un paciente o una categoría a la que tiene acceso y luego descargarlo por la ruta de lectura normal. El aviso se publicó el 13 de septiembre de 2026.
- OpenEMR anterior a 8.4.0 está afectado. Las acciones de escritura del controlador de documentos nunca comprobaban el acceso del llamante al documento que modificaban.
- El aviso lo califica como CVSS v3.1 9.6 Critical. Incluye una prueba de concepto en Python, y la CISA registra una prueba de concepto pública sin explotación en entornos reales. La CVE no está en el catálogo KEV de la CISA a 6 de noviembre de 2026.
- Actualice a OpenEMR 8.4.0 o posterior. Si no puede, restrinja el acceso al controlador de documentos y audite los movimientos; consulte "¿Cómo lo corrijo?".
¿Estoy afectado?
Está expuesto solo cuando se cumplen todas estas condiciones:
- La instalación ejecuta OpenEMR anterior a 8.4.0. El aviso enumera todas las versiones anteriores a 8.4.0 como afectadas.
- El atacante tiene una cuenta válida en la instancia. Cualquier rol sirve, incluidas las cuentas de recepción que pueden abrir documentos de pacientes.
- El controlador de documentos responde a las peticiones en
/controller.phpcon el controladordocument. Forma parte de la interfaz web estándar de OpenEMR, así que no hace falta ningún módulo opcional. - El atacante puede indicar un identificador de documento. Los identificadores son enteros, y la prueba de concepto del aviso toma un identificador existente como argumento.
No se necesita ninguna configuración especial. El fallo está en el controlador de documentos del núcleo, no en un módulo contribuido. La ruta de lectura ya bloquea un documento al que el usuario no tiene acceso. La ruta de escritura es la que mueve el documento a un registro que el usuario puede leer.
| Rama | Versiones afectadas | Versión corregida |
|---|---|---|
| OpenEMR | todas las versiones anteriores a 8.4.0 | 8.4.0 (publicada el 13 de septiembre de 2026) |
Compruebe la versión instalada desde la raíz web de OpenEMR:
grep -E "^\$v_(major|minor|patch)" version.php
Las tres variables muestran la versión instalada, por ejemplo 8.3.0. Una versión inferior a 8.4.0 está afectada.
¿Cómo lo corrijo?
- Actualice a OpenEMR 8.4.0 o posterior. La versión 8.4.0 es la primera que incluye la protección de escritura de documentos. Siga las instrucciones de actualización del proyecto para la base de datos y los archivos.
- Si no puede actualizar ahora, restrinja el acceso al controlador de documentos al personal que lo necesita y revise el registro de la aplicación en busca de escrituras denegadas. La corrección registra un aviso "An attempt was made to modify a document without authorization" cuando bloquea una petición; ese aviso solo aparece después de la actualización.
- Trate los movimientos de documentos anteriores a la actualización como sospechosos. Las tablas
documentsycategories_to_documentsguardan las asignaciones actuales de paciente y categoría. Un movimiento sobrescribe esas asignaciones sin conservar sus valores anteriores. Un movimiento correcto entre pacientes también retira el documento del registro del paciente original, lo que puede notarse como un documento que falta en lugar de como una lectura. Un cambio solo de categoría reclasifica el documento en el mismo registro, donde aparece bajo otra categoría.
El aviso del proveedor no indica ningún workaround, y ningún ajuste de configuración cierra la comprobación que falta. Restringir las cuentas que alcanzan el controlador de documentos reduce la exposición, pero no corrige el código.
¿Cuál es la causa raíz?
El controlador de documentos protege sus acciones de lectura pero no sus acciones de escritura, así que un llamante puede cambiar el propietario de un documento antes de leerlo. La acción de lectura en controllers/C_Document.class.php comprueba que el documento pertenece al paciente solicitado, con un comentario explícito sobre la prevención de 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");
}
La acción de movimiento lee el identificador del documento y los identificadores de destino de la petición y solo comprueba que son numéricos:
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);
}
}
}
Ni $document_id ni $patient_id se comparan con el acceso del llamante. El controlador llega a move_action_process() con el identificador del paciente y el del documento como argumentos posicionales. El controlador tampoco tenía una entrada ACL global para document: Controller::CONTROLLER_ACL_MAP contenía entradas para practice_settings y prescription, pero no para el controlador de documentos.
Un detalle operativo hace que los movimientos entre pacientes sean más difíciles de notar. El movimiento se ejecuta primero, y después el método llama a list_action($patient_id) para mostrar la lista de documentos del paciente de origen. Ese paso de renderizado sí está controlado por ACL. Un probador que solo lee el código de estado HTTP puede ver un 403 después de una petición que ya movió el documento.
¿Cómo se explota?
- El atacante inicia sesión con una cuenta que puede abrir el controlador de documentos. La prueba de concepto del aviso usa una cuenta de recepción.
- El atacante elige un identificador de documento y el identificador del paciente al que pertenece actualmente. Ambos valores son enteros. La prueba de concepto toma un identificador de documento existente como argumento.
- El atacante envía un POST al controlador de documentos con estos campos:
process=trueen el cuerpo del POST- el
patient_idde origen - el
document_id - un
new_patient_idonew_category_idde destino
- El controlador ejecuta
move_action_process(). El método actualizacategories_to_documentspara un cambio de categoría y llama aDocument::change_patient()para un movimiento entre pacientes, sin comprobar el acceso del llamante al documento. - El documento pertenece ahora al paciente o a la categoría del atacante. El atacante lo abre por la ruta de lectura normal, que pasa porque el propietario registrado es ahora un registro al que tiene acceso.
- Un movimiento entre pacientes retira el documento del registro del paciente original; un cambio solo de categoría lo reclasifica dentro del mismo registro. El atacante puede repetir el paso con otros identificadores de documento.
La prueba de concepto del aviso envía la petición con el enrutamiento moderno del controlador. La petición mínima, adaptada de esa prueba de concepto, es:
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>
El patient_id de la cadena de consulta debe ser el paciente actual del documento para que el controlador enrute la petición correctamente. La ruta de escritura nunca comprueba que el llamante pueda acceder a ese paciente.
¿Cómo se desarrolla el ataque?
Una cuenta con pocos privilegios mueve un documento que no puede leer a un registro de paciente que sí puede leer, y después lo descarga por la ruta normal.
¿Qué puede hacer un atacante?
Un atacante con una cuenta válida puede leer cualquier documento de la instancia, incluidos documentos de categorías y registros de pacientes que la cuenta no puede abrir directamente. El impacto es un bypass completo de los controles de acceso a documentos. Todas las condiciones se enumeran aquí:
- La instalación ejecuta OpenEMR anterior a 8.4.0.
- El atacante tiene una cuenta válida de cualquier rol.
- El atacante puede alcanzar el controlador de documentos.
- El atacante conoce un identificador de documento y el identificador del paciente al que pertenece.
- El atacante puede acceder a algún paciente o categoría para usarlo como destino.
El impacto en la confidencialidad es alto porque el documento se puede descargar tras el movimiento. El impacto en la integridad es alto porque el movimiento cambia las asignaciones del documento. Un movimiento entre pacientes retira el documento del registro del paciente original y lo añade al de destino. Un cambio solo de categoría mantiene la asignación de paciente.
El aviso califica el impacto en la disponibilidad como nulo. La vulnerabilidad no ejecuta código y no requiere interacción del usuario. Cambia de ámbito porque el atacante alcanza datos fuera del ámbito de su propia cuenta.
El aviso dice "any authenticated user of any role". No demuestra una ruta para un atacante no autenticado, y la prueba de concepto empieza con un inicio de sesión.
¿Por qué funciona la corrección?
La corrección publicada añade una protección que carga el documento y exige que el llamante pueda acceder a él en su estado actual. Las acciones de escritura llaman a esa protección. El cambio relevante en 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.'));
La protección de escritura del documento, con el bloque de registro acortado:
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;
}
La protección falla en modo cerrado. Un patient_id ausente o vacío se trata como una incoherencia, así que un llamante no puede retirar el contexto de paciente para pasar la comprobación de categoría.
Document::can_access() comprueba la ACL de cada categoría a la que pertenece actualmente el documento. La comparación del contexto de paciente repite la regla de la ruta de lectura. La comprobación de la categoría de destino replica la de la ruta de subida.
La misma protección se llama desde las acciones de escritura que nombra el aviso:
move_action_process()update_action_process()tag_action_process()validate_action_process()- las acciones de etiquetado de procedimientos
- la ruta de nota y correo
Controller::CONTROLLER_ACL_MAP también gana una entrada document para patients/docs, así que el controlador tiene ahora una barrera global además de la comprobación por documento. Los llamantes de servicio y de línea de comandos que omiten explícitamente la ACL siguen exentos, como en la ruta de lectura.
¿Cómo compruebo si estuve comprometido?
El proveedor no publica indicadores de compromiso. Las comprobaciones útiles están en los datos de la aplicación y en el registro del servidor web:
# 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
La prueba de concepto del aviso usa un POST a /controller.php. La cadena de consulta contiene document y move, y los parámetros llevan los identificadores de paciente y de documento.
Un resultado es una pista, no una prueba: el personal legítimo también mueve documentos. Busque movimientos hechos por cuentas que no deberían acceder al paciente de origen. Busque también movimientos a un registro de paciente seguidos de la descarga del mismo documento.
La corrección registra "An attempt was made to modify a document without authorization" con el identificador de usuario, el paciente solicitado, el paciente del documento y el identificador del documento. Ese aviso solo existe en 8.4.0 y posterior, así que no aparecerá en una instancia sin corregir.
Un intento que el código defectuoso permitió no deja ningún aviso. El movimiento puede no dejar rastro más allá del registro modificado.
Cronología
- 2026-08-11: el primer commit de corrección,
312b5d042f7fa49cc78ebed041155fba339a991c, se fusiona mediante la pull request #13353. - 2026-09-13: se publica OpenEMR 8.4.0 con la protección de escritura de documentos, y el aviso GHSA-92rh-g949-4mvv se publica el mismo día.
- 2026-11-05: se publica CVE-2026-102940 con CVSS v3.1 9.6 y CWE-639.
- 2026-11-06: la evaluación SSVC de la CISA registra una prueba de concepto pública y ninguna explotación en entornos reales. La CVE no está en el catálogo KEV.
Preguntas frecuentes
¿El atacante necesita una cuenta de administrador?
No. El aviso indica que cualquier usuario autenticado, sea cual sea su rol, puede reasignar un documento, y su prueba de concepto usa una cuenta de recepción. Una cuenta de administrador facilita el ataque porque alcanza más registros de destino, pero no es necesaria.
¿Puede el atacante leer el documento sin moverlo?
No, por la ruta de lectura. Las acciones de lectura comprueban el acceso del paciente y de la categoría del documento. El ataque funciona cambiando el registro en el que confía la ruta de lectura y leyendo después por esa ruta.
¿OpenEMR 8.3.x está afectado?
Sí. El aviso y el registro CVE enumeran todas las versiones anteriores a 8.4.0 como afectadas. La versión 8.4.0 es la primera que incluye la protección de escritura de documentos. Actualice a 8.4.0 o posterior.
¿Se explotó en entornos reales?
No se ha notificado ninguna explotación en entornos reales a 6 de noviembre de 2026, y la CVE no está en el catálogo KEV de la CISA. La evaluación SSVC de la CISA registra una prueba de concepto pública, que el propio aviso incluye.
¿Una respuesta 403 significa que el movimiento falló?
No. En un movimiento entre pacientes, la reasignación se ejecuta antes de que se muestre la lista del paciente de origen, y ese paso de renderizado se controla por separado con ACL. Un 403 puede llegar después de que el documento ya se haya movido. Compruebe el registro del documento, no solo el código de estado.
¿Hay un workaround si no puedo actualizar?
Ningún workaround del proveedor cierra la comprobación que falta. Restrinja las cuentas que alcanzan el controlador de documentos. Revise los movimientos de documentos. Actualice lo antes posible.
Referencias
- Aviso de seguridad de OpenEMR GHSA-92rh-g949-4mvv, publicado el 13 de septiembre de 2026, con el rango afectado, el código vulnerable, la prueba de concepto y la corrección: https://github.com/openemr/openemr/security/advisories/GHSA-92rh-g949-4mvv (comprobado el 6 de noviembre de 2026)
- Registro CVE-2026-102940, publicado el 5 de noviembre de 2026, CVSS v3.1 9.6, CWE-639 y rango afectado: https://www.cve.org/CVERecord?id=CVE-2026-102940 (comprobado el 6 de noviembre de 2026)
- Commit de OpenEMR 312b5d042f7fa49cc78ebed041155fba339a991c, "fix(acl): add several staff-write checks (#13353)", 11 de agosto de 2026: https://github.com/openemr/openemr/commit/312b5d042f7fa49cc78ebed041155fba339a991c (comprobado el 6 de noviembre de 2026)
- Pull request de OpenEMR #13353, primera corrección: https://github.com/openemr/openemr/pull/13353 (comprobado el 6 de noviembre de 2026)
- Versión 8.4.0 de OpenEMR, 13 de septiembre de 2026, que enumera la corrección crítica de reasignación de documentos: https://github.com/openemr/openemr/releases/tag/v8_4_0 (comprobado el 6 de noviembre de 2026)
- Archivo
controllers/C_Document.class.phpde OpenEMR en la versión v8_4_0, la protecciónauthorizeDocumentWrite(): https://github.com/openemr/openemr/blob/v8_4_0/controllers/C_Document.class.php (comprobado el 6 de noviembre de 2026) - Archivo
controllers/C_Document.class.phpde OpenEMR en el commit 61f48a2a1d27b9d37039dfac9a2f1c8732bbb467, la función vulnerablemove_action_process(): https://github.com/openemr/openemr/blob/61f48a2a1d27b9d37039dfac9a2f1c8732bbb467/controllers/C_Document.class.php (comprobado el 6 de noviembre de 2026) - Archivo
library/classes/Controller.class.phpde OpenEMR en la versión v8_4_0, la entradadocumentenCONTROLLER_ACL_MAP: https://github.com/openemr/openemr/blob/v8_4_0/library/classes/Controller.class.php (comprobado el 6 de noviembre de 2026) - Evaluación SSVC de la CISA para CVE-2026-102940, Exploitation: poc, marca temporal del 5 de noviembre de 2026: https://cveawg.mitre.org/api/cve/CVE-2026-102940 (comprobado el 6 de noviembre de 2026)
- Fuente JSON del catálogo de vulnerabilidades explotadas de la CISA, comprobada para CVE-2026-102940 el 6 de noviembre de 2026 (no incluida): https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json (comprobado el 6 de noviembre de 2026)
- Docker Hub,
openemr/openemr, 10,067,383 descargas y 74 estrellas, la cifra de alcance de la imagen de contenedor: https://hub.docker.com/r/openemr/openemr (comprobado el 6 de noviembre de 2026) - GitHub,
openemr/openemr, 5,584 estrellas y 3,128 forks: https://github.com/openemr/openemr (comprobado el 6 de noviembre de 2026)