7.7 טכניקות אנטי הנדסה הפוכה הרצאה
בהרצאה הזו נלמד על טכניקות שמחברי תוכנות משתמשים בהן כדי להקשות על הנדסה הפוכה. תוכנות זדוניות משתמשות בטכניקות האלו כדי להתחמק מחוקרי אבטחה, ותוכנות מסחריות משתמשות בהן כדי להגן על קניין רוחני. בכל מקרה, כחוקרים אנחנו צריכים להכיר את הטכניקות האלו - גם כדי לזהות אותן, וגם כדי לדעת איך לעקוף אותן.
הסרת סימבולים - stripping symbols¶
הצעד הפשוט ביותר: הסרת מידע דיבוג וסימבולים מהבינארי.
מה strip עושה?¶
הפקודה 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:
עכשיו כשהתוכנית קוראת ל-ptrace, היא מקבלת את הגרסה שלנו שתמיד מחזירה 0.
אפשר גם ב-GDB:
בדיקת /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:
בדיקות זמן - 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:
זיהוי 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 לתוכנית:
בדיקות מבוססות סיגנלים - 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) רץ קודם, פורש את הקוד המקורי בזיכרון, ואז מעביר שליטה לקוד המקורי.
הכלי UPX¶
UPX (Ultimate Packer for eXecutables) הוא ה-packer הנפוץ ביותר:
UPX מזוהה בקלות:
איך לפרוק - unpacking¶
עבור packers מוכרים כמו UPX, יש כלים אוטומטיים. עבור packers מותאמים אישית:
- הריצו את התוכנית ב-GDB
- תנו ל-unpacker לרוץ
- מצאו את ה-OEP (Original Entry Point) - הנקודה שבה הקוד המקורי מתחיל לרוץ
- שימו breakpoint ב-OEP
- כשמגיעים ל-OEP, השתמשו ב-dump memory של gdb כדי לשמור את הקוד הפרוש
ואז אפשר לנתח את 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 |
הגישה הכללית¶
- זהו את הטכניקה - מה בדיוק הבדיקה עושה?
- הבינו את המנגנון - איך הבדיקה קובעת אם יש debugger/VM?
- נטרלו - שנו את התנאי, הערך, או התוצאה כדי שהבדיקה "תעבור"
ב-GDB, הדרך הנפוצה ביותר היא פשוט לשנות את ערך ההחזרה של הפונקציה או לשנות את כתובת הקפיצה:
# שינוי ערך החזרה:
(gdb) finish
(gdb) set $rax = 0
# דילוג על בדיקה (שינוי rip לכתובת שאחרי הבדיקה):
(gdb) set $rip = 0x401234
או בגידרה, אפשר ל-patch את הבינארי ולשנות הוראות (למשל לשנות jne ל-je או להחליף ב-NOP-ים).
סיכום¶
טכניקות אנטי-RE הן חלק מהמשחק בין מחבר התוכנה לחוקר. הכלל החשוב: שום טכניקת הגנה לא בלתי ניתנת לעקיפה. עם מספיק זמן ומומחיות, תמיד אפשר לנתח את הקוד. הטכניקות האלו רק מקשות ומאטות את החוקר, אבל לא עוצרות אותו לגמרי.
כחוקרים, חשוב ש:
- תזהו טכניקות אנטי-RE כשאתם נתקלים בהן
- תדעו לעקוף אותן
- תבינו שהשילוב של ניתוח סטטי (גידרה) עם ניתוח דינמי (GDB) הוא המפתח - מה שקשה סטטית, קל דינמית, ולהפך