5.1 יסודות format string הרצאה
בפרקים הקודמים דרסנו את המחסנית: מילאנו buffer, עברנו את הreturn address, ושלטנו בזרימת הביצוע. עכשיו נכיר משפחה אחרת לגמרי של חולשות, שבמבט ראשון נראית תמימה - שורה אחת קטנה של printf. חולשת מחרוזת format לא דורשת overflow בכלל: מספיק שהמשתמש שולט במחרוזת הformat, ופתאום אפשר לקרוא כמעט כל מקום בזיכרון, ובהמשך הפרק גם לכתוב. בהרצאה הזו נבנה את היסודות: איך printf בכלל שולפת את הארגומנטים שלה, למה %p ו-%x מדליפים לנו את המחסנית, מה זה ארגומנט ממוקם כמו %7$p, ואיך מוצאים בדיוק היכן הקלט שלנו יושב על המחסנית. זו התשתית לכל מה שנעשה בפרק הזה - קריאה שרירותית, כתיבה שרירותית, ודריסת GOT.
מהי חולשת מחרוזת פורמט - format string¶
הבאג כולו נובע מהבדל בין שתי שורות שנראות כמעט זהות:
printf("%s", user_input); // safe: the format is constant, user_input is an argument
printf(user_input); // vulnerable: the user controls the format itself
בשורה הבטוחה, מחרוזת הformat היא קבוע שהמתכנת כתב, והקלט של המשתמש מגיע כארגומנט רגיל. בשורה השנייה הקלט של המשתמש הוא מחרוזת הformat. אם המשתמש מקליד טקסט רגיל כמו hello, לא קורה כלום מיוחד - printf פשוט מדפיסה hello. אבל אם הוא מקליד %p %p %p, הוא מבקש מ-printf להדפיס שלושה ארגומנטים - ארגומנטים שהמתכנת מעולם לא העביר. מכאן מתחיל הכיף.
הבעיה קיימת בכל משפחת הפונקציות שמקבלות format: printf, fprintf, sprintf, snprintf, vprintf, syslog וחברים. הכלל: אם ערך שהמשתמש שולט בו מגיע למקום של ארגומנט הformat, יש חולשה.
פונקציות עם מספר משתנה של ארגומנטים - variadic¶
כדי להבין למה זה עובד, צריך להיזכר איך printf בנויה. החתימה שלה היא:
השלוש נקודות (...) אומרות שהפונקציה מקבלת מספר משתנה של ארגומנטים. הנה הנקודה הקריטית: printf לא יודעת כמה ארגומנטים באמת הועברו לה. אין שדה אורך, אין ספירה. הדבר היחיד שאומר לה כמה ערכים לשלוף הוא מחרוזת הformat עצמה. כל המרה (conversion) כמו %d או %p אומרת ל-printf: "שלוף עכשיו עוד ארגומנט אחד".
אז כשאנחנו כותבים printf("%d %d", a, b), הפונקציה סופרת שתי המרות %d, ולכן שולפת שני ארגומנטים. אבל אם נכתוב printf("%d %d %d %d") בלי אף ארגומנט - printf תשלוף בכל זאת ארבעה ערכים. מהיכן? מהמקומות שבהם ארגומנטים אמורים להיות לפי מוסכמת הקריאה - האוגרים והמחסנית. פשוט מה שיושב שם באותו רגע.
זה בדיוק הכוח שלנו: אנחנו קובעים כמה המרות יש בformat, ולכן אנחנו קובעים כמה ערכים printf תשלוף וכמה עמוק היא תחפור.
מהיכן printf שולפת ארגומנטים - registers ו-stack¶
כאן מתחבר הידע מהקורס הקודם על מוסכמת הקריאה. הדרך שבה הארגומנטים מועברים שונה לגמרי בין 64 ביט ל-32 ביט, וזה משפיע ישירות על מספרי ההיסט (offsets) שנשתמש בהם.
במערכת 64 ביט, לפי מוסכמת System V, ששת הארגומנטים הראשונים של כל פונקציה עוברים באוגרים: rdi, rsi, rdx, rcx, r8, r9, ורק מהשביעי ואילך על המחסנית. עבור printf, האוגר rdi תפוס על ידי מצביע הformat עצמו. לכן ההמרה הראשונה בformat שולפת מ-rsi, השנייה מ-rdx, וכן הלאה. אחרי שחמשת אוגרי הארגומנטים הנותרים נגמרים, printf ממשיכה לשלוף מהמחסנית:
64-bit - where each value printf prints is fetched from
+-------------+---------------------+---------------------+
| Conversion | Value source | Positional argument |
+-------------+---------------------+---------------------+
| (format) | rdi | - |
| Conversion 1| rsi | %1$p |
| Conversion 2| rdx | %2$p |
| Conversion 3| rcx | %3$p |
| Conversion 4| r8 | %4$p |
| Conversion 5| r9 | %5$p |
| Conversion 6| [rsp+0x00] from stack | %6$p |
| Conversion 7| [rsp+0x08] from stack | %7$p |
| Conversion 8| [rsp+0x10] from stack | %8$p |
| ... | ... | ... |
+-------------+---------------------+---------------------+
שימו לב לתובנה החשובה ביותר בשקף הזה: הbuffer שאנחנו כותבים אליו נמצא על המחסנית. חמש ההמרות הראשונות שולפות מאוגרים, ולכן הן מציגות זבל שלא קשור לקלט שלנו. רק מההמרה השישית ואילך printf מתחילה לקרוא מהמחסנית, ושם, איפשהו, יושב הקלט שלנו. לכן ההיסט של הbuffer במערכת 64 ביט הוא כמעט תמיד 6 ומעלה.
במערכת 32 ביט הסיפור פשוט יותר: מוסכמת cdecl מעבירה את כל הארגומנטים על המחסנית. מצביע הformat יושב ב-[esp], ומיד אחריו הארגומנטים ב-[esp+4], [esp+8] וכן הלאה. אין שלב אוגרים:
32-bit - everything from the stack (cdecl)
+-------------+---------------------+---------------------+
| Conversion | Value source | Positional argument |
+-------------+---------------------+---------------------+
| (format) | [esp+0x00] | - |
| Conversion 1| [esp+0x04] | %1$x |
| Conversion 2| [esp+0x08] | %2$x |
| Conversion 3| [esp+0x0c] | %3$x |
| Conversion 4| [esp+0x10] | %4$x |
| ... | ... | ... |
+-------------+---------------------+---------------------+
כי אין שלב אוגרים, ההיסט של הקלט שלנו ב-32 ביט קטן בהרבה - לרוב מספר חד-ספרתי נמוך. שווה לזכור את ההבדל הזה, כי הוא מבלבל תלמידים שוב ושוב: אותו payload מדויק ייתן לכם היסט אחד ב-32 ביט והיסט אחר ב-64 ביט.
הleak של ערכים עם המרות x ו-p¶
בואו נעבוד על בינארי דמה קטן שילווה אותנו לאורך כל ההרצאה:
// fmt.c
#include <stdio.h>
int main(void) {
char buf[128];
printf("input> ");
fflush(stdout);
fgets(buf, sizeof(buf), stdin);
printf(buf); // the vulnerability: the input is the format
return 0;
}
נהדר את זה בלי הגנות שיפריעו לנו ללמוד, ונכבה זמנית ASLR כדי שהכתובות יהיו יציבות בין הרצות:
gcc -fno-stack-protector -no-pie -O0 -o fmt fmt.c
echo 0 | sudo tee /proc/sys/kernel/randomize_va_space # for learning purposes only
checksec --file=./fmt
עכשיו נדליף. שתי ההמרות שנשתמש בהן כדי לקרוא זיכרון הן %x ו-%p:
- ההמרה
%xמדפיסה ערך שלunsigned intבהקסדצימלי, כלומר 4 בתים. - ההמרה
%pמדפיסה מצביע בהקסדצימלי - 8 בתים ב-64 ביט, 4 בתים ב-32 ביט - ומוסיפה קידומת0x.
ההעדפה שלנו ב-64 ביט היא כמעט תמיד %p, ומסיבה מדויקת: כל המרה, גם %x, "צורכת" סלוט ארגומנט שלם של 8 בתים במרחב הארגומנטים. אם נשתמש ב-%x נראה רק את חצי התחתון של כל סלוט, ונתבלבל בספירה. %p מראה את הסלוט המלא, ולכן רצף של %p הוא דרך נקייה לטייל סלוט-סלוט על המחסנית.
נשלח רצף %p ונראה מה יוצא:
פלט אופייני (הכתובות אצלכם יהיו אחרות):
מה אנחנו רואים כאן? חמשת הערכים הראשונים מגיעים מהאוגרים rsi עד r9 - לרוב זבל שרידי מהקוד שרץ לפני הקריאה. מההמרה השישית והלאה אנחנו רואים ערכים אמיתיים מהמחסנית: מצביעים, ערכים מקומיים, ולפעמים אפילו בתים מהקלט שלנו עצמו. הערך השישי 0x70252e70252e7025 הוא בדיוק הבתים של %p.%p. מהקלט שלנו. כל %p נוסף חופר סלוט אחד עמוק יותר.
ארגומנטים ממוקמים - positional arguments¶
לכתוב %p שמונה פעמים כדי להגיע לסלוט השמיני זה מסורבל ומועד לטעויות. למרבה המזל, תקן ה-C נותן לנו קיצור מצוין: ארגומנט ממוקם. התחביר %N$ בוחר ישירות את הארגומנט מספר N בלי להדפיס את כל מה שלפניו:
ההמרה %7$p אומרת ל-printf: "קח את הארגומנט השביעי בלבד, והדפס אותו כמצביע". זה בדיוק הערך ש-[rsp+0x08] מכיל במערכת 64 ביט. שימו לב לתו הבריחה \$ בשל ה-shell - במחרוזת פייתון או pwntools נכתוב פשוט %7$p.
הכוח כאן עצום. במקום לשלוח format ארוך ולספור, אנחנו קוראים בדיוק את הסלוט שמעניין אותנו. אחרי שנמצא באיזה סלוט יושב הקלט שלנו, או באיזה סלוט יושבת כתובת libc שימושית, נוכל לשלוף אותה בהמרה ממוקמת אחת נקייה:
מציאת ההיסט של הקלט שלנו - finding the offset¶
הצעד המעשי החשוב ביותר בכל חולשת מחרוזת format הוא למצוא באיזה מספר ארגומנט ממוקם יושב הbuffer שלנו. למה זה כל כך חשוב? כי ברגע שאנחנו יודעים את ההיסט הזה, אנחנו יכולים לשתול כתובת בתוך הקלט, ואז לגרום ל-printf להתייחס לכתובת ההיא כאל מצביע - וזו הדלת לקריאה שרירותית (%s) ולכתיבה שרירותית (%n) בשיעורים הבאים.
הטכניקה קלאסית ופשוטה: שולחים סמן ייחודי בתחילת הקלט, ומיד אחריו רצף של %p. אחר כך מחפשים באיזה %p מופיע הסמן. במערכת 64 ביט הסמן צריך להיות באורך 8 בתים כדי למלא סלוט שלם. סמן נוח הוא שמונה תווי A, שערכם בזיכרון little-endian הוא 0x4141414141414141:
פלט אופייני:
ספרו את מיקום הערך 0x4141414141414141 ברצף ה-%p. אם הוא ה-%p השישי - ההיסט של הקלט שלנו הוא 6, כלומר %6$p קורא את שמונת הבתים הראשונים של הbuffer שלנו. שימו לב שהערך שאחריו, 0x252e70252e70252e, הוא פשוט המשך הקלט שלנו - הבתים של .%p.%p שנקראים כערך. זה סימן טוב שאנחנו בטווח הנכון.
מדוע הסמן חייב להיות 8 בתים aligned? כי printf קוראת את המחסנית סלוט-סלוט בגבולות של 8 בתים. אם הbuffer מתחיל בכתובת aligned ל-8 (מה שקורה כמעט תמיד עבור מערך על המחסנית), שמונת הבתים הראשונים שלו יישבו בדיוק בגבול סלוט אחד, והסמן יופיע נקי. אם תשתמשו בסמן קצר יותר כמו AAAA, הוא עלול להתערבב עם בתים אחרים באותו סלוט.
הנה אותה שיטה, אבל אוטומטית עם pwntools - פשוט סורקים היסטים עד שהסמן צץ:
#!/usr/bin/env python3
from pwn import *
context.log_level = 'error'
elf = context.binary = ELF('./fmt')
def leak_slot(i):
p = process(elf.path)
p.recvuntil(b'input> ')
# 8-byte marker, then a positional read of slot i only
p.sendline(b'AAAAAAAA|' + f'%{i}$p'.encode())
line = p.recvline()
p.close()
return line.strip()
for i in range(1, 15):
out = leak_slot(i)
log.info(f'slot {i}: {out.decode(errors="replace")}')
if b'0x4141414141414141' in out:
log.success(f'buffer offset = {i}')
break
הסקריפט הזה מריץ את הבינארי מחדש לכל היסט, שולח סמן קבוע וקורא סלוט אחד ממוקם, ועוצר ברגע שהסלוט מחזיר את 0x4141414141414141. המספר הזה הוא ההיסט שנשתמש בו בכל שאר הפרק.
דריאת מצביע עם ההמרה s - dereference¶
עד עכשיו קראנו ערכים גולמיים מהמחסנית. ההמרה %s שונה במהות: היא מתייחסת לארגומנט שהיא שולפת כאל מצביע, ניגשת לכתובת שהוא מצביע אליה, ומדפיסה את המחרוזת שם עד לבית ה-null.
זו קפיצת מדרגה. %p נותן לנו את הערך בסלוט. %s נותן לנו את מה שנמצא בכתובת שהסלוט מכיל. אם נוכל לשלוט בערך שבסלוט מסוים - למשל לשתול בו כתובת של פונקציה ב-GOT או של מחרוזת ב-libc - אז %N$s יקרא לנו את התוכן של הכתובת הזו. זו קריאה שרירותית מהזיכרון, והיא הנושא המרכזי של השיעור הבא (5.2).
אזהרה חשובה: %s תקרוס אם הסלוט לא מכיל מצביע חוקי. אם ננסה %6$s והסלוט השישי מכיל 0x4141414141414141 (הסמן שלנו), printf תנסה לקרוא מהכתובת 0x4141414141414141, שאינה ממופה, ונקבל Segmentation fault. זה בעצם אישור מצוין שההיסט נכון - הוכחנו שאנחנו שולטים בערך שההמרה מנסה לפרש כמצביע. בשיעור הבא נחליף את הסמן בכתובת אמיתית ונקרא ממנה.
הנה טעימה שמראה את ההבדל בין %p ל-%s על סלוט שכן מכיל מצביע חוקי (למשל מצביע לסביבה או לארגומנטים של התוכנית, שכמעט תמיד יושבים גבוה על המחסנית):
python3 -c "print('%p')" | ./fmt # prints an address, e.g. 0x7fffffffe4c8
python3 -c "print('%s')" | ./fmt # prints the string at that address
אוטומציה עם pwntools¶
בפועל לא נעבוד עם python3 -c ב-shell אלא נכתוב סקריפט מסודר. הנה שלד מלא שמדליף ערך מהמחסנית ומפרסר אותו לכתובת שאפשר לחשב איתה:
#!/usr/bin/env python3
from pwn import *
context.binary = elf = ELF('./fmt')
context.log_level = 'info'
OFFSET = 6 # the offset we found earlier
p = process(elf.path)
p.recvuntil(b'input> ')
# positional leak: read the slot at our offset
p.sendline(f'LEAK:%{OFFSET}$p'.encode())
line = p.recvline()
# parse the address that came back
leak = int(line.split(b'LEAK:')[1].strip(), 16)
log.success(f'leaked value = {hex(leak)}')
p.close()
הספרייה pwntools מספקת גם עזר אוטומטי לזיהוי ההיסט, המחלקה FmtStr, שמזריקה סמנים ומוצאת בעצמה את המיקום. אנחנו נלמד לעשות את זה ידנית קודם כי חשוב להבין מה קורה מתחת למכסה המנוע, אבל שווה לדעת שהעזר קיים.
מה עם ההגנות - protections¶
נקודה קריטית להבנה: חולשת מחרוזת format עוקפת חלק גדול מההגנות המודרניות, כי היא לא overflow.
- הגנת קנרי - stack canary לא רלוונטית לקריאה. אנחנו לא דורסים את המחסנית, רק קוראים ממנה, אז הcanary לא נבדק ולא מופר. למעשה, אחת השימושים הכי נפוצים של leak של format הוא לדלוף את הcanary עצמו כדי לעקוף אותו אחר כך בoverflow.
- הגנת NX לא רלוונטית. אנחנו לא מריצים קוד על המחסנית, רק קוראים וכותבים נתונים.
- הגנות ASLR ו-PIE הן דווקא היעד המרכזי. הleak של כתובת מהמחסנית או מ-GOT מגלה לנו כתובת בזמן ריצה, וממנה אנחנו מחשבים את בסיס libc או את בסיס הקוד. במילים אחרות, חולשת format היא נשק ההדלפה המושלם נגד אקראיות כתובות.
מה כן עוצר אותנו? הmitigations של הקומפיילר עצמו:
gcc -Wformat -Wformat-security fmt.c # warning about a non-constant format
gcc -Werror=format-security fmt.c # turns the warning into a compilation error
בנוסף, _FORTIFY_SOURCE (שמופעל כברירת מחדל בהרבה הפצות) מגביל את ההמרה %n כשמחרוזת הformat יושבת בזיכרון בר-כתיבה, וזה מקשה על שלב הכתיבה בשיעורים הבאים. אבל שימו לב: כל הmitigations האלה מכוונות לזמן קומפילציה או לכתיבה. קריאה באמצעות %p ו-%s כמעט תמיד עובדת גם כשה-FORTIFY פעיל.
סיכום¶
- חולשת מחרוזת format נובעת מקריאה כמו
printf(user_input)שבה המשתמש שולט בformat. הפתרון הבטוח הוא תמידprintf("%s", user_input). - הפונקציה printf היא variadic ואינה יודעת כמה ארגומנטים באמת הועברו. מספר ההמרות בformat הוא שקובע כמה ערכים היא שולפת, ומאיפה.
- ב-64 ביט ההמרות שולפות קודם מהאוגרים
rsi, rdx, rcx, r8, r9(חמש המרות) ורק אז מהמחסנית, ולכן הקלט שלנו נמצא בהיסט 6 ומעלה. ב-32 ביט הכל על המחסנית וההיסט נמוך בהרבה. - ההמרות
%xו-%pמדליפות ערכים מהמחסנית. ב-64 ביט מעדיפים%pכי הוא מציג סלוט שלם של 8 בתים. - ארגומנט ממוקם כמו
%7$pקורא סלוט ספציפי ישירות, בלי לספור. - מציאת ההיסט נעשית עם סמן 8-בתים (
AAAAAAAA) ורצף%p, ואיתור המיקום שבו מופיע0x4141414141414141. - ההמרה
%sמפענחת את הסלוט כמצביע וקוראת ממנו מחרוזת - הבסיס לקריאה שרירותית, אבל תקרוס על מצביע לא חוקי. - חולשות format עוקפות canary ו-NX, ומהוות את הכלי המרכזי לעקיפת ASLR ו-PIE דרך leak של כתובות.
בתרגול נבנה את הבינארי הvulnerable, נדליף את המחסנית, ונמצא בעצמנו את ההיסט המדויק של הbuffer - הצעד שכל השיעורים הבאים בפרק נשענים עליו.