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, כלומר אנחנו בחלק שמכיל קוד ולא מידע (data section) לכן במקום זה אפשר
להוסיף אינסטרומנטציה. (כשאני אומר אינסטרומנטציה – אני למעשה מתכוון לקוד אסמבלי
קטן)
במידה ואנחנו נמצאים באזור שאפשר להוסיף בו אינסטרומנטציה, אנו עוברים
שורה-שורה באסמבלי ובודקים 2 מקרים:
·
אם השורה מתחילה בהוראת קפיצה בתנאי (jz, jle, jg) – סימן שאנחנו נמצאים
ב"צומת" שמוביל לשני ענפים שונים – במקרה כזה נוסיף אינסטרומנטציה ישר
אחרי הקפיצה, קטע קוד זה רץ במידה והתנאי לא מתקיים ולא קפצנו (ענף ראשון בצומת).
·
אם השורה מתחילה ב”.L”
ואז מספר (כלומר .L0: .L1: .L2:
) סימן שהגענו לlabel שמסמן יעד של קפיצה. זה רמז של GCC שעוזר לנו להבין לאן קופץ
קוד אחרי קפיצה בתנאי. במקרה כזה נוסיף אינסטרומנטציה ישר אחרי הlabel, קטע קוד זה רץ במידה
והתנאי כן מתקיים (ענף שני בצומת).
רק כדי להבין כמה פשוט זה נעשה, נסתכל על קטע הקוד הבא (שורות 372-381
afl-as.c)
if (line[0] == '\t') {
if (line[1] == 'j' && line[2] != 'm' && R(100) < inst_ratio) {
fprintf(outf, use_64bit ? trampoline_fmt_64 : trampoline_fmt_32,
R(MAP_SIZE));
ins_lines++;
}
המערך line מכיל את
השורה שכרגע אנו נמצאים בה בקוד האסמבלי של התוכנה שלנו. אם אחרי טאב (\t) מתחילה ההוראה באות j – סימן שזו
קפיצה. אך אם האות הבאה היא m, ההוראה היא jmp
– וזו קפיצה ללא תנאי. לא נוסיף אינסטרומנטציה לקפיצה ללא תנאי כי
אין פה צומת ואין פה שום דבר מעניין. אם האות השנייה היא לא m סימן שזו קפיצה בתנאי (je, jne, jg) ונוסיף אינסטרומנטציה – כלומר
נכתוב לקובץ באותה נקודה כמה הוראות אסמבלי שנמצאות במשתנה trampoline_fmt_ ,
אחרי שafl-as סיים להוסיף את האינסטרומטציה איפה שהיה צריך – הוא מוסיף בסוף עוד קוד אסמבלי שנמצא בmain_payload – הקוד הזה מכיל כל מיני פונקציות לתפקוד של AFL.
הגיע הזמן להבין איזה קוד נוסף לנו לתוכנה. כדי לעשות זאת אפשר:
·
לקרוא ישירות מקוד המקור, כי כל הקוד שנוסף לתוכנה כתוב ב afl-as.h
·
לקחת את התוכנה שלנו אחרי קומפילציה של afl-gcc ולנתח אותה בעצמינו בdisassembler.
אלך על הדרך השנייה משום שקוד המקור כתוב בסינטקס של AT&T והוא
מסורבל בגלל כל מיני preprocessor
statements (ifndef, define stuff).
אשתמש בכלי הפשוט והאהוב, objdump, כי לא צריך יותר מזה במקרה שלנו (אנחנו רק צריכים לקרוא את
האסמבלי).
כל הפונקציות של AFL (מקורן בmain_payload
בafl-as.h)
מתחילות ב __afl לכן לא תהיה בעיה למצוא
אותן: __afl_maybe_log,
__afl_store, __afl_return, __afl_setup, __afl_setup_first, __afl_forkserver,
__afl_fork_wait_loop, __afl_fork_resume, __afl_die, __afl_setup_abort
Trampoline
כל הפונקציות הבאות כתובות בסוף הtext section. אך אנו יודעים גם שבקוד
שלנו נוספו הוראות לפני כן, האינסטרומנטציה עצמה של הענפים בקוד – ההוראות שהיו בתוך
המשתנה trampoline_fmt.
בואו נראה דוגמה:
1398: lea rsp,[rsp-0x98]
13a0: mov QWORD PTR [rsp],rdx
13a4: mov QWORD PTR [rsp+0x8],rcx
13a9: mov QWORD PTR [rsp+0x10],rax
13ae: mov rcx,0x18fa
13b5: call 1538 <__afl_maybe_log>
13ba: mov rax,QWORD PTR [rsp+0x10]
13bf: mov rcx,QWORD PTR [rsp+0x8]
13c4: mov rdx,QWORD PTR [rsp]
13c8: lea rsp,[rsp+0x98]
קטע הקוד הזה חוזר על עצמו (למעט הערך שמועבר לrcx) אחרי כל קפיצה בתנאי ואחרי כל ענף חדש בקוד. קטע הקוד הזה לא היה קיים בתוכנית אם הייתי מקמפל אותה עם קומפיילר
רגיל, וגם השורה שקודמת לקטע הזה כוללת את ההוראה jne – כלומר זו
אינסטרומנטציה שafl-as
הוסיף כי הוא זיהה קפיצה.
בקוד עצמו ניתן לראות שהוא שומר לו מקום על המחסנית (0x98 בתים), שומר שלושה
רגיסטרים, שם ערך כלשהו בrcx, קורא לפונקציה __afl_maybe_log ולאחר מכן משחזר את הרגיסטרים ואת המחסנית לקדמותה.
הערך שבתוך rcx הוא מספר רנדומלי בין 0 ל65535. המספר הזה הוא "המזהה הייחודי" של הענף שאנו נמצאים בו בקוד - כך AFL יודע אם הגענו לענף חדש או לא. אם נחזור לשורה בafl-as.c שבה הוא כותב את הtrampoline_fmt לקובץ נראה שמועבר פרמטר לפונקציה. הפרמטר הזה נכתב בתוך הקובץ משום שtrampoline_fmt הוא format string. אם נציץ באמת בקוד המקור, בafl-as.h בהגדרה של trampoline_fmt נראה
movq $0x%08x, %%rcx
שלכאן מגיע הפרמטר לפונקציה fprintf.
את הפונקציות הבאות אתאר בקצרה מכיוון שהן קטנות ועושות דבר מאוד פשוט:
afl_setup_abort: הפונקציה הזו משנה את
הערך של המשתנה __afl_setup_failure מ0 ל1 כדי לסמן שהsetup נכשל, משחזרת את כל הרגיסטרים שהיה להם גיבוי במחסנית, משחזרת את
רגיסטר הflags וחוזרת למי
שקרא לה.
afl_return: משחזרת את רגיסטר הflags, וחוזרת למי שקרא לה
(עושה return
פשוט).
afl_die: קוראת לexit()
את הפונקציות האחרות ננתח לפי הסדר הלוגי שבו הן פועלות.
__afl_maybe_log:
0000000000001538 <__afl_maybe_log>:
1538: lahf
1539: seto al
153c: mov rdx,QWORD PTR [rip+0x2add] # 4020 <__afl_area_ptr>
1543: test rdx,rdx
1546: je 1568 <__afl_setup>
הפונקציה הזאת נקראת בכל פעם שמגיעים לענף בקוד שלנו (ה"טרמפולינה" שלנו).
2 ההוראות הראשונות שבה מגבות את הflags רגיסטר לתוך ax, ולאחר מכן בודקת אם המשתנה __afl_area_ptr הוא 0. במידה וכן, נקפוץ לafl_setup. במידה ולא, הפונקציה תמשיך לשורה הבאה לafl_store.
__afl_setup:
0000000000001568 <__afl_setup>:
1568: cmp BYTE PTR [rip+0x2ac9],0x0 # 4038 <__afl_setup_failure>
156f: jne 1560 <__afl_return>
1571: lea rdx,[rip+0x2ae0] # 4058 <__afl_global_area_ptr>
1578: mov rdx,QWORD PTR [rdx]
157b: test rdx,rdx
157e: je 1589 <__afl_setup_first>
1580: mov QWORD PTR [rip+0x2a99],rdx # 4020 <__afl_area_ptr>
1587: jmp 1548 <__afl_store>
משווים את הערך שבמשתנה __afl_setup_failure ל0. במידה והוא שונה מ0 קופצים ל __afl_return. אפשר להניח שבדיקה זו בודקת אם הsetup כבר נכשל פעם, ואם כן,
נצא ישר מהפונקציה במקום לעבור אותו שוב (את ההנחות אפשר לבסס ע"י קריאה של
ההערות בקוד המקור afl-as.h).
נמשיך עם קריאה של המצביע __afl_global_area_ptr. אם הערך של המצביע הוא 0 נקפוץ ל __afl_setup_first. אם לא, נשמור את הערך הזה גם ב __afl_area_ptr
ונקפוץ ל __afl_store. נניח ש__afl_global_area_ptr
אמור להכיל מצביע כלשהו, אם הוא NULL, כנראה שזו פעם ראשונה
שאנו מריצים את הsetup
ולכן צריך לקרוא ל __afl_setup_first. הפונקציה נראית בערך כך בפסודו קוד:
afl_setup:
if (afl_setup_failure) {
goto afl_return;
}
if (afl_global_area_ptr == NULL) {
goto afl_setup_first;
}
afl_area_ptr = afl_global_area_ptr;
goto afl_store;
__afl_setup_first:
0000000000001589 <__afl_setup_first>:
1589: lea rsp,[rsp-0x160]
1591: mov QWORD PTR [rsp],rax
1595: mov QWORD PTR [rsp+0x8],rcx
159a: mov QWORD PTR [rsp+0x10],rdi
159f: mov QWORD PTR [rsp+0x20],rsi
15a4: mov QWORD PTR [rsp+0x28],r8
15a9: mov QWORD PTR [rsp+0x30],r9
15ae: mov QWORD PTR [rsp+0x38],r10
.....
164a: push r12
164c: mov r12,rsp
164f: sub rsp,0x10
1653: and rsp,0xfffffffffffffff0
1657: lea rdi,[rip+0x2c1] # 191f <.AFL_SHM_ENV>
165e: call 10f0 <getenv@plt>
1663: test rax,rax
1666: je 184e <__afl_setup_abort>
166c: mov rdi,rax
166f: call 1180 <atoi@plt>
1674: xor rdx,rdx
1677: xor rsi,rsi
167a: mov rdi,rax
167d: call 1170 <shmat@plt>
1682: cmp rax,0xffffffffffffffff
1686: je 184e <__afl_setup_abort>
168c: mov rdx,rax
168f: mov QWORD PTR [rip+0x298a],rax # 4020 <__afl_area_ptr>
1696: lea rdx,[rip+0x29bb] # 4058 <__afl_global_area_ptr>
169d: mov QWORD PTR [rdx],rax
16a0: mov rdx,rax
הפונקציה שומרת לעצמה 0x160 בתים על המחסנית, לשם היא מכניסה את ערכי כל הרגיסטרים, כנראה
גיבוי שתשחזר בהמשך. אחר כך טוענת את הכתובת .AFL_SHM_ENV שמכילה מחרוזת, משתנה סביבה. הקריאה לgetenv בודקת אם קיים משתנה
סביבה עם השם הזה ומחזירה לנו מצביע אליו. אם לא, נקפוץ ל __afl_setup_abort
לבטל את כל מה שעשינו. את המצביע שקיבלנו מgetenv נעביר לפונק' atoi שתמיר לנו את המחרוזת למספר
– ואת המספר שקיבלנו נעביר כפרמטר לפונקציה shmat. אם הערך שחוזר הוא -1, נקפוץ
ל setup_abort.
הערך שחוזר הוא מצביע ונשמור אותו ב__afl_global_area_ptr וב __afl_area_ptr.
כדי להבין מה קרה פה נצטרך להבין מה זה shm.
Shared memory
כשמו כן הוא, זה מנגנון לתקשורת
בין תהליכים ע"י זיכרון משותף ביניהם. כדי לקבל זיכרון משותף נקרא לפונקציה shmget ונקבל ID מיוחד לשימוש פונקציות של
shm (כמו file descriptor אבל רק לשימוש של המנגנון
shm). כדי לקבל מצביע לזיכרון
המשותף ולקרוא \ לכתוב אליו, משתמשים בפונקציה shmat עם הID שקיבלנו (אפשר לגשת
לזיכרון הזה באמצעות הID
מכל תהליך שנרצה). המידע של התוכנית שלנו עובר לפאזר באמצעות זיכרון משותף, אבל
איך מלכתחילה יש לתוכנית שלנו את הID לזיכרון המשותף? התשובה היא באמצעות משתנה סביבה __AFL_SHM_ID שיוצר הפאזר ושם בתוכו את
הID לזיכרון המשותף. הקוד הבא בafl-fuzz.c:
shm_id = shmget(IPC_PRIVATE, MAP_SIZE, IPC_CREAT | IPC_EXCL | 0600);
if (shm_id < 0) PFATAL("shmget() failed");
atexit(remove_shm);
shm_str = alloc_printf("%d", shm_id);
if (!dumb_mode) setenv(SHM_ENV_VAR, shm_str, 1);
הקריאה ל shmget
מבקשת מהמערכת ליצור סגמנט זיכרון משותף חדש בגודל MAP_SIZE (משתנה של הפאזר שהוגדר כ-64KB).
הsetup בתוכנית שלנו קורא לgetenv כדי לקרוא את משתנה הסביבה, לאחר מכן מעביר את המחרוזת לatoi כי מדובר בID שהוא מספר ולבסוף קריאה לshmat עם הID הזה – והערך החוזר הוא מצביע לזיכרון המשותף שנשמר ב __afl_area_ptr.
פסודו קוד של afl_setup_first:
afl_setup_first:
char registers_backup[0x160];
BackupRegisters(registers_backup);
char* afl_shm_env = getenv(AFL_SHM_ENV); // getenv("AFL_SHM_ID");
if (afl_shm_env == NULL) {
goto afl_setup_abort;
}
int shm_id = atoi(afl_shm_env);
void* shared_mem_ptr = shmat(shm_id, NULL, 0);
if (shared_mem_ptr == -1) {
goto afl_setup_abort;
}
afl_global_area_ptr = shared_mem_ptr;
afl_area_ptr = shared_mem_ptr;
goto afl_forkserver;
אעצור בנקודה זו כדי שהפוסט לא יהפוך להיות ארוך מדי ופעם הבאה נמשיך
מהנקודה בה עצרנו – forkserver.
סיכום
בפוסט זה למדנו שafl-as וafl-gcc הם מעטפת קטנה לקומפיילר ולאסמבלר שכל מה שהם עושים זה לשבץ כמה שורות קוד אסמבלי בתוכנה שלנו. עברנו על תהליך הsetup של התוכנה לקראת פאזינג – שכולל יצירת סגמנט זיכרון משותף בין הפאזר לתוכנה שלנו. בפוסט הבא נלמד יותר על התקשורת בין הפאזר לתוכנה שלנו, על forkserver ועל תפקיד הזיכרון המשותף.
Comments
Post a Comment