当计费溢出:一个整数如何把扣费变成充值
有一类 bug 模式在 LLM 网关、分销平台和自研计量层里反复出现。它不是越狱,也不是提示注入。它更古老、更平淡:计费路径上的一次整数溢出,由一个用户完全可控的参数触发。一旦发生,平台非但没有向用户多收钱 — 反而付钱给他们。这篇实战笔记讲清楚它为什么会发生、如何识别、以及如何把它关死。
这个 bug 长什么样
面向 AI API 的按量计费几乎总是落到同一个算术上:把一个数量乘以一个费率,取整,再把结果存成一个有符号整数 — 通常是一个代表某种货币小数单位或内部「quota」点数的 int64。这个数量通常是一个 token 计数。但喂给这个数量的若干请求参数,是由调用方提供的:
- 图片数量(
n),用在图像生成请求上 — 它是单张图片价格上的一个乘数。 - 视频时长或视频生成请求上的
seconds— 它是每秒价格上的一个乘数。 - max_tokens /
max_output_tokens,某些预扣费估算器会把它直接折算进预先扣除的额度里。
拿其中任意一个,把它放到荒谬的大,中间乘积就会无上限地增长。一个有符号 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 计费里特别常见,有三个原因:
- 这些乘数是新出现的。文本计费是 token 进、token 出 — 两者都由服务端测量。图片的
n、视频的seconds、以及激进的预扣费估算器,把调用方可控的乘数引入了一条历史上只处理服务端测量数量的代码路径。校验没有跟上。 - 没人测试那个荒谬的输入。测试套件覆盖
n = 1、n = 4,也许还有n = 10。它们不会覆盖n = 9999999999,因为没有任何正当用户会发这个。溢出住在输入空间里一块只有攻击者才会造访的区域。 - 在正常路径里,这个失败是隐形的。溢出不产生异常、不产生 500、不产生日志行。余额只是静静地往上漂,而不是往下扣。除非你对算出来的负扣费额报警,否则第一个信号是几周后才发现的对账差异。
如何判断你是否受影响
你可以在一个下午之内回答这个问题:
- 列举所有调用方可控的计费输入。每一个最终被乘进扣费额的请求字段 —
n、seconds/duration、max_tokens、批量大小,以及任何类似的东西。 - 把每一个都追踪到最终的整数。顺着这个值走过预扣费估算和请求后结算。记下每一个把巨大的中间值收窄成原生整数的地方(一次强制转换、一次
IntPart()、一次截断)。 - 在每个收窄点问一个问题:如果这个值是 1019,输出会是什么?如果答案是「一个负数」或「未定义」,那你就有这个 bug。
- 审计你的账本,查找负扣费。查询历史计费记录,找出在付费模型上算出来小于等于零的任何扣费。一份干净的历史令人安心;哪怕命中一条,就是一起正在发生的事故。
如何防御
可持续的修复是两个相互独立的层。任何一层单独存在都有帮助;两者合起来,关掉的是这一整类 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. 在边界校验输入
在那个荒谬的参数触及算术之前就把它拒掉。给 n、seconds 和 max_tokens 设一个合理的上限,请求超过时返回 400。把这些上限做成可配置的,这样运营可以在不发版的情况下调整它们。这一层还帮你省去第二个更安静的问题:一个仅仅是大但没溢出的参数,会产生一笔真实的、巨额的、合法的扣费,而你事后还得退款并道歉。
| 层 | 拦截什么 | 缺失时的漏网模式 |
|---|---|---|
| 入口校验(对荒谬参数返回 400) | 在算术之前拦截攻击;也拦截合法但巨额的请求 | 溢出到达数学运算;或产生一笔真实的天文数字扣费 |
| 在每个出口做 quota 钳制 | 拦截流水线中任何位置的溢出,包括你遗漏的路径 | 一条新的代码路径重新引入回绕 |
更大的教训
这提醒我们:AI 计费是一个安全面,而不只是一个记账细节。针对推理平台的有意思的攻击并不总是奇技淫巧。有时候漏洞不过是一个戴了新帽子的、四十年前的整数溢出,之所以够得着,是因为一个调用方可控的乘数被加进了一条过去只见过服务端测量数字的计费路径。把每一个触及扣费的请求参数都当作敌意输入:在边界处界定它,在出口处再钳制一次。两次廉价的检查,就把一起「扣费变充值」的事故变成了一个被拒绝的请求和一行日志。
Q1:这是越狱或提示注入问题吗?
不是。它是计费算术里一个经典的整数溢出,由一个过大但完全普通的 API 参数触发。它和模型的行为毫无关系。
Q2:用一个 big-decimal 库不就能防住吗?
只能防住一部分。big-decimal 能容纳那个过大的中间值,但溢出会在你把它收窄回原生 int64 来存储的那一刻重新出现 — 除非那个收窄步骤有边界检查。要在向下转换之前钳制。
Q3:我怎么检查自己是否已经中招?
查询你的计费账本,找出在付费模型上结算为小于等于零的任何扣费,并留意那些没有对应付款却上涨的用户余额。一笔算出来的负扣费就是它的特征。
最近校订:2026-07-08