Skip to content
On this page

Unlocking the Safe: A Backdoor into the DeFi Wallet Registry

In the high-stakes world of decentralized finance, security is paramount. When a system promises robust protection, auditors and white-hat hackers are quick to test its mettle. Today, we delve into the "Backdoor" challenge from Damn Vulnerable DeFi, where a seemingly ironclad WalletRegistry guarding valuable tokens falls prey to a subtle yet devastating vulnerability.

The Setup: A "Secure" Wallet Registry

Our story begins with a WalletRegistry contract designed to incentivize the creation of secure Gnosis Safe wallets within a team. The premise is simple: deploy and register a legitimate Safe wallet, and earn 10 DVT (Damn Vulnerable Token) tokens. The registry proudly boasts "strict safety checks," ensuring that only properly configured Safe wallets are rewarded.

Four beneficiaries – Alice, Bob, Charlie, and David – are eagerly waiting to register their wallets. The registry holds a total of 40 DVT tokens, ready to be distributed. The challenge? Uncover a hidden vulnerability, rescue all funds, and deposit them into a designated recovery account, all in a single, atomic transaction.

The WalletRegistry's proxyCreated function is the gatekeeper. It's invoked as a callback when a user deploys a Safe wallet through the SafeProxyFactory. Before rewarding tokens, it performs several crucial checks:

  1. Caller is Factory: Ensures the call originates from the legitimate SafeProxyFactory.
  2. Correct Singleton: Verifies the deployed Safe uses the expected singleton copy.
  3. Standard Initialization: Confirms the Safe was initialized using the standard Safe::setup function.
  4. Single Owner, One Threshold: Requires the Safe to have exactly one owner and a threshold of one.
  5. Beneficiary Owner: Checks that the sole owner is a registered beneficiary.
  6. No Fallback Manager: This is the critical check: if (fallbackManager != address(0)) { revert InvalidFallbackManager(fallbackManager); }. The registry insists that the new Safe wallet must not have any fallbackManager configured.

At first glance, these checks seem exhaustive. How could an attacker bypass them, let alone drain all funds in one go?

The Illusion of Security: Where the Backdoor Hides

The vulnerability lies in that last "no fallback manager" check, specifically how the WalletRegistry determines if a fallback manager exists.

The WalletRegistry implements a private helper function, _getFallbackManager, which attempts to read the fallbackManager from the deployed Safe wallet's storage:

solidity
function _getFallbackManager(address payable wallet) private view returns (address) {
    return abi.decode(
        Safe(wallet).getStorageAt(uint256(keccak256("fallback_manager.handler.address")), 0x20), (address)
    );
}

Here's the critical flaw: While Safe.sol does use a fallback_manager to handle delegate calls to unregistered functions, it stores this manager at an internal storage slot specific to the Gnosis Safe implementation (often a hash derived from the contract type or a predefined constant). The WalletRegistry, however, attempts to read from a slot derived from keccak256("fallback_manager.handler.address").

These are two different storage slots!

This means an attacker can initialize a Gnosis Safe with a custom fallbackManager through the Safe::setup function, and the WalletRegistry's _getFallbackManager function will still return address(0) because it's looking at the wrong, empty storage slot. It's like checking the wrong locker number for a hidden key – the key is there, but you're not looking where it actually is!

This storage slot mismatch creates a "backdoor." We can deploy a Safe wallet that does have a malicious fallback manager, and the WalletRegistry will unwittingly approve it and award it 10 DVT tokens.

Crafting the Attack: Unlocking the Funds

The challenge requires draining all 40 DVT tokens from the registry into a recovery account in a single transaction. This implies our attack needs to be self-contained and orchestrate multiple steps efficiently.

Our solution involves deploying a custom MyContract that will:

  1. Deploy Malicious Modules:

    • TokenToRecovery: A custom Safe module whose purpose is to transfer all DVT tokens it holds to the recovery account.
    • EnableModule: A simple helper contract that can be delegatecall-ed by the Safe to enable another module (our TokenToRecovery module).
  2. Iterate Through Beneficiaries: For each of Alice, Bob, Charlie, and David:

    • Craft a Malicious Initializer: MyContract prepares the initializer data for the Safe::setup function. This initializer is crucial:
      • It sets EnableModule as the Safe's fallbackManager.
      • It includes data for the fallbackManager to immediately call ModuleManager.enableModule within the Safe, activating our TokenToRecovery module.
    • Deploy the "Backdoored" Safe: MyContract calls walletFactory.createProxyWithCallback, passing the malicious initializer and setting the WalletRegistry as the callback.
    • Pass the Registry's Checks: The WalletRegistry::proxyCreated function executes. All its checks pass, including the flawed _getFallbackManager check (which returns address(0) despite EnableModule being the actual fallback manager). The registry then unsuspectingly transfers 10 DVT tokens to this newly created, compromised Safe.
    • Drain the Funds: Crucially, immediately after the createProxyWithCallback returns (meaning the Safe is created, has EnableModule as its manager, TokenToRecovery is enabled, and 10 DVT are inside), MyContract calls TokenToRecovery.execTransactionFromModule(address(proxy)). This triggers the Safe to delegatecall TokenToRecovery::toRecovery, which transfers the 10 DVT from the Safe directly to the recovery account.

By repeating this process for all four beneficiaries within the constructor of MyContract, all 40 DVT tokens are successively deposited into the compromised Safes and then immediately drained to the recovery account. All these operations unfold within the single transaction that deploys MyContract.

The Takeaway: Auditing Storage and Low-Level Interactions

This "Backdoor" challenge is a brilliant illustration of how subtle misunderstandings of underlying contract implementations can lead to critical vulnerabilities.

Key takeaways for developers and auditors:

  • Don't Assume Storage Layouts: When interacting with external contracts, especially those as complex as Gnosis Safe, never assume knowledge of their internal storage layout or rely on custom getStorageAt calls unless absolutely certain of the address and slot. Rely on documented interfaces and getter functions.
  • Deep Dive into Dependencies: A "strict safety check" is only as good as its implementation. For critical security checks, understand exactly how the underlying contract works, especially regarding initialization and state variables.
  • The Power of createProxyWithCallback: This Gnosis Safe factory function is incredibly powerful for complex deployments, but it also allows for intricate attack vectors if the callback contract doesn't fully understand the proxy's initialization.

The "Backdoor" challenge serves as a stark reminder that in DeFi, even seemingly robust security measures can harbor hidden vulnerabilities waiting to be uncovered by a keen eye and a deep understanding of EVM mechanics. The continuous pursuit of secure code requires vigilance, meticulous auditing, and an unwavering commitment to understanding every layer of the decentralized stack.

Built with AiAda