文章总结: OnlyOfficeDocumentServer被披露前台RCE漏洞(CNVD-2026-28199),源于路径穿越与配置注入组合利用,无需认证即可远程执行命令。影响5.0及以上全系列,9.1.0修复。攻击链涉及写入原语、/info/config配置注入及信任错置。建议立即封禁/info端点、启用JWT、升级版本,并可使用附带的只读自查脚本核查配置篡改与异常文件。
综合评分: 88
文章分类: 漏洞分析,应急响应,安全工具,解决方案
【漏洞通告】OnlyOffice Server前台RCE漏洞细节披露分析~【附自查脚本】
FL_Clover
FL_Clover
网络安全007
2026年10月10日 17:24
广东
在小说阅读器读本章
去阅读
在公众号小说中沉浸阅读
一、 漏洞事件概述
近期,两个月前某安全实验室披露了 OnlyOffice Document Server(一款广泛使用的开源在线办公协作套件)中存在一个极高危的远程代码执行(RCE)漏洞(编号:CNVD-2026-28199),其核心成因是系统在处理用户输入时缺乏严格校验,导致“路径穿越”与“配置注入”两个缺陷被恶意组合利用。攻击者可以在完全无需身份认证的前提下,篡改服务器底层运行配置,并最终在目标主机上执行任意系统命令,从而完全控制服务器。
二、影响范围与前置条件判定
- 版本维度
- 受影响:5.0 及以上全系列(含官方 Docker 镜像 onlyoffice/documentserver)。
- 差异点:
- 9.x:新增了可直接访问的配置端点,配置覆写后能被主动、即时加载,因此可形成完整主动利用链;已确认 9.0.0(Build 168)存在。
- 5.0 ~ 9.0 之间:缺少该配置端点,需依赖进程重启、服务重载等外部条件才能被动生效,利用稳定性差,但仍属受影响范围。
- 修复版本:升级至 9.1.0 及以上(原文口径;若条件允许,建议直接追到当前最新稳定版,因为 9.1 同时修复了数百个问题,包含本条链路的两个根因)。
- 部署形态维度
| 部署形态 | 风险 |
| — | — |
| 直连后端 8000 端口(未走 nginx 反代) | /info 端点直接对外,默认即可打 |
| nginx 反代但把 /info 转发出去 | 同上,本地访问限制被绕开 |
| 标准 nginx 反代(/info 仅 allow 127.0.0.1) | 配置端点被挡住,但路径穿越写文件仍可能成立(仍可能被用于覆盖其他资源、配合其他缺陷) |
| 容器内挂载了 config 卷 / 以 root 运行容器 | 写入面更广,后果更重 |
- 暴露面与行业分布
- 全球具备 OnlyOffice 指纹的记录约 19.7 万条 / 约 7 万个独立 IP;
- 国内约 8.4 万条 / 约 3 万个独立 IP;
- 资产指纹:app:”onlyoffice”
三、漏洞机理(攻击链拆解)
| 环节 | 缺陷本质 | 是否可单独成事 | 与其他环节关系 |
| — | — | — | — |
| ① 写入原语 | savekey 未规范化,path.join 后无存储根断言 | 中危(任意目录写文件,文件名由服务端生成) | 默认链路里仅作探针;理论上可回写 Data 目录,是 ② 被阻断时的降级路径 |
| ② 配置注入 | /info/config 无鉴权、整份覆写 | 高危但需前置条件 | RCE 的直接入口;唯一屏障是 nginx IP 白名单 |
| ③ 信任错置 | 转换器可执行路径来自可信度不足的配置 | 不可单独成事 | 把“改配置”桥接为“改执行体”,使关键字过滤全部失效 |
3.1 写入原语 = 可控目录 + 服务端定名 + 原样内容
实测落盘为 /tmp/pwn_write/Editor1.txt,传入的 id=probe 未映射为文件名。反推逻辑为 存储根 + path.join(savekey, 服务端生成文件名)。因此攻击者能控制目录前缀与文件内容,但不能精确命中指定文件名。
这决定了它难以精准覆盖已知文件名的目标,却足以完成探针验证、向 Web 可达目录投毒以及回写配置目录。防守侧排查应转向“异常目录下新建的常规扩展名文件”,而非仅搜索可疑脚本名。
3.2 /info/config 宽松到不校验 MIME
使用 application/octet-stream 仍返回 200 并成功覆写;同时不做 JWT、不做会话、代码层来源校验默认关闭,且为整份覆写。
这迫使攻击者必须先 GET 原始配置再修改——在日志里留下极清晰的取证线索(正常运维几乎不会对该端点发送大体积包),同时也让“打完自动还原”成为可能。
3.3 生效靠缓存 TTL,不是重启
runtimeConfigManager 对运行期配置做带 TTL 的内存缓存,实测 ≤85s 重读磁盘,无需重启、无需新请求、无人交互。
这是本漏洞静默且高危的根源。推论:注入与触发之间天然存在时间窗口,文件完整性监控抓 runtime.json 变更,是比抓命令执行更早、误报更低的检测点。
3.4 spawn 签名解释了所有参数切分现象
调用形态近似 spawn(“/bin/sh”, [“-c”, “<命令>“, params.xml])。执行体被整体替换,; | $( ) 等注入符无用,常规命令注入规则全部失效;args 被展开成数组,params.xml 成为 $0。
若把 args 写成 touch /tmp/x,shell 收到的 -c 后只有 touch 一词,于是报 missing file operand——这个报错出现在 converter 日志里,本身就是 Shell 已被拉起的独立旁证,在无回显场景下比反弹连接更可靠。
四、复现流程(仅供参考)
| 步骤 | 动作 | 实证 |
| — | — | — |
| 前置判定 | GET /info 走 9880 与 8000 两口 | 判定可达性,决定后续走向 |
| Step 1 | POST /downloadas/normal?cmd={c:"save",savekey:"../../..//tmp/pwn_write",format:"txt"},body 放唯一标记串 | 返回 {"type":"save","status":"ok"};容器内 /tmp/pwn_write/Editor1.txt 内容等于请求体 |
| Step 2 | GET /info dump 原始配置 → 仅改 FileConverter.converter.x2tPath="/bin/sh"、args="-c id>/tmp/pwned_rce_config_injection" → 整份 POST /info/config | 200;/var/www/onlyoffice/Data/runtime.json 被覆写,属主 ds |
| Step 3 | 静置约 90s(热加载) | 无需任何人工干预 |
| Step 4 | 再发一次进转换队列的请求 | cat /tmp/pwned_rce_config_injection → uid=105(ds) gid=107(ds) groups=107(ds) |
复现过程坑点自查
| 现象 | 根因 | 处置 |
| — | — | — |
| 返回 ok 但文件未落盘 | savekey 跳级层数不准,落到不存在的父目录 | 必须二次读回确认,不能信 status |
| 日志报 missing file operand / not found | -c 后的命令未作为一个完整字符串 | 已执行但未执行对,非未执行 |
| Step 2 返回 200 但配置未变 | 反代丢 body 或改写 Content-Type | 检查透传,抓包确认 |
| 注入成功但不执行 | 在 TTL 窗口内就发了触发请求,读到旧配置 | 间隔拉到 3 分钟重试 |
| 拿不到回显 | ds 低权、容器缺交互工具、出网受限 | 首轮改用 HTTP/DNS 外带确认 |
五、修复、加固、自查脚本
1.修复与加固
| 优先级 | 动作 | 验收方式 |
| — | — | — |
| P0·小时级 | nginx 显式 deny all 封 /info、/info/config;禁止后端 8000 直曝公网 | 从外网 GET 返回 403 |
| P0·小时级 | JWT_ENABLED=true + 安装期自定义密钥;关闭 EXAMPLE_ENABLED | 无 token 请求返回 403 |
| P0·小时级 | 对 runtime.json 及所在目录做不可变标记 / 文件完整性监控 | 变更后 5 分钟内告警 |
| P1·版本级 | 升级至 9.1.0 及以上(同时修了路径穿越与配置鉴权) | 两条链路都复测,确认不是只修一处 |
| P2·架构级 | 输入层:savekey/format/id 做 .. 剥离、绝对路径拒绝、白名单,path.join 后加“必须以存储根为前缀”断言 | 代码评审 + 单测 |
| P2·架构级 | 配置层:运行期配置写入端点强制认证 + 来源限制 + 变更审计 | 未鉴权 POST 返回 403 |
| P2·架构级 | 执行层:转换器可执行路径白名单(只允许预期 x2t 二进制被派生);配合 seccomp/AppArmor 限制 execve | 篡改配置后转换任务报错而非执行 |
| P2·架构级 | 运行层:非 root 最小权限、容器只暴露必要端口、/tmp/Data/cache 评估 noexec 与定期清理 | 权限审计通过 |
2.自查脚本
只读、无破坏、幂等可重复执行;优先查“配置是否被改”(最早、最准的检测点),其次查产物与日志;默认输出人类可读报告,支持 –json 供 SIEM 采集;含 –baseline 模式建立完整性基线,自行下载或更改后进行自查。
#!/usr/bin/env bash# oo-emergency-check.sh# ONLYOFFICE Document Server 应急核查脚本(只读 / 无破坏 / 可重复执行)# 用法:# ./oo-emergency-check.sh # 全量检查,文本报告# ./oo-emergency-check.sh --json # JSON 输出,供 SIEM/流水线采集# ./oo-emergency-check.sh --baseline # 建立 runtime.json 基线(hash+权限+属主)# ./oo-emergency-check.sh --verify # 对照基线做完整性校验# ./oo-emergency-check.sh --quick # 只做 T0 三项(适合批量巡检)# 注意:本脚本不修复、不删除任何文件;--suggest 仅打印建议命令,需人工确认后执行。set -euo pipefailexport LC_ALL=C# ---------- 可配置项 ----------RUNTIME_JSON="/var/www/onlyoffice/Data/runtime.json"LOG_DIR="/var/log/onlyoffice/documentserver"CACHE_DIR="/var/lib/onlyoffice/documentserver/App_Data/cache/files/data"TMP_DIRS="/tmp /var/tmp"DS_UID="105"DS_GID="107"BASELINE_FILE="/var/lib/onlyoffice/.oo-runtime-baseline"NGINX_CONF_DIR="/etc/onlyoffice/documentserver"SHELL_PATTERN='/bin/(ba)?sh|python[0-9.]*|busybox|nc|netcat|curl|wget|perl|ruby'HOURS_BACK=168 # 日志/文件回溯窗口(小时)# ---------- 全局状态 ----------MODE="full"; OUT="text"; FINDINGS=(); RC=0declare -A SCORE=( [crit]=0 [high]=0 [med]=0 [low]=0 )log_find() { local lvl="$1" msg="$2"; FINDINGS+=("${lvl}:${msg}"); echo "[${lvl}] ${msg}"; }bump() { ((SCORE[$1]++)) || true; case "$1" in crit) RC=3;; high) ((RC<3))&&RC=2;; med) ((RC<2))&&RC=1;; esac; }file_exists() { [[ -e "$1" ]] && echo "1" || echo "0"; }# ---------- T0-1:运行期配置是否被篡改(最高信度) ----------check_runtime_config() { echo "=== [T0-1] 运行期配置核查 ===" [[ $(file_exists "$RUNTIME_JSON") == "0" ]] && { log_find "INFO" "runtime.json 不存在(路径可能随版本变化),跳过"; return 0; } # 1) 权限/属主异常:不应被服务账户以外的主体可写 local perm owner perm=$(stat -c '%a' "$RUNTIME_JSON" 2>/dev/null || echo "?") owner=$(stat -c '%U:%G' "$RUNTIME_JSON" 2>/dev/null || echo "?") echo " 路径: $RUNTIME_JSON 权限: $perm 属主: $owner" if [[ "$perm" =~ ^[0-7]*[2367]$ ]]; then log_find "MED" "runtime.json 对其他/同组可写($perm),易被自身服务账户或同组进程污染"; bump med fi # 2) 基线对照(如果存在) if [[ -f "$BASELINE_FILE" ]]; then local now hash_now hash_now=$(sha256sum "$RUNTIME_JSON" | awk '{print $1}') if ! grep -q "^$hash_now " "$BASELINE_FILE"; then log_find "CRIT" "runtime.json 与基线不一致(hash=$hash_now),疑似被篡改!"; bump crit else echo " 完整性: 与基线一致 ✓"; fi else echo " 完整性: 无基线(先用 --baseline 建立)"; fi # 3) 核心:x2tPath / args 是否指向 shell 类程序 if command -v jq &>/dev/null; then local xp ar xp=$(jq -r '..|.x2tPath?//empty' "$RUNTIME_JSON" 2>/dev/null | tr '\n' ' ') ar=$(jq -r '..|.args?//empty' "$RUNTIME_JSON" 2>/dev/null | tr '\n' ' ') echo " x2tPath: ${xp:-(not found)}" echo " args : ${ar:-(not found)}" if echo "$xp" | grep -qiE "$SHELL_PATTERN"; then log_find "CRIT" "x2tPath 指向 shell 类程序:${xp} —— 高度疑似已被利用(CNVD-2026-28199)"; bump crit fi if echo "$ar" | grep -qiE '(^|[[:space:]])-c[[:space:]]|/tmp/|bash -i|/dev/tcp|curl .*http|wget .*http'; then log_find "CRIT" "converter args 含可疑载荷特征:${ar}"; bump crit fi # 4) 结构健康度:缺少关键段说明是被不完整覆写过 if ! jq -e '.FileConverter?' "$RUNTIME_JSON" &>/dev/null; then log_find "HIGH" "runtime.json 缺失 FileConverter 段,疑似被不完整配置覆写"; bump high fi else log_find "INFO" "缺少 jq,改用 grep 降级检测(可能漏报)" if grep -qiE '"x2tPath"[[:space:]]*:[[:space:]]*"/bin/(sh|bash)"' "$RUNTIME_JSON"; then log_find "CRIT" "x2tPath 疑似被改为 /bin/sh 或 /bin/bash"; bump crit fi fi}# ---------- T0-2:/info 是否暴露在公网/非本机 ----------check_info_exposure() { echo; echo "=== [T0-2] /info 暴露面核查 ===" # 反代/官方 nginx 配置中是否有允许 info 的痕迹 if [[ -d "$NGINX_CONF_DIR" ]]; then if grep -rqE 'location\s+/info' "$NGINX_CONF_DIR" 2>/dev/null; then log_find "HIGH" "nginx 存在 location /info 配置块,需确认是否被放开"; bump high fi if grep -rqE 'allow\s+(all|0\.0\.0\.0/0)' "$NGINX_CONF_DIR" 2>/dev/null; then log_find "HIGH" "nginx 存在 allow all/0.0.0.0 宽松规则"; bump high fi if grep -rqE '#\s*allow\s+127\.0\.0\.1' "$NGINX_CONF_DIR" 2>/dev/null; then log_find "MED" "发现被注释掉的 allow 127.0.0.1(常见于照做文档放开 info 页面)"; bump med fi else echo " nginx 配置目录不存在(可能是纯后端端口暴露场景),重点查端口监听"; fi # 本机监听:8000 是否直接监听在非回环地址 if ss -lntp 2>/dev/null | grep -qE ':8000\b'; then local bind bind=$(ss -lntp 2>/dev/null | grep ':8000\b' | awk '{print $4}' | tr '\n' ' ') echo " 8000 监听: $bind" if echo "$bind" | grep -qvE '127\.0\.0\.1|::1'; then log_find "CRIT" "DocService 内部端口 8000 监听在非回环地址,边界形同虚设"; bump crit fi fi}# ---------- T0-3:JWT / 示例页状态 ----------check_auth_hardening() { echo; echo "=== [T0-3] 认证与示例页核查 ===" local env_file="" for p in /etc/onlyoffice/documentserver/local.json /app/ds/setup/config/local.json /var/www/onlyoffice/Data/local.json; do [[ -f "$p" ]] && { env_file="$p"; break; } done if [[ -n "$env_file" ]] && command -v jq &>/dev/null; then local jwt jwt=$(jq -r '..|.jwt?.enabled?//empty' "$env_file" 2>/dev/null | head -1) echo " JWT enabled: ${jwt:-(unset)}" [[ "$jwt" != "true" ]] && { log_find "HIGH" "JWT 未启用($env_file),/info/config 等端点缺乏应用层鉴权"; bump high; } else echo " 未能定位 local.json,请手工确认 JWT_ENABLED / services.CoAuthoring.secret"; fi}# ---------- T1-1:异常产物(写入原语指纹) ----------check_artifacts() { echo; echo "=== [T1-1] 异常文件产物(近 ${HOURS_BACK}h) ===" local hits=0 for d in $TMP_DIRS "$CACHE_DIR"; do [[ -d "$d" ]] || continue echo " 扫描: $d" while IFS= read -r f; do [[ -z "$f" ]] && continue ((hits++)) log_find "MED" "异常产物: $f"; bump med done < <(find "$d" -maxdepth 3 -type f \( -name '*.txt' -o -name '*.sh' -o -name '*.py' -o -name 'pwn*' -o -name 'poc*' -o -name 'rce*' \) \ -mmin -$((HOURS_BACK*60)) -ls 2>/dev/null | head -200) done ((hits==0)) && echo " 未发现可疑产物 ✓"}# ---------- T1-2:Shell 级报错日志(历史利用残留,极可靠) ----------check_logs() { echo; echo "=== [T1-2] Converter 日志 Shell 报错特征 ===" [[ -d "$LOG_DIR" ]] || { echo " 日志目录不存在: $LOG_DIR"; return 0; } local n=0 for pat in 'missing file operand' 'command not found' "can't open" 'No such file or directory' '/bin/sh:' '/bin/bash:'; do while IFS= read -r line; do [[ -z "$line" ]] && continue ((n++)) log_find "HIGH" "日志异常: $line"; bump high done < <(grep -rhi --binary-files=text "$pat" "$LOG_DIR" 2>/dev/null | tail -50) done ((n==0)) && echo " 未发现 Shell 参数级报错 ✓"}# ---------- T1-3:ds 身份外连 ----------check_network() { echo; echo "=== [T1-3] ds 账户进程与外连 ===" if ps -u "$DS_UID" -o pid,comm,args --no-headers 2>/dev/null | grep -qiE 'sh|bash|python|curl|wget|nc|socat'; then log_find "HIGH" "ds 账户下存在可疑进程"; bump high ps -u "$DS_UID" -o pid,comm,args 2>/dev/null || true else echo " ds 账户无可疑进程 ✓"; fi if ss -antp 2>/dev/null | grep -qiE 'ESTAB.*ds|ESTAB.*(curl|wget|nc)'; then log_find "HIGH" "发现可疑外连"; bump high else echo " 无可疑 ESTABLISHED 外连 ✓"; fi}# ---------- 基线 / 校验 ----------do_baseline() { [[ $(file_exists "$RUNTIME_JSON") == "1" ]] || { echo "错误: $RUNTIME_JSON 不存在"; exit 2; } sha256sum "$RUNTIME_JSON" > "$BASELINE_FILE" stat -c '%a %U %G %s %Y' "$RUNTIME_JSON" >> "$BASELINE_FILE" echo "基线已写入 $BASELINE_FILE"; echo "$(cat "$BASELINE_FILE")"}do_verify() { [[ -f "$BASELINE_FILE" ]] || { echo "错误: 无基线,先执行 --baseline"; exit 2; } echo "=== 基线完整性校验 ===" local now; now=$(sha256sum "$RUNTIME_JSON" | awk '{print $1}') if grep -q "^$now " "$BASELINE_FILE"; then echo "runtime.json: 一致 ✓"; else echo "runtime.json: 不一致 ✗(可能被篡改)"; RC=3; fi}# ---------- 汇总与建议 ----------print_summary() { echo; echo "============================================" echo "核查汇总 crit=${SCORE[crit]} high=${SCORE[high]} med=${SCORE[med]} low=${SCORE[low]}" echo "============================================" if ((SCORE[crit]>0)); then echo ">>> 结论:发现严重异常,请按『隔离→留证→还原配置→杀进程重载→清产物→封网关→再升级』顺序处置"; fi echo; echo "建议命令(请人工确认后执行):" cat <<'EOF' # 1) 留证 cp /var/www/onlyoffice/Data/runtime.json /var/www/onlyoffice/Data/runtime.json.$(date +%F%H%M%S).bak # 2) 还原转换器配置(用备份或默认值),然后促其重载 supervisorctl restart ds:converter # 或 kill converter 子进程 # 3) 封暴露面(nginx) # location /info { deny all; return 403; } # 4) 建基线 ./oo-emergency-check.sh --baselineEOF}# ---------- 入口 ----------case "${1:-full}" in --baseline) do_baseline; exit 0;; --verify) do_verify; exit $RC;; --json) MODE="full";OUT="json";; --quick) MODE="quick";; esacecho "ONLYOFFICE Document Server 应急核查 | $(date) | host=$(hostname)"echo "================================================================"check_runtime_config[[ "$MODE" == "quick" ]] || { check_info_exposure; check_auth_hardening; check_artifacts; check_logs; check_network; }if [[ "$OUT" == "json" ]]; then printf '{"ts":"%s","host":"%s","crit":%d,"high":%d,"med":%d,"findings":[', "$(date -Iseconds)" "$(hostname)" "${SCORE[crit]}" "${SCORE[high]}" "${SCORE[med]}" first=1; for f in "${FINDINGS[@]}"; do ((first))||printf ','; first=0; printf '"%s"' "${f//\"/\\\"}"; done printf ']}\n'; else print_summary; fiexit $RC
参考链接:
1.https://www.onlyoffice.com/2.https://mp.weixin.qq.com/s/6WztkNCmxPKKkriZVlj4qg
本文技术推断部分基于公开披露信息与移动安全领域通用原理,具体漏洞细节以正式发布的研究报告为准。本文不构成任何攻击指导性内容,仅用于防御研究与安全教育,不含可直接武器化的完整利用代码。
免责声明:
本文章仅做网络安全技术研究使用!另利用网络安全007公众号所提供的所有信息进行违法犯罪或造成任何后果及损失,均由使用者自身承担负责,与网络安全007公众号无任何关系,也不为其负任何责任,请各位自重!公众号发表的一切文章如有侵权烦请私信联系告知,我们会立即删除并对您表达最诚挚的歉意!感谢您的理解!让我们一起为中国网络安全事业尽一份自己的绵薄之力!
—推荐阅读—
写作不易,分享快乐
期待你的 分享●点赞●在看●关注●收藏****
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:网络安全007 FLClover
FLClover《【漏洞通告】OnlyOffice Server前台RCE漏洞细节披露分析~【附自查脚本】》