文章总结: 文档分析onlyoffice前台RCE链:CVE-2021-3199路径穿越漏洞被CISA收录,新研究展示v9版本未授权路径穿越结合配置热重载实现任意文件写直达RCE,全程无需凭据。核心问题在于存储层路径检查缺乏统一边界校验,建议升级版本、启用JWT并限制访问。
综合评分: 85
文章分类: 漏洞分析,web安全,红队,渗透测试
OnlyOffice 前台 RCE 代码分析:五年没修干净的路径穿越,撞上 v9 的热重载
原创
Kratos
Kratos
Kratos Sec
2026年10月10日 22:01
北京
在小说阅读器读本章
去阅读
在公众号小说中沉浸阅读
OnlyOffice 前台 RCE 代码分析:五年没修干净的路径穿越,撞上 v9 的热重载
10 月 8 日,CISA 把一个”高龄”漏洞 CVE-2021-3199 加进 KEV 目录:OnlyOffice Document Server 5.6.3 之前版本 /upload 接口的路径穿越,2021 年 1 月就修了,五年来一直在野外被打,联邦机构的整改截止日期只给了三天(10 月 11 日)。两天前,一篇新的公开研究又把同一个产品送回聚光灯下:现行 9.x 版本上,一条未授权路径穿越 + 配置热重载的组合链,从任意文件写直达 RCE,全程不需要任何凭据。把这两件事放在一起看,这是一个”修了五年没修干净”的典型样本。这篇从代码层面把新链路拆开讲,顺带回答自查和处置该怎么做。
0x0 事件概述
先把两件叠加的事分清楚,预警稿经常把它们混成一团:
| 项目 | 事件 A:KEV 收录 | 事件 B:新公开研究 |
| — | — | — |
| 编号 | CVE-2021-3199(CWE-22) | 暂无 CVE(截稿时官方未公告) |
| 内容 | /upload 图片上传参数路径穿越,可 RCE | /downloadas 任意文件写 + v9 配置热重载 → 未授权 RCE |
| 影响版本 | < 5.6.3(2021 年 1 月修复) | 写原语 5.0+ 就有;v9.x 可即时触发(验证于 9.4/master) |
| 时间线 | 10 月 8 日入 KEV,10 月 11 日整改截止 | 10 月 3 日 PoC 仓库公开,HKCERT 10 月 9 日发公告 |
事件 A 是老洞的在野利用确认,CVE-2021-3199 的 NVD 评分 9.8,CISA 要求按 BOD 26-04 做取证分诊。事件 B 是新的攻击面,本文主角。两者的共同点才是重点:穿越点都在 DocumentServer 的存储层,而存储层的路径检查至今没有统一的边界校验。
0x1 攻击面:三行路由和默认关闭的 JWT
DocumentServer 的 DocService 是个 Express 应用(现行版本源码在 ONLYOFFICE/server 仓库),路由注册就几行:
// DocService/sources/server.js
app.post('/converter', utils.checkClientIp, rawFileParser, converterService.convertJson);
app.param('docid', (req, res, next, val) => { // 只校验 URL 里的 :docid
if (constants.DOC_ID_REGEX.test(val)) next();
else res.sendStatus(403);
});
app.post('/upload/:docid*', rawFileParser, fileUploaderService.uploadImageFile);
app.post('/downloadas/:docid', rawFileParser, canvasService.downloadAs);
app.post('/savefile/:docid', rawFileParser, canvasService.saveFile);
两个事实决定了风险面。第一,DOC_ID_REGEX 只管 URL 路径段,真正参与存储路径拼接的 id、savekey、format 全部来自 query 里的 cmd JSON,不经过这个正则。第二,JWT 校验默认是关的:
// Common/config/default.json
"token": { "enable": { "browser": false, "request": { "inbox": false, "outbox": false } } }
官方 Docker 镜像 7.2 之后默认开启 JWT 并生成随机密钥,但源码默认值是全关,裸机 deb/rpm 安装、老版本升级上来的部署、以及自行改过配置的实例,很可能处于”前台完全裸奔”的状态。源码里甚至专门打了警告(server.js:141),JWT 没开全时启动日志会提示你。/downloadas 处理器里的校验逻辑长这样:
// DocService/sources/canvasservice.js downloadAs
const strCmd = req.query['cmd'];
const cmd = new commonDefines.InputCommand(JSON.parse(strCmd)); // cmd 字段全部攻击者可控
...
if (tenTokenEnableBrowser || cmd.getTokenDownload() || cmd.getTokenSession()) {
// 校验 JWT,失败 403
}
// browser 开关为 false 且请求不带 token:上面整段跳过,直接往下走
cmd.setData(req.body); // 请求体原样进数据
JWT 关闭时,任何人一个 POST 就能走到存储写入。
0x2 任意文件写:savetype=3 绕过归一化
写入链路三步,每一步都能在代码里看到”防了一半”的痕迹。
第一步,进 saveParts。downloadAs 里 c:"save" 分支进入 commandSave,把用户可控的 format 拼进文件名:
// DocService/sources/canvasservice.js
function* commandSave(ctx, cmd, outputData) {
const format = cmd.getFormat() || 'bin';
const completeParts = yield* saveParts(ctx, cmd, 'Editor.' + format);
第二步,归一化只做了一半。saveParts 的代码和注释都值得细读:
function* saveParts(ctx, cmd, filename) {
const saveType = cmd.getSaveType();
if (SAVE_TYPE_COMPLETE_ALL !== saveType) { // savetype != 3 才处理
const ext = pathModule.extname(filename);
const saveIndex = parseInt(cmd.getSaveIndex()) || 1; //prevent path traversal
filename = pathModule.basename(filename, ext) + saveIndex + ext;
}
...
yield storage.putObject(ctx, cmd.getDocId() + cmd.getSaveKey() + '/' + filename,
buffer, buffer.length);
注释写着 prevent path traversal,手段是 path.basename 剥掉所有目录成分——但这个处理被包在 savetype !== 3 的条件里。攻击者把 savetype 设成 3(COMPLETE_ALL),归一化整段跳过,filename 原样进入拼接。同文件 361 行还有一条历史注释:”set saveKey as postfix to fix vulnerability with path traversal”——同一个函数附近,两次针对穿越的补丁痕迹,都没有覆盖全部路径。
拼接串 docId + saveKey + '/' + filename 三个成分全部来自 cmd JSON,savekey 自己带上就跳过随机化(417 行:只有没带 saveKey 才生成随机值)。三者组合出的相对路径完全可控。
第三步,存储层裸 join。
// Common/sources/storage/storage-fs.js
function getFilePath(storageCfg, strPath) {
return path.join(storageCfg.fs.folderPath, strPath); // 无包含性检查
}
async function putObject(storageCfg, strPath, buffer, _contentLength) {
const fsPath = getFilePath(storageCfg, strPath);
await mkdir(path.dirname(fsPath), {recursive: true}); // 递归建目录
...
await writeFile(fsPath, buffer); // 原样落盘
}
path.join 会归一化 ..,穿越序列直接吃掉前面的固定段。仓库里其实有 checkPathTraversal(utils.js:1156,检查解析后路径是否逃出根目录、顺带防空字节),但全局搜调用点,只有 FileConverter/converter.js 用了,/downloadas 这条路上一处都没有。
生产配置的存储根是 /var/lib/onlyoffice/documentserver/App_Data/cache/files(production-linux.json),请求形态大致是:
POST /downloadas/aaa?cmd={"c":"save","id":"PWNA","savekey":"SK","savetype":3,
"format":"/../../../../var/www/onlyoffice/Data/runtime.json"}
Content-Type: text/plain
<任意字节,原样落盘>
任意文件写原语成立:路径、文件名、内容三者全部攻击者可控。
0x3 从文件写到 RCE:v9 的新灯
任意文件写不等于 RCE,通常要等重启、等加载。v9 把这个”等”消掉了。
v9 引入了 runtime.json 热重载。 生产配置里 runtimeConfig.filePath 指向 /var/www/onlyoffice/Data/runtime.json,管理器对这个文件做 fs.watch,变更后 200ms 防抖重载:
// Common/sources/runtimeConfigManager.js
const RELOAD_DEBOUNCE_MS = 200;
reloadTimer = setTimeout(() => {
nodeCache.del(configFileName);
operationContext.global.cleanRuntimeConfigCache(); // 清空配置缓存
getConfig(...); // 立即重读文件
}, RELOAD_DEBOUNCE_MS);
热重载的配置优先级高于一切。 每个请求初始化上下文时的合并顺序:
// Common/sources/operationContext.js
this.config = utils.deepMergeObjects({}, moduleReloader.getBaseConfig(),
runtimeConfig, tenantConfig);
runtimeConfig 排在 baseConfig 后面,同名键直接覆盖。也就是说,写进 runtime.json 的任何配置项,200ms 内全局生效,不用重启,没有键位限制。
执行点在 FileConverter。 converter 的 docbuilder 路径同样从这份热配置里读:
// FileConverter/sources/converter.js
const tenDocbuilderPath = resolveConverterPath(
ctx.getCfg('FileConverter.converter.docbuilderPath', cfgDocbuilderPath));
...
processPath = tenDocbuilderPath;
const spawnAsyncPromise = spawnAsync(processPath, childArgs, spawnOptions); // 直接 spawn
而 resolveConverterPath 对绝对路径原样放行(converterPaths.js:注释明说 “Absolute paths are returned unchanged”)。
完整链条收拢成四步:
POST /downloadas穿越写入/var/www/onlyoffice/Data/runtime.json,内容为攻击者构造的 JSON:关闭token.enable.*,把FileConverter.converter.docbuilderPath指向自己的可执行文件;- 200ms 后热重载生效,JWT 校验被关掉(研究里观察到
/converter从报错 -8 实时翻转为放行); POST /docbuilder投递构建任务;- converter spawn 劫持后的路径,代码执行。社区版 converter 在进程内跑内存队列,下一个任务就会触发。
顺带一提,spawnOptions.env、x2tPath 等键同样能通过这份配置注入,LD_PRELOAD 一类的玩法也在射程内。v9 之前的版本没有热重载,但写原语一样成立:覆写 local.json 等重启、覆写服务端 JS 文件、覆写 web-apps/sdkjs 静态资源打存储型 XSS 偷 JWT,只是从”即时”退化成”待机而动”。
0x4 历史回响:同一个类别的三次补丁
CHANGELOG 里的记录可以直接当编年史读:5.6.2 “Fix Path Traversal vulnerability via savefile param”,5.6.3 “Fix Path Traversal vulnerability via image upload params”(即 CVE-2021-3199)。2022 年某红队实录正是拿老版 OnlyOffice 的 savefile 任意文件写突进内网;2024 年长亭的低版本复现分析、10 月 3 日的 v9 新研究,打的都是同一类问题:存储层路径拼接缺少统一的根目录边界校验,修复长期是”哪个参数被报了修哪个”。
这次 KEV 收录说明老洞在野外持续有流量。而对已升级到新版本的实例,事件 A 不构成直接影响,事件 B 的写原语却依然存在——这也是为什么自查不能只看版本号。
0x5 自查
第一层,暴露面。 DocumentServer 按设计只该被集成应用(ownCloud/Nextcloud/Confluence 等)的内网回调访问。查你的实例是否映射到公网:/healthcheck 返回 true 即为 DocService。FOFA/Shodan 上搜 ONLYOFFICE 相关特征,把自家的实例数量先摸清。
第二层,版本与 JWT 状态。 版本用包管理器查(dpkg -l | grep onlyoffice / rpm -qa | grep onlyoffice)。低于 5.6.3 的直接按失陷处理——KEV 在野加三年半的暴露窗口。JWT 状态看 /etc/onlyoffice/documentserver/local.json 或容器环境变量 JWT_ENABLED,确认 token.enable.browser 和 token.enable.request.inbox 都是 true 且密钥为强随机值。注意 downloadas 的校验挂在 browser 开关上,两个开关要一起看。
第三层,失陷痕迹。 按研究给出的 IOC 排查:
/var/www/onlyoffice/Data/runtime.json的 mtime 与内容,重点看有没有token.enable、docbuilderPath、spawnOptions、x2tPath相关键;- 存储根
/var/lib/onlyoffice/documentserver/App_Data/cache/files下出现../无法解释的目录结构、.cmd/.sh/.so等异常扩展名文件; - DocService 日志里
Start downloadAs记录的 cmd JSON 含savetype":3且 format 带../; - converter/docbuilder/x2t 子进程的可执行路径异常、DocService 派生 shell。
临时拦截。 WAF 对 /downloadas、/upload、/converter、/docbuilder 四个路径的请求做限制:来源收敛到集成应用网段,query 的 cmd 参数中出现 ../ 直接拦截。
0x6 修复与缓解
升级是基础动作但不是全部。 5.6.3 之前版本立即升级,老洞的五年在野利用是实打实的。但要向管理层讲清楚:新研究的链路截稿时还没有官方补丁公告,9.x 一样在影响范围内,升级解决不了事件 B。
现阶段真正有效的三道防线:
- JWT 全开 + 强随机密钥 + 密钥不落代码仓库。写原语的入口在没有 token 的接口上,把门关上,链条第一步就断了;
- 网络收敛。DocServer 只允许集成应用所在网段访问,公网直连一律掐掉,这是对未知穿越点最普适的防御;
- 监控 runtime.json 的变更与 converter 的进程派生行为,作为兜底告警。
对厂商的期待(研究者的修复建议,值得跟进验证): 存储层统一做 path.resolve 后的根目录前缀校验,拒绝含 .. 的键;DOC_ID_REGEX 覆盖到 cmd 内的 id;savetype=3 同样过 basename;runtime/tenant 配置对 token.*、secret.*、*Path、spawnOptions 这类敏感键做白名单隔离,热重载不该能改安全开关。
参考
- https://github.com/FlowerWitch/OnlyOffice_Document_Server_Unauthenticated_RCE
- https://github.com/ONLYOFFICE/server
免责声明:本文仅用于安全研究与学习,未经授权不得用于任何非法渗透测试活动。
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:Kratos Sec Kratos
Kratos《OnlyOffice 前台 RCE 代码分析:五年没修干净的路径穿越,撞上 v9 的热重载》