ברוכים הבאים לאזור השיעורים והאתגרים הטכנולוגיים של ITsafe

קריאה מהנה

Minesweeper Debugger Loop

זהו מאמר המשך למאמר הקודם בו הצגתי לכם כיצד בצעתי הנדסה לאחור למשחק שולה המוקשים לינק

במאמר זה אנו נכתוב קוד שיפתור את המשחק בשבילנו, מטרת המאמר היא ללמד אתכם כיצד עובד debugger מאחורי הקלעים, להסביר לכם מה זה debugger loop וכיצד נראה break point.

את הקוד של המאמר ניתן להוריד מה-GitHub שלי בלינק הבא:

להורדת הקוד

בסרטון הבא אציג לכם שימוש ב-debugger loop וניצחון בשולה המוקשים באמצעות קוד:



איך עובד debugger?

כעיקרון debuggerים עובדים באמצעות שתי שלבים:
  • תחילה עלינו לומר למערכת ההפעלה לתת לנו מידע על התהליך שאנו רוצים לבחון באמצעות אחד הדגלים הבאים DEBUG_ONLY_THIS_PROCESS או DEBUG_PROCESS.
  • יצירת debugger loop אשר יטפל באירועים שיכולים לקרות בזמן ה-debugging.

לפני שנתחיל יש לעבור על מספק חוקים בעולם הdebugging-:
  • Debugger - זו התוכנה שבעצם מבצעת את כל הלוגיה של ה-debugger loop ואיתה אתם חוקרים את ה-debuggee.
  • Debuggee - זו התוכנה שעליה מבצעים את פעולת ה-debug ואותה אנו חוקרים.
  • לכל תוכנה יכול להיות מחובר רק debugger אחד שהוא יכול לחקור אותה ואת כל ה-threadים שלה.
  • רק ה-thread שפתח את ה- Debuggee יכול לבצע debug לתהליך, לכן CreateProcess חייב להיות באותו ה-thread עם Debugger-loop.
  • בזמן ביצוע debug event כל ה-threadים ב-debuggee הם במצב suspended.


הפעלת תוכנה תחת debugger:

כפי שציינתי קודם לכן כדי לבצע debugging עלינו להתחיל את התהליך עם ה dwCreateionFlags – DEBUG_ONLY_THIS_PROCESS מה שיראה כך:
כך בעצם התחלנו את התהליך במצב debugging ואמרנו למערכת ההפעלה לתת לתהליך שלנו מידע על כל ה- debugging events כגון:
  • Process creation/termination
  • Thread creation/termination
  • Runtime exceptions
  • ועוד...

כמו כן, הדגל DEBUG_ONLY_THIS_PROCESS מציין שאנו מבצעים debug לתהליך הנוכחי בלבד ומתעלמים מכל תהליכי הילד שהוא יוצר.

יחד עם זאת בתום שלב זה נראה שנוצר תהליך חדש ב- Task Manager אבל הוא במצב suspend וכעת עלינו ליצור את ה- debugging-loop שדיברנו עליו קודם לכן.

debugger loop

debugger loop הוא המוח של ה-debugger, הוא מתבצע בלולאה וכל פעם מחזיר את האירוע שמערכת ההפעלה העבירה אליו באמצעות WaitForDebugEvent. הדבר נראה כך:

הפקודה ContinueDebugEvent בעצם אומרת למערכת ההפעלה להמשיך להריץ את ה- debuggee. כמו כן, אנו מקבלים את כל הערכים הנחוצים ב- debug_event מהפקודה WaitForDebugEvent.

הפרמטר האחרון מציין האם להמשיך או לא להמשיך בפעולה ואליו מתייחסים רק כאשר ה-event הינו exception-event. בנוסף, אפשר גם לציין DBG_EXCEPTION_NOT_HANDLED כדי לתת לתוכנה שעושים לה debugging לטפל באירוע.

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

גם לא process explorer, task manager וכו'... פעולת kill תגרום לכך שמערכת ההפעלה תתזמן EXIT_PROCESS_DEBUG_EVENT כפקודה הבאה שה-debugger שלנו יקבל ב-debugging loop.

Handling debugging events

קיימים 9 סוגים שונים של אירועים עיקריים, ועוד 20 תתי אירועים עבור exception-event, נתמקד בהם בהמשך וכרגע נסתכל על המבנה של DEBUG_EVENT שנראה כך:

הפקודה WaitForDebugEvent ממתינה עד אשר נקבל אירוע כלשהו ואז ממלאה את כל הנתונים במבנה הזה. ה-dwDebugEventCode מציין מה האירוע שבעצם התחרש ועל סמך מספר האירוע נדע מה לעשות לפי האירוע ב- union.

debugger events

האירוע OUTPUT_DEBUG_STRING_EVENT מתרש כאשר אנו רוצים ליצור debug טקסט כלשהו שיוצג בחלון ה-Output של ה-Debugger. כשאנו מקבלים אירוע מסוג זה אנו עובדים עם מבנה שנקרא OUTPUT_DEBUG_STRING_INFO אשר נראה כך:

המשתנה nDebugStringLength מהווה את גודל הטקסט יחד עם ‘\0’ ( null terminiating ).
המשתנה fUnicode מציין את פורמט הטקסט ב-0 (unicode) או ב-1 (ANSI).
המשתנה lpDebugStringData בהתאם ל-fUnicde מכיל את הטקסט.

יש לקחת בחשבון, שהכתובת של ה-lpDebugStringData לא נמצאת במרחב הזיכרון של ה-debugger ולכן יש לקרוא את הכתובת ממרחב הזיכרון של התהליך הנחקר ה-debuggee.

כדי לקרוא את הזיכרון של תהליך אחר אנו משתמשים ב-ReadProcessMemory. כמובן שעל התהליך הקורא להיות בעל ההרשאות המתאימות שזה כלל לא בעיה מכיוון שאנחנו יוצרים את התהליך מתוך ה-debugger אז יש לנו את ההרשאות לבצע קריאות מסוג זה.

CREATE_PROCESS_DEBUG_EVENT הוא האירוע הראשון שה-debugger יקבל כאשר נוצר תהליך ה-debuggee.

בתוך ה- DEBUG_EVENT נשתמש ב-CreateProcessInfo שימלא לנו את המבנה CREATE_PROCESS_DEBUG_INFO

מבנה זה מכיל מידע רב על התהליך הנוצר שאנו יכולים להשתמש בו בהמשך.

מיד לאחר סיום ההוראה CREATE_PROCESS_DEBUG_EVENT מתבצעת טעינת dllים לזיכרון של ה-Debuggee מה שגורם להוראה מסוג LOAD_DLL_DEBUG_EVENT להתבצע עבור כל dll ו-dll או כאשר תהליך ה-debuggee מבצע LoadLibrary במפורש.

על מנת לגשת למבנה שמכיל מידע על ה-dll אנו משתמשים ב-LoadDll שממלא לנו את המבנה LOAD_DLL_DEBUG_INFO מה שנראה כך:

כאשר נוצר thread חדש ב-debuggee האירוע CREATE_THREAD_PROCESS_DEBUG_EVENT נקרא. האירוע הזה נקרא לפני שה-thread מתחיל לעבוד בפועל ובכך מאפשר ל-debugger להחליט מה לעשות איתו. כדי להגיע לנתונים הקשורים לאירוע אנו פונים ל-CreateThread שממלא את CREATE_THREAD_DEBUG_INFO שנראה כך:

כמובן שגם כאן מדובר בכתובת של ה-debuggee.

כאשר ה-thread מסיים את פעילותו נקרא האירוע EXIT_THREAD_DEBUG_EVENT. גם כאן כמו בקודמיו ניתן לגשת לאירוע דרך משתנה מ-DEBUG_EVENT שנקרא dwThreadId. כך נדע איזה thread בעצם סיים את פעולתו. כדי לקבל את כל המידע על ה-thread נסתכל במבנה EXIT_THREAD_DEBUG_INFO:

בדיוק כמו בעבודה עם threadים כאשר dll כלשהו מבצע unload מתרחש האירוע UNLOAD_DLL_DEBUG_EVENT. כדי להגיב בהתאם לאירוע הזה אנו משתמשים ב-UnloadDll שממלא את UNLOAD_DLL_DEBUG_INFO:

כפי שניתן לראות, יש לנו רק את המצביע ל-base-address של אותו ה-DLL, לכן עלינו לקחת בחשבון בעת יצירת ה-debugger שלנו שיש לשמור lpBaseOfDll של כלל DLL שנטען ב-LOAD_DLL_DEBUG_EVENT אחרת לא נדע מי ביצע unload!

כאשר נסגרת התוכנה בצורה תקינה או כתוצאה משגיאה מתרחש האירוע EXIT_PROCESS_DEBUG_EVENT שהוא האירוע הפשוט ביותר. כדי להגיע למידע שלו נשתמש ב-ExitProcess שממלא את EXIT_PROCESS_DEBUG_INFO:

כאשר אירוע זה קורה עלינו להפסיק את ה-debugger loop שלנו ובכך נסיים את תהליך ה-debugging שלנו.

כאשר מתרחשת שגיאה נקרא האירוע EXCEPTION_DEBUG_EVENT שזה בעצם האירוע המרכזי של ה-debugger ועליו אני רוצה להתמקד.

EXCEPTION_DEBUG_EVENT כפי שציינתי קודם לכן זהו האירוע החשוב ביותר ב-debugger loop אשר אחראי לטפל בכל השגיאות שקורות בעת תהליך ה-debug כגון:
  • גישה למקומות זיכרון מחוץ לתחום שלנו
  • Breakpoint
  • חלוקה ב-0
  • SEH
  • וכו'...

בנוסף, יש מבנה שמתמלא על ידי מערכת ההפעלה שנקרא EXCEPTION_DEBUG_INFO ונגיש דרך Exception:

בתוך מבנה זה נמצא עוד מבנה בשם EXCEPTION_RECORD שמכיל מידע לגבי השגיאה עצמה:

כאשר אנו מבצעים debugging יש לקחת בחשבון שה-debugger מקבל את השגיאות לפני ה-debuggee.
  • כאשר ה-debugger מקבל את השגיאה זה נקרא שגיאת First Chance
  • כאשר ה-debuggee מקבל את השגיאה זה נקרא שגיאת Second Chance

קיימים סוגים של שגיאות שהן לא באמת שגיאות כגון break point, שהן קשורות רק ל-debugger וכלל לא קשורות ל-debuggee ולכן ה-debugger מקבל את היכולת לטפל בהן ראשון.
במידה ויש לנו אירוע שגיאה עלינו להעביר ל-ContinueDebugEvent את אחת האפשרויות הבאות:
  • DBG_CONTINUE – כאשר השגיאה טופלה בצורה מסודרת ואין שום פעולה שה-debuggee צריך לבצע כדי להמשיך לעבוד בצורה תקינה.
  • DBG_EXCEPTION_NOT_HANDLED – כאשר ה-debugger לא הצליח לטפל בשגיאה ועל התוכנה לטפל בה.

במידה ואנחנו לא יודעים מה לעשות עם השגיאה עדיף להעביר אותה לתוכנה באמצעות DBG_EXCEPTION_NOT_HANDLED אלא אם כן יש לנו breakpoint ששמנו ועלינו לטפל בו כמובן.

Exceptions codes
1. EXCEPTION_ACCESS_VIOLATION
2. EXCEPTION_ARRAY_BOUNDS_EXCEEDED
3. EXCEPTION_BREAKPOINT
4. EXCEPTION_DATATYPE_MISALIGNMENT
5. EXCEPTION_FLT_DENORMAL_OPERAND
6. EXCEPTION_FLT_DIVIDE_BY_ZERO
7. EXCEPTION_FLT_INEXACT_RESULT
8. EXCEPTION_FLT_INVALID_OPERATION
9. EXCEPTION_FLT_OVERFLOW
10. EXCEPTION_FLT_STACK_CHECK
11. EXCEPTION_FLT_UNDERFLOW
12. EXCEPTION_ILLEGAL_INSTRUCTION
13. EXCEPTION_IN_PAGE_ERROR
14. EXCEPTION_INT_DIVIDE_BY_ZERO
15. EXCEPTION_INT_OVERFLOW
16. EXCEPTION_INVALID_DISPOSITION
17. EXCEPTION_NONCONTINUABLE_EXCEPTION
18. EXCEPTION_PRIV_INSTRUCTION
19. EXCEPTION_SINGLE_STEP
20. EXCEPTION_STACK_OVERFLOW


לא נעבור עליהם כרגע, אלא רק נתמקד ב-EXCEPTION_BREAKPOINT שנראה כך:


Debugger Main Engine

כדי שה-debugger שלנו יעבוד בצורה תקינה עלינו להוסיף לו מספר יכולות בסיסיות כגון:

1. קבלת נתונים בסיסיים על ה-Debuggee שלנו באמצעות האירוע CREATE_PROCESS_DEBUG_EVENT.

כאשר תוכנית מתחילה לרוץ האירוע CREATE_PROCESS_DEBUG_EVENT נקרא וממלא את המבנה CREATE_PROCESS_DEBUG_INFO שמכיל lpStartAddress ונתונים נוספים כגון ה-handle ל-process וה-handle ל-thread שנשתמש בהם בהמשך כדי לעבוד מול ה-debuggee:
                                            
g_hProcess = DebugEv->u.CreateProcessInfo.hProcess;
g_hThread = DebugEv->u.CreateProcessInfo.hThread;
                                            
                                        
כמו כן כדי לבצע breakpoint עלינו להחליף byte אחד בתחילת ההוראה ל-CC מה שמציין INT 3. לאחר מכן, כאשר נטפל באירוע בקוד שלנו נצטרך להחזיר את ה-byte שהיה שם. כך בעצם עובד breakpoint מאחורי הקלעים, כפי שהסברתי בסרטון בראש המאמר.

2. לבצע break point במקום שמעניין אותנו.
כדי לבצע break point עלינו לבצע את השלבים הבאים:

1. לקרוא byte אחד מהכתובת שאנו רוצים לבצע שם breakpoint ולשמור אותו במבנה נתונים כלשהו.
2. לכתוב את הopcode- 0xCC במקומו ובכך לשנות את ההוראה.


3. לטפל באירוע ה-breakpoint בצורה נכונה.

כפי שציינתי קודם לכן, EXCEPTION_BREAKPOINT הינו אירוע מסוג של EXCEPTON_DEBUG_EVENT שממלא את המבנהEXCEPTION_DEBUG_INFO .

חשוב לציין שכאשר בונים debugger מערכת ההפעלה תשלח לנו breakpoint instruction עוד לפני שתהליך ה-debuggee יעלה כדי לציין שהתהליך תחת debugger. לכן מומלץ לדלג על ה- breakpoint הראשון שה-debugger שלי מקבל כפי שאני ממליץ. כך:

                                            
case EXCEPTION_BREAKPOINT:
    if (m_bBreakpointOnceHit)
    {
        // actual breakpoint event
    }
    else
    {
        m_bBreakpointOnceHit = true;
    }
    break;
                                            
                                        
כך בעצם נדלג על ה-breakpoint הראשון של מערכת ההפעלה ולא נטפל בו כי מערכת ההפעלה מטפלת בו בעצמה ואין לנו מידע שאנחנו צריכים לשחזר.

לאחר שהמעבד יעצור בגלל ה-breakpoint שלנו, עלינו לשחזר אותו על ידי חזרה של הוראה אחת אחורה, זאת אומרת שעלינו לחזור byte אחד ב-EIP לפני שאנחנו מתקנים את ה-breakpoint.

כדי לעשות זאת עלינו להשתמש בשתי הפקודות:
  • GetThreadContext – קבלת גישה ל-context של ה-thread, כלומר כל האוגרים של המעבד.
  • SetThreadContext – כתיבה ל-context, משמע שינוי האוגרים.

כדי לקבל את ה-context נבצע את הפעולה הבאה:

                                            
CONTEXT lcContext;
lcContext.ContextFlags = CONTEXT_ALL;
GetThreadContext(m_cProcessInfo.hThread, &lcContext);

                                            
                                        
ובכך נקבל את כל האוגרים, כעת ניגש ל-EIP ונחזיר אותו byte אחד אחורה.
                                        
lcContext.Eip--; // Move back one byte
SetThreadContext(m_cProcessInfo.hThread, &lcContext);
                                        
                                        
כדי לבצע פעולות אלו אני צריך את ההרשאות הבאות, שכמובן יש לנו כי אנחנו debugger.
  • THREAD_SET_CONTEXT
  • THREAD_GET_CONTEXT

פעולות אלו מבצעות context-switch ומחזירות הוראה אחת אחורה, כעת נשחזר את ה-byte ששמרנו ונתקן את ההוראה כדי שהתוכנה תמשיך לרוץ בצורה תקינה.

                                        
DWORD dwWriteSize;
WriteProcessMemory(g_hProcess, (void*)dwAddress, &temp->cInstruction, 1, &dwWriteSize);
FlushInstructionCache(g_hProcess, (void*)dwAddress, 1);
                                        
                                        
זו בעצם הלוגיקה המרכזית שיש להכיר כאשר עובדים עם debugger, ממליץ לכם לנסות לממש בעצמכם עוד מספר הוראות ב-debugger שלכם וכך להבין לעומק את כל פעולותיו של ה-debugger. כמו כן ממליץ לכם לקרוא כיצד אפשר לבצע break point בצורה שונה באמצעות trap flag.

בהצלחה!

Share this post