BroncoCTF : Negative Bread Writeup
Recon
We're given a single ELF binary, bank:
$ file bank
bank: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, not stripped
Running it presents a menu-driven "bank" simulator:
+------------------------------------------+
| BRONCO NATIONAL BANK v3.0 |
| "We take security seriously." |
+------------------------------------------+
Welcome! Starting balance: $100
Looking for the flag? It costs $1000000.
[1] Deposit (max $10000 per txn, 3 remaining)
[2] Withdraw
[3] Dispute a Charge (up to $1000000, one-time)
[4] Invest (fixed 0.5% return)
[5] Buy Flag ($1000000)
[6] Exit
Goal: get balance to โฅ $1,000,000 starting from $100, with deposits capped at $10,000 ร 3 = $30,000 total - nowhere near enough through legitimate means. There must be a logic bug in one of the other options.
$ checksec --file=bank
RELRO STACK CANARY NX PIE
Partial RELRO No canary found NX disabled No PIE
No canary, NX disabled, no PIE - this binary could support classic stack smashing / shellcode injection, but the binary is small, not stripped, and has a suspiciously named function sitting right there:
$ nm bank | grep ' T '
0000000000401223 T main
00000000004011d6 T win
A dedicated win function that isn't called from anywhere obvious in normal program flow is the classic signature of a "reach this function" style challenge - no shellcode or ROP needed, just a logic bug that gets main to call win() on our behalf.
$ objdump -d --disassemble=win bank
00000000004011d6 <win>:
4011d6: endbr64
...
call puts@plt ; prints first message
...
call puts@plt ; prints second message (the flag)
ret
Confirmed: win() just prints two strings - almost certainly the flag. The only remaining question is what condition triggers a call to win.
Finding the call site
$ objdump -d --disassemble=main bank | grep -B5 'call.*win'
401713: mov -0x4(%rbp),%eax
401716: cmp $0xf423f,%eax ; 0xf423f = 999,999
40171b: jbe 401731 ; if balance <= 999999, skip win()
401722: call 4011d6 <win>
0xf423f = 999,999. So the check is simply:
if (balance > 999999) {
win();
}
This is the "Buy Flag" option's logic - straightforward, no funny business here. The bug must be in how balance (stored at -0x4(%rbp) throughout main) can be inflated.
The vulnerable function: Dispute a Charge
Disassembling the "Dispute" branch (menu option 3):
; scanf reads dispute_amount into -0x18(%rbp)
4015a9: printf "Dispute amount: $"
4015bf: scanf "%d", &dispute_amount
4015e2: mov -0x18(%rbp), %eax ; eax = dispute_amount
4015e5: mov %eax, %edx
4015e7: neg %edx ; edx = -dispute_amount
4015e9: cmovns %edx, %eax ; eax = abs(dispute_amount)
4015ec: cmp $0xf423f, %eax ; compare abs(amount) to 999,999
4015f1: jle 401611 ; if abs(amount) <= 999,999 -> allowed
; 401611: this is the "allowed" path
401611: mov -0x18(%rbp), %eax ; eax = ORIGINAL dispute_amount (NOT abs!)
401614: add %eax, -0x4(%rbp) ; balance += dispute_amount
401617: movl $0x1, -0x8(%rbp) ; mark "already disputed" flag
Decompiled, the logic is equivalent to:
int dispute_amount;
scanf("%d", &dispute_amount);
if (abs(dispute_amount) <= 999999) {
balance += dispute_amount; // uses the ORIGINAL signed value, not the abs()
already_disputed = 1;
}
The bugs
- No verification of a prior charge. "Dispute a Charge" implies you're getting a refund for money you previously spent - but the function never checks that any such charge exists. You can "dispute" an amount you never paid.
- Bounds check vs. effect mismatch. The bounds check validates
abs(dispute_amount) <= 999999, correctly rejecting anything with a magnitude over that threshold in either direction. But the actual balance update uses the raw, signeddispute_amount- so a large positive value close to the cap sails through the check and gets added directly to balance, with no requirement that it be tied to real prior activity.
Combined, this means: enter 999999 as the dispute amount, and balance jumps by nearly a million dollars, no strings attached.
Exploit
Starting balance is $100. A single dispute clears the flag threshold:
[3] Dispute a Charge (up to $1000000, one-time)
Dispute amount: $999999
[+] Dispute processed. Refund of $999999 applied.
New balance: $1000099
Balance is now $1,000,099 - over the $999,999 threshold checked at the "Buy Flag" call site.
[5] Buy Flag ($1000000)
This triggers:
if (balance > 999999) {
win(); // prints the flag
}
No deposits, investing, or withdrawals are even necessary - a fresh run straight into option 3 with 999999 is sufficient.
Root Cause Summary
| Issue | Detail |
|---|---|
| Missing state validation | "Dispute" has no concept of a real prior charge to dispute against - it's an unconditional credit gated only by a magnitude check. |
| Check/effect mismatch | The bounds check computes abs(amount) but the balance update uses the original signed amount, so the "safety" check doesn't actually constrain what gets added. |
| One-time limit is irrelevant | The already_disputed flag prevents a second dispute, but a single dispute is already enough to win, so the limit provides no real protection. |
Flag
Running the exploit path yields the flag output from win().
Key Takeaways
- When a binary is unstripped and has a suspiciously-named function like
win, always check first whether it's reachable directly through some input-driven logic bug before reaching for exploitation techniques like ROP/shellcode - despite NX disabled and no canary hinting at that path, this challenge didn't need it. - Always check that a bounds/validation check and the operation it guards actually use the same value. Here, the check computed
abs(x)but the effect usedxdirectly - a very common class of bug where sanitization is performed on a copy/derived value instead of the value actually used downstream. - "Refund"/"dispute"/"credit" style features are worth extra scrutiny in any financial-logic challenge - they're an intentional design pattern for injecting value into a system, and are exactly where missing state/history validation tends to hide.
Comments
No comments yet. Start the discussion.