$ cat writeup.md…
$ cat writeup.md…
broncoctf2026
Task: x86-64 ELF banking simulator; reach a $1,000,000 balance to buy the flag despite deposit caps. Solution: chain an INT_MIN abs()/cmovns bug in Dispute (INT_MIN passes a signed <=999999 upper-bound check and is added to balance, making it hugely negative) with a signed/unsigned comparison bug in Buy Flag (signed negative balance is reinterpreted by an unsigned jbe as a value > 999999), so win() prints the flag.
"Your account starts at $100. The flag costs $1,000,000. Deposits are capped. Withdrawals can't go below zero. No strings attached. We don't even need to guard the vault. This bank is impenetrable!"
A Linux ELF binary named
bankwas provided.
English summary: A menu-driven "banking" ELF starts you with a $100 balance.
The flag costs $1,000,000, deposits are capped at 3 transactions of $10,000
(max legitimate balance ~$30,100), and withdrawals are guarded against going
negative. All input is numeric (integer scanf — "no strings attached", so no
format-string / NaN tricks). The goal is to reach a balance that satisfies the
"Buy Flag" check and call win(). This is a pure business-logic / signed-integer
challenge: two integer bugs are chained.
file bank:
ELF 64-bit LSB, x86-64, dynamically linked, GCC (Ubuntu 11.4.0) 11.4.0, NOT stripped
SHA256: d5ba54ab23cdbe05c9d0a782295e7515009e0078ff6fedf6a124ae9cb579b00a
checksec: Partial RELRO, No canary, No PIE (base 0x400000),
SHSTK/IBT enabled. NX status is irrelevant here — no memory corruption is used.
nm / symbols (not stripped):
main @ 0x401223win @ 0x4011d6 — prints [!] TRANSACTION APPROVED and the flagflush_stdin @ 0x4011ffstrings reveals the embedded flag bronco{REDACTED} living in
.rodata, printed only from win(). So this is a ret2win-style goal, but
reached purely through the program's own control flow (call win), not via a
stack overwrite.
main, signed 32-bit unless noted)| Variable | Slot | Init |
|---|---|---|
balance | [rbp-4] | 100 (0x64) |
dispute_used | [rbp-8] | 0 |
deposits_remaining | [rbp-0xc] | 3 |
Menu:
[1] Deposit — requires 1..10000 per txn, max 3 total[2] Withdraw — requires amount > 0 and signed balance >= amount[3] Dispute a Charge — one-time, "up to $999999"[4] Invest — fixed 0.5% return (rate in bps via %hd) — red herring[5] Buy Flag — needs balance >= $1,000,000[6] ExitAll numeric input goes through __isoc99_scanf with %d / %hd. No string
input path exists — the "no strings attached" hint rules out format-string,
NaN/Inf, and buffer tricks. The intended attack is arithmetic.
cmovns edge case in Dispute (menu 3)Dispute reads a signed 32-bit amount, computes an inline abs() and then
enforces an upper bound before crediting the balance. The key disassembly around
0x4015e2:
; eax = amount (signed, from scanf %d) mov edx, eax neg edx ; edx = -amount cmovns eax, edx ; if (-amount) has SF==0 (i.e. >= 0), eax = -amount ; -> the intended "abs" of a negative amount cmp eax, 0xf423f ; 0xf423f == 999999 jle <ok> ; SIGNED compare: pass if eax <= 999999
The programmer's intent: "take abs(amount), reject if it exceeds 999999."
The bug is the classic abs(INT_MIN) is still INT_MIN:
amount = -2147483648 (INT_MIN, 0x80000000).neg edx on INT_MIN yields INT_MIN again (it has no positive counterpart)
and sets SF = 1 (result is negative).cmovns eax, edx moves only when SF == 0. Since SF == 1, the move is
skipped — eax keeps the original INT_MIN, not a positive magnitude.cmp eax, 0xf423f; jle is a signed comparison. INT_MIN (-2147483648)
is <= 999999, so the upper-bound check passes.The code then adds the original input to the balance:
balance = 100 + (-2147483648) = -2147483548
No 32-bit addition overflow occurs (result fits in int32), and dispute_used
is set. Result: balance is now a huge negative number, -2147483548.
Buy Flag is supposed to require balance >= 1000000, but the check at 0x401713
loads the signed balance and compares it with an unsigned branch:
mov eax, [rbp-4] ; eax = balance (stored as signed int) cmp eax, 0xf423f ; 999999 jbe <reject> ; UNSIGNED compare: reject if (unsigned)balance <= 999999 call win ; else -> print the flag
jbe treats the 32-bit value as unsigned. Our balance -2147483548 has the
bit pattern 0x80000064, which as an unsigned integer is 2147483748. That is
far greater than 999999, so jbe is not taken and control falls through to
call win, which prints the flag.
(A legitimate $1,000,000 balance would also pass this check — the bug is that a negative balance masquerades as a gigantic positive one.)
jbe
happily accepts. Dispute's negative-magnitude credit + Buy Flag's unsigned
compare = free flag.No script or memory corruption is needed — just three lines fed to stdin:
3
-2147483648
5
3 — choose Dispute a Charge.-2147483648 — submit INT_MIN; passes the signed <=999999 check and sets
balance = -2147483548.5 — choose Buy Flag; the unsigned jbe sees 2147483748 > 999999,
falls through to call win, and the flag is printed.Reproduce:
printf '3\n-2147483648\n5\n' | ./bank # ... [!] TRANSACTION APPROVED # bronco{REDACTED}
1..10000; Withdraw requires amount > 0 and signed balance >= amount.NaN/Inf bypass is impossible: every input uses integer scanf formats
(%d, %hd) — the "no strings attached" hint.$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar