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. ההוראה היחידה שחשובה היא:
אנחנו במצב ARM (ההוראות בנות ארבעה בתים), אז ה-pc שנקרא הוא כתובת ההוראה ועוד שמונה, בגלל צינור העיבוד. כלומר:
הערך הזה מועתק ל-r0 ב-mov r0, r3, ולכן זהו ערך החזרה של key1. בעשרוני:
למה זה עבד: 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 הן ארבעה בתים:
שלב 4 - התוספת. adds r3, #4 מוסיפה עוד ארבע:
שלב 5 - ההחזרה. push {r3} ואז pop {pc} בעצם קופצים ל-0x8d0c (זוגי, אז חוזרים למצב ARM). שם ההמשך מעתיק r3 ל-r6 ואז ל-r0, כך ש-key2 מחזירה:
למה זה עבד: כל הטריק הוא לזכור שהמעבד עבר ל-Thumb לפני קריאת ה-pc, ולכן משתמשים ב-pc+4 ולא ב-pc+8. מי שישתמש בטעות ב-pc+8 יחשב 0x8d04 + 8 = 0x8d0c, ואחרי adds r3, #4 יגיע ל-0x8d10 - כלומר יפספס בארבעה בתים. הדרך הבטוחה: זהו את המצב (ARM או Thumb) לפני כל קריאת pc, והחילו את הכלל הנכון.
פתרון תרגיל 4 - key3 מחזירה 0x8d80¶
הפונקציה key3 מחזירה את הreturn address שלה:
הערך של lr נקבע על ידי ה-bl שקרא ל-key3 מתוך main:
הוראת bl שומרת ב-lr את הכתובת של ההוראה שמיד אחריה. ה-bl יושבת ב-0x8d7c, אז:
למה זה עבד: ב-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 בעשרוני. זהו המפתח.
מזינים אותו לתוכנית:
התוכנית מזהה שהמספר תואם ל-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: תופעת צינור שאתם חייבים לקחת בחשבון כשקוראים את הקוד, אחרת תחשבו את התוצאה הלא נכונה.