לדלג לתוכן

7.2 דפוסים באסמבלי הרצאה

הקדמה

בהרצאה הקודמת (7.1) למדנו מהי הנדסה הפוכה והכרנו את הכלים. עכשיו נתחיל ללמוד את המיומנות המרכזית ב-RE: זיהוי דפוסים באסמבלי.

כשאתה קורא דיסאסמבלי של תוכנית, אתה לא קורא שורה-שורה - אתה מחפש דפוסים שאתה מזהה. כל מבנה בשפת C (if, for, while, switch, struct, array) מתורגם לדפוס מסוים באסמבלי. ברגע שאתה מזהה את הדפוס, אתה יודע מה הקוד המקורי ב-C עשה.

בהרצאה הזו נעבור על כל הדפוסים העיקריים ונלמד לזהות אותם.


דפוס תנאי - if/else pattern

נתחיל עם הדפוס הכי בסיסי. קוד C:

if (x > 5) {
    // if body
} else {
    // else body
}

באסמבלי (בהנחה ש-x נמצא ב-edi):

cmp     edi, 5
jle     .else_branch       ; אם x <= 5, קפוץ ל-else
; --- if body ---
jmp     .end
.else_branch:
; --- else body ---
.end:

הנקודה הקריטית: התנאי באסמבלי הפוך מהתנאי ב-C. בקוד C כתוב if (x > 5), אבל באסמבלי הקפיצה היא jle (jump if less or equal) - כלומר "קפוץ ל-else אם x קטן-שווה ל-5". זה כי באסמבלי הקפיצה היא מתי לדלג על גוף ה-if.

טבלת ההמרה:

תנאי ב-C קפיצה באסמבלי (signed) קפיצה באסמבלי (unsigned)
> jle (דלג אם <=) jbe (דלג אם <=)
>= jl (דלג אם <) jb (דלג אם <)
< jge (דלג אם >=) jae (דלג אם >=)
<= jg (דלג אם >) ja (דלג אם >)
== jne (דלג אם לא שווה) jne
!= je (דלג אם שווה) je

דוגמה נוספת - if בלי else:

if (x == 0) {
    do_something();
}
// continue...

test    edi, edi           ; בדיקה אם edi == 0
jne     .skip              ; אם לא אפס - דלג
call    do_something
.skip:
; continue...

שימו לב לשימוש ב-test edi, edi במקום cmp edi, 0. הפקודה test מבצעת AND לוגי בין edi לעצמו - התוצאה היא אפס רק אם edi הוא אפס. זו אופטימיזציה נפוצה של הקומפיילר (חוסכת בית בקידוד ההוראה).


דפוס לולאת for - for loop pattern

קוד C:

for (int i = 0; i < 10; i++) {
    // loop body
}

באסמבלי:

        mov     dword [rbp-4], 0       ; i = 0 (אתחול)
        jmp     .check                 ; קפוץ לבדיקת התנאי
.body:
        ; --- loop body ---
        add     dword [rbp-4], 1       ; i++ (קידום)
.check:
        cmp     dword [rbp-4], 10      ; i < 10? (תנאי)
        jl      .body                  ; אם כן - חזור לגוף הלולאה

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

עם אופטימיזציה (-O2), הקומפיילר עשוי לפשט:

        xor     ecx, ecx               ; i = 0 (xor עם עצמו = אפס)
.body:
        ; --- loop body ---
        inc     ecx                     ; i++
        cmp     ecx, 10                ; i < 10?
        jl      .body                  ; אם כן - חזור

בגרסה המאופטמת: המשתנה i נשמר באוגר (ecx) ולא על המחסנית, אין קפיצה ראשונית לתנאי (הקומפיילר יודע שהתנאי מתקיים בפעם הראשונה), ו-xor ecx, ecx מאפס את האוגר (אופטימיזציה נפוצה - קידוד קצר יותר מ-mov ecx, 0).


דפוס לולאת while - while loop pattern

קוד C:

while (x > 0) {
    // loop body
    x--;
}

באסמבלי:

        jmp     .check
.body:
        ; --- loop body ---
        sub     dword [rbp-4], 1       ; x--
.check:
        cmp     dword [rbp-4], 0       ; x > 0?
        jg      .body                  ; אם כן - חזור לגוף

דפוס הwhile דומה מאוד לfor, רק בלי שלב אתחול מובנה. אם יש do-while:

do {
    // loop body
    x--;
} while (x > 0);
.body:
        ; --- loop body ---
        sub     dword [rbp-4], 1       ; x--
        cmp     dword [rbp-4], 0       ; x > 0?
        jg      .body                  ; אם כן - חזור

בdo-while, אין קפיצה ראשונית לתנאי - הגוף תמיד רץ לפחות פעם אחת.


דפוס switch/case - switch pattern

ל-switch יש שני מימושים שונים באסמבלי, תלוי בכמות הcase-ים ובערכים שלהם.

אפשרות 1: שרשרת השוואות - if/else chain

כשיש מעט case-ים או שהערכים לא רצופים:

switch (x) {
    case 1: func_a(); break;
    case 5: func_b(); break;
    case 100: func_c(); break;
}

        cmp     edi, 1
        je      .case_1
        cmp     edi, 5
        je      .case_5
        cmp     edi, 100
        je      .case_100
        jmp     .end                   ; default (או סוף אם אין default)
.case_1:
        call    func_a
        jmp     .end
.case_5:
        call    func_b
        jmp     .end
.case_100:
        call    func_c
.end:

זה נראה בדיוק כמו שרשרת if-else if-else.

אפשרות 2: טבלת קפיצות - jump table

כשהcase-ים הם ערכים רצופים (או כמעט רצופים), הקומפיילר יוצר טבלת קפיצות:

switch (x) {
    case 0: func_a(); break;
    case 1: func_b(); break;
    case 2: func_c(); break;
    case 3: func_d(); break;
}

        cmp     edi, 3
        ja      .default               ; אם x > 3 (unsigned), זה לא case תקין
        lea     rax, [rip + .jump_table]
        movsxd  rdx, dword [rax + rdi*4]
        add     rax, rdx
        jmp     rax                    ; קפיצה עקיפה דרך הטבלה

.jump_table:
        .long   .case_0 - .jump_table
        .long   .case_1 - .jump_table
        .long   .case_2 - .jump_table
        .long   .case_3 - .jump_table

איך לזהות טבלת קפיצות בדיסאסמבלי:
- יש cmp ואז ja (unsigned above) - בדיקת גבולות
- יש lea שטוען כתובת בסיס
- יש חישוב עם אינדקס (כפולה של הערך הנבדק)
- יש jmp לכתובת שחושבה דינמית (לא כתובת קבועה)

כשאתה רואה jmp rax (קפיצה לכתובת שבאוגר) - זה סימן חזק לטבלת קפיצות.


דפוס קריאות לפונקציות - function call pattern

כשהקומפיילר מתרגם קריאה לפונקציה, יש דפוס ברור:

int result = calculate(10, 20, 30);
        mov     edx, 30                ; ארגומנט שלישי -> rdx
        mov     esi, 20                ; ארגומנט שני -> rsi
        mov     edi, 10                ; ארגומנט ראשון -> rdi
        call    calculate
        mov     [rbp-4], eax           ; שמירת ערך ההחזרה (rax) למשתנה מקומי

זיהוי פונקציות ספריה נפוצות

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

זיהוי printf:

lea     rdi, [rip+0x...]       ; rdi = format string (כתובת ב-.rodata)
mov     esi, [rbp-4]           ; rsi = ארגומנט ראשון ל-format
xor     eax, eax               ; al = 0 (מספר ארגומנטי SSE/float)
call    printf@plt

המפתח: rdi מצביע למחרוזת ב-.rodata (format string), ולפני הקריאה יש xor eax, eax (שאומר שאין ארגומנטי floating point).

זיהוי malloc:

mov     edi, 0x100             ; rdi = גודל ההקצאה (256 בתים)
call    malloc@plt
mov     [rbp-8], rax           ; שמירת המצביע שחזר

המפתח: ארגומנט מספרי אחד ב-rdi, וערך ההחזרה נשמר כמצביע.

זיהוי strcmp:

lea     rdi, [rbp-0x20]        ; rdi = מחרוזת ראשונה
lea     rsi, [rip+0x...]       ; rsi = מחרוזת שנייה
call    strcmp@plt
test    eax, eax               ; בדיקת ערך ההחזרה
je      .strings_equal         ; אם 0 - המחרוזות שוות

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


דפוס גישה לסטראקט - struct access pattern

כשיש גישה לשדות של struct, רואים באסמבלי גישה לoffset-ים קבועים מכתובת בסיס:

struct Point {
    int x;      // offset 0
    int y;      // offset 4
    char *name; // offset 8
};

struct Point p;
p.x = 1;
p.y = 2;
p.name = "origin";
        mov     dword [rax], 1         ; p.x = 1  (offset 0 מתחילת הstruct)
        mov     dword [rax+4], 2       ; p.y = 2  (offset 4)
        lea     rcx, [rip+0x...]       ; rcx = כתובת המחרוזת "origin"
        mov     qword [rax+8], rcx     ; p.name = "origin"  (offset 8)

איך לזהות struct בדיסאסמבלי:
- יש אוגר שמשמש ככתובת בסיס (כמו rax בדוגמה)
- יש כמה גישות לזיכרון עם offset-ים קבועים מאותו בסיס: [rax], [rax+4], [rax+8]
- הגדלים וה-offset-ים תואמים סוגי נתונים (int = 4 בתים, pointer = 8 בתים)

דוגמה יותר מורכבת - גישה דרך מצביע:

struct Node {
    int value;          // offset 0
    struct Node *next;  // offset 8 (padding after int)
};

void print_list(struct Node *head) {
    while (head != NULL) {
        printf("%d\n", head->value);
        head = head->next;
    }
}

print_list:
        push    rbx
        mov     rbx, rdi               ; rbx = head
        test    rbx, rbx               ; head == NULL?
        je      .end
.loop:
        mov     esi, [rbx]             ; esi = head->value (offset 0)
        lea     rdi, [rip+0x...]       ; rdi = "%d\n"
        xor     eax, eax
        call    printf@plt
        mov     rbx, [rbx+8]          ; head = head->next (offset 8)
        test    rbx, rbx               ; head == NULL?
        jne     .loop
.end:
        pop     rbx
        ret

דפוס גישה למערך - array access pattern

גישה למערך באסמבלי משתמשת בחישוב כתובת עם אינדקס:

int arr[10];
arr[i] = 42;
        mov     dword [rbx + rcx*4], 42    ; arr[i] = 42

הנוסחה היא: base + index * scale, כאשר:
- base (rbx) = כתובת תחילת המערך
- index (rcx) = האינדקס i
- scale (4) = גודל כל איבר (4 בתים עבור int)

זה נקרא כתובת SIB (Scale-Index-Base) - מצב כתובת מיוחד של x86 שתוכנן בדיוק בשביל מערכים.

הscale יכול להיות 1, 2, 4, או 8:
- [rbx + rcx*1] - מערך של char/byte
- [rbx + rcx*2] - מערך של short (2 בתים)
- [rbx + rcx*4] - מערך של int/float (4 בתים)
- [rbx + rcx*8] - מערך של long/double/pointer (8 בתים)

דוגמה עם לולאה:

int sum = 0;
for (int i = 0; i < n; i++) {
    sum += arr[i];
}

        xor     eax, eax               ; sum = 0
        xor     ecx, ecx               ; i = 0
.loop:
        add     eax, [rdi + rcx*4]     ; sum += arr[i]
        inc     ecx                    ; i++
        cmp     ecx, esi               ; i < n?
        jl      .loop

דפוס באפר על המחסנית - stack buffer pattern

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

void func(void) {
    char buffer[64];
    scanf("%s", buffer);
}
func:
        push    rbp
        mov     rbp, rsp
        sub     rsp, 0x40              ; הקצאת 64 בתים על המחסנית
        lea     rsi, [rbp-0x40]        ; rsi = כתובת הbuffer
        lea     rdi, [rip+0x...]       ; rdi = "%s"
        xor     eax, eax
        call    scanf@plt
        leave
        ret

איך לזהות:
- sub rsp, X - הקצאת מקום על המחסנית (X הוא גודל הbuffer ועוד אולי padding)
- lea reg, [rbp-X] - לקיחת כתובת הbuffer כדי להעביר אותה כארגומנט לפונקציה
- הכתובת מועברת כארגומנט לפונקציות כמו scanf, read, fgets וכו'

זה חשוב במיוחד בהקשר של אבטחה - buffer על המחסנית ליד כתובת החזרה הוא מתכון לstack buffer overflow (נושא שנלמד בהמשך הקורס).


דפוסי אופטימיזציה - optimization patterns

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

כפל בחזקת 2: שימוש ב-shl במקום imul

x * 2
x * 4
x * 8

shl     eax, 1              ; x * 2
shl     eax, 2              ; x * 4
shl     eax, 3              ; x * 8

הזזה שמאלה ב-N ביטים שקולה לכפל ב-2^N. זה הרבה יותר מהיר מכפל אמיתי.

כפל בקבועים קטנים: שימוש ב-lea

x * 3
x * 5
x * 9

lea     eax, [rdi + rdi*2]  ; x * 3 = x + x*2
lea     eax, [rdi + rdi*4]  ; x * 5 = x + x*4
lea     eax, [rdi + rdi*8]  ; x * 9 = x + x*8

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

חילוק בחזקת 2: שימוש ב-sar/shr

x / 4    // signed
y / 8    // unsigned

sar     eax, 2              ; x / 4 (signed - שומר על סימן)
shr     ecx, 3              ; y / 8 (unsigned)

הזזה ימינה ב-N ביטים שקולה לחילוק ב-2^N.

לחילוק signed, הקומפיילר מוסיף תיקון עבור מספרים שליליים:

mov     eax, edi
sar     eax, 31             ; eax = 0 אם חיובי, -1 אם שלילי
shr     eax, 30             ; תיקון עיגול (עבור חילוק ב-4)
add     eax, edi
sar     eax, 2              ; חילוק ב-4

בדיקה אם אפס: test במקום cmp

if (x == 0)
if (x != 0)

test    eax, eax            ; AND של eax עם עצמו - מגדיר דגלים
jz      .is_zero            ; ZF=1 אם eax==0

test eax, eax קצר יותר מ-cmp eax, 0 (חוסך בית) והוא דפוס שתראו כמעט בכל בינארי.

איפוס אוגר: xor במקום mov

x = 0;

xor     eax, eax            ; eax = 0

xor eax, eax קצר יותר מ-mov eax, 0 (2 בתים במקום 5). זה כל כך נפוץ שכל reverser צריך לזהות את זה אוטומטית.

שלילה בוליאנית ובדיקות

return x != 0;  // המרה לbool

test    edi, edi
setne   al                  ; al = 1 אם edi != 0, אחרת 0
movzx   eax, al             ; הרחבה ל-32 ביט


דוגמה מלאה: שחזור פונקציה מאסמבלי

בואו נראה דוגמה שמשלבת כמה דפוסים ונשחזר ממנה קוד C:

mystery_func:
        push    rbp
        mov     rbp, rsp
        mov     dword [rbp-4], edi     ; שמירת ארגומנט ראשון
        mov     dword [rbp-8], 0       ; משתנה מקומי = 0
        mov     dword [rbp-12], 1      ; משתנה מקומי = 1
        jmp     .check
.loop:
        mov     eax, [rbp-12]
        add     [rbp-8], eax           ; sum += i
        add     dword [rbp-12], 1      ; i++
.check:
        mov     eax, [rbp-12]
        cmp     eax, [rbp-4]           ; i <= n?
        jle     .loop
        mov     eax, [rbp-8]           ; ערך החזרה = sum
        pop     rbp
        ret

נפרק את זה:
1. [rbp-4] = הארגומנט הראשון (edi). נקרא לו n
2. [rbp-8] = משתנה מקומי שמתחיל ב-0 ומצטבר. נקרא לו sum
3. [rbp-12] = משתנה מקומי שמתחיל ב-1 ועולה ב-1 כל איטרציה. נקרא לו i
4. יש דפוס של לולאת for: אתחול, קפיצה לתנאי, גוף (חיבור), קידום (i++), תנאי (i <= n)
5. ערך ההחזרה הוא sum

שחזור:

int mystery_func(int n) {
    int sum = 0;
    for (int i = 1; i <= n; i++) {
        sum += i;
    }
    return sum;
}

זו פונקציה שמחשבת סכום של כל המספרים מ-1 עד n.


סיכום

בהרצאה הזו למדנו לזהות את הדפוסים הבסיסיים באסמבלי x86-64:

  • תנאי if/else - cmp ואז קפיצה מותנית (התנאי הפוך מה-C)
  • לולאת for - אתחול, קפיצה לתנאי, גוף, קידום, תנאי, קפיצה חזרה
  • לולאת while - דומה ל-for בלי אתחול מובנה
  • לולאת do-while - תנאי בסוף, בלי קפיצה ראשונית
  • switch/case - שרשרת השוואות (מעט case-ים) או טבלת קפיצות (ערכים רצופים)
  • קריאות לפונקציות - הכנת ארגומנטים באוגרים, call, קריאת ערך חזרה מ-rax
  • גישה לstruct - offset-ים קבועים מכתובת בסיס
  • גישה למערך - חישוב base + index * scale (כתובת SIB)
  • באפר על המחסנית - sub rsp, lea לכתובת הבאפר
  • אופטימיזציות - shl/shr במקום כפל/חילוק, lea לכפל בקבועים, test/xor לבדיקות, xor לאיפוס

בהרצאה הבאה (7.3) נלמד GDB מתקדם - שימוש ב-GDB ככלי הנדסה הפוכה לניתוח דינמי של תוכניות בלי קוד מקור.