当社のAIエンジンNeutronが、UCバークレーのCyberGymベンチマークで97.1%のスコアを記録しました。 詳細を見る

ディープダイブ

ディープダイブ

OpenEMRのIDORで任意の患者文書が閲覧可能に(CVE-2026-102940)

OpenEMR 8.4.0より前では、ログイン済みの任意のユーザーが他患者の文書を移動して閲覧できます。8.4.0で修正されました。

要点

  • OpenEMRにログインできるユーザーなら誰でも、自分の役割に関係なく、他患者の文書を自分がアクセスできる患者またはカテゴリへ移動し、通常の閲覧経路でダウンロードできます。アドバイザリは2026年9月13日に公開されました。
  • OpenEMR 8.4.0より前が影響を受けます。文書コントローラの書き込み処理は、変更する文書への呼び出し元のアクセスを確認していませんでした。
  • アドバイザリの評価はCVSS v3.1 9.6 Criticalです。Pythonの実証コードが含まれ、CISAは公開実証コードがあり、実際の悪用はないと記録しています。CVEは2026年11月6日時点でCISAのKEVカタログにありません。
  • OpenEMR 8.4.0以降へ更新してください。すぐに更新できない場合は、文書コントローラへのアクセスを制限し、文書の移動を監査してください。詳しくは「修正方法」を参照してください。

影響を受けますか?

次の条件がすべて当てはまる場合にのみ影響を受けます。

  • インストールされているOpenEMRが8.4.0より前である。アドバイザリは8.4.0より前のすべてのリリースを影響対象としています。
  • 攻撃者がインスタンスの有効なアカウントを持っている。患者文書を開ける受付アカウントを含め、どの役割でも該当します。
  • 文書コントローラが/controller.phpでdocumentコントローラとして応答する。これはOpenEMRの標準Webインターフェースの一部であり、任意のモジュールは不要です。
  • 攻撃者が文書IDを指定できる。IDは整数であり、アドバイザリの実証コードは既存のIDを引数に取ります。

特別な設定は不要です。欠陥はコアの文書コントローラにあり、追加モジュールにはありません。閲覧経路はユーザーがアクセスできない文書をすでに拒否します。文書をユーザーが読めるレコードへ移すのは書き込み経路です。

ブランチ 影響を受けるバージョン 修正バージョン
OpenEMR 8.4.0より前のすべてのリリース 8.4.0(2026年9月13日公開)

OpenEMRのWebルートでインストール済みバージョンを確認します。

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

3つの変数がインストール済みバージョンを表示します。たとえば8.3.0です。8.4.0より低いバージョンは影響を受けます。

修正方法

  1. OpenEMR 8.4.0以降へ更新します。8.4.0リリースは文書書き込みガードを最初に含むリリースです。データベースとファイルの移行についてはプロジェクトの更新手順に従ってください。
  2. すぐに更新できない場合は、文書コントローラへのアクセスを必要な担当者に限定し、拒否された書き込みについてアプリケーションログを確認します。修正はリクエストを拒否したときに「An attempt was made to modify a document without authorization」という警告を記録します。この警告は更新後にのみ現れます。
  3. 更新前の文書移動は疑わしいものとして扱ってください。documentsとcategories_to_documentsの各テーブルは、現在の患者とカテゴリの割り当てを保存します。移動はそれらの割り当てを上書きし、以前の値を保持しません。患者間の移動が成功すると元の患者の記録から文書が外れるため、閲覧ではなく文書の欠落として気づくことがあります。カテゴリだけを変更した場合、文書は同じ患者の記録内で別のカテゴリに表示されます。

ベンダーのアドバイザリは回避策を挙げておらず、不足している確認を閉じる設定もありません。文書コントローラへ到達できるアカウントを制限すると露出は減りますが、コードは修正されません。

根本原因は何ですか?

文書コントローラは閲覧処理を保護していますが書き込み処理を保護していないため、呼び出し元は文書を読む前に所有者を変更できます。controllers/C_Document.class.phpの閲覧処理は、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");
}

移動処理はリクエストから文書IDと移動先IDを読み取り、数値であることだけを確認します。

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

$document_idも$patient_idも呼び出し元のアクセスと比較されません。move_action_process()にはコントローラから患者IDと文書IDが位置引数として渡されます。コントローラにはdocumentの包括的なACLエントリもありませんでした。Controller::CONTROLLER_ACL_MAPにはpractice_settingsとprescriptionのエントリはありましたが、文書コントローラのエントリはありませんでした。

運用上の詳細が患者間の移動を見つけにくくします。移動が先に実行され、その後でメソッドがlist_action($patient_id)を呼び出して移動元患者の文書一覧を描画します。この描画処理はACLで確認されます。HTTPステータスコードだけを見る検査者は、すでに文書を移動したリクエストの後に403を受け取ることがあります。

どのように悪用されますか?

  1. 攻撃者は文書コントローラを開けるアカウントでログインします。アドバイザリの実証コードは受付アカウントを使います。
  2. 攻撃者は文書IDと、その文書が現在属する患者IDを選びます。どちらも整数です。実証コードは既存の文書IDを引数に取ります。
  3. 攻撃者は文書コントローラへPOSTし、次の値を指定します。
    • POST本文にprocess=true
    • 移動元のpatient_id
    • document_id
    • 移動先のnew_patient_idまたはnew_category_id
  4. コントローラはmove_action_process()を実行します。メソッドは、カテゴリ変更ではcategories_to_documentsを更新し、患者間の移動ではDocument::change_patient()を呼び出します。どちらも文書への呼び出し元のアクセスを確認しません。
  5. 文書は攻撃者の患者またはカテゴリに属するようになります。攻撃者は通常の閲覧経路で文書を開きます。記録上の所有者が攻撃者のアクセスできるレコードになったため、閲覧が許可されます。
  6. 患者間の移動では元の患者の記録から文書が外れ、カテゴリだけの変更では同じ記録内で文書が再分類されます。攻撃者は他の文書IDでも同じ手順を繰り返せます。

アドバイザリの実証コードはコントローラの現代的なルーティングでリクエストを送ります。その実証コードを基にした最小限のリクエスト例:

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>

クエリ文字列のpatient_idは、コントローラがリクエストを正しくルーティングするために、文書の現在の患者でなければなりません。書き込み経路は、呼び出し元がその患者へアクセスできることを決して確認しません。

攻撃の流れは?

低権限のアカウントが、読めない文書を自分が読める患者レコードへ移し、通常の経路でダウンロードします。

flowchart LR A["ログイン済みの任意のユーザー"] --> B["対象文書IDを付けて移動をPOST"] B --> C["move_action_processがIDを読みis_numericだけを確認"] C --> D["移動元文書へのアクセス確認なし"] D --> E["文書が攻撃者の患者またはカテゴリへ再割り当て"] E --> F["閲覧経路が文書を許可"] F --> G["攻撃者が他患者の文書をダウンロード"]

攻撃者に何ができますか?

有効なアカウントを持つ攻撃者は、直接開けないカテゴリや患者レコードの文書を含め、インスタンス内の任意の文書を読めます。影響は文書アクセス制御の完全な回避です。条件はすべてここに列挙します。

  • インストールされているOpenEMRが8.4.0より前である。
  • 攻撃者が任意の役割の有効なアカウントを持っている。
  • 攻撃者が文書コントローラへ到達できる。
  • 攻撃者が文書IDと、その文書が属する患者IDを知っている。
  • 攻撃者が移動先として使える患者またはカテゴリへアクセスできる。

移動後に文書をダウンロードできるため、機密性への影響は高いです。移動は文書の割り当てを変更するため、完全性への影響も高いです。患者間の移動では元の患者の記録から文書が外れ、移動先の記録に追加されます。カテゴリだけの変更では患者への割り当ては変わりません。アドバイザリは可用性への影響をなしとしています。この脆弱性はコードを実行せず、ユーザー操作を必要とせず、攻撃者が自分のアカウントの範囲外のデータへ到達するためスコープが変わります。

アドバイザリは「any authenticated user of any role」と述べています。未認証の攻撃者の経路は示しておらず、実証コードはログインから始まります。

修正が機能する理由

公開された修正は、文書を読み込み、呼び出し元が現在の状態でアクセスできることを要求するガードを追加し、書き込み処理からそのガードを呼び出します。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.'));

文書書き込みガードのコード例(ログ出力のブロックは短縮):

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

ガードは安全側に失敗します。patient_idがない場合や空の場合は不一致として扱われるため、呼び出し元が患者コンテキストを外してカテゴリ確認を通すことはできません。Document::can_access()は文書が現在属する各カテゴリのACLを確認し、患者コンテキストの比較は閲覧経路のルールを繰り返します。移動先カテゴリの確認はアップロード経路の確認を踏襲します。

同じガードは、アドバイザリが挙げる書き込み処理から呼び出されます。

  • move_action_process()
  • update_action_process()
  • tag_action_process()
  • validate_action_process()
  • 処置タグの処理
  • メモとメールの経路

Controller::CONTROLLER_ACL_MAPにもpatients/docsのdocumentエントリが追加され、コントローラは文書ごとの確認に加えて包括的なゲートを持つようになりました。ACLを明示的に省略するサービスやCLIの呼び出し元は、閲覧経路と同様に除外されたままです。

侵害されたかどうかを確認するには?

ベンダーは侵害指標を公開していません。有効な確認はアプリケーションデータと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

アドバイザリの実証コードは、クエリ文字列にdocumentとmoveを含み、患者IDと文書IDをパラメーターに持つPOSTを/controller.phpへ送ります。ヒットは手がかりであり、証拠ではありません。正当な担当者も文書を移動します。移動元の患者へアクセスすべきでないアカウントによる移動や、患者レコードへの移動の直後に同じ文書をダウンロードした記録を探してください。

修正は「An attempt was made to modify a document without authorization」をユーザーID、要求された患者ID、文書の患者ID、文書IDとともに記録します。この警告は8.4.0以降にのみ存在するため、未修正のインスタンスには現れません。欠陥のあるコードが許可した試行は警告を残さず、移動は変更されたレコード以外に痕跡を残さないことがあります。

年表

  • 2026-08-11: 最初の修正コミット312b5d042f7fa49cc78ebed041155fba339a991cがプルリクエスト#13353としてマージされました。
  • 2026-09-13: 文書書き込みガードを含むOpenEMR 8.4.0がリリースされ、同じ日にアドバイザリGHSA-92rh-g949-4mvvが公開されました。
  • 2026-11-05: CVE-2026-102940がCVSS v3.1 9.6とCWE-639付きで公開されました。
  • 2026-11-06: CISAのSSVC評価は公開実証コードがあり、実際の悪用はないと記録しています。CVEはKEVカタログにありません。

修正は最初のコミットから33日後の8.4.0リリースで提供され、CVEレコードはリリースから53日後に続きました。
図1: 最初の修正コミットから8.4.0リリース、CVEレコードまで。出典: OpenEMRのコミット312b5d0、8.4.0リリース、CVEレコード、2026年11月6日確認

よくある質問

攻撃者に管理者アカウントが必要ですか?

いいえ。アドバイザリは、任意の役割の認証済みユーザーが文書を再割り当てできると述べており、実証コードは受付アカウントを使います。管理者アカウントは移動先のレコードを多く選べるため攻撃を容易にしますが、必須ではありません。

攻撃者は文書を移動せずに読めますか?

いいえ、閲覧経路では読めません。閲覧処理は文書の患者とカテゴリへのアクセスを確認します。攻撃は、閲覧経路が信頼するレコードを変更してから、その経路で読むことで成立します。

OpenEMR 8.3.xは影響を受けますか?

はい。アドバイザリとCVEレコードは8.4.0より前のすべてのリリースを影響対象としており、8.4.0が文書書き込みガードを最初に含むリリースです。8.4.0以降へ更新してください。

実際の悪用はありましたか?

2026年11月6日時点で実際の悪用は報告されておらず、CVEはCISAのKEVカタログにありません。CISAのSSVC評価は公開実証コードを記録しており、その実証コードはアドバイザリ自体に含まれています。

403応答は移動が失敗したことを意味しますか?

いいえ。患者間の移動では、再割り当ては移動元患者の一覧を描画する前に実行され、その描画処理は別にACLで確認されます。そのため、文書がすでに移動した後に403が返ることがあります。ステータスコードだけでなく、文書レコードを確認してください。

更新できない場合の回避策はありますか?

不足している確認を閉じるベンダーの回避策はありません。文書コントローラへ到達できるアカウントを制限し、文書の移動を確認し、できるだけ早く更新してください。

参考資料

  • OpenEMRセキュリティアドバイザリGHSA-92rh-g949-4mvv、2026年9月13日公開、影響範囲、脆弱なコード、実証コード、修正を含む: https://github.com/openemr/openemr/security/advisories/GHSA-92rh-g949-4mvv (2026年11月6日確認)
  • CVE-2026-102940レコード、2026年11月5日公開、CVSS v3.1 9.6、CWE-639、影響範囲: https://www.cve.org/CVERecord?id=CVE-2026-102940 (2026年11月6日確認)
  • OpenEMRコミット312b5d042f7fa49cc78ebed041155fba339a991c、「fix(acl): add several staff-write checks (#13353)」、2026年8月11日: https://github.com/openemr/openemr/commit/312b5d042f7fa49cc78ebed041155fba339a991c (2026年11月6日確認)
  • OpenEMRプルリクエスト#13353、最初の修正: https://github.com/openemr/openemr/pull/13353 (2026年11月6日確認)
  • OpenEMR 8.4.0リリース、2026年9月13日、文書再割り当てのCritical修正を記載: https://github.com/openemr/openemr/releases/tag/v8_4_0 (2026年11月6日確認)
  • OpenEMR controllers/C_Document.class.php v8_4_0、authorizeDocumentWrite()ガード: https://github.com/openemr/openemr/blob/v8_4_0/controllers/C_Document.class.php (2026年11月6日確認)
  • OpenEMR controllers/C_Document.class.php コミット61f48a2a1d27b9d37039dfac9a2f1c8732bbb467、脆弱なmove_action_process(): https://github.com/openemr/openemr/blob/61f48a2a1d27b9d37039dfac9a2f1c8732bbb467/controllers/C_Document.class.php (2026年11月6日確認)
  • OpenEMR library/classes/Controller.class.php v8_4_0、CONTROLLER_ACL_MAPのdocumentエントリ: https://github.com/openemr/openemr/blob/v8_4_0/library/classes/Controller.class.php (2026年11月6日確認)
  • CISAのCVE-2026-102940に対するSSVC評価、Exploitation: poc、2026年11月5日のタイムスタンプ: https://cveawg.mitre.org/api/cve/CVE-2026-102940 (2026年11月6日確認)
  • CISAの悪用済み脆弱性カタログJSONフィード、2026年11月6日にCVE-2026-102940を確認(未掲載): https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json (2026年11月6日確認)
  • Docker Hub、openemr/openemr、10,067,383回の取得と74スター、コンテナイメージの到達規模: https://hub.docker.com/r/openemr/openemr (2026年11月6日確認)
  • GitHub、openemr/openemr、5,584スターと3,128フォーク: https://github.com/openemr/openemr (2026年11月6日確認)