Back to Blog
Security
Mar 6, 2026

Solidity's Most Classic Exploit: Understanding Reentrancy Attacks

In our previous article, we concluded that call is the recommended way to send ETH in Solidity.

But call's flexibility comes with a trade-off — it is the entry point for one of the most infamous smart contract exploits: the Reentrancy Attack.

What Is a Reentrancy Attack?

An attacker exploits a contract vulnerability to repeatedly call back into the same contract before the first execution finishes, draining its funds in a loop.

The Vulnerable Contract

Let's call this contract ContractA:

contract ContractA {
    mapping(address => uint) public balances;

    function withdraw(uint amount) public {
        require(balances[msg.sender] >= amount);

        // ⚠️ External call happens BEFORE state update
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success);

        // Balance is reduced AFTER the call — too late
        balances[msg.sender] -= amount;
    }
}

⚠️ The critical flaw: the balance is deducted after the ETH is sent. This window is what attackers exploit.

The Attacker's Contract

contract Attack {
    ContractA public contractA;

    // Step 1: deposit ETH, then trigger the first withdraw
    function attack() external payable {
        contractA.deposit{value: msg.value}();
        contractA.withdraw(msg.value);
    }

    // Step 2: every time ETH arrives, call withdraw again
    receive() external payable {
        contractA.withdraw(msg.value);
    }
}

How the attack unfolds

  1. The attacker deposits ETH into ContractA to build a legitimate balance.
  2. The attacker calls withdraw(amount). ContractA passes the balance check and executes:
    (bool success, ) = msg.sender.call{value: amount}("");
  3. Because msg.sender is the attacker contract, its receive() function fires before ContractA has a chance to update the balance.
  4. Inside receive(), the attacker immediately calls withdraw() again. The balance check still passes — the state hasn't changed yet.
  5. This loop continues until ContractA's ETH balance hits zero.

Root Cause

The core issue is that state is updated after the external call. When call hands control to an external address, an attacker can re-enter the same function before the bookkeeping happens.

call itself is not the bug — but because it allows arbitrary logic to execute in the callee, a poorly ordered contract can be exploited.

How to Prevent Reentrancy

1. Checks-Effects-Interactions Pattern

Always update state before making external calls:

function withdraw(uint amount) public {
    // ✅ Check
    require(balances[msg.sender] >= amount);

    // ✅ Effect — update state FIRST
    balances[msg.sender] -= amount;

    // ✅ Interaction — external call LAST
    (bool success, ) = msg.sender.call{value: amount}("");
    require(success);
}

2. ReentrancyGuard (OpenZeppelin)

Adding nonReentrant from OpenZeppelin is a battle-tested safety net. The Assetslink batch transfer contract uses it on batchTransferNative and batchTransferToken because they send native token to msg.sender (refunds) and to the fee collector after external transfers.

function batchTransferNative(Transfer[] calldata transfers)
    external
    payable
    nonReentrant
{
    // ... perform transfers, compute refundTotal ...
    if (refundTotal > 0) {
        (bool refSent, ) = msg.sender.call{value: refundTotal}("");
        require(refSent, "Refund transfer failed");
    }
}

nonReentrant prevents a malicious msg.sender from re-entering the batch entrypoint during the refund call.

Summary

  • Reentrancy attacks exploit the gap between an external call and a state update.
  • Follow Checks-Effects-Interactions: always update state before calling out.
  • Use OpenZeppelin's ReentrancyGuard as a second layer of protection.

Batch Transfer — Built Secure

ReentrancyGuard + Checks-Effects-Interactions, verified on-chain.

Open Batch Transfer