Reentrancy
Detects functions that transfer Ether or call external contracts before completing state updates, allowing attackers to recursively re-enter and drain funds.
Reentrancy
Overview
Remediation Guide: How to Fix Reentrancy
The reentrancy detector identifies functions that make external calls or transfer Ether before finalizing their own state updates, creating a window during which a malicious callee can re-enter the calling function and observe an inconsistent state. Sigvex detects this pattern by analyzing the control-flow graph (CFG) of decompiled EVM bytecode, tracking the ordering of SSTORE instructions relative to CALL, DELEGATECALL, and STATICCALL opcodes.
This detector targets the classic single-function reentrancy pattern: a contract sends Ether (or calls a contract that triggers a fallback), but has not yet zeroed out the balance variable that guards against double-spending. The canonical exploit is the 2016 DAO attack, in which $60 million was drained by recursively calling withdraw() before the balance was updated.
Why This Is an Issue
Reentrancy is consistently ranked among the top causes of DeFi losses. The attack succeeds because the EVM is synchronous but allows external calls to execute arbitrary code before returning. If a contract transfers value before updating its accounting state, an attacker’s fallback function can call back into the vulnerable function repeatedly, each time passing the stale guard check.
Historical losses are severe: the DAO hack ($60M, 2016), Lendf.Me ($25M, 2020 via ERC-777 reentrancy), Cream Finance ($18.8M, 2021), and dozens of smaller incidents. Modern variants — cross-function reentrancy, read-only reentrancy — are covered by separate detectors (cross-function-reentrancy, read-only-reentrancy).
How to Resolve
Apply the Checks-Effects-Interactions pattern: perform all state checks first, then update state, and only then make external calls.
// Before: Vulnerable — external call before state update
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount, "Insufficient balance");
// State is NOT updated before the external call
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
balances[msg.sender] -= amount; // Too late — reentrant call already saw old balance
}
// After: Fixed — Checks-Effects-Interactions
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount, "Insufficient balance");
balances[msg.sender] -= amount; // Update state FIRST
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
Alternatively, use OpenZeppelin’s ReentrancyGuard:
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract SafeVault is ReentrancyGuard {
function withdraw(uint256 amount) external nonReentrant {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount;
(bool success, ) = msg.sender.call{value: amount}("");
require(success);
}
}
Examples
Vulnerable Code
// Classic vulnerable pattern: external call before state update
contract VulnerableVault {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
// VULNERABLE: call before state update
(bool success, ) = msg.sender.call{value: amount}("");
require(success);
balances[msg.sender] = 0; // Attacker has already re-entered before this executes
}
}
Fixed Code
// Fixed: Checks-Effects-Interactions
contract SafeVault {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
balances[msg.sender] = 0; // State update FIRST
(bool success, ) = msg.sender.call{value: amount}("");
require(success);
}
}
Sample Sigvex Output
{
"detector_id": "reentrancy",
"severity": "critical",
"confidence": 0.92,
"description": "External call at offset 0x8a occurs before SSTORE at offset 0xb2 in function withdraw(). An attacker can re-enter and drain funds.",
"location": { "function": "withdraw()", "offset": 138 }
}
Detection Methodology
Sigvex decompiles the contract bytecode into its High-level Intermediate Representation (HIR). The detector then:
- Identifies external call sites: Scans for
CALL,DELEGATECALL,CALLCODE, andSTATICCALLopcodes in each function’s CFG blocks. - Tracks state-writing instructions: Identifies
SSTOREoperations that update balance or guard variables. - Checks ordering: For each function, verifies whether any
SSTOREthat guards the call occurs after the call site in the CFG topological order. - Calculates confidence: High confidence (0.85–0.95) when the pattern is clear; medium confidence (0.50–0.75) when inter-procedural analysis is needed or the called function is unknown.
The detector considers aliasing and variable propagation to reduce false positives where an intermediate variable is updated before the call but ultimately writes back after.
Limitations
False positives:
- Contracts that use a reentrancy mutex (
nonReentrantguard) at the bytecode level may still be flagged if the guard variable’s SSTORE follows the call in the bytecode layout but logically precedes reentry. - Proxy contracts where the external call delegates to a trusted implementation may produce false positives.
False negatives:
- Cross-function reentrancy (where state is written in function A but attacked via function B) is handled by the separate
cross-function-reentrancydetector. - Read-only reentrancy (where a read-only view is exploited during a call) is handled by
read-only-reentrancy. - Reentrancy via
transfer()orsend()is missed because those functions are limited to 2300 gas in older Solidity versions (though this limit was removed in Berlin).
Related Detectors
- Cross-Function Reentrancy — detects reentrancy that pivots through a second function with a shared state variable
- Unchecked Call — detects unchecked return values from external calls