לדלג לתוכן

9.2 syscall מותאם וret2usr הרצאה

בפרק הקודם הכרנו את המודל של kernel exploit: כשאנחנו תוקפים את הkernel אנחנו כבר לא מנסים לפתוח shell של משתמש אחר, אלא לקחת שליטה על הרכיב שמנהל את כל המשתמשים והתהליכים במערכת. בהרצאה הזו ניקח את זה צעד קדימה עם שני נושאים שהולכים יד ביד: קריאות מערכת מותאמות (syscalls שמישהו הוסיף לkernel) ששוכחות לבדוק את הקלט מהמשתמש, וטכניקת ret2usr - הדרך הקלאסית להפוך באג בkernel להרשאות root מלאות. זו בדיוק החולשה שמאחורי אתגר syscall ב-pwnable.kr, ונצא מההרצאה הזו עם exploit עובד.

קריאת מערכת - system call - רענון קצר

כשתוכנית במרחב המשתמש רוצה שהkernel יעשה עבורה משהו (לפתוח קובץ, לכתוב לזיכרון, ליצור תהליך), היא לא קופצת ישירות לקוד הkernel. היא מבצעת הוראת מעבר מיוחדת - syscall ב-x86-64, int 0x80 ב-32 ביט, svc #0 ב-ARM - שמעבירה את המעבד לring 0 (מצב מיוחס) ומקפיצה אותו לנקודת כניסה קבועה בkernel.

הkernel מסתכל על מספר הקריאה (באוגר rax ב-x86-64, ב-r7 ב-ARM), ניגש לטבלה בשם sys_call_table, ומריץ את המטפל שרשום שם:

userspace (ring 3)                |   kernel space (ring 0)
                                   |
  rax = 2  (open)                 |
  syscall  ----------------------->  entry_SYSCALL_64
                                   |      |
                                   |      v
                                   |   sys_call_table[2]  --> sys_open()
                                   |      |
  <----------------------------------- sysret (return to ring 3)

הנקודה החשובה: המטפל רץ ב-ring 0, עם הרשאות מלאות על כל הזיכרון של המערכת. כל מצביע שהמטפל מפענח (dereference), הוא מפענח בהרשאות kernel. תחזיקו את המחשבה הזו, היא הלב של כל ההרצאה.

קריאת מערכת מותאמת - custom syscall

מפתחי kernel יכולים להוסיף קריאת מערכת משלהם: כותבים פונקציית מטפל, ומרשמים אותה בכניסה פנויה ב-sys_call_table. באתגר syscall מישהו הוסיף לkernel קריאה במספר 223 בשם sys_upper שאמורה להפוך מחרוזת לאותיות גדולות. הצורה שלה בערך כזו:

asmlinkage long sys_upper(char *in, char *out) {
    int len = strlen(in);
    int i;
    for (i = 0; i < len; i++) {
        if (in[i] >= 0x61 && in[i] <= 0x7a)   // lowercase letter a-z
            out[i] = in[i] - 0x20;            // convert to uppercase
        else
            out[i] = in[i];                   // otherwise copy as-is
    }
    return 0;
}

נראה תמים. המשתמש מעביר מצביע למחרוזת קלט in ומצביע לbuffer פלט out, והkernel ממלא את out בגרסה באותיות גדולות. אבל שימו לב מה קורה כאן מבחינת הרשאות: הkernel, שרץ ב-ring 0, כותב אל out[i]. ומי בחר את out? המשתמש. אם המשתמש נותן כתובת kernel בתור out, הkernel ישמח לכתוב לשם - כי הוא לא בדק כלום.

הגבול בין מרחב המשתמש למרחב הkernel

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

low addresses
+-------------------------------+  0x0000...
|  userspace - userland         |  <-- code, stack, heap of the process
|  (ring 3, unprivileged)       |
+-------------------------------+  boundary (on ARM here: 0xbf... / kernel from 0x8000_0000)
|  kernel space - kernelland    |  <-- tables, struct cred, sys_call_table
|  (ring 0, privileged)         |
+-------------------------------+
high addresses

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

  • הפונקציה access_ok(ptr, size) בודקת שהמצביע והגודל שלו נמצאים כולם בטווח מרחב המשתמש ולא חורגים לkernel.
  • הפונקציות copy_from_user() ו-copy_to_user() מעתיקות בזהירות בין המרחבים, בודקות את הטווח, ונכשלות בחן (מחזירות שגיאה) אם המצביע לא חוקי, במקום להפיל את הkernel.

הנה איך sys_upper היה צריך להיכתב:

// safe version
char kbuf_in[256], kbuf_out[256];
if (copy_from_user(kbuf_in, in, len))     // copy in with checking
    return -EFAULT;
// ... the conversion happens on the internal buffers ...
if (copy_to_user(out, kbuf_out, len))     // copy out with checking
    return -EFAULT;

בגרסה הבטוחה, אם out היה כתובת kernel, copy_to_user היה מזהה שהכתובת לא בטווח המשתמש ומחזיר -EFAULT. בגרסה vulnerable, הkernel פשוט כותב out[i] = ... ישירות. זהו הבאג.

מהבאג לפרימיטיב - כתיבה שרירותית לkernel

מה בעצם קיבלנו? פרימיטיב של כתיבה למקום שנבחר - write-what-where: אנחנו בוחרים לאן (out, כתובת kernel כלשהי) ומה (in, הבתים שאנחנו רוצים). זה אחד הפרימיטיבים החזקים ביותר בkernel exploit.

יש רק אילוץ אחד שנובע מהקוד: הלולאה הופכת כל בית בטווח 0x61-0x7a (האותיות a-z) לאות גדולה, וה-strlen עוצרת בבית אפס. כלומר, הבתים שאנחנו כותבים חייבים להימנע מבתי אפס באמצע, ובתים בטווח האותיות הקטנות ייכתבו מעוותים. תכף נראה איך עוקפים את זה בבחירת כתובת מתאימה.

המטרה - הרשאות root דרך struct cred

יש לנו כתיבה לזיכרון הkernel. מה עושים איתה כדי להפוך ל-root? צריך להבין איך הkernel מנהל הרשאות.

לכל תהליך יש מבנה בkernel בשם struct cred שמחזיק את הזהות שלו: uid, gid, ואת מבנה היכולות (capabilities). root זה פשוט תהליך שה-uid שלו הוא 0. הkernel מספק שתי פונקציות פנימיות שנועדו לניהול ההרשאות האלה:

  • הפונקציה prepare_kernel_cred(0) בונה מבנה cred חדש עם הרשאות של תהליך kernel, כלומר uid=0 וכל היכולות פתוחות.
  • הפונקציה commit_creds(new) מתקינה את ה-cred הזה על התהליך הנוכחי.

מכאן נוסחת הקסם שכל חוקר kernel מכיר בעל פה:

commit_creds(prepare_kernel_cred(0));

השורה הזו אומרת "תבנה לי זהות של root, ותדביק אותה לתהליך שלי". אחריה, התהליך שלנו הוא root. יש בעיה אחת: commit_creds ו-prepare_kernel_cred הן פונקציות kernel. אי אפשר לקרוא להן ממרחב המשתמש הרגיל, הן חייבות לרוץ ב-ring 0. נעטוף אותן בפונקציה קטנה שנקרא לה getroot:

void getroot(void) {
    // the addresses are filled in at runtime from kallsyms
    commit_creds(prepare_kernel_cred(0));
}

השאלה עכשיו: איך גורמים ל-getroot לרוץ בהקשר kernel? כאן נכנסת טכניקת ret2usr.

מציאת כתובות הסמלים - kallsyms

לפני שנוכל לקרוא ל-commit_creds ו-prepare_kernel_cred, אנחנו צריכים את הכתובות שלהן, ואת הכתובת של sys_call_table (היעד שנדרוס). הkernel חושף את כל הכתובות האלה דרך קובץ:

cat /proc/kallsyms | grep -E ' commit_creds| prepare_kernel_cred| sys_call_table'

פלט לדוגמה (הכתובות משתנות ממערכת למערכת):

8003f2ec T prepare_kernel_cred
8003f0b8 T commit_creds
8027 f9c8 R sys_call_table

הגישה הזו עובדת רק כשההגנה kptr_restrict כבויה. בודקים אותה כך:

cat /proc/sys/kernel/kptr_restrict   # 0 = addresses are visible to everyone

באתגרים ישנים כמו syscall הקובץ קריא והכתובות אמיתיות, אז זה הצינור הטבעי להשגת הכתובות. בkernel מודרני עם kptr_restrict=2 נצטרך leak אחרת (info leak), אבל זה כבר סיפור לפרק הבא.

טכניקת החזרה למרחב המשתמש - ret2usr

עכשיו הטריק המרכזי. יש לנו פרימיטיב כתיבה בkernel, אבל לכתוב kernel payload שלם (shellcode שרץ ב-ring 0) זה מסובך ומוגבל. במקום זה נעשה משהו הרבה יותר פשוט: נכתוב את הפונקציה getroot במרחב המשתמש שלנו, בקוד C רגיל וללא שום אילוץ, ואז נגרום לkernel לקפוץ אליה.

זה בדיוק המשמעות של השם ret2usr - "return to userland": חטיפה של זרימת הבקרה בkernel כדי שתקפוץ לכתובת שנמצאת במרחב המשתמש. למה זה עובד בכלל? כי כשהמעבד ב-ring 0, אין לו שום התנגדות מובנית להריץ קוד שנמצא בעמוד של מרחב המשתמש. מבחינתו זה סתם עוד כתובת.

             the kernel runs at ring 0
                    |
   function pointer in kernel ------ overwritten by us ------> getroot()  (userspace!)
                    |                                    |
                    |                                    v
                    |                     commit_creds(prepare_kernel_cred(0))
                    |                                    |
                    |         <----- return to ring 0 ----+

חשוב להיות מדויקים: ret2usr עובד רק כשהmitigations המודרניים כבויים או חסרים:

  • מנגנון SMEP (Supervisor Mode Execution Prevention) חוסם הרצת קוד ממרחב המשתמש כשהמעבד ב-ring 0. אם הוא דלוק, הקפיצה ל-getroot תפיל את הkernel.
  • מנגנון SMAP (Supervisor Mode Access Prevention) חוסם אפילו קריאה וכתיבה של זיכרון משתמש מ-ring 0.
  • מנגנון KPTI מפריד את טבלאות הדפים כך שכתובות הkernel בכלל לא ממופות בזמן שרצים במשתמש.

באתגר syscall מדובר בkernel ARM ישן שבו אף אחת מההגנות האלה לא קיימת, ולכן ret2usr הוא הפתרון הישיר. בkernel x86-64 מודרני נצטרך לעקוף את SMEP/SMAP (ניגע בזה בפרק הבא), אבל העיקרון זהה.

חזרה נקיה למרחב המשתמש

הרצנו את getroot וקיבלנו הרשאות root. עכשיו צריך לחזור בשלום מ-ring 0 בלי להפיל את הkernel, אחרת נקבל root למשך אלפית שנייה ואז panic. יש שני מצבים:

המצב הקל: חטפנו כניסה ב-sys_call_table. אם דרסנו כניסה בטבלת הקריאות והרצנו את getroot דרך קריאת מערכת רגילה, אז כשה-getroot שלנו חוזר (סתם return של פונקציית C), מנגנון היציאה הרגיל של ה-syscall מחזיר אותנו למרחב המשתמש בצורה נקייה לגמרי. אנחנו לא צריכים לעשות כלום ידנית. זו הסיבה שדריסת כניסה בטבלת הקריאות היא היעד המועדף באתגר הזה.

המצב הקשה: חטפנו מצביע פונקציה שרירותי בkernel. אז אנחנו באמצע זרימת בkernel, וצריך לשחזר ידנית את מצב המשתמש. ב-x86-64 שומרים בהתחלה את cs, ss, rflags, rsp של המשתמש, ובסוף ה-privesc קופצים חזרה עם רצף כזה:

; restore user state at the end of privesc (x86-64)
swapgs                  ; restore the user's GS base
mov rax, user_ss
push rax
push user_rsp
push user_rflags
push user_cs
push user_rip           ; our shell function in userspace
iretq                   ; clean jump back to ring 3

באתגר syscall נבחר תמיד במצב הקל, כי ה-sys_upper נותן לנו בדיוק את היכולת לדרוס כניסה ב-sys_call_table.

הרכבת ה-payload המלא

בואו נחבר הכל לתוכנית פעולה לאתגר syscall:

1. read /proc/kallsyms -> obtain addresses of:
     prepare_kernel_cred, commit_creds, sys_call_table
2. write getroot() in userspace (calls commit_creds(prepare_kernel_cred(0)))
3. place getroot at a "clean" address (no zero byte and no a-z letters)
     because sys_upper corrupts such bytes. done via mmap with MAP_FIXED and copying the code.
4. trigger the bug: syscall(223, &addr_of_getroot, &sys_call_table[223])
     -> the kernel writes getroot's address into entry 223 in the table
5. call syscall(223, ...) again -> this time getroot runs at ring 0
     -> commit_creds(prepare_kernel_cred(0)) -> we're now root
6. the syscall returns cleanly -> in userspace we check getuid()==0
     -> system("/bin/sh") -> a root shell

שימו לב לצעד 3, שם מסתתר האילוג הקטן של האתגר: מכיוון ש-sys_upper הופכת אותיות קטנות לגדולות ונעצרת בבית אפס, הבתים של כתובת getroot שאנחנו כותבים לטבלה חייבים להיות כולם שונים מאפס ומחוץ לטווח 0x61-0x7a. הפתרון הסטנדרטי: להקצות עמוד בכתובת קבועה עם mmap(MAP_FIXED), ולהעתיק את קוד getroot לאופסט שנותן כתובת "נקייה" כזו. את זה נפרוט לפרוסות בפתרון התרגיל.

אותו רעיון על x86-64 - ret2usr מודרני

כדי שתראו שהטכניקה לא ייחודית ל-ARM, הנה איך נראית פונקציית privesc במרחב המשתמש בkernel x86-64 (עם SMEP כבוי לצורך אימון). שומרים את מצב המשתמש מראש, קוראים לנוסחת הקסם, וחוזרים עם iretq:

void privesc(void) {
    // both functions are loaded from kallsyms before jumping here
    commit_creds(prepare_kernel_cred(0));

    // clean return to userspace -> get_shell()
    __asm__ volatile(
        "swapgs\n"
        "mov %0, %%rsp\n"
        // push ss, rsp, rflags, cs, rip and execute iretq
        :: "r"(user_stack_frame)
    );
}

בתרגול ובפתרון נבנה מודול kernel vulnerable קטן ל-x86-64 ונתקוף אותו מקצה לקצה, כדי שתרגישו את ret2usr בידיים על מערכת שאתם שולטים בה במלואה.

סיכום

  • קריאת מערכת מותאמת שמפענחת מצביע מהמשתמש בלי access_ok/copy_to_user נותנת פרימיטיב כתיבה (או קריאה) שרירותי לתוך זיכרון הkernel.
  • הkernel מריץ מטפלי syscall ב-ring 0, ולכן כל מצביע שהוא מפענח נגיש בהרשאות kernel מלאות.
  • הנוסחה commit_creds(prepare_kernel_cred(0)) הופכת את התהליך הנוכחי ל-root, אבל חייבת לרוץ בהקשר kernel.
  • טכניקת ret2usr חוטפת זרימת בקרה בkernel אל פונקציה שכתבנו במרחב המשתמש, ועובדת כשאין SMEP/SMAP/KPTI.
  • חזרה נקייה: דריסת כניסה ב-sys_call_table מחזירה אותנו אוטומטית דרך יציאת ה-syscall, בלי שחזור ידני.
  • הקובץ /proc/kallsyms (כש-kptr_restrict=0) הוא הדרך להשיג את כתובות הסמלים בkernel.
  • באתגר syscall יש להתחשב באילוץ שהבתים הנכתבים חייבים להיות ללא אפס וללא אותיות קטנות, ולכן ממקמים את getroot בכתובת "נקייה" עם mmap.