Negative Bread
Negative Bread
Platform: Broncoctf2026 | Category: Pwn | Type: Challenge | Difficulty: Medium | OS: NA | Author: D3v0o0Nu11 | Date: 2026-07-11 | Status: Solved Techniques: cmovns_edge_case, int_min_abs_negation_bug, signed_unsigned_comparison_confusion, static_disassembly
Summary
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.
Recon
Port scan
nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
| Port | Service | Version | Notes |
|---|---|---|---|
| <PORT> | <SVC> | <VER> | <notes> |
Enumeration highlights
- Event:
broncoctf2026| ID:20260711_broncoctf2026_negative_bread - Tags: business_logic, scanf, reverse, pwn, signed_integer, int_min, signedness_bug, abs_bug, ret2win_style
- Indicators: inline abs() via neg + cmovns, signed input compared with unsigned jbe, INT_MIN edge case in negation, win() prints embedded flag, scanf %d/%hd only (no strings attached)
- Source:
20260711_broncoctf2026_negative_bread.md
Foothold
Vulnerability / Misconfiguration
- Cmovns_edge_case
- Int_min_abs_negation_bug
- Signed_unsigned_comparison_confusion
- Static_disassembly
<command>
Exploitation
- See original writeup content for detailed exploitation.
Privilege Escalation
Enumeration
sudo -l find / -perm -4000 2>/dev/null getcap -r / 2>/dev/null cat /etc/crontab ps aux
Exploitation
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
Key Takeaways / Lessons
- cmovns_edge_case
- int_min_abs_negation_bug
- signed_unsigned_comparison_confusion
- static_disassembly
- Tags: business_logic, scanf, reverse, pwn, signed_integer, int_min, signedness_bug, abs_bug, ret2win_style
Original Writeup
<details><summary>Click to expand original content</summary>Description
"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.
Recon
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 APPROVEDand the flagflush_stdin@0x4011ff
strings 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.
State layout (in 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— requires1..10000per txn, max 3 total[2] Withdraw— requiresamount > 0and signedbalance >= 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] Exit
All 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.
The Two Chained Bugs
Bug 1 — INT_MIN abs() / 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:
- Input
amount = -2147483648(INT_MIN,0x80000000). neg edxonINT_MINyieldsINT_MINagain (it has no positive counterpart) and sets SF = 1 (result is negative).cmovns eax, edxmoves only when SF == 0. Since SF == 1, the move is skipped —eaxkeeps the originalINT_MIN, not a positive magnitude.cmp eax, 0xf423f; jleis 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.
Bug 2 — signed/unsigned comparison confusion in Buy Flag (menu 5)
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.)
Why the two bugs are needed together
- Bug 1 alone gets you a negative balance but is meaningless on its own.
- Bug 2 alone can't be reached: you can't push the balance above $1M through legitimate deposits (capped at 3 × $10,000 = $30,100).
- Chained: Bug 1 makes the balance a "large unsigned" value that Bug 2's
jbehappily accepts. Dispute's negative-magnitude credit + Buy Flag's unsigned compare = free flag.
Solution
No script or memory corruption is needed — just three lines fed to stdin:
3
-2147483648
5
3— choose Dispute a Charge.-2147483648— submitINT_MIN; passes the signed<=999999check and setsbalance = -2147483548.5— choose Buy Flag; the unsignedjbesees2147483748 > 999999, falls through tocall win, and the flag is printed.
Reproduce:
printf '3\n-2147483648\n5\n' | ./bank
# ... [!] TRANSACTION APPROVED
# bronco{REDACTED}
What did NOT work (for other solvers)
- Ordinary negative deposits/withdrawals are rejected: Deposit requires
1..10000; Withdraw requiresamount > 0and signedbalance >= amount. - Deposit is capped at 3 txns of $10,000 → max legitimate balance $30,100; you can never reach $1M honestly.
NaN/Infbypass is impossible: every input uses integerscanfformats (%d,%hd) — the "no strings attached" hint.- Invest (menu 4) is a red herring — its 0.5% return is not needed.
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR