ניתוח חולשת PwnKit
הקדמה
ביום שלישי השבוע פירסמו חברת Qualys על חולשה ברכיב נפוץ בהרבה הפצות לינוקס שמאפשר לכל משתמש במערכת לקבל הרשאות רוט. החולשה קיבלה את השם PwnKit - הרכבה של שם הרכיב הפגיע (Polkit) והמילה Pwn שהוא סלנג לפריצה.
מה שמעניין, לדעתי, בחולשה זו יותר מחולשות אחרות שמתפרסמות יום יום, זה העובדה שלמרות שמדובר בחולשה מסוג memory corruption כאשר בד"כ חולשות מסוג זה הם קשים יותר להבנה למי שלא עמוק בתחום, כאן אפשר עם קצת מאמץ להבין את החולשה והמימוש של האקפלויט מא' עד ת'. תעיד על זה העובדה שאפשר למצוא נכון להיום כ-80 פרוייקטים בגיטהאב שעוסקים בחולשה זו.
איתגרתי את עצמי לנסות להסביר את זה. אשמח לשאלות, תגובות וביקורות באשכול נפרד.
רקע
setuid bit
אחד מהתפקידים המרכזיים של מערכת הפעלה, זה לנהל את הגישה למשאבים משותפים. זה עובד בצורה כזו שכל גישה למשאב משותף חייבת לעבור דרך הקוד של הקרנל (ליבת מערכת ההפעלה) והנ"ל עושה חישוב לפי כמה משתנים ומחליט לאפשר או לסרב.
אחת מהמשתנים המרכזיים שמשפיעים על ההחלטה אם לאפשר או לסרב, זה "מה רמת ההרשאות של התהליך שמבקשת גישה?"
במערכות *NIX (שם כללי לסוגי היוניקס למיניהם, כולל לינוקס), לכל תהליך מוצמד מזהה של היוזר שמריץ את התהליך, וזה קובע את רמת ההרשאות של התהליך.
כיום רוב הפצות לינוקס מייצרים עבור המשתמש - גם אם הוא מנהל - חשבון בעל הרשאות נמוכות כי זה נחשב יותר מאובטח. למה? כי כך אם אתה בטעות מריץ פקודה זדונית שתרצה לפוצץ את המחשב בפנים שלך, זה ייכשל. כי לפוצץ המחשב דרוש הרשאות גבוהות ובברירת מחדל הם לא קיימות.
בכל זאת, בשימוש היום יומי של המחשב יש לפעמים צורך לתת למשתמש מנהל אפשרות לעשות משהו שדורש הרשאות גבוהות. למשל לשנות הגדרה גלובלית, או לכבות את המחשב, או לעדכן מערכת וכו' וכו', לשם כך הומצאה פקודת sudo
. הרעיון הוא שעבור כל דבר שדורש הרשאות גבוהות, המשתמש יצטרך לבקש בפירוש, ולעבור מבחן כדי להוכיח שהוא באמת מורשה לעשות את הפעולה. אפשר להגדיר כל מיני מדיניות ותנאים והגבלות בקבצי התצורה של תוכנת sudo וזה אמור למנוע מתהליכים זדוניים אקראיים את האפשרות של הסלמת הרשאות בלי לפגוע ביכולת המנהל להסלים הרשאות במידת הצורך בצורה מבוקרת.
ואיך עובד הקסם הזה של sudo
שפתאום אתה מצליח להסלים הרשאות? לשם כך המציאו את ה-setuid bit. זה סה"כ פיסת מידע קטן שמוצמד לקובץ הבינארי של פקודת sudo
שאומר למערכת ההפעלה שכאשר מישהו מריץ פקודה זו, תן לפקודה הראשות רוט. (כמובן, רק מי שהוא כבר רוט, יכול להוסיף פיסת מידע זו לקובץ, אחרת כל אחד יוכל להסלים הראשות)
מנגנון זה הוא חרב פיפיות, מצד אחד זה מאפשר למשתמש רגיל לנהל את המחשב, מצד שני אנחנו לא רוצים לתת אפשרות לתהליך לא רצוי להסלים הרשאות. לשם כך, יש הרבה בדיקות וגדרים בתוך הקוד של sudo
שמוודא שרק מי שמורשה יכול להשתמש בו. מכיון שכן, הקוד של כל בינארי שיש לו את ה-setuid bit חייב להיות מאוד מאובטח, ובלי באגים.
משתני סביבה "מסוכנים"
כידוע, אפשר לשלוט על כל מיני התנהגויות של תוכנות באמצעות משתני סביבה. חלק ממשתנים אלו יכולים לגרום לתוכנה להריץ תהליך צאצא, או לטעון קוד בינארי לתוך התהליך ולהריץ אותו. דוגמה לזה הוא המשתנה LD_PRELOAD
. משתנה זו אומרת למערכת ההפעלה לטעון קוד כלשהו לתוך מרחב הזכרון של התהליך ולהריץ אותה.
כאשר אנחנו מריצים תוכנה עם ה-setuid bit אנחנו רוצים שליטה מלאה על הקוד שרץ, ולא רוצים להריץ קוד אקראי בצורה לא מבוקרת. לשם כך, לפני הרצת התוכנה, מערכת ההפעלה "מנקה" את משתני הסביבה של התהליך, ומאפשרת רק משתני סביבה "מאובטחים" ולא "מסוכנים".
Polkit ופקודת pkexec
לא ניכנס יותר מדי לפרטים מה זה בדיוק polkit, רק נאמר שזה מנגנון נוסף בדומה ל-sudo
שמאפשר לתהליכים להסלים הרשאות עבור פעולות מסויימות בצורה מבוקרת.
פקודת pkexec
מגיע כחלק מחבילת Polkit. הפקודה עובדת בערך כמו sudo
שהיא מקבלת מספר ארגומנטים לשלוט על כמה אופציות ובסוף כותבים שם של פקודה, התוכנה בודקת אם המשתמש מורשה לעשות פעולה זו, ובמידה וכן, היא מריצה את הפקודה עם הרשאות גבוהות.
מכיון שהפקודה צריכה להסלים הראשות, יש לפקודה את ה-setuid bit.
אם רק נמצא באג בבדיקות אלו, נוכל לקבל הרשאות רוט!
נמשיך כעת לחולשה עצמה!
(מכאן והלאה דרוש קצת הבנה בתכנות)
החולשה
argc
& argv
כידוע, פקודות שמורצים במערכת הפעלה יכולים לקבל פרמטרים ששולטים על פעולת הפקודה. זה עובד בצורה כזו שמי שמריץ את הפקודה (בד"כ ה-shell, אבל זה יכול להיות כל תהליך) מבקש ממערכת ההפעלה להריץ תהליך חדש (פונקציית execve
). פוקנציה זו מקבלת שלוש ארגומנטים, הראשון הוא נתיב לקובץ ה-exe של הפקודה. השני זה מערך של מחרוזות כאשר האבר האחרון במערך זה הוא NULL pointer, כל אבר במערך זה מצביע על מחרוזת של פרמטר שאתה רוצה להעביר לפקודה. זה נקרא argv
(כלומר arg vector כאשר vector זה שם נרדף למערך). השלישי זה מערך נוסף של מחרוזות שנקרא envp
שמכילה את משתני הסביבה שאתה רוצה להגדיר עבור התהליך.
המילים "מערך של מחרוזות" טעונים ביאור, כי מי שמשתמש בשפות "נורמליות" יבין את הדברים בצורה לא נכונה. ביוניקס, וכל API/ABI מבוססת שפות C/C++ הכוונה במילים אלו הוא כזה:
קודם כל נקדים כמה מושגים בסיסיים:
מצביע (pointer) = unsigned int
(= מספר חיובי) בגודל של מספר הסיביות של המעבד, שמצביע על כתובת במרחב הזכרון של התהליך
NULL pointer = מצביע עם הערך של 0, זה מסמן "משהו שלא קיים".
מערך = זה פשוט "מצביע" (כנ"ל) שמצביע על איזור זיכרון שמאכסנת אובייקט אחד או יותר ברצף (כל האובייקטים הם מאותו סוג ובגודל זהה, האורך של המערך לא ידוע, כלומר זה לא מקודד איפשהו ב"מערך" דהיינו ה"מצביע", במקרה שלנו ה-NULL pointer בסוף הוא זה שמסמן את סוף המערך)
מחרוזת = "מצביע" (כנ"ל) על איזור בזכרון שמאחסנת רצף של 0 או יותר תווים, ואחריהם NULL (בייט עם הערך 0) שזה מסמן את סוף המחרוזת
ובכן מה זה "מערך של מחרוזות"?
מתרגמים את זה ככה: תעביר מצביע שמצביע על רצף של מצביעים כאשר המצביע האחרון הוא NULL, וכל אחד ממצביעים אלו מצביעים על רצף של תווים שאחריהם מגיע תו 0.
מערך הפרמטרים מקובל לקרוא לו argv
(vector זה שם נרדף למערך) וזו הצורה להעביר ארגומנטים לתהליכים.
מערכת ההפעלה לוקחת המערכים אלו ומעתיקה אותם למרחב הזכרון של התהליך החדש.
פונקציית main
בתהליך החדש, הפונקציה הראשית (שנקרא בד"כ main
) מקבלת כארגומנטים שני פיסות מידע, א) argc
- זה int
שערכו הוא מספר הפרמטרים שבתוך argv
ב) argv
שזה כנ"ל מצביע על מערך של מצביעים למחרוזות.
argv[0]
מקובל שהמחרוזת הראשונה במערך argv
זה תמיד שם הקובץ של ה-exe. וכן הוא באמת כאשר מריצים תהליך דרך ה-shell או בדרכים מקובלות אחרות. אבל חשוב להבין שזו רק מוסכמה. אפשר בהחלט להריץ תהליך כאשר המחרוזת הראשונה זה "foobar" או NULL. (במקרה של NULL, ה-argc
יהיה 0, ו-argv
יצביע על מערך עם אבר אחד שערכו הוא 0)
הקוד הפגיע
עכשיו נתקדם קצת להבין את הקוד הפגיע, והנה קטע הקוד שבו מסתתר הבאג:
435 main (int argc, char *argv[])
436 {
...
534 for (n = 1; n < (guint) argc; n++)
535 {
...
568 }
...
610 path = g_strdup (argv[n]);
...
629 if (path[0] != '/')
630 {
...
632 s = g_find_program_in_path (path);
...
639 argv[n] = path = s;
640 }
מה אנחנו רואים פה?
נזכיר, הפקודה pkexec
מקבלת מספר פרמטרים ששולטים על פעולתה (דגלים) ואח"כ מגיעה הפקודה עצמה שהיא אמורה להריץ עם הפרמטרים שלה.
בשורה 435 יש הצהרה על פונקצית ה-main
שמקבלת שני ארגומנטים (כנ"ל). מדלגים על כמה שורות לא קשורות, ובשורה 534 המתכנת רוצה לטפל בדגלים שמגיעים לפני הפקודה עצמה. אז הוא עובר בלולאה על הפרמטרים, בגוף הלולאה (לא מופיע פה) יש תנאי עצירה על הפרמטר הראשון שלא מתאים לשום דגל. מה שחשוב להבין הוא שכאשר יוצאים מהלולאה, הערך של n
שווה לפרמטר הראשון שלא מתאים לשום דגל, כאשר זה יכול להיות גם ה-NULL שמסמן את סוף הפרמטרים. (כך חשב לעצמו המתכנת. האומנם זה נכון? נראה בהמשך...).
שוב נדלג על כמה שורות לא מעניינות, ונגיע לשורה 610, ובו המתכנת מעתיק את המחרוזת שעליה מצביע argv[n]
והוא מקבל חזרה מצביע שיצביע על המיקום של המחרוזת המועתקת. שוב מדלגים עד שורה 629 ובה אנחנו בודקים אם התו הראשון המחרוזת לא /
(כלומר זה לא נתיב אבסוטלוטי, אלא נתיב יחסי או שם של פקודה בלי נתיב) ואז נצטרך לחפש ב-PATH אם קיימת פקודה עם שם זה. בשורה 632, המשתנה s
מצביעה על מחרוזת חדשה שמכילה את הנתיב לפקודה ש-g_find_program_in_path
מצאה. בשורה 639 (כנראה אחרי כמה בדיקות) המצביע s
נכנסת ל-argv[n]
במקום במצביע הקודם.
בשורות הבאות כנראה התוכנה תבדוק אם להריץ או לא את הפקודה שמאוכסנת במחרוזת זו.
הבאג
איפה הבאג פה?
תחשבו לעצמכם מה יקרה אם הערך של argc
הוא אפס. הרי אפשר להריץ תוכנה עם מערך מחרוזות ריקה כנ"ל. מה יהיה הערך של n
במקרה כזה? הוא יהיה 1. כי בתחילת הלולאה איתחלנו אותו ל-1, וגוף הלולאה לא הרצנו כי התנאי n < argc
לא התקיים, אז n
נשאר 1.
על מה יצביע argv[n]
? הוא יצביע על הערך שאחרי סוף המערך. כי הרי argv[0]
יצביע על הערך היחיד שנמצא במערך שזה ה-NULL pointer ו-argv[1]
יגלוש מחוץ למערך לערך הבא.
ומה באמת נמצא שם? בד"כ מערכת ההפעלה שמה שם את המערך envp
שנתקלנו בו למעלה בתיאור של פונקציית execve
.
אם נחשוב על זה, מה שהקוד יעשה במקרה כזה הוא שהוא יתרגם את משתנה הסביבה הראשונה כשם של פקודה, הוא יחפש את הפקודה ב-PATH ואז הוא יכניס את הנתיב לפקודה חזרה למשתני הסביבה.
אם עקבת עד כאן, אז א) כל הכבוד! וב) הבנת איך קיבלנו את היכולת לשלוט על אחד ממשתני הסביבה של התהליך באמצעות באג זה.
בפוסט הבא (בעז"ה אם יהיה כח) ננסה להסביר איך אפשר לנצל עובדה זו להריץ תוכנה אקראית כרוט.