Audius 攻击事件分析

我们今天来研究一下前段时间 Audius 项目被黑的原因。这部分涉及到了内存槽位和合约升级方面的内容,如果有朋友不了解这一块,可以看看我之前写的这个系列。看完之后再来看这篇文章就比较容易理解了。

代码分析

我们先看一下 Audius 的合约架构,它采用了可升级合约的架构:

Audius 合约架构
Audius 合约架构

我们知道,可升级合约架构中,代理合约存储了数据,逻辑合约中只是执行逻辑而已,执行过程中涉及到的内存变量会反向修改代理合约中的数据。我们来看看 Audius 的合约是怎么写的:

AudiusAdminUpgradeabilityProxy(代理合约,节选)

contract AudiusAdminUpgradeabilityProxy is UpgradeabilityProxy {
    address private proxyAdmin;
    string private constant ERROR_ONLY_ADMIN = (
        "AudiusAdminUpgradeabilityProxy: Caller must be current proxy admin"
    );
}

在代理合约中,slot 0 的位置是 proxyAdmin,这与我们之前讲过的合约升级不同。我们前面说过为了避免内存槽位冲突,EIP-1967 标准应运而生。它将 implementationadmin 这两个字段放在了两个特殊的插槽中:

# bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1)
0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc

# bytes32(uint256(keccak256('eip1967.proxy.admin')) - 1)
0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103

这样做的目的就是为了最大限度保证这两个保留数据不会和逻辑合约中的数据槽位冲突,而 Audius 这种写法正是造成这次 bug 的原因。

我们再来看看逻辑合约的内容:

Governance(逻辑合约,节选)

contract Initializable {
    /**
     * @dev Indicates that the contract has been initialized.
     */
    bool private initialized;

    /**
     * @dev Indicates that the contract is in the process of being initialized.
     */
    bool private initializing;

    /**
     * @dev Modifier to use in the initializer function of a contract.
     */
    modifier initializer() {
        require(
            initializing || isConstructor() || !initialized,
            "Contract instance has already been initialized"
        );

        bool isTopLevelCall = !initializing;
        if (isTopLevelCall) {
            initializing = true;
            initialized = true;
        }

        _;

        if (isTopLevelCall) {
            initializing = false;
        }
    }

    ///....

    // Reserved storage space to allow for layout changes in the future.
    uint256[50] private ______gap;
}

contract InitializableV2 is Initializable {
    /// ....
}

contract Governance is InitializableV2 {
    /// ....
}

按照合约继承的内存分布规则,Initializable 合约中的 initializedinitializing 这两个变量分别位于逻辑合约 Governanceslot 0slot 1 中。

看到这里,大家是不是已经发现了问题。如果按照这种写法,那么代理合约和逻辑合约的内存槽位不是冲突了吗?没错,但还有一点要注意的是,由于 initializedinitializing 都是 bool 类型变量,因此他们各自都只占据一字节(注意,是 1 byte,不是 1 bit),所以说它们俩实际上是被打包放在了 slot 0 中。也就是说,slot 0 的结构是:

上图是逻辑合约的 slot 0 内存分布。由于与代理合约的 ProxyAdmin 冲突,且 ProxyAdmin 的值为:

0x4DEcA517D6817B6510798b7328F2314d3003AbAC

因此,对应的 slot 0 槽位图示为:

这说明 initializedinitializing 这两个变量的值使用了 ProxyAdmin 实际值的最后两个字节!而恰好最后两个字节(0xAB, 0xAC)都是非零值,这也就造成在实际可升级合约的数据读取中,initializedinitializing 的值总是 true。而这个巧合其实也取决于 ProxyAdmin 的最后两个字节是什么,如果它的地址最后两字节都是零: 0x4DEcA517D6817B6510798b7328F2314d30030000,那么 initializedinitializing 便都是 false 了。

冲突原因已经找到了,我们来看看这个冲突会造成什么。再次看看逻辑合约:

contract Initializable {
    ///....
    modifier initializer() {
        require(
            initializing || isConstructor() || !initialized,
            "Contract instance has already been initialized"
        );

        bool isTopLevelCall = !initializing;
        if (isTopLevelCall) {
            initializing = true;
            initialized = true;
        }

        _;

        if (isTopLevelCall) {
            initializing = false;
        }
    }

    ///....

    // Reserved storage space to allow for layout changes in the future.
    uint256[50] private ______gap;
}

对于 initializer 修饰符,由于 initializingtrue,因此可以通过 require 校验。而下面的 isTopLevelCall 会被赋值为 false,造成 if 语句无法执行,那么 initializing 将永远为 true,也就是说 initializer 已经起不到限制作用了。

黑客就是利用了这个 bug,从而可以调用各种被 initializer 修饰的方法。这些方法中包含一些特权方法,本来只能被管理员调用一次,这下被黑客调用,损失惨重。

解决办法

解决这个 bug 的中心思想就是去除代理合约和逻辑合约的内存冲突,我们来看看官方的新版逻辑合约:

contract Initializable {
    address private proxyAdmin;

    uint256 private filler1;
    uint256 private filler2;

    /**
     * @dev Indicates that the contract has been initialized.
     */
    bool private initialized;

    /**
     * @dev Indicates that the contract is in the process of being initialized.
     */
    bool private initializing;

    /**
     * @dev Modifier to use in the initializer function of a contract.
     */
    modifier initializer() {
        require(msg.sender == proxyAdmin, "Only proxy admin can initialize");
        require(
            initializing || isConstructor() || !initialized,
            "Contract instance has already been initialized"
        );

        bool isTopLevelCall = !initializing;
        if (isTopLevelCall) {
            initializing = true;
            initialized = true;
        }

        _;

        if (isTopLevelCall) {
            initializing = false;
        }
    }

    /// @dev Returns true if and only if the function is running in the constructor
    function isConstructor() private view returns (bool) {
        // extcodesize checks the size of the code stored in an address, and
        // address returns the current address. Since the code is still not
        // deployed when running a constructor, any checks on its code size will
        // yield zero, making it an effective way to detect if a contract is
        // under construction or not.
        address self = address(this);
        uint256 cs;
        assembly {
            cs := extcodesize(self)
        }
        return cs == 0;
    }

    // Reserved storage space to allow for layout changes in the future.
    uint256[47] private ______gap;
}
  1. initializedinitializing 变量后移,预留出空间解决内存冲突
  2. initializer 修饰符中同时添加只能 proxyAdmin 调用的限制,双重保险
  3. 由于前面添加了三个变量,因此最后的 ______gap 预留位置要减少,由之前的 50 个减少为 47 个,这样做是为了兼容之前的数据。避免更新后老数据又冲突了。

总结

一个内存插槽冲突引发的血案,警示我们在编写可升级合约时一定要注意这方面问题。

关于我

欢迎和我交流

参考

Subscribe to xyyme.eth
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.