לדלג לתוכן

5.3 כתיבה שרירותית עם n תרגול

תרגול - כתיבה שרירותית עם n

בתרגול הזה תהפכו את חולשת ה-format string ממכונת קריאה למכונת כתיבה. המטרה: לדרוס משתנה גלובלי בערך מדויק שאתם בוחרים, קודם בידיים (סופרים בתים, בונים את מחרוזת הformat, מטפלים בסדר עולה ובoverflow), ורק אחר כך עם fmtstr_payload שיעשה את זה בשורה אחת. אל תדלגו על החלק הידני - הוא מה שיבדיל אתכם ממישהו שרק מעתיק סקריפטים. תבינו מה קורה מתחת למכסה, ואז תרשו לכלי לעשות את זה בשבילכם.

סביבה והנחות

הכל רץ מקומית על לינוקס עם python3 ו-pwntools, gcc ו-gdb/pwndbg. הבינארי שלנו מקומפל בלי PIE (כתובת גלובלית קבועה), בלי canary, עם NX, ובלי _FORTIFY_SOURCE (אחרת glibc תחסום את %n על מחרוזת format כתיבה). שמרו כ-writeme.c:

#include <stdio.h>

long secret = 0;    // your target

int main(void) {
    char buf[256];
    setvbuf(stdout, NULL, _IONBF, 0);
    printf("secret @ %p\n", (void*)&secret);
    fgets(buf, sizeof(buf), stdin);
    printf(buf);
    printf("\nsecret = %#lx\n", secret);
    if (secret == 0x1337c0de)
        puts("WIN: secret overwritten exactly!");
    return 0;
}

קומפילציה:

gcc -fno-stack-protector -no-pie -O0 -o writeme writeme.c

בדקו את ההגנות עם checksec --file=./writeme וודאו שאתם רואים No PIE, No canary, NX enabled, Partial RELRO.

תרגיל 1 - סיור ומציאת offset

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

המטרה:

  1. שלפו את כתובת secret בשתי דרכים: פעם עם nm writeme | grep ' secret', ופעם מהשורה שהתוכנית מדפיסה בריצה. ודאו שהן זהות.
  2. מצאו את ה-offset - האינדקס שבו הקלט שלכם מתחיל בארגומנטים של printf. שלחו קלט כמו AAAAAAAA.%p.%p.%p.%p.%p.%p.%p.%p וזהו באיזה %p מופיע 0x4141414141414141.
  3. רשמו לעצמכם את המספר הזה. כל שאר התרגיל תלוי בו.

רמז: ב-x86-64 חמשת הארגומנטים הראשונים מגיעים מרגיסטרים, אז הקלט שעל המחסנית מתחיל בדרך כלל באינדקס 6 ומעלה. אל תניחו - תמדדו.

רמז: אם קשה לספור, תנו לכל %p מפריד ברור עם נקודות, ותשוו את מספר הנקודות עד שאתם רואים את רצף ה-41-ים.

תרגיל 2 - כתיבת בית בודד עם %hhn

נתחיל בקטן: לכתוב ערך של בית אחד כדי להרגיש את המנגנון.

המטרה:

  1. בנו קלט שכותב את הערך 0x41 (65 תווים) לבית הנמוך ביותר של secret, כלומר לכתובת &secret + 0.
  2. השתמשו בצורה %<רוחב>c%<אינדקס>$hhn. חשבו: כמה תווים צריך להדפיס לפני ה-%hhn?
  3. ודאו בפלט שהשורה secret = ... מסתיימת ב-41.

רמז: הכתובת &secret צריכה לשבת בארגומנט שאליו האינדקס בתוך %hhn מצביע. שימו אותה בסוף ה-payload (בגלל ה-null בתים), מרופדת לalignment של 8, וחשבו את האינדקס: offset + מספר ה-qwords של חלק הformat.

רמז: %65c מדפיס 65 תווים. אם צירפתם עוד תווי padding לפני זה, זכרו לספור גם אותם במונה.

תרגיל 3 - כתיבה מלאה בידיים עם %hn

עכשיו היעד המלא: לכתוב את הערך 0x1337c0de ל-secret כך שהתוכנית תדפיס WIN. עשו את זה בלי fmtstr_payload.

המטרה:

  1. פרקו את 0x1337c0de לשני נתחים של 16 ביט: הנתח הנמוך (0xc0de) לכתובת &secret+0, והגבוה (0x1337) לכתובת &secret+2.
  2. מיינו את הנתחים בסדר עולה לפי הערך שרוצים לכתוב. מי צריך להיכתב קודם?
  3. בנו את מחרוזת הformat: הדפיסו את ההפרש בין נתח לנתח, ואחרי כל הפרש בצעו %<idx>$hn לכתובת המתאימה.
  4. הצמידו את שתי הכתובות בסוף ה-payload, מרופדות לalignment, וכוונו את האינדקסים.
  5. הריצו וודאו שאתם רואים WIN: secret overwritten exactly!.

רמז: 0x1337 (4919) קטן מ-0xc0de (49374), אז הנתח הגבוה צריך להיכתב קודם. אחריו מדפיסים עוד 49374 - 4919 תווים כדי להגיע ל-0xc0de.

רמז: אם אתם מתקשים לייצב את האינדקס, רפדו את חלק הformat לאורך קבוע (כפולה של 8) עם תווי a מיותרים, ואז האינדקס של הכתובת הראשונה הוא בדיוק offset + FMT_QWORDS.

רמז: אחרי שזה עובד, נסו במכוון להחליף את סדר הכתיבות (קודם הנמוך ואז הגבוה) ותראו איך זה נשבר. זה יחדד לכם למה הסדר העולה קריטי.

תרגיל 4 - אותו הדבר עם fmtstr_payload

עכשיו כשהבנתם את הכאב, תנו לכלי לעשות את העבודה.

המטרה:

  1. השיגו את אותה כתיבה של 0x1337c0de ל-secret עם שורה אחת של fmtstr_payload(offset, {addr: value}, write_size='short').
  2. הריצו גם עם write_size='byte' וגם עם 'short'. השוו את אורך ה-payload שנוצר (len(payload)) ואת מספר התווים שמודפסים. איזה מהם "רועש" יותר?
  3. הדפיסו את ה-payload עצמו (print(payload)) והשוו אותו למה שבניתם ידנית בתרגיל 3. מה דומה ומה שונה?

רמז: אם התוכנית מדפיסה משהו לפני מחרוזת הformat שלכם (כאן היא לא, אבל באתגרים אחרים כן), העבירו את מספר התווים דרך numbwritten=.

רמז: fmtstr_payload מניח שה-payload שלכם מתחיל בדיוק ב-offset. אם שיניתם את מבנה הקלט (הוספתם prefix), עדכנו את ה-offset בהתאם.

תרגיל 5 (אתגר) - כתיבת כתובת שלמה של 8 בתים

עד עכשיו כתבנו ערך "קטן". עכשיו נכתוב כתובת מלאה, כמו שנצטרך כשנדרוס מצביע פונקציה בשיעורים הבאים.

המטרה:

  1. שנו את writeme.c כך ש-secret יאותחל למצביע כלשהו (למשל הכתובת של puts שאתם מדפיסים), והמטרה תהיה לדרוס אותו בערך שרירותי בן 8 בתים כמו 0xdeadbeefcafe1234.
  2. עם fmtstr_payload ו-write_size='short', כתבו את הערך המלא. אמתו עם gdb שכל ארבעת הנתחים נכתבו נכון: x/gx &secret.
  3. עכשיו נסו לבנות את זה בידיים: ארבע כתיבות %hn לכתובות &secret+0/+2/+4/+6, ממוינות בסדר עולה, עם טיפול בoverflow כשהערך הבא קטן מהמונה.

רמז: הערך 0xdeadbeefcafe1234 מתפרק לנתחים 0x1234, 0xcafe, 0xbeef, 0xdead (לכתובות +0, +2, +4, +6). מיינו אותם לפי הערך בסדר עולה, ותקבלו הפרשים חיוביים בלי צורך בoverflow. overflow נדרשת רק אם שני נתחים דורשים אותו ערך (ההפרש יוצא 0), ואז מדפיסים מחזור שלם של 0x10000.

רמז: כשהמונה כבר גדול והנתח הבא קטן, ההפרש (chunk - printed) % 0x10000 נותן לכם בדיוק כמה עוד להדפיס. אם יצא 0, הדפיסו 0x10000 שלם (מחזור).

רמז: אמתו כל נתח בנפרד ב-gdb. אם בית אחד שגוי, סימן שהמונה או האינדקס של אותו נתח לא מדויק - בדקו את הalignment של בלוק הכתובות.