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:

1
tcp://instancer.dalctf2026.com:21897

Connecting drops us into a cute little idle/clicker game:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
         Welcome to...

 ___ _ _   __  __ _
| _ |_) |_|  \/  (_)_ _  ___ _ _
| _ \ |  _| |\/| | | ' \/ -_) '_|
|___/_|\__|_|  |_|_|_||_\___|_|

Username: youss
Registering as "youss"
Password: ******
Options:
        Mine (1)
        Shop (2)
        Exit (3)

Option:

You log in (or register), then loop forever:

  • Mine — wait a few seconds, gain +1 bit (occasionally a small bonus).
  • Shop — buy upgrades to mine faster / earn more, or buy the flag.

The economy

The interesting constants:

1
#define FLAG_PRICE 9999999999999999   // ~10^16
1
2
3
4
5
6
typedef struct _Account {
    unsigned long bits;            // <-- note: UNSIGNED
    unsigned int  speed_upgrades;
    unsigned int  bonus_upgrades;
    unsigned int  bonus_chance_upgrades;
} Account;

Mining at its absolute best:

1
2
3
4
5
6
7
8
9
void mine() {
    ...
    int bonus_amount = 2 + account.bonus_upgrades * 2;          // max 42 at level 20
    double bonus_chance = (double)(1 + account.bonus_chance_upgrades)
                        / (double)(6 + account.bonus_chance_upgrades);
    ...
    if (got_bonus) account.bits += bonus_amount;               // +42 max
    else           account.bits++;                              // +1
}

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():

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
void shop() {
    Account account = storage_get_account(username);   // snapshot of balance
    ...
    long speed_cost = 10, ...;
    ...
    switch (option) {
        case 1: ...
            buy(1, account.speed_upgrades + 1, speed_cost, account.bits);  // <-- account.bits is a SNAPSHOT
            break;
        ...
        case 4:
            buy(4, 0, FLAG_PRICE, account.bits);
            break;
    }
}

And buy():

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
void buy(int item, int level, unsigned long price, unsigned long bits) {
    if (price > bits) {                                 // (1) CHECK against the snapshot ("bits" param)
        printf("You don't have enough money for this item\n");
        return;
    }

    // ----- blocks here waiting for "Confirm purchase (y / n)" on stdin -----
    char option_chr = 'X';
    while (option_chr != 'y' && option_chr != 'n') { ... }
    if (option_chr == 'n') return;

    Account account = storage_get_account(username);    // (2) RE-READ the account fresh
    switch (item) { ... }

    account.bits -= price;                              // (3) DEDUCT from the fresh balance (unsigned!)
    storage_save_account(username, account);
}

Three things matter:

  1. (1) The “can you afford it?” check uses bits — the value captured back in shop().
  2. (2) After you confirm, the account is re-read from storage, getting whatever the current balance is.
  3. (3) account.bits -= price is unsigned arithmetic. If price > account.bits here, 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):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
Start: balance = 13 bits (mined)

Session A:  enter shop  -> snapshot balance = 13
            choose Speed Upgrade (price 10)
            check: 10 > 13 ? no  -> PASS
            *** park at "Confirm purchase (y / n):" ***   (blocked on stdin)

Session B:  enter shop  -> snapshot balance = 13
            choose Speed Upgrade (price 10)
            check: 10 > 13 ? no  -> PASS
            confirm 'y'
            re-read balance = 13, deduct 10 -> save balance = 3

Session A:  confirm 'y'
            re-read balance = 3            <-- now smaller than price!
            account.bits = 3 - 10          <-- unsigned underflow
                         = 18446744073709551609   (2^64 - 7)
            save

Session A now holds 18,446,744,073,709,551,609 bitsFLAG_PRICE. Walk into the shop one more time and buy item 4:

1
2
3
4
5
6
Balance: 18446744073709551609 bits
...
        Flag                          :   9999999999999999 bits (4)
Option: 4
Confirm purchase (y / n): y
Flag: dalctf{b1t_w4rp1ng_5ucc3s5ful}

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
from pwn import *
import re

context.log_level = 'error'
HOST, PORT = 'instancer.dalctf2026.com', 21897
USER = b'youssrace7'          # purely alphanumeric (the username is sanitized)

def conn():
    r = remote(HOST, PORT)
    r.recvuntil(b'Username: '); r.sendline(USER)
    r.recvuntil(b'Password: '); r.sendline(b'pw')   # register or login, same password
    r.recvuntil(b'Option: ')
    return r

def mine(r):
    r.sendline(b'1'); r.recvuntil(b'Option: ', timeout=20)

def shop_open(r):
    r.sendline(b'2')
    d = r.recvuntil(b'Exit (5)')
    r.recvuntil(b'Option: ')                        # shop's own Option: prompt
    return int(re.search(rb'Balance: (\d+) bits', d).group(1))

# --- Session A: create the account and mine a little seed money ---
A = conn()
for _ in range(12):
    mine(A)
bal = shop_open(A)
log.info(f'A balance: {bal}')

A.sendline(b'1')                                    # buy Speed Upgrade (price 10)
A.recvuntil(b'Confirm purchase (y / n): ')          # *** parked, NOT confirming yet ***

# --- Session B: same user, spend the bits out from under A ---
B = conn()
shop_open(B)
B.sendline(b'1')
B.recvuntil(b'Confirm purchase (y / n): ')
B.sendline(b'y')                                    # commit: stored balance 13 -> 3
B.recvuntil(b'Option: ')

# --- Session A: now confirm -> deducts on the smaller balance -> underflow ---
A.sendline(b'y')
A.recvuntil(b'Option: ')

bal2 = shop_open(A)
log.success(f'A balance after race: {bal2}')        # ~1.8e19

assert bal2 > 9999999999999999
A.sendline(b'4')                                    # buy the flag
A.recvuntil(b'Confirm purchase (y / n): ')
A.sendline(b'y')
print(A.recvuntil(b'Option: ').decode())

Output:

1
2
3
[*] A balance: 13
[+] A balance after race: 18446744073709551609
Flag: dalctf{b1t_w4rp1ng_5ucc3s5ful}

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.
  • bits being unsigned long means an over-spend doesn’t go negative or error — it silently wraps to a near-maximal value.

How to fix it properly:

  1. 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.
  2. 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).
  3. 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.