AFL Under the hood (Part 1)

בפוסט הקודם נתתי הקדמה לפאזר AFL – איך הוא עובד ברמה הגבוהה, מהם השימושים שלו וגם דוגמה פשוטה לאופן השימוש בו. כעת, לאחר שהכרנו את הכלי במבט על, הגיע הזמן לצלול למימוש הפנימי שלו – נביט בקוד של AFL עצמו ואיך הוא מיישם את הרעיונות שמנחים את הפאזר. מה קורה ב afl-gcc ו afl-as ? בואו ניזכר בשלב הראשון של השימוש בפאזר, שהוא לקמפל את קוד המקור עם afl-gcc . התוצר של הפעולה הזאת הוא הבינארי שלנו עם קוד אינסטרומנטציה בתוכו. למעשה, afl-gcc הוא לא יותר ממעטפת קטנה, שמוסיפה \ עורכת כמה פרמטרים ל gcc וגורמת לו להשתמש באסמבלר אחר, שהוא afl-as – שם קורה הקסם האמיתי והאינסטרומנטציה נוספת אל התוכנית שלנו. הקלט שמועבר אל afl-as הוא קוד אסמבלי שעבר קומפילציה של gcc (יש לזכור שכשאומרים קומפילציה לעיתים מתכוונים להפוך קוד לתוצר הסופי, executable , אך לתהליך זה יש כמה תחנות בדרך: compiler->assembler->linker . אנחנו בתחנה האמצעית ועלינו להפוך קוד אסמבלי להוראות מכונה שהמעבד מבין). החלק המעניין מתחיל בפונקציה add_instrumentation ב afl-as.c . הפונקציה בודקת תחילה אם אנחנו נמצאים ב text section ,...

AFL מבוא לפאזינג עם


כמה מילים על פאזינג ותיאוריה בסיסית 

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

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

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

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

מה זה AFL?

American fuzzy lop הוא פאזר חינמי (הקוד שלו נמצא בgithub) שאופן הפעולה שלו הוא באמצעות אלגוריתם גנטי, מונחה ע"י אינסטרומנטציה, והוא מאוד קל לשימוש ולהפעלה ראשונית לכן אני משתמש בו כדרך להדגים תהליך פאזינג.

אופן הפעולה של AFL:

  1. שליפת קלט מהתור (התור מתחיל עם קלט ראשוני שאנו נותנים)
  2. הקטנת הקלט לגודל הקטן ביותר שיתקבל בתוכנית
  3. לבצע מוטציות שונות על הקלט
  4. אם הקלט המעוות יוצר התנהגות חדשה בתוכנית, נשמור אותו בתור ונחזור על התהליך.

AFL יכול לבצע source instrumentation: נעביר את הקוד של התוכנית שלנו בקומפיילר מיוחד של AFL כדי שיזריק לנו קוד ניטור בין כמה הוראות אסמבלי (קפיצות, קריאות לפונקציה וכו'). לAFL יש את היכולת גם לעבוד בלי קוד מקור אלא רק עם הבינארי עצמו – השיטה נקראת binary instrumentation והיא משתמשת בQEMU עם אמולציה לקוד user space. אך השיטה הזאת איטית בהרבה יותר מאשר source instrumentation בגלל שצריך להזריק ולהריץ הוראות בזמן ריצה, ונדרש אימולטור שיוצר overhead.

עכשיו כשאנו מבינים את התיאוריה הבסיסית של הפאזר, הגיע הזמן ללכלך את הידיים ולראות איך הכול עובד.


שימוש בAFL

שלב ראשון: נוריד ונתקין את הכלי. תהליך התקנה סופר פשוט: נעשה

git clone https://github.com/google/AFL.git

ואז מהתיקייה של הפרויקט שעכשיו על המחשב שלנו, נעשה make. (המכונה שלי היא linux x86_64 ולא נתקלתי בשום קושי בבניית הכלי, ואין צורך בשום dependencies פרט לקומפיילר למיטב הבנתי).

נוצרו אצלינו כמה בינארים בתיקייה: afl-as, afl-fuzz, afl-gcc, afl-tmin, afl-analyze.

מה שהכי מעניין אותנו כרגע אלה afl-fuzz  ו afl-gcc שתכף נראה מה עושים איתם.

 

AFL מצפה לתוכנית שקוראת מSTDIN, עושה משהו עם הקלט ומסיימת את פעולתה.

לצורך ההדגמה, נעבוד על קטע הקוד הבא:


#include <stdio.h>

void func(char*);
char str[20];

int main() {
	printf("Give me a string: \n");
	fgets(str, 20, stdin);
	func(str);
}

void func(char* buffer) {
    if (buffer[0] == 'F'){
	if (buffer[1] == 'U'){
	if (buffer[2] == 'Z'){
	if (buffer[3] == 'Z'){
	if (buffer[4] == 'M'){
	if (buffer[5] == 'E'){
	if (buffer[6] == '0'){
	printf("Crash! \n");
	int* x = 0;
	printf("%d\n", *x);
	}}}}}}
}


ניתן לראות בקוד שהתוכנית מבקשת קלט מהמשתמש, והקלט מועבר לפונקציה שבודקת האם המחרוזת מתחילה ב FUZZME0. במידה וכן, התוכנית קורסת באופן מלאכותי ע"י נסיון לעשות dereference  ל null pointer והפעולה לא חוקית משום שלא ניתן לגשת לזיכרון בכתובת 0  (NULL). הסיבה לריבוי תנאי ה-if שנראים חסרי משמעות, היא כדי לדמות הרבה נתיבי קוד בתוכנית (למעשה, כל if מתורגם באסמבלי לcmp וconditional jmp וזה בדיוק מה שAFL מחפש – קפיצות וענפים בקוד). כך נראית הפונקציה שלנו בצורה ויזואלית:


כדי לקמפל את התוכנית שלנו עם הinstrumentation (לא נתעסק כרגע בפאזינג ללא קוד מקור), נשתמש בafl-gcc במקום הקומפיילר הרגיל שהיינו משתמשים בו. זה נראה בערך כך:

./afl-gcc /path/to/file.c -o /path/to/output

אפשר להריץ את הבינארי שיצא לנו ולראות שהוא באמת עובד (ולבדוק אם הקלט FUZZME0 מקריס את התוכנה).


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


ניתן לראות בghidra שבאמת נוספו לבינארי שלנו כמה הוראות שלא כתבנו בתוכנית (הפונקציה __afl_maybe_log וכמה הוראות אסמבלי שמתעסקות במשתנים שקשורים לAFL). אלו אחראים לדווח לAFL מידע לגבי ההימצאות שלנו בקוד ובענפים השונים.

פרט לבינארי, אנחנו צריכים גם קלט ראשוני תקין שישמש את הפאזר ליצירת מוטציות לקלט. ניצור תיקייה חדשה שנקרא לה in, ושם ניצור קובץ (לא משנה איך הקובץ נקרא) ובו נכתוב קלט תקין. לדוגמה: “Hello AFL” זה קלט תקין, אז נכתוב אותו בקובץ ששמו test1 נניח.

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

עכשיו לחלק העיקרי, הרצת הפאזר:

afl-fuzz -i /path/to/in -o /path/to/out /path/to/binary

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



השדות די מסבירים את עצמם, השדה המשמעותי הוא כמובן uniq crashes שמציין כמה קלטים גרמו לקריסה ייחודית של התוכנית (ייתכן שכמה קלטים שונים יגרמו לקריסה בדיוק באותו מקום ומאותו מצב אז הם לא ייחודיים). כמו כן, cycle עובר כל פעם שAFL מסיים לעבור על תור הקלטים והמוטציות ואז הוא חוזר חלילה, total paths מציין לכמה נתיבי קוד שונים הצליח להגיע הפאזר. תחת Process timing נמצאים טיימרים, ושאר השדות פחות רלוונטיים לדוגמה הפשוטה שלנו אבל מוזמנים לקרוא עליהם בdocs של AFL.

ניתן לפאזר כמה דקות ונבדוק שוב מה מצבינו:



נראה שהפאזר מצא קלט שמקריס את התוכנה! בואו ניגש לתיקייה out ששם AFL שומר את המידע והקלטים שלו, ניגש לcrashes שבתוך התיקייה ונקרא את הקבצים שם: שם הקובץ הוא id:000000,sig:11,src:000005,op:havoc,rep:2 (ככה AFL קורא לקלטים) והתוכן שלו הוא FUZZME0M

נראה שאכן הפאזר הצליח לעלות על הקריסה בתוכנית שלנו שקורית כאשר הקלט מתחיל בFUZZME0.

עוד תיקייה מעניינת לצד crashes  היא queue, שם ממש ניתן לראות את כל הקלטים שהובילו לפתיחת כל נתיב חדש, שלבסוף מוביל לקריסה:


    ==> id:000000,orig:test1 <==
Hell
==> id:000001,src:000000,op:havoc,rep:8,+cov <==
FU��
==> id:000002,src:000001,op:havoc,rep:2,+cov <==
FUZ�
==> id:000003,src:000002,op:havoc,rep:2,+cov <==
FUZZ
==> id:000004,src:000003,op:havoc,rep:16,+cov <==
FUZZMMZM
==> id:000005,src:000004,op:flip1,pos:5,+cov <==
FUZZMEZM

  

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

סיכום

הרעיון של פאזינג אמנם לא חדש, אבל הפאזרים משתכללים ונהיים חכמים יותר כל הזמן. הפאזר חוסך המון זמן בחיפוש אחר חולשות, ופאזינג מיושם בבדיקה של כמעט כל תוכנה, החל מבינארים רגילים, מערכות הפעלה וקרנלים (ראו kAFL, syzkaller), ועד מערכות web ואתרי אינטרנט (wfuzz ונוספים). בחרתי להראות את תהליך הפאזינג עם AFL כי הוא כלי מהפכני מבחינת קלות השימוש, השתמשו בו למצוא המון חולשות בשנים האחרונות ופאזרים נוספים כיום מתבססים על רעיונות דומים לשל AFL.

 

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

מקורות:

https://en.wikipedia.org/wiki/Fuzzing

https://github.com/google/AFL


Comments

Popular posts from this blog

AFL Under the hood (Part 1)