מה כל כך מסוכן בפירצת Buffer Overflow?
בתחום אבטחת מידע אנו מייחסים חשיבות רבה יותר לתקלה ככל שהיא משפיעה על יותר תוכניות או מערכות. לכן ברור שתקלה במערכת ההפעלה או ברכיב תוכנה בסיסי אחר במחשב עלולה לגרום לנזק מאוד משמעותי. תקלת Buffer Overflow היא באג בתוכניות הניגשות לזיכרון המחשב שגורם להן לכתוב ערכים לזיכרון שהמתכנת לא התכוון שיכתבו. מה כבר יכול להשתבש? טוב ששאלתם.
1. תוכניות שכותבות ישירות לזיכרון
יש הרבה שפות תכנות איתן ניתן לכתוב תוכניות והן נבדלות בתחביר שונה וגם ביכולות שונות לכל שפה. ככלל אצבע ככל שלשפה יש יותר יכולות תקלות אבטחה בה יהיו חמורות יותר. שפות C ו C++ משמשות לפיתוח רכיבים בסיסיים במערכת ההפעלה, כולל לפיתוח מערכת ההפעלה עצמה. אלו שפות שכמעט לא שומרות על המתכנת מביצוע טעויות.
התוכנית הבאה בשפת C לדוגמא מקבלת מהמשתמש טקסט ובודקת אם הטקסט הוא סיסמא נכונה לפני שממשיכה להדפסה:
#include <stdio.h>
#include <strings.h>
int check_password()
{
char password[16];
gets(password);
return ! strncmp(password, "letmein", 16);
}
int main( int argc, char **argv )
{
printf("Hello. What's the password ?");
int master = check_password();
if ( master )
{
printf("Welcome, master\n");
}
else
{
printf("INTRUDER ALERT\n");
}
}
התוכנית מצפה לקבל טקסט באורך 16 תווים ולכן מקצה את הזיכרון הדרוש לשמירת הסיסמא שהמשתמש יזין. כל זה קורה בגוף הפונקציה check_password
איתה מתחיל הקוד.
2. מבנה הזיכרון בעת ריצת תוכנית
השאלה הראשונה מנקודת מבט של אבטחת מידע היא מה קורה כשהמשתמש מזין סיסמא ארוכה יותר מ-16 תווים. בשביל לענות על שאלה זו נרצה לקבל תמונה של מצב זיכרון המחשב בעת הפעלת הפונקציה ולאן בזיכרון נכתב הטקסט שמוזן.
לצורך כך אפעיל את התוכנית ב Debugger. אם אתם בלינוקס ורוצים לעקוב הפקודות הן:
# compile the program
gcc -g -fno-stack-protector demo.c -o demo
# run in debugger
gdb ./demo
לאחר הפעלת התוכנית בתוך מסך ה Debugger נקבע נקודת עצירה לשורה 9 ונריץ את התוכנית. כשנתבקש להזין סיסמא נזין את הערך aaaaaaaa:
(gdb) b 9
Breakpoint 1 at 0x40060f: file overflow2.c, line 9.
(gdb) start
Temporary breakpoint 2 at 0x40063e: file overflow2.c, line 14.
Starting program: /home/ynon/demos/secure-code-demos/01_Implementation/demo
Temporary breakpoint 2, main (argc=1,
argv=0x7fffffffdf18) at overflow2.c:14
14 printf("Hello. What's the password ?");
(gdb) continue
Continuing.
Hello. What's the password ?aaaaaaaa
Breakpoint 1, check_password () at overflow2.c:9
9 return ! strncmp(password, "letmein", 16);
לאחר הזנת הסיסמא באופן רגיל אנחנו רגילים לחשוב על התוכנית כשומרת את הערך בתוך משתנה, ולחשוב על משתנה כקופסא שבתוכה נשמר הערך. למעשה כל ערכי המשתנים נשמרים בזכרון המחשב (מחולקים לאזור שנקרא מחסנית ואזור שנקרא ערימה), והמשתנה הוא בסך הכל הדרך של המחשב לגשת לאותו אזור בזיכרון שמחזיק את הערך.
במקרה שלנו הזנו את הערך aaaaaaaa כלומר שמונה פעמים האות a. האות a מיוצגת במחשב על ידי המספר 61. בתוך ה Debugger אנו יכולים לראות תמונת זיכרון של המחסנית ומצפים למצוא בתוכה את תוכן המשתנה שלנו, כלומר את המספר 61 מופיע 8 פעמים:
(gdb) x/20x $rsp
0x7fffffffddf0: 0x61616161 0x61616161 0xf7ffe100 0x00007fff
0x7fffffffde00: 0xffffde30 0x00007fff 0x00400657 0x00000000
0x7fffffffde10: 0xffffdf18 0x00007fff 0x00400500 0x00000001
0x7fffffffde20: 0xffffdf10 0x00007fff 0x00000000 0x00000000
0x7fffffffde30: 0x00400680 0x00000000 0xf7a2e830 0x00007fff
ובאמת בתחילת אזור הזיכרון שנקרא המחסנית אנו רואים את הערך שהקלדנו. אגב ערכי משתנים נוספים שהפונקציה תגדיר נשמרים גם בהמשך אותה מחסנית.
בנוסף לערכי המשתנים אפשר לראות ערכים נוספים שנשמרים במחסנית אחרי הקלט שלנו. ביניהם הערך המעניין ביותר הוא המספר 0x00400657
. מספר זה מייצג את הכתובת בזיכרון אליה התוכנית אמורה לחזור אחרי סיום הפונקציה. בכתובת זו נמצאת ממש שורת הקוד הבאה של התוכנית. אפשר לראות את זה מתוך ה Debugger:
(gdb) disassemble main
Dump of assembler code for function main:
0x000000000040062f <+0>: push %rbp
0x0000000000400630 <+1>: mov %rsp,%rbp
0x0000000000400633 <+4>: sub $0x20,%rsp
0x0000000000400637 <+8>: mov %edi,-0x14(%rbp)
0x000000000040063a <+11>: mov %rsi,-0x20(%rbp)
0x000000000040063e <+15>: mov $0x40070c,%edi
0x0000000000400643 <+20>: mov $0x0,%eax
0x0000000000400648 <+25>: callq 0x4004c0 <printf@plt>
0x000000000040064d <+30>: mov $0x0,%eax
0x0000000000400652 <+35>: callq 0x4005f6 <check_password>
0x0000000000400657 <+40>: mov %eax,-0x4(%rbp)
0x000000000040065a <+43>: cmpl $0x0,-0x4(%rbp)
0x000000000040065e <+47>: je 0x40066c <main+61>
0x0000000000400660 <+49>: mov $0x400729,%edi
0x0000000000400665 <+54>: callq 0x4004b0 <puts@plt>
0x000000000040066a <+59>: jmp 0x400676 <main+71>
0x000000000040066c <+61>: mov $0x400739,%edi
0x0000000000400671 <+66>: callq 0x4004b0 <puts@plt>
0x0000000000400676 <+71>: mov $0x0,%eax
0x000000000040067b <+76>: leaveq
0x000000000040067c <+77>: retq
הפקודה disassemble main
מציגה את קוד הפונקציה הראשית main בתרגומו לשפת מכונה. גם בלי להבין הרבה בשפת מכונה אפשר לראות את הקריאה לפונקציה check_password
בשורה שמספרה 35, ומיד אחריה הכתובת של השורה הבאה היא בדיוק 400657
, המספר שראינו קודם על המחסנית.
3. בחזרה ל Buffer Overflow
דריסת זכרון או באנגלית Buffer Overflow הוא מצב בו קלט לתוכנית נכתב מחוץ למקום שיועד לו, ולכן דורס חלקים אחרים בזיכרון מה שמשפיע על המשך פעולת התוכנית. נניח למשל שבמקום 8 אותיות היינו כותבים 28 פעמים את האות a. מצב הזיכרון לאחר ההפעלה היה נראה כך:
(gdb) x/20x $rsp
0x7fffffffddf0: 0x61616161 0x61616161 0x61616161 0x61616161
0x7fffffffde00: 0x61616161 0x61616161 0x61616161 0x00000000
0x7fffffffde10: 0xffffdf18 0x00007fff 0x00400500 0x00000001
0x7fffffffde20: 0xffffdf10 0x00007fff 0x00000000 0x00000000
0x7fffffffde30: 0x00400680 0x00000000 0xf7a2e830 0x00007fff
קל לראות שעכשיו כתובת החזרה נמחקה ובמקומה מופיעות האותיות a שכתבנו. הרצת התוכנית והעברת קלט באורך זה תגרום להתרסקותה, מאחר והמחשב ינסה לחזור לכתובת 0x61616161
ולא ימצא שם את המשך התוכנית:
Hello. What's the password ?aaaaaaaaaaaaaaaaaaaaaaaaaaaa
Segmentation fault (core dumped)
4. ניצול לרעה של הפירצה
אחרי שאנחנו מבינים איך המנגנון עובד קל לדמיין איך אפשר לנצל אותו לרעה. נניח שבמקום האות a היינו מזינים קלט שבתרגום למספר היה נותן ערך שנראה למחשב כמו כתובת תקנית שמכילה קוד להפעלה. במצב כזה המחשב לא היה שם לב שהכתובת אליה צריך לחזור נדרסה והיה חוזר להמשיך את התוכנית בכתובת ההיא.
באמצעות עורך טקסט שמאפשר הזנה של מידע בינארי ניתן ליצור קובץ שיכיל את התוכן המתאים. אקרא לקובץ badinput.bin וזה תוכנו בכתיב Hexa:
$ xxd badinput.bin
00000000: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000010: 0000 0000 0000 0000 6006 4000 0000 0000 ........`.@.....
מספר השורה שבחרתי לחזור אליה, 0x400660
, היא השורה שמדפיסה את ברכת השלום למשתמש שידע את הסיסמא. כשמעבירים את הקובץ כקלט לתוכנית נקבל:
./demo < badinput.bin
Hello. What's the password ?Welcome, master
Segmentation fault (core dumped)
התוכנית ביצעה את עבודתה כאילו ידענו להזין את הסיסמא הנכונה. בהמשך היא כמובן התרסקה כי הדריסה שלנו היתה לא מדויקת וקילקלה לתוכנית מידע שהיה דרוש לפעולתה בהמשך.
דריסת הזיכרון איפשרה לי לעקוף את מנגנוני ההגנה של התוכנית ולקבל התנהגות כאילו ידעתי את הסיסמא. תהליך זיהוי הפירצה והבנת הכתובת אולי היה מייגע, אבל מרגע שהסתיים אפשר לעקוף את מנגנון ההגנה בתוכנית בכל מקום בו היא תרוץ.
5. מנגנון הגנה: הקנרית
חדי העין ביניכם וודאי שמו לב שבניתי עם התוכנית עם מתג מיוחד שהועבר לקומפיילר:
gcc -fno-stack-protector demo.c
המתג no-stack-protector מבטל מנגנון הגנה של הקומפיילר ממתקפה זו. המנגנון נקרא קנרית ומבוסס על קנריות בכלובים שכורים היו לוקחים אתם למכרה כדי להתגונן מחנק. השיטה: כשהקנרית מתה כולם בורחים. באנלוגיה למחשב, הקומפיילר שומר ערך ידוע מראש שקשה להזין אותו כקלט בדיוק לפני כתובת החזרה, וכך אם הערך הידוע מראש הזה שונה אפשר לזהות שהיתה דריסת זיכרון ולרסק את התוכנית באופן יזום (במקום לחזור לכתובת שהתוקף דרס).
מנגנון הקנרית לא נמצא יעיל במיוחד בעולם האמיתי. פורצים מתוחכמים כן הצליחו לדרוס בצורה נכונה את הערך או לדלג עליו.
קיימים היום מגוון כלים שיודעים לסרוק קוד של תוכניות C ו C++ באופן אוטומטי ולזהות מבנית מסוכנים שעשויים להביא לדריסת זיכרון. שימוש נכון בכלים אלו יחד עם מודעות לנושא עוזרת לצמצם תקלות מסוג זה.