הנדסה לאחור
למה להשתמש ב-NotHoly להנדסה הפוכה
השאירו את Ghidra, radare2 או IDA. הדביקו את פלט ה-decompiler והמודל מסביר אותו וכותב את הסקריפט, בלי לסרב. triage סטטי למבט ראשון.
לקמפל בדיקת רישיון לדוגמה ב-C בשתי דרכים, ואז למצוא ב-Ghidra את המחרוזת, לעקוב אחרי ההפניות ולהשוות את הדקומפיילר לקוד המקור.
קל יותר ללמוד הנדסה לאחור על קוד שכתבת בעצמך. ידוע מה התוכנית אמורה לעשות, ולכן אפשר לבדוק כל טענה של הכלי לגביה. המאמר הזה עובד בשיטה הזו: כותבים תוכנית C קטנה עם בדיקת רישיון לדוגמה, מקמפלים אותה פעמיים ופותחים את שתי הגרסאות ב-Ghidra. אחר כך משווים את מה שהדקומפיילר מציג לקוד שהוקלד.
כשהפלט של הדקומפיילר מונח ליד הקוד שלך, לומדים מהר מה הכלי מבין נכון, מה הוא מנחש ומה הולך לאיבוד כשהקומפיילר מבצע אופטימיזציה. זה שווה יותר משינון תפריטים.
אם קריאת קוד מכונה היא רעיון חדש, כדאי לקרוא קודם את מהי הנדסה לאחור?. לא צריך ניסיון קודם באסמבלי. כן צריך טרמינל וכשעה פנויה.
Ghidra הוא framework להנדסה לאחור של תוכנה שפיתחה ה-U.S. National Security Agency, והוא מתפרסם ב-GitHub במאגר NationalSecurityAgency/ghidra. הוא רץ על Windows, macOS ו-Linux. עבור תוכנית מקומפלת הוא יודע לעשות ארבעה דברים שרלוונטיים כאן:
Ghidra מפיק קירוב, ואת קוד המקור המקורי הוא לא משחזר. הצורה של הפסאודו־קוד בדרך כלל קרובה, אבל שמות, טיפוסים וחלק מזרימת הבקרה יכולים להיות שונים ממה שכתבת. המעבדה הזו בנויה כדי לחשוף את הפער הזה.
אפשר להוריד בינארי לדוגמה מפורום ולהתחיל להסתכל. לא לעשות את זה. בינאריים לא מוכרים יכולים להיות נוזקה, ובלי לדעת מהי התשובה הנכונה אין דרך לבדוק את הפלט של הכלי.
תוכנית שמקמפלים לבד נותנת שלושה דברים:
check() משווה את הארגומנט אל "LAB-1234".ok או no. אין קריאות רשת ואין כתיבת קבצים.כל המעבדה נשארת על המחשב שלך. הניתוח הוא של קובץ שלך, וזה הסקופ היחיד שהתרגיל הזה צריך.
gcc דרך מנהל החבילות. ב-macOS, הכלי clang מתוך כלי שורת הפקודה של Xcode מתאים למעבדה הזו, והדגלים זהים. ב-Windows, להשתמש ב-WSL או בקונטיינר עם GCC.sha256sum. הכלי כלול ב-Git Bash ב-Windows.יש להתקין את ה-JDK שגרסת Ghidra דורשת, ולא לסמוך על מדריך ישן בעניין המספר הזה. כשבדקתי את ה-README של המאגר ואת מדריך Getting Started בתאריך הכתיבה, שניהם דרשו JDK 25 של 64-bit, וה-README קישר ל-builds של Eclipse Temurin. צריך לקרוא את ה-README או את ה-release notes של הגרסה המדויקת שהורדת ולהשתמש ב-JDK שהם מציינים. אי־התאמה בגרסת Java היא סיבה נפוצה לכך ש-Ghidra לא עולה.
להוריד את Ghidra רק מדף ה-releases הרשמי תחת github.com/NationalSecurityAgency/ghidra. המאגר מקשר לקובצי הגרסה. יש לחלץ את הקובץ לתיקייה חדשה: מדריך Getting Started אומר לא לחלץ את Ghidra על גבי התקנה קיימת. מפעילים אותו עם ghidraRun ב-Linux וב-macOS, או עם ghidraRun.bat ב-Windows.
לפני שמחלצים משהו, בודקים את הקובץ מול ערך SHA-256 שפורסם. את הפקודה מריצים בתיקייה שבה נשמר הקובץ:
sha256sum ghidra_*.zipמשווים את ה-hash שהודפס לערך SHA-256 ש-GitHub מציג ליד הקובץ בדף הגרסה, או לערך שהפרויקט מפרסם. ה-hash חייב להיות זהה בדיוק. אם אין SHA-256 מפורסם לקובץ, צריך לרשום את זה ביומן המעבדה. hash שהועתק מ-mirror אקראי הוא לא checksum של המפרסם.
בדיקת hash מוכיחה רק שהקובץ תואם למה שהמפרסם רשם. היא לא מוכיחה שאפשר לסמוך על המפרסם. לכן משתמשים במקור הרשמי ושומרים ביומן את שם הקובץ ואת ה-hash.
יוצרים תיקייה ריקה למעבדה. קובץ המקור והבינאריים נשמרים בה יחד, ואת פרויקטי Ghidra מחזיקים מחוץ לה כדי שלא יעמיסו עליה.
mkdir -p ghidra-lab && cd ghidra-lab
cat > toy.c <<'EOF'
#include <stdio.h>
#include <string.h>
int check(const char *k){ return strcmp(k, "LAB-1234") == 0; }
int main(int c, char **v){ if(c>1 && check(v[1])) puts("ok"); else puts("no"); return 0; }
EOFהתוכנית קוראת ארגומנט אחד משורת הפקודה. אם הארגומנט הוא LAB-1234, היא מדפיסה ok. אחרת היא מדפיסה no. אין לבדיקה הזו שום ערך אבטחתי: זו מחרוזת קבועה בקובץ שכל אחד יכול לקרוא, וזו בדיוק הכוונה. תוך דקות ספורות המחרוזת תימצא בבינארי.
רושמים את ה-hash של קובץ המקור, כדי שאפשר יהיה להוכיח אחר כך איזה קובץ נותח:
sha256sum toy.cעל המחשב שבו השתמשתי, הפלט היה:
d9746d8fb118fa954a46b4d501ddf9b0ed86e986e7c99adb05e4a1a60ca0071d toy.cה-hash שלך יהיה שונה אם בקובץ יש רווחים או סיומות שורה אחרים.
בונים את אותו קוד מקור לשני בינאריים עם הגדרות שונות:
gcc -O0 -g -o toy_debug toy.c
gcc -O2 -s -o toy_stripped toy.cלכל דגל תפקיד אחר. כולם מתוארים במדריך GCC, שכדאי לקרוא פעם אחת:
-O0 מכבה את רוב האופטימיזציות. זו ברירת המחדל, ומדריך GCC מתאר אותה כהגדרה ששומרת על דיבוג צפוי. הקוד צמוד למקור.-O2 מפעיל את רוב האופטימיזציות שלא מחליפות גודל במהירות. לפי המדריך הוא מפעיל גם inlining של פונקציות קטנות, כולל כאלה שלא הוגדרו inline. הקוד יכול להיראות שונה מאוד מהמקור.-g מוסיף מידע דיבוג בפורמט הטבעי של מערכת ההפעלה. Ghidra יודע לקרוא חלק ממנו, ודיבאגרים משתמשים בכולו.-s מסיר את טבלת הסימבולים מהפלט בזמן הקישור. שמות הפונקציות check וגם main כבר לא נשמרים בקובץ.מה שהופך את המעבדה לשימושית הוא שני בינאריים מאותו מקור עם דגלים שונים. צריך לשמור את שניהם.
מריצים אותם קודם, לפני שפותחים את Ghidra. תוכנית שלא הורצה היא תוכנית שעדיין לא מבינים.
./toy_debug LAB-1234
./toy_debug wrong
./toy_debug
./toy_stripped LAB-1234
./toy_stripped wrongהרצתי את זה בתוך קונטיינר Debian עם GCC 14.2.0 על מארח x86-64. הפלט היה:
ok
no
no
ok
noשני ה-builds מתנהגים אותו דבר. strip ואופטימיזציה לא שינו את הלוגיקה, וזה הצפוי. אם הפלטים שלך שונים, צריך לעצור ולברר למה לפני שממשיכים.
לפני Ghidra, כדאי לראות מה שמור בכל קובץ. מספיקים שני כלים: nm מציג סימבולים, strings מציג טקסט קריא.
nm toy_debug | grep -E " (check|main)$"
nm toy_stripped
strings toy_stripped | grep LAB
ls -l toy_debug toy_strippedעל המחשב שבו השתמשתי, הפלט היה:
0000000000001149 T check
0000000000001175 T main
nm: toy_stripped: no symbols
LAB-1234
-rwxr-xr-x 1 root root 17384 Nov 8 09:17 toy_debug
-rwxr-xr-x 1 root root 14480 Nov 8 09:17 toy_strippedכדאי לקרוא את השורות האלה בעיון:
check וגם main. השמות האלה הגיעו מקוד המקור שלך.nm אין מה להציג.LAB-1234 נשארת בשני הקבצים. מחרוזת היא נתון, ו-strip לא מסיר נתונים שהתוכנית צריכה בזמן ריצה. המחרוזת נמצאת בקובץ כקבוע, ובגללה קל להתחיל עם המעבדה הזו.עכשיו דיסאסמבלי של check ב-build עם הדיבוג:
objdump -d -M intel toy_debug | grep -A14 "<check>:"הפלט נראה כך:
0000000000001149 <check>:
1149: 55 push rbp
114a: 48 89 e5 mov rbp,rsp
114d: 48 83 ec 10 sub rsp,0x10
1151: 48 89 7d f8 mov QWORD PTR [rbp-0x8],rdi
1155: 48 8b 45 f8 mov rax,QWORD PTR [rbp-0x8]
1159: 48 8d 15 a4 0e 00 00 lea rdx,[rip+0xea4] # 2004 <_IO_stdin_used+0x4>
1160: 48 89 d6 mov rsi,rdx
1163: 48 89 c7 mov rdi,rax
1166: e8 d5 fe ff ff call 1040 <strcmp@plt>
116b: 85 c0 test eax,eax
116d: 0f 94 c0 sete al
1170: 0f b6 c0 movzx eax,al
1173: c9 leave
1174: c3 retאפשר לראות את קוד המקור בהוראות האלה: קריאה לפונקציה strcmp, בדיקה של ערך ההחזרה, והוראת sete שהופכת "שווה לאפס" ל-0 או 1. כדאי לזכור את זה בזמן הקריאה בדקומפיילר.
עכשיו ה-build המאופטם. הרצתי דיסאסמבלי על toy_stripped באותה דרך. הקומפיילר הכניס את ההשוואה ישירות אל main, שיושבת בכתובת 0x1060 אצלי: cmp edi,0x1 עבור הבדיקה c>1, אחר כך mov rdi,[rsi+0x8] כדי לטעון את v[1], ואז call strcmp@plt. הוא עדיין שמר עותק נפרד של check בכתובת 0x1190, כי הפונקציה לא static וקובץ אחר יכול לקרוא לה. הכתובות ישתנו לפי ה-toolchain, אז זו מפה של מה לחפש ואין צורך שהמספרים יתאימו.
התוויות המדויקות בתפריטים תלויות בגרסת Ghidra, ולא הרצתי את ה-GUI של Ghidra על המחשב שבו נבדק המאמר. השלבים כאן מבוססים על התיעוד הרשמי ועל איך שהממשק נראה בדרך כלל. צריך לבדוק כל שם מול הגרסה שלך. בסוף כל שלב יש לצלם את המסך שלך, ולא למחזר צילומים ממדריך.
ghidraRun (או ghidraRun.bat).toy-lab. לשמור אותו בתיקייה שבשליטתך.toy_debug. לאשר את הפורמט שזוהה אם הוא מתאים למערכת שלך. קובץ שנבנה על x86-64 Linux אמור להופיע כ-ELF עם מעבד x86.toy_stripped. עכשיו יש בפרויקט שתי רשומות.מחכים לסיום הניתוח. Ghidra מציג התקדמות בשורה התחתונה. לא משנים שום שם לפני שהניתוח הסתיים.
מתחילים עם toy_stripped, כי זה המקרה הקשה יותר. אחר כך משווים מול toy_debug.
LAB-1234 ברשימה. ללחוץ עליה לחיצה כפולה כדי לקפוץ לכתובת שלה ב-listing.ב-build עם הדיבוג, Ghidra אמור להציג גם את שם הפונקציה check מטבלת הסימבולים. ב-build שעבר strip יופיע שם שנוצר אוטומטית, כמו FUN_00101190. המספר תלוי ב-build, אז צריך להשתמש בשם ש-Ghidra מציג אצלך על המסך.
אותו דבר עם המחרוזות "ok" וגם "no". שתיהן בשימוש בתוך main. מעקב אחריהן מתוך main נותן את אותה תמונה מהצד השני.
פותחים את חלונית הדקומפיילר עבור הפונקציה שמשווה את המחרוזת. מניחים את toy.c לידה בחלון אחר.
זה סוג התוצאה שכדאי לחפש. הרשימה נועדה להסביר איך לקרוא את הפלט, והיא לא פלט של גרסת Ghidra מסוימת:
strcmp, או לסימבול שהכלי מתייג בשם strcmp.עכשיו רושמים את ההבדלים. הבדלים נפוצים:
char * בתור char *, או להשתמש בטיפוס רופף יותר כמו undefined8. ייתכן שהוא לא יודע שהקלט הוא const.k שלך יופיע בתור param_1 או placeholder דומה.strcmp(...) == 0 עשוי להופיע כהשוואה ואחריה return מותנה. המשמעות זהה, הצורה שונה.main. הבדיקה c > 1 שלך הופכת להשוואה על מספר הארגומנטים. כדאי לשים לב ל-off-by-one: argv[0] הוא שם התוכנית, בעוד v[1] הוא הארגומנט האמיתי הראשון.לא נותנים ציון לדקומפיילר. מסמנים איפה הסיפור שלו והסיפור שלך תואמים, ואיפה הם מתפצלים. כשהם מתפצלים, חוזרים לדיסאסמבלי ולמקור ומכריעים מי צודק.
עכשיו החלק שהופך בינארי אחרי strip לקריא.
L. לקרוא לו input.check_input.main, לשנות את שם המשתנה של מערך הארגומנטים לשם argv ואת שם המונה לשם argc.Ghidra שומר את השמות האלה בפרויקט שלך. הבינארי בדיסק לא משתנה. כלומר, מתעדים את הקריאה שלך ולא עורכים את הקובץ שקומפל.
הקוד נעשה הרבה יותר קל לקריאה אחרי שינוי השמות. בהתחלה שמות המשתנים היו ניחושים, ועכשיו הם תואמים למקור שלך. כך אנשי הנדסה לאחור עובדים כל יום, וזו גם הסיבה שצריך להתייחס לשמות בתוכנית שעברה דקומפילציה כהשערות עד שהם נבדקו.
מניחים זו לצד זו את הדקומפילציה של ה-build עם הדיבוג ושל ה-build שעבר strip. ביומן צריך לענות על השאלות האלה:
-O2 עשה inlining של check בתוך main, או שמר פונקציה נפרדת? לחפש ב-listing מקום קריאה נפרד של strcmp וגם ret נפרד.משפט אחד לכל תשובה. אם אי אפשר לענות על אחת מהן, גם זה מידע. חוזרים ל-listing ומוצאים את ההוראה שמבלבלת.
לדלג על שלב ההרצה. מי שפותח את הבינארי ב-Ghidra לפני שהריץ אותו לא יודע מהי ההתנהגות הנכונה. קודם מריצים ורושמים את הפלטים.
לסמוך על השמות של הדקומפיילר. שם כמו FUN_00101190 או local_18 הוא placeholder. שינוי השם הוא טענה שלך, והיא צריכה ראיות.
לבלבל בין builds. לבינארי עם הדיבוג ולבינארי שעבר strip יש כתובות שונות ושמות שונים. יש לשמור את שמות הקבצים ביומן, ולבדוק באיזו רשומה בפרויקט נמצאים לפני כל שינוי שם.
דוגמה ממקור לא מהימן. הקובץ שקימפלת בעצמך הוא נקודת ההתחלה הבטוחה. גם אחרי שמתרגלים כאן, הצעד הבא הוא עדיין הקוד שלך או דוגמה ממקור שנבדק, ואת המקור צריך לאמת קודם. המאמר על hash של קבצים והאתיקה של VirusTotal מסביר למה hash לבדו לא סוגר את השאלה.
להתייחס למעבדה כאל בדיקת אבטחה. בדיקת המחרוזת כאן שבורה לגמרי, ומציאתה לא מוכיחה דבר על תוכנה אמיתית. קוד רישוי והגנה אמיתי קשה יותר, והניתוח שלו חייב להישאר בתוך הסקופ שמותר לך לעבוד בו.
לשכוח את גרסת ה-JDK. אם Ghidra לא עולה, בודקים את גרסת Java מול ה-release notes לפני שנוגעים בכל דבר אחר.
אחרי שהדוגמה עובדת, אפשר להקשות את התרגיל בצורה מבוקרת:
"LAB-1234" במחרוזת ארוכה יותר ולקמפל מחדש. למצוא אותה שוב בשני ה-builds.-O0 -s ולהשוות מול -O2 -s. ההבדל מראה כמה האופטימייזר משנה את צורת הקוד בלי להסיר את הלוגיקה.להבנה טובה יותר של דגלי הקומפיילר, המקורות הראשוניים הם הסעיפים על אפשרויות האופטימיזציה והדיבוג במדריך GCC. כדאי לקרוא אותם לפני שמסתמכים על דגל בדוח.
שומרים את ההערות. יומן מעבדה טוב כולל את ה-hash של המקור, פקודות הקימפול, פלטי ההרצה, גרסת Ghidra, גרסת ה-JDK והשמות שנתת, כל אחד עם הראיה שתומכת בו. התיעוד הזה הופך את התרגיל למיומנות שאפשר להשתמש בה בתוכנית הבאה.
הנדסה לאחור
השאירו את Ghidra, radare2 או IDA. הדביקו את פלט ה-decompiler והמודל מסביר אותו וכותב את הסקריפט, בלי לסרב. triage סטטי למבט ראשון.
הנדסה לאחור
מה קומפילציה מאבדת, במה דיסאסמבלר שונה מדקומפיילר, והמגבלות המשפטיות שכדאי להכיר לפני ניתוח של בינארי.