October DealsAmazon USOctober deal check: compare before you payAmazon US: current deals, useful picks and tech finds.Check DealsWindows FixRecommendedWindows errors stealing your time? Find the fix fastScan stability, cleanup and performance issues.Fix NowOctober DealsAmazon USDeal season is back - check today's better picksAmazon US: current deals, useful picks and tech finds.See Picks×
Skip to content
The Finance Base
The Money Desk · Blog
Re:

Hack Solidity: Reentrancy Attacks—How They Work and How to Prevent Them

A practical, local-only guide to Solidity reentrancy: trace the stale-balance callback, repair the bank with CEI and ReentrancyGuard, and test cross-function, token and read-only variants.
From TheFinanceBase Team7 min to read

Free tools Windows power users keep installed

One-click scans. No signup required.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Reentrancy is an unexpected control-flow bug: a contract calls untrusted code before completing its own state update, and that code calls back into the contract while its accounting is still stale. The classic safe order is checks → effects → interactions, backed by focused tests and, where appropriate, a reentrancy guard.

Everything below is for a local sandbox. Do not deploy the demonstration attacker against a live protocol or third-party contract.

Reentrancy in one minute

The Ethereum Virtual Machine executes instructions sequentially; reentrancy is not simultaneous multithreading. The danger is that an external call transfers control to another contract before the first contract has finished its state transition. The callee can then enter the original contract again.

That differs from ordinary recursion. Recursion is deliberately structured code calling itself or another function. Reentrancy is an external contract regaining control during an incomplete operation. Ethereum’s security guidance describes the classic pattern and the checks-effects-interactions remedy at ethereum.org’s smart-contract security documentation.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
Unsafe order Safe order
Check balance → external call → update balance Check balance → update balance → external call

Why external calls are a security boundary

Any interaction with an untrusted contract can transfer control. That includes sending Ether with call, calling an arbitrary contract function, and interacting with token contracts:

(bool ok, ) = payable(msg.sender).call{value: amount}("");
otherContract.externalFunction();
token.transfer(to, amount);

An ERC-20-shaped interface does not guarantee code-free behavior. A malicious or non-standard token can execute code during a transfer or callback, and NFT and token standards may invoke receiver or sender hooks. Solidity’s security considerations explain why external calls must be treated as potential control-flow transfers: docs.soliditylang.org/en/latest/security-considerations.html.

A deliberately vulnerable bank

This Solidity 0.8.20 example is educational only and is not production code:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract VulnerableBank {
    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, "Nothing to withdraw");

        // Vulnerable: control leaves the contract first.
        (bool ok, ) = payable(msg.sender).call{value: amount}("");
        require(ok, "ETH transfer failed");

        // Too late: a callback may already have re-entered.
        balances[msg.sender] = 0;
    }
}

The problem is not that call is forbidden. The problem is that the balance remains nonzero while the recipient’s code is running.

What’s actually slowing this PC down?

Pick the symptom - the matching free tool is one click away.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Local-only attacker and call trace

For a local test, the recipient can deliberately call withdraw again from its receive function:

contract ReentrancyAttacker {
    VulnerableBank public immutable bank;
    uint256 public attackCount;

    constructor(address payable bankAddress) {
        bank = VulnerableBank(bankAddress);
    }

    function beginAttack() external payable {
        require(msg.value > 0, "Seed required");
        bank.deposit{value: msg.value}();
        bank.withdraw();
    }

    receive() external payable {
        attackCount++;
        if (address(bank).balance >= 1 ether) {
            bank.withdraw();
        }
    }

    function collect() external {
        payable(msg.sender).transfer(address(this).balance);
    }
}

The important sequence is:

  1. An externally owned account calls beginAttack.
  2. The attacker deposits its seed Ether and calls the bank’s withdrawal function.
  3. The bank sends Ether to the attacker contract.
  4. The attacker’s receive function runs before the first withdrawal sets the balance to zero.
  5. receive calls withdraw again, so the bank reads the same stale balance.
  6. The cycle ends when the callback condition is false, available funds are exhausted, or the transaction runs out of gas.

At the first reentrant check, the victim still records 1 ETH for the attacker even though Ether has already left:

Execution point Recorded attacker balance Victim Ether balance
Before attack 1 ETH Seed plus other funds
First withdrawal check 1 ETH Unchanged
Inside first callback 1 ETH Reduced by first send
Reentrant withdrawal check Still 1 ETH Reduced again
After final unwind 0 ETH Potentially drained

The exact amount depends on the bank’s available funds, gas, and callback condition. The point is the stale accounting, not a target balance.

Fix one: checks-effects-interactions

Move the accounting update before the external call:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
function withdraw() external {
    uint256 amount = balances[msg.sender];
    require(amount > 0, "Nothing to withdraw");

    // Effect: commit the state transition first.
    balances[msg.sender] = 0;

    // Interaction: transfer control last.
    (bool ok, ) = payable(msg.sender).call{value: amount}("");
    require(ok, "ETH transfer failed");
}

The three stages are:

  • Checks: validate permissions, balances, limits and other preconditions.
  • Effects: update storage to represent the completed operation.
  • Interactions: call external contracts or send Ether.

If the attacker re-enters after the balance is set to zero, the nested call fails its balance check. CEI is foundational, but it is not a proof that every invariant, callback path or cross-contract interaction is safe.

Fix two: OpenZeppelin’s reentrancy guard

With OpenZeppelin Contracts 5.x, import the storage-based guard explicitly:

import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

contract SafeBank is ReentrancyGuard {
    mapping(address => uint256) public balances;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw() external nonReentrant {
        uint256 amount = balances[msg.sender];
        require(amount > 0, "Nothing to withdraw");
        balances[msg.sender] = 0;

        (bool ok, ) = payable(msg.sender).call{value: amount}("");
        require(ok, "ETH transfer failed");
    }
}

The nonReentrant modifier blocks nested entry into functions using that guard. It does not secure unguarded sibling functions, other contracts, proxy storage assumptions or the protocol’s wider invariants. OpenZeppelin’s API is documented at docs.openzeppelin.com/contracts/5.x/api/utils.

The shared-lock limitation

Two nonReentrant functions cannot directly call one another because they share one lock. Expose a guarded external entry point and put shared logic in a private function:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
function withdraw() external nonReentrant {
    _withdraw(msg.sender);
}

function _withdraw(address account) private {
    // Core state transition and interaction
}

Transient-storage variant

OpenZeppelin 5.x also documents ReentrancyGuardTransient, imported from @openzeppelin/contracts/utils/ReentrancyGuardTransient.sol. It requires a network supporting EIP-1153. Treat its availability as version- and network-dependent; do not assume a universal gas saving or migration requirement. See the implementation notes at github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/utils/ReentrancyGuardTransient.sol.

Fix three: pull payments

Instead of delivering Ether during another business operation, record what a recipient is owed and let the recipient claim it separately:

mapping(address => uint256) public pendingPayments;

function recordPayment(address recipient, uint256 amount) internal {
    pendingPayments[recipient] += amount;
}

function withdrawPayment() external nonReentrant {
    uint256 amount = pendingPayments[msg.sender];
    require(amount > 0, "No payment due");
    pendingPayments[msg.sender] = 0;

    (bool ok, ) = payable(msg.sender).call{value: amount}("");
    require(ok, "Payment failed");
}

Pull payments separate business accounting from delivery, reduce accidental callbacks to arbitrary recipients and can limit denial-of-service caused by one recipient reverting. Users must claim separately, accounting must remain correct, and the withdrawal path still needs CEI or a guard.

Why transfer and send are not complete defenses

Older tutorials recommend transfer or send because they forward a 2,300-gas stipend. Gas-cost changes can invalidate assumptions about what fits in that stipend, and current static-analysis guidance warns against treating either operation as a reentrancy guarantee. Slither documents this limitation at github.com/crytic/slither/wiki/Detector-Documentation. Make state updates safe first, then choose an Ether-transfer mechanism appropriate for the application. OpenZeppelin’s call utility guidance is at github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/utils/Address.sol.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Variants auditors must consider

Cross-function reentrancy

A guard on withdraw may not protect transfer, claim or borrow if those functions read or write the same state. Review invariants across every externally callable function.

Cross-contract reentrancy

Contract A can call B, and B can call back into A. The vulnerable state may be distributed across several contracts, so reviewing one source file is insufficient.

Read-only reentrancy

A view function can expose intermediate state while another function is executing. A separate protocol may use that value for pricing, collateral or accounting. OpenZeppelin 5.x provides nonReentrantView() for this specific coordination problem; it does not make a view function state-changing or prove the underlying design safe.

Token and NFT callbacks

ERC-721 transfers can invoke onERC721Received; ERC-777-style transfers can invoke sender or recipient hooks; arbitrary token contracts can behave maliciously around transfers or balance queries. Treat these paths as external interactions.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Proxies, delegatecall and failed calls

delegatecall runs implementation code in proxy storage, so guard slots and upgrade assumptions must be reviewed together. Separately, every failed external call needs an intentional policy: revert the whole operation or preserve a withdrawal credit. A pause switch may contain an incident, but it does not repair the bug.

OWASP classifies reentrancy as a current smart-contract risk, including several variants: OWASP SC05: Reentrancy Attacks.

Testing the vulnerable and fixed versions

Minimum local test plan

  • Use a local EVM or development framework; never test the exploit against a live third-party protocol.
  • Give the victim the attacker’s 1 ETH deposit plus another account’s funds.
  • Assert that the vulnerable transaction succeeds and the attacker receives more than its recorded deposit.
  • Run the same scenario against the CEI-fixed contract and assert that the attacker cannot withdraw more than its balance.
  • Test a recipient whose receive function reverts. Decide whether the operation intentionally reverts or creates a claimable credit.
  • Test repeated callbacks, token hooks and NFT receiver hooks where the production system supports them.

Guard-specific tests

  • A direct protected call succeeds.
  • A nested call to another guarded function fails.
  • An external guarded entry point calling a private core succeeds.
  • The guard resets after a reverted transaction.

Static analysis and fuzzing

Run Slither from the project root:

slither .
slither . --triage-mode

Its documented detectors include reentrancy-eth, reentrancy-no-eth, reentrancy-benign, reentrancy-events, reentrancy-unlimited-gas and reentrancy-balance. Slither finds recognizable patterns, but it cannot prove economic safety or every cross-contract invariant. Add fuzz and invariant tests that assert total liabilities, balances and authorization rules remain consistent across arbitrary callback sequences.

Production review checklist

  • Locate every external call, Ether transfer, token operation and receiver hook.
  • Confirm storage effects occur before interactions where the invariant requires it.
  • Review all state-sharing sibling functions, not just the function that sends Ether.
  • Identify whether any recipient can be a contract, even if an EOA was expected.
  • Use a guard where appropriate, while checking composability and unguarded paths.
  • Review proxy, delegatecall and upgrade storage assumptions.
  • Test reverting recipients and define recovery behavior.
  • Do not treat transfer, send, pausing, static analysis or an audit as a universal fix.
  • Pin compiler and library versions; avoid legacy syntax such as .call.value(amount)().
  • Keep exploit demonstrations in isolated local environments.

Safe ways to keep learning

For guided practice, use OpenZeppelin Ethernaut and Damn Vulnerable DeFi. For defensive building blocks, consult OpenZeppelin Contracts. A simulator such as Tenderly can help trace local or forked transactions without broadcasting them. None of these tools replaces protocol-specific testing and review.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

Leave a Reply

Your email address will not be published. Required fields are marked *

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

More post from the Money Desk

  1. The Money DeskBlogTheFinanceBase07 MAR 2625 minWhat Is a 457 Plan?
  2. The Money DeskBlogTheFinanceBase07 MAR 2621 minTime Value of Money: What It Is and How It Works
  3. The Money DeskBlogTheFinanceBase07 MAR 2627 minAre You Living in One of These Top 10 Most Expensive Cities to Retire?
Recommended PC Tool
Recommended PC Tool
Crashes, No Sound, or Screen Glitches?Free driver scan
Windows Errors? Fix Them Before They SpreadFree repair scan

Two free Windows tools

One Free Minute Could Fix That PC

Before you go - each of these free tools takes about a minute and tackles what quietly slows a Windows PC down.

Special offer. View Outbyte info, uninstall instructions, EULA, and Privacy Policy.