| 时间 | 2018 年 4 月 22 日 |
| 后果 | 凭空铸出 2 × 2²⁵⁵ 枚代币,交易所下架,价格归零 |
| 同期 | SMT(SmartMesh)等多个代币被同类漏洞打穿 |
| 类别 | C3 数值假设失效 |
一、背景:一个"省 Gas"的批量转账
BEC(BeautyChain)是 2018 年的一个 ERC-20 代币。它在标准 ERC-20 之外加了一个便利函数:一次性给多个地址转同样金额。
batchTransfer([地址1, 地址2, 地址3], 100)
⟹ 三个人各收到 100 个代币,发送方扣 300
⭐ 本意是省 Gas —— 一笔交易顶三笔。
二、漏洞在哪
function batchTransfer(address[] _receivers, uint256 _value)
public whenNotPaused returns (bool)
{
uint cnt = _receivers.length;
// ⚠️⚠️ 就是这一行 —— 裸的乘法
uint256 amount = uint256(cnt) * _value;
require(cnt > 0 && cnt <= 20);
require(_value > 0 && balances[msg.sender] >= amount);
balances[msg.sender] = balances[msg.sender].sub(amount); // ✅ SafeMath
for (uint i = 0; i < cnt; i++) {
balances[_receivers[i]] = balances[_receivers[i]].add(_value); // ✅ SafeMath
Transfer(msg.sender, _receivers[i], _value);
}
return true;
}
⭐ 看清楚这段代码的讽刺之处:
balances[...].sub(amount) ⟹ 用了 SafeMath ✅
balances[...].add(_value) ⟹ 用了 SafeMath ✅
uint256(cnt) * _value ⟹ ⚠️⚠️ 裸的,没有 SafeMath
⟹ 团队【知道】要防溢出,也【引入了】SafeMath 库,
⭐ 但漏掉了一行。
⭐而漏掉的这一行,恰好是那个用来做检查的中间量。
和第 8 篇一模一样:溢出发生在"用于判断的算式"里,而不是"用于转账的算式"里。
三、攻击复盘
攻击者的输入极其简单:
_receivers = [地址A, 地址B] ⟹ cnt = 2
_value = 2²⁵⁵
= 578960446186580977117854925043439539266
34992332820282019728792003956564819968
逐行推演:
① amount = 2 × 2²⁵⁵ = 2²⁵⁶
⚠️ uint256 的模是 2²⁵⁶
⟹ 2²⁵⁶ mod 2²⁵⁶ = ⭐ 0
② require(cnt > 0 && cnt <= 20)
⟹ 2 在范围内 ✅
③ require(_value > 0 && balances[msg.sender] >= amount)
⟹ _value 是个天文数字,> 0 ✅
⟹ balances[攻击者] >= 0 ⭐ 恒真!攻击者余额是 0 也能通过
✅ 通过
④ balances[攻击者] = balances[攻击者].sub(0)
⟹ ⭐ 一分钱没扣
⑤ 循环:
balances[A] = 0.add(2²⁵⁵) = 2²⁵⁵
balances[B] = 0.add(2²⁵⁵) = 2²⁵⁵
⟹ ⭐ 两个地址各获得 2²⁵⁵ 枚代币
净效果:攻击者不需要持有任何代币,一笔交易凭空造出了远超总发行量的代币。
后果
① 攻击者把代币抛向交易所
② 价格瞬间崩塌
③ 交易所紧急暂停 BEC 交易与提现
④ ⚠️ 代币事实上归零 —— 因为"总量"这个概念已经失去意义
⭐ 注意这里的损失形态和别的案例不同:
没有一分钱被"从合约里偷走"。
被摧毁的是【这个代币的价值本身】。
⚠️ 同一时期,SMT(SmartMesh)等多个代币被完全相同的模式打穿——因为这段 batchTransfer 代码被大量项目互相复制。
四、为什么没被发现
① 防护措施用得不一致,等于没用
⭐ 安全库的价值来自【覆盖率】,不是【存在性】。
"我们用了 SafeMath" ——
⚠️ 这句话在审计报告里看起来是一条正面结论,
但它没有回答真正的问题:
⭐【是不是【每一处】算术都用了?】
⟹ 99% 的覆盖率和 0% 的覆盖率,
在攻击者眼里是一样的 ——
他只需要那 1%。
② batchTransfer 不是标准函数,所以不在审计清单上
ERC-20 的审计有成熟的检查清单:
transfer / approve / transferFrom / 授权竞态 / 返回值……
⚠️ 而 batchTransfer 是【项目自己加的】。
⭐ 自定义函数没有现成的清单可对照,
而它们恰恰是最可能出问题的部分。
⟹ 一条经验:审计时把函数分成
"标准的" 和 "自己加的" 两堆,
⭐ 后者要花更多时间。
③ >= amount 这个检查看起来天经地义
require(balances[msg.sender] >= amount)
⭐ 这行代码在任何 code review 里都会被一眼扫过 ——
"检查余额,没问题"。
⚠️ 没有人会停下来问:
"如果 amount 本身就是错的呢?"
⟹ 检查的【正确性】依赖于被检查的量的【正确性】。
这一层依赖关系,几乎从来不被显式审视。
五、防御
① 用 0.8+ 的默认检查(但要知道它不管什么)
// ✅ Solidity 0.8.0 起,这行溢出会 revert
uint256 amount = cnt * _value;
⚠️ 但下面这三处,0.8 仍然不管:
// ⚠️ ① unchecked 块 —— 为省 Gas 显式关闭检查
unchecked { amount = cnt * _value; }
// ⚠️ ② 内联汇编 —— 完全绕过编译器
assembly { amount := mul(cnt, value) }
// ⚠️⚠️ ③ 显式类型转换 —— 【静默截断,不 revert】
uint128 small = uint128(bigValue); // 高位直接丢弃
uint32 ts = uint32(block.timestamp); // 2106 年回绕
int256 s = int256(hugeUint); // 可能变成负数
⭐ 第 ③ 条是今天最常见的溢出来源,因为很多人以为"升级到 0.8 就没有溢出问题了"。用 OpenZeppelin 的 SafeCast:
using SafeCast for uint256;
uint128 small = bigValue.toUint128(); // ⭐ 越界会 revert
② 结构上消除这个中间量
⭐ 最好的修复不是"给这行加检查",而是让这个危险的中间量根本不存在:
function batchTransfer(address[] calldata to, uint256 value) external {
require(to.length > 0 && to.length <= 20, "bad count");
require(value > 0, "zero value");
for (uint256 i = 0; i < to.length; i++) {
// ⭐ 每一笔都走标准 transfer 的完整检查
_transfer(msg.sender, to[i], value);
}
}
⭐ 好处:批量函数退化成"循环调用单笔转账",不引入任何新的算术,也就不可能引入新的算术漏洞。它复用了已经被审计过的路径。
⭐ 一条通用的设计原则:批量操作应该是单笔操作的循环,而不是一个"优化过的等价实现"。 每一次"为了省 Gas 而重写的等价逻辑",都是一次新的漏洞机会。
③ 检查前置于计算
// ⚠️ 先算后检查 —— 算的时候就可能出事
uint256 amount = cnt * value;
require(balances[msg.sender] >= amount);
// ✅ 先检查输入的合理范围,再计算
require(value <= MAX_REASONABLE_VALUE, "value too large"); // ⭐
require(cnt <= 20, "too many");
uint256 amount = cnt * value;
④ 不变量测试
// ⭐ 用 Foundry 的 invariant 测试,让工具反复随机调用你的函数,
// 每次之后检查这条永远该成立的性质:
function invariant_totalSupplyMatchesSumOfBalances() public {
assertEq(token.totalSupply(), sumAllBalances());
}
⭐ 这条不变量能自动抓住本案:batchTransfer 凭空造币之后,totalSupply 和余额总和立刻对不上。而写这条不变量只需要三行。
六、⭐ 举一反三
核心命题一:一致性
⭐⭐ 防护措施的价值等于它的覆盖率,不是它的存在性。
同一条规律的其它形态:
· 大部分函数有 nonReentrant,漏了一个 ⟹ [第 1 篇](https://blog.ifcalm.org/posts/security/blockchain/01-the-dao/)的跨函数重入
· 大部分外部调用检查了返回值,漏了一个
· 大部分敏感函数有 onlyOwner,漏了一个 ⟹ [第 4 篇](https://blog.ifcalm.org/posts/security/blockchain/04-parity-multisig-1/)
· 大部分地址参数检查了非零,漏了一个 ⟹ [第 7 篇](https://blog.ifcalm.org/posts/security/blockchain/07-nomad-bridge/)
⭐ 检查方法:不要问"我们用了 X 吗",
要问"有哪几处【没有】用 X?为什么?"
⟹ 后者能列出清单,前者只能得到一个"用了"。
工具化:用 Slither / Semgrep 写一条规则,扫出所有裸算术、所有无修饰符的 external 函数、所有未检查的返回值。让"漏掉一处"变成机器能发现的事。
核心命题二:自定义函数的风险溢价
⭐ 把合约里的函数分成两类:
① 标准/已被广泛审计的(OpenZeppelin 的实现、ERC 标准接口)
⟹ 风险低,因为有成千上万双眼睛看过
② ⚠️ 项目自己加的便利函数、优化函数、"聚合"函数
⟹ 风险高,而且【没有现成的检查清单】
⟹ 审计时间应该向 ② 倾斜,
而实践中往往相反 —— 因为 ① 更容易照着清单打勾。
⭐ 一条经验判据:任何名字里带 batch / multi / bulk / all / optimized 的函数,都值得单独花时间。
核心命题三:检查的正确性依赖被检查量的正确性
require(balance >= amount)
⭐ 这行代码保证的是 "balance ≥ amount" 这个【关系】,
它完全不保证 amount 【本身是对的】。
⟹ 一般化:
每一个 require,都建立在它所引用的变量的正确性之上。
⚠️ 如果那些变量来自计算,你就必须先保证那个计算是对的。
⟹ 追溯每个 require 里的变量,一直追到
"外部输入" 或 "已被验证的存储" 为止。
⭐ 中间的每一步计算,都是这个 require 的一部分。
一条能抓住整类问题的测试
⭐ 给任何一个代币合约写这四条不变量,让 fuzzer 去跑:
① totalSupply == Σ balances
② 除 mint/burn 外,任何操作后 totalSupply 不变
③ 任何操作后,没有账户余额凭空增加
④ ⭐ 转账前后,(发送方减少量) == (接收方增加量)
⚠️ 本案违反 ①②③④ 全部四条。
而这四条加起来不到 20 行代码。
七、本案小结
- 一行裸乘法:
uint256 amount = uint256(cnt) * _value;——cnt=2、_value=2²⁵⁵时amount溢出成 0。 - ⭐⭐ 最讽刺的地方:合约通篇在用 SafeMath(
.sub()、.add()都用了),唯独漏了这一行。团队知道要防溢出、也引入了库,但漏了一处。 - 和第 8 篇结构完全相同:溢出发生在用于判断的算式里,不是用于转账的算式里。相隔八年、换了语言,同一个错误。
balances[msg.sender] >= 0恒真——攻击者余额为 0 也能通过检查,扣款.sub(0)一分钱没扣,两个地址各得 2²⁵⁵ 枚。- 损失形态特殊:没有一分钱被从合约里偷走,被摧毁的是这个代币的价值本身。同期 SMT 等多个代币因复制同一段代码被打穿。 -⭐ 防护措施的价值等于覆盖率,不是存在性。 “我们用了 SafeMath"没有回答真正的问题——99% 覆盖率和 0% 在攻击者眼里一样,他只需要那 1%。 该问的是”哪几处没有用?为什么?"
- ⚠️
batchTransfer是项目自己加的函数,不在 ERC-20 的标准审计清单上。 凡是名字带batch/multi/bulk/optimized的,都值得单独花时间。 -⭐ 最好的修复不是给那行加检查,而是让危险的中间量不存在:把批量函数写成"循环调用已被审计的单笔_transfer",不引入任何新算术。 - ⚠️ Solidity 0.8 之后仍会静默溢出的三处:
unchecked块、内联汇编、显式类型转换(uint128(x)越界静默截断,不 revert)。第三条是今天最常见的溢出来源。 - 四条不变量(不到 20 行)能自动抓住本案:
totalSupply == Σbalances在攻击后立刻对不上。
思考题
- 逐行推演
cnt=2、_value=2²⁵⁵的执行过程,写出每一步amount和各账户余额的值。 - 如果攻击者用
cnt=4、_value=2²⁵⁴,结果一样吗?还有哪些(cnt, value)组合能让amount恰好为 0? - 为什么说"溢出发生在用于判断的算式里"比"发生在转账的算式里"更危险?
- 把
batchTransfer改成"循环调用_transfer"。这个写法多花了多少 Gas?你认为这个代价值得吗? - 写一段 Solidity,它在 0.8.20 下编译通过、不 revert,但结果是错的。(提示:类型转换)
uint32(block.timestamp)会在哪一年回绕?如果一个协议用它做时间比较,届时会发生什么?- 为本篇提到的四条代币不变量写出 Foundry 的 invariant 测试代码。
- 用 Slither 或 Semgrep 写一条规则,扫出项目里所有"没有被 SafeMath 或 0.8 检查保护的算术"。你会怎么定义这个模式?
- “每一个 require 都建立在它所引用变量的正确性之上。“请挑一个你写过的 require,把它引用的每个变量追溯到外部输入或已验证的存储。
- BEC 和 SMT 被同一个模式打穿,因为代码被互相复制。这和第 2 篇的"fork 一份安全代码不等于得到安全代码"是同一个问题吗?请说明异同。