🛑Vulnerability: Withdrawals Can Be Locked Forever If Recipient Is a Contract

In some Ethereum smart contracts, withdrawals of assets like ETH or ERC20 tokens are handled through a function that transfers the tokens back to the user after a cooldown period. One common method for sending ETH is the low-level transfer() function. However, using transfer() can inadvertently lock funds forever when the recipient is a contract, particularly if the recipient contract has a receive() or fallback function that requires more gas than what is forwarded by transfer().

This vulnerability can result in the permanent loss of funds, as the transfer fails and the user cannot retrieve their assets. This section will explain the mechanics of this vulnerability, the specific attack flow, and how to mitigate the issue to ensure that users, including contracts, can safely withdraw their funds.


Vulnerability Explanation

When the transfer() function is used to send ETH, it only forwards 2300 gas to the recipient. While this is sufficient for basic operations like receiving ETH in a standard externally owned account (EOA), it is not enough to cover more complex logic that might exist in a contract account (e.g., a multisig wallet or a contract implementing more complex functionality).

In scenarios where a user initiates a withdrawal from a contract account that requires more than 2300 gas to execute the receive() or fallback() function, the transfer() call will fail, causing the transaction to revert. Since the recipient contract cannot execute its logic, the funds remain locked in the smart contract, and the user cannot retrieve their assets.


Example Scenario: Locked Withdrawals

Let’s explore how this issue can occur in the context of a WithdrawQueue contract, where users are allowed to withdraw ETH or ERC20 tokens after a cooldown period.

Scenario Steps:

  1. User Initiates Withdrawal:

    • Alice calls the withdraw() function from her multisig wallet contract to withdraw 10 ETH worth of tokens. The multisig contract is registered as the msg.sender and is stored as the withdrawal requester in the smart contract.

  2. Cooldown Period:

    • After the cooldown period, Alice is ready to claim her withdrawn ETH using the claim() function.

  3. Withdrawal via transfer():

    • The contract attempts to send ETH back to Alice’s multisig wallet using transfer()

payable(msg.sender).transfer(_withdrawRequest.amountToRedeem);
  1. However, since transfer() only forwards 2300 gas, it is not enough for Alice’s multisig wallet to execute its receive() function, which requires more than 2300 gas.

  2. Transaction Fails:

    • The transfer() call fails due to an out of gas error, and the claim() transaction reverts. Alice is unable to withdraw her funds because her contract requires more gas to process the ETH transfer.

  3. Funds Locked Forever:

    • The 10 ETH is now permanently locked in the WithdrawQueue contract. Since the withdrawal request was tied to Alice's multisig wallet, no one else can claim the funds, and Alice cannot retrieve them due to the gas limitation imposed by transfer().


Why This Happens

The core issue lies in the fact that transfer() is too restrictive in the amount of gas it forwards to the recipient. While transfer() is considered a safer method for sending ETH because it limits the gas available to the recipient, it fails when interacting with contracts that need more than 2300 gas to process the transfer.

Many multisig wallets, smart contract wallets, or DeFi protocols have a receive() or fallback() function that may require significantly more gas to perform necessary actions, such as emitting events, updating internal state, or interacting with other contracts. When transfer() is used, these contracts cannot process the transaction, causing it to fail.

Last updated