לדלג לתוכן

8.2 ארכיטקטורות ARM ו MIPS פתרון

פתרון - ארכיטקטורות ARM ו-MIPS

הפתרון עובר על כל שלוש הפונקציות של leg שלב אחר שלב, מסביר בדיוק למה כל ערך יוצא כמו שהוא יוצא, מרכיב את המפתח, ומראה איך מאמתים את הכל מקומית תחת qemu. בסוף נפתור גם את תרגיל ה-MIPS.

פתרון תרגיל 1 - סביבת עבודה

אחרי sudo apt install qemu-user qemu-user-static gdb-multiarch gcc-arm-linux-gnueabi, הדרה והרצה של תוכנית ARM נראות כך:

$ cat > hello.c <<'EOF'
#include <stdio.h>
int main(){ printf("hello from ARM\n"); return 0; }
EOF
$ arm-linux-gnueabi-gcc -static -o hello_arm hello.c
$ file hello_arm
hello_arm: ELF 32-bit LSB executable, ARM, EABI5 ...
$ qemu-arm ./hello_arm
hello from ARM

קוד ARM רץ על מעבד אינטל דרך qemu. מכאן והלאה כל בינארי ARM זמין לנו להרצה ולניפוי באגים.

פתרון תרגיל 2 - key1 מחזירה 0x8ce4

הפונקציה key1 היא הדגמה נקייה של טריק ה-pc+8 במצב ARM. ההוראה היחידה שחשובה היא:

   0x00008cdc <+8>:   mov  r3, pc

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

key1 = 0x8cdc + 8 = 0x8ce4

הערך הזה מועתק ל-r0 ב-mov r0, r3, ולכן זהו ערך החזרה של key1. בעשרוני:

0x8ce4 = 36068

למה זה עבד: pc ב-ARM תמיד מצביע שתי הוראות קדימה. במצב ARM שתי הוראות זה שמונה בתים, ומכאן ה-8. לו היינו כותבים את כתובת ה-mov עצמה (0x8cdc) היינו טועים בשמונה - וזו בדיוק הטעות שהאתגר מנסה לתפוס.

פתרון תרגיל 3 - key2 מחזירה 0x8d0c

הפונקציה key2 הרבה יותר ערמומית, כי היא עוברת למצב Thumb באמצע. נעקוב שלב אחר שלב.

   0x00008cfc <+12>:  add   r6, pc, #1
   0x00008d00 <+16>:  bx    r6
   0x00008d04 <+20>:  mov   r3, pc     ; from here we are in Thumb mode
   0x00008d06 <+22>:  adds  r3, #4
   0x00008d08 <+24>:  push  {r3}
   0x00008d0a <+26>:  pop   {pc}

שלב 1 - הכנת הקפיצה. ב-0x8cfc אנחנו עדיין במצב ARM, אז pc שנקרא הוא 0x8cfc + 8 = 0x8d04. ההוראה מוסיפה 1, אז r6 = 0x8d05. הביט הנמוך דלוק.

שלב 2 - המעבר ל-Thumb. bx r6 קופצת ל-r6. הביט הנמוך הדלוק אומר למעבד "עבור למצב Thumb", הוא מכבה אותו, וממשיך לרוץ מ-0x8d04 - אבל עכשיו כהוראות Thumb בנות שני בתים.

שלב 3 - קריאת ה-pc במצב Thumb. וזה הלב: ההוראה mov r3, pc ב-0x8d04 רצה עכשיו בכללי Thumb. במצב הזה קריאת pc היא כתובת ההוראה ועוד ארבע (לא ועוד שמונה), כי שתי הוראות Thumb הן ארבעה בתים:

r3 = 0x8d04 + 4 = 0x8d08

שלב 4 - התוספת. adds r3, #4 מוסיפה עוד ארבע:

r3 = 0x8d08 + 4 = 0x8d0c

שלב 5 - ההחזרה. push {r3} ואז pop {pc} בעצם קופצים ל-0x8d0c (זוגי, אז חוזרים למצב ARM). שם ההמשך מעתיק r3 ל-r6 ואז ל-r0, כך ש-key2 מחזירה:

key2 = 0x8d0c = 36108

למה זה עבד: כל הטריק הוא לזכור שהמעבד עבר ל-Thumb לפני קריאת ה-pc, ולכן משתמשים ב-pc+4 ולא ב-pc+8. מי שישתמש בטעות ב-pc+8 יחשב 0x8d04 + 8 = 0x8d0c, ואחרי adds r3, #4 יגיע ל-0x8d10 - כלומר יפספס בארבעה בתים. הדרך הבטוחה: זהו את המצב (ARM או Thumb) לפני כל קריאת pc, והחילו את הכלל הנכון.

פתרון תרגיל 4 - key3 מחזירה 0x8d80

הפונקציה key3 מחזירה את הreturn address שלה:

key3:
   0x00008d28 <+8>:  mov  r3, lr

הערך של lr נקבע על ידי ה-bl שקרא ל-key3 מתוך main:

   0x00008d7c <+...>:  bl   0x8d20 <key3>
   0x00008d80 <+...>:  mov  r3, r0

הוראת bl שומרת ב-lr את הכתובת של ההוראה שמיד אחריה. ה-bl יושבת ב-0x8d7c, אז:

lr = 0x8d80
key3 = 0x8d80 = 36224

למה זה עבד: ב-ARM הreturn address לא נשמרת במחסנית אלא ב-lr. פונקציה שמעתיקה את lr לאוגר אחר בעצם מחזירה את הכתובת שאליה היא הייתה חוזרת - וזו הכתובת של ההוראה שאחרי ה-bl שקרא לה.

פתרון תרגיל 5 - המפתח והדגל

מחברים את השלושה:

key1 = 0x8ce4 = 36068
key2 = 0x8d0c = 36108
key3 = 0x8d80 = 36224
-----------------------------
sum = 0x8ce4 + 0x8d0c + 0x8d80 = 0x1a770 = 108400

בדיקה מהירה בהקסדצימלי: 0x8ce4 + 0x8d0c = 0x119f0, ו-0x119f0 + 0x8d80 = 0x1a770, שהוא 108400 בעשרוני. זהו המפתח.

מזינים אותו לתוכנית:

$ ./leg
Daddy has very strong arm! : 108400
Congratz!
My daddy has a lot of ARMv5te muscle!

התוכנית מזהה שהמספר תואם ל-key1()+key2()+key3(), פותחת את הקובץ flag ומדפיסה אותו.

אימות מקומי מלא תחת qemu (למקרה שרוצים לוודא בלי להתחבר לשרת):

# compile the challenge's source for ARM
arm-linux-gnueabi-gcc -static -o leg leg.c

# verify the addresses against our disassembly
arm-linux-gnueabi-objdump -d leg | sed -n '/<key1>:/,/<main>:/p'

# feed in the key
echo "108400" | qemu-arm ./leg

אזהרה חשובה: 108400 הוא הערך של הבינארי המקורי מ-pwnable.kr, שממנו נגזר ה-leg.asm שקיבלתם. אם תדרו מחדש עם מהדר או דגלים אחרים, הכתובות עשויות לזוז וה-key ישתנה בהתאם. במקרה כזה חזרו על החישוב (pc+8 ל-key1, pc+4 של Thumb ל-key2, ו-lr ל-key3) מול ה-objdump החדש שלכם. הטכניקה זהה, רק המספרים משתנים.

אם רוצים לראות את זה חי ב-gdb-multiarch, מריצים תחת qemu עם שרת דיבוג ועוצרים על כל key:

qemu-arm -g 1234 ./leg &
gdb-multiarch ./leg
(gdb) set architecture arm
(gdb) target remote localhost:1234
(gdb) break key1
(gdb) break key2
(gdb) break key3
(gdb) continue
# at every stop, run until the bx lr and check:
(gdb) finish
(gdb) print/x $r0     # this is the function's return value

תראו במו עיניכם ש-$r0 יוצא 0x8ce4, 0x8d0c ו-0x8d80 בהתאמה.

פתרון תרגיל 6 - חריץ ההשהיה מחזיר 7

get_val:
    li      $v0, 5      # v0 = 5
    jr      $ra         # start returning...
    li      $v0, 7      # delay slot - runs before the return takes effect

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

למה זה עבד ואיך להכליל: חריץ ההשהיה הוא תוצר של צינור העיבוד של MIPS - המעבד כבר הביא את ההוראה שאחרי הקפיצה, אז במקום לזרוק אותה הוא מריץ אותה. בקריאת MIPS תמיד בדקו את ההוראה שמתחת לכל ענף או קפיצה (b, beq, jr, jal וכו') - היא חלק מהזרימה שלפני הקפיצה, לא אחריה. זו בדיוק אותה משפחת מלכודים כמו pc+8 של ARM: תופעת צינור שאתם חייבים לקחת בחשבון כשקוראים את הקוד, אחרת תחשבו את התוצאה הלא נכונה.