1. 前言
合约升级代理模式是通过
delegatecall
操作码实现的,而这一操作码的特点便是,代码的数据来自于代理合约,执行逻辑来自于逻辑合约。同时正是由于这个特性容易引发插槽冲突问题。
delegatecall
的使用方式就不多说了,不懂的可以去看看这篇文章。
先来简单了解一下什么是插槽冲突,这是 solidity CTF 最常见的一种考察方式。
举个最简单的例子:
1 | // SPDX-License-Identifier: MIT |
比如这道题,通过的条件是成为Challenge
合约的所有者,熟悉delegatecall
原理的很容易想到,可以利用合约执行的上下文
在Challenge
中,而执行的逻辑来自hacker
地址。
因此,攻击合约可以这样写:
1 | contract Hacker { |
复现:
- 部署
Challege
合约
- 切换
EOA
账户
部署
Hacker
合约调用
pwn()
函数,并传入Hacker
的地址执行之后,可以看到
owner
的值已经发生了变化
画图理解:
简单来说,Hacker在回调函数中修改的owner
其实是Challenge
中的owner
。因此插槽冲突就可以看作是,逻辑合约和代理合约中具有相同插槽位置的变量,可能会因为操作逻辑合约中的变量从而覆盖掉代理合约中的变量值。
2. 简单的可升级合约
简单的可升级合约的实现原理是:部署一个代理合约,代理合约通过委托调用的形式去调用逻辑合约,如果要进行合约升级,只要将代理合约中的 实现合约(也叫逻辑合约)替换掉即可。需要注意的是:更换逻辑合约的函数需要有严格的访问控制,不能随便给其他人调用。
示意图:
2.1 复现可升级合约
1 | // SPDX-License-Identifier: MIT |
这是一个简单的代理合约,构造函数初始化了admin
以及implementation
。
1 | contract Logic1 { |
这是V1
版本的逻辑合约,逻辑合约中很明显有两个“无用”的变量,为什么不直接删掉呢?
答案:因为这是避免插槽冲突,前面我已经讲了。
1 | contract Logic2 { |
这是v2
版本的逻辑合约,同样需要保证插槽的一致性。
复现逻辑:
部署
Logic1
合约。部署
SimpleUpgrade
合约,传入Logic1
的地址。通过
remix
向可升级合约传入函数选择器。
可以看到words
返回的结果是old
。
进行合约升级,部署
Logic2
,让admin
调用SimpleUpgrade
合约的升级函数upgrade()
。再次通过
remix
向可升级合约传入函数选择器。
可以看到words
返回的结果已经变成new
了,说明合约升级成功。
注意:实际上这里需要有严格的访问控制,升级函数不能随意给他人调用。
2.2 缺陷
2.2.1 插槽冲突和代码冗余
1 | |Proxy |Implementation | |
这类升级方式会存在插槽冲突,如果想要解决这个冲突问题,则必须要在逻辑合约中,严格按照代理合约中状态变量的声明顺序,在逻辑合约中重声一遍,以此来解决冲突问题。但是这样一来会造成代码冗余,而且在以太坊上的gas费用很贵,状态变量的存储方式为storage
,占用一个槽的费用是20000gas
,如果代理合约中的状态变量的数量很多,那么会导致升级的成本变得很高。
但是如果不声明这些变量,则会造成插槽冲突。
2.2.2 选择器碰撞
先来了解一下什么是选择器碰撞。
都知道在智能合约的代码执行中,函数的调用是通过函数选择器来匹配的,如果说在代码中有两个函数的函数选择器是一样的,那么代码将无法找到msg.sender
想要调用的函数,从而导致函数调用被revert()
。
显然,在同一个合约中不可能出现两个函数选择器一样的函数,这是连编译都不能通过的。可代理模式不一样,代理合约和逻辑合约是分开的,就算他们之间存在“选择器冲突”也可以正常编译,这可能会导致很严重的安全事故。
例子:假如逻辑合约的 a
函数和代理合约中的的升级函数选择器相同,那么管理人就会在调用a
函数的时候,将代理合约升级成一个黑洞合约。
代码复现:
已知burn(uint256)
和collate_propagate_storage(bytes16)
函数的函数选择器都为0x42966c68
。
假设代理合约的升级函数为burn(uint256)
,此时合约管理人通过代理合约调用collate_propagate_storage(bytes16)
函数,实际的执行并不会通过fallback()
去委托调用,而是执行代理合约中的burn(uint256)
。
1 | // SPDX-License-Identifier: MIT |
代理合约的管理人通过Test
合约调用collate_propagate_storag()
函数,可以看到输出结果为:
也就是说明,被调用的函数是SelectorCrash
合约中的burn(uint256)
函数。
针对如上问题,现已有解决方法,解决方法如下:
3. EIP1967 proxy
3.1 非结构化存储
我之前有篇文章写过关于对 EIP1967
的解读,感兴趣的可以去看看。
在学习该代理时,需要了解什么是非结构化存储。
上面已经说了 EVM
存储数据的机制,也说了插槽冲突。
假设代理将逻辑合约的地址存储在其唯一变量 中
address public _implementation;
。现在,假设逻辑合约是一个基本代币,其第一个变量是address public _owner
。这两个变量的大小均为 32 字节,据 EVM 所知,它们占据代理调用结果执行流的第一个槽。当逻辑合约写入 时_owner
,它是在代理状态的范围内进行的,并且实际上写入的是_implementation
。这个问题可以称为“存储冲突”。
1 | |Proxy |Implementation | |
有很多方法可以克服这个问题,OpenZeppelin Upgrades 实现的“非结构化存储”方法的工作原理如下。它没有将
_implementation
地址存储在代理的第一个存储槽中,而是选择伪随机槽。该槽足够随机,逻辑合约在同一槽声明变量的概率可以忽略不计。代理存储中的随机槽位置的相同原理也适用于代理可能具有的任何其他变量,例如管理地址(允许更新 的值_implementation
)等。
1 | |Proxy |Implementation | |
比如在上述的 简单的可升级合约
案例中,代理合约的slot0
存储的是逻辑合约的地址,如果逻辑合约中也有状态变量则很可能会发生插槽冲突,EIP1967
的解决方式是,将implementation
的地址存储在随机位置,该随机位置的计算方法如下:
1 | bytes32 private constant implementationPosition = bytes32(uint256( |
那么会有人好奇,为什么这样会避免插槽冲突呢。因为keccak256('eip1967.proxy.implementation')) - 1)
的哈希结果是一个很庞大的数值,要是想实现插槽冲突那得声明多少变量,而合约的大小最大只能为24kb
,光部署的成本都已经非常高了,作为智能合约的开发者绝对不会允许这种情况发生。
3.2 复现
复现的逻辑是引用 OZ
库的 ERC1967Proxy.sol,旨在解决上述例子中的插槽冲突和逻辑合约代码冗余问题,我准备在逻辑合约中从slot0, slot1, slot2...
依次声明较多状态变量,而不像简单的可升级合约
案例中预留代理合约声明的状态变量。
1 | // SPDX-License-Identifier: MIT |
化繁为简,就不搞那么复杂了。旨在验证通过
Proxy_Test
合约,委托调用Logic
合约的set()
函数,验证是否会发生插槽冲突,以及验证Logic
合约的状态变量是否被写入逻辑合约中。
执行逻辑:
部署
Logic
合约,将其地址作为参数传入Proxy_Test
合约。通过
Proxy_Test
调用set()
函数。通过脚本验证各个插槽的值:
可以看到这没有发生插槽冲突,implementation的值没有发生变化,以及逻辑合约的状态变量的确被写入了代理合约中。
我是在本地开启了测试节点,脚本如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17import { ethers } from "ethers";
const URL = "HTTP://127.0.0.1:8545";
const provider = new ethers.JsonRpcProvider(URL);
let address = "0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9";
async function print() {
for (let i = 0; i < 6; i++) {
let slot = await provider.getStorage(address, i);
console.log(`slot${i} = ${slot}`);
}
let implementation = await provider.getStorage(address, 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbcn);
console.log(`implementation = ${implementation}`);
}
await print();
3.3 缺陷
- 这种代理模式解决了插槽冲突问题,但是并没有解决选择器冲突问题。
- 安全性较低。
4. 透明代理
该代理模式旨在解决选择器冲突问题,通常配合EIP1967
使用,虽然被弃用了,但是还是值得学习的。
透明代理模式的逻辑是:管理员可能会因为“函数选择器冲突”,在调用逻辑合约的函数时,误调用代理合约的可升级函数。那么限制管理员的权限,不让他调用任何逻辑合约的函数,就能解决冲突。
- 管理员变成工具人,仅能调用代理合约和可升级函数对合约升级,不能通过回调函数调用逻辑合约。
- 非管理员用户不能调用升级合约函数,可以通过回调函数调用逻辑合约。
4.1 复现
根据上述要求,代理合约的实现如下:
1 | contract TransparentProxy { |
这样就可以简单的实现 升级函数只能由管理员调用,回调函数不能由管理员调用。
4.2 缺陷
- 采用
EIP1967
的存储方式,每一次调用都需要sload
读取管理员插槽,消耗的gas成本高。
5. 通用可升级代理 - UUPS
UUPS 通用可升级代理 来自 EIP1822,该代理模式是采用伪随机的存储槽存储逻辑合约地址,其实也可以看作是EIP1967
的前生,后者将存储槽进行了规范化。
在EIP1822
中,逻辑合约的地址存储位置为:
1 | keccak256("PROXIABLE") = 0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7 |
由于计算结果数值很大,这也就消除了代理和逻辑合约中变量之间发生冲突的可能性。
除此之外,UUPS
还解决了选择器冲突问题,它采用的方式是:将升级函数编写于逻辑合约中而不是代理合约中,这样一来就可以直接避免了逻辑合约中的某个函数的函数选择器和升级函数的选择器相同,即使是开发者不经意间写出来了,编译器也会报错,从而很巧妙的解决了这个问题。