0xmrma/CVE-2026-34213
GitHub: 0xmrma/CVE-2026-34213
该项目披露并复现了 Docmost 协作文档平台中因布尔逻辑错误导致的附件未授权覆盖漏洞(CVE-2026-34213)。
Stars: 0 | Forks: 0
# CVE-2026-34213
低权限的 Docmost 用户可以向通用的上传接口提供受害者的 `attachmentId`,并覆盖同一工作空间中另一个页面存储的附件。
## 简介
我发现、负责任地披露并复现了开源协作文档平台 **Docmost** 中的一个**高危**授权漏洞。
Docmost 的官方网站将其描述为一个下载量超过 **300 万次**的企业级本地部署 Wiki,并表示它受到了包括 **Vilnius City**、**Bechtle**、**Australian Government**、**Red Cross** 和 **ETS Quebec** 在内的组织团队的信任。
该漏洞存在于通用的文件上传路径中,Docmost 的图表保存/更新流程也使用了该路径。
我在审查该代码时,脑海中思考了一个非常具体的问题:
**如果上传接口验证了对某个页面的编辑权限,但覆盖目标却是通过另一个用户控制的 `attachment ID` 来选择的,会发生什么?**
在这种情况下,这个问题直接导致了一个真实的对象绑定失败。
Docmost 允许调用者发送:
- 一个他们有权编辑的页面的 `pageId`,以及
- 一个属于同一工作空间中另一个不同页面的 `attachmentId`
服务器确实执行了覆盖一致性检查,但防护逻辑使用了错误的布尔逻辑。
这意味着该请求可以通过授权,并无论如何覆盖受害者的附件。
此问题成为了 **CVE-2026-34213**。
**Docmost:** [docmost/docmost](https://github.com/docmost/docmost)
**安全通告:** [GHSA-89fp-2hch-j9gp](https://github.com/docmost/docmost/security/advisories/GHSA-89fp-2hch-j9gp)
**CVE:** CVE-2026-34213
**修复版本:** `v0.71.0`
---
## 攻击链
`攻击者控制的具有编辑权限的 pageId -> 攻击者控制的受害者 attachmentId -> 存在缺陷的覆盖防护将跨页面覆盖视为有效 -> 根据受害者 attachmentId 重建存储路径 -> 攻击者的字节数据替换受害者文件 -> 受害者页面继续提供被篡改的附件`
## Docmost 这部分功能的作用
Docmost 将上传的页面附件存储为数据库记录以及存储中的后备文件。
对于常规上传,服务器会创建一个新的 `attachment ID` 并写入一个新文件。
然而,对于图表保存/更新流程,客户端有意重用现有的 `attachmentId`,以便可以就地更新同一个图表文件,而不是每次都生成一个全新的附件记录。
这种行为本身是合理的。
问题在于它创建了一条高风险的路径:
- 一个输入标识正在被授权的页面
- 另一个输入标识正在被覆盖的附件
每当一个接口混合了这两项职责时,实现就必须将它们精确地绑定在一起。
而 Docmost 没有做到。
## 为什么这个攻击面值得研究
混合的创建/更新接口是授权漏洞的常见高发地。
原因很简单:
- 创建流程通常针对容器对象进行授权
- 更新流程通常针对现有记录进行授权
- 如果一个接口试图同时完成这两项操作,很容易先验证错误的对象,并将第二个标识符视为“仅仅是元数据”
这正是此处发生的情况。
`POST /api/files/upload` 验证了调用者是否可以编辑由 `pageId` 指定的页面。
但是如果同时也提供了 `attachmentId`,服务器就会切换到覆盖路径,并单独选择一个现有的附件记录。
这就引出了一个关键的安全问题:
**覆盖路径是否证明了所选的附件实际上属于已授权的页面?**
在易受攻击的版本中,答案是否定的。
## 根本原因
根本原因是**通过用户控制的密钥绕过授权**,加上覆盖防护中的一个布尔逻辑漏洞。
易受攻击的流程如下所示:
1. `AttachmentController.uploadFile()` 从 multipart 表单数据中读取 `pageId`。
2. 它加载该页面并调用 `validateCanEdit(page, user)`。
3. 它单独接收来自同一请求的可选 `attachmentId`。
4. `AttachmentService.uploadFile()` 通过攻击者提供的 ID 加载现有附件。
5. 覆盖防护试图验证现有附件是否与已授权的页面匹配。
6. 该防护使用了 `&&`,而不是在任何不匹配时进行拒绝。
易受攻击的防护代码为:
```
if (
existingAttachment.pageId !== pageId &&
existingAttachment.fileExt !== preparedFile.fileExtension &&
existingAttachment.workspaceId !== workspaceId
) {
throw new BadRequestException("File attachment does not match");
}
```
该条件仅在以下情况同时发生时才拒绝请求:
- 页面 ID 不匹配,且
- 文件扩展名不匹配,且
- 工作空间 ID 不匹配
这三点同时成立。
这与覆盖防护应该做的完全相反。
对于真实的攻击场景,攻击者有意留在了同一个工作空间内。
因此:
- `existingAttachment.workspaceId !== workspaceId` 为 `false`
一旦该操作数变为 false,整个 `&&` 条件的计算结果就为 false,即使该附件属于不同的页面。
因此,服务器将跨页面覆盖视为有效。
这是该漏洞的前半部分。
后半部分则是使其影响变为现实的原因。
检查之后,该服务使用攻击者提供的 `attachmentId` 和文件名重建了目标存储路径:
```
const filePath =
`${getAttachmentFolderPath(AttachmentType.File, workspaceId)}/` +
`${attachmentId}/${preparedFile.fileName}`;
```
然后,在更新路径上,Docmost 仅更新了可变的元数据,例如:
- `fileSize`
- `updatedAt`
它**没有**将所有权重新绑定到攻击者的页面。
因此,受害者页面继续保持指向相同的附件记录和相同的 `attachment ID`。
只有底层文件字节发生了改变。
这就是为什么这不是一个无害的不匹配。
它是一个持久化的未授权覆盖原语。
## 为什么这是一个安全问题,而不仅仅是逻辑错误
这不是一个表面上的漏洞,也不是文件名冲突问题。
攻击者不需要竞态。
攻击者不需要猜测随机路径。
攻击者不需要对受害者页面的写入权限。
他们只需要:
- 读取权限以获取受害者附件的引用,以及
- 对同一工作空间中任何其他页面的写入权限
从那里开始,他们可以替换另一个页面附件的存储文件字节,而受害者页面将继续引用并提供该附件,就好像什么都没改变一样。
这是一种直接的完整性失效。
在实际操作中,攻击者可以:
- 篡改图表
- 用误导性内容替换附件
- 破坏引用的文件
- 造成混乱的审计轨迹,因为附件看起来仍然属于受害者页面
重要的一点是:
**服务器接受了由攻击者选择的覆盖目标,而没有将其绑定到其实际已检查编辑权限的页面上。**
这是一种访问控制失效,而不仅仅是糟糕的布尔卫生。
## 为什么该漏洞利用具有实际可行性
这种漏洞利用对于图表附件尤其具有实际意义。
Docmost 的客户端有意重用 `attachmentId` 进行图表保存,并使用确定性的文件名:
- `diagram.excalidraw.svg`
- `diagram.drawio.svg`
这很重要,因为它降低了攻击者的要求。
对于常规附件,攻击者需要同时知道:
- 受害者的 attachment ID
- 受害者的文件名
对于图表,文件名已经是可预测的。
因此,如果攻击者可以读取受害者的页面内容,他们通常就能获取到所需唯一缺失的部分:
- 受害者的 `attachmentId`
在我的验证环境中,我正是使用了这条路径:
- 攻击者仅对受害者空间拥有**读取者 (reader)**权限
- 攻击者对另一个由攻击者控制的空间拥有**写入者 (writer)**权限
- 这两个空间属于同一个工作空间
这就足够了。
该漏洞利用跨越了同一工作空间内的页面边界和空间边界,同时仍然满足了存在缺陷的工作空间检查。
## 概念验证 (PoC)
我使用由 `docmost/docmost:0.70.3`、Postgres 和 Redis 构建的一次性实验环境,针对 **Docmost `v0.70.3`** 实时验证了该问题。
PoC 流程如下:
1. 创建一个所有者帐户。
2. 在同一个工作空间中创建一个受害者空间和一个由攻击者控制的空间。
3. 邀请第二个用户作为攻击者。
4. 授予攻击者:
- 对受害者空间的读取者权限
- 对由攻击者控制空间的写入者权限
5. 在受害者空间中,将一个图表附件上传到受害者页面。
6. 以攻击者身份,检索受害者页面信息并记录受害者的 `attachmentId`。
7. 发送 `POST /api/files/upload`,包含:
- `pageId` = 攻击者页面 ID
- `attachmentId` = 受害者 attachment ID
- `file` = 使用受害者文件名的由攻击者控制的替换文件
8. 在覆盖前后分别下载受害者附件并比较哈希值。
最小的请求结构为:
```
POST /api/files/upload
Content-Type: multipart/form-data
pageId=
attachmentId=
file=@diagram.excalidraw.svg;filename=diagram.excalidraw.svg
```
观察到的实时结果为:
- 攻击者获取到的受害者 attachment ID:`019d18ae-b176-751c-8525-b5f3cede131d`
- 用于覆盖请求的攻击者页面 ID:`019d18ae-b15b-70e9-ac67-64948e87cc5e`
- 受害者所有者页面 ID 保持为:`019d18ae-b12f-75ec-8c1c-5aff3ba6be9c`
- 服务器对覆盖请求的响应:`200 OK`
- 覆盖前的受害者文件 SHA-256:
```
686a0a0ede90ece1cbb975bb29304a6c3a90373a9c3ab2496345cf7ca59cc8fa
```
- 覆盖后的受害者文件 SHA-256:
```
e0168298846cdaf75c4d880f4b721d7c0ef0ef310f75617bf2b833af34cdbeba
```
- 攻击者 payload 的 SHA-256:
```
e0168298846cdaf75c4d880f4b721d7c0ef0ef310f75617bf2b833af34cdbeba
```
- 挂载的存储确认受害者路径现在包含:
```
Attacker replacement from another page
```
这是一个完整的端到端覆盖证明,而不仅仅是理论上的源代码审查。
## 为什么选择这种方式进行 PoC
在漏洞排查期间,我使用了两种证明方式:
- 一个复现易受攻击覆盖逻辑的狭窄独立测试环境,和
- 针对一次性 Docmost 实例的完整实时 HTTP 漏洞利用
独立的测试环境对于隔离布尔逻辑失败很有用。
实时的 HTTP PoC 是更强有力的工件,因为它证明了完整的安全逻辑:
- 页面授权在攻击者页面上成功
- 受害者的 `attachmentId` 被接受
- 覆盖请求返回成功
- 受害者页面保持为逻辑所有者
- 磁盘上的存储字节更改为攻击者控制的内容
这种区别在访问控制漏洞中很重要。
仅仅“条件错误”本身是不够的。
“条件错误,且应用程序可以被端到端驱使进行持久化的未授权覆盖”才是完整的案例。
## 修复分析
**`v0.71.0`** 中发布的修复将覆盖防护从 `&&` 改为了 `||`:
```
if (
existingAttachment.pageId !== pageId ||
existingAttachment.fileExt !== preparedFile.fileExtension ||
existingAttachment.workspaceId !== workspaceId
) {
throw new BadRequestException("File attachment does not match");
}
```
该补丁对于所报告的漏洞来说是极简、直接且正确的。
它恢复了正确的规则:
**仅当现有附件与授权的页面/工作空间/类型假设完全匹配时,才允许覆盖。**
一旦防护在任何不匹配时拒绝:
- 跨页面覆盖失败
- 跨工作空间覆盖失败
- 类型/扩展名不匹配失败
这是正确类型的修复:
- 没有重新设计
- 没有含糊不清的兼容性逻辑
- 没有试图“尽力”恢复
只是授权页面和覆盖目标之间的严格绑定。
这里还有一个更广泛的工程经验教训:
同时也服务于就地更新流程的通用上传接口应被视为高风险的 API 表面。
即使眼前的漏洞已被修复,更强大的长期设计应该是:
- 用于图表保存流程的专用更新接口
- 对文件名以及 attachment ID 进行不可变的绑定检查
- 明确对跨页面覆盖尝试进行建模的回归测试覆盖
但就漏洞本身而言,发布的补丁已经干净利落地解决了核心问题。
## 重要的回归测试用例
无论该项目是否围绕该修复添加了自己的私有测试,以下是对长期覆盖范围至关重要的测试用例:
- 使用相同页面 ID 和相同 attachment ID 的覆盖应该成功
- 在相同工作空间内使用不同页面 ID 的覆盖应该失败
- 使用不同工作空间的覆盖应该失败
- 使用不匹配文件扩展名的覆盖应该失败
- 使用已知图表文件名但外部 attachment ID 的覆盖应该失败
- 覆盖操作在写入攻击者控制的字节后,绝不应默默保留受害者的所有权
这些测试的重点不仅在于正确性。
在于锁定授权绑定,以便将来“有帮助的”上传重构不会重新打开同一类漏洞。
## 严重性和分类
发布的安全通告将此问题归类为:
- **CWE-639**: 通过用户控制的密钥绕过授权
- **CVSS v3.1**:
```
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:L
```
结果为 **7.1 / High**,这是正确的结论。
这里的重要指标是完整性。
这不是一个低级别的元数据漏洞。
攻击者完全控制了写入另一个页面附件路径的替换字节,并且受害者页面随后继续提供被篡改的对象。
这正是值得评为**完整性 High (High)** 的典型的存储型跨记录篡改行为。
可用性保持 Low 也是合理的,因为破坏图表或附件文档会使受害者的内容无法使用,但主要影响仍然是未授权的修改,而不是完全的服务中断。
## 披露
我通过 GitHub Security Advisories 私下报告了该问题,包含:
- 根本原因分析
- 实时 HTTP PoC
- 请求/响应证据
- 覆盖前后的文件哈希值
- 固定的一次性实验环境设置
该问题被维护者接受,被分配为 **CVE-2026-34213**,并于 **2026 年 4 月 14 日**发布。
公开的安全通告列出了:
- 受影响的版本:`>= v0.3.0`
- 修复版本:`v0.71.0`
该历史记录也与我的源代码审查相符:
易受攻击的覆盖逻辑存在于我在易受攻击分支中检查的最早标记版本中。
## 这个漏洞真正教会了我们什么
这里有趣的教训不仅仅是“使用 `||` 而不是 `&&`”。
那只是症状。
更深层次的教训是:
这条规则无处不在:
- 文档附件
- 个人资料媒体
- 云对象引用
- Issue/评论编辑
- 后台作业重新处理
当一个系统说出这句话的那一刻:
- “你可以编辑页面 X”
- “请告诉我需要更新哪条现有记录”
它就创造了一个安全边界,必须使用精确匹配的不变量来强制执行该边界。
任何不如严格要求的设计迟早都会变成用户控制密钥的漏洞。
这个问题还强化了另一个很容易被低估的观点:
**防护代码中微小的布尔错误可能会产生一等的安全后果。**
一眼看去“看起来合理”的三子句条件,就足以颠覆覆盖路径的保护模型。
这就是为什么这些表面值得刻意审查,而不是盲目自信。
## 关键点
- Docmost 使用同一个接口进行新上传和就地附件更新。
- 授权是针对调用者提供的 `pageId` 进行检查的,但覆盖目标的选择使用的是单独由调用者提供的 `attachmentId`。
- 仅当所有不匹配条件同时成立时,覆盖防护才会拒绝。
- 在同一工作空间的攻击案例中,该检查失效放行了请求。
- 服务根据受害者 attachment ID 重建了存储路径,并将攻击者控制的字节写入了其中。
- 覆盖后,附件记录仍然绑定在受害者页面上。
- 确定性的图表文件名使得漏洞利用极其具有实际意义。
- `v0.71.0` 中的修复正确地将防护更改为在任何不匹配时拒绝。
## 结语
此漏洞与奇异的存储行为无关。
它关乎的是一个更新路径过度信任了攻击者选择的对象标识符。
Docmost 在一个页面上证明了编辑权限,接受了来自另一个页面的现有 `attachment ID`,然后让有缺陷的覆盖检查将这种不匹配转化为成功的跨页面文件替换。
这就是它成为 **CVE-2026-34213** 的原因。
`v0.71.0` 中的补丁干净利落地修复了眼前的问题,但更广泛的教训依然具有价值:
当授权和对象选择被拆分到不同的用户控制字段时,精确绑定就是核心的安全属性。
---
## 攻击链
`攻击者控制的具有编辑权限的 pageId -> 攻击者控制的受害者 attachmentId -> 存在缺陷的覆盖防护将跨页面覆盖视为有效 -> 根据受害者 attachmentId 重建存储路径 -> 攻击者的字节数据替换受害者文件 -> 受害者页面继续提供被篡改的附件`
## Docmost 这部分功能的作用
Docmost 将上传的页面附件存储为数据库记录以及存储中的后备文件。
对于常规上传,服务器会创建一个新的 `attachment ID` 并写入一个新文件。
然而,对于图表保存/更新流程,客户端有意重用现有的 `attachmentId`,以便可以就地更新同一个图表文件,而不是每次都生成一个全新的附件记录。
这种行为本身是合理的。
问题在于它创建了一条高风险的路径:
- 一个输入标识正在被授权的页面
- 另一个输入标识正在被覆盖的附件
每当一个接口混合了这两项职责时,实现就必须将它们精确地绑定在一起。
而 Docmost 没有做到。
## 为什么这个攻击面值得研究
混合的创建/更新接口是授权漏洞的常见高发地。
原因很简单:
- 创建流程通常针对容器对象进行授权
- 更新流程通常针对现有记录进行授权
- 如果一个接口试图同时完成这两项操作,很容易先验证错误的对象,并将第二个标识符视为“仅仅是元数据”
这正是此处发生的情况。
`POST /api/files/upload` 验证了调用者是否可以编辑由 `pageId` 指定的页面。
但是如果同时也提供了 `attachmentId`,服务器就会切换到覆盖路径,并单独选择一个现有的附件记录。
这就引出了一个关键的安全问题:
**覆盖路径是否证明了所选的附件实际上属于已授权的页面?**
在易受攻击的版本中,答案是否定的。
## 根本原因
根本原因是**通过用户控制的密钥绕过授权**,加上覆盖防护中的一个布尔逻辑漏洞。
易受攻击的流程如下所示:
1. `AttachmentController.uploadFile()` 从 multipart 表单数据中读取 `pageId`。
2. 它加载该页面并调用 `validateCanEdit(page, user)`。
3. 它单独接收来自同一请求的可选 `attachmentId`。
4. `AttachmentService.uploadFile()` 通过攻击者提供的 ID 加载现有附件。
5. 覆盖防护试图验证现有附件是否与已授权的页面匹配。
6. 该防护使用了 `&&`,而不是在任何不匹配时进行拒绝。
易受攻击的防护代码为:
```
if (
existingAttachment.pageId !== pageId &&
existingAttachment.fileExt !== preparedFile.fileExtension &&
existingAttachment.workspaceId !== workspaceId
) {
throw new BadRequestException("File attachment does not match");
}
```
该条件仅在以下情况同时发生时才拒绝请求:
- 页面 ID 不匹配,且
- 文件扩展名不匹配,且
- 工作空间 ID 不匹配
这三点同时成立。
这与覆盖防护应该做的完全相反。
对于真实的攻击场景,攻击者有意留在了同一个工作空间内。
因此:
- `existingAttachment.workspaceId !== workspaceId` 为 `false`
一旦该操作数变为 false,整个 `&&` 条件的计算结果就为 false,即使该附件属于不同的页面。
因此,服务器将跨页面覆盖视为有效。
这是该漏洞的前半部分。
后半部分则是使其影响变为现实的原因。
检查之后,该服务使用攻击者提供的 `attachmentId` 和文件名重建了目标存储路径:
```
const filePath =
`${getAttachmentFolderPath(AttachmentType.File, workspaceId)}/` +
`${attachmentId}/${preparedFile.fileName}`;
```
然后,在更新路径上,Docmost 仅更新了可变的元数据,例如:
- `fileSize`
- `updatedAt`
它**没有**将所有权重新绑定到攻击者的页面。
因此,受害者页面继续保持指向相同的附件记录和相同的 `attachment ID`。
只有底层文件字节发生了改变。
这就是为什么这不是一个无害的不匹配。
它是一个持久化的未授权覆盖原语。
## 为什么这是一个安全问题,而不仅仅是逻辑错误
这不是一个表面上的漏洞,也不是文件名冲突问题。
攻击者不需要竞态。
攻击者不需要猜测随机路径。
攻击者不需要对受害者页面的写入权限。
他们只需要:
- 读取权限以获取受害者附件的引用,以及
- 对同一工作空间中任何其他页面的写入权限
从那里开始,他们可以替换另一个页面附件的存储文件字节,而受害者页面将继续引用并提供该附件,就好像什么都没改变一样。
这是一种直接的完整性失效。
在实际操作中,攻击者可以:
- 篡改图表
- 用误导性内容替换附件
- 破坏引用的文件
- 造成混乱的审计轨迹,因为附件看起来仍然属于受害者页面
重要的一点是:
**服务器接受了由攻击者选择的覆盖目标,而没有将其绑定到其实际已检查编辑权限的页面上。**
这是一种访问控制失效,而不仅仅是糟糕的布尔卫生。
## 为什么该漏洞利用具有实际可行性
这种漏洞利用对于图表附件尤其具有实际意义。
Docmost 的客户端有意重用 `attachmentId` 进行图表保存,并使用确定性的文件名:
- `diagram.excalidraw.svg`
- `diagram.drawio.svg`
这很重要,因为它降低了攻击者的要求。
对于常规附件,攻击者需要同时知道:
- 受害者的 attachment ID
- 受害者的文件名
对于图表,文件名已经是可预测的。
因此,如果攻击者可以读取受害者的页面内容,他们通常就能获取到所需唯一缺失的部分:
- 受害者的 `attachmentId`
在我的验证环境中,我正是使用了这条路径:
- 攻击者仅对受害者空间拥有**读取者 (reader)**权限
- 攻击者对另一个由攻击者控制的空间拥有**写入者 (writer)**权限
- 这两个空间属于同一个工作空间
这就足够了。
该漏洞利用跨越了同一工作空间内的页面边界和空间边界,同时仍然满足了存在缺陷的工作空间检查。
## 概念验证 (PoC)
我使用由 `docmost/docmost:0.70.3`、Postgres 和 Redis 构建的一次性实验环境,针对 **Docmost `v0.70.3`** 实时验证了该问题。
PoC 流程如下:
1. 创建一个所有者帐户。
2. 在同一个工作空间中创建一个受害者空间和一个由攻击者控制的空间。
3. 邀请第二个用户作为攻击者。
4. 授予攻击者:
- 对受害者空间的读取者权限
- 对由攻击者控制空间的写入者权限
5. 在受害者空间中,将一个图表附件上传到受害者页面。
6. 以攻击者身份,检索受害者页面信息并记录受害者的 `attachmentId`。
7. 发送 `POST /api/files/upload`,包含:
- `pageId` = 攻击者页面 ID
- `attachmentId` = 受害者 attachment ID
- `file` = 使用受害者文件名的由攻击者控制的替换文件
8. 在覆盖前后分别下载受害者附件并比较哈希值。
最小的请求结构为:
```
POST /api/files/upload
Content-Type: multipart/form-data
pageId=标签:CSV导出, DNS 反向解析, 协同文档, 授权缺陷, 搜索引擎查询, 文件上传, 测试用例, 漏洞分析, 请求拦截, 越权漏洞, 路径探测