BUZZ AI Gateway
首页博客 › 当计费溢出

当计费溢出:一个整数如何把扣费变成充值

有一类 bug 模式在 LLM 网关、分销平台和自研计量层里反复出现。它不是越狱,也不是提示注入。它更古老、更平淡:计费路径上的一次整数溢出,由一个用户完全可控的参数触发。一旦发生,平台非但没有向用户多收钱 — 反而付钱给他们。这篇实战笔记讲清楚它为什么会发生、如何识别、以及如何把它关死。

发布于 2026-07-08 · 行业笔记 · 阅读时间约 7 分钟

这个 bug 长什么样

面向 AI API 的按量计费几乎总是落到同一个算术上:把一个数量乘以一个费率,取整,再把结果存成一个有符号整数 — 通常是一个代表某种货币小数单位或内部「quota」点数的 int64。这个数量通常是一个 token 计数。但喂给这个数量的若干请求参数,是由调用方提供的:

拿其中任意一个,把它放到荒谬的大,中间乘积就会无上限地增长。一个有符号 64 位整数的上限是 9,223,372,036,854,775,807 — 大约 9.22 × 1018。越过它,这个值不会被钳制、也不会报错;它会回绕成一个负数。在 C、Go、Java、Rust 的 release 构建以及许多其他环境里,有符号溢出要么静默回绕,要么被明确定义为回绕。你算出来的扣费额一旦变成负数,那个本该从用户余额里减去的结算步骤,就变成了到余额上。扣费成了充值。

攻击者根本不需要攻破你的鉴权、你的模型或你的网络。他们只是发来了一个你的算术容纳不下的数字。

一个具体的走查

假设某个图像端点按一个很小的单张图片 quota 计价,图片数量 n 作为乘数应用。简化之后,结算大致是这样:

// quota is an int64 of internal billing points
quota := perImageQuota * int64(n)          // n comes straight from the request
user.Balance -= quota                       // "charge" the user

n = 3 时,这是一次正常扣费。而当 n 在数十亿量级或更大时,perImageQuota * n 会冲过 int64 的上限并回绕成负数。此时 user.Balance -= (负数) 反而增加了余额。当乘数首先是一个浮点值时,故事同样上演 — 一个巨大的 float64 转换回 int64,在大多数语言里是未定义或回绕的行为,同样会轻易落到负数。

decimal 库本身并不能救你。一个 big-decimal 类型会欣然容纳一个远大于 int64 的数字 — 溢出于是被推迟到你调用类似 .IntPart()、或把 decimal 转回原生整数来存储的那一刻。如果这最后的收窄步骤没有边界检查,回绕就在那里发生。

为什么它能溜过评审

这个 bug 之所以在 AI 计费里特别常见,有三个原因:

  1. 这些乘数是新出现的。文本计费是 token 进、token 出 — 两者都由服务端测量。图片的 n、视频的 seconds、以及激进的预扣费估算器,把调用方可控的乘数引入了一条历史上只处理服务端测量数量的代码路径。校验没有跟上。
  2. 没人测试那个荒谬的输入。测试套件覆盖 n = 1n = 4,也许还有 n = 10。它们不会覆盖 n = 9999999999,因为没有任何正当用户会发这个。溢出住在输入空间里一块只有攻击者才会造访的区域。
  3. 在正常路径里,这个失败是隐形的。溢出不产生异常、不产生 500、不产生日志行。余额只是静静地往上漂,而不是往下扣。除非你对算出来的负扣费额报警,否则第一个信号是几周后才发现的对账差异。

如何判断你是否受影响

你可以在一个下午之内回答这个问题:

如何防御

可持续的修复是两个相互独立的层。任何一层单独存在都有帮助;两者合起来,关掉的是这一整类 bug,而不只是你找到的那一个实例。

1. 在每一个 quota 出口做钳制(纵深防御)

只要有一个算出来的扣费额被收窄成你要存储的那个整数,就让它经过同一个钳制函数,这个函数要:(a) 在收窄之前,用一个不会溢出的类型来做比较 — big-decimal 或一次饱和检查;(b) 把结果约束到 [0, MAX_SAFE]。扣费永远不为负;单个请求永远不会大到天文数字。选一个明显低于 int64 上限的天花板(比如 1015 个计费点数),这样后续的加法不会再次溢出,并把下限钉在零,这样任何算术路径都不可能通过扣费这条路给用户充值。

// One helper, used at every settlement/pre-charge exit.
func clampQuota(d Decimal) int64 {
    if d.LessThanOrEqual(Zero) { return 0 }        // never credit via a charge
    if d.GreaterThan(MaxSafe)  { return MaxSafe }  // compare before narrowing
    return d.RoundToInt64()
}

关键细节:比较必须在宽类型里发生,你向下转换之前。在转换之后再钳制就太晚了 — 回绕已经发生了。

2. 在边界校验输入

在那个荒谬的参数触及算术之前就把它拒掉。给 nsecondsmax_tokens 设一个合理的上限,请求超过时返回 400。把这些上限做成可配置的,这样运营可以在不发版的情况下调整它们。这一层还帮你省去第二个更安静的问题:一个仅仅是大但没溢出的参数,会产生一笔真实的、巨额的、合法的扣费,而你事后还得退款并道歉。

拦截什么缺失时的漏网模式
入口校验(对荒谬参数返回 400)在算术之前拦截攻击;也拦截合法但巨额的请求溢出到达数学运算;或产生一笔真实的天文数字扣费
在每个出口做 quota 钳制拦截流水线中任何位置的溢出,包括你遗漏的路径一条新的代码路径重新引入回绕

更大的教训

这提醒我们:AI 计费是一个安全面,而不只是一个记账细节。针对推理平台的有意思的攻击并不总是奇技淫巧。有时候漏洞不过是一个戴了新帽子的、四十年前的整数溢出,之所以够得着,是因为一个调用方可控的乘数被加进了一条过去只见过服务端测量数字的计费路径。把每一个触及扣费的请求参数都当作敌意输入:在边界处界定它,在出口处再钳制一次。两次廉价的检查,就把一起「扣费变充值」的事故变成了一个被拒绝的请求和一行日志。

Q1:这是越狱或提示注入问题吗?

不是。它是计费算术里一个经典的整数溢出,由一个过大但完全普通的 API 参数触发。它和模型的行为毫无关系。

Q2:用一个 big-decimal 库不就能防住吗?

只能防住一部分。big-decimal 能容纳那个过大的中间值,但溢出会在你把它收窄回原生 int64 来存储的那一刻重新出现 — 除非那个收窄步骤有边界检查。要在向下转换之前钳制。

Q3:我怎么检查自己是否已经中招?

查询你的计费账本,找出在付费模型上结算为小于等于零的任何扣费,并留意那些没有对应付款却上涨的用户余额。一笔算出来的负扣费就是它的特征。

可信赖的计费

BUZZ AI Gateway 是纯按 token 计费,在文本、图像和视频端点上都有边界约束、经过校验的计费。一把密钥、透明定价,两个方向都不会有意外。

创建账号
发布:2026-07-08
最近校订:2026-07-08