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是Amsg.value是A传过来的值。B执行完逻辑后把返回值还给A但B对存储做的任何修改都不会影响A的状态。这就像你把东西送到别人家加工加工完东西拿回来但别人家里的任何摆设都不会因为加工过程而改变。DELEGATECALL则完全不同。A调用B时B只提供代码上下文却是A的存储是A的存储、余额是A的余额、地址是A的地址连msg.sender和msg.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);只要target或data有一部分来自用户输入甚至治理参数就相当于把一个能写任意存储的权限交了出去。敌人不需要密码不需要私钥只需要知道哪个槽位存着owner、哪个槽位存着余额。这就像你家门锁没坏但你亲手把钥匙交给了一个你根本不了解生活习惯的房客。2. 存储槽位的“房产证”EVM里每个合约的状态都是编好号的抽屉2.1 存储槽分配规则与变量地址计算EVM的存储模型是一个巨大的键值对表每个键就是一个所谓的存储槽slot从0开始编号一直到2的256次方减1。合约的每个状态变量最终都会被编译器映射到某个具体槽位。规则看起来简单第一个状态变量从slot 0开始第二个接着放当放不下一整个32字节槽位时换到下一个槽。但这里面有几个细节如果没吃透后面做存储布局对齐时非常容易出错。对于基础类型比如uint8、bool、address如果多个变量加起来不超过32字节编译器会尽力把它们打包进同一个槽。比如uint128 a; // slot 0 低位 uint128 b; // slot 0 高位 address c; // slot 1a和b都是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 ownerB合约的第一个变量是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的ownerimplementationadminslot 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。如果代理合约里存在withdraw、transferOwnership这类管理员函数攻击者就能提走合约里所有资产。3.2 从普通槽位到特权槽位威胁扩大路径覆盖owner只是最低级的威胁。真正恐怖的是覆盖以下这些槽位一是implementation槽位。如果被委托代码能写slot 1或其他存放逻辑合约地址的槽位攻击者可以把实现地址改成自己部署的恶意合约。之后所有调用都被转发到恶意合约攻击者可以在恶意合约里做任何事包括读取并清空整个代理的存储。二是权限角色mapping。很多协议使用mapping(address bool) public isAdmin或者mapping(address uint256) public balanceOf。如果被委托代码的某个mapping和这个角色mapping发生碰撞攻击者可以通过一次普通转账把自己标记为管理员。三是pendingOwner或pendingAdmin这类两段式治理变量。这类变量通常原本只能由指定函数写入但通过存储碰撞攻击者可以绕过函数的权限检查直接写这个槽位。然后自己再走一遍正常治理的“接收权限”流程合法地成为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 FinanceWido在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 0implementation在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-7201Namespaced 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攻击者没有动用任何密码学攻击只是精准地算准了两个合约的存储槽位就把整个协议撬动了。如果你手头正在开发代理合约或者准备集成外部代币逻辑建议立刻把存储布局对照表拉出来看一眼。确认过布局的人才有资格谈“这个调用是安全的”。