לדלג לתוכן

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:

printf(user_input);            // dangerous: attacker controls the format
printf("%s", user_input);      // safe

אם 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 -rnE '\b(gets|strcpy|strcat|sprintf|scanf|system|popen|alloca)\b' src/

וכדי למצוא מקורות - נקודות כניסת קלט:

grep -rnE '\b(read|recv|fgets|getenv|scanf|fread)\b' src/

הכלי 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 אמיתית עם כמה באגים שתולים, ותפעילו עליה בדיוק את השיטה הזו. אל תמהרו לפתרון - היכולת למצוא באג לבד היא בדיוק המיומנות שאתם באים לפתח.