Delegatecall 深度解析:借用代码、共享存储

彻底理解 delegatecall 如何在调用者上下文执行目标代码、存储冲突风险与 immutable 陷阱

8 分钟阅读
Delegatecall 深度解析:借用代码、共享存储

Delegatecall 深度解析:借用代码、共享存储

目录


一、delegatecall 是什么

delegatecall 是 EVM 操作码(0xF4)。它执行目标合约的代码,但用的是调用者自己的上下文(存储、余额、地址)

心智模型:实现合约把自己的字节码””给调用者,调用者像执行自己的代码一样运行这段借来的逻辑。这就是可升级合约的全部魔法所在。

二、与 call 的关键区别

方面CALLDELEGATECALL
改谁的存储被调合约的存储调用者的存储
msg.sender变成调用者保持原始发送者
msg.value被调上下文可见调用者上下文可见
address(this)被调合约地址调用者地址

一句话:delegatecall 执行的是别人的代码,操作的却是自己的一切。

三、存储插槽冲突风险(核心)

这是 delegatecall 头号危险。

发起 delegatecall 的合约必须极其小心地预测自己的哪个存储插槽会被修改。

因为目标代码按”插槽编号”读写,而它读写的是调用者的插槽

3.1 错误示范

// 被调合约
contract Called {
    uint public number;  // 插槽 0
    function increment() public { number++; }
}

// 调用者(危险!)
contract Caller {
    address public calledAddress;  // 插槽 0
    uint public myNumber;          // 插槽 1
    function callIncrement() public {
        calledAddress.delegatecall(abi.encodeWithSignature("increment()"));
    }
}

increment() 想给”插槽 0 的 number” 加 1,但在 delegatecall 下它加的是 Caller 的插槽 0——也就是 calledAddress!结果把合约地址变量改成了 1,逻辑彻底崩坏。

3.2 修正版

让布局对齐——把要被修改的变量放到匹配的插槽:

contract Caller {
    uint public myNumber;          // 插槽 0  ← 对齐 Called 的 number
    address public calledAddress;  // 插槽 1
    function callIncrement() public {
        calledAddress.delegatecall(abi.encodeWithSignature("increment()"));
    }
}

现在 increment 改的是插槽 0 的 myNumber,符合预期。

3.3 变量名不重要,插槽才重要

变量叫什么名字无所谓,根本的是它在哪个插槽。

Caller 里叫 myNumber、Called 里叫 number,名字不同没关系——只要都在插槽 0,delegatecall 就能正确协作。这也是为什么所有代理框架都强调”升级时不能改变量声明顺序、只能在末尾追加”。

四、上下文的保留:msg.sender / msg.value / address(this)

contract Called {
    function getInfo() public payable returns (address, uint, address) {
        return (msg.sender, msg.value, address(this));
    }
}
contract Caller {
    function getDelegatedInfo(address _called) public payable
        returns (address, uint, address) {
        (, bytes memory data) = _called.delegatecall(abi.encodeWithSignature("getInfo()"));
        return abi.decode(data, (address, uint, address));
    }
}

返回的三个值全是 Caller 的上下文

  • msg.sender = 最初发起交易的人(不是 Caller);
  • msg.value = 原交易带的 ETH;
  • address(this) = Caller 的地址(不是 Called)。

五、immutable 与 constant 陷阱

一个非常隐蔽的坑:

contract Caller {
    uint256 private immutable a = 3;
    function getValueDelegate(address called) public view returns (uint256) {
        (, bytes memory data) = called.delegatecall(abi.encodeWithSignature("getValue()"));
        return abi.decode(data, (uint256));
    }
}
contract Called {
    uint256 private immutable a = 2;
    function getValue() public pure returns (uint256) { return a; }
}

返回 2,不是 3!因为 immutable/constant 变量硬编码在字节码里,不在存储插槽。delegatecall 执行的是 Called 的字节码,里面写死的 a 就是 2,与 Caller 的存储上下文无关。

记住:delegatecall 共享的是存储,不是字节码里的常量。

六、msg.data 的行为

contract Called {
    function returnMsgData() public pure returns (bytes memory) { return msg.data; }
}

通过 delegatecall 调用 returnMsgData 时,里面读到的 msg.data它自己被调用时的 calldata(即选择器 0x0b1c837f),而不是原始交易的 input data。每一层调用的 msg.data 是该层的 calldata。

七、链式 delegatecall

A delegatecall B,B 再 delegatecall C——整个过程上下文始终是 A 的

contract Caller {
    address calledFirst = 0xF273...01cb;
    function delegateCallToFirst() public {
        calledFirst.delegatecall(abi.encodeWithSignature("logSender()"));
    }
}
contract CalledFirst {
    event SenderAtCalledFirst(address sender);
    address constant calledLast = 0x1d14...B8BD;
    function logSender() public {
        emit SenderAtCalledFirst(msg.sender);
        calledLast.delegatecall(abi.encodeWithSignature("logSender()"));
    }
}
contract CalledLast {
    event SenderAtCalledLast(address sender);
    function logSender() public { emit SenderAtCalledLast(msg.sender); }
}

两个事件记录的 msg.sender 完全相同——都是原始交易发送者。因为”delegatecall 只是借字节码”,上下文层层传递不变。(如果 CalledFirst 改用普通 call 调 CalledLast,最后那个事件就会显示 CalledFirst 的地址。)

八、CODESIZE 例外

有一个操作码打破”借字节码”模型:CODESIZE 返回被调合约的字节码大小,而不是调用者的。这个细节在某些”检测代码大小”的绕过攻击里会被利用,需要留意。

九、Yul 底层实现与 Gas 转发

DELEGATECALL 操作码取 6 个栈参数:

delegatecall(gas, address, argsOffset, argsSize, retOffset, retSize)

Yul 示例:

assembly {
    mstore(0x00, 0x34ee2172)  // 函数选择器
    let result := delegatecall(
        gas(), target_address,
        0x1c,   // 参数偏移(跳过选择器前的 28 字节)
        4,      // 参数大小
        0, 0x20 // 返回偏移、返回大小
    )
    data := mload(0)
}

Gas 转发限制:EIP-150 后只转发可用 Gas 的 63/64,保留 1/64。用 gas() 也不能保证 100% 转发。

十、安全隐患

delegatecall 到不可信目标风险极高:

  1. 存储破坏:插槽布局不对齐会悄无声息地改坏状态;
  2. 逻辑劫持:目标代码以调用者的权限执行——它能动用调用者的全部余额和权限;
  3. selfdestruct 灾难:如果目标代码含 selfdestruct,会摧毁的是调用者(代理)合约——历史上 Parity 多签钱包就是这样被冻结了数亿美元;
  4. msg.sender 无法区分:实现合约分不清是原始用户还是中间合约在调用。

铁律:只 delegatecall 你完全信任、且存储布局严格对齐的合约

十一、代理模式:delegatecall 的正当用途

delegatecall 最主要的合法用途就是可升级代理

// 代理合约(存数据)
contract Proxy {
    uint public price;             // 插槽 0
    address public implementation; // 插槽 1
    function setDiscountPrice(uint rate) public {
        implementation.delegatecall(abi.encodeWithSignature("applyDiscount(uint256)", rate));
    }
}
// 逻辑合约(可替换)
contract ImplementationV1 {
    function applyDiscount(uint rate) public {
        // price 在调用者上下文是插槽 0,这里改的是 Proxy 的 price
    }
}

升级时只需把 implementation 换成新逻辑合约地址,存储(price 等)原封不动保留——用户数据不丢,代码却换新了。后续几篇(ERC-1967、Transparent、UUPS、Beacon)都是在解决”如何安全地管理 implementation 地址和存储布局”这个问题。

十二、总结

  • delegatecall = 执行别人的字节码 + 用自己的存储/sender/余额/地址;
  • 头号风险是存储插槽冲突,必须对齐布局(看插槽不看名字);
  • immutable/constant 在字节码里,delegatecall 取的是目标的硬编码值;
  • 上下文层层保留,msg.sender 一路不变;CODESIZE 是例外;
  • 只 delegatecall 可信且布局对齐的目标,警惕 selfdestruct。

十三、动手练习项目:可升级计数器代理 MiniProxy

项目目标

亲手实现一个最小可升级代理:Proxy 用 delegatecall 转发到逻辑合约,逻辑合约改的是 Proxy 的存储;制造一次存储冲突 bug 再修复它;最后把逻辑从 V1 升级到 V2,验证存储保留。部署到 Sepolia。

合约要求

1. LogicV1.sol

// 存储布局:插槽 0 = count
contract LogicV1 {
    uint256 public count; // 插槽 0
    function increment() external { count++; }
}

2. LogicV2.sol(升级版,必须保持布局兼容)

contract LogicV2 {
    uint256 public count;     // 插槽 0(不变!)
    uint256 public lastCaller;// 插槽 1(末尾追加)
    function increment() external { count += 2; lastCaller = uint256(uint160(msg.sender)); }
}

3. MiniProxy.sol

  • 存储布局严格对齐逻辑合约uint256 public count;(插槽 0)、address public implementation;(插槽 1)——注意这里故意暴露第三节的冲突隐患,让你在测试里发现并思考;
  • 更稳妥的做法(推荐实现):把 implementation 存到 ERC-1967 风格的哈希插槽(下一篇内容预习),用汇编 sload/sstore 读写,彻底避开冲突;
  • upgradeTo(address newImpl):仅 admin 可调,更新 implementation;
  • fallback() external payable:用 assembly 把 calldata 原样 delegatecall 到 implementation,并冒泡返回值/revert(标准代理 fallback 模板)。

测试要求(Foundry)

  1. test_DelegatecallUsesProxyStorage:通过 Proxy 调 increment,断言 Proxy 的 count 变了、LogicV1 自身的 count 始终为 0;
  2. test_StorageCollisionBug:故意把 Proxy 的 implementation 放到插槽 0,复现”increment 改坏了 implementation 地址”,断言之后调用失败——亲手制造并观察 bug;
  3. test_FixedLayout:用对齐布局(或哈希插槽)版本,验证 increment 正常且 implementation 不被破坏;
  4. test_Upgrade_PreservesStorage:count 增到 5 后 upgradeTo(LogicV2),再 increment,断言 count 变成 7(保留 5 + V2 的 +2)、lastCaller 被记录;
  5. test_MsgSenderPreserved:在 LogicV2 里记录的 lastCaller == 最初的 EOA,不是 Proxy 地址;
  6. test_OnlyAdminUpgrade:非 admin 调 upgradeTo 应 revert。

Sepolia 部署与验证步骤

  1. 部署 LogicV1、MiniProxy(构造传 LogicV1 地址 + admin);
  2. 用 Proxy 地址 + LogicV1 的 ABI 在 Etherscan 上调 increment() 几次,读 count
  3. 部署 LogicV2,调 upgradeTo(LogicV2),再 increment,确认 count 在原值基础上 +2、且数据未丢;
  4. (如果用了 ERC-1967 插槽)用 cast storage <proxy> 0x360894...382bbc 读出 implementation 地址验证。

进阶挑战(可选)

  • 给逻辑合约加一个 destroy() 含 selfdestruct,在测试网(用一次性合约)观察 delegatecall selfdestruct 摧毁的是谁,理解 Parity 事故;
  • 写注释回答:为什么代理的 fallback 必须用 assembly 而不能用 implementation.delegatecall(msg.data) 的高层写法返回?(提示:返回任意长度数据 + 冒泡 revert)

💬 评论