לדלג לתוכן

7.7 טכניקות אנטי הנדסה הפוכה הרצאה

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


הסרת סימבולים - stripping symbols

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

מה strip עושה?

gcc -o program program.c
strip program

הפקודה strip מסירה את הsection .symtab - טבלת הסימבולים. בלי .symtab, אין שמות פונקציות, אין שמות משתנים גלובליים, ואין מספרי שורות.

מה נשאר אחרי strip?

  • טבלת .dynsym נשארת - כי היא הכרחית לקישור דינמי. בלעדיה התוכנית לא יכולה לקרוא לפונקציות מ-libc.
  • מחרוזות נשארות - strings עדיין עובד
  • מבנה הקוד נשאר - הפניות צולבות, דפוסי קוד, הכל שם

ההשפעה על ניתוח

בלי סימבולים, גידרה תקרא לפונקציות FUN_00401000, FUN_00401050 וכו'. זה מקשה אבל בהחלט אפשר לנתח - פשוט צריך יותר עבודה. המחרוזות, ייבואים, ודפוסי קוד עדיין שם ועוזרים להבין מה הולך.

# לפני strip:
nm program | head
0000000000401136 T main
0000000000401119 T check_password

# אחרי strip:
nm program
nm: program: no symbols

טכניקות ערפול - obfuscation techniques

ערפול הוא הפיכת הקוד לקשה להבנה, בלי לשנות את הפונקציונליות שלו.

הכנסת קוד מת - dead code insertion

הוספת הוראות שלא משפיעות על הלוגיקה אבל מבלבלות את החוקר:

; הקוד האמיתי:
mov eax, 5
add eax, 3

; אחרי הכנסת קוד מת:
mov eax, 5
push rbx          ; לא עושה כלום
mov rbx, 0x1234   ; לא משמש לכלום
xor rcx, rcx      ; לא משפיע
nop               ; כלום
pop rbx           ; מבטל את ה-push
add eax, 3

התוצאה זהה, אבל עכשיו יש הרבה יותר קוד לקרוא. ב-Decompiler של גידרה, חלק מהקוד המת ינוקה אוטומטית, אבל לא תמיד.

תנאים אטומים - opaque predicates

תנאי שתמיד מתקיים (או תמיד לא) אבל נראה מורכב:

// תמיד true (כי x^2 + x תמיד זוגי)
if ((x * x + x) % 2 == 0) {
    // הקוד האמיתי כאן
} else {
    // קוד מזויף שלעולם לא ירוץ
}

החוקר רואה if-else ולא יודע אם שני הענפים רלוונטיים. הוא צריך לנתח את התנאי המתמטי כדי להבין שרק ענף אחד ירוץ.

שיטוח זרימת בקרה - control flow flattening

ממירים קוד מובנה (if-else, for, while) למכונת מצבים עם switch גדול:

// לפני:
if (a > 0) {
    b = a + 1;
} else {
    b = a - 1;
}
c = b * 2;

// אחרי שיטוח:
int state = 0;
while (state != 4) {
    switch (state) {
        case 0: state = (a > 0) ? 1 : 2; break;
        case 1: b = a + 1; state = 3; break;
        case 2: b = a - 1; state = 3; break;
        case 3: c = b * 2; state = 4; break;
    }
}

הקוד עושה אותו דבר, אבל הזרימה הרבה פחות ברורה. ב-Function Graph של גידרה, במקום לראות מבנה ברור, רואים כוכב גדול עם הכל מצביע למרכז (ה-switch).

הצפנת מחרוזות - string encryption

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

// רגיל - strings ימצא את זה:
printf("Password incorrect!\n");

// מוצפן - strings לא ימצא:
char encrypted[] = {0x29, 0x42, 0x52, 0x52, ...};
for (int i = 0; i < len; i++) {
    encrypted[i] ^= 0x37;  // מפענח בזמן ריצה
}
printf(encrypted);

עכשיו strings לא ימצא את המחרוזת "Password incorrect!". כדי לגלות אותה, צריך או להריץ את התוכנית (ניתוח דינמי), או למצוא את פונקציית הפענוח בגידרה.

קוד שמשנה את עצמו - self-modifying code

הקוד משנה את עצמו בזמן ריצה. למשל, הוראות האסמבלי בזיכרון משתנות לפני שהן מורצות:

// הקוד בדיסק הוא שטויות
// בזמן ריצה, הקוד מפענח את עצמו:
unsigned char *code = (unsigned char *)function_addr;
mprotect(code, 4096, PROT_READ | PROT_WRITE | PROT_EXEC);
for (int i = 0; i < code_len; i++) {
    code[i] ^= key;
}
// עכשיו הקוד פוענח ואפשר להריץ אותו

ניתוח סטטי יראה קוד מוצפן שלא הגיוני. רק בניתוח דינמי, אחרי שהקוד מפענח את עצמו, אפשר לראות את הקוד האמיתי.


טכניקות אנטי-דיבוג - anti-debugging

בדיקת ptrace

כמו שלמדנו, GDB משתמש ב-ptrace כדי לשלוט בתהליך. תוכנית יכולה לבדוק אם מישהו מדבג אותה:

#include <sys/ptrace.h>

void anti_debug() {
    if (ptrace(PTRACE_TRACEME, 0, 0, 0) == -1) {
        // debugger מחובר!
        printf("Debugger detected!\n");
        exit(1);
    }
}

int main() {
    anti_debug();
    // הקוד האמיתי...
}

איך זה עובד: ptrace(PTRACE_TRACEME) מבקש מהקרנל "תן למישהו לעקוב אחרי". אם debugger כבר מחובר (כבר עושה ptrace), הקריאה תיכשל ותחזיר -1.

איך לעקוף: הדרך הפשוטה ביותר - להשתמש ב-LD_PRELOAD:

// fake_ptrace.c
long ptrace(int request, ...) {
    return 0;  // תמיד מצליח
}
gcc -shared -o fake_ptrace.so fake_ptrace.c
LD_PRELOAD=./fake_ptrace.so gdb ./program

עכשיו כשהתוכנית קוראת ל-ptrace, היא מקבלת את הגרסה שלנו שתמיד מחזירה 0.

אפשר גם ב-GDB:

(gdb) catch syscall ptrace
(gdb) run
# כשנעצרים ב-ptrace:
(gdb) set $rax = 0
(gdb) continue

בדיקת /proc/self/status

void check_debugger() {
    FILE *f = fopen("/proc/self/status", "r");
    char line[256];
    while (fgets(line, sizeof(line), f)) {
        if (strncmp(line, "TracerPid:", 10) == 0) {
            int pid = atoi(line + 11);
            if (pid != 0) {
                // מישהו מדבג אותנו!
                exit(1);
            }
        }
    }
    fclose(f);
}

איך זה עובד: השדה TracerPid ב-/proc/self/status מכיל את ה-PID של התהליך שעוקב אחרינו. אם הערך שונה מ-0, מישהו מדבג.

איך לעקוף: ב-GDB, אפשר לשנות את ערך pid ל-0:

(gdb) break atoi
(gdb) run
# כשנעצרים ב-atoi:
(gdb) finish
(gdb) set $rax = 0
(gdb) continue

בדיקות זמן - timing checks

#include <time.h>

void timing_check() {
    struct timespec start, end;
    clock_gettime(CLOCK_MONOTONIC, &start);

    // קוד רגיל...
    some_function();

    clock_gettime(CLOCK_MONOTONIC, &end);

    long diff = (end.tv_sec - start.tv_sec) * 1000000000 +
                (end.tv_nsec - start.tv_nsec);

    if (diff > 1000000) {  // יותר ממילישנייה = חשוד
        exit(1);
    }
}

איך זה עובד: כשמדבגים עם breakpoints, הזמן שעובר בין שתי נקודות בקוד הוא הרבה יותר גדול מריצה רגילה.

איך לעקוף: ב-GDB, אפשר לשנות את ערך diff:

(gdb) break *<address_of_comparison>
(gdb) run
# לפני ההשוואה:
(gdb) set diff = 0
(gdb) continue

זיהוי int3

#include <signal.h>

int debugger_detected = 0;

void trap_handler(int sig) {
    // אם ה-handler רץ = אין debugger
    debugger_detected = 0;
}

void check_int3() {
    debugger_detected = 1;
    signal(SIGTRAP, trap_handler);

    // int3 - trap instruction
    __asm__("int3");

    if (debugger_detected) {
        // ה-handler לא רץ = debugger תפס את ה-int3
        exit(1);
    }
}

איך זה עובד: int3 יוצר SIGTRAP. אם debugger מחובר, הוא תופס את ה-SIGTRAP ועוצר (כמו breakpoint). אם אין debugger, ה-signal handler שהגדרנו ירוץ.

איך לעקוף: ב-GDB, אפשר להגיד לGDB להעביר את ה-signal לתוכנית:

(gdb) handle SIGTRAP nostop pass

בדיקות מבוססות סיגנלים - signal-based

void handler(int sig) {
    // אם הגענו לכאן = אין debugger
}

void check() {
    signal(SIGALRM, handler);
    alarm(1);  // SIGALRM בעוד שנייה
    // debugger עלול לתפוס את ה-signal
}

זיהוי מכונה וירטואלית - anti-VM detection

תוכנות זדוניות רבות מנסות לזהות אם הן רצות בתוך VM, כי חוקרי אבטחה בדרך כלל מנתחים ב-VM.

בדיקת CPUID

הוראת cpuid מחזירה מידע על המעבד. ב-VM, המידע כולל לפעמים את שם ה-hypervisor:

void check_cpuid() {
    unsigned int eax, ebx, ecx, edx;
    __asm__ volatile("cpuid"
                     : "=a"(eax), "=b"(ebx), "=c"(ecx), "=d"(edx)
                     : "a"(0x40000000));

    char vendor[13] = {0};
    memcpy(vendor, &ebx, 4);
    memcpy(vendor + 4, &ecx, 4);
    memcpy(vendor + 8, &edx, 4);

    // "VMwareVMware", "Microsoft Hv", "KVMKVMKVM", "VBoxVBoxVBox"
    printf("Hypervisor vendor: %s\n", vendor);
}

בדיקת חומרה ספציפית ל-VM

void check_vm_hardware() {
    // בדיקת MAC address - VMware, VirtualBox יש prefix-ים ידועים
    // VMware: 00:0C:29, 00:50:56
    // VirtualBox: 08:00:27

    // בדיקת קיומם של כלי VM
    if (access("/usr/bin/vmtoolsd", F_OK) == 0) {
        // VMware tools מותקנים
    }

    if (access("/usr/bin/VBoxService", F_OK) == 0) {
        // VirtualBox Guest Additions מותקנים
    }
}

בדיקות זמן

void timing_vm_check() {
    // פעולות מסוימות (כמו I/O) איטיות יותר ב-VM
    unsigned long long start = __rdtsc();
    // פעולה...
    unsigned long long end = __rdtsc();

    if (end - start > THRESHOLD) {
        // כנראה VM
    }
}

אריזה ודחיסה - packing and compression

מה זה packer?

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

לפני אריזה:
[קוד מקורי] [נתונים] [imports]

אחרי אריזה:
[unpacker קטן] [קוד מקורי דחוס/מוצפן]

הכלי UPX

UPX (Ultimate Packer for eXecutables) הוא ה-packer הנפוץ ביותר:

# אריזה
upx -o packed_program program

# פריקה
upx -d packed_program

UPX מזוהה בקלות:

strings packed_program | grep UPX

איך לפרוק - unpacking

עבור packers מוכרים כמו UPX, יש כלים אוטומטיים. עבור packers מותאמים אישית:

  1. הריצו את התוכנית ב-GDB
  2. תנו ל-unpacker לרוץ
  3. מצאו את ה-OEP (Original Entry Point) - הנקודה שבה הקוד המקורי מתחיל לרוץ
  4. שימו breakpoint ב-OEP
  5. כשמגיעים ל-OEP, השתמשו ב-dump memory של gdb כדי לשמור את הקוד הפרוש
(gdb) dump binary memory unpacked.bin 0x400000 0x500000

ואז אפשר לנתח את unpacked.bin בגידרה.

Packers מותאמים אישית

Packers מותאמים הם קשים יותר לפריקה כי:
- אין כלי אוטומטי שמזהה אותם
- הם עשויים לפרוש את הקוד בשלבים
- הם עשויים לשלב טכניקות אנטי-דיבוג


עקיפת טכניקות אנטי-RE - סיכום

טכניקה עקיפה
בדיקת ptrace LD_PRELOAD עם ptrace מזויף, או שינוי ערך ההחזרה ב-GDB
בדיקת /proc/self/status שינוי ערך ב-GDB, או mount bind של קובץ מזויף
בדיקות זמן שינוי ערכי זמן ב-GDB
הצפנת מחרוזות breakpoint אחרי פונקציית הפענוח, קריאת הערך המפוענח
אריזה (UPX) upx -d לפריקה, או breakpoint ב-OEP ו-dump
אריזה מותאמת breakpoint ב-OEP, dump מהזיכרון
קוד שמשנה את עצמו breakpoint אחרי הפענוח, dump הקוד המפוענח
בדיקת VM הסרת כלי VM, שינוי MAC, או שינוי ערכים ב-GDB

הגישה הכללית

  1. זהו את הטכניקה - מה בדיוק הבדיקה עושה?
  2. הבינו את המנגנון - איך הבדיקה קובעת אם יש debugger/VM?
  3. נטרלו - שנו את התנאי, הערך, או התוצאה כדי שהבדיקה "תעבור"

ב-GDB, הדרך הנפוצה ביותר היא פשוט לשנות את ערך ההחזרה של הפונקציה או לשנות את כתובת הקפיצה:

# שינוי ערך החזרה:
(gdb) finish
(gdb) set $rax = 0

# דילוג על בדיקה (שינוי rip לכתובת שאחרי הבדיקה):
(gdb) set $rip = 0x401234

או בגידרה, אפשר ל-patch את הבינארי ולשנות הוראות (למשל לשנות jne ל-je או להחליף ב-NOP-ים).


סיכום

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

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