跳到正文

Delegatecall 存储风险

Delegatecall 会在调用者合约的存储上运行另一个合约的代码。了解存储冲突、升级权限和不受信任的目标如何危及代理合约或智能钱包。

更新于

仅供教育参考,不构成投资建议;投资可能产生损失。

直接答案

delegatecall 在调用者合约的上下文中执行目标合约的代码。调用者保留自己的存储、余额和 address(this),而 msg.sendermsg.value 则保留原始调用中的值。

这种行为让代理合约、库和智能钱包模块成为可能,但也会把调用者的实际权限交给被委托执行的代码。存储写入、资产转移、授权或外部调用均以调用者而非提供代码的目标合约身份执行。

应将每个可触达的 delegatecall 目标都视为特权代码。安全性取决于目标选择规则、存储布局是否兼容、初始化状态、升级控制,以及该笔交易实际使用的确切实现。

工作原理

普通外部调用会读写被调用者自己的存储。使用 delegatecall 时,目标字节码则在调用者的存储上运行:一条 SSTORE 指令会更改属于调用者的存储槽。目标代码中的变量名在运行时并不重要,只有计算得到的存储槽位置才重要。

因此需要审计以下四个边界:

  • 目标控制: 确定目标地址是固定的、由用户选择的、通过注册表解析的,还是可由管理员更改的。
  • 存储兼容性: 对比每个实现版本的变量顺序、类型、继承关系、存储间隙,以及命名空间槽或标准化槽。
  • 初始化与授权: 确认初始化器无法重复执行,并确认升级或模块管理函数会强制校验预期的调用者和治理延迟。
  • 返回值处理: 验证失败会向上传播,且返回数据按预期类型解码;低级调用不会提供 Solidity 通常具备的合约类型检查。

ERC-1967 将实现、信标和管理员地址放在正常编译器分配范围之外的标准化存储槽中,从而减少代理合约的存储冲突。但它并不能证明某个实现是安全的,也不能证明经授权的升级是善意的。

示例

假设某个钱包将 owner 存储在 slot 0。一个插件在编译时把 counter 放在 slot 0,钱包通过 delegatecall 调用该插件时,插件递增这个计数器。

由于存储属于钱包,这次写入会改变钱包的 owner 值。如果写入后的字编码了攻击者控制的地址,后续授权检查就可能把攻击者识别为所有者,尽管插件本身从未持有钱包资产。

成功的交易回执无法区分预期状态变更和有害状态变更。因此,交易模拟应针对确切的代理地址和实现地址,检查存储差异、资产和授权变化、发出的事件以及下游调用。

风险

  • 任意目标执行: 用户控制或验证薄弱的目标可以利用调用者的权限运行恶意代码。
  • 存储冲突: 实现合约可以覆盖所有权、余额、暂停状态,甚至覆盖用于选择下一个实现的存储槽。
  • 不安全升级: 管理员或治理流程遭到入侵后,可以在用户存入资产或授予授权之后替换此前已经审查的代码。
  • 初始化失败: 未初始化的代理或实现合约可能允许其他账户取得特权角色,或配置危险的依赖项。
  • 误导性检查: 只验证代理源码、当前实现或接口,可能会遗漏信标、待执行升级、模块注册表或其他执行路径。

签名前,应在近期区块上解析实现地址,核实谁能在何种延迟下更改它,检查目标的已验证字节码和存储布局,模拟完整调用数据,并比较执行前后的敏感存储与代币授权。对于智能钱包,还应审查模块如何启用、禁用,以及它们如何获准选择目标。

常见误区

  • “目标无法动用调用者的资产。” 被委托执行的代码以调用者身份运行;只要调用者具备相应能力,它就能调用外部合约、转移资产或创建授权。
  • “变量名相同就能避免冲突。” EVM 使用存储槽,而不是源代码中的名称。布局顺序、继承关系和类型必须保持兼容。
  • “代理代码经过验证,就代表整个系统已经过验证。” 当前实现、信标、升级管理员、初始化状态和模块权限都是信任边界中彼此独立的部分。

相关主题

来源

导航

搜索知识库...