7.7 - טכניקות אנטי-הנדסה הפוכה - הרצאה
בהרצאה הזו נלמד על טכניקות שמחברי תוכנות משתמשים בהן כדי להקשות על הנדסה הפוכה. תוכנות זדוניות משתמשות בטכניקות האלו כדי להתחמק מחוקרי אבטחה, ותוכנות מסחריות משתמשות בהן כדי להגן על קניין רוחני. בכל מקרה, כחוקרים אנחנו צריכים להכיר את הטכניקות האלו - גם כדי לזהות אותן, וגם כדי לדעת איך לעקוף אותן.
הסרת סימבולים - stripping symbols¶
הצעד הפשוט ביותר: הסרת מידע דיבוג וסימבולים מהבינארי.
מה strip עושה?¶
הפקודה strip מסירה את הsection .symtab - טבלת הסימבולים. בלי .symtab, אין שמות פונקציות, אין שמות משתנים גלובליים, ואין מספרי שורות.
מה נשאר אחרי strip?¶
- טבלת
.dynsymנשארת - כי היא הכרחית לקישור דינמי. בלעדיה התוכנית לא יכולה לקרוא לפונקציות מ-libc. - מחרוזות נשארות - strings עדיין עובד
- מבנה הקוד נשאר - הפניות צולבות, דפוסי קוד, הכל שם
ההשפעה על ניתוח¶
בלי סימבולים, גידרה תקרא לפונקציות FUN_00401000, FUN_00401050 וכו'. זה מקשה אבל בהחלט אפשר לנתח - פשוט צריך יותר עבודה. המחרוזות, ייבואים, ודפוסי קוד עדיין שם ועוזרים להבין מה הולך.
# before strip:
nm program | head
0000000000401136 T main
0000000000401119 T check_password
# after strip:
nm program
nm: program: no symbols
טכניקות ערפול - obfuscation techniques¶
ערפול הוא הפיכת הקוד לקשה להבנה, בלי לשנות את הפונקציונליות שלו.
הכנסת קוד מת - dead code insertion¶
הוספת הוראות שלא משפיעות על הלוגיקה אבל מבלבלות את החוקר:
; the real code:
mov eax, 5
add eax, 3
; after dead code insertion:
mov eax, 5
push rbx ; does nothing
mov rbx, 0x1234 ; not used for anything
xor rcx, rcx ; has no effect
nop ; nothing
pop rbx ; cancels the push
add eax, 3
התוצאה זהה, אבל עכשיו יש הרבה יותר קוד לקרוא. ב-Decompiler של גידרה, חלק מהקוד המת ינוקה אוטומטית, אבל לא תמיד.
תנאים אטומים - opaque predicates¶
תנאי שתמיד מתקיים (או תמיד לא) אבל נראה מורכב:
// always true (because x^2 + x is always even)
if ((x * x + x) % 2 == 0) {
// the real code here
} else {
// fake code that will never run
}
החוקר רואה if-else ולא יודע אם שני הענפים רלוונטיים. הוא צריך לנתח את התנאי המתמטי כדי להבין שרק ענף אחד ירוץ.
שיטוח זרימת בקרה - control flow flattening¶
ממירים קוד מובנה (if-else, for, while) למכונת מצבים עם switch גדול:
// before:
if (a > 0) {
b = a + 1;
} else {
b = a - 1;
}
c = b * 2;
// after flattening:
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¶
במקום לשמור מחרוזות בטקסט גלוי, מצפינים אותן בזמן קומפילציה ומפענחים בזמן ריצה:
// normal - strings will find this:
printf("Password incorrect!\n");
// encrypted - strings won't find:
char encrypted[] = {0x29, 0x42, 0x52, 0x52, ...};
for (int i = 0; i < len; i++) {
encrypted[i] ^= 0x37; // decrypts at runtime
}
printf(encrypted);
עכשיו strings לא ימצא את המחרוזת "Password incorrect!". כדי לגלות אותה, צריך או להריץ את התוכנית (ניתוח דינמי), או למצוא את פונקציית הפענוח בגידרה.
קוד שמשנה את עצמו - self-modifying code¶
הקוד משנה את עצמו בזמן ריצה. למשל, הוראות האסמבלי בזיכרון משתנות לפני שהן מורצות:
// the code on disk is gibberish
// at runtime, the code decrypts itself:
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;
}
// now the code is decrypted and can be run
ניתוח סטטי יראה קוד מוצפן שלא הגיוני. רק בניתוח דינמי, אחרי שהקוד מפענח את עצמו, אפשר לראות את הקוד האמיתי.
טכניקות אנטי-דיבוג - anti-debugging¶
בדיקת ptrace¶
כמו שלמדנו, GDB משתמש ב-ptrace כדי לשלוט בprocess. תוכנית יכולה לבדוק אם מישהו מדבג אותה:
#include <sys/ptrace.h>
void anti_debug() {
if (ptrace(PTRACE_TRACEME, 0, 0, 0) == -1) {
// debugger attached!
printf("Debugger detected!\n");
exit(1);
}
}
int main() {
anti_debug();
// the real code...
}
איך זה עובד: 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) {
// someone is debugging us!
exit(1);
}
}
}
fclose(f);
}
איך זה עובד: השדה TracerPid ב-/proc/self/status מכיל את ה-PID של הprocess שעוקב אחרינו. אם הערך שונה מ-0, מישהו מדבג.
איך לעקוף: ב-GDB, אפשר לשנות את ערך pid ל-0:
בדיקות זמן - timing checks¶
#include <time.h>
void timing_check() {
struct timespec start, end;
clock_gettime(CLOCK_MONOTONIC, &start);
// normal code...
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) { // more than a millisecond = suspicious
exit(1);
}
}
איך זה עובד: כשמדבגים עם breakpoints, הזמן שעובר בין שתי נקודות בקוד הוא הרבה יותר גדול מריצה רגילה.
איך לעקוף: ב-GDB, אפשר לשנות את ערך diff:
(gdb) break *<address_of_comparison>
(gdb) run
# before the comparison:
(gdb) set diff = 0
(gdb) continue
זיהוי int3¶
#include <signal.h>
int debugger_detected = 0;
void trap_handler(int sig) {
// if the handler runs = no debugger
debugger_detected = 0;
}
void check_int3() {
debugger_detected = 1;
signal(SIGTRAP, trap_handler);
// int3 - trap instruction
__asm__("int3");
if (debugger_detected) {
// the handler didn't run = debugger caught the int3
exit(1);
}
}
איך זה עובד: int3 יוצר SIGTRAP. אם debugger מחובר, הוא תופס את ה-SIGTRAP ועוצר (כמו breakpoint). אם אין debugger, ה-signal handler שהגדרנו ירוץ.
איך לעקוף: ב-GDB, אפשר להגיד לGDB להעביר את ה-signal לתוכנית:
בדיקות מבוססות סיגנלים - signal-based¶
void handler(int sig) {
// if we got here = no debugger
}
void check() {
signal(SIGALRM, handler);
alarm(1); // SIGALRM in one second
// debugger might catch the 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() {
// checking MAC address - VMware, VirtualBox have known prefixes
// VMware: 00:0C:29, 00:50:56
// VirtualBox: 08:00:27
// checking for the presence of VM tools
if (access("/usr/bin/vmtoolsd", F_OK) == 0) {
// VMware tools installed
}
if (access("/usr/bin/VBoxService", F_OK) == 0) {
// VirtualBox Guest Additions installed
}
}
בדיקות זמן¶
void timing_vm_check() {
// certain operations (like I/O) are slower in a VM
unsigned long long start = __rdtsc();
// operation...
unsigned long long end = __rdtsc();
if (end - start > THRESHOLD) {
// probably a VM
}
}
אריזה ודחיסה - packing and compression¶
מה זה packer?¶
Packer הוא כלי שדוחס (ולפעמים מצפין) את הקובץ הבינארי. כשהתוכנית מורצת, חלק קטן (ה-unpacker) רץ קודם, פורש את הקוד המקורי בזיכרון, ואז מעביר שליטה לקוד המקורי.
Before packing:
[original code] [data] [imports]
After packing:
[small unpacker] [compressed/encrypted original code]
הכלי 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, הדרך הנפוצה ביותר היא פשוט לשנות את ערך ההחזרה של הפונקציה או לשנות את כתובת הקפיצה:
# changing the return value:
(gdb) finish
(gdb) set $rax = 0
# skipping a check (changing rip to the address after the check):
(gdb) set $rip = 0x401234
או בגידרה, אפשר ל-patch את הבינארי ולשנות הוראות (למשל לשנות jne ל-je או להחליף ב-NOP-ים).
סיכום¶
טכניקות אנטי-RE הן חלק מהמשחק בין מחבר התוכנה לחוקר. הכלל החשוב: שום טכניקת הגנה לא בלתי ניתנת לעקיפה. עם מספיק זמן ומומחיות, תמיד אפשר לנתח את הקוד. הטכניקות האלו רק מקשות ומאטות את החוקר, אבל לא עוצרות אותו לגמרי.
כחוקרים, חשוב ש:
- תזהו טכניקות אנטי-RE כשאתם נתקלים בהן
- תדעו לעקוף אותן
- תבינו שהשילוב של ניתוח סטטי (גידרה) עם ניתוח דינמי (GDB) הוא המפתח - מה שקשה סטטית, קל דינמית, ולהפך