OpenEMR 的 IDOR 漏洞可读取任意患者文档(CVE-2026-102940)
OpenEMR 8.4.0 之前,任何已登录用户都能把其他患者的文档移到自己的记录并读取。8.4.0 已修复。
TL;DR
- 任何已登录的 OpenEMR 用户,无论角色如何,都能把其他患者的文档移到其可访问的患者或分类下,然后通过正常读取路径下载。该公告于 2026 年 9 月 13 日发布。
- OpenEMR 8.4.0 之前受影响。文档控制器的写入操作从未检查调用者是否有权访问所修改的文档。
- 公告评分为 CVSS v3.1 9.6 Critical。公告包含 Python 概念验证,CISA 记录了公开概念验证且未发现在野利用。截至 2026 年 11 月 6 日,该 CVE 不在 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
三个变量会输出已安装版本,例如 8.3.0。低于 8.4.0 的版本受影响。
如何修复?
- 升级到 OpenEMR 8.4.0 或更高版本。8.4.0 版本是第一个包含文档写入保护的版本;请按照项目的升级说明处理数据库和文件迁移。
- 如果现在无法升级,请把文档控制器的访问限制在需要的员工范围内,并检查应用日志中的拒绝写入记录。修复在阻止请求时会记录“An attempt was made to modify a document without authorization”警告;该警告只在升级后出现。
- 把升级前的文档移动视为可疑。
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 都没有与调用者的访问权限比较。控制器把患者 ID 和文档 ID 作为位置参数传给 move_action_process()。控制器也没有针对 document 的整体 ACL 条目:Controller::CONTROLLER_ACL_MAP 有 practice_settings 和 prescription 的条目,但没有文档控制器的条目。
一个运行细节让患者间移动更难被发现。移动先执行,然后方法调用 list_action($patient_id) 渲染源患者的文档列表。该渲染步骤会做 ACL 检查。只查看 HTTP 状态码的测试人员可能在文档已经移动后收到 403。
如何被利用?
- 攻击者用能打开文档控制器的账户登录。公告的概念验证使用前台账户。
- 攻击者选择一个文档 ID 以及该文档当前所属的患者 ID。两个值都是整数。概念验证把一个已有文档 ID 作为参数。
- 攻击者向文档控制器发送 POST,并指定这些值:
- POST 正文中的
process=true - 源
patient_id document_id- 目标
new_patient_id或new_category_id
- POST 正文中的
- 控制器执行
move_action_process()。更改分类时,该方法更新categories_to_documents;在患者间移动时,则调用Document::change_patient()。两种操作都不检查调用者对文档的访问权限。 - 文档现在属于攻击者的患者或分类。攻击者通过正常读取路径打开文档,因为记录的所有者现在是其可访问的记录,读取被允许。
- 患者间移动会把文档从原患者记录中移除;仅更改分类则会在同一记录内重新分类。攻击者可以对其他文档 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 必须是文档当前所属的患者,控制器才能正确路由请求。写入路径从不检查调用者是否有权访问该患者。
攻击如何展开?
低权限账户把自己无法读取的文档移到可读取的患者记录,然后通过正常路径下载。
攻击者能做什么?
拥有有效账户的攻击者可以读取实例中的任意文档,包括其账户无法直接打开的类别和患者记录中的文档。影响是完全绕过文档访问控制。这里完整列出所有条件:
- 安装的 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
公告的概念验证向 /controller.php 发送 POST,查询字符串中包含 document 和 move,参数中包含患者 ID 和文档 ID。命中是线索而不是证据:合法员工也会移动文档。请查找不应访问源患者的账户所做的移动,以及移动到患者记录后立即下载同一文档的记录。
修复会记录“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 目录中。
常见问题
攻击者需要管理员账户吗?
不需要。公告说任何已认证用户无论角色都能重新分配文档,其概念验证使用前台账户。管理员账户能访问更多目标记录,使攻击更容易,但不是必需的。
攻击者能在不移动文档的情况下读取吗?
不能通过读取路径读取。读取操作会检查文档的患者和分类访问权限。攻击先更改读取路径所信任的记录,再通过该路径读取。
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.phpv8_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.phpv8_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 日检查)