Skip to content
On this page

Guardian of the Bridge: Unmasking the "Withdrawal" Vulnerability in Damn Vulnerable DeFi

Imagine a crucial bridge connecting Layer 2 (L2) to Layer 1 (L1) on a blockchain, entrusted with millions of valuable tokens. You, as a vigilant bridge operator, receive a batch of withdrawal requests. Four seem legitimate, but one… one feels off. All funds are at risk if you fail. Your mission: finalize all withdrawals, prevent the suspicious one from draining the bridge, and secure the assets. This is the intriguing "Withdrawal" challenge from Damn Vulnerable DeFi.

Let's dive into the architecture and uncover how this ingenious vulnerability works and how to defeat it.

The Bridge's Blueprint: A Three-Contract Dance

The L1 side of our bridge operates through three key contracts:

  1. L1Gateway.sol: This is the primary entry point for L2 withdrawals onto L1.

    • It enforces a DELAY period (7 days) before withdrawals can be finalized.
    • It usually requires a MerkleProof against a root of valid withdrawals.
    • Crucially for our role, as an OPERATOR_ROLE holder, we can bypass the Merkle proof validation.
    • The Key Mechanic: Before initiating the actual withdrawal, L1Gateway temporarily sets its public xSender variable to the l2Sender address that initiated the withdrawal on L2. This xSender is then unset after the call.
  2. L1Forwarder.sol: This contract acts as an intermediary, receiving messages from the L1Gateway and forwarding them to their ultimate L1 destination (in our case, the TokenBridge).

    • It records successfulMessages and failedMessages to prevent replay attacks or re-processing failed transactions.
    • The Key Mechanic: When forwardMessage is called (by L1Gateway), it sets its internal context.l2Sender to the l2Sender parameter it received. This context.l2Sender can later be retrieved via getSender().
  3. TokenBridge.sol: This is the vault, holding the precious Damn Valuable Tokens (DVT). Its executeTokenWithdrawal function is responsible for transferring tokens to the L1 receiver.

    • The Key Vulnerability Check: This function includes a critical authorization check:
      solidity
      if (msg.sender != address(l1Forwarder) || l1Forwarder.getSender() == otherBridge) revert Unauthorized();
      
      This means:
      • msg.sender must be the L1Forwarder (which it will be).
      • AND l1Forwarder.getSender() must not be equal to otherBridge. The otherBridge address represents the L2 side of the bridge itself.

The Subtle Trap: A Revert Disguised as Finality

The vulnerability hinges on the precise order of operations and the state changes across these contracts:

  1. When you, as an operator, call L1Gateway.finalizeWithdrawal, the L1Gateway first updates its internal finalizedWithdrawals mapping, marking the specific withdrawal leaf as true. This means, from L1Gateway's perspective, the withdrawal is processed.
  2. Only after marking it finalized, L1Gateway makes an external call to L1Forwarder.forwardMessage.
  3. L1Forwarder then makes an external call to TokenBridge.executeTokenWithdrawal.
  4. Inside TokenBridge.executeTokenWithdrawal, the Unauthorized check fires. l1Forwarder.getSender() will return the l2Sender value that was passed to L1Forwarder.forwardMessage.
  5. If this l2Sender (the original L2 initiator of the withdrawal) happens to be the otherBridge address, the Unauthorized() error is triggered, and TokenBridge.executeTokenWithdrawal reverts.

The critical insight: The TokenBridge transaction reverts, preventing tokens from being transferred, but the L1Gateway has already marked the withdrawal as finalized!

The Guardian's Solution

Your task is to finalize all the given withdrawals, ensuring the "suspicious" one doesn't actually transfer tokens, thus protecting the bridge's main funds.

  1. Identify the Culprit: We must examine the provided L2 withdrawal logs (which, in the challenge, are hardcoded into the solution test file). We're looking for one of the l2Sender addresses (the l2SenderFs parameters in the solution code) that matches the otherBridge address. This otherBridge address is typically the L2 counterpart to our L1 TokenBridge.

    Let's assume, through analysis of the provided data, that one of the l2Sender values is indeed the otherBridge's address. (For example, if l2SenderFs[4] in the solution code refers to otherBridge).

  2. Execute the Finalizations: As the operator, we can bypass Merkle proofs. We iterate through all the withdrawal requests. For each one, we craft the message to L1Forwarder (which itself contains the message for TokenBridge) and call l1Gateway.finalizeWithdrawal with the appropriate parameters.

    • For the three legitimate withdrawals, l1Forwarder.getSender() == otherBridge will be false, and executeTokenWithdrawal will proceed, transferring the tokens as intended.
    • For the one "suspicious" withdrawal where l2Sender (passed to L1Forwarder) is otherBridge, the TokenBridge.executeTokenWithdrawal function will revert due to the Unauthorized check. This prevents the malicious transfer.

Crucially, in both cases, L1Gateway will mark the withdrawal as finalized in its finalizedWithdrawals mapping because this state update occurs before the external call to L1Forwarder (and subsequently TokenBridge).

By performing these steps, we successfully finalize all withdrawals as required, prevent the specific malicious withdrawal from draining funds, and keep the majority of DVT tokens safely within the bridge, thus safeguarding the protocol.

Lessons Learned

The "Withdrawal" challenge brilliantly illustrates several key principles in smart contract security:

  • Order of Operations: The sequence of state changes versus external calls is paramount. Updating state before an external call that might revert can lead to inconsistent states or, in this case, a successful defense against a specific attack.
  • Context Management: Understanding how xSender and context.l2Sender provide temporary context across contract calls is vital. Misinterpreting or misusing this context can create vulnerabilities.
  • Layered Authorization: While the TokenBridge had an authorization check, its reliance on a value (l1Forwarder.getSender()) that could inadvertently be set to a "forbidden" address created a loophole.

This challenge serves as a powerful reminder for developers to rigorously audit cross-contract interactions and authorization logic, especially in complex bridge designs.

Ready to test your skills and explore more vulnerabilities? Check out Damn Vulnerable DeFi at damnvulnerabledefi.xyz.

Built with AiAda