EIP-150 与 Gas 转发的 63/64 规则详解
目录
- 一、EIP-150 是什么
- 二、要解决的问题:调用深度攻击
- 三、63/64 规则详解
- 四、为什么能防住调用深度攻击
- 五、对 CALL* 操作码的改动
- 六、各种影响
- 七、与 gas stipend 的关系
- 八、现状
- 九、总结
- 十、动手练习项目:Gas 转发可视化 GasForwardLab
一、EIP-150 是什么
EIP-150 于 2016 年 3 月提出、7 月实施。它从根本上改变了嵌套合约调用中 Gas 的分配方式,引入 63/64 规则来防御一个关键漏洞——调用深度攻击(Call Depth Attack)。
二、要解决的问题:调用深度攻击
EIP-150 之前,调用者可以把全部 Gas 转发给被调者。结合以太坊的 1024 层调用深度上限,这制造了一个攻击向量:
- 恶意者先递归调用自己 1023 次,把调用栈堆到接近上限;
- 然后再调用目标合约——这最后一次调用因触及深度上限而失败;
- 关键操作(如退款逻辑)被静默 revert。
文章用一个拍卖合约举例:竞拍者可以利用这个漏洞,让自己成为最高出价者,同时阻止前一个出价者收到退款(退款那次调用因深度耗尽而失败,但若代码没检查返回值,主流程照常进行)。
三、63/64 规则详解
3.1 核心原则
一次调用消耗的 Gas 不能超过父级 Gas 的 63/64。
也就是说,每次调用都自动保留 1/64 的可用 Gas 给父级,无法全部转发出去。
3.2 Gas 计算公式
在任意栈深度 N 可用的 Gas 呈指数衰减:
栈深度 N 处可用 Gas = 初始可用 Gas × (63/64)^N
3.3 实际例子
初始 3,000 gas:
- 深度 10:3,000 × (63/64)¹⁰ ≈ 2,562 gas
- 深度 20:3,000 × (63/64)²⁰ ≈ 2,189 gas
初始 1,000 gas:
- 深度 0:1,000 × (63/64)⁰ = 1,000 gas
- 保留给父级:1,000 − (63/64 × 1,000) = 15 gas
四、为什么能防住调用深度攻击
指数衰减让深层调用链变得不切实际。每多嵌套一层,Gas 就快速减少,自然把递归深度限制在远低于理论值 1024 的地方。
要堆到深度 1023,需要的初始 Gas 会是天文数字((64/63)^1023 倍),计算上不可行。所以攻击者无法靠堆栈深度来强制目标调用失败。
五、对 CALL* 操作码的改动
EIP-150 还改了 CALL、STATICCALL、DELEGATECALL:
提供的 gas 现在是最大值而非严格值。
之前如果调用者指定的 gas 超过实际可用,调用会失败。现在它会用实际可用的 gas(上限为请求值)继续执行。这让”指定 gas 但可用不足”的情况更宽容。
六、各种影响
- 对嵌套调用:深层交互逐层受限,开发者不能指望把全部 gas 一路转发;
- 对 gas griefing:大幅减少(但未消除)gas 耗尽攻击——攻击者更难精心构造调用序列来榨干目标 gas;
- 对合约设计:设计含外部调用(尤其退款机制、依赖调用成功的关键状态变更)时,必须考虑 gas 保留——永远检查外部调用的返回值,别假设它一定成功。
七、与 gas stipend 的关系
63/64 规则独立于 gas stipend(如 transfer/send 给 fallback 的 2,300 gas 补贴)运作。63/64 规则适用于所有通过合约调用的 gas 转发。
实践提醒:因为 1/64 保留,外层在调用后仍有少量 gas 可以处理失败(这也是 try/catch 能工作的基础——外层保留的 gas 足以执行 catch 分支)。
八、现状
虽然以太坊技术上仍保留 1024 层调用深度上限,但由于 EIP-150 的指数 gas 约束,这个上限实际上无法达到。协议升级成功地中和了调用深度攻击,而无需在实践中真正触发硬上限。
九、总结
- EIP-150 引入 63/64 规则,防御调用深度攻击;
- 每次调用自动保留 1/64 gas,无法全部转发;
- 可用 gas 按 (63/64)^N 指数衰减,深层调用快速枯竭;
- CALL* 的 gas 参数从”严格值”变”最大值”;
- 影响:嵌套调用受限、缓解 gas griefing、必须检查调用返回值;
- 1024 深度上限因 gas 约束实际不可达。
十、动手练习项目:Gas 转发可视化 GasForwardLab
项目目标
实现一组递归调用合约,实测每层 gas 如何按 63/64 衰减,复现”调用深度攻击为何失效”,并演示”不检查返回值”导致的退款被吞。部署到 Sepolia。
合约要求
1. Recurser.sol
recurse(uint256 depth) external returns (uint256 gasAtThisLevel):记录进入时的gasleft(),若 depth > 0 则调用自己recurse(depth-1),返回本层 gasleft;- 发出事件
Level(uint256 depth, uint256 gasLeft)记录每层剩余 gas。
2. AuctionVuln.sol(复现攻击场景)
bid() external payable:新出价者成为最高价,用低层 call 给前一个出价者退款但不检查返回值(previousBidder.call{value: refund}(""));bidSafe():同样逻辑但检查返回值,失败则 revert(对照修复)。
3. Attacker.sol
- 一个
receive()故意消耗大量 gas 或 revert 的合约,模拟”让退款失败”; - 但要演示 63/64 规则下,单纯堆深度无法可靠地让特定子调用失败。
测试要求(Foundry)
test_GasDecays63_64:调recurse(20),从事件读各层 gasLeft,断言相邻层之比 ≈ 63/64;test_GasFormula:给定初始 gas,断言深度 N 的可用 gas ≈ 初始 × (63/64)^N(容差范围内,忽略调用开销);test_DepthLimitUnreachable:尝试 recurse 很深,论证 gas 在到 1024 前早已耗尽;test_UncheckedRefundSwallowed:用 Attacker(receive 必 revert)作为前出价者,调 bid(),断言新出价成功但退款静默失败(Attacker 余额没增加,合约却继续);test_SafeRefundReverts:同场景调 bidSafe(),断言整笔交易 revert(检查了返回值);test_OneSixtyFourthReserved:构造一个调用,验证调用后父级仍保留约 1/64 gas 可继续执行(如发个事件)。
Sepolia 部署与验证步骤
- 部署 Recurser,调
recurse(15),在 Etherscan 事件日志里读各层 gasLeft,制表验证 63/64 衰减; - 部署 AuctionVuln 和 Attacker;
- Attacker 先出价,正常账户调 bid() 出更高价,观察 Attacker 退款失败但出价成功(漏洞);
- 换 bidSafe() 重试,观察交易 revert(修复)。
进阶挑战(可选)
- 量化 gas griefing:写一个让子调用消耗到只剩 1/64 的攻击,看父级用这 1/64 能否完成关键操作;
- 写注释回答:try/catch 为什么依赖 63/64 规则才能工作?如果调用能转发 100% gas,catch 块还有 gas 执行吗?