news 2026/9/10 8:11:28

Delegatecall存储碰撞漏洞详解:从EVM存储布局到DeFi安全审计

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Delegatecall存储碰撞漏洞详解:从EVM存储布局到DeFi安全审计

2021年10月,Cream Finance遭遇了一场损失过亿美元的攻击,起因不是私钥泄露,也不是预言机操控,而是许多开发者几乎不会留意的那个调用指令——Delegatecall。攻击者利用一次存储碰撞,让cAMP合约的关键状态被凭空改写,最终借走了协议里几乎全部流动性。这类漏洞基本不依赖预言机价格、不依赖闪电贷极限操作,只要合约里埋了一颗“委托调用转错向”的雷,DeFi协议的治理者、资金池、权限系统都可能被彻底接管。这篇文章我会把Delegatecall的底层语义、EVM存储布局、碰撞触发链路、公开案例和审计方法一次性讲透,适合正在做合约开发、协议审计或准备部署代理合约升级的同仁参考。

1. 先理解命运共同体:delegatecall为什么是“借壳执行”的安全地狱

1.1 从CALL到DELEGATECALL:三种调用指令的语义差异

EVM里最基础的跨合约调用是CALL。A调用B时,B在独立的世界里运行:B有自己的存储、自己的地址、自己的余额,msg.sender是A,msg.value是A传过来的值。B执行完逻辑后,把返回值还给A,但B对存储做的任何修改,都不会影响A的状态。这就像你把东西送到别人家加工,加工完东西拿回来,但别人家里的任何摆设都不会因为加工过程而改变。

DELEGATECALL则完全不同。A调用B时,B只提供代码,上下文却是A的:存储是A的存储、余额是A的余额、地址是A的地址,连msg.sendermsg.value都会保留成最初调用A的那个外部账户。这就不是“送到别人家加工”,而是“借别人的手艺,用自己家的材料干活”。最终修改的是A家的家具,B自己却毫发无损。

STATICCALL又加了一层限制:无论CALL还是DELEGATECALL,被调用的代码都不能用SSTORE修改状态。STATICCALL连这个权限也禁掉,只能读,不能写,常用于视图函数和部分严谨的协议内部调用。

1.2 代理升级与库函数:delegatecall不可替代的价值

为什么这种危险的机制至今仍然被广泛使用?因为它是可升级合约的基石。代理合约(Proxy)把用户的调用原封不动地通过delegatecall转发给逻辑合约(Implementation),所有状态其实存在代理合约里,逻辑合约只负责代码。升级的时候,只需要让代理合约指向新的逻辑合约地址,状态数据原封不动保留,用户感知不到合约内部已经“换了一套脑回路”。

很多库函数也依赖delegatecall。比如早期Solidity没有原生支持某些复杂操作时,开发者会把通用逻辑写成一个库,通过delegatecall方式嵌入调用者的存储上下文执行。这样库本身不存状态,代码逻辑却可以操作调用者的数据。EIP-2535 Diamond多模块代理也是基于同样的思路,把不同facets的逻辑通过delegatecall组合在一起。

但问题恰恰出在这里:当代码和存储分离,开发者很容易忘记一个关键事实——**被委托的代码写的每一个存储槽,写的是调用者自己的房产证。**一旦被委托方对存储布局的理解和委托方不一致,就会出现“A以为是owner的槽位,在B的代码里被当成timer来写”这种事。

1.3 开发者最常见的认知盲区

我在审计中经常看到一种危险心态:合约里只要不出现selfdestruct、不出现tx.origin校验缺失、不用assembly直接写SSTORE,就觉得安全。可delegatecall这颗雷是隐性的,它藏在“调用关系的信任边界”里。你信任了某个外部合约的实现,却无法信任它对存储布局的认知。

很多DeFi协议的逻辑合约根本不是标准代理模板,而是直接在某个功能里写了:

(bool success, ) = target.delegatecall(data); require(success);

只要targetdata有一部分来自用户输入甚至治理参数,就相当于把一个能写任意存储的权限交了出去。敌人不需要密码,不需要私钥,只需要知道哪个槽位存着owner、哪个槽位存着余额。这就像你家门锁没坏,但你亲手把钥匙交给了一个你根本不了解生活习惯的房客。

2. 存储槽位的“房产证”:EVM里每个合约的状态都是编好号的抽屉

2.1 存储槽分配规则与变量地址计算

EVM的存储模型是一个巨大的键值对表,每个键就是一个所谓的存储槽(slot),从0开始编号,一直到2的256次方减1。合约的每个状态变量,最终都会被编译器映射到某个具体槽位。

规则看起来简单:第一个状态变量从slot 0开始,第二个接着放,当放不下一整个32字节槽位时换到下一个槽。但这里面有几个细节,如果没吃透,后面做存储布局对齐时非常容易出错。

对于基础类型,比如uint8booladdress,如果多个变量加起来不超过32字节,编译器会尽力把它们打包进同一个槽。比如:

uint128 a; // slot 0 低位 uint128 b; // slot 0 高位 address c; // slot 1

ab都是16字节,加在一起正好32字节,所以它们会被塞进slot 0。而address占20字节,塞不进去了,放到slot 1。

对于mapping类型,它的槽位由声明顺序决定。比如:

mapping(address => uint256) public balances; // 基槽位 slot 1

如果合约第0个状态变量是uint256占了slot 0,那么balances的基槽位就是slot 1。但实际存储某个地址的余额时,不是直接写在slot 1,而是通过一个哈希计算:

slot = uint256(keccak256(abi.encode(key, uint256(1))))

这里的1就是mapping的基槽位。也就是说,mapping的实际数据位置是keccak256(key + baseSlot),这个结果可能落在存储空间里任何一个角落。

动态数组的规则类似:数组长度存在基槽位,元素从keccak256(基槽位)开始连续存放。

2.2 为什么存储碰撞会“看不见摸不着”

现在假设有两份完全不同的合约源码。A合约的第一个变量是address public owner,B合约的第一个变量是uint256 public timer。从源码看,一个是地址,一个是计数器,八竿子打不着。但在EVM眼里,它们都是slot 0,都是同一个抽屉。

如果A通过delegatecall调用B的函数修改timer,那么B的代码会往slot 0写入一个uint256,而这个槽位在A的布局里恰好是owner。攻击者只要把传入B的timer参数设成自己的地址对应的uint256值,就可以让A的owner变成攻击者自己。

这就是“存储碰撞”的本质:两个合约对同一个物理存储槽赋予了不同的语义,而delegatecall把两者的存储上下文强行缝合在了一起。

我在给一些项目做安全评审时,经常用一张表格让开发者直观理解这个问题:

合约A(代理)合约B(被委托逻辑)物理槽位后果
ownertimerslot 0调用B可篡改A的owner
implementationadminslot 1调用B可篡改A的实现合约地址
balances[msg.sender]totalSupplykeccak256(...)转账可伪造余额

2.3 存储布局兼容是代理安全的基石

可升级代理暗含一个硬性要求:新逻辑合约的存储布局必须和旧逻辑合约完全兼容。假设旧逻辑合约slot 0是owner,新逻辑合约为了排版本把owner挪到了slot 5,那么升级后旧数据读出来就是错乱的,可能把余额当成owner,也可能把地址当成分数。

但很多人不知道的是,这个兼容性要求不仅适用于“同一个代理升级前后的逻辑合约”,还适用于“代理通过delegatecall调用的任何外部合约”。哪怕你只是临时调用一个别人写的工具合约,只要那段代码动了存储,就应该把它的存储布局当作你自己合约的一部分来审查。不兼容的布局就是碰撞,碰撞就是漏洞。

3. 一次碰撞如何捅穿代理:最简攻击模型与漏洞触发链路

3.1 最简可复现代码:一次delegatecall覆盖owner

下面我用一个极简示例把漏洞链路完整串起来。首先是代理合约,它会把所有不认识的调用通过delegatecall转发给逻辑合约:

// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; contract Proxy { address public owner; // slot 0 address public implementation; // slot 1 constructor(address _impl) { owner = msg.sender; implementation = _impl; } fallback(bytes calldata data) external returns (bytes memory result) { (bool ok, bytes memory ret) = implementation.delegatecall(data); require(ok, "delegatecall failed"); return ret; } }

再看一个看起来人畜无害的被调用合约:

contract Timelock { address public timer; // slot 0 function setTimer(address who) external { timer = who; } }

如果管理员把这个Timelock地址作为implementation指向的合约,那么当任何用户调用proxy.setTimer(attacker)时,代理合约的fallback会把这个调用转发给Timelock的setTimer。Timelock代码执行时,修改的是代理合约的slot 0,也就是代理合约的owner

用Foundry写个测试验证:

contract StorageCollisionTest is Test { function testDelegatecallOverwritesOwner() public { Proxy proxy = new Proxy(address(new Timelock())); address attacker = address(0xC0FFEE); (bool ok, ) = address(proxy).call( abi.encodeWithSelector(bytes4(keccak256("setTimer(address)")), attacker) ); require(ok, "call failed"); assertEq(proxy.owner(), attacker); } }

测试会直接通过。这意味着攻击者只用一笔交易,就从一个普通用户变成了代理合约的owner。如果代理合约里存在withdrawtransferOwnership这类管理员函数,攻击者就能提走合约里所有资产。

3.2 从普通槽位到特权槽位:威胁扩大路径

覆盖owner只是最低级的威胁。真正恐怖的是覆盖以下这些槽位:

一是implementation槽位。如果被委托代码能写slot 1(或其他存放逻辑合约地址的槽位),攻击者可以把实现地址改成自己部署的恶意合约。之后所有调用都被转发到恶意合约,攻击者可以在恶意合约里做任何事,包括读取并清空整个代理的存储。

二是权限角色mapping。很多协议使用mapping(address => bool) public isAdmin或者mapping(address => uint256) public balanceOf。如果被委托代码的某个mapping和这个角色mapping发生碰撞,攻击者可以通过一次普通转账把自己标记为管理员。

三是pendingOwnerpendingAdmin这类两段式治理变量。这类变量通常原本只能由指定函数写入,但通过存储碰撞,攻击者可以绕过函数的权限检查,直接写这个槽位。然后自己再走一遍正常治理的“接收权限”流程,合法地成为owner,整个过程在链上看起来毫无异常。

3.3 为什么这类漏洞在审计中特别隐蔽

存储碰撞不像重入漏洞那么直观。重入攻击只需要看代码里是否在外部调用后还操作状态,存储碰撞需要同时理解多个合约的存储布局、调用路径和参数可控性,任何一环脱节,漏洞就从眼皮底下溜过。

更隐蔽的是,被委托的合约可能不是你自己的代码,而是某个经过审计的第三方库、旧版本的代币合约、甚至链上某个知名项目。开发者往往天然信任这些代码,却忽略了“审计过的代码”只代表它在自己的存储上下文安全,不代表它在你的存储上下文安全。

这也是为什么我每次看到delegatecall语句,都会立刻进入“红色警戒”状态,先把调用目标、调用数据、存储写入三件事查个底朝天,再往下看业务逻辑。

4. 从理论上到现实中:三起公开攻击事件的共同签名

4.1 Cream Finance AMP事件复盘

Cream Finance的AMP攻击是存储碰撞类漏洞最出名的案例之一。2021年10月,Cream上线了cAMP市场,允许用户抵押和借贷AMP代币。cAMP合约为了提高兼容性,借助delegatecall调用了AMP代币合约的转账逻辑。

问题在于,AMP代币合约和cAMP协议合约的存储布局并不一致。攻击者调用AMP的转账函数时,代码写入的是cAMP合约自己的存储。由于两个合约在某个或某几个槽位上的语义不同,攻击者精心构造转账参数后,cAMP账本中记录攻击者余额的槽位被改写成了远超实际数量的数值。

这里最值得学习的一点是:攻击者并不需要改变代码逻辑,也不需要控制私钥,只需要“找到一个能触发特定函数并精确命中存储槽位”的参数组合。攻击者的链上地址可以被反复创建、筛选,直到哈希结果恰好落在目标槽位。这种碰撞本质上是数学意义上的可计算、可预谋。

最终,攻击者以虚增的cAMP代币作为抵押,从协议中借走了大量USDC、ETH、DAI等资产,总损失在当时被报道超过1亿美元。事件过后,Cream紧急暂停了相关市场,但资金已经被抽走。

4.2 另外两起同类事件:Wido与Hundred Finance

Wido在2021年12月被攻击,原因同样是合约中存在一个允许delegatecall到用户可控地址的入口,攻击者利用它覆盖了协议的关键权限槽位,从而控制了治理或提款路径。这个案例虽然没有Cream那么轰动,但手法非常典型,适合作为安全审查时的样板参考。

Hundred Finance在2023年4月又遇到类似问题,损失约700万美元。它的问题和Cream非常像,都是把cToken这类基于Compound的借贷合约与外部代币实现混在一起,在delegatecall过程中发生了存储碰撞。两次事件之间相隔一年多,说明这个漏洞类型并非一时的“技术考古学”,而是一直在真实世界里反复重演。

我梳理这些案例时发现一个共同签名:受害者合约都有一个“非常规的delegatecall调用点”,要么调用目标不受控,要么目标合约布局与调用方不兼容,要么两者都有。

4.3 案例共性总结

把这些事件放到一起看,有三条规律值得所有开发者和审计师记住:

第一,存储碰撞的攻击成本可以很低。攻击者不需要大量资金,也不需要高级权限,只需要找到正确的入口和参数。

第二,损失规模通常很大。因为碰撞一旦发生,攻击者往往能变成协议的关键角色,或者直接虚增资产余额,这带来的可能不是某一个池子的损失,而是整个协议的流动性收割。

第三,审计盲区常常集中在“第三方代币的转账逻辑”。很多人会把代币的transfer、transferFrom当作普通外部调用,却没有意识到当它们通过delegatecall执行时,本质上是把协议自己的状态交给了代币合约的代码来写。

5. 审计视角:如何在代码海里提前找到“隐形炸弹”

5.1 首先给delegatecall目标“上户口”

审计时我第一步是全局搜索所有delegatecall出现的位置,无论大小写,无论源码还是assembly。找到后,建立一张“调用点清单”,每个调用点都要回答四个问题:

  • 调用的目标地址是常量、治理参数,还是用户输入?治理参数也要看治理权限的集中度和升级机制,不能一票就认为安全。
  • 调用的calldata是否允许用户指定?如果用户能传入任意data,哪怕目标地址是白名单内的合约,也可能因为白名单合约里存在任意执行函数而变成任意存储写入。
  • 目标合约是否会修改存储?如果一个delegatecall只调用纯函数、不碰任何状态,碰撞风险会低很多。
  • 目标合约的存储变量声明顺序和目标合约是否兼容?只有把所有可能被调用的外部合约都纳入存储布局审查,才叫真正的审计。

5.2 对照存储布局:用脚本画出合约的槽位地图

手工翻源码太容易漏,我一般会用工具把合约的存储布局直接打出来。

Foundry提供了一条指令:

forge inspect ContractName storage-layout --pretty

它会输出每个状态变量的槽位、偏移、类型。比如你可以看到owner在slot 0,implementation在slot 1。对代理合约和所有被委托契约分别跑一遍,然后把槽位表放在一起对比,谁和谁撞了,一目了然。

对于mapping类型,还需要额外计算哈希槽位。可以用一个简单脚本读取任意地址的任意槽:

function readSlot(address target, uint256 slot) external view returns (bytes32 value) { assembly { value := sload(slot) } }

在测试里先用vm.store模拟一个关键槽位被写入,再调用相关函数,看看管理员权限是否被篡改。这类针对性测试比单纯看代码更能暴露真实问题。

5.3 针对性测试:把“调用后状态”作为断言对象

审计时我会写一个专门的测试文件,把所有通过delegatecall触发的函数都包装成一次状态断言。比如针对上面的Proxy示例,断言就应该是“调用setTimer后,owner不应变化”。如果测试失败,说明存储碰撞已经发生。

更全面的做法是,在测试里对比delegatecall前和delegatecall后,代理合约每个槽位的值。Foundry里可以用vm.record()vm.accesses()追踪某个地址访问过的所有槽位,也可以直接写循环读取前50个槽位做快照。这样即使没有预料到某个特定碰撞路径,也能通过“存储布局发生变化”这个异常信号发现问题。

5.4 静态工具的辅助与局限

Slither有内置检测器可以扫出controlled-delegatecalldelegatecall到用户可控地址的问题它能帮上忙:

slither . --detect controlled-delegatecall

但存储碰撞这种“布局语义不一致”的问题,静态工具并不可靠。Slither能告诉你某个delegatecall的目标是否可控,却很难判断两个独立的合约在存储语义上是否会发生冲突。所以我始终强调:**工具只能缩小搜索范围,最终结论必须靠人对存储布局做推演。**审计报告里如果只列了扫描结果而没有人工对照存储槽位,那这份报告的价值要打折扣。

6. 从源头拆弹:三套存储布局隔离方案与编码铁律

6.1 方案一:存储间隙与固定布局

最传统也最常用的方案是预留足够的“空白槽位”。所有逻辑合约继承一个带__gap的基类,表示“这部分空间属于未来扩展禁止使用”:

abstract contract Gapped { uint256[50] private __gap; }

这样即使未来升级需要新增状态变量,也优先使用这些gap槽位,不会改变已有变量的位置。这个方案解决了“升级前后布局兼容”的问题,但无法解决“跨合约调用时碰撞”的问题。如果代理通过delegatecall调用一个外部合约,而外部合约也有自己的slot 0、slot 1,碰撞仍可能发生。所以存储间隙只是地基,不能替代调用边界审查。

6.2 方案二:EIP-7201命名空间

EIP-7201(Namespaced Storage Layout)提供了一种更彻底的隔离思路:把所有状态变量定义在一个自定义的storage struct里,通过一个独特的哈希值作为storage指针。这样无论代理合约里有多少其他变量,只要命名空间字符串不同,就不会碰撞。

library ProtocolStorage { bytes32 private constant POSITION = keccak256("protocol.workspace.v1"); struct Layout { address owner; address pendingOwner; uint256 totalSupply; } function layout() internal pure returns (Layout storage s) { bytes32 slot = POSITION; assembly { s.slot := slot } } } contract Protocol { function owner() external view returns (address) { return ProtocolStorage.layout().owner; } }

在这种设计下,状态变量不在传统的slot 0、slot 1连续区域,而是从keccak256("protocol.workspace.v1")这个位置开始排列。攻击者即使通过某些delegatecall写了一个普通的顺序槽位,也很难命中命名空间内部的变量位置,因为那个位置只有知道命名空间字符串的人才能预知。EIP-7201的设计理念本质上是“用独立命名空间隔离存储”,比硬数变量顺序要稳健得多。

6.3 方案三:把delegatecall关进“白名单+无状态”笼子

如果能不用delegatecall,就直接不用。很多场景下“通过delegatecall调用外部逻辑”并不是唯一解,可以用普通CALL、预编译复合合约、或者在逻辑合约内部实现完整功能。

如果确实必须使用,至少要做到三点:

第一,目标地址必须是不可变常量或由多签治理更新,严禁用户传入。

第二,目标合约必须经过存储布局检查,确保它要么完全没有存储变量,要么只使用EIP-7201命名空间存储。

第三,调用data必须由协议内部拼接,不能直接把用户提供的calldata原封不动传给delegatecall。如果业务逻辑需要用户参数,应该只允许传入白名单内的函数选择器和受限参数。

6.4 我个人的底线建议

我在审计中遇到delegatecall时有一条铁规矩:**凡是delegatecall到非本协议部署的合约,一律先标红,再谈业务。**第三方合约的代码经过审计不代表它适合被委托调用,因为存储语义的冲突和普通代码bug是两回事。

实际部署前,我还会要求开发团队做一次全量存储快照,记录所有关键槽位的初始值;将来升级或调用路径变更时,再做一次diff。这样即便某个隐藏碰撞在事后触发,审计线索也是清晰的。

写这篇文章时我回想起最初复现Cream事件时的那种冲击感。一个简单的转账,一次普通的delegatecall,攻击者没有动用任何密码学攻击,只是精准地算准了两个合约的存储槽位,就把整个协议撬动了。如果你手头正在开发代理合约或者准备集成外部代币逻辑,建议立刻把存储布局对照表拉出来看一眼。确认过布局的人,才有资格谈“这个调用是安全的”。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/10 8:10:29

ESP32 RMT外设详解:从NEC协议到红外学习发射器实战

看看家里的电器遥控器,十个里面有八个是红外,电视、空调、机顶盒、风扇,全是那一枚小灯珠在前头闪。但你要是真拿示波器去量它的输出,会发现这玩意儿一点都不简单——一串宽度各异的脉冲,有的几百微秒,有的…

作者头像 李华
网站建设 2026/9/10 8:08:30

OpenAI与Anthropic的AI硬件定义权之争:芯片、入口与生态

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/10 8:06:54

Open WebUI存储型DOM XSS漏洞(CVE-2025-64495)深度复现分析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华