DEV Community

Cover image for BroncoCTF : Negative Bread Writeup
Yogeshwar Peela
Yogeshwar Peela

Posted on • Originally published at exploitnotes.hashnode.dev

BroncoCTF : Negative Bread Writeup

Challenge

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Recon

$ checksec --file=bank
RELRO           STACK CANARY      NX            PIE
Partial RELRO   No canary found   NX disabled   No PIE
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

0xf423f = 999,999. So the check is simply:

if (balance > 999999) {
    win();
}
Enter fullscreen mode Exit fullscreen mode

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)   <-- bounds check uses ABS VALUE
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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

The bugs

  1. 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.

  2. 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
Enter fullscreen mode Exit fullscreen mode

Balance is now $1,000,099 — over the $999,999 threshold checked at
the "Buy Flag" call site.

[5] Buy Flag ($1000000)
Enter fullscreen mode Exit fullscreen mode

This triggers:

if (balance > 999999) {
    win();   // prints the flag
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)