לדלג לתוכן

8.2 ארכיטקטורות ARM ו MIPS הרצאה

עד עכשיו כל מה שפירקנו רץ על x86-64 מוכר וידידותי: אותם rdi, rsi, אותה מחסנית, אותו objdump. אבל העולם האמיתי מלא במעבדים אחרים. הטלפון בכיס, הראוטר בבית, מצלמות האבטחה, בקרים תעשייתיים וכמעט כל מכשיר IoT רצים על ARM, ולא מעט ציוד רשת ותקשורת ישן ועכשווי רץ על MIPS. חוקר חולשות שמכיר רק x86 פשוט עיוור בחצי מהשוק. במפגש הזה נכיר את ARM ואת MIPS מנקודת המבט של reverse engineering, נלמד להריץ ולנפות באגים בבינאריים "זרים" על מכונת x86 רגילה בעזרת qemu ו-gdb-multiarch, ובסוף נצלול לאתגר leg מ-pwnable.kr - אתגר קטן וחמוד שכולו נשען על תופעה אחת מבלבלת בצינור העיבוד של ARM.

למה בכלל ARM ו-MIPS

המעבד x86 הוא מעבד CISC: הוראות באורך משתנה (מבית אחד עד חמישה עשר בתים), הוראות שקוראות וכותבות לזיכרון תוך כדי חשבון, והמון "קסם" היסטורי. ARM ו-MIPS הם מעבדי RISC, וזה משנה את כללי המשחק כשקוראים אסמבלי:

  • ההוראות באורך קבוע. ב-ARM הקלאסי כל הוראה היא ארבעה בתים בדיוק, וב-MIPS גם. זה אומר שאפשר לפרק את הקוד בלי לנחש איפה מתחילה ההוראה הבאה.
  • ארכיטקטורת load/store. אין הוראה שעושה גם חשבון וגם גישה לזיכרון. כדי לעבוד על ערך בזיכרון צריך קודם ldr (טעינה לאוגר), אחר כך חשבון על האוגר, ואז str (כתיבה בחזרה). זה מייגע לקריאה בהתחלה אבל צפוי מאוד.
  • הרבה אוגרים. ARM נותן שישה עשר אוגרים גלויים, MIPS נותן שלושים ושניים. לכן פחות תעבורה למחסנית ויותר עבודה באוגרים.

הידע הזה שווה זהב: רוב חולשות ה-IoT שמתפרסמות היום הן בבינאריי ARM או MIPS של ראוטרים ומצלמות. אותם עקרונות שלמדנו (buffer overflow, ROP, format string) עובדים גם שם - רק המוסכמות והאוגרים משתנים.

ארכיטקטורת ARM - הבסיס לחוקר

אוגרים - Registers

ל-ARM (מצב 32 סיביות, המצב שנפגוש באתגר) יש שישה עשר אוגרים בני 32 סיביות, מ-r0 עד r15. שלושת האחרונים הם מיוחדים ויש להם שמות חלופיים:

אוגר שם חלופי תפקיד
r0-r3 - ארגומנטים לפונקציה, ו-r0 גם מחזיר את ערך החזרה
r4-r11 - אוגרים כלליים, נשמרים על ידי הפונקציה הנקראת
r12 ip אוגר עזר זמני - scratch
r13 sp מצביע המחסנית - stack pointer
r14 lr אוגר הקישור - link register
r15 pc מונה התוכנית - program counter
cpsr - אוגר הדגלים והמצב - flags and state

שני האוגרים שהופכים את ARM לשונה מ-x86 הם lr ו-pc, ושניהם עומדים בלב האתגר שלנו, אז נעצור עליהם.

ב-x86, הוראת call דוחפת את הreturn address למחסנית, ו-ret שולפת אותה משם. ב-ARM זה שונה: הוראת הקריאה לפונקציה נקראת bl (ראשי תיבות של branch with link), והיא לא נוגעת במחסנית. במקום זה, היא שומרת את הreturn address - כלומר את כתובת ההוראה שאחרי ה-bl - ישירות באוגר lr. החזרה מהפונקציה נעשית עם bx lr (קפיצה לכתובת שב-lr).

main:
   ...
   0x8d7c:  bl  key3        ; lr <- 0x8d80 (the next instruction), then jump to key3
   0x8d80:  mov r3, r0      ; we return here

שימו לב לנקודה הקריטית: אחרי bl key3, האוגר lr מכיל את 0x8d80, שהיא הכתובת של ההוראה שמיד אחרי הקריאה. אם פונקציה כלשהי תעתיק את lr לאוגר אחר, היא בעצם "תגלה" את הreturn address שלה. תחזיקו את המחשבה הזו, נחזור אליה ב-key3.

בגלל ש-bl דורסת את lr, פונקציה שקוראת לפונקציה אחרת חייבת קודם לשמור את lr במחסנית (בדרך כלל push {lr} בכניסה), אחרת היא תאבד את דרך החזרה שלה. פונקציית עלה (שלא קוראת לאף אחד) יכולה להשאיר את lr באוגר.

מוסכמת הקריאה - Calling Convention

מוסכמת הקריאה של ARM נקראת AAPCS, והיא פשוטה לזכירה:

  • ארבעת הארגומנטים הראשונים עוברים ב-r0, r1, r2, r3.
  • ארגומנטים נוספים עוברים במחסנית.
  • ערך החזרה חוזר ב-r0.
  • הreturn address יושבת ב-lr אחרי bl.

הנה השוואה מהירה לשלוש הארכיטקטורות שכבר עבדנו איתן, כדי לסדר את התמונה:

תפקיד x86-64 System V x86 cdecl ARM (AAPCS) MIPS (o32)
ארגומנט 1 rdi מחסנית r0 a0
ארגומנט 2 rsi מחסנית r1 a1
ארגומנט 3 rdx מחסנית r2 a2
ארגומנט 4 rcx מחסנית r3 a3
ערך חזרה rax eax r0 v0
הreturn address במחסנית במחסנית lr ra

מונה התוכנית וטריק ה-pc+8

עכשיו מגיע החלק הכי חשוב במפגש, ולמעשה כל האתגר שלנו סובב סביבו. ב-ARM, האוגר pc לא מצביע על ההוראה שמתבצעת עכשיו. הוא מצביע קדימה.

הסיבה היא היסטורית וקשורה לצינור העיבוד - pipeline. המעבד הקלאסי של ARM עובד בשלושה שלבים במקביל: הבאה (fetch), פענוח (decode) והרצה (execute). בזמן שהוראה אחת רצה, המעבד כבר הביא את שתי ההוראות הבאות. כדי לשמור על עקביות, ARM הגדיר שכשקוראים את pc מתוך הוראה, הערך שמתקבל הוא כתובת ההוראה הנוכחית ועוד שני מקומות קדימה. במצב ARM כל הוראה היא ארבעה בתים, אז:

pc read = current instruction address + 8

בואו נראה את זה על דוגמה מוחשית. נניח את הקוד הבא:

0x8000:  mov r3, pc     ; what goes into r3?
0x8004:  nop
0x8008:  nop

התשובה איננה 0x8000, ואפילו לא 0x8004. כשההוראה ב-0x8000 רצה, ה-pc כבר קודם קדימה בשתי הוראות, כלומר הוא מצביע על 0x8008. לכן r3 יקבל 0x8008, שהם 0x8000 + 8 בדיוק. זה נראה כמו טעות בהתחלה, אבל זו התנהגות מתועדת וקבועה. חוקר שלא מכיר אותה יחשב את הערך הלא נכון ויתקע.

       The pipeline while the instruction at 0x8000 executes:
       +-----------+   +-----------+   +-----------+
execute| 0x8000    |   |           |   |           |
       | mov r3,pc |   |           |   |           |
       +-----------+   +-----------+   +-----------+
decode                 | 0x8004    |
                       | nop       |
                       +-----------+
fetch                                  | 0x8008    |  <-- pc points here
                                       | nop       |
                                       +-----------+

הכלל המעשי: כל פעם שאתם רואים mov r3, pc (או כל קריאה של pc) במצב ARM, חשבו כתובת ההוראה ועוד שמונה. זה בדיוק מה ש-key1 באתגר עושה, ותכף נראה.

מצב Thumb - Thumb State

ל-ARM יש מצב הרצה שני שנקרא Thumb. במצב הזה ההוראות באורך שני בתים בלבד (במקום ארבעה), מה שמצמצם משמעותית את גודל הקוד - שימושי מאוד במכשירים משובצים עם זיכרון מוגבל. אותו מעבד יכול לרוץ חלק מהזמן במצב ARM וחלק מהזמן במצב Thumb, ולעבור ביניהם תוך כדי ריצה.

איך עוברים בין המצבים? הטריק הוא הביט הכי נמוך של כתובת הקפיצה. כשקופצים עם הוראה כמו bx לכתובת אי-זוגית (הביט הנמוך דלוק), המעבד לא באמת קופץ לכתובת אי-זוגית - הוא מפרש את הביט הזה כ"עבור למצב Thumb", מכבה אותו, וממשיך לרוץ מהכתובת הזוגית במצב Thumb. קפיצה לכתובת זוגית מחזירה למצב ARM.

bx r6   where   r6 = 0x8d05  (odd)
                       |
                       +--> jump to address 0x8d04 + switch to Thumb state

והנה הפרט שסוגר את האתגר: במצב Thumb, קריאת ה-pc היא כתובת ההוראה ועוד ארבע, לא ועוד שמונה. למה? כי כאן ההוראות בנות שני בתים, אז שתי הוראות קדימה זה 2 כפול 2 שהם 4. מי שיזכור רק את "pc+8" של מצב ARM ולא ישים לב שהקוד עבר ל-Thumb, יחשב שוב את הערך הלא נכון. זה בדיוק המלכוד ב-key2.

הרצת בינארי זר - qemu-user

יש לנו בעיה מעשית: הבינארי של leg מהודר ל-ARM, והמחשב שלנו הוא x86. איך מריצים קוד ARM על מעבד אינטל? התשובה היא אמולטור. הכלי qemu-user מדמה מעבד ARM (או MIPS, או כל ארכיטקטורה נתמכת) ומריץ את התוכנית בתוך מערכת ההפעלה הרגילה שלנו, בלי צורך במכונה וירטואלית מלאה.

ההתקנה (על דביאן/אובונטו):

sudo apt install qemu-user qemu-user-static gdb-multiarch
# cross-platform compilers, useful for compiling ARM and MIPS code ourselves
sudo apt install gcc-arm-linux-gnueabi gcc-mips-linux-gnu

הרצה של בינארי ARM. אם הבינארי קושר סטטית (static), פשוט מריצים אותו:

qemu-arm ./leg

אם הוא קושר דינמית, האמולטור צריך לדעת איפה נמצאות ספריות ה-ARM (כמו libc של ARM), אז מצביעים עליהן עם -L:

qemu-arm -L /usr/arm-linux-gnueabi ./leg

יש עוד אפשרות נוחה: החבילה qemu-user-static רושמת את עצמה במנגנון binfmt_misc של הkernel, וכך אפשר להריץ בינארי ARM כאילו הוא מקומי, פשוט עם ./leg, והkernel יפעיל את qemu מאחורי הקלעים. זה הכרחי כשעובדים בתוך chroot של מערכת קבצים שלמה של ARM.

ניפוי באגים - gdb-multiarch

הכלי gdb הרגיל מכיר רק את הארכיטקטורה של המכונה. כדי לנפות באגים בקוד ARM צריך גרסה שמכירה כמה ארכיטקטורות: gdb-multiarch. השילוב עם qemu עובד דרך שרת דיבוג מובנה.

מריצים את הבינארי תחת qemu עם דגל -g ומספר פורט. qemu יעצור לפני ההוראה הראשונה ויחכה שנתחבר:

qemu-arm -g 1234 ./leg

בטרמינל שני, פותחים את gdb-multiarch, אומרים לו מה הארכיטקטורה, ומתחברים לפורט:

gdb-multiarch ./leg
(gdb) set architecture arm
(gdb) target remote localhost:1234
(gdb) break main
(gdb) continue
(gdb) info registers
(gdb) x/20i $pc

תוספות מוכרות כמו pwndbg ו-gef עובדות מצוין מעל gdb-multiarch ומציגות לכם אוגרים, מחסנית וקוד בצורה ברורה. לצד הדיבוג החי, לניתוח סטטי אפשר לפרק את הבינארי עם objdump - הוא מזהה את הארכיטקטורה מכותרת ה-ELF לבד:

arm-linux-gnueabi-objdump -d ./leg
# or simply
objdump -d ./leg

בדיוק כך נוצר הקובץ leg.asm שהאתגר נותן לנו: זו פשוט פריקה של הפונקציות ל-ARM אסמבלי.

סקירת MIPS - MIPS Overview

לפני שנצלול ל-leg, נעצור רגע ב-MIPS, כי גם אותו תפגשו בשטח (בעיקר בראוטרים). ל-MIPS יש שלושים ושניים אוגרים, ולכל אחד תפקיד מוסכם עם שם קריא:

אוגר תפקיד
$zero קבוע 0, תמיד
$v0-$v1 ערכי חזרה מפונקציה
$a0-$a3 ארבעת הארגומנטים הראשונים
$t0-$t9 אוגרים זמניים - temporary
$s0-$s7 אוגרים נשמרים - saved
$sp מצביע המחסנית
$ra הreturn address - return address
$gp, $fp מצביע גלובלי ומצביע מסגרת

קריאה לפונקציה נעשית עם jal (jump and link), שממלאת את $ra בדיוק כמו ש-bl ממלאת את lr ב-ARM. החזרה נעשית עם jr $ra. עד כאן דומה מאוד.

מלכוד החריצים המושהים - Branch Delay Slot

יש ל-MIPS תכונה אחת שהיא המקבילה המושלמת לטריק ה-pc+8 של ARM: חריץ ההשהיה - delay slot. הכלל הוא כזה: ההוראה שכתובה מיד אחרי הוראת קפיצה או ענף מתבצעת תמיד, עוד לפני שהקפיצה נכנסת לתוקף. זו לא טעות ולא באג, זו התנהגות מתוכננת שנובעת מהצינור. המעבד כבר הביא את ההוראה שאחרי הקפיצה, אז במקום לזרוק אותה, MIPS פשוט מריץ אותה.

זה מטעה מאוד בקריאת קוד. שימו לב לדוגמה:

    li      $v0, 5      # v0 = 5
    jr      $ra         # we return... but wait
    li      $v0, 7      # the delay slot - runs before the return takes effect!

מי שקורא מלמעלה למטה יחשוב שהפונקציה מחזירה 5. בפועל היא מחזירה 7, כי li $v0, 7 שיושבת בחריץ ההשהיה רצה אחרי ה-jr אבל לפני שהבקרה באמת עוברת. כל מי שמנתח MIPS ולא מודע לחריץ ההשהיה יטעה בקריאה של כמעט כל ענף בקוד.

בנוסף, כדאי לזכור ש-MIPS מגיע בשני סדרי בתים: big-endian (mips) ו-little-endian (mipsel), ותצטרכו לדעת באיזה מהם הבינארי מהודר. את קוד ה-MIPS מריצים תחת אמולטור בדיוק כמו ARM: qemu-mips או qemu-mipsel.

אתגר leg מ-pwnable.kr - קריאת ARM אמיתי

עכשיו נחבר הכל. אתגר leg מ-pwnable.kr הוא אתגר קריאת ARM טהור. הוא נותן לנו שני קבצים: leg.c (קוד המקור) ו-leg.asm (הפריקה ל-ARM אסמבלי). התוכנית מבקשת מספר, ובודקת אם הוא שווה לסכום של שלוש פונקציות קטנות: key1() + key2() + key3(). אם המספר נכון - היא מדפיסה את הדגל.

הקוד המקורי נראה בערך כך:

int key1(){ asm("mov r3, pc\n"); }
int key2(){ asm("push {r6}\n add r6, pc, $1\n bx r6\n .code 16\n mov r3, pc\n add r3, $0x4\n push {r3}\n pop {pc}\n .code 32\n pop {r6}\n mov r3, r6\n"); }
int key3(){ asm("mov r3, lr\n"); }

int main(){
    int key = 0;
    scanf("%d", &key);
    if( (key1() + key2() + key3()) == key )
        /* open("flag") ... */ ;
}

כל פונקציה כותבת ערך ל-r3, וה-r3 הזה מוחזר דרך r0. כל האתגר הוא לחשב מה כל פונקציה מחזירה - וכאן נכנסות בדיוק שלוש התופעות שלמדנו: pc+8 במצב ARM, pc+4 במצב Thumb, וה-lr בתור כתובת חזרה.

בואו נפתור את הקלה מבין השלוש כדוגמה, ואת השתיים האחרות תשלימו בתרגול. הנה הפריקה של key1:

key1:
   0x00008cd4:  push {r11}
   0x00008cd8:  add  r11, sp, #0
   0x00008cdc:  mov  r3, pc      ; <-- this is where it all happens
   0x00008ce0:  mov  r0, r3      ; return value
   0x00008ce4:  ...

ההוראה mov r3, pc יושבת ב-0x8cdc, ואנחנו במצב ARM. לפי הכלל, ה-pc שנקרא הוא כתובת ההוראה ועוד שמונה:

key1 = 0x8cdc + 8 = 0x8ce4

זהו. key1 מחזירה 0x8ce4. שימו לב כמה קל היה לטעות ולכתוב 0x8cdc או 0x8ce0 - כל האתגר בנוי כדי לתפוס בדיוק את הטעות הזו.

לגבי שתי האחרות, אלו הרמזים: key2 מבצעת add r6, pc, #1 ואז bx r6, כלומר היא מכריחה את המעבד לעבור למצב Thumb (זוכרים את הביט האי-זוגי?), ורק שם היא קוראת את ה-pc - אז חובה להשתמש בכלל של Thumb, pc+4, ולא pc+8. key3 עושה mov r3, lr, כלומר היא מחזירה את הreturn address שלה - ההוראה שמיד אחרי ה-bl key3 שב-main. את החישוב המלא של שתי אלו, ואת הסכום הסופי שפותח את הדגל, תעשו בתרגול ותוכלו לאמת מול הפתרון.

סיכום

  • ARM ו-MIPS הם מעבדי RISC - הוראות באורך קבוע, ארכיטקטורת load/store, והרבה אוגרים. הם רצים כמעט בכל מכשיר משובץ ו-IoT.
  • ב-ARM הארגומנטים עוברים ב-r0-r3, ערך החזרה ב-r0, והreturn address נשמרת ב-lr על ידי bl (לא במחסנית).
  • טריק ה-pc+8: במצב ARM, קריאת pc מחזירה את כתובת ההוראה ועוד שמונה, בגלל צינור העיבוד.
  • מצב Thumb משתמש בהוראות בנות שני בתים, עוברים אליו עם קפיצה לכתובת אי-זוגית, ובו קריאת pc מחזירה כתובת ההוראה ועוד ארבע.
  • הכלי qemu-user מריץ בינאריי ARM/MIPS על מכונת x86, ו-gdb-multiarch (עם qemu -g) מנפה אותם באגים מרחוק.
  • ב-MIPS הזהרו מחריץ ההשהיה - branch delay slot: ההוראה שאחרי הקפיצה רצה תמיד.
  • אתגר leg הוא קריאת שלוש פונקציות ARM זעירות שכל אחת מדגימה תופעה אחרת - key1 את pc+8, key2 את pc+4 של Thumb, ו-key3 את ה-lr.