לדלג לתוכן

6.2 הsyscall מבפנים פתרון

פתרון תרגול - הsyscall מבפנים

1. מעקב אחרי syscall-ים

התוכנית:

#include <stdio.h>
#include <unistd.h>

int main() {
    pid_t pid = getpid();
    printf("My PID: %d\n", pid);
    return 0;
}

קימפול והרצה עם strace:

gcc -o mypid mypid.c
strace ./mypid

כמה syscall-ים לפני main:
בדרך כלל נראה בין 20 ל-40 syscall-ים לפני שהתוכנית מגיעה ל-main. אלה כוללים:
- execve - הרצת התוכנית עצמה
- brk - הגדרת heap
- arch_prctl - הגדרת TLS (Thread Local Storage)
- access - בדיקת קבצי קונפיגורציה של ld
- openat + read + close - קריאת /etc/ld.so.cache
- openat + mmap + close - טעינת libc.so
- mprotect - הגדרת הרשאות על אזורי הזכרון של הספריות

syscall-ים לטעינת ספריות:
הsyscall-ים openat (לפתיחת קובץ הso) ו-mmap (למיפוי הקוד והנתונים שלו לזכרון) הם אלה שטוענים את הספריות המשותפות. נראה בדרך כלל כמה קריאות mmap לכל ספריה - אחת לsegment הקוד (עם הרשאות r-x) ואחת לsegment הנתונים (עם הרשאות rw-).

סיכום סטטיסטי:

strace -c ./mypid

פלט לדוגמה:
% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- --------
  0.00    0.000000           0         3           read
  0.00    0.000000           0         1           write
  0.00    0.000000           0         4           close
  0.00    0.000000           0         7         3 openat
  0.00    0.000000           0         7           mmap
  ...

בדרך כלל mmap או mprotect נקראים הכי הרבה פעמים, בגלל טעינת הספריות המשותפות.


2. הconvention של האוגרים - אסמבלי

section .data
    msg: db "Hello from syscall!", 10    ; 10 = '\n'
    msg_len: equ $ - msg

section .text
    global _start

_start:
    ; syscall write(1, msg, msg_len)
    mov rax, 1          ; syscall number: write
    mov rdi, 1          ; fd: stdout
    mov rsi, msg        ; buf: address of message
    mov rdx, msg_len    ; count: length of message
    syscall

    ; syscall exit(0)
    mov rax, 60         ; syscall number: exit
    mov rdi, 0          ; exit code: 0
    syscall

קימפול והרצה:

nasm -f elf64 program.asm -o program.o
ld program.o -o program
./program

פלט:

Hello from syscall!

נקודות חשובות:
- אין כאן libc בכלל - התוכנית מדברת ישירות עם הקרנל
- נקודת הכניסה היא _start ולא main (כי main היא convention של libc)
- חייבים לקרוא ל-exit בסוף, כי אין libc שעושה את זה בשבילנו. אם לא נקרא ל-exit, התוכנית תמשיך לבצע זבל מהזכרון ותקרוס


3. copy_from_user - שאלה תיאורטית

תרחיש 1: הפוינטר מצביע לכתובת קרנלית
אם משתמש זדוני קורא ל-bad_syscall עם כתובת שנמצאת במרחב הקרנל (למשל 0xFFFFFFFF80000000), הקרנל יקרא מכתובת קרנלית ויחזיר את התוכן ליוזר. זוהי דליפת מידע קרנלית (information leak) - המשתמש יכול לקרוא מידע רגיש מזכרון הקרנל, כמו מפתחות הצפנה, כתובות של מבני נתונים (שימושי לעקיפת ASLR), ועוד.

תרחיש 2: הפוינטר מצביע לכתובת לא ממופה
אם הפוינטר מצביע לכתובת שלא ממופה (למשל NULL, או כתובת אקראית), הגישה תגרום ל-page fault בתוך הקרנל. page fault בקרנל הוא מצב מסוכן - אם הקרנל לא מוכן לטפל בזה, התוצאה היא kernel panic (קריסת המערכת).

איך copy_from_user מונעת את זה:
1. הפונקציה בודקת שהכתובת נמצאת במרחב היוזר (מתחת לגבול kernel/user). אם הכתובת בתוך מרחב הקרנל, הפונקציה מחזירה שגיאה מיד.
2. הפונקציה רושמת את עצמה בטבלת exception מיוחדת. אם מתרחש page fault בזמן ההעתקה, הקרנל יודע שזה page fault "צפוי" ובמקום לקרוס, הוא פשוט מחזיר שגיאת EFAULT מהsyscall.


4. מעקב עם strace על פעולות קבצים

התוכנית:

#include <stdio.h>
#include <unistd.h>

int main() {
    // פתיחה לכתיבה
    FILE *f = fopen("test.txt", "w");
    fprintf(f, "hello world\n");
    fclose(f);

    // פתיחה לקריאה
    char buf[256];
    f = fopen("test.txt", "r");
    fgets(buf, sizeof(buf), f);
    fclose(f);

    // מחיקה
    unlink("test.txt");

    return 0;
}

הsyscall-ים הרלוונטיים (בתוך strace):

openat(AT_FDCWD, "test.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666) = 3   <- fopen("test.txt", "w")
write(3, "hello world\n", 12) = 12                                   <- fprintf(f, ...)
close(3) = 0                                                         <- fclose(f)

openat(AT_FDCWD, "test.txt", O_RDONLY) = 3                          <- fopen("test.txt", "r")
read(3, "hello world\n", 4096) = 12                                  <- fgets(buf, ...)
close(3) = 0                                                         <- fclose(f)

unlink("test.txt") = 0                                               <- unlink("test.txt")

שימו לב:
- הdescriptor שחוזר (3) הוא אותו מספר בשתי הפתיחות, כי אחרי שסגרנו את 3, המספר התפנה ונוצל שוב. זה כי הקרנל תמיד מקצה את המספר הנמוך ביותר הפנוי (כזכור מפרק 5.5).
- write מחזיר 12 - מספר הבתים שנכתבו (אורך "hello world\n").
- read מבקש 4096 בתים (הbuffer הפנימי של libc) אבל מקבל רק 12 - כמות הנתונים שקיימת בקובץ.


5. למה r10 ולא rcx

פקודת ה-syscall של המעבד דורסת את rcx - היא שומרת שם את כתובת החזרה (הRIP של הפקודה שאחרי הsyscall). זו התנהגות חומרתית שלא ניתן לשנות.

אם libc הייתה שמה את הפרמטר הרביעי ב-rcx, מה שהיה קורה:
1. libc שמה את הערך ב-rcx
2. פקודת syscall מתבצעת
3. המעבד דורס את rcx עם כתובת החזרה
4. הקרנל מקבל ב-rcx את כתובת החזרה במקום את הפרמטר הרביעי

הפרמטר הרביעי היה אובד לגמרי. לכן, הconvention של syscall-ים משתמש ב-r10 לפרמטר הרביעי - אוגר שפקודת syscall לא נוגעת בו.

באותו אופן, r11 לא משמש לפרמטרים כי פקודת syscall דורסת אותו עם הFLAGS.

הwrapper של libc דואג להעביר את הפרמטר הרביעי מ-rcx (שם הוא נמצא לפי הconvention הרגיל של C) ל-r10 לפני שמבצע את פקודת syscall:

; בתוך libc, בערך:
mov r10, rcx    ; מעביר פרמטר רביעי ל-r10
syscall