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