Challenge: Bit Miner · CTF: DalCTF 2026 · Category: Reversing (logic bug) · Difficulty: Medium
TL;DR
The flag costs 9,999,999,999,999,999 bits and mining gives you ~1 bit at a time, so you can never grind your way there. The shop’s purchase routine checks whether you can afford an item using a stale, cached balance but performs the actual deduction against a freshly re-read balance. Because accounts are persisted per-username in shared storage, you can log in twice as the same user and race the two sessions: pass the affordability check on one connection, drain the balance from the other connection, then let the first connection’s unsigned long subtraction underflow to ~1.8 × 10¹⁹ bits — more than enough to buy the flag.
That author’s “note” about other people using your bits? It’s not flavor text. It’s the whole exploit.
Recon
We’re given main.c (the storage layer is stubbed out — “The storage details are not important for this challenge”) and a remote service:
| |
Connecting drops us into a cute little idle/clicker game:
| |
You log in (or register), then loop forever:
- Mine — wait a few seconds, gain
+1bit (occasionally a small bonus). - Shop — buy upgrades to mine faster / earn more, or buy the flag.
The economy
The interesting constants:
| |
| |
Mining at its absolute best:
| |
Even fully maxed out you earn ~42 bits per mine, with a multi-second delay each time. To buy the flag legitimately you’d need on the order of 10¹⁵ mining operations. That is obviously not the intended path — “think outside the box.”
The bug
Everything funnels through shop() → buy(). Here’s shop() reading the account and passing the price/balance into buy():
| |
And buy():
| |
Three things matter:
- (1) The “can you afford it?” check uses
bits— the value captured back inshop(). - (2) After you confirm, the account is re-read from storage, getting whatever the current balance is.
- (3)
account.bits -= priceis unsigned arithmetic. Ifprice > account.bitshere, it wraps around to a gigantic number.
Between (1) and (3) there’s a blocking fgets/fgetc on the confirmation prompt. The check and the deduction operate on two different reads of the same persistent account. In a single session nothing changes between them — but the storage is keyed by username, and nothing stops two connections from logging in as the same user at the same time.
That’s the author’s hint made literal: “make sure other users can’t guess your password and use your bits!” — because if someone else is acting on your account concurrently, the check and the deduction disagree, and bits underflows.
The exploit
We race two sessions, A and B, both logged in as the same user, against the cheapest purchase (the level-1 Speed Upgrade, 10 bits):
| |
Session A now holds 18,446,744,073,709,551,609 bits ≫ FLAG_PRICE. Walk into the shop one more time and buy item 4:
| |
The 9999999999999999 deduction afterward barely dents the balance, and we have our flag.
Why mine exactly enough to pass the check
We just need the snapshot balance ≥ 10 (to pass check (1) on both sessions) but small enough that after one session spends 10, the other’s fresh - 10 underflows. Mining ~12 times lands us around 13 bits, which is perfect: both sessions see ≥10 and pass, B drops it to 3, and A’s 3 - 10 wraps. (Mining is the only part that takes real time — each mine sleeps a few seconds.)
Solve script
| |
Output:
| |
Root cause & fix
This is a textbook TOCTOU (time-of-check to time-of-use) race that turns into an unsigned integer underflow:
- The validity check and the mutating operation read the account twice, with a blocking, attacker-controlled prompt in between.
- The shared, username-keyed store lets two concurrent sessions interleave those reads.
bitsbeingunsigned longmeans an over-spend doesn’t go negative or error — it silently wraps to a near-maximal value.
How to fix it properly:
- Single source of truth + atomicity. Re-read the balance once, inside a transaction/lock per account, and perform check-and-deduct atomically. Never validate against a stale snapshot.
- Guard the subtraction. Even after a check, write
if (account.bits < price) { abort; } account.bits -= price;right before the deduction (or use a checked/saturating subtraction). - Serialize per-account access. A per-username lock (or compare-and-swap on the balance) prevents concurrent sessions from interleaving.
The challenge name and flag — b1t_w4rp1ng — are a nod to the unsigned long “warping” past zero.
Lessons
- “Unsigned” arithmetic is a recurring CTF gift: any subtraction that can dip below zero is a potential overflow primitive.
- When a check and the action it guards read state separately, ask: can anything change that state in between? Here the answer was “yes, a second login as the same account.”
- Read the challenge description carefully. The author’s parenthetical warning about other users spending your bits was the intended solution path.