EIP-150 与 Gas 转发的 63/64 规则详解

理解调用深度攻击、每层调用保留 1/64 Gas 的机制及其对嵌套调用与 gas griefing 的影响

5 分钟阅读
EIP-150 与 Gas 转发的 63/64 规则详解

EIP-150 与 Gas 转发的 63/64 规则详解

目录


一、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)

  1. test_GasDecays63_64:调 recurse(20),从事件读各层 gasLeft,断言相邻层之比 ≈ 63/64;
  2. test_GasFormula:给定初始 gas,断言深度 N 的可用 gas ≈ 初始 × (63/64)^N(容差范围内,忽略调用开销);
  3. test_DepthLimitUnreachable:尝试 recurse 很深,论证 gas 在到 1024 前早已耗尽;
  4. test_UncheckedRefundSwallowed:用 Attacker(receive 必 revert)作为前出价者,调 bid(),断言新出价成功但退款静默失败(Attacker 余额没增加,合约却继续);
  5. test_SafeRefundReverts:同场景调 bidSafe(),断言整笔交易 revert(检查了返回值);
  6. test_OneSixtyFourthReserved:构造一个调用,验证调用后父级仍保留约 1/64 gas 可继续执行(如发个事件)。

Sepolia 部署与验证步骤

  1. 部署 Recurser,调 recurse(15),在 Etherscan 事件日志里读各层 gasLeft,制表验证 63/64 衰减;
  2. 部署 AuctionVuln 和 Attacker;
  3. Attacker 先出价,正常账户调 bid() 出更高价,观察 Attacker 退款失败但出价成功(漏洞);
  4. 换 bidSafe() 重试,观察交易 revert(修复)。

进阶挑战(可选)

  • 量化 gas griefing:写一个让子调用消耗到只剩 1/64 的攻击,看父级用这 1/64 能否完成关键操作;
  • 写注释回答:try/catch 为什么依赖 63/64 规则才能工作?如果调用能转发 100% gas,catch 块还有 gas 执行吗?

💬 评论