抱歉,您的浏览器无法访问本站
本页面需要浏览器支持(启用)JavaScript
了解详情 >

1. 前言

合约升级代理模式是通过 delegatecall操作码实现的,而这一操作码的特点便是,代码的数据来自于代理合约,执行逻辑来自于逻辑合约。同时正是由于这个特性容易引发插槽冲突问题。

delegatecall的使用方式就不多说了,不懂的可以去看看这篇文章

先来简单了解一下什么是插槽冲突,这是 solidity CTF 最常见的一种考察方式。

举个最简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract Challenge {

address public owner;

constructor() {
owner = msg.sender;
}

function pwn(address hacker) external {
hacker.delegatecall("");
}
}

比如这道题,通过的条件是成为Challenge合约的所有者,熟悉delegatecall原理的很容易想到,可以利用合约执行的上下文Challenge中,而执行的逻辑来自hacker地址。

因此,攻击合约可以这样写:

1
2
3
4
5
6
7
8
contract Hacker {

address public addr;

fallback() external {
addr = msg.sender;
}
}

复现:

  • 部署Challege合约

image-20240513171959597

  • 切换EOA账户

image-20240513172017842

  • 部署Hacker合约

  • 调用pwn()函数,并传入Hacker的地址

    image-20240513172124569

  • 执行之后,可以看到owner的值已经发生了变化

image-20240513172203045

画图理解:

image-20240513174212954

简单来说,Hacker在回调函数中修改的owner其实是Challenge中的owner因此插槽冲突就可以看作是,逻辑合约和代理合约中具有相同插槽位置的变量,可能会因为操作逻辑合约中的变量从而覆盖掉代理合约中的变量值。

2. 简单的可升级合约

简单的可升级合约的实现原理是:部署一个代理合约,代理合约通过委托调用的形式去调用逻辑合约,如果要进行合约升级,只要将代理合约中的 实现合约(也叫逻辑合约)替换掉即可。需要注意的是:更换逻辑合约的函数需要有严格的访问控制,不能随便给其他人调用。

示意图:

image-20240513201028387

2.1 复现可升级合约

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;

// 简单的可升级合约,管理员可以通过升级函数更改逻辑合约地址,从而改变合约的逻辑。
contract SimpleUpgrade {
address public implementation; // 逻辑合约地址
address public admin; // admin地址
string public words; // 字符串,可以通过逻辑合约的函数改变

// 构造函数,初始化admin和逻辑合约地址
constructor(address _implementation){
admin = msg.sender;
implementation = _implementation;
}

// fallback函数,将调用委托给逻辑合约
fallback() external payable {
(bool success, bytes memory data) = implementation.delegatecall(msg.data);
}

// 升级函数,改变逻辑合约地址,只能由admin调用
function upgrade(address newImplementation) external {
require(msg.sender == admin);
implementation = newImplementation;
}
}

这是一个简单的代理合约,构造函数初始化了admin以及implementation

1
2
3
4
5
6
7
8
9
10
11
contract Logic1 {
// 状态变量和proxy合约一致,防止插槽冲突
address public implementation;
address public admin;
string public words; // 字符串,可以通过逻辑合约的函数改变

// 改变proxy中状态变量,选择器: 0xc2985578
function foo() public{
words = "old";
}
}

这是V1版本的逻辑合约,逻辑合约中很明显有两个“无用”的变量,为什么不直接删掉呢?

答案:因为这是避免插槽冲突,前面我已经讲了。

1
2
3
4
5
6
7
8
9
10
11
contract Logic2 {
// 状态变量和proxy合约一致,防止插槽冲突
address public implementation;
address public admin;
string public words; // 字符串,可以通过逻辑合约的函数改变

// 改变proxy中状态变量,选择器:0xc2985578
function foo() public{
words = "new";
}
}

这是v2版本的逻辑合约,同样需要保证插槽的一致性。

复现逻辑:

  • 部署Logic1合约。

  • 部署SimpleUpgrade合约,传入Logic1的地址。

  • 通过remix向可升级合约传入函数选择器。

    image-20240513202423552

可以看到words返回的结果是old

  • 进行合约升级,部署Logic2,让admin调用SimpleUpgrade合约的升级函数upgrade()

  • 再次通过remix向可升级合约传入函数选择器。

    image-20240513202745324

可以看到words返回的结果已经变成new了,说明合约升级成功。

注意:实际上这里需要有严格的访问控制,升级函数不能随意给他人调用。

2.2 缺陷

2.2.1 插槽冲突和代码冗余
1
2
3
4
5
6
|Proxy                     |Implementation           |
|--------------------------|-------------------------|
|address _implementation |address _owner | <=== 插槽冲突
|... |mapping _balances |
| |uint256 _supply |
| |... |

这类升级方式会存在插槽冲突,如果想要解决这个冲突问题,则必须要在逻辑合约中,严格按照代理合约中状态变量的声明顺序,在逻辑合约中重声一遍,以此来解决冲突问题。但是这样一来会造成代码冗余,而且在以太坊上的gas费用很贵,状态变量的存储方式为storage,占用一个槽的费用是20000gas,如果代理合约中的状态变量的数量很多,那么会导致升级的成本变得很高。

但是如果不声明这些变量,则会造成插槽冲突。

2.2.2 选择器碰撞

先来了解一下什么是选择器碰撞。

都知道在智能合约的代码执行中,函数的调用是通过函数选择器来匹配的,如果说在代码中有两个函数的函数选择器是一样的,那么代码将无法找到msg.sender想要调用的函数,从而导致函数调用被revert()

显然,在同一个合约中不可能出现两个函数选择器一样的函数,这是连编译都不能通过的。可代理模式不一样,代理合约和逻辑合约是分开的,就算他们之间存在“选择器冲突”也可以正常编译,这可能会导致很严重的安全事故。

例子:假如逻辑合约的 a函数和代理合约中的的升级函数选择器相同,那么管理人就会在调用a函数的时候,将代理合约升级成一个黑洞合约。

代码复现:

已知burn(uint256)collate_propagate_storage(bytes16)函数的函数选择器都为0x42966c68

image-20240514152633794

假设代理合约的升级函数为burn(uint256),此时合约管理人通过代理合约调用collate_propagate_storage(bytes16)函数,实际的执行并不会通过fallback()去委托调用,而是执行代理合约中的burn(uint256)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;

import "hardhat/console.sol";

contract SelectorCrash {

address public implementation;

constructor(address _implementation){
implementation = _implementation;
}

fallback() external payable {
bytes memory _calldata = abi.encodeWithSignature("burn(uint256)", uint256(0));
(bool success, bytes memory data) = implementation.delegatecall(_calldata);
}

function burn(uint256 num) external {
console.log("SelectorCrash Contract");
}
}

contract Hacker {
function collate_propagate_storage(bytes16) external {
console.log("Hacker Contract");
}
}

contract Test {

function test(address _crash) external {
(bool ok,) = _crash.call(abi.encodeWithSelector(Hacker.collate_propagate_storage.selector, bytes16(0)));
console.log("ok=>", ok);
}
}

代理合约的管理人通过Test合约调用collate_propagate_storag()函数,可以看到输出结果为:

image-20240514153210373

也就是说明,被调用的函数是SelectorCrash合约中的burn(uint256)函数。

针对如上问题,现已有解决方法,解决方法如下:

3. EIP1967 proxy

3.1 非结构化存储

我之前有篇文章写过关于对 EIP1967的解读,感兴趣的可以去看看。

在学习该代理时,需要了解什么是非结构化存储。

上面已经说了 EVM存储数据的机制,也说了插槽冲突。

假设代理将逻辑合约的地址存储在其唯一变量 中address public _implementation;。现在,假设逻辑合约是一个基本代币,其第一个变量是address public _owner。这两个变量的大小均为 32 字节,据 EVM 所知,它们占据代理调用结果执行流的第一个槽。当逻辑合约写入 时_owner,它是在代理状态的范围内进行的,并且实际上写入的是_implementation。这个问题可以称为“存储冲突”。

1
2
3
4
5
6
|Proxy                     |Implementation           |
|--------------------------|-------------------------|
|address _implementation |address _owner | <=== Slot collision!
|... |mapping _balances |
| |uint256 _supply |
| |... |

有很多方法可以克服这个问题,OpenZeppelin Upgrades 实现的“非结构化存储”方法的工作原理如下。它没有将_implementation地址存储在代理的第一个存储槽中,而是选择伪随机槽。该槽足够随机,逻辑合约在同一槽声明变量的概率可以忽略不计。代理存储中的随机槽位置的相同原理也适用于代理可能具有的任何其他变量,例如管理地址(允许更新 的值_implementation)等。

1
2
3
4
5
6
7
8
9
10
11
12
13
|Proxy                     |Implementation           |
|--------------------------|-------------------------|
|... |address _owner |
|... |mapping _balances |
|... |uint256 _supply |
|... |... |
|... | |
|... | |
|... | |
|... | |
|address _implementation | | <=== Randomized slot.
|... | |
|... | |

比如在上述的 简单的可升级合约案例中,代理合约的slot0存储的是逻辑合约的地址,如果逻辑合约中也有状态变量则很可能会发生插槽冲突,EIP1967的解决方式是,将implementation的地址存储在随机位置,该随机位置的计算方法如下:

1
2
bytes32 private constant implementationPosition = bytes32(uint256(
keccak256('eip1967.proxy.implementation')) - 1));

那么会有人好奇,为什么这样会避免插槽冲突呢。因为keccak256('eip1967.proxy.implementation')) - 1)的哈希结果是一个很庞大的数值,要是想实现插槽冲突那得声明多少变量,而合约的大小最大只能为24kb,光部署的成本都已经非常高了,作为智能合约的开发者绝对不会允许这种情况发生。

3.2 复现

复现的逻辑是引用 OZ 库的 ERC1967Proxy.sol,旨在解决上述例子中的插槽冲突和逻辑合约代码冗余问题,我准备在逻辑合约中从slot0, slot1, slot2...依次声明较多状态变量,而不像简单的可升级合约案例中预留代理合约声明的状态变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/proxy/ERC1967/ERC1967Proxy.sol";

contract Proxy_Test is ERC1967Proxy {
constructor(address implementation) ERC1967Proxy(implementation, "") {}
}

contract Logic {

uint256 public a;
uint256 public b;
uint256 public c;
uint256 public d;
uint256 public e;
uint256 public f;

function set() public {
a = 0;
b = 1;
c = 2;
d = 3;
e = 4;
f = 5;
}
}

化繁为简,就不搞那么复杂了。旨在验证通过Proxy_Test合约,委托调用Logic合约的set()函数,验证是否会发生插槽冲突,以及验证Logic合约的状态变量是否被写入逻辑合约中。

执行逻辑:

  • 部署Logic合约,将其地址作为参数传入Proxy_Test合约。

  • 通过Proxy_Test调用set()函数。

    image-20240515165914551

    通过脚本验证各个插槽的值:

    image-20240515170023037

    可以看到这没有发生插槽冲突,implementation的值没有发生变化,以及逻辑合约的状态变量的确被写入了代理合约中。

    我是在本地开启了测试节点,脚本如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    import { 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. 透明代理

EIP-1967 透明合约标准

该代理模式旨在解决选择器冲突问题,通常配合EIP1967使用,虽然被弃用了,但是还是值得学习的。

透明代理模式的逻辑是:管理员可能会因为“函数选择器冲突”,在调用逻辑合约的函数时,误调用代理合约的可升级函数。那么限制管理员的权限,不让他调用任何逻辑合约的函数,就能解决冲突。

  • 管理员变成工具人,仅能调用代理合约和可升级函数对合约升级,不能通过回调函数调用逻辑合约。
  • 非管理员用户不能调用升级合约函数,可以通过回调函数调用逻辑合约。

4.1 复现

根据上述要求,代理合约的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
contract TransparentProxy {
address implementation; // logic
address admin;
string public words;

constructor(address _implementation){
admin = msg.sender;
implementation = _implementation;
}

// fallback函数,将调用委托给逻辑合约
// 不能被admin调用,避免选择器冲突引发意外
fallback() external payable {
require(msg.sender != admin);
(bool success, bytes memory data) = implementation.delegatecall(msg.data);
}

// 升级函数,改变逻辑合约地址,只能由admin调用
function upgrade(address newImplementation) external {
if (msg.sender != admin) revert();
implementation = newImplementation;
}
}

这样就可以简单的实现 升级函数只能由管理员调用,回调函数不能由管理员调用。

4.2 缺陷

  • 采用EIP1967的存储方式,每一次调用都需要sload读取管理员插槽,消耗的gas成本高。

5. 通用可升级代理 - UUPS

UUPS 通用可升级代理 来自 EIP1822,该代理模式是采用伪随机的存储槽存储逻辑合约地址,其实也可以看作是EIP1967的前生,后者将存储槽进行了规范化。

EIP1822中,逻辑合约的地址存储位置为:

1
keccak256("PROXIABLE") = 0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7

由于计算结果数值很大,这也就消除了代理和逻辑合约中变量之间发生冲突的可能性。

除此之外,UUPS还解决了选择器冲突问题,它采用的方式是:将升级函数编写于逻辑合约中而不是代理合约中,这样一来就可以直接避免了逻辑合约中的某个函数的函数选择器和升级函数的选择器相同,即使是开发者不经意间写出来了,编译器也会报错,从而很巧妙的解决了这个问题。

评论



政策 · 统计 | 本站使用 Volantis 主题设计