什么是 delegatecall?借用别人的代码,为什么改的是自己的合约存储

delegatecall 会借用目标合约代码,却在调用合约的地址、余额和存储上下文执行。本文讲清代理升级、存储冲突、初始化与权限风险。

什么是 delegatecall?借用别人的代码,为什么改的是自己的合约存储

合约 A 调用合约 B 的代码,通常修改的是 B 自己的状态。但 delegatecall 很特别:它像把 B 的代码临时借过来,在 A 的“房间”里执行。代码来自 B,地址、余额和存储却仍属于 A,原始调用者的 msg.sender 与 msg.value 也会保留。

这种能力让代理升级和代码复用成为可能,也意味着目标代码几乎可以触碰调用者的全部状态。理解 delegatecall 的关键不是“调用了谁”,而是谁的上下文正在被修改。

普通 call 像去别人家请他办事;delegatecall 像把别人的操作说明拿回自己家执行。步骤属于别人,抽屉和财产仍是自己的。

call 与 delegatecall 有什么区别

项目calldelegatecall
执行代码目标合约代码目标合约代码
读写存储目标合约存储调用合约存储
address(this)目标地址调用者地址
余额上下文目标合约余额调用合约余额
msg.sender通常变为调用合约保留原消息发送者
delegatecall 借用目标合约代码,但在调用合约的地址、余额和存储上下文执行
delegatecall 借用目标合约代码,但在调用合约的地址、余额和存储上下文执行

Solidity 官方文档说明,delegatecall 会在调用合约的上下文执行目标代码,msg.sender 和 msg.value 不改变。这正是库与代理模式的重要基础。

代理合约为什么需要它

常见代理把用户长期交互的地址和数据放在代理合约中,再将函数调用委托给实现合约。实现地址可以按治理规则更新,于是外部地址和历史存储保持不变,业务逻辑却能升级。用户看见的是代理地址,实际代码可能来自当前实现。

  1. 用户向代理地址发送函数调用。
  2. 代理的 fallback 根据 calldata 找到实现合约。
  3. 代理通过 delegatecall 执行实现代码。
  4. 实现代码读取和修改的是代理存储。
  5. 返回值或错误再由代理传回用户。

这也解释了为什么核查可升级协议时,不能只读代理地址上的薄代码;还要找到当前实现、管理员和升级路径。

存储槽为什么必须对齐

通过 delegatecall 访问状态时,代理与实现合约的存储布局必须严格兼容
通过 delegatecall 访问状态时,代理与实现合约的存储布局必须严格兼容

EVM 存储按槽位组织。实现代码里名为 owner 的变量并不会根据名字寻找代理的 owner,而是读写编译后对应的槽位。如果代理槽 0 放实现地址,实现代码却以为槽 0 是计数器,一次写入就可能破坏关键地址。

升级时同样危险:删除、重排或改变已有变量类型,可能让新逻辑用旧槽位的比特解释成另一种含义。成熟代理标准使用特定存储槽和升级工具检查布局,但开发者仍需理解继承、结构体、映射和存储间隙的影响。

初始化为何是高风险入口

实现合约的构造函数不会通过 delegatecall 替代理初始化状态,所以可升级合约通常使用 initializer 函数。若初始化没有及时执行或缺少一次性保护,外部地址可能抢先成为管理员。实现合约本身也应按框架要求禁用初始化,避免被单独接管后产生其他风险。

目标代码拥有多大权力

因为它在代理上下文运行,恶意或有缺陷的实现可以改写权限、转移资产、发起外部调用或破坏状态。安全边界不只是“实现地址有没有漏洞”,还包括谁能升级到新实现、升级是否经过多签和时间锁、用户是否有观察与退出窗口。

风险问题
存储冲突实现写入了错误槽位
未初始化攻击者抢先取得管理员或业务角色
升级权集中单一地址可替换全部逻辑
任意目标委托用户可控制 delegatecall 目标或数据
返回值处理错误调用失败未正确回滚或错误被吞掉

delegatecall 和“代码经过审计”之间的误区

目标实现经过审计,不代表当前代理安全。代理的存储布局、初始化状态、升级管理员、实现版本和组合调用都影响结果。同一份实现代码连接到不同代理,也可能因为状态与权限不同而呈现不同风险。

普通用户怎样核查代理

  1. 在区块浏览器确认地址是否为代理,并找到当前实现。
  2. 检查实现源码是否验证,版本和更新时间是否合理。
  3. 找到升级管理员:个人钱包、多签、时间锁还是治理合约。
  4. 查看近期 Upgraded、AdminChanged 等事件和异常升级。
  5. 确认升级是否有延迟、公开提案和紧急暂停机制。
  6. 理解“已放弃 Owner”不一定等于失去代理升级权。

浏览器自动识别可能不完整,复杂的 Beacon、Diamond 或自定义代理还需继续追踪。不要仅凭界面上的“Proxy”标签下结论。

开发者安全清单

  • 采用成熟代理标准和升级插件,不自行拼接未经审查的 fallback。
  • 锁定实现初始化,并在部署交易中完成代理初始化。
  • 升级前比较存储布局,禁止重排或覆盖旧变量。
  • 限制升级权限,优先使用多签、时间锁和公开监控。
  • 检查 delegatecall 返回的 success 与 returndata,正确传播错误。
  • 绝不允许不受信任用户任意指定委托目标和 calldata。
  • 在主网分叉上测试升级、回滚、权限和历史状态兼容性。

三个常见误区

  • “逻辑合约没有资金,所以风险小”:它能在代理上下文控制代理资产与状态。
  • “代理地址没变,代码也没变”:实现地址可能已经升级。
  • “变量名字相同就能对齐”:真正对应的是编译后的存储槽位与布局。

delegatecall 本身不是后门,而是一种强大的执行机制。它把代码与状态分离,同时把实现合约和升级权提升为核心安全边界。看到可升级合约时,真正要问的是:当前执行哪份代码、它会写哪些槽位、谁能把它换掉?

风险提示:代理与升级结构可能复杂且随治理变化。交互前请独立核验代理、实现、管理员、时间锁和交易内容。本文仅作技术科普,不构成任何投资建议。


本文为链上指南原创内容。技术依据参考 Solidity delegatecall 官方文档与Solidity 存储布局文档。