לדלג לתוכן

10.1 סקירת קוד מקור פתרון

פתרון - סקירת קוד של מנהל פתקים vulnerable

כאן נעבור על כל הבאגים שתולים ב-notes.c, אחד אחרי השני. לכל באג נציין: את מספר השורה, את מחלקת הבאג, את זרימת הנתונים ממקור לסינק, את ההשלכה, ואת התיקון. בסוף נכליל. סך הכל יש בקוד הזה שבעה באגים נפרדים, חלקם מתחברים לשרשרת exploit אחת.


פתרון תרגיל 1 - מיפוי משטח התקיפה

מקורות בתוכנית:

  • הפונקציה read_line - הלב של כל קליטת הקלט. כל תו שנכנס לתוכנית עובר דרכה.
  • הפונקציה read_int - עוטפת את read_line ומחזירה מספר. מקור לכל האורכים והאינדקסים.
  • הקריאה read(0, n->body, len) ב-add_note - קוראת את גוף הפתק ישירות מהקלט.

סינקים בתוכנית:

  • הכתיבה buf[i] = c ב-read_line - כתיבה למערך בלי בדיקת גבול.
  • הקריאה strcpy(n->title, title) ב-add_note.
  • הקריאה malloc(len + 1) ב-add_note - חשבון על גודל.
  • הקריאה read(0, n->body, len) ב-add_note - אורך בשליטת התוקף.
  • הגישה notes[idx] ב-show_note - אינדקס בשליטת התוקף.
  • הקריאה printf(n->title) ב-show_note - format בשליטת התוקף.
  • הקריאה sprintf(path, ...) ב-save_note.
  • הצמד access ואז fopen ב-save_note.

שימו לב ש-read_line היא גם מקור וגם סינק, וזה בדיוק מה שהופך אותה למסוכנת כל כך: הבאג בתוכה מזהם כל קורא שלה.


פתרון תרגיל 2 - חוסר בדיקת גבולות ב-read_line

מחלקה: חוסר בדיקת גבולות - missing bounds check (overflow buffer גנרית).

השורות:

void read_line(char *buf, int size) {
    int i = 0;
    char c;
    while (read(0, &c, 1) == 1 && c != '\n') {
        buf[i] = c;    // the bug: i is never compared against size
        i++;
    }
    buf[i] = '\0';
}

הפרמטר size מתקבל אבל לא בשימוש. הלולאה כותבת תו אחרי תו לתוך buf[i] ומגדילה את i בלי סוף, עד שהתוקף שולח תו שורה חדשה. כלומר, התוקף קובע כמה בתים ייכתבו, לא גודל הbuffer.

זרימה: המקור הוא הקלט הסטנדרטי. הסינק הוא buf[i] = c. ההנחה השבורה: "המשתמש לא יקליד יותר מ-size תווים".

ההשלכה: כל קורא של read_line vulnerable. הכי בולט הוא ב-main:

char choice[8];
read_line(choice, sizeof(choice));   // 200 chars to choice[8] -> stack overflow

המשתנה choice הוא 8 בתים על המחסנית של main. מי שמקליד 200 תווים בתפריט הראשי דורס את הreturn address של main. הבינארי קומפל עם -fno-stack-protector, אז אין canary שיעצור את זה - שליטה ישירה ב-rip.

התיקון:

void read_line(char *buf, int size) {
    int i = 0;
    char c;
    while (i < size - 1 && read(0, &c, 1) == 1 && c != '\n') {
        buf[i] = c;
        i++;
    }
    buf[i] = '\0';
}

הוספת התנאי i < size - 1 (משאירים מקום לבית ה-null) סוגרת את הבאג במקום אחד, ומרפאה בבת אחת את כל הקוראים.

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


פתרון תרגיל 3 - שרשרת ה-length

בפונקציה add_note מסתתרים שלושה באגים. הראשון כבר בכותרת, לפני שמגיעים ל-len, ושניים נוספים שמשתפים את המקור len.

באג - overflow strcpy בכותרת

מחלקה: פונקציה מסוכנת (strcpy) ללא בדיקת גבולות, אי-התאמת גדלים.

char title[64];
read_line(title, sizeof(title));    // up to 63 bytes (even more, because of the bug in read_line)
strcpy(n->title, title);            // n->title is only char title[32]!

הbuffer המקומי title הוא 64 בתים, אבל שדה היעד n->title בתוך struct note הוא 32 בתים בלבד. כותרת של 40 תווים כבר גולשת. מכיוון שהמערך notes גלובלי, הoverflow דורסת את השדות שאחרי הכותרת באותה רשומה - קודם את המצביע body, אחר כך את len, ואז את הרשומה הבאה במערך. דריסת המצביע body היא כתיבה שרירותית פוטנציאלית: התוקף קובע לאן fwrite/read הבאים יכתבו. וזכרו שבגלל הבאג ב-read_line (תרגיל 2) הכותרת יכולה להיות ארוכה בהרבה מ-64, מה שמרחיב את הoverflow עוד יותר.

התיקון: להעתיק עם גבול מפורש לגודל שדה היעד, או לקרוא ישר לתוכו:

read_line(n->title, sizeof(n->title));   // reads directly into the field, with its correct size
// no need for an intermediate buffer, and no strcpy at all

באג א - overflow שלם בהקצאה

מחלקה: גלישת שלם - integer overflow.

int len = read_int();
n->body = malloc(len + 1);   // len == 2147483647 -> len+1 overflows to INT_MIN

המשתנה len הוא int. אם התוקף שולח 2147483647 (שהוא INT_MAX), אז len + 1 גולש ל--2147483648. הביטוי הזה מומר ל-size_t בקריאה ל-malloc, והופך למספר ענק - ההקצאה תיכשל ותחזיר NULL. השורה הבאה, read(0, n->body, len), קוראת אל תוך מצביע NULL - קריסה, ובתנאים מסוימים גרוע מזה.

באג ב - בלבול סימן ב-read

מחלקה: בלבול סימן - signedness confusion (שמוביל לoverflow heap).

read(0, n->body, len);   // len is signed, the third parameter of read is size_t

אם התוקף שולח len == -1, קורים שני דברים. ראשית, malloc(-1 + 1) הוא malloc(0), שמחזיר buffer זעיר. שנית, read מקבל את len כ-size_t, ו--1 הופך ל-0xffffffffffffffff - כמות ענקית. read ינסה לכתוב עד SSIZE_MAX בתים לתוך buffer בגודל אפס. זו overflow heap עצומה בשליטת התוקף, שדורסת את מבני ה-heap ואת ה-chunks שאחריו.

גם בלי ערך שלילי, שימו לב ש-malloc(len + 1) מקצה len + 1 בתים אבל read כותב len, ואז n->body[len] = '\0' כותב לבית האחרון - אין כאן off-by-one, אבל אם ההקצאה נכשלה או גלשה, גם הכתיבה הזו חורגת.

התיקון: לאמת את len לפני כל שימוש, ולהשתמש בטיפוס לא-חתום:

int len = read_int();
if (len < 0 || len > 4096) {          // lower bound (sign) and upper bound
    puts("bad length");
    return;
}
n->body = malloc((size_t)len + 1);
if (!n->body) { puts("oom"); return; }   // checking the return value
n->len = len;
read(0, n->body, (size_t)len);
n->body[len] = '\0';

למה זה עבד / איך להכליל: אורך שמגיע מהתוקף הוא אחד הדברים המסוכנים ביותר בקוד. תמיד תבדקו אותו בשני קצוות (שלילי וגדול מדי), תזכרו שהמרה בין חתום ללא-חתום קורית בשקט בקריאות ספרייה, ותוודאו שהגודל שהוקצה תואם לגודל שנכתב. חשד אוטומטי לכל malloc/memcpy/read שהגודל שלהם עבר חשבון או הגיע ממקור.


פתרון תרגיל 4 - הדפסה ואינדוקס

בפונקציה show_note שני באגים נפרדים.

באג ג - בדיקת גבולות שבורה על האינדקס

מחלקה: חוסר בדיקת גבולות + בלבול סימן (off-by-one בבדיקה).

int idx = read_int();
if (idx > note_count) { ... return; }   // broken in two ways
struct note *n = &notes[idx];

שתי בעיות בבדיקה אחת:

  • הבדיקה היא idx > note_count, אבל האינדקסים החוקיים הם 0 עד note_count - 1. idx == note_count עובר את הבדיקה וקורא רשומה אחת מעבר לפתקים שנוצרו. זו שגיאת אחד בבדיקה.
  • אין בדיקה ש-idx >= 0. idx מגיע מ-atoi והוא חתום, אז התוקף שולח -1, -100 וקורא מתחת למערך notes. גישה ל-notes[-100] נותנת מצביע לזיכרון שרירותי לפני המערך הגלובלי.

ההשלכה: קריאה מחוץ לתחום. עם אינדקס שלילי מבוקר, n->title ו-n->body מצביעים לזיכרון שהתוקף יכול להשפיע עליו, וזה מתחבר לבאג הבא (הדפסת n->body כמצביע) לleak מידע.

באג ד - מחרוזת format

מחלקה: מחרוזת פורמט - format string.

printf(n->title);            // the title is attacker-controlled and is the format string
printf("\n%s\n", n->body);   // this line is actually safe

הכותרת נשלטת לגמרי על ידי התוקף (הוא הזין אותה ב-add). אם היא מכילה %p %p %p הוא קורא ערכים מהמחסנית (leak כתובות, עקיפת ASLR/PIE), ואם היא מכילה %n הוא כותב לזיכרון. זו חולשת format string מלאה.

התיקון:

int idx = read_int();
if (idx < 0 || idx >= note_count) {   // both ends
    puts("bad index");
    return;
}
struct note *n = &notes[idx];
printf("%s", n->title);               // the format is constant, the title is just an argument
printf("\n%s\n", n->body);

למה זה עבד / איך להכליל: כל בדיקת גבול חייבת לכסות גם את הקצה העליון וגם את התחתון, ולהתחשב בכך שערך חתום יכול להיות שלילי. וכל קריאה ממשפחת printf שהformat שלה אינו מחרוזת קבועה היא באג - זה מהחיפושים הראשונים שכדאי להריץ (grep -nE 'printf\s*\([^"]').


פתרון תרגיל 5 - שמירה לקובץ

בפונקציה save_note שני באגים נוספים.

באג ה - overflow buffer ב-sprintf

מחלקה: פונקציה מסוכנת (sprintf) ללא בדיקת גבולות, בשילוב path traversal.

char path[64];
sprintf(path, "/tmp/notes/%s.txt", n->title);

הbuffer path הוא 64 בתים. הקידומת /tmp/notes/ והסיומת .txt לוקחות 15 בתים, אז נשארים 49 בתים ל-n->title. אבל n->title יכולה להכיל הרבה יותר: זכרו את הבאג מתרגיל 2 - read_line דחפה כותרת בכל אורך, ו-strcpy העתיקה את כולה לתוך n->title[32] (וגם זה כבר גלש). כותרת ארוכה גורמת ל-sprintf לגלוש מעבר ל-path[64] על המחסנית. בנוסף, כותרת שמכילה ../../etc/cron.d/x יוצרת path traversal - כתיבה לקובץ מחוץ ל-/tmp/notes.

התיקון: snprintf עם גבול, וסינון של שם הקובץ:

char path[64];
// make sure n->title doesn't contain '/' or '..' before this
snprintf(path, sizeof(path), "/tmp/notes/%s.txt", n->title);

באג ו - תנאי מרוץ TOCTOU

מחלקה: תנאי מרוץ - TOCTOU.

if (access(path, W_OK) == 0) {   // check (Time Of Check)
    FILE *f = fopen(path, "w");   // use (Time Of Use)
    ...
}

בין ה-access ל-fopen יש חלון זמן. אם התוכנית רצה עם setuid, access בודקת עם ה-uid האמיתי (של התוקף) אבל fopen פותחת עם ה-uid האפקטיבי (המורשה). התוקף מחליף את path בקישור סימבולי לקובץ מוגן בדיוק בין שתי הקריאות, וגורם לתוכנית לכתוב עבורו לקובץ שאסור לו. זה בדיוק דפוס ה-TOCTOU מההרצאה.

התיקון: אל תבדקו לפי שם ואז תפתחו לפי שם. פתחו פעם אחת, ובדקו על ה-descriptor (למשל עם open(path, O_WRONLY|O_NOFOLLOW|O_CREAT|O_EXCL, ...)), או השתמשו ב-faccessat עם AT_EACCESS, או פשוט הורידו הרשאות לפני הכתיבה.


פתרון תרגיל 6 - כלים

מה ש-grep מוצא:

grep -nE '\b(strcpy|sprintf|memcpy|malloc|read|printf|access|fopen)\b' notes.c

הפלט מסמן מיד את strcpy, sprintf, malloc, את קריאות ה-read, את שני ה-printf, ואת access/fopen. אבל שימו לב מה grep לא יכול לומר לכם:

  • הוא מראה printf(n->title) ו-printf("%s", ...) באותה שורת פלט, בלי לדעת מי מהם מסוכן. צריך עין אנושית להבחין שהformat הראשון אינו קבוע.
  • הוא לא יודע ש-len ב-read(0, n->body, len) חתום ובשליטת התוקף. בלבול הסימן בלתי נראה ל-grep.
  • הוא לא רואה בכלל את הבאג ב-read_line (חוסר בדיקת גבולות) ולא את בדיקת האינדקס השבורה ב-show_note, כי הם לא כרוכים בשם פונקציה מסוכנת - הם באגים של לוגיקה חסרה.

זה בדיוק הלקח: grep מסמן מועמדים, אבל את שלושת הבאגים המסוכנים ביותר כאן (read_line, בלבול הסימן, בדיקת האינדקס) מוצאים רק בקריאה.

כלל semgrep שתופס את ה-format string:

rules:
  - id: printf-non-constant-format
    patterns:
      - pattern: printf($FMT, ...)
      - pattern-not: printf("...", ...)
      - pattern-not: printf("...")
    message: printf with a format that isn't a constant string
    languages: [c]
    severity: ERROR

הכלל הזה ידליק את printf(n->title) ולא את printf("%s", n->body).


פתרון תרגיל 7 - הוכחת מושג

הדרך הכי מהירה להוכיח שהבאג ב-read_line אמיתי, דרך overflow המחסנית ב-main:

from pwn import *

context.binary = ELF('./notes')
p = process('./notes')

# choice[8] in main. read_line doesn't check bounds, so we'll overwrite everything
p.sendline(b'A' * 200)     # overflow on choice -> saved rbp -> return address
p.wait()
print(p.poll())            # -11 = SIGSEGV: the program crashed due to a corrupted rip

והוכחת ה-format string:

from pwn import *

p = process('./notes')
p.sendlineafter(b'> ', b'add')
p.sendlineafter(b'title: ', b'%p.%p.%p.%p')   # title with format specifiers
p.sendlineafter(b'length: ', b'4')
p.sendafter(b'body: ', b'AAAA')
p.sendlineafter(b'> ', b'show')
p.sendlineafter(b'index: ', b'0')
print(p.recvline())       # prints values from the stack - proof that the printf is vulnerable

אם במקום %p נכניס כותרת ארוכה מאוד, נראה Segmentation fault שמוכיח את overflow ה-sprintf/strcpy. המעבר מ"קוד חשוד" ל-SIGSEGV בפועל הוא מה שהופך ממצא סקירה לחולשה מאומתת.


הכללה - שבעת הבאגים והחוט המקשר

בואו נסכם את מה שמצאנו:

הבאג המחלקה המיקום
באג 1 חוסר בדיקת גבולות (overflow מחסנית) read_line
באג 2 פונקציה מסוכנת, title[64] אל title[32] add_note - strcpy(n->title, title)
באג 3 גלישת שלם add_note - malloc(len + 1)
באג 4 בלבול סימן, overflow heap add_note - read(0, n->body, len)
באג 5 חוסר בדיקת גבולות + סימן show_note - בדיקת האינדקס
באג 6 מחרוזת format show_note - printf(n->title)
באג 7 גלישת חוצץ + TOCTOU save_note - sprintf ו-access/fopen

שימו לב איך הבאגים מתחברים: הבאג ב-read_line (מספר 1) מזין את overflow ה-strcpy (מספר 2), שמזינה את overflow ה-sprintf (מספר 7). זו לא צירוף מקרים - באג יסודי אחד במקור מרכזי מזהם שרשרת שלמה של סינקים במורד הזרם. זה בדיוק למה מתחילים סקירה מהמקורות: מתקנים את read_line, ושלושה תסמינים נעלמים.

החוט המקשר:

  • כל באג כאן חי על מסלול ממקור לסינק, במקום שבו הנחה לא נבדקה: "הקלט לא ארוך מדי", "האורך חיובי", "האינדקס בטווח", "הformat קבוע".
  • הבאגים שהיו קלים למצוא עם fuzzer הם overflows שמפילות את התוכנית (1, 2, 7). הבאגים שפאזר היה מפספס הם בדיוק אלה שדורשים הבנת זרימה או סימן (4, 5), ואלה שלא מקריסים תמיד (6). לכן שתי השיטות משלימות.
  • הכלים (grep, semgrep) סימנו את המועמדים, אבל ההכרעה - ובמיוחד זיהוי בלבול הסימן ובדיקת האינדקס - הייתה אנושית.

זו המיומנות שבניתם כאן: לקחת קוד שלא ראיתם, למפות מקורות וסינקים, ולעקוב אחרי הנתון עד שאתם מוצאים את המקום שבו התוכנית סומכת על משהו שהיא לא בדקה. זו הליבה של מחקר חולשות.