合约怎么知道自己站在调用栈多深处:撤回的 EIP-3 与浅栈攻击 图 1
合约怎么知道自己站在调用栈多深处:撤回的 EIP-3 与浅栈攻击 · 图 1

以太坊早期提案列表的第三位躺着一份从未实现的小提案:EIP-3,2015 年 11 月 19 日创建,作者 Martin Holst Swende,状态 Withdrawn(已撤回),内容只有半页——给 EVM 加一条叫 CALLDEPTH 的指令,返回当前调用栈的剩余可用深度。指令不起眼,它盯着的问题却不小:合约调用深度有硬上限(提案写作时的限制是 256 层),而这个上限当时对合约自己不可见——合约能算 Gas,却不知道自己站在栈的哪一层,攻击者可以在这一点上做文章。

浅栈攻击怎么发生

提案动机段给的剧本是这样的:攻击者先制造一段合适的调用深度——比如递归调用一串合约把栈垫到接近上限——然后在栈底的最后一次调用里触发目标合约。目标合约若需要再向下调用别的合约,就会撞上天花板直接失败。危险在于失败的形态:调用失败并不炸掉整笔交易,它只体现为返回一个失败标志。如果目标合约没有检查这个返回值,后续逻辑就基于「调用成功了」的错觉继续跑。提案举的例子是一枚按块付酬的合约:它先调用两个外部合约做完事、再给调用者发钱;恶意用户把栈垫深后接入,两次内部调用静默失败,钱照拿不误。这类「垫栈再触发」的打法,社区也叫它浅栈攻击(shallow stack attack)。

合约怎么知道自己站在调用栈多深处:撤回的 EIP-3 与浅栈攻击 图 2
合约怎么知道自己站在调用栈多深处:撤回的 EIP-3 与浅栈攻击 · 图 2

提案当时的两种防御

EIP-3 写于 2015 年末,那时合约能用的防御只有两条,提案都列了:其一,检查每次外部调用的返回值,失败就中止——朴素、有效,但依赖开发者纪律,漏检一处就留一处伤疤;其二,实验性地探测当前深度——用越来越深的调用试探直到失败,从而估算剩余空间,提案点名了 Piper Merriam 写的一个测试库,同时承认这条路「相当费 Gas」。CALLDEPTH 的提议就是第三条路:深度信息本来就躺在 EVM 执行状态里,只是没暴露给字节码,加一条返回它的指令,防御成本从几十次试错降到一条廉价操作码。提案的实现栏后来一直是 Not implemented。

为什么撤回,以及问题怎么被接管

EIP-3 没有轰轰烈烈的反对记录,它输给了两件大事。第一件是 2016 年的 Tangerine Whistle 升级:EIP-150 在重定价若干操作码的同时,引入了调用只转发 63/64 可用 Gas 的规则,并借此把调用栈深度从「256 层占满即败」改造为受 Gas 约束的结构——想垫满 1024 层栈需要真金白银的 Gas 预算,攻击的固定成本被抬高,浅栈攻击从「人人都能试」降级为「要看你舍不舍得烧钱」。第二件是安全惯例的成熟:检查外部调用返回值在 Solidity 里从可选变成默认(低层调用返回布尔、高层转账直接 revert),加上重入守卫、检查-效果-交互模式的普及,让「调用静默失败还继续跑」这类剧本越来越难成立。到 EIP-150 时代,CALLDEPTH 想解决的问题已经被别的机制间接按住,提案自然滑向撤回。

一条直觉账

值得记住的是失败模式的不对称:浅栈攻击里输家不是攻击者而是目标合约的守约方,因为攻击者本来就想让那两次调用失败。这提示了一个通用原则——任何「调用别人」的代码路径,都要假设失败态是被别人精心挑选过的,而不是随机小概率事件。

快速问答

问:今天 EVM 的调用栈上限是多少? 答:1024 层,且受 63/64 Gas 转发规则约束;256 是 EIP-3 写作时代的历史数字。

问:合约今天能查自己的调用深度吗? 答:没有直接指令。可用 Gas 余量是一个粗略代理指标,深度本身仍不可见。

问:浅栈攻击今天还现实吗? 答:成本高了很多但概念未死,评估依赖深度的合约逻辑时它仍是检查清单条目。

常见误区

一是把「栈」混为一谈:EVM 里操作数栈(每帧最多 1024 项)与调用栈(帧数,上限 1024)是两回事,EIP-3 原文特意提醒过这个区分;二是以为加了 CALLDEPTH 攻击就消失——检查返回值仍是第一防御,深度感知只是补充;三是把 63/64 规则说成「为了防浅栈攻击而设计」,它的首要目标是限制深层嵌套调用的 Gas 转发攻击面,对浅栈攻击的效果是顺带的成本抬升。

风险提示:本文为 EVM 机制与合约安全科普,不构成投资建议;合约交互风险请以代码审计与官方文档为准。