In our previous article, we concluded that call is the recommended way to send ETH in Solidity.
But call's flexibility comes with a trade-off — it is the entry point for one of the most infamous smart contract exploits: the Reentrancy Attack.
What Is a Reentrancy Attack?
An attacker exploits a contract vulnerability to repeatedly call back into the same contract before the first execution finishes, draining its funds in a loop.
The Vulnerable Contract
Let's call this contract ContractA:
contract ContractA {
mapping(address => uint) public balances;
function withdraw(uint amount) public {
require(balances[msg.sender] >= amount);
// ⚠️ External call happens BEFORE state update
(bool success, ) = msg.sender.call{value: amount}("");
require(success);
// Balance is reduced AFTER the call — too late
balances[msg.sender] -= amount;
}
}⚠️ The critical flaw: the balance is deducted after the ETH is sent. This window is what attackers exploit.
The Attacker's Contract
contract Attack {
ContractA public contractA;
// Step 1: deposit ETH, then trigger the first withdraw
function attack() external payable {
contractA.deposit{value: msg.value}();
contractA.withdraw(msg.value);
}
// Step 2: every time ETH arrives, call withdraw again
receive() external payable {
contractA.withdraw(msg.value);
}
}How the attack unfolds
- The attacker deposits ETH into
ContractAto build a legitimate balance. - The attacker calls
withdraw(amount).ContractApasses the balance check and executes:(bool success, ) = msg.sender.call{value: amount}(""); - Because
msg.senderis the attacker contract, itsreceive()function fires beforeContractAhas a chance to update the balance. - Inside
receive(), the attacker immediately callswithdraw()again. The balance check still passes — the state hasn't changed yet. - This loop continues until
ContractA's ETH balance hits zero.
Root Cause
The core issue is that state is updated after the external call. When call hands control to an external address, an attacker can re-enter the same function before the bookkeeping happens.
call itself is not the bug — but because it allows arbitrary logic to execute in the callee, a poorly ordered contract can be exploited.
How to Prevent Reentrancy
1. Checks-Effects-Interactions Pattern
Always update state before making external calls:
function withdraw(uint amount) public {
// ✅ Check
require(balances[msg.sender] >= amount);
// ✅ Effect — update state FIRST
balances[msg.sender] -= amount;
// ✅ Interaction — external call LAST
(bool success, ) = msg.sender.call{value: amount}("");
require(success);
}2. ReentrancyGuard (OpenZeppelin)
Adding nonReentrant from OpenZeppelin is a battle-tested safety net. The Assetslink batch transfer contract uses it on batchTransferNative and batchTransferToken because they send native token to msg.sender (refunds) and to the fee collector after external transfers.
function batchTransferNative(Transfer[] calldata transfers)
external
payable
nonReentrant
{
// ... perform transfers, compute refundTotal ...
if (refundTotal > 0) {
(bool refSent, ) = msg.sender.call{value: refundTotal}("");
require(refSent, "Refund transfer failed");
}
}nonReentrant prevents a malicious msg.sender from re-entering the batch entrypoint during the refund call.
Summary
- Reentrancy attacks exploit the gap between an external call and a state update.
- Follow Checks-Effects-Interactions: always update state before calling out.
- Use OpenZeppelin's ReentrancyGuard as a second layer of protection.