3.9 דיבוג עם GDB הרצאה
בהרצאה הזו נלמד איך להשתמש ב־GDB, הכלי החזק של עולם התכנות בשפת C (ושפות נוספות) לצורך ניפוי שגיאות (Debugging).
הכלי GDB (GNU Debugger) מאפשר לנו להריץ תוכנית שלב־אחר־שלב, לעצור אותה בכל נקודה, לבדוק ערכים של משתנים, לשנות זיכרון, לקפוץ בין פונקציות, לבדוק איפה הייתה קריסה – וכל זה מתוך סביבת פקודה טקסטואלית.
מבוא: למה צריך Debugger בכלל?¶
כשאתה כותב תוכנית – ייתכנו שגיאות לוגיות (התוכנית רצה, אבל לא עושה מה שציפית), שגיאות בזיכרון (גישה למקום לא תקין), או פשוט קוד שלא עושה כלום.
אז printf עוזר – אבל קשה להוסיף אותו בכל מקום, הוא איטי, ולא תמיד חושף את הבעיה.
הכלי GDB נותן שליטה בכל שלב בריצה. אתה יכול לעצור, להסתכל, להריץ פקודה, לבדוק ערך, לשנות משתנה – בזמן שהתוכנית "קפואה".
ניפוי שגיאות בעזרת GDB – מבט מהבסיס¶
לפני שלומדים פקודות או שיטות, צריך להבין מה בעצם GDB עושה.
כלי הGDB הוא debugger – כלי שמאפשר לנו להריץ תוכנית בקצב איטי ומבוקר, לעצור אותה בכל רגע, לבדוק ערכים בזיכרון, לבדוק מה קרה רגע לפני קריסה, ולהבין מה גורם לבאגים שקשה לאתר בקריאה רגילה של הקוד.
אבל GDB לא מדבג את הקוד C שלך. הוא מדבג את שפת המכונה.
כשאתה מקמפל תוכנית ב־C, התוצאה היא קובץ הרצה (ELF ב־Linux, EXE ב־Windows) – כלומר קוד בשפת מכונה שהמעבד יכול להריץ.
ו- GDB פועל על קובץ ההרצה הזה – ומאפשר לעצור בו, להריץ פקודות, לצפות בתוכן הזיכרון ובקוד האסמבלי של התוכנית בפועל.
מה הם Debugging Symbols ולמה צריך את -g¶
ברירת מחדל, כשאתה מקמפל קובץ C, הקומפיילר שומר רק את הקוד המכונתי – אין שם שום זכר לשמות הפונקציות שכתבת, שמות המשתנים שלך, ואפילו לא מספרי שורות.
כדי ש־GDB יוכל להראות לך איפה אתה נמצא בקוד המקור, לקרוא משתנים בשמותיהם, לעצור ב־main ועוד, – אתם חייבים לקמפל את הקוד עם הדגל -g.
ה־-g מבקש מהקומפיילר להוסיף לקובץ ההרצה debugging symbols – שהם פשוט מיפוי: איזה שורת קוד נמצאת באיזה כתובת בזיכרון, איזה משתנה נמצא באיזה offset, איך קוראים לפונקציה הזו, וכו'.
הם לא משפיעים על הריצה, הם רק עוזרים ל־GDB לתרגם את שפת המכונה לקוד שאתה כתבת.
הפעלת GDB¶
להרצת GDB על קובץ שהרגע קימפלת:
תקבל את ה־prompt:
מכאן תוכלו להתחיל להשתמש בכל הפקודות של gdb.
הפעלת התוכנית מתוך GDB¶
כדי להריץ את התוכנית שלכם (מההתחלה), השתמשו בפקודה:
אם התוכנית דורשת ארגומנטים:
עצירה בקוד – Breakpoints¶
כדי לעצור את התוכנית בנקודה מסוימת (לפני שהיא מגיעה לשם), השתמש ב־breakpoint. לדוגמה:
עוצר בכניסה לפונקציה
main.עכשיו תוכלו לעשות
run ולראות שהתוכנה נעצרת בmain.הריצו את הפקודה
c (קיצור של continue) כדי לתת לתוכנה להמשיך לרוץ עד הסוף.
בצעו את הפקודה disass main כדי לראות את האסמבלי של פונקציית הmain.
הפלט יהיה ברמת אסמבלי (הוראות כמו mov, call, ret, וכו') עם כתובות זיכרון.
עכשיו, תוכלו לבצע break לכתובת מסיימת בזכרון, בחרו כתובת ובצעו:
במקום break תוכלו לכתוב כקיצור (b)
פקודות לבקרה על ריצה¶
אחרי שעשינו break point בנקודה מסוימת באסמבלי שלנו, נוכל לבצע המון פקודות כדי לזוז בקוד.
continue– ממשיך לרוץ עד breakpoint הבא (או סוף התוכנית)
קיצור הפקודה הוא (c)nexti– מבצע את ההוראה הנוכחית ומתקדם לשורה הבאה (לא נכנס לפונקציות)
קיצור הפקודה הוא (ni)stepi– נכנס לתוך פונקציה בהוראה הנוכחית
קיצור הפקודה הוא (si)finish– יוצא מהפונקציה הנוכחית וממשיך לאחריה
קיצור הפקודה הוא (f)
דוגמה:
בדיקת מבנה התוכנית¶
אז אלו דברים נוכל לבדוק על המערכת בזמן breakpoint?
- פקודת backtrace (או bt) – מדפיס את ה־call stack (מי קרא למי עד כה)
- פקודת info frame – מידע על הstack frame הנוכחית
- פקודת frame – בוחר פריים כלשהו שנמצא ב־stack
- פקודת info breakpoints – מציג את כל הbreak point-ים
- פקודת delete – מוחק breakpoint מסוים
- פקודות disable / enable – מכבה/מדליק breakpoint מסוים זמנית
- פקודת info registers- מציגה את כל האוגרים במצב מסוים
פקודת x - examine¶
הפקודה x (קיצור של examine) ב־GDB מאפשרת לנו לבדוק את תוכן הזיכרון בתוכנית בזמן ריצה. היא אחת הפקודות המרכזיות לדיבוג ברמת שפת מכונה, במיוחד כשאנחנו רוצים לראות מה יש בזיכרון בכתובת מסוימת – בין אם זה מערך, מחרוזת, מצביע, או קוד אסמבלי.
התחביר הבסיסי של x¶
רכיבי התחביר:¶
כתובת- לאיזה כתובת בזכרון לעשות dereference[מספר]– כמה יחידות להדפיס (ברירת מחדל: 1)[פורמט]– איך להציג את הערכים בזכרון[יחידה]– גודל התצוגה (כלומר כמה בתים להציג בכל פעם)
סוגי הפורמטים השונים:x– הקסדצימלי (hex)d– עשרוני (decimal)u– עשרוני לא־חתוםt– בינאריf– מספר ממשי (float)s– מחרוזת (string)i– אינסטרוקציה (הוראות אסמבלי)c– תו (char)
סוגי היחידות השונות:b– byte (1 בית)h– half-word (2 בתים)w– word (4 בתים)g– giant word (8 בתים)
לדוגמה:
x/4xb 0x601000– הצג 4 בתים כהקס.
ניתן לציין כתובת באמצעות אוגרים עם שימוש בשם האוגר בסימן $.
דוגמאות שימושיות¶
הצגת 10 מילים בזיכרון בכתובת מסוימת כהקסדצימליות¶
- מציג 10 "words" של 4 בתים כל אחד
- כתובת ההתחלה היא
0x601000- כל ערך יודפס כהקסדצימלי (x)
הצגת תוכן של מערך שלמים¶
נניח שיש לכם:
כדי לראות את הערכים בזמן ריצה, תוכלו להבין מה הכתובת של המשתנה arr על פי, או הפקודה print אם יש לכם symbole-ים בצורה הבאה:
או באמצעות הסתכלות על האסמבלי (פשוט disass לפונקציה שבה נמצא המשתנה) ומציאת הכתובת על פי האסמבלי.
לאחר שיש לנו את הכתובת, נוכל לבצע x כזה:
-
4d – 4 ערכים עשרוניים-
w – גודל של כל איבר (4 בתים)-
0x601040 – הכתובת של המערך
הצגת מחרוזת מכתובת מסוימת¶
- יציג את המחרוזת שנמצאת שם עד
\0
הצגת הוראות אסמבלי¶
-
i = instruction-
$rip = מיקום התוכנית (instruction pointer)- יציג את 5 ההוראות הבאות שהמעבד יבצע
זה נותן מבט ישיר על קוד המכונה שירוץ.
שימוש עם משתנים או מצביעים¶
נניח שיש לכם:
אפשר לבדוק:
- מציג 16 בתים שמצביע
ptr מצביע אליהם- כל ערך יודפס כהקס
קיצור – שימוש חוזר¶
אחרי שאתם מריצים x פעם אחת, אפשר לחזור על אותה פקודה עם enter.
למשל:
כל לחיצה תדפיס את 4 המילים הבאות בזיכרון.
נניח שאתם בתוך פונקציה כלשהי במהלך דיבוג עם GDB, ורוצה לראות את תוכן ה־stack frame הנוכחי — כלומר מה נמצא על מחסנית הקריאות (stack): משתנים מקומיים, כתובת חזרה לפונקציה הקודמת, פרמטרים לפונקציה ועוד.
הכלי GDB מאפשר לך לבדוק את ערכי הזיכרון ישירות, בעזרת הפקודה x, דרך מצביע ה־rbp (או ebp במערכות 32 ביט) שהוא בסיס ה־frame של הפונקציה הנוכחית.
דוגמה מלאה: צפייה ב־stack frame¶
נניח שיש לנו את הקוד הבא:
#include <stdio.h>
void print_values(int a, int b) {
int sum = a + b;
printf("sum = %d\n", sum);
}
int main() {
print_values(10, 20);
return 0;
}
הרצה עד לכניסה לפונקציה:¶
צפייה בכתובת בסיס ה־frame:¶
תקבלו משהו כמו:
זוהי כתובת הבסיס של stack frame הנוכחי.
הצגת תוכן ה־stack:¶
הסבר:
- 10 – נסתכל על 10 תאים (words)
- x – תצוגה הקסדצימלית
- w – גודל של כל תא: word (4 בתים במערכת 32 ביט, 8 בתים במערכת 64 ביט)
- $rbp – רשום של בסיס המחסנית
זה ידפיס את תוכן המחסנית מהנקודה של rbp והלאה, כולל:
- כתובת החזרה לפונקציה הקודמת
- משתנים מקומיים
- פרמטרים לפונקציה (בהתאם למערכת ול־calling convention)
הערה: מבנה stack frame משתנה לפי מערכת ההפעלה, קומפיילר, ו־architecture (x86 לעומת x86_64)¶
במערכת 64 ביט עם ABI סטנדרטי (System V), הפרמטרים הראשונים מועברים ברגיסטרים (rdi, rsi, rdx, וכו'), ולכן לא תמיד תראה אותם בתוך ה־stack. אבל תמיד תוכלו לראות את כתובת החזרה וערכים מקומיים שנשמרו במחסנית.
דיבוג C¶
אז למדנו איך לדבג קוד אסמבלי — איך לעצור בכתובות זיכרון, לצפות ברמות הנמוכות של הקוד, ואפילו להדפיס את המחסנית בזכות פקודת x.
לדבג אסמבלי קורה בדרך כלל כשאנחנו חוקרים תוכנה של מישהו אחר, ואין לנו גישה לsource code שלו.
אבל כשאנחנו מפתחים תוכנה משלנו, במיוחד בשפת C, יש לנו גישה לקוד המקור – ועם קימפול מתאים (-g), GDB מאפשר לנו לדבג את קוד ה־C עצמו – בצורה הרבה יותר נוחה, קריאה, ומהירה.
תצוגת קוד בזמן אמת: layout¶
כדי לדבג בצורה נוחה יותר – GDB כולל ממשק מבוסס טקסט שנקרא TUI (Text User Interface).
אפשר לעבור אליו בכל רגע באמצעות הפקודה:
זה יפתח מסך מפוצל: למעלה – קוד המקור בשפת C, ולמטה – הפקודות שלכם.
אפשר גם להשתמש ב־layout asm אם אתם רוצים לראות קוד אסמבלי בצורה דומה.
ליציאה מהתצוגה: Ctrl + x ואז 1
עצירת התוכנית לפי קובץ ושורה: break¶
במקום לעצור לפי כתובת זיכרון, כשיש לנו קוד C – נוכל לעצור בקלות לפי שם קובץ ומספר שורה, או לפי שם פונקציה:
(gdb) break main // עצור בתחילת main
(gdb) break 23 // עצור בשורה 23 בקובץ הראשי
(gdb) break myfunc.c:42 // עצור בשורה 42 בקובץ myfunc.c
זה הרבה יותר נוח מאשר להשתמש ב־disas ואז לנחש את הכתובת.
תצוגת משתנים: print¶
אחת הפקודות השימושיות ביותר. ברגע שהגעתם לנקודת עצירה, תוכלו להדפיס ערך של כל משתנה גלובלי, מקומי או מצביע:
אם אתם משתמשים ב־TUI, תוכלו לראות את הקוד למעלה ולשלב את זה בזמן אמת.
שליטה על זרימת הריצה: next ו־step¶
בניגוד ל־nexti ו־stepi שראינו קודם (שזזים הוראת אסמבלי אחת), ב־C נרצה לזוז שורת קוד אחת (שורת קוד בC יכולה להיות כמה שורות אסמבלי) בכל פעם:
- הפקודה
next– מבצע את השורה הנוכחית וממשיך לשורה הבאה, בלי להיכנס לפונקציות. (קיצור שלה זהn)
(אם השורה היאfoo();הוא יבצע אתfoo()במלואה ואז יעבור לשורה הבאה.) - הפקודה
step– נכנס לתוך הפונקציה שמריצים כרגע. (קיצור שלה זהs)
(gdb) break main
(gdb) run
(gdb) next
(gdb) print a // תראה שהוא 5
(gdb) next
(gdb) print b // תראה שהוא 10
בדיקת משתנים מקומיים: info locals¶
רוצים לראות את כל המשתנים המקומיים בפונקציה הנוכחית? פשוט הריצו:
אם נכנסתם לפונקציה גדולה עם הרבה משתנים – זה שימושי במיוחד. (כמובן פשוט יותר מאפשר להדפיס בתים בstack מrbp ולחשב offset-ים.)
בדיקת כל המשתנים המוגדרים:¶
(זה מדפיס את כל המשתנים שה־GDB מכיר בקובץ — כולל גלובליים.)
שזה כמובן פשוט יותר מאשר לעשות x על כתובות של משתנים גלובלים ב data segment.
שינוי ערכים בזמן ריצה: set¶
אפשר לשנות כל משתנה תוך כדי הריצה – וזה שימושי לבדיקת התנהגות של הקוד בלי לשנות את הקוד עצמו:
אחרי זה תוכלו לעשות print ולוודא שזה אכן השתנה.
(gdb) break main
(gdb) run
(gdb) next // מגיע לשורת a = 4
(gdb) next // מגיע לשורת b = square(a)
(gdb) step // נכנס לתוך square
(gdb) print x // x = 4
(gdb) next // return x * x
(gdb) finish // חזרה לmain
(gdb) print b // b = 16
עצירה כשהערך משתנה: watch¶
הפקודה watch עוצרת את הריצה בכל פעם שערך של ביטוי משתנה.
בשונה מ־break, שלא אכפת לו מה מצב המשתנים – כאן אנחנו מגדירים תנאי עצירה שמבוסס על שינויים בזמן הריצה.
דוגמה:
ב־GDB:
התוכנית תיעצר בכל פעם שהמשתנה count משתנה.
כל עצירה תראה לך את הערך הישן והחדש.
אפשר גם לצפות בשדה בתוך struct, איבר במערך, מצביע וכו':
break עם תנאים¶
לפעמים לא רוצים לעצור בכל פעם, אלא רק כשהתנאי מתקיים.
נניח שיש לנו לולאה:
ואתם לא רוצים לעצור בכל איטרציה – אלא רק כש־i == 500.
ב־GDB:
או לפי משתנה:
אפשר גם להוסיף תנאי אחרי שה־break נוצר:
(1 זה מספר ה־breakpoint, אותו רואים ב־info breakpoints)
display – הצגה אוטומטית בכל צעד¶
כדי לא לרשום print x בכל שלב, GDB מאפשר להוסיף משתנים ל־רשימת תצוגה אוטומטית.
עכשיו, בכל פעם שהתוכנית נעצרת (ב־next, step, break וכו'), GDB אוטומטית ידפיס את ערך x.
רשימה של כל המשתנים שמוצגים ככה:
להסרה:
(1 הוא מספר הפריט מהרשימה – תראה אותו ב־info display)
דוגמה:
(gdb) break main
(gdb) run
(gdb) display i
(gdb) next
(gdb) next
// בכל צעד – תראה את i בלי לרשום כלום
שלושת הפקודות האלה שימושיות במיוחד כשאתם:
- עוקבים אחרי משתנה בעייתי שפתאום משתנה
- רוצים לבדוק באיזו נקודה בדיוק נגרמת בעיה
- לא רוצים לרשום
printכל הזמן
טיפול בקריסה¶
קריסה בתוכנית – כמו Segmentation Fault – נובעת כאשר אתה ניגש לזיכרון שאסור לכם לגשת אליו.
זה יכול לקרות בגלל:
- מצביע לא מאותחל
- גישה מחוץ לגבולות מערך
- ניסיון לקרוא מ־
NULL - בעיות בקצאת זיכרון (malloc) (נדבר בהמשך על מה זה malloc)
ברגע שתוכנית שקימפלתם עם -g קורסת – GDB עוצר אותה בדיוק בנקודת הקריסה.
נראה הודעה כמו:
משם נוכל לנתח איפה בדיוק הקוד נכשל, מה הערך של המשתנים בזמן הקריסה, ואיזה פונקציות הובילו לשם.
1. backtrace – הצגת call stack¶
מראה את כל הפונקציות שנקראו עד רגע הקריסה – החל מה־main, דרך כל הפונקציות שקראו אחת לשנייה, ועד הפונקציה שהתפוצצה.
הפקודה הזו עוזרת להבין:
- מי קרא למי
- איך הגענו למצב השגוי
- באיזה קובץ ומספר שורה התרחשו הקריאות
2. frame – מעבר בין פריימים¶
אפשר לעבור לפריים ספציפי ב־call stack:
ככה תוכלו לבדוק משתנים בכל שלב במסלול הקריאות.
3. list – הצגת קוד המקור¶
מציג את השורות האחרונות בקובץ הקוד – עוזר לראות מה בדיוק קרה סביב שורת הקריסה.
אם אתם יודע את שם הקובץ:
דוגמה לתוכנית עם קריסה¶
gcc -g program.c -o program
gdb ./program
(gdb) run
Program received signal SIGSEGV
(gdb) backtrace
(gdb) print ptr
(gdb) list
שימוש ב־watch -location¶
כבר הכרנו את watch, שעוצרת את התוכנית כשביטוי משנה ערך.
אבל לפעמים אנחנו רוצים לדעת מתי נכתב משהו לכתובת מסוימת, בלי קשר לשינוי ערך.
למשל, אם מישהו דורך על משתנה בטעות בזיכרון – הפקודה הבאה תעצור את התוכנית בדיוק כשזה קורה:
או:
הפקודה watch -location עוצרת את התוכנית בכל כתיבה לזיכרון בכתובת הנתונה, גם אם הערך שנכתב שווה לערך הקודם.
שימושי מאוד לאיתור דריסה בזיכרון.
ניתוח core dump אחרי קריסה¶
לפעמים התוכנית קורסת מחוץ ל־GDB, ואתם לא הספקתם להריץ אותה בתוך debugger.
אבל אפשר לגרום למערכת לשמור קובץ core – תצלום זיכרון של התוכנית בזמן הקריסה.
שלב 1: הפעלת יצירת core dump¶
זה מאפשר למערכת לכתוב קובץ
core כשהתוכנית קורסת. (נדבר בהמשך הקורס על פקודת הulimit)
שלב 2: הרצת התוכנית שגורמת לקריסה¶
שלב 3: פתיחת הקובץ ב־GDB¶
כעת תוכלו להשתמש ב־GDB בדיוק כאילו התוכנית עדיין רצה – לבדוק backtrace, להדפיס משתנים, לעבור על frames, להסתכל בזיכרון – והכל אחרי שהקריסה קרתה בפועל.
יציאה מ־GDB¶
מה זה .gdbinit¶
כאשר מריצים את GDB, הוא קודם כל מחפש קובץ בשם .gdbinit בתיקיית הבית (~) או בתיקייה הנוכחית.
קובץ זה מכיל פקודות GDB שמורצות אוטומטית עם פתיחת הסשן – ממש כמו .bashrc ל־Bash.
בעזרת הקובץ הזה ניתן:
- להגדיר קיצור מקשים
- לטעון סקריפטים
- לקבוע Breakpoints מראש
- להפעיל הרחבות
- לשנות צבעים, תצוגה, Layout ועוד
דוגמה בסיסית לתוכן של .gdbinit:¶
ישנם המון פלאגינים שאנשים מכל העולם כתבו לgdb באמצעות הgdbinit.
פלאגינים ל־GDB¶
כלי הGDB הוא כלי חזק בפני עצמו, אך ניתן להרחיב אותו משמעותית על־ידי פלאגינים (תוספים) שכתובים ב־Python.
פלאגינים נפוצים כוללים:
- כלי הpwndbg – תוסף מתקדם לדיבוג של תוכניות בשפת C, כולל מבני זיכרון, heap, stack, הוראות, ועוד המון
- כלי הgef (GDB Enhanced Features) – תוסף עם ממשק מינימליסטי אך עשיר.
- כלי הPEDA – פלאגין בסיסי עם הרחבות מועילות.
אנחנו נתמקד ב־pwndbg.
התקנת pwndbg¶
שלב 1: התקנת GDB עם תמיכה ב־Python¶
וודאו שה־GDB שלכם תומך ב־Python 3:
ואז בתוך GDB:
אם קיבלתם פלט תקין – הכל בסדר.
אם לא, התקינו מחדש GDB דרך המערכת או דרך source.
שלב 2: הורדת pwndbg¶
הפקודה setup.sh תבצע:
- בניית הפלאגין
- קישור לקובץ
~/.gdbinit - התקנת תלויות (כולל pip packages)
בסיום, הריצו שוב GDB ותראו את הלוגו של pwndbg.
מה pwndbg נותן?¶
- תצוגת זיכרון עשירה – stack, heap, code, ומצב אוגרים בצבעים נוחים.
- פקודות חדשות – מאות פקודות חדשות שנוספו ל־GDB.
- שיפור הפקודות הרגילות – הפקודות הקיימות כמו
x,info registersמוצגות טוב יותר. - פקודות Heap, Tcache, ROP, Canary, Syscalls (נדבר בהמשך הקורס)
- עוזר לנו מאוד כשאנחנו רוצים לחקור תוכנה של מישהו, או למצוא חולשות (נדבר בהמשך הקורס על זה)
שימוש בסיסי ב־pwndbg¶
כאשר אתם עושים break בpwndbg, או s, n, finish, si, ni או כל פקודה כזאת או אחרת, אתם תראו מסך עשיר שמתאר אל מצב האוגרים, הקוד, המחסנית, הheap הרלוונטי והכל.
זה נקרא context, תוכלו גם להשתמש בפקודה context כדי להציג את זה בכל הזמן.
חיפוש בזיכרון¶
פקודות shell מה־gdb¶
מידע על המערכת¶
נדבר בהמשך כל אזורי זכרון בלינוקס, ובניהם נשתמש בפקודה הזו.
למעשה ישנם עוד המון פקודות עם pwndbg, נדבר עליהן בהמשך, כרגע- תהנו מהcontext העשיר שנפתח אליכם כל פעם שאתם משתמשים בדיבגר.