Windows Process – תהליכים ומה שבניהם
הקדמה
תכנית היא רצף של הוראות בעלות לוגיקה משותפת. קובץ הרצה הוא תכנית שעברה תהליך קימפול (Compiling) וקישור (Linking). כאשר קובץ הרצה נטען לזיכרון, הישות שמייצגת אותו נקראת תהליך. במאמר זה נדבר על המבנה של תהליכים במערכת ההפעלה Windows. מאמר זה הוא בעיקר סיכום של החומר ולא מתיימר להיות משהו מעבר לכך.מרכיבי התהליך
כל תהליך מורכב ממספר חלקים, על חלקם נפרט יותר על חלקם נפרט פחות. אך לפני שנדבר על החלקים, נחלק את התהליך לחלקים מעט יותר כלליים. כל תהליך מורכב מאובייקט ב-Kernel שבאמצעותו המערכת מנהלת את התהליך - מבנה זה נקרא EPROCESS. החלק השני הוא מרחב כתובות ב-User-Space המכיל משאבים שלרכיבים ב-User-Space יש צורך לגשת אליהם. כעת נפרט על כל אחד מהחלקים.אובייקט ב-Kernel
כאמור, כל תהליך מיוצג ב-Kernel באמצעות אובייקט הנקרא EPROCESS. אובייקט זה, וכל המידע המוכל בו, נמצא ב-Kernel-Space מלבד שדה אחד שנקרא PEB שחי ב-User-Space על מנת לאפשר גישה למידע שהוא מחזיק מה-User-Space. דוגמאות לרכיבים שעושים זאת יכולות להיות ה-Image Loader של Windows וה-Heap Manager.שדות מעניינים במבנה ה-EPROCESS:
1. PCB – ה-Process Control Block הוא למעשה מבנה בשם KPROCESS. למרות שרוב הרוטינות ב-Executive, בהקשר של תהליכים, מתבצעות מול ה-EPROCESS, ישנם רכיבים שמשתמשים ב-KPROCESS. דוגמה לאחד מהם היא ה-Scheduler שמקבל את ערכי ה-Priorities של תהליך וכנגזרת גם של Thread-ים של אותו תהליך – נרחיב על כך כשנפרט על Priority Class של תהליך. ה-PCB הוא השדה הראשון ב-EPROCESS ולכן לשניהם יש את אותה כתובת.
2. UniqueProcessId – זה ה-PID של התהליך. בא לידי שימוש כאשר יש לי שני תהליכים מאותו קובץ הרצה.
3. ActiveProcessLinks – זה Node ברשימה מקושרת דו-צדדית. מכיל בתוכו שתי הפניות, אחת ל-Node הבא ואחת ל-Node הקודם. FLINK ו-BLINK בהתאמה. כלים כגון Task Manager מייצרים רשימה של תהליכים באמצעות ריצה על הרשימה הזו. ניתן להסתיר תהליך, מאותם כלי שמשתמשים בשדה זה, אם מנתקים תהליך מהרשימה זו. אולי אם אכתוב בעתיד מאמר בנושא מניפולציות ב-Kernel לצרכי התקפה, אפרט יותר על הנושא.
4. Token – מחזיק את המצביע ל-Security Token עליו נדבר בהמשך.
ה-Process Environment Block
כאמור, כל תהליך מחזיק מבנה PEB שמכיל מידע נחוץ לרכיבים מה-User-Space. להלן מספר שדות מעניינים במבנה הזה:1. Image Base Address – הכתובת אליה נטען התהליך בזיכרון, כלומר ה-Image Base שלו בזיכרון.
2. BeingDebugged – סיבית שמתארת אם תהליך רץ תחת Debugger או לא. פונקציות API כגון IsDebuggerPresent בוחנות את הסיבית הזו.
3. Ldr – או בשמו המדויק יותר PEB_LDR_DATA, הוא מבנה המכיל את כל המידע על מודולים הטעונים לתהליך הנוכחי.
4. NtGlobalFlag – משומש על ידי תכוניות כדי לדעת אם התהליך רץ תחת Debugger. בדרך כלל, כאשר הוא לא, הערך כאן הוא 0x0, אך כאשר כן הערך הוא 0x70 שמורכב מהדגלים FLG_HEAP_ENABLE_TAIL_CHECK –הערך 0x10, FLG_HEAP_ENABLE_FREE_CHECK – הערך 0x20 ו-FLG_HEAP_VALIDATE_PARAMETERS שמכיל את הערך 0x40. סך הכל 0x70, הוא כן.
רצוי לציין שמעבר לכך שניתן לגשת ל-PEB של תהליך באמצעות ה-EPROCESS, כל Thread מיוצג באמצעות מבנה שנקרא TEB, עליו נפרט בהמשך, שמכיל הפניה ל-PEB של אותו תהליך אליו שייך ה-Thread הנוכחי.
מרחב כתובת
לכל תהליך יש מרחב כתובות וירטואלי משל עצמו. מבחינת התהליך, המרחב הזה מבודד מכל שאר הזיכרון וכל עוד לא צוין אחרת, לתהליכים בעלי הרשאות רגילות (לא הרשאות אדמיניסטרטוריות או מערכת) לא אמורה להיות גישה לזיכרון הזה. המרחב כתובת מכיל DLL-ים טעונים, משתני סביבה, את ה-PEB, את הערימה (Heap) של התהליך, את המחסניות של ה-Thread-ים, את הקוד של ה-Thread-ים ועוד משאבים ממופים שהתהליך צריך כדי לרוץ בצורה תקינה. בתוך מרחב הכתובות הזה יש חלק שנמצא ב-RAM, כלומר בזיכרון הפיזי של המחשב ולא ב-Pagefile (הזיכרון האלטרנטיבי ש-Windows משתמש בו כדי להגדיל את הזיכרון הווירטואלי של המערכת). החלק הזה נקרא ה-Working set של התהליך.Thread-ים
תהליך הוא רק מכולה של משאבים שמאוגדים תחת ישות אחת שמטרתן לייצג קובץ הרצה בזיכרון. בפועל תהליך ללא Thread-ים לא מתקיים וגם אם הוא היה, הוא לא היה עושה כלום כי הוא לא מחזיק קוד בעצמו בצורה ישירה. Thread הוא הישות שעבורה המערכת מקצה זמן עיבוד. בפועל מי שמריץ קוד בתוך תהליך הם ה-Thread-ים של התהליך. תהליך יכול להריץ כל קוד ששייך לתהליך, כולל קוד שרץ על ידי Thread אחר. כשאנחנו מדברים על Thread אנחנו מדברים על כמה חלקים. לכל Thread יש מבנה ב-Kernel שנקרא ETHREAD. כל השדות ב-ETHREAD מצביעים לכתובות במרחב הכתבות של המערכת (כלומר ב-Kernel Space) מלבד ה-Thread Environment Block. ה-Thread Environment Block, או בקצרה ה-TEB, מכיל בתוכו מספר שדות מעניינים כגון מצביע ל-SEH, מצביע למחסנית של ה-Thread, מצביע ל-PEB של התהליך שאליו שייך ה-Thread ועוד. ב-ETHREAD יש את ה-KTHREAD שמייצג את ה-Thread Information Block. בנוסף לכל אלו, ל-Thread-ים שרצים תחת תהליכים שרצים תחת ה-Subsystem של Windows, משויך גם מבנה CSR_THREAD. על אותו קונספט, ל-Thread-ים שרצים תחת תהליך שמשתמש בגרפיקה של Windows, יהיה מבנה שנקרא WIN32THREAD. כדי להבין את שני המשפטים האחרונים טוב יותר תצטרכו לקרוא את הפסקה על CSR_PROCESS ו-WIN32PROCESS בהמשך אך נכון לעכשיו מספיק שתזכרו את העובדות הללו.Security Token
לכל תהליך יש Access Token. זהו מבנה המשמש את המערכת כאשר היא רוצה לבדוק האם לתהליך יש גישה לביצוע הפעולה שהוא מבקש לבצע על אובייקט אבטחתי כלשהו במערכת. אובייקט אבטחתי – Securable Object הוא אובייקט שיש לו איזשהו "תיאור אבטחתי" או במונח המדויק Security Descriptor. ה-Security Descriptor נוצר ביצירת האובייקט באמצעות פרמטר המתאר אותו (תראו לדוגמה ב-CreateMutex יש פרמטר בשם lpMutexAttributes). לכל Thread יש גם כן Access Token, שהוא בדרך כלל אותו ה-Access Token של התהליך שאליו שייך ה-Thread (שהרי בפועל לתהליך אין קוד ולכן אין מצב שבו הוא ניגש למשאב מסוים אלא זה נעשה באמצעות ה-Thread-ים של אותו תהליך). אולם Thread יכול להכיל Access Token אחר, באמצעות ביצוע Impersonation לישות אחרת וכך להשתמש ב-Access Token האחר הזה. המבנה של Access Token מכיל בתוכו Byte שמציין האם זה Access Token ראשי (כלומר המקורי) או Access Token שנובע מ-Impersonation וכך המערכת יכולה לדעת זאת כאשר היא קוראת את ה-Access Token של Thread. אתם שואלים כיצד המנגנון שבודק את ה-Access Token עובד ומתי? שאלה טובה שנענה עליה עכשיו.כאשר Thread רוצה לגשת לאובייקט כלשהו, נניח Mutex, המערכת רוצה לדעת האם ל-Thread יש הרשאות מתאימות לתצורת הגישה שהוא מבקש. כדי לברר זאת היא מסתכלת על ה-DACL של הקובץ, שזהו מבנה בפני עצמו שנוצר על ידי הבעלים של האובייקט. בתוך ה-DACL יש לנו רשימה של ACE-ים כאשר כל אחד מהם הוא Entry, כלומר רשומה, כך שלמעשה ה-DACL הוא רשימה של ACE-ים שנוצרה על ידי הבעלים של האובייקט. כל ACE מכיל בתוכו את ה-SID (מספר מזהה שיש למשתמשים, קבוצות ו-Session-ים של התחברויות) של הישות שאליה הוא מתייחס, את תצורת הגישה (Access Mask) שאליה הוא מתייחס ואת סוג הרשומה עצמה (האם הרשומה מציינת Access Denied או Access Allowed ועוד). המערכת קוראת את ה-DACL ומבינה אם להעניק או לא להעניק גישה ל-Thread המבקש.
כדי להבין זאת מעט טוב יותר נביט בשרטוט הבא:
Process Handle Table
לפני שנסביר את זה, צריך להבין מהם אובייקטים ב-Kernel באופן כללי ועל כן נדבר על נושא זה בקצרה. אובייקטים ב-Kernel הם אובייקטים הנוצרים או מקריאה כלשהי ב-Kernel או מקריאה כלשהי ב-User-Space והם אינם נגישים בצורה ישירה מה-User-Space לעולם. במקום זאת הם נגישן באמצעות פונקציות API. דוגמאות לאובייקטים ב-Kernel הן Access Token Objects, Event Objects ו-Process Objects (EPROCESS זוכרים?). כאשר נשתמש בפונקציות API על מנת לקבל גישה לאובייקטים ב-Kernel, נקבל בסופו של דבר Data Type שנקרא Handle. זהו למעשה מבנה שמייצג את הדרך שלנו לגשת לאותו אובייקט שהוא משויך אליו בכל פעם. בפועל זהו ערך בגודל 32bit או 64bit (לפי הארכיטקטורה של המערכת). ה-Handle הזה הוא בעצם Index של רשומה בטבלה שנקראת Process Kernel Object Handle Table שקיימת ב-Kernel עבור כל תהליך. טבלה זו מתחילה כריקה בצורת ברירת המחדל שלה. היא יכולה להתחיל גם עם שורות קיימות, במידה ותהליך האב הוריש Handle-ים לתהליך הבן (שאליו מתייחסת הטבלה שאנחנו מתארים לצורך העניין). הטבלה מורכבת (לצורך הבנה בלבד ולא באופן מדויק) מהמאפיינים Index, מצביע לבלוק זיכרון של האובייקט ב-Kernel, Access Mask שמסביר את מדיניות הגישה לאותו אובייקט (נגיד FULL_ACCESS) ו-Flags שכל אחד מהם מציין דבר אחר (נגיד אם ה-Handle יכול להיות מורש בין תהליך אב ובן). כל רשומה בטבלה מייצגת Handle לאובייקט Kernel-י מסוים. כאשר אנו משתמשים בפונקציות כמו OpenFileMapping או OpenMutex ומקבלים Handle לאותו אובייקט שאנו מנסים לפתוח, אנו למעשה מיצרים רשומה חדשה ב-Process Kernel Object Handle Table. כאשר אנו משתמשים בפונקציה CloseHandle אנו למעשה מוחקים את אותה רשומה. אולם אנו לא מוחקים את האובייקט עצמו והוא יימחק רק ברגע שאין שום Handle אליו בשום מקום במערכת (והמערכת תדע זו מכיוון שלכל אובייקט יש מונה שמציג כמה פניות יש אליו כרגע. כאשר סוגרים Handle לאובייקט אותו מונה פוחת ב-1). הגישה לאובייקט Kernel-י באמצעות Handle לא אפשרית בין תהליכים שונים. כלומר אם תהליך א' קיבל Handle לאובייקט Kernel-י כלשהו והוא מעביר את ערך ה-Handle באמצעות IPC לתהליך ב', תהליך ב' לא יוכל להשתמש ב-Handle כי אין לו את אותה טבלה ועל כן תקרה שגיאה או התנהגות בלתי צפויה (נגיד וכן יש לו Handle במספר הזה אך הוא מכוון לאובייקט שונה). גישה לאובייקטים ב-Kernel מאופיינת, בין היתר, בפוליסת אבטחה. כלומר מה תכלית הגישה, האם המטרה היא לקרוא את תוכן האובייקט בלבד, האם יש צורך לשכתב אותו וכו'. מחד גיסא, כל תהליך ירצה לקבל גישה על פי הדרישה שלו. מאידך – המערכת מגדירה מה מותר ומה אסור על איזה אובייקט ולמי. כל למעשה כל אובייקט ב-Kernel מוגן באמצעות Security Descriptor. זהו מבנה שמתאר מי בעל האובייקט (כלומר מי יצר אותו), אילו קבוצות או משתמשים יכולים לגשת לאובייקט ואלו לא. פוליסת הגנה זו מגודרת באמצעות מבנה שנקרא SECURITY_ATTRIBUTES שאנו מעבירים לפונקציות היצירה של אובייקטים. נגיד ביצירת תהליך (CreateProcess) יש פרמטר שנקרא lpProcessAttributes והוא מהסוג LPSECURITY_ATTRIBUTES שזה מצביע למבנה ה SECURITY_ATTRIBUTES. השדה היחיד שמעניין אותנו במבנה הזה נקרא lpSecurityDescriptor שמטרתו לתאר את חוקי האבטחה של המבנה. ישנן כל מיני דרכים לשתף Kernel Objects נדבר על שלוש דרכים בקצרה. הדרך הראשונה היא באמצעות הורשה לתהליך בן. כדי שתהליך הבן ידע לאיזה אובייקט הורשה לו הגישה, נפוץ להעביר זאת באמצעות ה-Command-Line. דרך נוספת היא באמצעות ה-Environment Variables, גם כאן על תהליך הבן לדעת את שם המשתנה שמכיל את המידע הנחוץ לקבלת הגישה לאובייקט. לעיתים נרצה גם לשנות הרשאות מסוימות שיש לנו על אובייקט לפני שנעביר את הגישה אליו לתהליך בן. כך אם יש לנו גישת FULL ACCESS לאובייקט א' אך נרצה שלתהליך הבן תהיה גישת READONLY נוכל לערוך את ה-Handle באמצעות SetHandleInformation – פוקנציית API שעורכת את רשומת ה-Handle בטבלה. דרך שניה לחלוק אובייקטים מתבססת על Naming Objects. בעברית זה אומר שיש אובייקטים ב-Kernel, כמעט כולם אך לא כולם, שניתן להעניק להם שם בעת יצירתם. כך תהליך אחר שיודע את השם של האובייקט שיצרנו, יוכל לגשת לאותו אובייקט. פעולה זו נעשת באמצעות הפרמטר pszName שחלק מפונקציות היצירה מקבלות. נגיד עם Mutex זה יראה כך: CreateMutex(NULL, FALSE, TEXT("MyMutex")). הדרך השלישית היא באמצעות הפונקציה DuplicateHandle שיודעת לקחת Handle מטבלה של תהליך א' ולשכפל אותו לרשומה חדשה בטבלה של תהליך ב'. זה יכול לקרות באמצעות תהליך מתווך או באמצעות אחד מהצדדים שרוצים לחלוק את האובייקט.CSR_PROCESS
זהו מבנה שיש לכל תהליך ששייך ל-Subsystem של Windows. נבין את המשפט מהסוף להתחלה. ב-Windows יש כל מיני Subsystems. Subsystem היא בעצם אוסף של פונקציות ב-DLL-ים שונים או באחד, אשר מונגשות ל-User-Space, או יותר נכון למתכנתים של אפליקציות שירוצו שם, כדי לאפשר להן לבצע פעולות בצורה קלה יותר. כך יש Subsystem בשם WSL שמאפשרת ריצה של תכניות Linux על Windows, או Subsystem של Xbox שמאפשרת ריצה של תכניות Xbox על Windows או במקרים הפשוטים יותר Subsystem לאפליקציות Windows. ה-DLL-ים שמאפשרים את ה-Subsystem של Windows הם BASESRV.DLL שאחראי על ניהול Thread-ים ותהליכים, WINSRV שאחראי על שירותי משתמש ו-Console Management ו-CSRSRV.DLL שאחראי על כל שאר הדברים שמאפשרת התת-סביבה של Windows. את כל ה-DLL-ים האלה טוען התהליך שאחראי על כל התת-סביבה של Windows הלא הוא Csrss.exe. תהליך זה בהרשאות System ונוצר בידי התהליך Smss.exe מיד לאחר טעינת win32k.sys. אם תהליך זה מושבת, ה-Kernel משבית את המערכת (BSOD 😊). תחזרו למשפט הראשון של הפסקה ותקראו אותו שוב. עדיין לא מובן מספיק נכון? אז נסביר עכשיו את החלק הראשון של המשפט. לכל תהליך שרץ בתת-סביבה של Windows יש מבנה שנקרא CSR_PROCESS. באמצעות מבנה זה מנהלת התת-סביבה את התהליך. מבנה זה נמצא גם כן ב-Kernel ומנוהל על ידי התהליך Csrss.exe. המבנה שלו מתואר כאן. חלקים מעניינים במבנה הם ה-Reference count שאומר כמה אובייקטים משתמשים בתהליך הזה, ה-Thread List – רשימה של כל ה-Thread-ים של התהליך, ה-Parent – מצביע למבנה ה-CSR_PROCESS של התהליך אב של התהליך הזה. עכשיו אתם מסוגלים לקרוא את המשפט הראשון ולהבין מה הוא אומר.W32PROCSS
זהו מבנה שיש לכל תהליך Windows-י גראפי. בצורה פשוטה, כל תהליך שיש לו לפחות קריאת GDI אחת. מבנה זה מכיל הצבעה למבנה ה-EPROCESS של התהליך כמו גם מונה פניות (Ref Counter כמו ב-CSR_PROCESS לדוגמה), את PID של התהליך, Handle Table של התהליך ועוד.Priority Class
כאשר תהליך נוצר במערכת, מוגדר לו איזשהו ערך בין 0-31. ערך זה מסמל את העדיפות (ה-Priority) שלו במערכת מול תהליכים אחרים. 0 מייצג את העדיפות הנמוכה ביותר ואילו 31 את הגבוהה ביותר. מ-0 עד 15 כולל, יש לנו את הערכים של תהליכים נורמליים. מ-16 עד 31 יש לנו ערכי עדיפות של תהליכים שנקראים Real Time (זה לא באמת Real Time שהרי Windows היא לא מערכת הפעלה Real Time אלא זה מציין שפשוט התהליכים האלה הרבה יותר דחופים מהשאר). כך, במצב הפשוט ביותר – כאשר יש יחידת עיבוד אחת במערכת, התהליכים עם העדיפות הגבוהה ביותר יועבדו בדחיפות גבוהה יותר אל מול תהליכים שיש להם עדיפות נמוכה יותר. למעשה, כמו שכבר ציינו, תהליכים כשלעצמם אינם רצים, מי שרץ הם ה-Thread-ים של התהליכים. לכל Thread יש Priority שהוא יחסי ל-Priority של התהליך אליו הוא משתייך. למעשה אין לנו דרך מה-User-Mode לכתוב ערך מוחלט ולא יחסי ל-Thread. הדרך שלנו לשנות את ערך ה-Priority של Thread היא באמצעות הפונקציה SetThreadPriority. השינוי האפשרי הוא בין מינוס 2 לפלוס 2 ביחס ל-Priority Class של התהליך אליו משתייך ה-Thread (שדה זה נקרא Base Priority) או למינוס 15 או לפלוס 15 ביחס ל-Base Priority. מן הסתם שאין אפשרות לצאת מגבולות ה-0 עד 31 (וגם Thread של תהליך שלא מוגדר כ-Real Time לא יעבור את ה-15). ניתן לשנות את ה-Priority של Thread לערך אבסולוטי באמצעות הפונקציה KeSetPriorityThread, אולם פונקציה זו ניתנת להרצה רק מה-Kernel. ניתן להרחיב את השיח על Priority Class וההשפעה של הדבר על זמן העיבוד של כל Thread אך הדבר חורג מגבולות המאמר ועל כן לא נרחיב מעבר לנאמר בפסקה זו בנושא. אתם מוזמנים להרחיב על הנושא בלינק הבא כמו גם בהמון מקורות אחרים.עד לכאן לגבי מרכיבי התהליך ב-Windows. עכשיו שאנחנו יודעים ממה בנוי תהליך, נלמד כיצד תהליכים נולדים ומתים ונבחן דוגמאות עם קוד כדי להבין את ההבדלים הפרקטיים בין האפשרויות השונות.
איך תהליך נולד – כמו תינוק
יצירת תהליך ב-Windows מתבצעת באמצעות פונקציית API בשם CreateProcess. כאשר פונקציה זו נקראת, המערכת יוצרת אובייקט Kernel-לי שמטרתו לייצג את התהליך ב-kernel. זהו לא התהליך עצמו אלא רק מבנה שמכיל מידע על התהליך. בנוסף, המערכת יוצרת מבנה דומה בתכליתו, עבור ה-Thread הראשי של התהליך. המערכת יוצרת גם את מרחב הכתובות הוירטואלי של התהליך וטוענת את ה-Section-ים השונים של ה-Executable בנוסף לכל ה-DLL-ים שה-Executable מבקש. אם התהליך מבקש DLL-ים שכבר טעונים על ידי תהליך אחר, לדוגמה את Kernel32.dll, הוא לא ייטען אלא רק תהיה הפניה אליו במיקום (לתהליך זה שקוף, מבחינתו ה-DLL נטען כרגיל). ה-DLL-ים האלה, שכבר טעונים ורק נוצרת הפניה אליהם, מוגדרים על פי גישת Copy-on-write. מבחינת התהליך הגישה לא משנה, הוא מקבל את המידע שהוא צריך ולא אכפת לו אם זה הפניות או העתק של ממש. מבחינת המערכת, הגישה אומרת – "אני טוענת פעם אחת את ה-DLL לזיכרון, ואני אייצר הפניות לאותן כתובות פיזיות של ה-DLL שטענתי לכל התהליכים עד הרגע שהוא תהליך יבקש לשנות מידע ב-DLL הזה. ברגע שזה יקרה אני אשכפל את אותו דף בזיכרון שמכיל את המידע שהתהליך רוצה לשנות וככה אני אחסוך מקום בזיכרון".כאשר המערכת מסיימת ליצור את המבנה של התהליך, והמבנה של ה-Thread, המערכת מכניסה את ה-Context של ה-Thread למעבד כאשר האוגר שאחראי על הכתובת הבאה להריץ (EIP/RIP תלוי בארכיטקטורה), מצביע על ה-Entry Point שהוגדרה על ידי ה-Linker. בתכניות C/C++ זה יהיה ה C/C++ run time start up code שבסופו של דבר יריץ את פונקצית ה-main של התכנית שלך (בין אם זה wmain, main, WinMain או wWinMain).
כל זה מאוד תאורטי ואולי קצת לא מדויק אז ננסה להפוך את זה לרשימה מסודרת של פעולות שמבוצעות במערכת בעת יצירת תהליך.
נתחיל בכך שנציין כי מי שטוען תהליך הוא רכיב ה-Loader של Windows.
הדבר הראשון שקורה הוא בדיקה ופירסור הפרמטרים שמועברים לפונקציה CreateProcess. ברגע שצעד זה מושלם בהצלחה, CreateProcess קוראת לפונקציית NtCreateUserProcess שהמטרה שלה היא להבין איזה קובץ הרצה היא קיבלה, האם זה DLL, POSIX Executable, EXE רגיל של Windows או אחת ממספר אופציות נוספות. לאחר שנמצא סוג הקובץ, בהנחה שזה קובץ הרצה רגיל (משמע EXE Valid-י של Windows) הפונקציה PspAllocateProcess נקראת והיא אחראית ליצור את מבנה ה-EPROCESS ולאתחל את ה-KPROCESS, ליצור את מרחב הכתובות של התהליך, ליצור את ה-PEB ולמפות את ה-EXE למרחב הכתובות של התהליך).השלב הבא הוא לאתחל את ה-Thread הראשי, את המחסנית שלו (אמרנו שלכל Thread יש מחסנית משלו) ואת ה-Context שלו. לאחר השלב הזה, המערכת מבצעת מספר פעולות ייחודיות ל-Windows Subsystem כגון יצירה ואתחול מבנה ה-CSR_PROCESS ומבנה ה-CSR_THREAD שהזכרנו קודם לכן. כעת הכל מוכן – יש מרחב כתובות, מבנים ב-Kernel, ה-Windows Subsystem מודעת לתהליך החדש, כל מה שנותר זה להריץ את ה-Thread הראשי (אלא אם צוין ב-CreateProcess שהוא אמור להיות במצב Suspend). מכאן יש עוד מספר פעולות שרצות ב-Context של ה-Thread ולאחר מכן התהליך מתחיל את שגרת חייו על פי רצון המתכנת שלו.
מותו של תהליך
תהליכים יכולים להיהרג/להפוך מושבתים (נתייחס לפעולה באמצעות המילה השבתה – יותר מתאים לתרגום של Terminating) בארבע דרכים:הדרך הראשונה שהיא גם הכי מומלצת למתכנים היא באמצעות סיום תהליך הריצה של ה-Primary Thread בצורה תקינה – כלומר הגעה להוראת return. כך נוודא שהמערכת מנקה את כל המשאבים של האפליקציה בצורה מוסדרת. זה אומר שכל אובייקט שנוצר (נגיד שיש שימוש בתכנות מונחה עצמים עם C++), נהרס בשימוש ב-Destructor הייעודי לו, שהמערכת שחררה את הזיכרון בו השתמשה המחסנית של ה-Thread, המערכת תגדיר את קוד היציאה (process' exit code) לערך שהמתכנת ציין בהוראת ה-return ולסיום המערכת תפחית ב-1 את ה usage count במבנה ה-Kernel-לי של התהליך.
הדרך השנייה היא באמצעות קריאה לפונקציה ExitProcess - בפועל נקראת על ידי ה-C/C++ run-time code כאשר אנו מבצעים return באפליקציות C/C++. ההבדל בין הדרך הראשונה לדרך הזו היא שכאן אין ל-C/C++ run-time code את ההזדמנות לבצע את מה שהיא צריכה בעת הריגת התהליך – כמו לקרוא ל-Destructors. כל שאר הדברים אמורים להתנהל פחות או יותר או דבר – המערכת תוודא שכל הזיכרון שהוקצה והמשאבים האחרים שהוקצו לתהליך ישוחררו.
הדרך השלישית היא באמצעות קריאה ל-TerminateProcess - שונה מ-ExitProcess בדבר אחד: היא משביתה תהליך מרוחק, כלומר לא נועדה להשבית את התהליך שקורא לה אלא תהליך אחר, באמצעות העברת Handle לתהליך הזה.
הדרך הרביעית והאחרונה היא כאשר כל ה-Thread-ים בתהליך מתים - אם אמרנו שמי שבפועל מורץ על ידי המעבד הוא הקוד של Thread-ים של התהליך, מן הסתם שאם אין Thread-ים חיים לתהליך, אין סיבה שהמערכת תמשיך לקיים את התהליך הזה. לכן כאשר המערכת מזהה שאין לתהליך Thread-ים חיים, היא הורגת את התהליך ומגדירה את קוד היציאה לקוד היציאה של ה-Thread האחרון שמת.
לידתו של Thread
דיברנו על איך תהליך נולד, כעת נבין איך Thread נולד. הפונקציה CreateThread היא זו שאחראית ב-WinAPI ליצור Thread-ים (אולם בפועל נכון יותר להשתמש ב-_beginthreadex אבל לא נפרט על זה). כאשר Thread חדש נוצר, המערכת יוצרת את מבנה ה-ETHREAD וה-KTHREAD עבור ה-Thread. בנוסף המערכת מקצה מתוך מרחב הזיכרון הוירטואלי של התהליך אליו משתייך ה-Thread, זיכרון עבור המחסנית של ה-Thread. המערכת גם מגדירה את ה-TEB של ה-Thread ומודיעה ל-Subsystem של Windows על Thread החדש (והיא על פי המקרה, יוצרת מבנים רלוונטיים כמו CSR_THREAD). כיוון שה-Thread חי במרחב הכתובות של התהליך, הוא יכול לגשת לכל משאבי התהליך, כולל לכאלה של Thread-ים אחרים. הדבר מקל על עבודה משותפת בין Thread-ים שונים. דוגמה קלאסית יכולה להיות כתיבה ל-database – כל Thread מקבל קלט מ-socket כלשהו ואמור לרשום את המידע ל-DB. ה-Thread-ים יסונכרנו על ידי Mutex (מנגנון סנכרון ב-Windows) וכולם ישתמשו באותו חיבור ל-DB. כך אין צורך ליצור מספר חיבורים – אחד עבור כל Thread אלא אפשר לחלוק את אותו חיבור בין כל ה-Thread-ים.מותו של Thread
גם בסוגית הפטירה של Thread-ים יש דמיון נרחב למקרה המקביל בתהליכים. יש לנו ארבע דרכים בהן Thread יפטר מן העולם ויעלה לגן עדן של ה-Thread-ים.הדרך הראשונה היא באמצעות הגעה להוראת ה-return של ה-Thread - זו הדרך המומלצת ביותר לסיים את חייו של ה-Thread כי רק כך נוכל לוודא שהאובייקטים שנוצרו ימותו באמצעות ה-Destructors שלהם, המערכת תנקה את משאבי ה-Thread בזיכרון, המערכת תגדיר את קוד החזרה של ה-Thread למה שהמתכנת הגדיר בהוראת ה-return וה-Usage count יופחת באחד.
הדרך השנייה היא באמצעות הפונקציה ExitThread - בסופו של דבר ההבדל המהותי בין הדרך הזו לדרך הראשונה הוא שכאן אנו מדלגים על שלב הריגת האובייקטים בצורה המוגדרת להם (על פי Destructor-ים). אולם המערכת עדיין מנקה ומשחררת את המשאבים של ה-Thread.
הדרך השלישית היא באמצעות TerminateThread - שעושה את מה ש-ExitThread עושה רק על Thread מרוחק, כלומר אחר. הפונקציה תקבל Handle לאותו Thread ותנסה להרוג אותו.
הדרך הרביעית היא באמצעות הריגת התהליך לפני שכל Thread מת בעצמו - זו הדרך הכי פחות מומלצת. המערכת הורגת את התהליך וזה אומר שהיא גם הורגת את כל ה-Thread-ים של התהליך ומנקה את כל המשאבים שלהם אך שום פרוצדורה שאמורה לקרות לפני שה-Thread מת מתקיימת – לא יהיה ניקוי של אובייקטים, לא יהיה עדכון של מידע שה-Thread ביצע אלא פשוט הריגה שלו ללא התחשבות במצבו הנוכחי.
מעולה, הבנו איך תהליכים נולדים ומתים ואיך Thread-ים נולדים ומתים. כעת אני רוצה לדבר על תהליכים מוגנים הלוא הם Protected Processes.
Protected Process
לפני שיצרו את הקונספט של Protected Process, על פי מודל האבטחה של Windows, כל תהליך עם הרשאות Debug יכל לגשת לכל משאבי תהליך אחר, להזריק לתוכו DLL, ליצור בו Thread-ים, לקרוא ולכתוב לכתובות זיכרון שלו, בקיצור לעשות מה שבא לו. הדבר חרה למיקרוסופט ועל כן הם יצרו את הקונספט של Protected Process. הדבר בפועל, מתחת לפני השטח מתבטא בשני רבדים עיקריים – האחד במבנה ה-EPROCESS שם יש Flag חדש שמציין האם Process הוא Protected Process או לא. השני הוא באמצעות חתימה מיוחדת ש-Windows בודקת לפני שהיא מרשה לתהליך לרוץ כ-Protected Process. כלומר על מנת שתהליך יוכל לרוץ כ-Protected Process, הבינארי שלו חייב להכיל את החותמת הזו שמגיעה עם המון הגבלות. על פי מאמר מ-2007 שכתב Alex lonescu, המנגנון גרוע. אלכס מסביר כי עצם התקיימותו של המנגנון יוצרת בעיה עבור תכניות Anti Malware שבגלל שאינן מכילות את אותה חתימה ש-Windows דורשת עבור הרצה של קובץ הרצה כ-Protected Process. בגלל הבעיה הזו, ב-Windows 8.1 יצא לאור העולם מנגנון חדש בשם Protected Process Light – האח הפחות כבד של Protected Process. Protected Process Light או בקיצור PPL, מביא אתו מספר שינויים. ראשית ההגבלות החלות על DLL-ים שנטענים ל-Protected Process קצת הוקלו, ואף דרישות החתימה על ה-Executable הראשי השתנו גם הן. שינוי גדול נוסף שבא עם PPL הוא שיש מספר רמות של הגנה. מעכשיו במקום bit אחד ב-EPROCESS שמסמן אם תהליך הוא Protected יש שדה חדש שהוא מבנה אשר מתאר ארבעה פרמטרים – Level, Type, Audit, Signer. הפרמטר של Type יכול להיות כלום – PsProtectedTypeNone, יכול להיות Protected Light – PsProtectedTypeLight, יכול להיות Protected – PsProtectedTypeProteced או מוגן ברמה המקסימלית – PsProtectedTypeMax.ככה נראה המבנה החדש ב-EPROCESS:
אלו האפשרויות ל-Type:
אלו האפשרויות ל-Signer:
נוציא את הנותנים על Csrss.exe שאנחנו יודעים שהוא תהליך קריטי למערכת:
הכתובת לאחר המילה "PROCESS" היא הכתובת של ה-EPROCESS. נשתמש בפקודה הבאה כדי להוציא את ה-Protection של התהליך (נלך על השני) הזה:
מה אנחנו רואים כאן בעצם? אפשר לראות שה-Level הוא 0x6 ועוד 0x1. אם נסתכל על ה-Type נראה את הערך 0x1 שאומר לנו כי ה-Type של התהליך הוא PsProtectedLightProtected כלומר תהליך שהוא PPL. כעת נסתכל על ה-Signer ונראה שהוא בעצם 0x0110 שזה 0x6 שאומר PsProtectedSignerWinTcp. החיבור של שניהם הוא בעצם ה-Level.
כעת נראה את אותם נתונים עבור notepad.exe:
אפשר לראות ש-notepad.exe הוא לא PP או PPL בכלל (כי למה שיהיה?).
נקודה אחרונה לציין בנושא - ככל שהערך גבוה יותר, התהליך יותר מוגן (בין אם הוא PP או PPL). תהליך מקבל הרשאות מלאות על תהליכים עם אותו ערך או ערך נמוך ממנו. אולם הקשר הזה לא עובד כאשר PPL מבקש גישה ל-PP באותו רמה אך מהצד השני כן.
התמונה הבאה מהבלוג ProjectZero ממחיש את הנקודה:
יש עוד המון מה לדבר על PP ו-PPL אך זה חורג מגבולות הסיכום הזה, אולי במאמר כלשהו בעתיד ייצא לנו לחזור לנושא.
הנושא האחרון שאני אגע בו במסגרת המאמר הוא IPC. הגענו לסוף, גם לעייפים מבנינו – תתאמצו עוד קצת.
IPC
כמו שבני אדם רוצים וצריכים לתקשר אחד עם השני כדי לבצע משימות משותפות, גם בין תהליכים יש אמצעי תקשורת. אמצעי תקשורת בין תהליכים נקראים Inter Process Communication או בקיצור IPC. ישנם תשעה מנגנונים כאלה אשר נתמכים ב-Windows והם: Clipboard, COM, Data Copy, DDE, File mapping, Mailslots, Pipes, RPC, ו-Windows Sockets. כעת נפרט על חלק מהם.Clipboard
פעם חשבתם מה קורה כאשר אתם עושים העתק הדבק? כאשר אתם מעתיקים משהו מאיזשהו תהליך, המשהו הזה בעצם עובר ליחדת מידע שנקראת Clipboard. כל אפליקציה יכולה לגשת ל-Clipboard ולקחת משם מידע או לכתוב לשם מידע. ישנן פונקציות API המשמשות לכך.File Mapping
המנגנון הזה מאפשר לתהליכים להתנהל מול קובץ כאילו היה בלוק רגיל בזיכרון במרחב הכתובות של התהליך. התהליך עובד מול הזיכרון הזה באמצעות pointer אליו וכך יכול לבצע מולו מה שמוגדר לו לפי ההרשאות שהוגדרו. כאשר מספר תהליכים רוצים לעבוד מול אותו Mapped File (התוצאה של פעולת File Mapping), כל אחד מהם מקבל pointer אליו. כמו בכל מצב בו יש שימוש משותף במשאבים בין תהליכים, צריך לשים לב שתהליך אחד לא הורס לשני. לדוגמה שתהליך אחד כותב ל-Mapped File ותהליך אחר גם כן כותב אליו בו זמנית מה שייצור אי סדר אחד גדול. בשביל זה יש לנו מנגנוני סנכרון בין תהליכים כגון Mutex, Semaphore ועוד. המצב הסופי נראה ככה:בפועל מה שהמתכנת צריך לעשות זה להשתמש בפונקציה CreateFileMapping כדי לקחת את הקובץ מהדיסק ולמפות אותו לזיכרון. לאחר מכן להשתמש ב-MapViewOfFile כדי להשתמש במיפוי שיצרנו. MapViewOfFile תחזיר לנו מצביע לתחילת ה-View שיצרנו. אבל עוד לא הבנו איך מיפוי שביצע תהליך אחד נגיש למיפוי של תהליך אחר. יש כמה דרכים, אחת מהן שהיא זו שגם הכי קל ליישם היא באמצעות מתן שם למיפוי הזה. הפונקציה CreateFileMapping מקבל פרמטר שם אותו היא מעניקה למיפוי החדש. הפונקציה OpenFileMapping מקבלת שם של מיפוי ומחפשת אותו, אם היא מוצאת היא מחזירה Handle אליו בו הפונקציה MapViewOfFile משתמשת כדי לקבל View למיפוי.
Pipes
אמצעי תקשורת זה דומה למצב בו ילד אחד מחזיק קצה אחד של חוט אליו מחוברת כוס וילד שני מחזיק קצה שני של חוט אליו מחוברת גם כן כוס. כל אחד מהילדים מדבר בתורו כאשר השני מקשיב וכך הם יכולים להעביר מידע. ב-Windows יש לנו שני סוגים של Pipe-ים. הסוג הראשון הוא Pipe אנונימי (Anonymous Pipe). סוג זה דורש משני התהליכים שמשתמשים בו להיות קשורים אחד לשני. מצב נפוץ ביותר לשימוש ב-Pipe אנונימי הוא תקשורת בין תהליך אב לתהליך בן. נקודה חשובה נוספת על Pipe-ים אנונימיים היא שרק צד אחד יכול לכתוב ל-Pipe והצד השני יכול רק לקרוא ממנו. כך על מנת שתהליך אב יוכל לתקשר עם תהליך בן יש צורך בשני Pipe-ים. אחד לקריאה עבור תהליך האב ולכתיבה עבור תהליך הבן, ואחד לקריאה עבור תהליך הבן ולכתיב עבור תהליך האב. הסוג השני של Pipe-ים נקרא Named Pipes כלומר Pipe-ים בעלי שם. בסוג זה הדרישה לקשר בין התהליכים המשתתפים בתקשורת לא נדרשת. על כן כל תהליך יכול לדבר עם כל תהליך שבמרחב הכתובות שלו הוגדר שם ה-Pipe. בנוסף, שוב בשונה מ-Anonymous Pipes, Named Pipe אחד יכול לשמש לתקשורת מלאה בין שני תהליכים. כלומר אין צורך בשני Pipe-ים כדי לבצע תקשורת בה שני הצדדים מכניסים Input או Output. כאשר אחד כותב השני יאזין וכאשר השני כותב הראשון יאזין. המימוש של Named Pipes הוא בפועל איזשהו Shared Memory אליו המערכת מתנהגת כ-File Object בזיכרון. בפועל זה אומר שהעבודה מול Named Pipes תתבצע עם פונקציות כגון ReadFile ו-WriteFile. כאשר תהליך אחד כותב ל-Named Pipe הוא בעצם כותב לאותן כתובות של ה-File Object ובאותו קונספט מתבצעת גם הקריאה. חשוב לציין שכאשר נקרא מ-Pipe, המידע שקראנו ינוקה מה-Pipe מה שאומר שהוא לא יהיה נגיש שוב. אולם הפונקציה PeekNamedPipe פותרת בעיה זו.עד לכאן לגבי IPC – לא דיברנו על כל האפשרויות, בחרתי לדבר דווקא על אלו, כי הן ממחישות היטב את הרעיון מאחורי IPC.
מאוד מעניין, אינפורמטיבי ומהנה. מנסה ללמוד כרגע כמה תוכנות כמו פייתון וג'אווה אז מרגיש שכל פיסת מידע מועילה לי.
השבמחקתודה על השיתוף