Bridging the gap between complex security reports and the practical knowledge developers need. This project provides deep-dive articles on the top attack vectors to help developers build safer protocols.
Reentrancy is arguably the most common vulnerability in smart contract. It occurs when a function makes an external call to an untrusted contract before it finalizes its own state changes.
This allows an attacker to repeatedly call back into the original function, or other functions, exploiting the incomplete state to bypass security checks and drain funds.
In June 2016, “The DAO”, a decentralized fund, was exploited via a classic reentrancy attack. The attacker repeatedly called the withdraw function. Each time, the contract sent ETH to the attacker before updating their internal balance ledger. The attacker’s contract re-entered the withdraw function again and again, draining 3.6 million ETH and triggering a contentious hard fork of the Ethereum network that created Ethereum Classic.
The vulnerability can manifest in several ways, from simple single-function exploits to complex cross-contract interactions.
Single Function Reentrancy
A function makes an external fund transfer before updating the user’s balance.
mapping(address => uint) public balances;
function withdraw() external {
uint amount = balances[msg.sender];
require(amount > 0);
// VULNERABILITY: External call is made BEFORE state is updated.
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed.");
// State update happens too late.
balances[msg.sender] = 0;
}
Attacker’s Playbook: The attacker’s contract calls withdraw(). Its receive() function is triggered by the .call{value: amount}. Inside receive(), it calls withdraw() again. Because the line balances[msg.sender] = 0; has not yet been reached, the require check passes, and another transfer is sent. This loop continues until the contract is drained.
Cross-Function Reentrancy
It is where the re-entrant call targets a different function within the same contract. A reentrancy guard on the withdraw function is useless if another function can manipulate the same state.
function withdraw() external nonReentrant { // Guard is on the wrong function
uint amount = balances[msg.sender];
// ... external call that re-enters ...
balances[msg.sender] = 0;
}
function transfer(address to, uint amount) external {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount;
balances[to] += amount;
}
Attacker’s Playbook: The attacker calls withdraw(). The re-entrant call doesn’t target withdraw() again but instead calls transfer(). Since the attacker’s balance hasn’t been zeroed out yet, they can transfer their soon to be deleted balance to another account they control.
Read-Only Reentrancy
A read-only reentrancy attack occurs when an attacker executes an external call that triggers a subsequent function, which then reads the contract’s state while it is in a temporary, incomplete, or manipulated condition.
Cross-chain Reentrancy
This type of attack involves two or more distinct contracts, where an external call from the primary victim contract re-enters a function in a second, related contract that shares or interacts with the same state variables.
Any external call that can trigger a callback to a user-controlled contract is a potential vector. Always check for:
Low-level call(): The function for sending ETH.
ERC721 safeMint & safeTransferFrom: These make external calls where the recipient onERC721Received if it is a contract, can use it to re-enter.
ERC777 transfer: This standard has tokensToSend and tokensReceived hooks that can re-enter.
ERC1155 safeTransferFrom & safeBatchTransferFrom: These call onERC1155Received or onERC1155BatchReceived.
Advanced/Custom ERCs: Any token with a *AndCall pattern (e.g., ERC223, ERC677, ERC1363) or custom callbacks.
Checks-Effects-Interactions Pattern
This is the single most important pattern for preventing reentrancy. Structure your functions in this order:
Checks: Perform all validations first require(balance > 0).
Effects: Write all state changes to your contract before the external call balances[msg.sender] = 0.
Interactions: Make the external call last msg.sender.call{...} .
function withdraw() external {
uint amount = balances[msg.sender];
// CHECK
require(amount > 0);
// EFFECT
balances[msg.sender] = 0;
// INTERACTION
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed.");
}
Use a Reentrancy Guard
For an explicit layer of security, use OpenZeppelin’s ReentrancyGuard or ReentrancyGuardTransient. It provides a nonReentrant modifier that locks the contract during an external call, preventing any re-entrant calls to functions with the same modifier.
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract MyContract is ReentrancyGuard {
function guardedWithdraw() external nonReentrant {
// ... logic with external call ...
}
}
Note: This guard is highly effective but must be applied to all functions that share state to prevent Cross-Function Reentrancy.
Use Pull-over-Push Payments
Instead of the contract “pushing” funds to users, have users “pull” them. The withdraw function doesn’t transfer ETH; it just credits an internal balance that the user can claim via a separate, isolated function call. This isolates the state change from the interaction.
Understand Transient Storage (EIP-1153)
Introduced in the Cancun upgrade, transient storage (TSTORE, TLOAD) provides a gas-efficient way to store data only for the duration of a single transaction. This alternative for creating reentrancy locks, as it avoids costly SLOAD/SSTORE operations and automatically clears after the transaction, leaving no storage footprint. It’s the future of robust, gas-efficient reentrancy protection.