לדלג לתוכן

9.3 טכניקות קרנל מתקדמות הרצאה

בשני השיעורים הקודמים נכנסנו לעולם kernel exploitation. ב-9.1 הבנו איך נראה מודול vulnerable, מה זה task_struct, ולמה באג בkernel שווה הרבה יותר מבאג ב-userspace - כי כאן אין הפרדת הרשאות מעלינו, אנחנו כבר הכי גבוה שאפשר. ב-9.2 בנינו את ההתקפה הקלאסית הראשונה, ret2usr: השתלטנו על זרימת הביצוע בkernel, קפצנו לפונקציה שכתבנו במרחב המשתמש שהריצה commit_creds(prepare_kernel_cred(0)), וחזרנו למשתמש עם shell של root. זה עבד יפה - על kernel בלי הגנות. בשיעור הזה נפגוש את המציאות: KASLR, SMEP, SMAP, ו-KPTI. כל אחת מהן שוברת חלק אחר מ-ret2usr, ולכל אחת יש עקיפה. ובסוף נלמד את מה שהופך לרוב לדרך הכי נקייה להשיג root בכלל - פרימיטיבים מבוססי-נתונים כמו דריסת modprobe_path ו-core_pattern, שלא צריכים אפילו ROP. זו הרצאה עמוסה, אבל אחריה תהיה לכם תמונה מלאה של איך נראה kernel exploitation מודרני.

כל מה שנכתוב מתייחס ל-Linux על x86-64. ההנחות: אתם כבר יודעים אסמבלי, C, את המחסנית, ELF ו-GDB, ואתם מכירים את commit_creds/prepare_kernel_cred מהשיעור הקודם. כאן נבנה על זה.


מטרת המשחק - the win condition

לפני שנדבר על הגנות, בואו נחדד מה בעצם אנחנו מנסים להשיג. בexploitation userspace המטרה כמעט תמיד execve("/bin/sh"). בkernel המטרה אחרת: אנחנו כבר רצים (חלקית) בהקשר הkernel, ומה שאנחנו רוצים זה להעלות את ההרשאות של התהליך שלנו ואז לחזור בשלום למרחב המשתמש כדי לנצל אותן.

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

  • הפקעה מבוססת-קוד - code-based: להשתלט על rip בkernel (דרך ROP או חטיפת מצביע פונקציה) ולהריץ commit_creds(prepare_kernel_cred(0)). חזק, אבל דורש לעקוף את כל שכבת ההגנות של הרצת קוד בkernel - וזה בדיוק SMEP/SMAP/KPTI.
  • הפקעה מבוססת-נתונים - data-only: לא להריץ שום קוד משלנו בכלל. רק לכתוב בית או שניים למקום הנכון בזיכרון הkernel, ולתת למנגנון קיים לתת לנו root. modprobe_path ו-core_pattern הם הדוגמאות המושלמות. הן חסינות ל-SMEP/SMAP/KPTI כי אנחנו אף פעם לא מריצים קוד בkernel.

עכשיו נבין את ההגנות, ואז נראה איך כל אחת מהמשפחות מתמודדת איתן.


מבנה ה-cred והפקעת הרשאות - the cred struct

כדי לדעת מה לדרוס, צריך להבין איפה יושבות ההרשאות. לכל תהליך יש task_struct, ובתוכו מצביע למבנה cred:

struct cred {
    atomic_t usage;
    kuid_t   uid;    /* real UID  */
    kgid_t   gid;
    kuid_t   suid;   /* saved UID */
    kuid_t   euid;   /* effective UID - this is what determines actual permissions */
    kgid_t   egid;
    /* ... */
};

המצב root הוא פשוט uid == 0 וכל שדות ה-uid האחרים אפס. יש שתי דרכים עקרוניות להפוך לעצמנו root:

הדרך הראשית, שכבר ראינו, היא הקריאה הקנונית:

commit_creds(prepare_kernel_cred(0));

הקריאה prepare_kernel_cred(0) מקצה cred חדש עם הרשאות root (נגזר מ-init_cred), ו-commit_creds משייך אותו לתהליך הנוכחי. שתי הפונקציות הן סמלים בkernel בכתובות קבועות ביחס לבסיס הkernel. הערה חשובה לגרסאות מודרניות: מ-Linux 6.2 שינו את prepare_kernel_cred(NULL) כך שלא יעתיק בטעות הרשאות; הדפוס עדיין נותן root, אבל בגלל שינויים כאלה הרבה חוקרים מעדיפים היום את הדרך השנייה.

הדרך השנייה, הנקייה יותר כשיש לנו כתיבה שרירותית, היא לאתר את ה-cred של התהליך שלנו ולאפס בו את השדות בעצמנו. מוצאים את task_struct הנוכחי (למשל דרך current, או בסריקת הheap אחרי דפוס מזהה), הולכים ל-cred, וכותבים אפסים על uid,gid,suid,euid,.... לא צריך להריץ שום פונקציה - זו כבר data-only.

task_struct                 cred
+-------------------+       +-----------------+
| ...               |       | usage           |
| real_cred  --------+----->| uid   = 0  <-- overwritten
| cred       --------+----->| gid   = 0  <-- overwritten
| comm[16]="myproc" |      | euid  = 0  <-- overwritten
| ...               |       | ...             |
+-------------------+       +-----------------+

הדפוס של comm (שם התהליך, 16 בתים) שימושי מאוד: קוראים לתהליך שלנו בשם ייחודי כמו "PWNPWNPWN", ואז אפשר לסרוק את הheap של הkernel, לחפש את המחרוזת, ומשם לחשב את הכתובת של cred הסמוך.


עקיפת KASLR - defeating KASLR

המנגנון KASLR (Kernel Address Space Layout Randomization) הוא ה-ASLR של הkernel. בכל אתחול, בסיס הkernel מוגרל (הזזה בכפולות של 2MB בתוך טווח נתון), וכך גם בסיס המודולים. המשמעות: הכתובת של commit_creds, של modprobe_path, של הגאדג'טים - כולן זזות בכל boot. בלי לדעת את הבסיס, כל exploit מבוסס-כתובות מת.

היתרון שלנו: כל הkernel זז באותה הזזה (kernel slide). מספיק לדלוף כתובת אחת של סמל ידוע, להחסיר את ה-offset הקבוע שלו, ולקבל את הבסיס. משם כל כתובת אחרת נגזרת. הנה הדרכים הנפוצות להשיג את הleak הזו:

  • דרך dmesg: הkernel מדפיס לפעמים כתובות ל-log (ראו dmesg, /dev/kmsg). אם dmesg_restrict=0 ו-kptr_restrict רופף, שורות כמו oops, WARN, או הדפסות דיבאג עלולות לחשוף כתובות kernel אמיתיות. גם /proc/kallsyms נותן את כל הסמלים עם הכתובות שלהם אם kptr_restrict=0. בהרבה מכונות CTF זה בדיוק המצב:
cat /proc/kallsyms | grep " commit_creds"
# ffffffff81089b40 T commit_creds   <-- real address -> the base is derived from it
  • דרך זיכרון לא מאותחל - uninitialized memory: זו הleak הקלאסית באמת. מודול שמחזיר ל-userspace מבנה בלי לאפס אותו קודם (copy_to_user על buffer מהheap שלא נוקה) עלול להחזיר בתוכו מצביעי kernel שנשארו שם משימוש קודם. כל ioctl/read שמדליף אפילו 8 בתים של מצביע kernel מספיק. זה מקביל בדיוק לleak libc מ-heap שראינו בפרק ה-heap.

  • דרך ערוץ צדדי בזמן - timing side channel: אם אין leak "רגילה", אפשר להסיק את בסיס הkernel ממדידת זמן. טכניקות כמו prefetch side-channel מודדות כמה זמן לוקח prefetch על כתובת kernel - כתובת ממופה נגישה מהר יותר מכתובת לא ממופה, וכך אפשר למפות איפה הkernel יושב, גם עם KPTI. זו טכניקה מתקדמת ורועשת, אבל היא לא דורשת שום באג בקוד - רק את ה-CPU.

הרעיון החשוב: אחרי שיש leak אחת, החישוב טריוויאלי:

leaked_commit_creds = 0xffffffff81089b40      # leaked from /proc/kallsyms or an infoleak
KNOWN_OFFSET        = 0x089b40                # the fixed offset from the kernel base in this build
kernel_base = leaked_commit_creds - KNOWN_OFFSET
prepare_kernel_cred = kernel_base + 0x089d80  # every other symbol by its offset
modprobe_path       = kernel_base + 0x...     # and so on

בלי leak, KASLR הוא חומה. עם leak אחת, הוא נפרץ לגמרי. לכן החיפוש הראשון בכל אתגר kernel הוא: איפה מסתתרת leak הכתובת.


הגנות SMEP ו-SMAP - kernel/user separation

עכשיו לשתי ההגנות ששוברות את ret2usr עצמו. שתיהן הן ביטים ב-CR4:

  • המנגנון SMEP (Supervisor Mode Execution Prevention), ביט 20 ב-CR4: הkernel (מצב supervisor) לא יכול להריץ קוד שנמצא בעמוד עם דגל user. כלומר, אם ננסה לקפוץ מהkernel לפונקציה שכתבנו ב-userspace - בדיוק מה שעשינו ב-ret2usr - נקבל מיד crash. זה הורג את הטכניקה הפשוטה.
  • המנגנון SMAP (Supervisor Mode Access Prevention), ביט 21 ב-CR4: הkernel אפילו לא יכול לקרוא או לכתוב נתונים בעמודי user (מלבד דרך copy_from_user וחברותיה שמכבות זמנית את ההגנה). זה מקשה גם על שיטות שמסתמכות על קריאת buffer שהכנו במשתמש.
CR4 (partial):
 ...  bit 21   bit 20  ...
 ...  [ SMAP ][ SMEP ] ...
        |         |
        |         +--- 0x100000 : no running user code from the kernel
        +------------- 0x200000 : no user data access from the kernel

יש שלוש דרכים עקרוניות לעקוף את זה:

להישאר בkernel - kernel-only ROP

הפתרון הכי נקי: לא לקפוץ ל-userspace בכלל. במקום פונקציה שכתבנו, בונים ROP chain מגאדג'טים שנמצאים בתוך הקוד של הkernel עצמו (ב-.text של הkernel, שהוא supervisor+executable). הchain מסדרת את האוגרים וקוראת ל-prepare_kernel_cred ואז ל-commit_creds, בדיוק כמו ROP רגיל - רק שהגאדג'טים הם כתובות kernel (שנגזרות מהבסיס שדלף). SMEP לא רלוונטי כי לא הרצנו אף עמוד user; SMAP לא רלוונטי כי לא ניגשנו לנתוני user. זו הדרך המקובלת היום, וזו הסיבה שleak KASLR כל כך קריטית.

the ROP stack in the kernel (all addresses inside the kernel):
+----------------------------------+
| addr(commit_creds)               |  <- receives the result from the previous stage
+----------------------------------+
| addr(mov rdi, rax ; ret)         |  <- moves the cred from rax to rdi
+----------------------------------+
| addr(prepare_kernel_cred)        |
+----------------------------------+
| 0                                |  <- goes into rdi
+----------------------------------+
| addr(pop rdi ; ret)              |  <- the vulnerable ret jumps here
+----------------------------------+
| addr(KPTI trampoline ...)        |  <- after hijacking: clean return to userspace
+----------------------------------+

להפוך ביטים ב-CR4 - flipping CR4

אם בכל זאת רוצים לקפוץ ל-userspace (למשל כי ה-shellcode שלנו כבר שם), אפשר קודם לכבות את SMEP/SMAP על ידי כתיבה ל-CR4 שמאפסת את ביטים 20 ו-21. בkernel יש את native_write_cr4, וגם גאדג'ט גולמי mov cr4, rdi ; ret נמצא לפעמים בקוד. הרעיון:

; short ROP chain:
pop rdi ; ret           ; rdi = the new value for CR4 (with SMEP/SMAP disabled)
mov cr4, rdi ; ret      ; write to CR4 -> the mitigations are off
; now we can jump to shellcode in userspace just like in ret2usr

הערך המדויק תלוי-מטרה: קוראים את ה-CR4 הנוכחי ומאפסים בו רק את ביטים 20/21. בכתבות CTF רואים לעיתים את הקבוע הקסום 0x6f0 (ערך שבו SMEP/SMAP כבר כבויים), אבל הנכון הוא לחשב מהערך האמיתי. אזהרה: מ-kernel 5.3 יש CR4 pinning - native_write_cr4 מחזיר בכוח את ביטי ההגנה אם ננסה לכבות אותם, אז דרך הפונקציה זה לא יעבוד. גאדג'ט mov cr4 גולמי עוקף את הנעילה כי הוא כותב ישירות בלי הבדיקה.

לחזור דרך physmap - ret2dir

עקיפה אלגנטית ל-SMEP בלי לגעת ב-CR4 בכלל. הkernel ממפה את כל הזיכרון הפיזי לתוך אזור וירטואלי רציף שנקרא physmap (ה-direct map, בסביבות 0xffff888000000000). המשמעות: כל עמוד user שהקצינו קיים גם ככפיל בתוך ה-physmap, אבל שם הוא ממופה כעמוד supervisor. אם נמצא את הכתובת של הכפיל ב-physmap ונקפוץ אליה, SMEP מרוצה - מבחינתו זה קוד kernel, לא קוד user. כך "מבריחים" shellcode מ-userspace אל תוך מרחב שהkernel מותר להריץ. השם ret2dir בא מ-"return to direct-mapped memory". זו טכניקה חזקה כשאין מספיק גאדג'טים לkernel chain מלאה.


חזרה נקייה למשתמש - the KPTI trampoline

נניח שהצלחנו: הרצנו commit_creds(prepare_kernel_cred(0)) בkernel ועכשיו אנחנו root. עכשיו צריך לחזור למרחב המשתמש כדי לנצל את זה (להריץ shell). זה נשמע פשוט, אבל שתי הגנות מסבכות אותנו.

הראשונה היא KPTI (Kernel Page Table Isolation), ההגנה שהוכנסה נגד Meltdown. הרעיון: לkernel ולמשתמש יש טבלאות עמודים נפרדות. כשרצים בkernel, טבלאות הkernel טעונות; לפני החזרה למשתמש חייבים להחליף את CR3 לטבלאות המשתמש וגם לעשות swapgs. אם פשוט נעשה iretq בעצמנו בלי ההחלפה הזו - crash, כי CR3 עדיין מצביע לטבלאות הkernel שלא ממופות למשתמש.

הפתרון: לא לעשות את זה לבד. הkernel כבר מכיל את הקוד שמבצע חזרה נכונה - הפונקציה swapgs_restore_regs_and_return_to_usermode. היא מחליפה CR3, עושה swapgs, ואז iretq. הטריק הוא לקפוץ לא לתחילתה אלא כמה הוראות פנימה, מעבר לחלק שמשחזר את האוגרים השמורים, כך שהיא מגיעה ישר לרצף swapgs ; iretq עם המסגרת שאנחנו הכנו:

+---------------------------------------------+
| swapgs_restore_regs_and_return_to_usermode  |
|   ... (register pops - we skip past these)   |
|   mov  rdi, rsp                             |
|   ... swap CR3 to the user page tables (KPTI)|
|   swapgs                                    |
|   iretq   <-- jumps back to userspace per the frame |
+---------------------------------------------+

ה-iretq מצפה למסגרת מסודרת על המחסנית, בדיוק בסדר הזה:

low addresses (rsp points here when we reach iretq)
+---------------------------+
| RIP  - user shell/func    |  <-- where we jump to in userspace
+---------------------------+
| CS   - the user's cs      |
+---------------------------+
| RFLAGS                    |
+---------------------------+
| RSP  - the user's stack   |
+---------------------------+
| SS   - the user's ss      |
+---------------------------+
high addresses

מאיפה משיגים את הערכים הנכונים של cs, ss, rsp, rflags? שומרים אותם מראש, בתחילת ה-exploit, לפני שנכנסים לkernel, עם קטע אסמבלי קטן ב-userspace:

unsigned long user_cs, user_ss, user_rflags, user_sp;

void save_state(void) {
    __asm__ volatile(
        "mov %0, cs\n"
        "mov %1, ss\n"
        "mov %2, rsp\n"
        "pushf; pop %3\n"
        : "=r"(user_cs), "=r"(user_ss),
          "=r"(user_sp), "=r"(user_rflags) :: "memory");
}

ואת ה-RIP קובעים לפונקציה שתריץ לנו את ה-shell אחרי החזרה:

void get_shell(void) { system("/bin/sh"); }   // runs with uid=0 after the privesc

ה-offset המדויק שאליו קופצים בתוך הטרמפולינה (מעבר לחלק שמשחזר אוגרים) משתנה בין גרסאות kernel - מוצאים אותו בדיסאסמבלי של swapgs_restore_regs_and_return_to_usermode. בלי KPTI אפשר לעשות iretq ידני עם אותה מסגרת בדיוק; עם KPTI חייבים לעבור דרך הטרמפולינה כי רק הוא יודע להחליף את CR3.


דריסת modprobe_path - the clean privesc

עכשיו הפרימיטיב האהוב על כולם, וזו הסיבה שדיברתי כל הזמן על "data-only". מה אם אני אומר לכם שכל מה שצריך כדי להפוך ל-root זה כתיבה שרירותית אחת של כמה בתים, בלי ROP, בלי leak KPTI, בלי SMEP/SMAP בכלל?

בkernel יש משתנה גלובלי:

char modprobe_path[KMOD_PATH_LEN] = "/sbin/modprobe";

מתי הוא נקרא? כשהkernel מנסה לטעון מודול דרך call_usermodehelper, הוא מריץ את התוכנית ש-modprobe_path מצביע עליה - בהרשאות root, מהkernel. וקל מאוד לגרום לkernel לנסות לטעון מודול: מספיק להריץ קובץ עם magic bytes לא מזוהים. כשה-kernel נתקל בפורמט הרצה שהוא לא מכיר, הוא מנסה request_module("binfmt-...."), וזה מפעיל את modprobe_path.

הזרימה, מקצה לקצה:

// 1. prepare a script to run as root, and a trigger file with unrecognized magic bytes
system("echo -e '#!/bin/sh\\nchmod 777 /flag' > /tmp/x; chmod +x /tmp/x");
system("echo -e '\\xff\\xff\\xff\\xff' > /tmp/trigger; chmod +x /tmp/trigger");

// 2. use our arbitrary write to overwrite modprobe_path
//    at address (kernel_base + offset of modprobe_path) with the string "/tmp/x"
arbitrary_write(kernel_base + MODPROBE_PATH_OFFSET, "/tmp/x\x00");

// 3. run the trigger file. the kernel doesn't recognize the format,
//    calls modprobe_path = "/tmp/x" as root -> our script runs as root
system("/tmp/trigger");   // will fail, but along the way it runs /tmp/x as root

מריצים על מכונת ה-qemu של האתגר, ומקבלים shell של root שקורא את הדגל.

למה זה כל כך אהוב:

  • לא מריצים שום קוד בkernel, אז SMEP, SMAP ו-KPTI לא רלוונטיים בכלל.
  • לא צריך לחזור נקי למשתמש - התהליך שלנו ממשיך לרוץ רגיל.
  • צריך רק את הכתובת של modprobe_path (offset קבוע מבסיס הkernel), כלומר רק את leak KASLR - ואפילו זה נחסך אם אין KASLR.
  • הכתיבה קטנה: 6-7 בתים של מחרוזת. כל באג שנותן כתיבה שרירותית או אפילו כתיבה מוגבלת מספיק.

בגלל כל היתרונות האלה, ברגע שיש לכם primitive של כתיבה שרירותית, modprobe_path הוא כמעט תמיד המסלול הקצר ביותר ל-root.


הexploitation core_pattern - core_pattern abuse

קרוב רוח ל-modprobe_path, ובמיוחד שימושי לבריחה ממכל (container escape). הקובץ /proc/sys/kernel/core_pattern קובע מה קורה כשתהליך קורס ומייצר core dump. אם המחרוזת מתחילה בתו צינור |, הkernel מריץ את התוכנית שאחריו - כ-root, ב-namespace הראשוני - ומזרים אליה את ה-core.

core_pattern = "|/tmp/collector %p"
                ^
                the | character tells the kernel: run this program as root when something crashes

התרחיש: יש לכם primitive לכתוב ל-core_pattern (או שאתם root בתוך מכל שבטעות מאפשר לכתוב לו, או דרך כתיבה שרירותית לסמל core_pattern בkernel). דורסים אותו ל-|/tmp/x, ואז גורמים לתהליך כלשהו לקרוס (למשל תהליך שמבצע גישה לכתובת לא חוקית). הkernel יריץ את /tmp/x כ-root:

# given write access to core_pattern:
echo '|/tmp/x' > /proc/sys/kernel/core_pattern    # or via an arbitrary write to the symbol in the kernel
cat > /tmp/x <<'EOF'
#!/bin/sh
cp /bin/sh /tmp/rootsh; chmod 4755 /tmp/rootsh
EOF
chmod +x /tmp/x

# crash a process to trigger it:
sh -c 'kill -SIGSEGV $$'      # crash -> the kernel runs /tmp/x as root
/tmp/rootsh -p                # shell with root privileges

בהקשר של בריחה ממכל זה יהלום: core_pattern הוא גלובלי ברמת ה-host, אז אם הצלחתם לכתוב אליו מתוך מכל, הקוד ירוץ כ-root על ה-host עצמו. גם כאן - data-only, בלי לגעת בהרצת קוד בkernel.


הkernel heap exploitation - slab, SLUB ו-kmalloc

עד עכשיו הנחנו שיש לנו כבר primitive (כתיבה/הפקעת rip). מאיפה הם באים? חלק גדול מהבאגים המודרניים בkernel הם באגי heap. בדיוק כמו שלמדנו heap ב-userspace, יש heap בkernel - וכדאי להבין את המקצה שלו.

המקצה של הkernel נקרא SLUB (יורש של SLAB; שניהם ממשים את ה-slab allocator). הרעיון דומה מאוד ל-tcache: במקום לבקש עמוד מהkernel בכל הקצאה, מחזיקים caches של אובייקטים בגדלים קבועים וממחזרים אותם.

  • הקצאות בגודל שרירותי (kmalloc(n)) הולכות לcaches כלליים לפי גודל: kmalloc-8, kmalloc-16, kmalloc-32, kmalloc-64, kmalloc-96, kmalloc-128, ... kmalloc-4096. בקשה מעוגלת כלפי מעלה ל-cache המתאים. kmalloc(50) נכנס ל-kmalloc-64.
  • מבנים נפוצים מקבלים cache ייעודי משלהם (למשל cred_jar ל-struct cred, task_struct וכו'), מטעמי ביצועים.
  • בכל cache יש freelist - רשימה חד-כיוונית של אובייקטים פנויים, בדיוק כמו tcache. וכאן הנקודה הקריטית: המצביע לאובייקט הפנוי הבא (fd) נשמר בתוך האובייקט הפנוי עצמו - ב-16 הבתים הראשונים שלו. מכיר לכם? זה בדיוק אותו רעיון כמו ב-heap של glibc.
kmalloc-64 freelist:
 cpu freelist -> obj C -> obj B -> obj A -> NULL
                 ^         ^
                 |         +-- the fd is stored inside the free object (offset 0)
                 +-- LIFO: the last one freed gets allocated first

שימוש לאחר שחרור בkernel - kernel UAF

הבאג הכי נפוץ והכי חזק בkernel הוא UAF: הkernel משחרר אובייקט אבל שומר עליו מצביע, וממשיך להשתמש בו. הexploitation הקלאסי:

  1. גורמים לkernel להקצות אובייקט vulnerable (למשל דרך open/ioctl על המודול vulnerable) בגודל שנכנס ל-cache מסוים, נגיד kmalloc-1024.
  2. גורמים לkernel לשחרר אותו, אבל שומרים עליו dangling pointer.
  3. ריקליים - reclaim/spray: מקצים מיד המון אובייקטים מגודל זהה שהתוכן שלהם נשלט על ידינו, כדי שאחד מהם יתפוס בדיוק את הזיכרון ששוחרר. עכשיו כשהkernel משתמש ב-dangling pointer, הוא קורא את הנתונים שלנו.
  4. אם לאובייקט vulnerable יש מצביע פונקציה בפנים - שתלנו את הכתובת שאנחנו רוצים, וקיבלנו הפקעת rip בkernel. משם ROP או data-only.

איזה אובייקטים משתמשים ל-spray? "אובייקטים אלסטיים" עם תוכן נשלט ובגודל גמיש הם זהב:

  • ההודעה msg_msg נוצרת עם msgsnd, בגודל גמיש שמכסה הרבה kmalloc caches, ותוכן כמעט חופשי. הכוכב של spray מודרני.
  • הbuffers רשת sk_buff, המבנה pipe_buffer (מכיל מצביע ops לפונקציות - נהדר להפקעת rip), המבנה tty_struct ב-kmalloc-1024 (מלא מצביעי פונקציות), והמבנה seq_operations ב-kmalloc-32 (מצביעי פונקציות).
before:  [ vulnerable object ]  <-- freed, but the kernel still holds a pointer
after spray: the exact same memory ->  [ fake msg_msg with my data ]
when the kernel reads the old pointer -> it reads our data -> control

הקשחות הheap - hardening

בדיוק כמו safe-linking ו-tcache key ב-glibc, גם ל-SLUB יש הקשחות מודרניות שכדאי להכיר:

  • ההקשחה CONFIG_SLAB_FREELIST_HARDENED: מצביע ה-fd ב-freelist מעורבב (XOR) עם ערך אקראי לכל cache ועם הכתובת - בדיוק כמו safe-linking ב-glibc 2.32. אי אפשר סתם לזייף fd בלי לדעת את הסוד.
  • ההקשחה CONFIG_SLAB_FREELIST_RANDOM: סדר האובייקטים ב-freelist מעורבב, כך שהקצאות רצופות לא בהכרח סמוכות בזיכרון - מקשה על spray דטרמיניסטי.
  • ההקשחה CONFIG_STATIC_USERMODEHELPER: אם מופעלת, call_usermodehelper עובר דרך נתיב קבוע - וזה מנטרל את טריק ה-modprobe_path. שווה לבדוק אם היא מופעלת לפני שסומכים על הפרימיטיב הזה.

הקשחות אלו לא הופכות את הexploitation לבלתי אפשרי, אבל הן קובעות אילו טכניקות זמינות - בדיוק כמו שבדקנו glibc version בפרק ה-heap. תמיד תבדקו את ה-.config של הkernel היעד.


סיכום

  • מטרת kernel exploitation היא העלאת הרשאות: או להריץ commit_creds(prepare_kernel_cred(0)), או לדרוס ישירות את שדות ה-uid ב-cred של התהליך, או פרימיטיב data-only כמו modprobe_path.
  • המנגנון KASLR מגריל את בסיס הkernel בכל boot. הleak אחת של כתובת סמל ידוע (dmesg, /proc/kallsyms, זיכרון לא מאותחל, או ערוץ צדדי בזמן) שוברת אותו לגמרי - כל הכתובות נגזרות מה-slide.
  • המנגנון SMEP (CR4 ביט 20) מונע הרצת קוד user מהkernel; SMAP (ביט 21) מונע גישת נתונים ל-user. הם הורגים ret2usr פשוט.
  • עוקפים אותם ב-ROP בתוך הkernel בלבד, בהפיכת ביטי CR4 (זהירות מ-CR4 pinning; צריך גאדג'ט mov cr4 גולמי), או ב-ret2dir דרך ה-physmap.
  • המנגנון KPTI דורש להחליף CR3 ו-swapgs בחזרה למשתמש. חוזרים נקי דרך הטרמפולינה swapgs_restore_regs_and_return_to_usermode, עם מסגרת iretq של RIP/CS/RFLAGS/RSP/SS ששמרנו מראש.
  • הפרימיטיב הנקי modprobe_path: כתיבה קטנה שמפנה אותו לסקריפט שלנו, טריגר על ידי הרצת קובץ עם magic לא מוכר, והסקריפט רץ כ-root. חסין ל-SMEP/SMAP/KPTI כי לא מריצים קוד בkernel.
  • דומה לו core_pattern: מפנים אותו ל-|/tmp/x, מפילים תהליך, והתוכנית רצה כ-root - נהדר לבריחה ממכל.
  • הkernel heap מנוהל על ידי SLUB עם kmalloc caches לפי גודל, freelist חד-כיווני שהמצביע שלו יושב בתוך האובייקט הפנוי. UAF מנוצל ב-free ואז spray/reclaim באובייקט נשלט (msg_msg, pipe_buffer, tty_struct) כדי לתפוס את הזיכרון ולהשתלט.
  • להכיר את ההקשחות: SLAB_FREELIST_HARDENED, SLAB_FREELIST_RANDOM, STATIC_USERMODEHELPER. הן קובעות אילו טכניקות בכלל זמינות ביעד.

בשיעורים הבאים ניקח את הרעיונות האלה ונרכיב מהם kernel exploit מלא מקצה לקצה - מבאג ה-UAF ועד ה-shell של root.