לדלג לתוכן

8.1 קבצים ארוזים ו UPX הרצאה

עד עכשיו הנחנו שהבינארי שאנחנו חוקרים פרוש לפנינו: פותחים אותו ב-objdump או ב-gdb, קוראים את ה-main, ורואים את המחרוזות ב-strings. אבל מה קורה כשמישהו רצה בכוונה שלא נראה כלום? כשפותחים את הבינארי ב-strings ומקבלים ג'יבריש, וב-objdump רואים בלוק קוד זעיר שאין בו שום היגיון? זו נקודת הפתיחה של פרק הreverse engineering לexploit, ובמפגש הזה נתמודד עם הצורה הנפוצה ביותר של הסתרה: קבצים ארוזים (packed), ובראשם הארז הפופולרי מכולם, UPX. נלמד מה ארז עושה, איך מזהים אריזה בלי לרוץ את הקובץ, ואיך פורקים אותו - גם עם כלי מוכן וגם ידנית מהזיכרון.

מה עושה ארז - Packer

ארז לוקח בינארי מהודר רגיל, דוחס או מערבב את הקוד והנתונים שלו, ועוטף את התוצאה בבינארי חדש. הבינארי החדש מכיל שני חלקים: גוש הנתונים הדחוס (הבינארי המקורי, בלתי קריא), ומעטפת קוד קטנה שנקראת stub. כשמריצים את הקובץ הארוז, ה-stub הוא שרץ ראשון: הוא מפרק את הגוש הדחוס אל תוך הזיכרון, משחזר את הבינארי המקורי לגמרי, ואז קופץ אל נקודת הכניסה המקורית שלו וממשיך משם כאילו כלום.

packed file on disk                    process in memory at runtime
+--------------------------+          +--------------------------+
|  ELF header (entry->stub)|          |  stub (runs first)       |
+--------------------------+          +--------------------------+
|  stub - unpacking wrapper|  run      |  original code (unpacked)|
+--------------------------+  ----->  |  .text / .rodata / .data |
|  compressed blob (the    |          |  fully restored to memory|
|  original binary, high   |          |  then jmp to OEP         |
|  entropy)                |          |                          |
+--------------------------+          +--------------------------+

הנקודה החשובה: על הדיסק הבינארי המקורי לא קיים בצורה קריאה. הוא מופיע רק בזיכרון, אחרי שה-stub סיים לרוץ. לכן ניתוח סטטי (strings, objdump) של הקובץ הארוז לא מגלה כמעט כלום, וכדי לראות את האמת צריך או לפרוק את הקובץ, או לתפוס אותו בזיכרון אחרי שהתפרק.

למה בכלל אורזים בינארי

יש כמה מניעים שונים, וכדאי להכיר את כולם כי הם משנים את הגישה:

  • הקטנת גודל. זו המטרה המקורית של UPX (ראשי תיבות של Ultimate Packer for eXecutables). דחיסה יכולה להוריד בינארי לחצי מגודלו, וזה נוח להפצה.
  • הסתרה מפני ניתוח. אצל תוכנות זדוניות זו הסיבה העיקרית: ארז מקשה על אנטי-וירוס לזהות חתימות, ומקשה על חוקר לקרוא את הקוד בלי להריץ אותו.
  • הגנה על קניין רוחני. מפתחים מסחריים אורזים כדי להקשות על reverse engineering של המוצר שלהם.

חשוב להבין: אריזה היא הסתרה, לא הצפנה אמיתית. ה-stub חייב להיות מסוגל לפרוק את הקובץ בלי סוד חיצוני, אחרת הקובץ לא ירוץ. כלומר כל מה שצריך כדי לחשוף את המקור נמצא בתוך הקובץ עצמו. בגלל זה אריזה תמיד ניתנת לפירוק - השאלה היחידה היא כמה עבודה זה ידרוש.

איך מזהים אריזה בלי להריץ

לפני שמנסים לפרוק, קודם צריך לדעת שהקובץ בכלל ארוז, ועדיף במה. יש ארבעה סימנים שמסגירים אריזה כמעט תמיד.

אנטרופיה גבוהה - Entropy

אנטרופיה מודדת עד כמה רצף בתים "אקראי". הנוסחה היא אנטרופיית שאנון: מחשבים את ההסתברות של כל ערך בית (0 עד 255) בגוש, ומחברים. התוצאה נעה בין 0 (בית אחד חוזר על עצמו) ל-8 סיביות לבית (התפלגות אחידה לגמרי, כלומר נראה אקראי).

0.0 ------------------------- 8.0  bits per byte
 |        |           |         |
text    normal code  compressed/encrypted  pure random
~4.5      ~6.0       ~7.9       8.0

קוד מכונה רגיל מכיל הרבה חזרתיות (אותם opcodes, אפסי padding, מחרוזות קריאות), ולכן האנטרופיה שלו סביב 6 סיביות לבית. נתונים דחוסים או מוצפנים נראים כמעט אקראיים, ולכן האנטרופיה שלהם קרובה מאוד ל-8. קפיצה חדה של האנטרופיה בתוך הקובץ, מאזור נמוך לאזור גבוה, היא סימן קלאסי לגוש ארוז.

הכלי binwalk יודע לצייר גרף אנטרופיה:

binwalk -E ./target

ואפשר גם לחשב בעצמנו בסקריפט קטן, בלי תלות בכלים חיצוניים:

#!/usr/bin/env python3
import sys, math, collections

data = open(sys.argv[1], 'rb').read()
block = 4096
for off in range(0, len(data), block):
    chunk = data[off:off + block]
    counts = collections.Counter(chunk)
    ent = -sum((c / len(chunk)) * math.log2(c / len(chunk)) for c in counts.values())
    bar = '#' * int(ent * 4)
    print(f'{off:#08x}  {ent:5.2f}  {bar}')

כשמריצים את זה על קובץ ארוז רואים בבירור: החלק הראשון (ה-stub) באנטרופיה בינונית, ואז קפיצה לאזור רחב שכולו סביב 7.9. זה הגוש הדחוס.

מעט מדי מחרוזות קריאות

בינארי רגיל, אפילו קטן, מלא מחרוזות: הודעות שגיאה, שמות פונקציות, נתיבים, פורמטים. קובץ ארוז כמעט ריק מהן, כי הכול דחוס. אם strings על קובץ בגודל מכובד מחזיר חופן זעום של מחרוזות חסרות היגיון, זה דגל אדום.

חתימת הארז עצמו

הנה הטריק הכי שימושי לזיהוי UPX ספציפית: הכלי משאיר בתוך הקובץ שלוש חתימות טקסט קבועות. פשוט מריצים:

strings ./target | grep -i upx

ומקבלים משהו כמו:

UPX!
$Info: This file is packed with the UPX executable packer http://upx.sf.net $
$Id: UPX 3.96 Copyright (C) 1996-2020 the UPX Team. All Rights Reserved. $

המחרוזת UPX! היא מספר הקסם (magic) של פורמט האריזה, והיא מופיעה בכותרת של כל גוש UPX. שתי השורות האחרות הן חתימות שה-UPX שותל במכוון. אם רואים אותן, אין ספק: הקובץ ארוז ב-UPX, ואפשר לנסות upx -d.

חריגוּת במבנה ה-ELF

בבינארי PE של Windows, אריזת UPX יוצרת סקשנים בשמות UPX0 ו-UPX1 שרואים מיד ב-readelf המקביל. בבינארי ELF של לינוקס התמונה קצת שונה: UPX בדרך כלל משמיט את טבלת הסקשנים לגמרי, כך שהפקודה

readelf -S ./target

מחזירה אזהרה או "אין סקשנים", בעוד ש-

readelf -l ./target

מראה בסך הכול שני מקטעי LOAD גדולים, ונקודת הכניסה יושבת בתוך המקטע השני. בינארי מהודר רגיל, לעומת זאת, תמיד מציג רשימת סקשנים מסודרת (.text, .data, .rodata וכו'). היעדר סקשנים לגמרי הוא בפני עצמו סימן חזק לאריזה.

יש גם כלים ייעודיים לזיהוי ארזים: הכלי die (ראשי תיבות של Detect It Easy) מזהה עשרות ארזים, ו-upx -t פשוט בודק אם הקובץ הוא UPX תקין:

upx -t ./target

נקודת הכניסה המקורית - OEP

לפני שנפרק ידנית צריך להבין מונח מפתח: נקודת הכניסה המקורית - Original Entry Point, בקיצור OEP. זו הכתובת שבה הבינארי המקורי היה מתחיל לרוץ לפני שנארז (בדרך כלל ה-_start שקורא ל-main).

בקובץ הארוז, נקודת הכניסה בכותרת ה-ELF כבר לא מצביעה ל-OEP, אלא לתחילת ה-stub. ה-stub עושה את שלו (פורק את הגוש לזיכרון), ובשורה האחרונה שלו קופץ אל ה-OEP. כלומר הרצף הוא:

packed _start (entry in header) ->  stub unpacks to memory  ->  jmp to OEP  ->  the original program runs

הרעיון של פריקה ידנית הוא בדיוק לתפוס את הרגע הזה: מריצים את הקובץ, נותנים ל-stub לסיים את הפריקה, עוצרים אחרי הקפיצה ל-OEP - וכעת כל הבינארי המקורי שוכן בזיכרון, פרוש וקריא. משם או שמדליפים ממנו את מה שרצינו (מחרוזת, מפתח), או שמשליכים את הזיכרון לקובץ ומשחזרים בינארי שלם.

פריקה אוטומטית - upx -d

כשהקובץ ארוז ב-UPX סטנדרטי, הפריקה טריוויאלית. הדגל -d פורק, ו--o קובע קובץ פלט חדש כדי לא לדרוס את המקור:

upx -d ./target -o ./target.unpacked

וזהו. עכשיו ./target.unpacked הוא הבינארי המקורי לגמרי, עם כל הסקשנים, המחרוזות והסמלים. אפשר להריץ עליו strings, objdump -d, gdb כרגיל.

הסיבה שזה עובד כל כך חלק: כותרת ה-UPX בתוך הקובץ שומרת את כל המידע שהפורק צריך (גודל מקורי, כתובת OEP, פרמטרי דחיסה). upx -d פשוט קורא את הכותרת ומריץ את אלגוריתם הפריקה עצמו. לכן, כל עוד אף אחד לא התעסק עם הקובץ, זו פקודה אחת ונגמר.

פריקה ידנית - dumping at OEP

מתי upx -d לא יספיק? כשמישהו שיבש בכוונה את כותרת ה-UPX (טריק נפוץ ב-CTF: משנים בית אחד ב-magic UPX!, ואז upx -d מסרב לפרוק אף שהקובץ עדיין רץ מצוין). וגם באופן כללי, פריקה ידנית היא מיומנות בסיסית שעובדת מול כל ארז, לא רק UPX. הרעיון: לתת ל-stub לפרוק, ואז לקרוא מהזיכרון.

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

gdb ./target
pwndbg> starti                 # stop at the first instruction of the stub (entry point)
pwndbg> catch syscall write    # the write syscall only happens after unpacking finished
pwndbg> continue
pwndbg> search "FLAG"           # search for the secret across all mapped memory

ברגע שה-write הראשון קרה, כל הקוד והנתונים המקוריים כבר בזיכרון, ו-search (פקודה של pwndbg שסורקת את כל המיפויים) מוצא את המחרוזת שביקשנו. זו הדרך הפשוטה והאמינה ביותר לחלץ סוד מקובץ ארוז.

אם רוצים להשליך את כל הזיכרון לקובץ ולהריץ עליו strings (שימושי כשלא יודעים מראש מה מחפשים), מסתכלים במפת הזיכרון ומשליכים את המקטע הכתיב:

pwndbg> info proc mappings         # see the process's address ranges
pwndbg> dump memory /tmp/dump.bin 0x400000 0x4d0000
strings /tmp/dump.bin | grep -i flag

ולמי שרוצה לשחזר בינארי שלם ולא רק לקרוא ממנו: צריך לעצור בדיוק ב-OEP, להשליך את הזיכרון, ואז לתקן ידנית את כותרות ה-ELF וטבלת הסקשנים כדי לקבל קובץ שרץ. זו עבודה עדינה, ולרוב עדיף להיעזר בכלים אוטומטיים כמו unipacker או מסגרת האמולציה qiling שיודעים לזהות OEP ולהשליך אוטומטית. לצורך קריאת דגל, חיפוש בזיכרון מספיק לגמרי.

דוגמה מקצה לקצה

בואו נבנה קובץ ארוז משלנו ונעבור עליו את כל התהליך. קודם תוכנית קטנה שמחזיקה סוד ב-.rodata אבל לא מדפיסה אותו, בדיוק כמו אתגר טיפוסי:

// secret.c
#include <stdio.h>
#include <string.h>

int main(void) {
    char buf[64];
    strcpy(buf, "FLAG{this_string_was_hidden_by_upx}");
    puts("packed demo running");
    printf("len=%zu\n", strlen(buf));   // keeps buf alive without exposing it
    return 0;
}

מהדרים בקישור סטטי (נוח לאריזה) ואורזים:

gcc -O0 -static -o secret secret.c
strings secret | grep FLAG        # before packing - the string is there
upx --best -o secret.packed secret

עכשיו מזהים שהקובץ ארוז:

strings secret.packed | grep FLAG    # empty! the string is gone
strings secret.packed | grep -i upx  # reveals the UPX signatures

ולבסוף פורקים וחושפים:

upx -d secret.packed -o secret.unpacked
strings secret.unpacked | grep FLAG  # FLAG{this_string_was_hidden_by_upx}

וכתרגיל בפריקה ידנית, אפשר לחלץ את אותה מחרוזת מהקובץ הארוז secret.packed בלי upx -d, בעזרת מתכון ה-gdb מלמעלה: starti, catch syscall write, continue, ואז search "FLAG".

הקשר לexploit ולמחקר חולשות

למה כל זה שייך לקורס pwn? כמה סיבות. ראשית, בעולם האמיתי הרבה מהבינארים שתצטרכו לחקור, בעיקר תוכנות זדוניות, ארוזים, וזיהוי ופירוק של האריזה הם השלב הראשון לפני שבכלל אפשר להתחיל לנתח את הלוגיקה. שנית, ארז הוא דוגמה נקייה לקוד שמשנה את עצמו בזמן ריצה: ה-stub כותב קוד חדש לזיכרון ואז קופץ אליו, מה שדורש שהאזור יהיה גם כתיב וגם ניתן להרצה. זה מלמד המון על היחס בין NX לבין הרשאות זיכרון (ה-stub קורא ל-mprotect כדי לאפשר הרצה של מה שפרק). ושלישית, אתגרי reverse engineering רבים משתמשים באריזה כשכבת הגנה ראשונה, ומי שלא יודע לפרק פשוט תקוע בשער.

סיכום

  • ארז דוחס או מערבב בינארי ועוטף אותו ב-stub שפורק אותו לזיכרון בזמן ריצה ואז קופץ ל-OEP. על הדיסק המקור בלתי קריא.
  • אורזים כדי להקטין גודל, להסתיר מפני ניתוח, ולהגן על קניין רוחני. אריזה היא הסתרה ולא הצפנה, ולכן תמיד ניתנת לפירוק.
  • מזהים אריזה לפי אנטרופיה גבוהה (קרוב ל-8 סיביות לבית), מיעוט מחרוזות, חתימות הארז (UPX! וחברותיה ב-strings), והיעדר טבלת סקשנים (readelf -S).
  • נקודת הכניסה המקורית - OEP היא המקום שאליו ה-stub קופץ בסוף. פריקה ידנית שואפת לתפוס את הזיכרון אחרי הקפיצה הזו.
  • פריקה אוטומטית: upx -d file -o out. עובד תמיד כשהכותרת שלמה.
  • פריקה ידנית: מריצים תחת gdb, נותנים ל-stub לפרוק (catch syscall write), ואז search בזיכרון או dump memory ו-strings. שימושי כשהכותרת שובשה בכוונה.
  • זיהוי ופירוק אריזה הם השלב הראשון בכל ניתוח של בינארי ארוז, ובראש ובראשונה בניתוח תוכנות זדוניות.