一段看似平平无奇的代码,行为完全不同
四个看起来很像的调用:
| |
它们都是"调另一个合约"——但存储上下文、msg.sender、能否改状态完全不同。用错任何一个轻则无效,重则被攻击者利用整个协议归零。
本文把这四种调用的差异系统讲清楚,并配代码例子说明各自用法和风险。
一、四者本质对比
EVM 中合约调用的"上下文"由三个核心要素组成:
- 代码(Code):要执行哪段字节码?
- 存储(Storage):读写哪个合约的存储?
msg.sender/msg.value:调用者身份是谁?
四种调用对这三个要素的处理:
| 执行的代码 | 操作的存储 | msg.sender | msg.value | 能否改状态 | |
|---|---|---|---|---|---|
call | target | target | caller | 可传值 | ✓ |
callcode | target | caller | caller | 可传值 | ✓ |
delegatecall | target | caller | 原发起者 | 原 value | ✓ |
staticcall | target | target | caller | 不可传值 | ✗ 只读 |
callcode 已经被官方标记为 deprecated(功能被 delegatecall 替代),实战中几乎不用。重点是 call / delegatecall / staticcall 三种。
二、call:最朴素的"远程调用"
| |
call 的语义是——让对方合约执行对方的代码、操作对方的存储。
- 执行
target的代码 - 改
target的存储 - 在
target看来,msg.sender = Caller这个调用方 - 可以转账(
{value: x})
这就是一次"标准的合约间调用"。
典型场景
- 转账给一个普通地址:
recipient.call{value: amount}("") - 调用未知接口:当目标合约接口不固定时(如 ERC-721 的
onERC721Received钩子),用 call
注意点
- call 失败不会自动 revert ——返回
bool ok,要自己 require - 重入风险:call 触发外部代码执行,可能再调回来——经典 The DAO 漏洞就是这个
三、delegatecall:用别人的代码、操作自己的存储
| |
delegatecall 的语义是——借用对方的字节码逻辑,但操作的是自己的存储和上下文。
- 执行
target的代码 - 改 caller 的存储(不是 target 的!)
msg.sender是原始调用者(不是 caller)msg.value也是原始的
典型场景:代理升级模式
可升级合约(OpenZeppelin Proxy / UUPS)的根基就是 delegatecall:
Proxy 把所有调用 delegatecall 给 Logic 合约——Logic 操作的存储是 Proxy 的,所以升级 Logic 不影响数据。
致命陷阱:存储槽冲突
delegatecall 操作 caller 的存储,但用的是 target 的存储槽布局——两边布局必须严格对齐。
| |
升级到 V2 后,原来 owner 槽存的地址被当作 counter 解读——协议直接报废。
致命陷阱二:误用 delegatecall 给陌生地址
| |
如果攻击者诱导 owner 调用此函数指向恶意 target,target 的代码可以任意改 caller 的存储——包括把 owner 改成攻击者地址。
历史上的 Parity Wallet 漏洞、Audius 攻击等,都是 delegatecall 滥用导致的灾难。
delegatecall 必须只指向你自己控制的、白名单内的合约。
四、staticcall:只读调用
| |
staticcall 是 EIP-214 引入——和 call 几乎一样,但强制只读:
- 执行 target 的代码
- 改 target 的存储——不行,只读
- 不能转账
- target 内部任何 SSTORE / LOG / CREATE 都会让调用 revert
典型场景
view/pure函数的调用——Solidity 编译器会自动用 staticcall- 集成第三方合约的查询:你想查别人合约状态但不信任它会改你状态——staticcall 强制只读
| |
为什么重要
没有 staticcall 之前,要"安全地查询别人的合约"非常麻烦——对方合约可能在 view 函数里偷偷改状态(虽然不该)。staticcall 从 EVM 层面强制只读——这是一个安全保障。
五、callcode:已弃用
| |
callcode 是 delegatecall 的早期版本,唯一区别是 msg.sender 不会保留为原始调用者——这通常是个 bug 而不是 feature。所以 Solidity 0.5.0 之后 callcode 被禁用,全部用 delegatecall。
新代码里永远不要写 callcode。
六、四种调用的"决策树"
七、Solidity 高级语法的隐式调用
直接写函数调用时,Solidity 会根据函数修饰符自动选择:
| |
view/pure→ staticcall- 普通函数 → call
- delegatecall 永远要显式写——没有"自动 delegatecall"
这是合理的——delegatecall 太危险,必须开发者明确知道自己在做什么。
八、几个工程实践
1. call 一律检查返回值
| |
不检查就忽略错误,等于"我以为调用成功了,其实没有"——脏数据由此而生。
2. 转账永远用 call 不要用 transfer
老代码:
| |
transfer 一直限制 2300 gas(这点从 Solidity 引入 transfer 时就如此)。真正让它频繁出问题的是 EIP-2929(Berlin 升级,2021-04)——抬高了 SLOAD/CALL 等状态访问的 gas 成本,接收方 fallback 里很多原本能跑过的操作再也挤不进 2300 gas,导致 OOG。现代写法:
| |
3. delegatecall 配 onlyOwner / 白名单
| |
永远不要让外部参数决定 delegatecall 的目标。
4. staticcall 保证只读
集成新协议时——只查询、不修改:
| |
如果对方接口没声明 view 但你确定它只读——可以手动 staticcall:
| |
如果对方实际改了状态,staticcall 会让交易失败——EVM 层面拦截,比靠开发者自觉安全。
5. 用 OpenZeppelin Address 库
| |
OZ 的封装多了 require、revert reason 解析等——比裸 call 安全得多。
九、几个真实案例
案例 1:Parity Wallet 的 14 万 ETH(2017)
Parity 的多签钱包用 delegatecall 委托给一个共享 Library。攻击者发现可以直接调用 Library 的 init 函数把自己设成 owner——再调用 kill 函数,整个 Library 自毁,所有依赖它的钱包永久无法转账。
教训:delegatecall 目标合约的所有 public/external 函数都要审慎设计——public 入口被外部直接调用 + delegatecall 同时滥用,就是这种灾难。
案例 2:Audius 治理攻击(2022)
Audius 协议合约存在 storage collision + initialize 未保护双重 bug——存储槽布局错位让攻击者可以重新调用 initialize 把自己设为治理 guardian,然后通过提交并自我通过的恶意治理提案转走社区池里约 1860 万 AUDIO(约 600 万美元)。
教训:proxy 模式下,所有可升级合约的 initialize 必须加 initializer 修饰符防止重复调用,并且基类与子类的 storage 布局必须严格对齐——这两个 bug 任何一个不出,攻击都成立不了。
案例 3:The DAO(2016)
经典重入——call 触发的外部调用导致重新进入合约,余额还没扣就被反复提款。
教训:调用外部前先改自己的状态(Checks-Effects-Interactions 模式)。
十、调试工具
- Foundry trace:
forge test -vvvv能看到每一层 call/delegatecall/staticcall 的细节 - Tenderly:可视化交易调用链路
- Etherscan trace:交易详情里的"Internal Txns"标签
调试 delegatecall 出现"为什么状态没变 / 变错"的问题——第一步永远是看清楚到底是 call 还是 delegatecall,存储槽对齐没。
小结
把全文压一句:
call 是远程调用、delegatecall 是借代码、staticcall 是强制只读、callcode 是历史。四者用错就是漏洞。
工程上几条铁律:
- call 一定要 require(ok)
- delegatecall 只指向自己控制的、白名单内的合约
- delegatecall 升级要严格保持存储布局
- 查询用 staticcall 比信任 view 更安全
- 永远不要 callcode
- 转账用
call{value:...}不用 transfer
把这四种调用的语义吃透——你看 OpenZeppelin / Aave / Uniswap 的代码就能瞬间看懂"为什么这里要这么写"。