时间 2010 年 8 月 15 日,区块 74638
后果 凭空产生 1844.67 亿 BTC(总量上限的约 8784 倍)
结局 ⭐ 5 小时内发布修复,链被重组,该交易作废
类别 C3 数值假设失效

一、背景:比特币怎么保证不凭空造币

比特币的全部货币政策,最终落在验证代码的一个不等式上:

对每一笔交易(coinbase 除外):

   ⭐ Σ 输出金额  ≤  Σ 输入金额

差额就是手续费。这一条如果失守,
【总量 2100 万】这个承诺就不存在了。

金额在比特币里用 int64 表示,单位是聪(1 BTC = 10⁸ 聪):

总量上限 2100 万 BTC = 2.1 × 10¹⁵ 聪
int64 上限            ≈ 9.22 × 10¹⁸ 聪

⟹ ⭐ 类型的容量比货币总量【大约四千倍】。
   这个余量看起来非常安全 —— 而它恰恰是问题所在。

二、漏洞在哪

当时的验证代码大致是:

int64 nValueOut = 0;
for (int i = 0; i < vout.size(); i++) {
    nValueOut += vout[i].nValue;      // ⚠️ 累加,没有任何范围检查
}
if (nValueIn < nValueOut)
    return error("transaction: value in < value out");

三个缺失叠在一起:

① 没有检查【单个输出】是否在合法范围内
      ⟹ 一个输出可以是 92,233,720,368 BTC
         (远超总量,但仍在 int64 的表示范围内)

② 累加时没有溢出检查
      ⟹ 两个巨大的正数相加,⭐ 溢出成负数

③ 于是那个不等式被绕过:
      nValueIn = 0.5 BTC (很小的一笔真实输入)
      nValueOut = 输出1 + 输出2 ⟹ 溢出 ⟹ 一个【负数】

      检查:nValueIn < nValueOut ?
            0.5 < (负数) ?  ⟹ ❌ 不成立
      ⟹ 检查通过,交易被接受

⭐⭐ 注意漏洞的位置:它不在"给用户转账"的业务逻辑里,它在负责阻止作弊的那段检查代码里

保护措施自己被绕过了,而不是保护措施没写。

三、攻击复盘

构造一笔交易:
   输入: 一笔正常的、金额很小的 UTXO(0.5 BTC)
   输出: 两笔,各 92,233,720,368.54 BTC

验证:
   nValueOut = 9223372036854275808 + 9223372036854275808
             = 18446744073708551616
   ⚠️ int64 的范围是 ±9223372036854775807
   ⟹ 溢出,回绕成一个负数

   检查 nValueIn < nValueOut
        50000000 < (负数)  ⟹ false
   ⟹ ✅ "输出没有超过输入",交易合法

结果:⭐ 凭空产生 184,467,440,737.09551616 BTC
      —— 是 2100 万上限的约 8784 倍

这笔交易进了区块 74638。

响应

2010-08-15 17:05 UTC   异常区块被打包
        约 1.5 小时后   社区在论坛发现并报告
        约 5 小时后     ⭐ 中本聪发布 0.3.10,加入范围检查
                        并呼吁矿工从 74638 之前的区块重新挖
        约 19 小时后    "正确"的链超过了含异常交易的链
                        ⟹ 那笔交易被永久作废

这是比特币历史上唯一一次凭空造币,也是唯一一次通过社会协调进行的链重组。

⚠️ 值得注意的是它成功的条件:2010 年全网算力极小、矿工数量少、社区集中在一个论坛上。同样的响应速度在今天不可能复现——这不是安全设计的成功,是当时网络规模小的副产品。

四、为什么没被发现

① “类型足够大"被当成了"不会溢出”

心智模型:
   "总量才 2.1×10¹⁵,int64 能装 9.2×10¹⁸,
    余量四千倍,怎么可能溢出?"

⚠️ 这个推理默认了一个前提:
   ⭐【所有出现在计算里的数,都是合法范围内的数。】

   而攻击者输入的数【不需要合法】——
   它只需要能被【类型表示】,就能进入计算。

⭐⭐ 这是本篇最重要的一句话你的算术运行在"类型的范围"上,不是"业务的范围"上。 只有当你显式检查过,两者才重合。

② 检查的顺序反了

当时的顺序:
   先累加 ⟹ 再检查总和

⭐ 正确的顺序:
   先检查每一项 ⟹ 再累加 ⟹ 再检查总和

⟹ 因为一旦非法的数值进入了累加,
   累加的结果就已经不可信了,
   ⚠️ 你拿一个已经被污染的结果去做检查,检查本身就是无效的。

③ 单元测试测的是"正常范围"

测试用例通常是:
   转 1 BTC、转 0.001 BTC、转 0 BTC、余额不足……
⚠️ 没有人测 "转 92,233,720,368 BTC"
   —— 因为"那不是一个有意义的金额"。

⭐ 而攻击者恰恰只对"没有意义的输入"感兴趣。

五、防御

① 同时校验单项与总和

这正是中本聪的修复:

static const int64 MAX_MONEY = 21000000 * COIN;
inline bool MoneyRange(int64 nValue) {
    return (nValue >= 0 && nValue <= MAX_MONEY);
}

int64 nValueOut = 0;
for (int i = 0; i < vout.size(); i++) {
    const CTxOut& txout = vout[i];
    if (!MoneyRange(txout.nValue))          // ⭐ ① 每一项
        return error("out of range");
    nValueOut += txout.nValue;
    if (!MoneyRange(nValueOut))             // ⭐ ② 每次累加后的中间结果
        return error("total out of range");
}

注意第 ② 条检查在循环【内部】——每加一次就检查一次,而不是最后检查一次。这样溢出永远不会真的发生,因为在能溢出之前就已经超出 MAX_MONEY 了。

② 在 Solidity 里

// ✅ 0.8.0 起,+ - * / % ** 默认检查溢出,溢出直接 revert
uint256 total = a + b;      // 溢出会 revert

// ⚠️ 但下面这些【仍然不检查】:
unchecked { total = a + b; }         // 显式关闭
assembly { total := add(a, b) }      // 汇编完全绕过
uint128 small = uint128(bigValue);   // ⭐⭐ 类型转换【静默截断】!
int256 signed = int256(hugeUint);    // ⭐ 可能变成负数

⭐⭐ 最容易被忽略的一条:Solidity 0.8 的溢出检查【不覆盖显式类型转换】。

uint128(x)x > type(uint128).max静默丢掉高位,不 revert。 用 SafeCast 库,或者自己写 require(x <= type(uint128).max)

③ 业务范围检查独立于类型范围

uint256 constant MAX_SUPPLY = 21_000_000e8;

function _checkAmount(uint256 v) internal pure {
    require(v <= MAX_SUPPLY, "amount out of business range");   // ⭐
}

即使类型不会溢出,也要检查业务范围。 类型范围是实现细节,业务范围才是你真正的不变量。

④ 校验代码里的算术要格外小心

⭐ 一条排序规则:

   业务代码算错  ⟹ 结果不对,通常有人会发现
   ⚠️ 校验代码算错 ⟹ 【防线消失,而一切看起来正常】

⟹ 所以对"用来做判断的那些算式",
   要用比业务代码更严格的标准去审。

六、⭐ 举一反三

核心命题

⭐⭐ 你的算术运行在类型的范围上,而不是业务的范围上——除非你显式地把两者对齐。

同一个盲区的其它形态

① 求和之前不检查单项

· 批量转账:先算 Σamount,再检查余额     ⟹ 见[第 9 篇](https://blog.ifcalm.org/posts/security/blockchain/09-bec-batch-overflow/)
· 投票统计:先累加权重,再比对门槛
· 手续费分账:先算总额,再按比例拆分
⭐ 判据:任何"先聚合、后校验"的地方,聚合本身就是攻击面。

② 乘法在除法之前 / 之后

// ⚠️ 先除后乘:精度损失,小额被吃掉
uint fee = amount / 10000 * feeRate;

// ✅ 先乘后除:精度好,⚠️ 但要确认乘法不会溢出
uint fee = amount * feeRate / 10000;

两种写法都可能出问题,必须显式判断哪个风险更大。

③ 舍入方向

⚠️ 舍入永远要朝着【对协议有利】的方向:

   用户存入 ⟹ 给他的份额【向下】取整
   用户提取 ⟹ 扣他的份额【向上】取整

⚠️ 反过来写,每一笔交易都会漏出一点点 ——
   攻击者用极小额反复调用,把池子慢慢抽干。
   ⭐ 这叫"舍入攻击",损失是累积的、无声的。

④ 减法下溢

// ⚠️ 0.8 之前:balance - amount 下溢成天文数字
// ✅ 0.8 之后:会 revert
// ⚠️ 但在 unchecked 块里,或者 int/uint 混用时,坑还在

require(balance >= amount, "insufficient");   // ⭐ 显式检查比依赖 revert 更好
balance -= amount;                             //    因为报错信息可读

⑤ 精度不一致

⚠️ USDC 是 6 位小数,WETH 是 18 位,WBTC 是 8 位。
   把它们的数量直接相加/比较,差 10¹² 倍。

⭐ 规则:任何跨代币的算术,
   都要先归一化到同一个精度基准,并把基准写进变量名。

⑥ 边界值

⭐ 对每个数值参数,测这七个值:
      0 / 1 / 最大值 / 最大值+1 / 最大值-1 / 中间值 / 负数(如果是 int)

⚠️ 特别注意"最大值"这一档 ——
   本案和[第 9 篇](https://blog.ifcalm.org/posts/security/blockchain/09-bec-batch-overflow/)都死在这里。

一条测试原则

⭐⭐ 单元测试应该由"攻击者会传什么"驱动,而不是由"用户会传什么"驱动。

正常测试:转 1 BTC、转 0.001 BTC、余额不足
攻击测试:转 type(uint).max、转 2²⁵⁵、转 0、
          传空数组、传 20 个相同地址、
          ⭐ 传两个相加恰好溢出的数

⟹ 后者才是防线的测试。

现代工具让这件事变得便宜:用 Foundry 的模糊测试(fuzzing),对每个数值参数自动生成极端值:

function testFuzz_NoOverflow(uint256 a, uint256 b) public {
    // ⭐ 让工具替你找边界,而不是靠想象
    vm.assume(a <= MAX_MONEY && b <= MAX_MONEY);
    assertLe(sum(a, b), 2 * MAX_MONEY);
}

七、本案小结

  • 凭空产生 1844.67 亿 BTC,是 2100 万上限的约 8784 倍。比特币历史上唯一一次凭空造币
  • ⭐⭐ 漏洞不在业务逻辑里,在负责阻止作弊的那段检查代码里——Σ输出 ≤ Σ输入 这个不等式中,左边的加法自己溢出成了负数。保护措施自己被绕过了。 -⭐ 根因:类型的范围(int64 ≈ 9.2×10¹⁸ 聪)远大于业务的范围(2.1×10¹⁵ 聪)。 攻击者输入的数不需要合法,只需要能被类型表示就能进入计算。
  • 检查顺序反了:先累加再检查总和。正确顺序是先检查每一项 → 再累加 → 每次累加后再检查中间结果——这样溢出根本不会发生。
  • 中本聪的修复正是这个结构MoneyRange() 同时校验单项和循环内的累加中间值。
  • ⚠️ 5 小时修复 + 链重组成功,是 2010 年网络规模小的副产品,不是安全设计的成功。同样的响应速度今天不可能复现。 -⭐ Solidity 0.8 的溢出检查不覆盖显式类型转换uint128(x) 在越界时静默截断,不 revert。unchecked 块和内联汇编同样不受保护。
  • 舍入方向必须朝着对协议有利的方向,反过来写会被极小额反复调用慢慢抽干——损失是累积的、无声的。 -⭐ 测试原则:单元测试应由"攻击者会传什么"驱动,而不是"用户会传什么"。 没人会测"转 922 亿 BTC",因为那不是有意义的金额——而攻击者恰恰只对没有意义的输入感兴趣。

思考题

  1. 精确计算:两个 92,233,720,368.54275808 BTC 的输出相加,在 int64 下的结果是多少?为什么它能通过 nValueIn < nValueOut 的检查?
  2. 为什么中本聪的修复要把 MoneyRange(nValueOut) 放在循环内部?放在循环外面还安全吗?
  3. 用自己的话解释"你的算术运行在类型的范围上,而不是业务的范围上"。给出一个 Solidity 的例子。
  4. 写一个 Solidity 函数,它在 0.8.20 下编译,但仍然会静默产生错误的结果。(提示:类型转换)
  5. amount / 10000 * feeRateamount * feeRate / 10000 各有什么问题?在什么条件下应该选哪个?
  6. 设计一个 ERC-4626 金库的存取函数,明确写出两个方向的舍入,并论证为什么反过来会被攻击。
  7. 一个协议同时处理 USDC(6 位)和 WETH(18 位)。请写出一个会导致 10¹² 倍定价错误的具体代码路径。
  8. 为本篇的验证逻辑写五条模糊测试的不变量(invariant)。
  9. “校验代码里的算术要用比业务代码更严格的标准去审”——请论证这句话,并在你熟悉的代码里找出三处"用于做判断的算式"。
  10. 比较本案和第 9 篇:一个是 2010 年的 C++,一个是 2018 年的 Solidity,相隔八年。它们在结构上是同一个错误吗?如果是,这说明了什么?