Reentrancy attacks and how to deal with them

Srinivas Joshi
CoinsBench
Published in
5 min readFeb 5, 2023

--

Reentrancy attack

The ability of Ethereum smart contracts to use and call code from other external contracts is one of their features. The contracts must submit external calls in order to do these tasks. Attackers may take advantage of these external calls to force the contracts to run additional code.

An attacker can carefully craft a contract at an external address that contains malicious code in the fallback function. Thus, when a contract sends ether to this address, it will invoke the malicious code.

The term “reentrancy” comes from the fact that the external malicious contract calls a function on the vulnerable contract and the path of code execution “reenters” it.

Consider this simple vulnerable contract :

contract EtherStore {

uint256 public withdrawalLimit = 1 ether;
mapping(address => uint256) public lastWithdrawTime;
mapping(address => uint256) public balances;

function depositFunds() external payable {
//update sender balance
balances[msg.sender] += msg.value;
}

function withdrawFunds (uint256 _weiToWithdraw) public {
//sender should have required balance
require(balances[msg.sender] >= _weiToWithdraw);

// limit the withdrawal
require(_weiToWithdraw <= withdrawalLimit);

// limit the time allowed to withdraw
require(now >= lastWithdrawTime[msg.sender] + 1 weeks);

// send ether to the user
require(msg.sender.call.value(_weiToWithdraw)());

//update state
balances[msg.sender] -= _weiToWithdraw;
lastWithdrawTime[msg.sender] = now;
}
}

This contract has two public functions, depositFunds and withdrawFunds. The depositFunds function simply increments the sender’s balance. The withdrawFunds function allows the sender to specify the amount of wei to withdraw.

The vulnerability is in line where the contract sends the user their requested amount of ether. Consider an attacker who has created the contract in like so.

import "EtherStore.sol";

contract Attack {
EtherStore public etherStore;

// intialize the etherStore variable with the contract address
constructor(address _etherStoreAddress) {
etherStore = EtherStore(_etherStoreAddress);
}

function attackEtherStore() external payable {
// attack to the nearest ether
require(msg.value >= 1 ether);
// send eth to the depositFunds() function
etherStore.depositFunds.value(1 ether)();
// start the magic
etherStore.withdrawFunds(1 ether);
}

function collectEther() public {
msg.sender.transfer(this.balance);
}

// fallback function - where the magic happens
function () payable {
if (etherStore.balance > 1 ether) {
etherStore.withdrawFunds(1 ether);
}
}
}

The malicious contract would first be created by the attacker (let’s say at address 0x0… 123), using the contract address of the EtherStore as the parameter for the constructor. By doing so, the public variable etherStore would be initialised and pointed toward the target contract.

The attacker would next use some amount of ether bigger than or equal to 1 as to run the attackEtherStore function; for now, let’s suppose this amount to be 1 ether. We’ll also assume that several other users have contributed ether to this contract in this example, leaving it with a balance of 10 ether at the moment. The following will then occur :

  1. The depositFunds function of the EtherStore contract will be called with a msg.value of 1 ether. The sender (msg.sender) will be the malicious contract (0x0…​123). Thus, balances[0x0..123] = 1 ether.
  2. The malicious contract will then call the withdrawFunds function of the EtherStore contract with a parameter of 1 ether. This will pass all the requirements as no previous withdrawals have been made.
  3. The contract will send 1 ether back to the malicious contract.
  4. The payment to the malicious contract will then execute the fallback function.
  5. The if statement succeeds because the EtherStore contract’s overall balance has decreased from 10 ether to 9 ether .
  6. The fallback function calls the EtherStore withdrawFunds function again and 'reenters' the EtherStore contract.
  7. In this second call to withdrawFunds, the attacking contract’s balance is still 1 ether as balances[0x0..123] has not yet been updated. Thus, we still have balances[0x0..123] = 1 ether. This is also the case for the lastWithdrawTime variable. Again, we pass all the requirements.
  8. The attacking contract withdraws another 1 ether.
  9. Repeat steps 4–8 until it is no longer the case that EtherStore.balance > 1 .
  10. Once there is 1 (or less) ether left in the EtherStore contract, this if statement will fail. This will then allow further lines of the EtherStore contract to be executed.The balances and lastWithdrawTime mappings will be set and the execution will end.

The final result is that the attacker has withdrawn all but 1 ether from the EtherStore contract in a single transaction.

Preventative Techniques

Reentrancy guard(Method #3)
  1. Send ether to external contracts using the built-in transfer mechanism (where possible). The external call from the transfer function only sends 2300 gas, which is insufficient for the recipient address/contract to call another contract (i.e., reenter the sending contract).
  2. Ensure that all logic that changes state variables happens before ether is sent out of the contract.This is known as the checks-effects-interactions pattern. This is how to implement it :
...

function withdrawFunds (uint256 _weiToWithdraw) public {
require(balances[msg.sender] >= _weiToWithdraw);
require(_weiToWithdraw <= withdrawalLimit);
require(now >= lastWithdrawTime[msg.sender] + 1 weeks);

//update state before sending transaction
balances[msg.sender] -= _weiToWithdraw;
lastWithdrawTime[msg.sender] = now;

// send ether to the user
require(msg.sender.call.value(_weiToWithdraw)());
}

...

3. A third technique is to introduce a mutex — that is, to add a state variable that locks the contract during code execution, preventing reentrant calls.

OpenZeppelin provides contract module that helps prevent reentrant calls to a function. Inheriting from ReentrancyGuard will make the nonReentrant modifier available, which can be applied to functions to make sure there are no nested (reentrant) calls to them. Here is an example of the OpenZeppelin Reentrancy Guard in use :

import "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/release-v4.3/contracts/security/ReentrancyGuard.sol";

contract EtherStore is ReentrancyGuard{

uint256 public withdrawalLimit = 1 ether;
mapping(address => uint256) public lastWithdrawTime;
mapping(address => uint256) public balances;

function depositFunds() external payable {
balances[msg.sender] += msg.value;
}

function withdrawFunds (uint256 _weiToWithdraw) public nonReentrant{
require(balances[msg.sender] >= _weiToWithdraw);
require(_weiToWithdraw <= withdrawalLimit);
require(now >= lastWithdrawTime[msg.sender] + 1 weeks);

balances[msg.sender] -= _weiToWithdraw;
lastWithdrawTime[msg.sender] = now;

msg.sender.transfer(_weiToWithdraw);
}
}

Real world example

A famous real-world Reentrancy attack is the DAO attack which caused a loss of 60 million US dollars back in 2016.Reentrancy played a major role in the attack, which ultimately led to the hard fork that created Ethereum Classic (ETC). For a good analysis of the DAO exploit, see this.

Further reading

  1. Try out Ethernaut_challenge_#10
  2. Understand Read-only_Reentrancy
  3. Understand Pull-Payment_Strategy

--

--

Frontend Developer | Learning and sharing solidity,EVM knowledge 🚀