לדלג לתוכן

אבטחה בקרנל - kernel security

מבוא

הקרנל הוא גבול האבטחה האולטימטיבי במערכת ההפעלה. אם תוקף מצליח לפרוץ לקרנל - הוא שולט על כל המערכת. אין שכבה גבוהה יותר שתגן עליכם. לכן, הבנה של מנגנוני האבטחה בקרנל היא קריטית - גם למי שכותב קוד קרנל, וגם למי שמנסה לפרוץ אותו.

בהרצאה הזו נעבור על כל שכבות ההגנה של לינוקס, מרמת החומרה ועד רמת היישום.


הגנה מבוססת טבעות - ring-based protection

חזרה לחומר מסעיף 2 - ראינו שהמעבד מספק מנגנון הגנה מובנה בחומרה:

  • טבעת 0 (Ring 0) - הקרנל. גישה מלאה לכל הזיכרון, כל ההוראות
  • טבעת 3 (Ring 3) - user space. גישה מוגבלת

ה-CPU אוכף את ההפרדה הזו. קוד ב-Ring 3 לא יכול:
- לגשת לזיכרון הקרנל (page table entries מסומנים כ-supervisor only)
- להריץ הוראות פריבילגיות (כמו hlt, lgdt, mov cr3)
- לשנות את ה-ring שבו הוא רץ (חוץ מדרך syscall)

קריאות מערכת (syscalls) הן השער המבוקר - הדרך היחידה שבה קוד user space יכול לבקש שירות מהקרנל. הקרנל מאמת כל בקשה לפני שמבצע אותה.


הרשאות משתמשים ויכולות - capabilities

המודל המסורתי

במודל המסורתי של Unix, יש הפרדה פשוטה:
- UID 0 (root) - יכול לעשות הכל
- כל השאר - מוגבלים

הבעיה: זה all-or-nothing. אם תוכנה צריכה רק לפתוח פורט מתחת ל-1024, היא צריכה הרשאות root מלאות? זה מסוכן.

יכולות - capabilities

לינוקס פיצל את "כוחות ה-root" ליכולות (capabilities) נפרדות:

יכולת מה היא מאפשרת
CAP_NET_RAW יצירת raw sockets (לסריקת רשת, ping)
CAP_NET_BIND_SERVICE חיבור לפורטים מתחת ל-1024
CAP_SYS_ADMIN פעולות ניהול שונות (mount, swapon, ועוד)
CAP_SYS_PTRACE דיבוג תהליכים אחרים
CAP_DAC_OVERRIDE עקיפת בדיקות הרשאות קבצים
CAP_SETUID שינוי UID
CAP_SYS_MODULE טעינת מודולי קרנל
CAP_SYS_RAWIO גישה ישירה לחומרה (I/O ports)

כל תהליך מחזיק ב-set של capabilities. אפשר לראות אותן:

cat /proc/self/status | grep Cap
# CapInh: 0000000000000000    (inherited - עוברות בירושה)
# CapPrm: 0000000000000000    (permitted - מותרות)
# CapEff: 0000000000000000    (effective - פעילות)
# CapBnd: 000001ffffffffff    (bounding set - הגבלה עליונה)
# CapAmb: 0000000000000000    (ambient - סביבתיות)

אפשר לפענח עם:

capsh --decode=000001ffffffffff

תוכניות setuid

איך /usr/bin/passwd יכולה לשנות את הסיסמה שלכם? הרי קובץ הסיסמאות (/etc/shadow) שייך ל-root.

ls -la /usr/bin/passwd
# -rwsr-xr-x 1 root root 68208 ... /usr/bin/passwd

שימו לב ל-s (setuid bit). כשתהליך מריץ קובץ עם setuid bit, הוא רץ עם ההרשאות של הבעלים (במקרה הזה, root). הקרנל מבצע את זה בזמן execve().

מנקודת מבט אבטחתית, תוכניות setuid הן יעד תקיפה מועדף - כי הן רצות עם הרשאות גבוהות אבל מקבלות קלט מהמשתמש.


מנגנוני הגנת זיכרון - memory protection

ASLR - Address Space Layout Randomization

ASLR מקרין (randomizes) את הכתובות של אזורים שונים בזיכרון: stack, heap, mmap, ספריות, ואפילו ה-executable עצמו (כש-PIE מופעל).

# בדיקת מצב ASLR:
cat /proc/sys/kernel/randomize_va_space
# 0 = כבוי
# 1 = חלקי (stack, mmap, VDSO)
# 2 = מלא (כולל heap)

למה זה עוזר? תוקף שמנצל buffer overflow צריך לדעת לאן לקפוץ. עם ASLR, הכתובות משתנות בכל הרצה, אז הוא לא יכול לחזות אותן.

# ריצה חוזרת מראה כתובות שונות:
cat /proc/self/maps | head -5
# הכתובות ישתנו בכל הרצה

חולשה: אם התוקף מצליח לדלוף (leak) כתובת אחת, הוא יכול לחשב את כל השאר (כי ה-offsets בתוך ספריה קבועים).

NX/DEP - No-Execute

מסמן דפי זיכרון של נתונים כ-לא ניתנים להרצה. זה מונע את ההתקפה הקלאסית של הזרקת קוד (shellcode injection) - גם אם התוקף הצליח לכתוב קוד ל-stack או ל-heap, ה-CPU יסרב להריץ אותו.

מבחינה טכנית, זה ה-NX bit ב-page table entry (חזרה לסעיף 2 על paging!). ה-CPU בודק את הביט הזה בכל instruction fetch.

SMEP - Supervisor Mode Execution Prevention

מונע מהקרנל להריץ קוד שנמצא ב-user space. למה זה חשוב?

התקפה קלאסית בשם ret2usr (חזרה ל-user): התוקף מנצל באג בקרנל כדי לקפוץ לקוד שהוא כתב ב-user space (שם הוא שולט). הקוד רץ עם הרשאות קרנל כי ה-CPU עדיין ב-Ring 0. SMEP חוסם את זה - אם ה-CPU ב-Ring 0 ומנסה להריץ קוד מדף שמסומן כ-user, הוא מייצר page fault.

SMAP - Supervisor Mode Access Prevention

מונע מהקרנל לקרוא זיכרון user space בלי אישור מפורש. זה מכריח שימוש ב-copy_from_user() / copy_to_user() - פונקציות שמאמתות שהכתובת באמת שייכת ל-user space.

בלי SMAP, באג בקרנל יכול לאפשר לתוקף להערים על הקרנל לקרוא נתונים מזויפים מ-user space.

KASLR - Kernel ASLR

כמו ASLR, אבל לקרנל עצמו. בכל boot, הקרנל נטען לכתובת אקראית. זה מקשה על תוקף שמנצל באג בקרנל לדעת איפה נמצאות פונקציות ומבני נתונים של הקרנל.

# בלי KASLR, הכתובת קבועה:
# _text = 0xffffffff81000000

# עם KASLR, הכתובת משתנה בכל boot:
# _text = 0xffffffff81000000 + random_offset

הגנה משלימה: /proc/kallsyms מוצפן ברירת מחדל למשתמשים רגילים (kptr_restrict).

מגני מחסנית - stack canaries

ערכים אקראיים שממוקמים על ה-stack, בין המשתנים המקומיים לכתובת החזרה:

+------------------+
| return address   |  <-- התוקף רוצה לשנות את זה
+------------------+
| stack canary     |  <-- ערך אקראי. אם שונה = overflow detected
+------------------+
| local variables  |  <-- buffer overflow מתחיל פה
+------------------+

אם buffer overflow דורס את ה-canary, הקרנל מזהה את השינוי ומבצע panic (עדיף לקרוס מאשר לתת לתוקף שליטה). מופעל עם CONFIG_STACKPROTECTOR.


Seccomp - מצב חישוב מאובטח

Seccomp מגביל אילו syscalls תהליך יכול לבצע. זה עיקרון ה-least privilege - למה לתת לתוכנה גישה ל-300+ syscalls אם היא צריכה רק 10?

מצבים

מצב 1 (strict): מאפשר רק read, write, _exit, sigreturn. כל syscall אחר = SIGKILL.

#include <linux/seccomp.h>
prctl(PR_SET_SECCOMP, SECCOMP_MODE_STRICT);

מצב 2 (filter - seccomp-bpf): מאפשר להגדיר מסנן BPF שמחליט לכל syscall אם לאשר, לדחות, או לדווח.

// דוגמה פשוטה עם libseccomp:
scmp_filter_ctx ctx = seccomp_init(SCMP_ACT_KILL);  // ברירת מחדל: kill
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(read), 0);
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(write), 0);
seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(exit), 0);
seccomp_load(ctx);

שימושים בעולם האמיתי

  • Chrome/Chromium - ה-renderer process (שמריץ JavaScript ו-HTML לא מהימנים) רץ עם seccomp שחוסם syscalls מסוכנים
  • Docker - קונטיינרים רצים עם profil seccomp שחוסם ~44 syscalls כברירת מחדל
  • systemd - שירותים יכולים להגדיר SystemCallFilter= כדי להגביל syscalls

מרחבי שמות - namespaces

מרחבי שמות מאפשרים בידוד - כל תהליך (או קבוצת תהליכים) רואה "גרסה" שונה של המערכת.

מרחב שמות מה הוא מבודד דוגמה
PID namespace טבלת תהליכים תהליך רואה את עצמו כ-PID 1
Mount namespace מערכת קבצים עץ קבצים שונה לגמרי
Network namespace מחסנית רשת כתובות IP, טבלאות ניתוב, ממשקים שונים
User namespace מיפוי UID/GID root בתוך הקונטיינר הוא user רגיל בחוץ!
UTS namespace שם המחשב hostname שונה
IPC namespace תקשורת בין-תהליכית תורי הודעות, semaphores נפרדים
Cgroup namespace תצוגת cgroups התהליך רואה את עצמו בשורש ה-cgroup
Time namespace שעון המערכת זמן שונה (נוסף ב-5.6)

יצירת namespace

// יצירת תהליך ב-namespace חדש:
clone(child_func, stack, CLONE_NEWPID | CLONE_NEWNET | SIGCHLD, NULL);

// כניסה ל-namespace קיים:
int fd = open("/proc/1234/ns/net", O_RDONLY);
setns(fd, CLONE_NEWNET);

// יצירת namespace חדש בתהליך הנוכחי:
unshare(CLONE_NEWNS);

מרחב שמות משתמשים - user namespace

זה אולי ה-namespace המעניין ביותר מבחינה אבטחתית. הוא מאפשר מיפוי UID:

# בתוך הקונטיינר: UID 0 (root)
# בחוץ: UID 1000 (משתמש רגיל)

מה שאומר שגם אם התוקף משיג root בתוך הקונטיינר, בחוץ הוא עדיין משתמש רגיל. זה בסיס האבטחה של קונטיינרים rootless.

namespaces וקונטיינרים

Docker = namespaces + cgroups + seccomp + capabilities.

כשאתם מריצים docker run, Docker יוצר:
- PID namespace (התהליך רואה את עצמו כ-PID 1)
- Mount namespace (מערכת קבצים של ה-image)
- Network namespace (ממשק veth משלו)
- UTS namespace (hostname של הקונטיינר)
- ומגביל capabilities ו-syscalls


Cgroups - קבוצות בקרה

קבוצות בקרה (Control Groups) מגבילות משאבים לקבוצת תהליכים:

  • CPU - כמה זמן מעבד הקבוצה מקבלת
  • זיכרון - הגבלת זיכרון (OOM killer הורג תהליכים בקבוצה שחורגת)
  • קלט/פלט - הגבלת bandwidth לדיסק
  • רשת - תעדוף תעבורה
# מבנה cgroups:
ls /sys/fs/cgroup/
# cpu  memory  blkio  pids  ...

# הגבלת זיכרון לתהליך:
echo $$ > /sys/fs/cgroup/memory/mygroup/tasks
echo 100M > /sys/fs/cgroup/memory/mygroup/memory.limit_in_bytes

# הגבלת CPU ל-50%:
echo 50000 > /sys/fs/cgroup/cpu/mygroup/cpu.cfs_quota_us
echo 100000 > /sys/fs/cgroup/cpu/mygroup/cpu.cfs_period_us

Cgroups v2

הגרסה החדשה (cgroups v2) מאחדת את כל הבקרים תחת היררכיה אחת ופשוטה יותר:

# cgroups v2 - הכל תחת נקודת mount אחת:
ls /sys/fs/cgroup/
# cgroup.controllers  cgroup.subtree_control  memory.max  cpu.max  ...

LSM - Linux Security Modules

LSM הוא framework שמאפשר להוסיף מדיניות אבטחה מעל ההרשאות הרגילות. הוא מוסיף hooks בנקודות קריטיות בקרנל (פתיחת קובץ, יצירת socket, טעינת מודול, וכו').

SELinux

  • פותח ע"י ה-NSA (כן, באמת)
  • בקרת גישה חובה (Mandatory Access Control - MAC) - בניגוד ל-DAC שבו הבעלים מחליט, ב-MAC המדיניות מחליטה
  • כל קובץ, תהליך ו-socket מקבל תווית (label)
  • מדיניות מגדירה אילו תוויות יכולות לגשת לאילו תוויות
  • משמש ב-Android, Red Hat, Fedora
# בדיקת מצב SELinux:
getenforce
# Enforcing / Permissive / Disabled

# תוויות:
ls -Z /usr/bin/passwd
# system_u:object_r:passwd_exec_t:s0 /usr/bin/passwd

AppArmor

  • גישה פשוטה יותר מ-SELinux
  • מבוסס על נתיבי קבצים (לא תוויות)
  • משמש ב-Ubuntu, SUSE
# פרופיל AppArmor:
cat /etc/apparmor.d/usr.bin.firefox
# /usr/bin/firefox {
#   /home/*/.mozilla/** rw,
#   /tmp/** rw,
#   deny /etc/shadow r,
#   ...
# }

חולשות נפוצות בקרנל

סוגי חולשות

גלישת מאגר - buffer overflow:
כמו ב-user space, אבל עם השלכות חמורות יותר. overflow בקרנל יכול לדרוס מבני נתונים קריטיים.

שימוש אחרי שחרור - use-after-free:
אובייקט ב-slab allocator משוחרר, אבל מצביע אליו עדיין קיים. תוקף יכול להקצות אובייקט חדש באותו מקום ולשלוט בתוכן.

struct my_obj *obj = kmalloc(sizeof(*obj), GFP_KERNEL);
// ... שימוש ב-obj ...
kfree(obj);
// ... בהמשך, בטעות ...
obj->func_ptr();    // Use-after-free! התוקף יכול לשלוט ב-func_ptr

תנאי מרוץ - race conditions:
שני threads ניגשים למשאב ללא סנכרון נכון. יכול להוביל ל-privilege escalation (ראו את חולשת Dirty COW - CVE-2016-5195).

גלישת שלמים - integer overflow:

size_t size = user_provided_count * sizeof(struct element);
// אם user_provided_count גדול מאוד, size גולש ל-0
void *buf = kmalloc(size, GFP_KERNEL);  // מקצים 0 בייטים
copy_from_user(buf, user_data, real_size);  // כותבים מעבר לגבול!

מבנה ה-cred - המטרה של התוקף

struct cred {
    atomic_t    usage;
    kuid_t      uid;        // מי אני?
    kgid_t      gid;
    kuid_t      euid;       // מי אני בפועל?
    kgid_t      egid;
    // ...
    kernel_cap_t    cap_effective;   // יכולות פעילות
    kernel_cap_t    cap_permitted;   // יכולות מותרות
    // ...
};

כל תהליך מצביע למבנה cred שמכיל את ההרשאות שלו. אם תוקף מצליח לשנות את ה-uid ל-0, או להוסיף capabilities - הוא השיג privilege escalation.

טכניקת ההתקפה הקלאסית:
1. מנצלים באג בקרנל (overflow, use-after-free, וכו')
2. משיגים שליטה על ביצוע קוד בקרנל
3. קוראים ל-commit_creds(prepare_kernel_cred(NULL)) - יוצרים cred חדש עם הרשאות root ומחילים אותו על התהליך הנוכחי
4. חוזרים ל-user space עם הרשאות root

עם SMEP, SMAP, ו-KASLR, ההתקפה הזו הרבה יותר קשה - אבל לא בלתי אפשרית (יש טכניקות עקיפה כמו ROP בקרנל).


סיכום - שכבות ההגנה

+-----------------------------------------------+
|  אפליקציה                                      |
+-----------------------------------------------+
|  seccomp (הגבלת syscalls)                       |
+-----------------------------------------------+
|  LSM - SELinux/AppArmor (בקרת גישה חובה)       |
+-----------------------------------------------+
|  capabilities (הרשאות מפורטות)                  |
+-----------------------------------------------+
|  namespaces (בידוד)     |  cgroups (הגבלת משאבים) |
+-----------------------------------------------+
|  ASLR / KASLR / NX / SMEP / SMAP / canaries   |
+-----------------------------------------------+
|  Ring 0 / Ring 3 (הפרדת חומרה)                 |
+-----------------------------------------------+

אבטחה בקרנל עובדת בשכבות - הגנה לעומק (defense in depth). גם אם שכבה אחת נפרצת, השכבות האחרות עדיין מגינות. לכן, במערכות מאובטחות מפעילים את כולן.