EFValut Storage冲突漏洞分析

说明

之前分析的漏洞都是合约本身的逻辑漏洞,包括DeFi或者是NFT相关的漏洞,本篇文章分析的EFValut的漏洞却是一个Storage Collision类型的漏洞,这也是本次我第一次分析这个类型的漏洞。

关于StorageStorage Collision,前面也进行了详细的介绍和说明。本篇文章着重关注的是这个漏洞。

基本信息

代理升级模式

openzeppelin的这篇文章 proxies 对代理模式中的各种问题都介绍得非常的清楚了。EFValut的漏洞和这篇文章的The COnstrcutor Caveat(构造函数警告)的原理有关。

在 Solidity 中,构造函数内的代码或全局变量声明的一部分不是已部署合约的运行时字节码的一部分。此代码仅在部署合约实例时执行一次。因此,逻辑合约的构造函数中的代码永远不会在代理状态的上下文中执行。换句话说,代理完全没有注意到构造函数的存在。就好像他们不在代理那里一样。

不过问题很容易解决。逻辑合约应该将构造函数中的代码移动到一个常规的“初始化”函数中,并在代理链接到这个逻辑合约时调用这个函数。需要特别注意这个初始化函数,使其只能被调用一次,这是一般编程中构造函数的属性之一。

这就是为什么当我们使用 OpenZeppelin Upgrades 创建代理时,您可以提供初始化函数的名称并传递参数。

为了确保initialize函数只能被调用一次,使用了一个简单的修饰符。OpenZeppelin Upgrades 通过可扩展的合约提供此功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// contracts/MyContract.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
import "@openzeppelin/contracts-upgradeable/proxy/Initializable.sol";
contract MyContract is Initializable{
function initialize(
address arg1,
uint256 arg2,
bytes memory arg3
) public payable initializer {
// "constructor" code...
}
}

来自于:https://learnblockchain.cn/article/2900

EFValut的实现合约

EFValut存在两个版本的实现合约:

实现合约1: 0x582010c270ef877031e6b16554e51CA5Bbda882E

实现合约2: 0x80cB73074A6965F60DF59BF8fA3CE398Ffa2702c

关注这两个实现合约之前的差异性:

差异性一

额外申明了变量assetDecimal,但是没有排在既有变量的后面,而是替换了原来的maxDeposit位置。

差异性二

在新版本中得initialize()中初始化了这两个变量。

差异性三:

在新版本的实现合约中,额外实现了一个redeem()函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function redeem(uint256 shares, address receiver)
public
virtual
nonReentrant
unPaused
onlyAllowed
returns (uint256 assets)
{
require(shares > 0, "ZERO_SHARES");
require(shares <= balanceOf(msg.sender), "EXCEED_TOTAL_BALANCE");

assets = (shares * assetsPerShare()) / 1e24;

require(assets <= maxWithdraw, "EXCEED_ONE_TIME_MAX_WITHDRAW");

// Withdraw asset
_withdraw(assets, shares, receiver);
}

这个新引入的redeem()后面也成为了漏洞的入口函数。

漏洞函数

因为实现合约中新增加了变量,所以新旧实现合约的storge layout也发生了变化。

旧的实现合约 0x582010c270ef877031e6b16554e51CA5Bbda882E

新的实现合约 0x80cb73074a6965f60df59bf8fa3ce398ffa2702c

新的实现合约中的Slot Index的204的位置已经是assetDecimal,在旧的实现合约中则是maxDeposit。

initialize

漏洞函数就是新版本实现合约中的initialize()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function initialize(
ERC20Upgradeable _asset,
string memory _name,
string memory _symbol,
uint256 _assetDecimal,
address _whiteList
) public initializer {
__ERC20_init(_name, _symbol);
__Ownable_init();
__ReentrancyGuard_init();
asset = _asset;
assetDecimal = _assetDecimal;
maxDeposit = type(uint256).max;
maxWithdraw = type(uint256).max;

whiteList = _whiteList;
}

前面的代理升级模式中也指出了initialize()只会初始化一次。所以在新版本的实现合约中虽然额外多申明了assetDecimalwhiteList变量,因为initialize()已经被被调用了,所以导致assetDecimalwhiteList无法被初始化。

redeem

redeem()函数是在新版本中引入的,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function redeem(uint256 shares, address receiver)
public
virtual
nonReentrant
unPaused
onlyAllowed
returns (uint256 assets)
{
require(shares > 0, "ZERO_SHARES");
require(shares <= balanceOf(msg.sender), "EXCEED_TOTAL_BALANCE");

assets = (shares * assetsPerShare()) / 1e24;

require(assets <= maxWithdraw, "EXCEED_ONE_TIME_MAX_WITHDRAW");

// Withdraw asset
_withdraw(assets, shares, receiver);
}

function assetsPerShare() internal view returns (uint256) {
return (IController(controller).totalAssets(false) * assetDecimal * 1e18) / totalSupply();
}

redeem()函数通过调用assetsPerShare()用以获得份额比例,在assetsPerShare()中则是通过assetDecimal来关联计算。

攻击分析

可以看到攻击非常的简单,就是调用redeem()函数。整个攻击中需要着重关注的是assetDecimal这个变量的内容。

按照之前的分析,在新版本的实现合约中assetDecimal整个变量对应的Slot Index是204,转换为十六进制就是cc

可以看到最终通过0xcc获取的值是48c27395000,转换为十进制就是5000000000000

通过forge查看:

1
2
$ cast storage 0xbdb515028a6fa6cd1634b5a9651184494abfd336 0xcc --rpc-url=https://rpc.ankr.com/eth                                   ✔  spoock@spoockdev
0x0000000000000000000000000000000000000000000000000000048c27395000

可以发现当前的Slot Index为0xcc的值,确实已经变为了0x48c27395000

分析为什么会出现这样的情况。

前面已经说了因为initialize()已经被被调用了,所以导致assetDecimalwhiteList无法被初始化。当实现合约通过assetDecimalSlot Index(即0xcc)去寻找的时候,寻找到的是之前的老的实现合约中的0xcc的值。

在老的实现合约中0xcc(即204)对应的是变量MaxDeposit,而MaxDeposit的值是5000000000000。

这样就导致攻击者可以无故获得大量的代币。

获利

按照我现在计算的结果,是3.4M。

总结

这个就是一个典型的Storage Collision类型的漏洞。因为合约升级新增加的变量不是在原有的变量后面添加的,导致同一个Slot Index的位置映射到同一个变量的位置不一样。在利用新的实现合约读取变量值时就读取的是之前存储在链上的值而导致的漏洞。

参考

https://www.chaindd.online/3788470.html

https://medium.com/taipei-ethereum-meetup/solidity-data-collision-51e88f1529a8

https://github.com/EarningFarm/ENF_ETH_Lowrisk/commit/379058e59468169fba0d7eb42be49f8fe55d05a5

https://docs.openzeppelin.com/upgrades-plugins/1.x/proxies

文章作者: Oooverflow
文章链接: https://www.oooverflow.com/2023/03/07/EFValut-vuln/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Oooverflow