BinaryVision

Buffer Overflow

URL = https://www.binaryvision.co.il/2008/12/48-revision-3/URL = https://www.binaryvision.co.il/2008/12/48-revision-3/

מבוא

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

גורמים נפוצים

טיפול במחרוזות

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

#include <stdio.h>

int main(int argc,char * argv[])
{
 char user_name[32];
 printf("Enter username: ");
 scanf("%s",user_name);
 printf("Welcome %s!",user_name);
 return 0;
}

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

  • strcpy
  • strcat
  • sprintf
  • gets
  • ועוד…

גלישת מספר

קורה בדרך כלל כשחלק מהקלט זה מספר עצמים שצריך לשמור אותם אחרי זה או לעשות איתם משהו אחר.
קלט יכול להיות ישיר, ששאול נגיד משתמש כמה אנשים הוא רוצה להוסיף ליומן, או כמה תנועות כסף הוא רוצה להוסיף.
או קלט עקיף, למשל קובץ ZIP שמכיל במבנה שלו את כמות הקבצים שמאוחסנים בתוכו.
הרעיון של גלישת מספר זה שבחישובים שמבצעת התוכנה, בשלב מסויים גולשת מהערך המקסימלי שאפשר לתת למספר מהסוג שנבחר ע"י המתכנת.
קורה בד"כ כשכמות המידע שצריך לשמור היא כפולה כלשהיא של כמות האובייקטים שנותן המשתמש.
ניקח לדוגמה הכנסת שם, סיסמה, ות"ז של משתמשים למערך נתונים דינאמי בזיכרון.
הקוד הבא קולט מהמשתמש את כמות האנשים שהוא רוצה לשמור, מאלקץ מקום, ושומר אותם אחד אחד.

#include <stdio.h>

typedef struct _user_data {
	char name[64];
	char password[128];
	unsigned long uid;
} user_data;

int main(int argc,char * argv[])
{
	unsigned long ucount = 0;
	unsigned long i = 0;
	user_data * udata = NULL;

	printf("How many users would you like to add: ");
	scanf("%d",&amp;ucount);

	udata = malloc(ucount * sizeof(user_data));
	if (NULL == udata) {
		return 0;
	}
	for (i = 0;i &lt; ucount;i++) {
		printf("Enter Name: ");
		fgets(udata[i].name,64,stdin);
		printf("Enter Password: ");
		fgets(udata[i].password,128,stdin);
		printf("Enter UID: ");
		scanf("%d",&amp;udata[i].uid);
	}
	printf("Thank you, entered %d users");
	free(udata);
	return 0;
}

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

64 + 128 + 4 = 196

אם לוקחים את המספר הכי גדול שניתן לאחסן בסוג נתונים שבחרנו (unsigned long), ומחלקים אותו ב196,
מקבלים את המספר המקסימלי של "משתמשים" שהתוכנה הזאת מסוגלת להתמודד איתם בצורה תקינה.
וזה יוצא:

4294967295 / 196 = 21913098.44

ז"א אם הקלט ההתחלתי של התוכנה חורג מ21913098, אנחנו מקבל כמות לא נכונה של זיכרון שצריך לאלקץ בתור פרמטר לmalloc,
אבל הכתיבה מאוחר יותר מתבססת על ההנחה שיש לנו מספיק זיכרון לכל הכתיבה, והמבנים האחרונים ידרסו זיכרון שלא שייך לנו.

סוגי גלישה

Stack

קורה כאשר הזיכרון שגולשים ממנו נמצא על המחסנית של התוכנה.

int main(int argc,char * argv[])
{
 char Username[16];
}

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

Username[0] <- ESP
Username[4]
Username[8]
Username[12]
ReturnAddressFromMain
argc
argv
.
.
.

כאשר נגלוש מUsername אנחנו נשכתב את הReturnAddress ואחרי זה גם את argc והלאה…

Heap

קורה כאשר הזיכרון שגולשים ממנו נמצא על הHeap של התהליך.
כדי להקצות זיכרון על הHeap אפשר להשתמש בקוד הבא:

int main(int argc,char * argv)
{
 char *mem1 = malloc(16);
 char *mem2 = malloc(16);
}

הHeap נמצא בדף אחר מהקוד או המחסנית ומנוהל ע"י מערכת ההפעלה בעזרת מבנים מיוחדים שמוכנסים יחד עם המידע.
בדרך כלל, אם התוכנה לא השתמשה בזיכרון Heap עדיין, שני המצביעים יצביעו על זיכרון עוקב.
ואם נגרום לגלישה מהזיכרון שנמצא בmem1 אז הוא ישכתב את mem2 וגם את בלוק הבקרה של mem2 שנמצא לפניו בזיכרון.
מבנה הHeap מכיל מידע חיוני לשחרור בלוקים, וקבלת בלוקים חדשים מהמערכת, שכתוב הבלוק בקרה יגרום להתנהגות לא צפוייה.

שיטות ניצול

Ret

שיטת ניצול זו טובה כאשר הגלישה היא מסוג Stack Overflow.
הקוד הבא מדגים שימוש בStack והמחסנית בזמן ביצוע הפונקצייה.
עוד אפשר ללמוד מStack Format and Usage.

int main(int argc,char * argv[])
{
 char Username[16];
}
Username[0] <- ESP
Username[4]
Username[8]
Username[12]
ReturnAddressFromMain
argc
argv
.
.
.

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

add esp,16
ret

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

SEH

ניצול זה מתבסס על מנגנון Structured Exception Handling במערכת ההפעלה.
אני לא הולך להסביר כאן על המנגנון עצמו, מה שכן, המנגנון שומר את המצביע של הפונקציה שמטפלת בשגיאות על המחסנית.
ואם השכתוב שלנו מספיק גדול במחסנית, אפשר לדרוס את המצביע לגרום להרצת קוד שלנו ברגע שקופצת שגיאה כלשהיא (Exception).
שזה לא קשה לעשות כי דרסנו ממש הרבה זיכרון במחסנית בדרך, ובכל מקרה התוכנה בדרך לקריסה.

DWORD Overwrite

בשיטת ניצול זו משתמשים כאשר יש גלישה מסוג Heap Overflow.
בגלל מבנה הHeap במערכת (Heap Structure)
כאשר גולשים מHeap Buffer אחד ומשכתבים אחר כלשהו, משכתבים בדרך גם את הכותרת בקרה שלו.
ואחרי זה, כאשר המערכת רוצה לשחרר את הBuffer, היא משתמשת במבנים שאנחנו שיכתבנו.
למשל בזמן שיחרור בלוק זיכרון, צריך לקשר מחדש את הבלוק שבא לפניו, והבלוק שבא אחרי הבלוק שמשחררים.
והוא עושה את זה ע"י כתיבת מצביעים למקומות שכתובים הבלוק בקרה של אותו הBuffer.
מקומות שאנחנו שולטים בהם, ומצביעים שאנחנו שולטים בהם.
מה שאומר שיש לנו כתיבה של DWORD כלשהו שאנחנו רוצים, למקום כלשהו בזיכרון שאנחנו אומרים מה הוא.
מכאן הניצול פשוט יותר, אפשר לשכתב מצביעים שהמיקום שלהם ידוע כמו הUnhandled Exception Handler.
(עוד מידע על זה בStructured Exception Handling).
ולגרום לException בכתיבה השנייה שתנסה לכתוב DWORD למקום שאסור לכתוב למשל.
בכך יקרא הException Handler שאת המצביע שלו שיכתבנו, והוא מצביע לקוד שאנחנו שלחנו או למקפצה שתביא אותנו לקוד ששלחנו.

נושאים נוספים

סיכום

להבנה יותר טובה של המאמר הזה, צריך לדעת את הבסיס של שפת Assembly, שפת C, וגם מערכות הפעלה.
מומלץ להמשיך בקריאה לקישורים שמצורפים למאמר.

אין תגובות בינתיים

השאר תגובה

מחפש משהו?

תשתמש בטופס למטה כדי לחפש באתר: