Neutron, notre moteur d’IA, a obtenu un score de 97,1 % sur le benchmark CyberGym de l’UC Berkeley. En savoir plus

Analyse approfondie

Analyse approfondie

OpenEMR : un IDOR expose tout document patient (CVE-2026-102940)

OpenEMR avant 8.4.0 : tout utilisateur connecté peut déplacer le document d'un autre patient puis le lire. Corrigé en 8.4.0.

En bref

  • Un utilisateur OpenEMR connecté, quel que soit son rôle, peut déplacer le document d'un autre patient vers un patient ou une catégorie auquel il a accès, puis le télécharger par le chemin de lecture normal. L'avis a été publié le 13 septembre 2026.
  • OpenEMR avant 8.4.0 est concerné. Les actions d'écriture du contrôleur de documents ne vérifiaient jamais l'accès de l'appelant au document modifié.
  • L'avis le note CVSS v3.1 9,6 Critical. Il contient une preuve de concept Python, et la CISA enregistre une preuve de concept publique sans exploitation dans la nature. La CVE n'est pas au catalogue KEV de la CISA au 6 novembre 2026.
  • Mettez à niveau vers OpenEMR 8.4.0 ou ultérieur. Si vous ne pouvez pas, limitez l'accès au contrôleur de documents et auditez les déplacements ; voir « Comment corriger ? ».

Suis-je concerné ?

Vous êtes exposé uniquement lorsque toutes ces conditions sont réunies :

  • L'installation exécute OpenEMR avant 8.4.0. L'avis liste toutes les versions antérieures à 8.4.0 comme concernées.
  • L'attaquant dispose d'un compte valide sur l'instance. Tous les rôles fonctionnent, y compris les comptes d'accueil qui peuvent ouvrir des documents patients.
  • Le contrôleur de documents répond aux requêtes à /controller.php avec le contrôleur document. Il fait partie de l'interface web standard d'OpenEMR, donc aucun module optionnel n'est nécessaire.
  • L'attaquant peut nommer un identifiant de document. Les identifiants sont des entiers, et la preuve de concept de l'avis prend un identifiant existant en argument.

Aucune configuration particulière n'est requise. La faille se trouve dans le contrôleur de documents du cœur, pas dans un module contribué. Le chemin de lecture bloque déjà un document auquel l'utilisateur n'a pas accès ; c'est le chemin d'écriture qui déplace le document vers un dossier que l'utilisateur peut lire.

Branche Versions concernées Version corrigée
OpenEMR toutes les versions avant 8.4.0 8.4.0 (publiée le 13 septembre 2026)

Vérifiez la version installée depuis la racine web d'OpenEMR :

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

Les trois variables affichent la version installée, par exemple 8.3.0. Une version inférieure à 8.4.0 est concernée.

Comment corriger ?

  1. Mettez à niveau vers OpenEMR 8.4.0 ou ultérieur. La version 8.4.0 est la première qui contient le garde-fou d'écriture des documents. Suivez les instructions de mise à niveau du projet pour la base de données et les fichiers.
  2. Si vous ne pouvez pas mettre à niveau maintenant, limitez l'accès au contrôleur de documents au personnel qui en a besoin, et surveillez le journal applicatif pour les écritures refusées. Le correctif journalise un avertissement « An attempt was made to modify a document without authorization » lorsqu'il bloque une requête ; cet avertissement n'apparaît donc qu'après la mise à niveau.
  3. Considérez les déplacements effectués avant la mise à niveau comme suspects. Les tables documents et categories_to_documents stockent les affectations actuelles de patient et de catégorie. Un déplacement écrase ces affectations sans conserver leurs valeurs précédentes. Un déplacement réussi entre patients retire aussi le document du dossier du patient d'origine, ce qui peut se remarquer comme un document manquant plutôt que comme une lecture. Un changement de catégorie seul reclasse le document dans le même dossier, où il apparaît sous une autre catégorie.

L'avis du fournisseur ne nomme aucun contournement, et aucun réglage de configuration ne ferme la vérification manquante. Limiter les comptes qui atteignent le contrôleur de documents réduit l'exposition mais ne corrige pas le code.

Quelle est la cause racine ?

Le contrôleur de documents protège ses actions de lecture mais pas ses actions d'écriture, donc un appelant peut changer le propriétaire d'un document avant de le lire. L'action de lecture dans controllers/C_Document.class.php vérifie que le document appartient au patient demandé, avec un commentaire explicite sur la prévention des 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");
}

L'action de déplacement lit l'identifiant du document et les identifiants de destination dans la requête et vérifie seulement qu'ils sont numériques :

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 ne sont comparés à l'accès de l'appelant. move_action_process() est atteint par le contrôleur avec l'identifiant du patient et celui du document comme arguments positionnels. Le contrôleur n'avait pas non plus d'entrée ACL globale pour document : Controller::CONTROLLER_ACL_MAP contenait des entrées pour practice_settings et prescription, mais pas pour le contrôleur de documents.

Un détail opérationnel rend les déplacements entre patients plus difficiles à remarquer. Le déplacement s'exécute d'abord, puis la méthode appelle list_action($patient_id) pour afficher la liste des documents du patient d'origine. Cette étape de rendu est contrôlée par ACL. Un testeur qui ne lit que le code de statut HTTP peut donc voir un 403 après une requête qui a déjà déplacé le document.

Comment la faille est-elle exploitée ?

  1. L'attaquant se connecte avec un compte qui peut ouvrir le contrôleur de documents. La preuve de concept de l'avis utilise un compte d'accueil.
  2. L'attaquant choisit un identifiant de document et l'identifiant du patient auquel le document appartient actuellement. Les deux valeurs sont des entiers. La preuve de concept prend un identifiant de document existant en argument.
  3. L'attaquant envoie un POST au contrôleur de documents avec ces champs :
    • process=true dans le corps du POST
    • le patient_id source
    • le document_id
    • un new_patient_id ou new_category_id cible
  4. Le contrôleur exécute move_action_process(). La méthode met à jour categories_to_documents pour un changement de catégorie et appelle Document::change_patient() pour un déplacement entre patients, sans vérifier l'accès de l'appelant au document.
  5. Le document appartient maintenant au patient ou à la catégorie de l'attaquant. L'attaquant l'ouvre par le chemin de lecture normal, qui passe parce que le propriétaire enregistré est désormais un dossier auquel il a accès.
  6. Un déplacement entre patients retire le document du dossier du patient d'origine ; un changement de catégorie seul le reclasse dans ce même dossier. L'attaquant peut répéter l'étape pour d'autres identifiants de documents.

La preuve de concept de l'avis envoie la requête avec le routage moderne du contrôleur. La requête minimale, adaptée de cette preuve de concept, est :

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>

Le patient_id de la chaîne de requête doit être le patient actuel du document pour que le contrôleur route correctement la requête. Le chemin d'écriture ne vérifie jamais que l'appelant peut accéder à ce patient.

Comment se déroule l'attaque ?

Un compte à faibles privilèges déplace un document qu'il ne peut pas lire vers un dossier patient qu'il peut lire, puis le télécharge par le chemin normal.

flowchart LR A["Tout utilisateur connecté"] --> B["POST de déplacement avec un identifiant de document cible"] B --> C["move_action_process lit les identifiants, vérifie seulement is_numeric"] C --> D["Aucune vérification d'accès au document source"] D --> E["Document réaffecté au patient ou à la catégorie de l'attaquant"] E --> F["Le chemin de lecture autorise maintenant le document"] F --> G["L'attaquant télécharge le document d'un autre patient"]

Que peut faire un attaquant ?

Un attaquant disposant d'un compte valide peut lire tout document de l'instance, y compris les documents de catégories et de dossiers patients qu'il ne peut pas ouvrir directement. L'impact est un contournement complet des contrôles d'accès aux documents. Toutes les conditions sont listées ici :

  • L'installation exécute OpenEMR avant 8.4.0.
  • L'attaquant a un compte valide, quel que soit son rôle.
  • L'attaquant peut atteindre le contrôleur de documents.
  • L'attaquant connaît un identifiant de document et l'identifiant du patient auquel il appartient.
  • L'attaquant peut accéder à un patient ou à une catégorie pour servir de destination.

L'impact sur la confidentialité est élevé, car le document peut être téléchargé après le déplacement. L'impact sur l'intégrité est élevé, car le déplacement modifie les affectations du document. Un déplacement entre patients retire le document du dossier du patient d'origine et l'ajoute au dossier de destination. Un changement de catégorie seul conserve l'affectation au patient.

L'avis note l'impact sur la disponibilité comme nul. La vulnérabilité n'exécute pas de code et ne demande pas d'interaction utilisateur. Elle change de portée, car l'attaquant atteint des données hors de la portée de son propre compte.

L'avis dit « any authenticated user of any role ». Il ne démontre pas de chemin pour un attaquant non authentifié, et la preuve de concept commence par une connexion.

Pourquoi le correctif fonctionne-t-il ?

Le correctif publié ajoute un garde-fou qui charge le document et exige que l'appelant puisse y accéder dans son état actuel. Les actions d'écriture appellent ce garde-fou. La modification pertinente dans 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.'));

Le garde-fou d'écriture du document, avec le bloc de journalisation raccourci :

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;
}

Le garde-fou échoue en mode fermé. Un patient_id absent ou vide est traité comme une incohérence, donc un appelant ne peut pas retirer le contexte patient pour passer la vérification de catégorie.

Document::can_access() vérifie l'ACL de chaque catégorie à laquelle le document appartient actuellement. La comparaison du contexte patient reprend la règle du chemin de lecture. La vérification de la catégorie de destination reprend celle du chemin d'envoi.

Le même garde-fou est appelé depuis les actions d'écriture nommées par l'avis :

  • move_action_process()
  • update_action_process()
  • tag_action_process()
  • validate_action_process()
  • les actions d'étiquetage de procédure
  • le chemin de note et d'e-mail

Controller::CONTROLLER_ACL_MAP gagne aussi une entrée document pour patients/docs, donc le contrôleur a désormais une barrière globale en plus de la vérification par document. Les appelants de service et de ligne de commande qui ignorent explicitement l'ACL restent exemptés, comme pour le chemin de lecture.

Comment vérifier si j'ai été compromis ?

Le fournisseur ne publie aucun indicateur de compromission. Les vérifications utiles portent sur les données applicatives et le journal du serveur 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 preuve de concept de l'avis utilise un POST vers /controller.php. La chaîne de requête contient document et move, et les paramètres portent les identifiants de patient et de document.

Un résultat est une piste, pas une preuve : le personnel légitime déplace aussi des documents. Cherchez les déplacements effectués par des comptes qui ne devraient pas accéder au patient source. Cherchez aussi les déplacements vers un dossier patient suivis du téléchargement du même document.

Le correctif journalise « An attempt was made to modify a document without authorization » avec l'identifiant utilisateur, le patient demandé, le patient du document et l'identifiant du document. Cet avertissement n'existe que sur 8.4.0 et ultérieur, donc il n'apparaîtra pas sur une instance non corrigée. Une tentative que le code défaillant a autorisée ne laisse aucun avertissement, et le déplacement peut ne laisser aucune trace au-delà de l'enregistrement modifié.

Chronologie

  • 2026-08-11 : le premier commit de correction, 312b5d042f7fa49cc78ebed041155fba339a991c, est fusionné via la pull request #13353.
  • 2026-09-13 : OpenEMR 8.4.0 est publié avec le garde-fou d'écriture des documents, et l'avis GHSA-92rh-g949-4mvv est publié le même jour.
  • 2026-11-05 : la CVE-2026-102940 est publiée avec CVSS v3.1 9,6 et CWE-639.
  • 2026-11-06 : l'évaluation SSVC de la CISA enregistre une preuve de concept publique et aucune exploitation dans la nature. La CVE n'est pas au catalogue KEV.

Le correctif est arrivé dans la version 8.4.0 33 jours après le premier commit, et l'enregistrement CVE a suivi 53 jours après la version.
Figure 1 : Du premier commit de correction à la version 8.4.0 et à l'enregistrement CVE. Source : commit 312b5d0 d'OpenEMR, version 8.4.0 et enregistrement CVE, vérifiés le 6 novembre 2026

FAQ

L'attaquant a-t-il besoin d'un compte administrateur ?

Non. L'avis indique que tout utilisateur authentifié, quel que soit son rôle, peut réaffecter un document, et sa preuve de concept utilise un compte d'accueil. Un compte administrateur facilite l'attaque car il atteint plus de dossiers de destination, mais il n'est pas nécessaire.

L'attaquant peut-il lire le document sans le déplacer ?

Non, par le chemin de lecture. Les actions de lecture vérifient l'accès du patient et de la catégorie du document. L'attaque fonctionne en modifiant l'enregistrement auquel le chemin de lecture fait confiance, puis en lisant par ce chemin.

OpenEMR 8.3.x est-il concerné ?

Oui. L'avis et l'enregistrement CVE listent toutes les versions antérieures à 8.4.0 comme concernées, et 8.4.0 est la première version qui contient le garde-fou d'écriture des documents. Mettez à niveau vers 8.4.0 ou ultérieur.

La faille a-t-elle été exploitée dans la nature ?

Aucune exploitation dans la nature n'est signalée au 6 novembre 2026, et la CVE n'est pas au catalogue KEV de la CISA. L'évaluation SSVC de la CISA enregistre une preuve de concept publique, que l'avis contient lui-même.

Une réponse 403 signifie-t-elle que le déplacement a échoué ?

Non. Lors d'un déplacement entre patients, la réaffectation s'exécute avant l'affichage de la liste du patient d'origine, et cette étape de rendu est contrôlée séparément par ACL. Un 403 peut donc arriver après que le document a déjà été déplacé. Vérifiez l'enregistrement du document, pas seulement le code de statut.

Existe-t-il un contournement si je ne peux pas mettre à niveau ?

Aucun contournement du fournisseur ne ferme la vérification manquante. Limitez les comptes qui atteignent le contrôleur de documents. Surveillez les déplacements de documents. Mettez à niveau dès que possible.

Références

  • Avis de sécurité OpenEMR GHSA-92rh-g949-4mvv, publié le 13 septembre 2026, avec la plage concernée, le code vulnérable, la preuve de concept et le correctif : https://github.com/openemr/openemr/security/advisories/GHSA-92rh-g949-4mvv (vérifié le 6 novembre 2026)
  • Enregistrement CVE-2026-102940, publié le 5 novembre 2026, CVSS v3.1 9.6, CWE-639 et plage concernée : https://www.cve.org/CVERecord?id=CVE-2026-102940 (vérifié le 6 novembre 2026)
  • Commit OpenEMR 312b5d042f7fa49cc78ebed041155fba339a991c, « fix(acl): add several staff-write checks (#13353) », 11 août 2026 : https://github.com/openemr/openemr/commit/312b5d042f7fa49cc78ebed041155fba339a991c (vérifié le 6 novembre 2026)
  • Pull request OpenEMR #13353, premier correctif : https://github.com/openemr/openemr/pull/13353 (vérifié le 6 novembre 2026)
  • Version OpenEMR 8.4.0, 13 septembre 2026, qui liste le correctif critique de réaffectation de document : https://github.com/openemr/openemr/releases/tag/v8_4_0 (vérifié le 6 novembre 2026)
  • Fichier controllers/C_Document.class.php d'OpenEMR à la version v8_4_0, le garde-fou authorizeDocumentWrite() : https://github.com/openemr/openemr/blob/v8_4_0/controllers/C_Document.class.php (vérifié le 6 novembre 2026)
  • Fichier controllers/C_Document.class.php d'OpenEMR au commit 61f48a2a1d27b9d37039dfac9a2f1c8732bbb467, la fonction vulnérable move_action_process() : https://github.com/openemr/openemr/blob/61f48a2a1d27b9d37039dfac9a2f1c8732bbb467/controllers/C_Document.class.php (vérifié le 6 novembre 2026)
  • Fichier library/classes/Controller.class.php d'OpenEMR à la version v8_4_0, l'entrée document dans CONTROLLER_ACL_MAP : https://github.com/openemr/openemr/blob/v8_4_0/library/classes/Controller.class.php (vérifié le 6 novembre 2026)
  • Évaluation SSVC de la CISA pour CVE-2026-102940, Exploitation : poc, horodatage du 5 novembre 2026 : https://cveawg.mitre.org/api/cve/CVE-2026-102940 (vérifié le 6 novembre 2026)
  • Flux JSON du catalogue des vulnérabilités exploitées de la CISA, vérifié pour CVE-2026-102940 le 6 novembre 2026 (non listée) : https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json (vérifié le 6 novembre 2026)
  • Docker Hub, openemr/openemr, 10 067 383 téléchargements et 74 étoiles, la mesure de portée de l'image de conteneur : https://hub.docker.com/r/openemr/openemr (vérifié le 6 novembre 2026)
  • GitHub, openemr/openemr, 5 584 étoiles et 3 128 forks : https://github.com/openemr/openemr (vérifié le 6 novembre 2026)