Featured image of post 智能合约的四种调用方式:Call / CallCode / DelegateCall / StaticCall

智能合约的四种调用方式:Call / CallCode / DelegateCall / StaticCall

EVM 提供了四种合约调用方式,行为差异巨大,用错就是漏洞。本文把它们的存储上下文、msg.sender 表现、典型用法讲透

一段看似平平无奇的代码,行为完全不同

四个看起来很像的调用:

1
2
3
4
target.call(data);          // call
target.callcode(data);      // callcode(已弃用)
target.delegatecall(data);  // delegatecall
target.staticcall(data);    // staticcall

它们都是"调另一个合约"——但存储上下文、msg.sender、能否改状态完全不同。用错任何一个轻则无效,重则被攻击者利用整个协议归零。

本文把这四种调用的差异系统讲清楚,并配代码例子说明各自用法和风险。


一、四者本质对比

EVM 中合约调用的"上下文"由三个核心要素组成:

  • 代码(Code):要执行哪段字节码?
  • 存储(Storage):读写哪个合约的存储?
  • msg.sender / msg.value:调用者身份是谁?

四种调用对这三个要素的处理:

执行的代码操作的存储msg.sendermsg.value能否改状态
calltargettargetcaller可传值
callcodetargetcallercaller可传值
delegatecalltargetcaller原发起者原 value
staticcalltargettargetcaller不可传值✗ 只读

callcode 已经被官方标记为 deprecated(功能被 delegatecall 替代),实战中几乎不用。重点是 call / delegatecall / staticcall 三种。


二、call:最朴素的"远程调用"

1
2
3
4
5
6
7
8
contract Caller {
    function callTarget(address target) external payable {
        (bool ok, bytes memory data) = target.call{value: msg.value}(
            abi.encodeWithSignature("doSomething(uint256)", 100)
        );
        require(ok, "call failed");
    }
}

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:用别人的代码、操作自己的存储

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
contract Proxy {
    address public implementation;

    fallback() external payable {
        address impl = implementation;
        assembly {
            calldatacopy(0, 0, calldatasize())
            let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
            returndatacopy(0, 0, returndatasize())
            switch result
            case 0 { revert(0, returndatasize()) }
            default { return(0, returndatasize()) }
        }
    }
}

delegatecall 的语义是——借用对方的字节码逻辑,但操作的是自己的存储和上下文

  • 执行 target 的代码
  • caller 的存储(不是 target 的!)
  • msg.sender原始调用者(不是 caller)
  • msg.value 也是原始的

典型场景:代理升级模式

可升级合约(OpenZeppelin Proxy / UUPS)的根基就是 delegatecall:

Proxy 把所有调用 delegatecall 给 Logic 合约——Logic 操作的存储是 Proxy 的,所以升级 Logic 不影响数据

致命陷阱:存储槽冲突

delegatecall 操作 caller 的存储,但用的是 target 的存储槽布局——两边布局必须严格对齐。

1
2
3
4
5
6
7
8
9
contract LogicV1 {
    address public owner;        // slot 0
    uint256 public counter;      // slot 1
}

contract LogicV2 {
    uint256 public counter;      // slot 0  ← 和 V1 的 owner 撞了
    address public owner;        // slot 1
}

升级到 V2 后,原来 owner 槽存的地址被当作 counter 解读——协议直接报废

致命陷阱二:误用 delegatecall 给陌生地址

1
2
3
function execute(address target, bytes calldata data) external onlyOwner {
    target.delegatecall(data);   // ❌ 极度危险
}

如果攻击者诱导 owner 调用此函数指向恶意 target,target 的代码可以任意改 caller 的存储——包括把 owner 改成攻击者地址。

历史上的 Parity Wallet 漏洞、Audius 攻击等,都是 delegatecall 滥用导致的灾难。

delegatecall 必须只指向你自己控制的、白名单内的合约。


四、staticcall:只读调用

1
2
3
4
5
6
7
function readSomething(address target) external view returns (uint256) {
    (bool ok, bytes memory data) = target.staticcall(
        abi.encodeWithSignature("getValue()")
    );
    require(ok, "staticcall failed");
    return abi.decode(data, (uint256));
}

staticcall 是 EIP-214 引入——和 call 几乎一样,但强制只读

  • 执行 target 的代码
  • 改 target 的存储——不行,只读
  • 不能转账
  • target 内部任何 SSTORE / LOG / CREATE 都会让调用 revert

典型场景

  • view / pure 函数的调用——Solidity 编译器会自动用 staticcall
  • 集成第三方合约的查询:你想查别人合约状态但不信任它会改你状态——staticcall 强制只读
1
2
3
4
function getPriceFromOracle(IOracle oracle) external view returns (uint256) {
    // 编译器自动选 staticcall
    return oracle.price();
}

为什么重要

没有 staticcall 之前,要"安全地查询别人的合约"非常麻烦——对方合约可能在 view 函数里偷偷改状态(虽然不该)。staticcall 从 EVM 层面强制只读——这是一个安全保障。


五、callcode:已弃用

1
target.callcode(data);    // 已 deprecated

callcode 是 delegatecall 的早期版本,唯一区别是 msg.sender 不会保留为原始调用者——这通常是个 bug 而不是 feature。所以 Solidity 0.5.0 之后 callcode 被禁用,全部用 delegatecall。

新代码里永远不要写 callcode


六、四种调用的"决策树"


七、Solidity 高级语法的隐式调用

直接写函数调用时,Solidity 会根据函数修饰符自动选择

1
2
3
ITarget(addr).viewFn();        // staticcall
ITarget(addr).pureFn();        // staticcall
ITarget(addr).normalFn();      // call
  • view / pure → staticcall
  • 普通函数 → call
  • delegatecall 永远要显式写——没有"自动 delegatecall"

这是合理的——delegatecall 太危险,必须开发者明确知道自己在做什么。


八、几个工程实践

1. call 一律检查返回值

1
2
(bool ok, ) = target.call(data);
require(ok, "call failed");

不检查就忽略错误,等于"我以为调用成功了,其实没有"——脏数据由此而生。

2. 转账永远用 call 不要用 transfer

老代码:

1
recipient.transfer(amount);   // ❌ 现代不推荐

transfer 一直限制 2300 gas(这点从 Solidity 引入 transfer 时就如此)。真正让它频繁出问题的是 EIP-2929(Berlin 升级,2021-04)——抬高了 SLOAD/CALL 等状态访问的 gas 成本,接收方 fallback 里很多原本能跑过的操作再也挤不进 2300 gas,导致 OOG。现代写法:

1
2
(bool ok, ) = recipient.call{value: amount}("");
require(ok, "transfer failed");

3. delegatecall 配 onlyOwner / 白名单

1
2
3
4
5
6
7
mapping(address => bool) public trustedTargets;

function delegateAction(address target, bytes calldata data) external onlyOwner {
    require(trustedTargets[target], "not trusted");
    (bool ok, ) = target.delegatecall(data);
    require(ok);
}

永远不要让外部参数决定 delegatecall 的目标

4. staticcall 保证只读

集成新协议时——只查询、不修改:

1
ISomeProtocol(addr).getInfo();    // 用 view 接口,编译器自动 staticcall

如果对方接口没声明 view 但你确定它只读——可以手动 staticcall:

1
(bool ok, bytes memory data) = addr.staticcall(abi.encodeWithSignature("getInfo()"));

如果对方实际改了状态,staticcall 会让交易失败——EVM 层面拦截,比靠开发者自觉安全

5. 用 OpenZeppelin Address 库

1
2
3
4
5
using Address for address;

target.functionCall(data);                  // 安全的 call + revert
target.functionStaticCall(data);            // 安全的 staticcall
target.functionDelegateCall(data);          // 安全的 delegatecall(要白名单)

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 traceforge test -vvvv 能看到每一层 call/delegatecall/staticcall 的细节
  • Tenderly:可视化交易调用链路
  • Etherscan trace:交易详情里的"Internal Txns"标签

调试 delegatecall 出现"为什么状态没变 / 变错"的问题——第一步永远是看清楚到底是 call 还是 delegatecall,存储槽对齐没。


小结

把全文压一句:

call 是远程调用、delegatecall 是借代码、staticcall 是强制只读、callcode 是历史。四者用错就是漏洞。

工程上几条铁律:

  1. call 一定要 require(ok)
  2. delegatecall 只指向自己控制的、白名单内的合约
  3. delegatecall 升级要严格保持存储布局
  4. 查询用 staticcall 比信任 view 更安全
  5. 永远不要 callcode
  6. 转账用 call{value:...} 不用 transfer

把这四种调用的语义吃透——你看 OpenZeppelin / Aave / Uniswap 的代码就能瞬间看懂"为什么这里要这么写"。

使用 Hugo 构建
主题 StackJimmy 设计