10.1 סקירת קוד מקור הרצאה
עד עכשיו בקורס תמיד קיבלתם את החולשה ביד. הגיע בינארי vulnerable, ידעתם שיש בו overflow או use-after-free, ונשארה רק העבודה המהנה של בניית ה-exploit. אבל בעולם האמיתי אף אחד לא מגיש לכם את הבאג על מגש. הצעד הראשון, והקשה בהרבה, הוא למצוא אותו לבד. בפרק הזה נתחיל ללמוד את המיומנות הזו, ונפתח במיומנות הבסיסית ביותר של חוקר חולשות: סקירת קוד מקור ידנית - לקרוא קוד C ולזהות בדיוק איפה הוא מפר את ההנחות של עצמו.
בהרצאה הזו נבנה שיטת עבודה. נגדיר מהם מקורות - sources (המקומות שבהם קלט של תוקף נכנס לתוכנית) ומהם סינקים - sinks (הפעולות המסוכנות שבטיחותן תלויה בקלט), ונלמד לעקוב אחרי זרימת הנתונים ביניהם. נכיר את משפחת הפונקציות המסוכנות, את תבניות הבאגים הקלאסיות (overflow שלם, שגיאת אחד, בלבול סימן, תנאי מרוץ, חוסר בדיקת גבולות ומחרוזות format), ונראה איך כלים כמו grep, semgrep ו-CodeQL עוזרים לסרוק בסיסי קוד גדולים. המטרה: שתוכלו לקחת קובץ C שלא ראיתם מעולם ולמצוא בו את החורים.
למה לקרוא קוד ידנית - manual auditing¶
בפרקים הבאים נלמד fuzzing - להטיל על התוכנית מיליוני קלטים אקראיים ולחכות לקריסה. זה כלי מצוין, אבל הוא לא מחליף קריאה ידנית, אלא משלים אותה. הנה למה סקירה ידנית עדיין הכרחית:
- הרבה באגים מסתתרים אחרי שכבת אימות קלט עמוקה, או דורשים רצף מדויק של פעולות. פאזר עיוור כמעט לעולם לא יגיע לשם, אבל עין אנושית שקוראת את הלוגיקה כן.
- באגים לוגיים - עקיפת הרשאות, בדיקה שכתובה הפוך, מצב שאסור להגיע אליו - לרוב לא מתבטאים בקריסה בכלל, ולכן פאזר שמחפש קריסות פשוט לא רואה אותם.
- כשאתם קוראים את הקוד אתם בונים מודל מנטלי של איך התוכנית עובדת. בלי המודל הזה, גם אם פאזר ימצא קריסה, לא תדעו איך להפוך אותה ל-exploit. הקריאה היא זו שנותנת לכם את ההבנה.
הכלל: אם יש לכם מקור (source code), תמיד תתחילו בקריאה. תגדירו את משטח התקיפה בראש, ורק אחר כך תפעילו כלים אוטומטיים על המקומות שסימנתם כחשודים.
מקורות וסינקים - sources and sinks¶
כל באג exploit הוא בסופו של דבר סיפור על נתון שנכנס במקום אחד וגורם לנזק במקום אחר. שני הקצוות של הסיפור הם מקור וסינק.
מקור - source הוא כל נקודה שבה נכנסת לתוכנית נתונים שהתוקף שולט בהם:
- ארגומנטים משורת הפקודה:
argv, ומשתני סביבה:environ. - קלט סטנדרטי דרך
read,fgets,scanf,getline. - קבצים שהתוקף יכול לשלוט בתוכנם.
- רשת:
recv,recvfrom, קריאת socket. - ערוצי תקשורת בין תהליכים: צינורות, זיכרון משותף, הודעות.
סינק - sink הוא פעולה מסוכנת שהבטיחות שלה תלויה בערכים שהיא מקבלת:
- העתקת זיכרון:
memcpy,strcpy,strcat- התלות היא בין הגודל למקום היעד. - הקצאה:
malloc(size),alloca(n)- התלות בגודל. - גישה למערך:
arr[i]- התלות באינדקס. - מחרוזת format:
printf(fmt)- התלות בכך שהformat אינו בשליטת התוקף. - הרצת פקודה:
system(cmd),execl- התלות בתוכן המחרוזת.
הבאג חי על מסלול שמוביל ממקור לסינק, מסלול שבו איזו הנחה שהייתה צריכה להתקיים - פשוט לא נבדקת. המיומנות המרכזית של סקירת קוד היא מעקב אחרי הכתם - taint tracking - בראש שלכם: לוקחים כל מקור, ושואלים "לאן הנתון הזה זורם, ומה נעשה בו בדרך?".
Source Processing along the way Sink
+----------+ +---------------------+ +------------------+
| read() | ----> | len = atoi(...) | ----> | malloc(len) |
| recv() | | strcpy(tmp, input) | | memcpy(d,s,len) |
| argv[1] | | idx = ... | | buf[idx] = x |
| environ | | (no bounds check?) | | printf(input) |
+----------+ +---------------------+ +------------------+
^ ^ ^
attacker input assumption breaks here damage happens here
שימו לב לעמודה האמצעית. שם, בין המקור לסינק, יושבת ההנחה שהתוכנית סומכת עליה בלי לבדוק. כל העבודה שלנו היא למצוא את הרגע שבו ההנחה הזו לא מוגנת.
פונקציות מסוכנות - dangerous functions¶
יש משפחה של פונקציות ספרייה שכמעט תמיד שוות מבט שני. הן לא אסורות, אבל כל שימוש בהן הוא סינק פוטנציאלי. כדאי לזכור את הרשימה בעל פה, כי grep על השמות האלה הוא הצעד הראשון בכל סקירה.
| הפונקציה | הבעיה | חלופה בטוחה |
|---|---|---|
הפונקציה gets(buf) |
אין שום הגבלת גודל, הוסרה מ-C11 | fgets(buf, size, stdin) |
הפונקציה strcpy(d, s) |
מעתיקה עד null בלי לבדוק את גודל היעד | strncpy בזהירות, או snprintf |
הפונקציה strcat(d, s) |
משרשרת בלי לבדוק כמה מקום נשאר ב-d | strncat עם החישוב הנכון |
הפונקציה sprintf(d, fmt, ...) |
כותבת ל-d בלי גבול | snprintf(d, size, ...) |
הפונקציה scanf("%s", buf) |
קוראת מילה בלי הגבלת אורך | scanf("%31s", buf) עם רוחב מפורש |
הפונקציה memcpy(d, s, n) |
מסוכנת כש-n בשליטת התוקף | לוודא n <= sizeof(d) לפני |
הפונקציה alloca(n) |
מקצה על המחסנית, n גדול מפיל אותה | malloc עם בדיקת החזרה |
הפונקציה system(cmd) |
מעבירה מחרוזת ל-/bin/sh -c |
execve עם מערך ארגומנטים |
חשוב להבין: מציאת קריאה ל-strcpy היא לא מציאת באג. היא מציאת מועמד. הבאג קיים רק אם המקור של הנתון שמועתק בשליטת התוקף, ואם אין בדיקת גבולות בדרך. הפונקציות האלה הן פשוט המקום שבו כדאי להתחיל להסתכל.
הנה דוגמה קלאסית שבה שילוב של שתי פונקציות מהרשימה יוצר את הבאג:
void greet(char *name) {
char msg[64];
strcpy(msg, "Hello, "); // fine so far, 7 bytes
strcat(msg, name); // name is attacker-controlled, no check - overflow
puts(msg);
}
אם name ארוך מ-56 תווים, ה-strcat גולש מעבר ל-msg[64] ודורס את המחסנית. המקור הוא name, הסינק הוא ה-strcat, וההנחה השבורה היא ש-"name לעולם לא יהיה ארוך מדי".
תבניות באגים - bug patterns¶
מעבר לפונקציות המסוכנות, יש דפוסי חשיבה שגויים שחוזרים שוב ושוב. כדאי להכיר כל אחד מהם כתבנית, כדי לזהות אותו במבט.
גלישת שלם - integer overflow¶
חישוב גודל שגולש מעבר לטווח של הטיפוס, ואז מוזן להקצאה או להעתקה:
int n = read_count(); // attacker-controlled, e.g. 0x40000000
char *p = malloc(n * 4); // n * 4 overflows to 0, malloc(0) returns a tiny buffer
for (int i = 0; i < n; i++)
p[i * 4] = read_byte(); // writes far beyond the tiny buffer
הכפל n * 4 על טיפוס 32 ביט גולש, malloc מקצה כמעט כלום, והלולאה כותבת כאילו יש מקום. תמיד חשדו כשרואים חשבון על גודל: כפל, חיבור של אורך, או len + 1.
שגיאת אחד - off-by-one¶
טעות של בית אחד, שלרוב דורסת בדיוק את הבית שאחרי הbuffer:
char buf[64];
int i;
for (i = 0; i <= 64; i++) // <= instead of < : writes to buf[64], one byte past the buffer
buf[i] = input[i];
או הקלאסיקה של סיום מחרוזת: buf[strlen(s)] = 0 כאשר s כבר מילא את כל הbuffer. בית null בודד שגולש יכול לדרוס בית תחתון של מצביע שמור על המחסנית - זו חולשת off-by-one אמיתית ומנוצלת.
בלבול סימן - signedness confusion¶
ערך חתום שנקרא מהתוקף, עובר בדיקה כמספר חתום, ואז משמש כמספר לא-חתום:
int len = read_int(); // attacker sends -1
if (len > 1024) return -1; // -1 is less than 1024, passes the check
memcpy(dst, src, len); // the third parameter is size_t: -1 becomes 0xffffffffffffffff
הבדיקה len > 1024 נראית בטוחה, אבל len חתום. ערך שלילי עובר אותה, ואז memcpy מקבל אותו כ-size_t ענק. זו אחת התבניות הקטלניות ביותר, כי הבדיקה נראית קיימת ותקינה במבט ראשון.
תנאי מרוץ - TOCTOU - Time-Of-Check to Time-Of-Use¶
בדיקה על משאב, ואז שימוש בו - עם חלון זמן ביניהם שבו התוקף מחליף את המשאב:
if (access("/tmp/data", W_OK) == 0) { // check: do I have permission?
// here the attacker replaces /tmp/data with a symlink to a protected file
FILE *f = fopen("/tmp/data", "w"); // use: writes to a completely different file
fwrite(data, 1, len, f);
}
בין ה-access ל-fopen יש חלון. בתוכנית setuid, access בודקת עם ה-uid האמיתי אבל fopen פותחת עם ה-uid האפקטיבי, והתוקף מנצל את הפער כדי לכתוב לקובץ שאסור לו. הכלל: אל תבדקו לפי שם ואז תפתחו לפי שם. פתחו פעם אחת, ובדקו על ה-descriptor.
חוסר בדיקת גבולות - missing bounds check¶
התבנית הפשוטה והנפוצה ביותר: אינדקס או אורך שמגיע מהתוקף ומשמש ישירות, בלי לבדוק שהוא בטווח:
struct item items[16];
int idx = read_int(); // attacker-controlled
return items[idx]; // no check that 0 <= idx < 16
ערך idx שלילי קורא מתחת למערך, וערך גדול קורא הרבה מעליו. חפשו כל גישה למערך שהאינדקס שלה מגיע ממקור, ותוודאו שיש בדיקה גם לגבול העליון וגם לגבול התחתון.
מחרוזת פורמט - format string¶
קלט התוקף שמגיע ישירות כמחרוזת הformat של פונקציה ממשפחת printf:
אם user_input מכיל %p התוקף קורא מהמחסנית, ואם הוא מכיל %n הוא כותב לזיכרון. חפשו כל קריאה ל-printf, fprintf, snprintf, syslog שבה הארגומנט הראשון (הformat) אינו קבוע מחרוזת.
קריאה לאיתור אינווריאנטים שבורים - broken invariants¶
מעבר לתבניות הספציפיות, יש דרך חשיבה כללית שמחברת את הכל. לכל פיסת קוד יש חוזה סמוי - הנחות שצריכות להתקיים כדי שהיא תהיה בטוחה. למשל: "האורך לעולם לא גדול מגודל הbuffer", "המצביע הזה עדיין תקף אחרי ה-free", "האינדקס בטווח", "malloc לא החזירה NULL". באג קורה כשהחוזה מונח אבל לא נאכף.
כשאתם קוראים קוד, שאלו בכל שורה חשודה:
- כל כתיבה למערך: מה מבטיח שהאינדקס בטווח? מאיפה הוא הגיע?
- כל חשבון על גודל: האם הוא יכול לגלוש? האם אחד המשתנים חתום?
- כל בדיקת גבול: האם היא מכסה גם את הגבול העליון וגם את התחתון? האם המשתנה חתום או לא-חתום, וזה מסתדר עם הבדיקה?
- כל קריאה שמחזירה ערך שגיאה (
malloc,read,fopen): מה קורה במסלול השגיאה? האם ממשיכים להשתמש במצביע NULL? - אחרי כל
free: האם המצביע משמש שוב? האם הוא מאופס?
הטריק המנטלי: אל תקראו את הקוד כמו שהמתכנת התכוון שירוץ. קראו אותו כמו יריב. שאלו "מה הקלט הכי מרושע שאני יכול לשלוח לכאן?", ובדקו אם השורה שורדת אותו.
כלים - grep, semgrep ו-CodeQL¶
קריאה ידנית לא סקיילבילית לבד על מיליון שורות קוד. הכלים לא מוצאים באגים במקומכם, אבל הם מצמצמים את שדה החיפוש למקומות ששווה להסתכל בהם.
חיפוש טקסטואלי - grep¶
הכלי הבסיסי. סורקים את כל הפונקציות המסוכנות בבת אחת:
וכדי למצוא מקורות - נקודות כניסת קלט:
הכלי grep מהיר וזמין תמיד, אבל הוא עיוור לזרימה: הוא לא יודע אם ה-len שמגיע ל-memcpy בשליטת התוקף. בשביל זה צריך כלי שמבין את מבנה הקוד.
כללים סמנטיים - semgrep¶
הכלי semgrep מחפש לפי תבנית תחבירית, לא לפי טקסט גולמי, ואפשר לכתוב לו כללים משלכם. כלל פשוט שמסמן כל strcpy:
rules:
- id: unsafe-strcpy
pattern: strcpy($DST, $SRC)
message: strcpy without bounds checking - check the source $SRC
languages: [c]
severity: WARNING
כוחו האמיתי הוא במצב מעקב-כתם - taint mode, שבו מגדירים מקורות וסינקים והוא מוצא מסלול ביניהם:
rules:
- id: tainted-alloc-size
mode: taint
pattern-sources:
- pattern: atoi(...)
pattern-sinks:
- pattern: malloc($SIZE)
message: allocation size coming from user input without validation
languages: [c]
severity: ERROR
הכלל הזה ידליק כל malloc שהגודל שלו מגיע, בשרשרת, מקריאה ל-atoi. בדיוק תבנית overflow השלם שראינו.
ניתוח זרימת נתונים - CodeQL¶
הכלי CodeQL של GitHub לוקח את הרעיון צעד קדימה: הוא מהדר את הקוד למסד נתונים ומריץ עליו שאילתות בשפה דמויית-SQL. אפשר לשאול שאלות כמו "מצא כל מסלול מקריאת recv לפרמטר הגודל של memcpy בלי בדיקת גבול בדרך". זו הדרך שבה חוקרים מוצאים חולשות בבסיסי קוד ענקיים כמו הkernel של לינוקס או chromium. השלד של שאילתה הוא הגדרת DataFlow::Configuration עם מקורות וסינקים, ואז hasFlowPath. לא נצלול לזה כאן, אבל חשוב לדעת שהכלי קיים ושהוא הסטנדרט התעשייתי לניתוח זרימה בקנה מידה גדול.
הכלל: grep כדי לסמן מועמדים, semgrep לכללים חוזרים ולמעקב-כתם קל, ו-CodeQL לניתוח זרימה עמוק על פרויקטים גדולים. אבל ההחלטה הסופית אם משהו הוא באג exploit - תמיד אנושית.
מתודולוגיה מעשית - a workflow¶
בואו נסדר הכל לתהליך שאפשר להריץ על כל פרויקט:
1. Attack surface mapping
Find all the sources: where does attacker input enter? (grep for read/recv/argv/getenv)
2. Marking sinks
Find all the dangerous operations: (grep for strcpy/memcpy/malloc/system/printf)
3. Connecting
For each source, trace the data: where does it flow? Does it reach a sink?
4. Invariant checking
At each sink on the path: what assumption needs to hold? Is it checked?
Check especially: bounds, sign, integer overflow, error values.
5. Exploitability assessment
Found a broken assumption? Write down: what the bug is, which class it belongs to,
what the attacker controls, and what the impact is (crash? arbitrary write? RCE?).
תעדו הכל תוך כדי. רשימת "מקורות", רשימת "סינקים", ורשימת "חשודים" עם מספרי שורות. חוקר טוב לא מחזיק את זה בראש - הוא כותב.
סיכום¶
בהרצאה הזו למדנו את הבסיס של מציאת חולשות בקוד מקור:
- סקירה ידנית משלימה fuzzing: היא מוצאת באגים לוגיים ובאגים עמוקים שפאזר מפספס, ובונה את ההבנה שצריך כדי לנצל.
- מקורות הם המקומות שקלט תוקף נכנס, סינקים הם הפעולות המסוכנות. הבאג חי על המסלול ביניהם, במקום שבו הנחה לא נבדקת.
- פונקציות מסוכנות -
gets,strcpy,strcat,sprintf,scanf("%s"),memcpyעם אורך של תוקף,alloca,system- הן נקודות ההתחלה של כל סקירה, אבל הן מועמדות, לא באגים מוכחים. - תבניות הבאגים החוזרות: overflow שלם, שגיאת אחד, בלבול סימן, תנאי מרוץ (TOCTOU), חוסר בדיקת גבולות ומחרוזת format.
- קוראים כמו יריב, ומחפשים אינווריאנטים שבורים: כל בדיקת גבול, כל חשבון גודל, כל ערך חזרה של
malloc/read. - הכלים - grep לסימון מהיר, semgrep לכללים ולמעקב-כתם, CodeQL לניתוח זרימה עמוק - מצמצמים את שדה החיפוש, אבל ההכרעה אנושית.
בתרגול תקבלו תוכנית C אמיתית עם כמה באגים שתולים, ותפעילו עליה בדיוק את השיטה הזו. אל תמהרו לפתרון - היכולת למצוא באג לבד היא בדיוק המיומנות שאתם באים לפתח.