BroncoCTF : Negative Bread Writeup
DEV Community

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, signed dispute_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 used x directly - 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.