Stack ופונקציות
Stack ופונקציות — Calling Conventions ו-Stack Frames
שיעור זה הוא המפתח להבנת Reverse Engineering ו-Exploitation. כאן תלמד בדיוק מה קורה כשפונקציה נקראת — מה נדחף לStack, איפה יושבים הארגומנטים, ואיך Return Address הוא המטרה בכל Buffer Overflow.
- Stack Mechanics: כיוון גדילה, PUSH/POP, מה ESP תמיד מצביע
- ESP ו-EBP: Stack Pointer לעומת Frame Pointer — תפקיד כל אחד
- Prologue ו-Epilogue: שלושת השורות שכל פונקציה מתחילה ומסתיימת בהן
- Stack Frame Layout: RetAddr, SavedEBP, Args, Locals — בדיוק באיזו כתובת
- Calling Conventions: cdecl (Linux) לעומת stdcall (Windows API)
- רקורסיה: Stack Frames נערמים — Stack Overflow
- Stack Smashing: מה Buffer Overflow ולמה Return Address הוא המטרה
- Stack Canary: ההגנה הקלאסית נגד Overflow
Stack Mechanics — כיצד ה-Stack בנוי ועובד
ה-Stack הוא מבנה נתונים LIFO שחי ב-RAM ומנוהל אוטומטית על ידי המעבד. כדי להבין Buffer Overflow ו-Calling Conventions — חייבים להבין תחילה איך ה-Stack עובד ברמה הפיזית.
1.1 — מיקום ה-Stack בזיכרון
ל-Stack מאפיין ייחודי: הוא גדל כלפי מטה — כלומר לכתובות קטנות יותר.
| כתובות | אזור | תוכן | כיוון גדילה |
|---|---|---|---|
| High | Stack | פרמטרים, כתובות חזרה, משתנים מקומיים | ↓ גדל למטה |
| — | Heap | זיכרון דינמי (malloc/new) | ↑ גדל למעלה |
| — | BSS | משתנים גלובליים לא מאותחלים | — |
| — | Data Segment | משתנים גלובליים מאותחלים | — |
| Low | Text Segment | קוד התוכנית (Read-Only) | — |
1.2 — ESP: מצביע ה-Stack
ESP (Extended Stack Pointer) מצביע תמיד לראש ה-Stack — לכתובת הנמוכה ביותר שבה יש נתון. כל PUSH מוריד את ESP ב-4, וכל POP מעלה אותו ב-4.
; מצב התחלתי: ESP = 0x1000
push eax ; ESP = 0x0FFC; memory[0x0FFC] = EAX
push ebx ; ESP = 0x0FF8; memory[0x0FF8] = EBX
push ecx ; ESP = 0x0FF4; memory[0x0FF4] = ECX
; Stack עכשיו:
; 0x1000 | ... (מעל ה-Stack)
; 0x0FFC | EAX ← (ראשון שנדחף)
; 0x0FF8 | EBX
; 0x0FF4 | ECX ← ESP מצביע כאן (ראש)
pop edx ; EDX = ECX; ESP = 0x0FF8
; ⚠️ POP לא מוחק נתונים — רק מעלה את ESP!!כלל ברזל: אל תשנו ESP ידנית!▶
ESP מנוהל אוטומטית על ידי PUSH, POP, CALL, RET. שינוי ידני של ESP ישבש את מבנה ה-Stack.
mov eax, 10
mov ebx, 20
push eax
push ebx
pop eax
pop ebxמה יהיו הערכים ב-EAX וב-EBX בסיום?Stack Frame — 'הבית' של כל פונקציה
כל פעם שפונקציה נקראת, היא מקבלת 'בית' משלה ב-Stack — Stack Frame. זה המקום שבו הפונקציה שומרת ארגומנטים, משתנים מקומיים, וכתובת החזרה.
2.1 — EBP: Frame Pointer
EBP (Extended Base Pointer) מצביע לבסיס ה-Stack Frame הנוכחי. בניגוד ל-ESP שמשתנה כל הזמן, EBP קבוע לאורך כל ריצת הפונקציה.
; [EBP + N] → מעל EBP = ארגומנטים (N חיובי)
; [EBP - N] → מתחת ל-EBP = משתנים מקומיים (N שלילי)
; מבנה Stack Frame:
; ┌────────────────────────┐ כתובת גבוהה
; │ arg3 [EBP + 16] │
; │ arg2 [EBP + 12] │
; │ arg1 [EBP + 8] │
; │ Return Address [EBP+4]│ ← כתובת חזרה
; │ Saved EBP [EBP + 0] │ ← EBP מצביע כאן
; │ local1 [EBP - 4] │
; │ local2 [EBP - 8] │
; └────────────────────────┘ ← ESP מצביע כאן (ראש)2.2 — Prologue: פתיחת פונקציה
כל פונקציה מתחילה ב-Prologue — שלוש שורות Assembly שמגדירות את ה-Stack Frame:
my_function:
push ebp ; (1) שמור EBP של הקורא ב-Stack
mov ebp, esp ; (2) EBP = ESP — קבע את בסיס ה-Frame
sub esp, 12 ; (3) הקצה מקום ל-3 משתנים מקומיים
; לאחר Prologue:
; [EBP + 0] = EBP השמור
; [EBP + 4] = Return Address
; [EBP + 8] = arg1
; [EBP + 12] = arg2
; [EBP - 4] = local1
; [EBP - 8] = local2
; [EBP - 12] = local32.3 — Epilogue: סיום פונקציה
; שיטה 1: mov + pop
mov eax, result ; ערך החזרה → EAX
mov esp, ebp ; שחזר ESP לבסיס ה-Frame
pop ebp ; שחזר EBP של הקורא
ret ; POP כתובת חזרה → JMP לשם
; שיטה 2: LEAVE (קיצור מובנה)
leave ; = mov esp,ebp + pop ebp
retסיום פונקציה: leave (או mov+pop) → ret
כל פעם שרואים push ebp + mov ebp,esp — זה תחילת פונקציה חדשה!
my_add:
push ebp
mov ebp, esp
mov eax, [ebp+8]
add eax, [ebp+12]
retCalling Conventions — מי מנקה את ה-Stack?
Calling Convention הוא 'חוזה' בין הפונקציה הקוראת (Caller) לנקראת (Callee): איך מעבירים ארגומנטים, ומי מנקה את ה-Stack. ללא Convention קבוע, קוד שונה לא יכול לשתף פעולה.
3.1 — cdecl: Convention של Linux/GCC
| כלל | cdecl — הפרטים |
|---|---|
| העברת ארגומנטים | דחיפה ל-Stack בסדר הפוך (arg האחרון נדחף ראשון) |
| מי מנקה Stack | הקורא (Caller) — ADD ESP, N לאחר CALL |
| ערך החזרה | ב-EAX (32 ביט) |
| רגיסטרים שמורים | EBX, ESI, EDI, EBP — Callee חייב לשמרם |
| יתרון | תומך ב-Variadic functions (printf, scanf) |
; cdecl — קריאה ל-add(3, 7)
push 7 ; arg2 נדחף ראשון (סדר הפוך!)
push 3 ; arg1 נדחף שני
call add_func ; CALL = PUSH RetAddr + JMP
add esp, 8 ; Caller מנקה: 2 args × 4 = 8
add_func:
push ebp
mov ebp, esp
mov eax, [ebp+8] ; arg1 = 3
add eax, [ebp+12] ; arg2 = 7 → EAX = 10
pop ebp
ret ; חזרה — Caller מנקה!3.2 — stdcall: Convention של Windows API
| כלל | stdcall — הפרטים |
|---|---|
| מי מנקה Stack | הנקראת (Callee) — RET N (לא ADD ESP בצד הקורא) |
| Windows כתיב | __stdcall בC, או WINAPI macro |
| חיסרון | אי-אפשר לעשות Variadic functions |
; stdcall — קריאה ל-WinAPI
push 0 ; arg4
push offset caption_str ; arg3
push offset message_str ; arg2
push 0 ; arg1: hWnd = NULL
call MessageBoxA ; ⚠️ Caller לא מנקה!
; MessageBoxA עצמה עשתה: ret 16 = RET + ESP+=163.3 — System V AMD64 (64 ביט)
בקוד 64 ביט, ארגומנטים מועברים דרך רגיסטרים — לא Stack:
| Convention | Arg 1 | Arg 2 | Arg 3 | Arg 4 | Arg5+ |
|---|---|---|---|---|---|
| System V AMD64 (Linux) | RDI | RSI | RDX | RCX | Stack |
| Microsoft x64 (Windows) | RCX | RDX | R8 | R9 | Stack |
PUSH args לפני CALL + אין ADD ESP אחרי CALL → stdcall
MOV RDI/RSI לפני CALL → System V AMD64 (Linux x64)
MOV RCX/RDX לפני CALL → Microsoft x64
דוגמה מלאה — פונקציה עם ארגומנטים ומשתנים מקומיים
נבנה דוגמה מלאה שמציגה את כל המרכיבים: Prologue, גישה לארגומנטים, הקצאת משתנים מקומיים, חישוב, ו-Epilogue.
4.1 — קוד C
int calculate(int a, int b, int c) {
int temp = a * b; // local 1
int result = temp + c; // local 2
return result;
}
int main() {
int x = calculate(3, 4, 5); // x = (3*4)+5 = 17
return x;
}4.2 — Assembly המקביל (cdecl)
main:
push ebp
mov ebp, esp
sub esp, 4 ; מקום ל-x
push 5 ; arg3 = c
push 4 ; arg2 = b
push 3 ; arg1 = a
call calculate
add esp, 12 ; Caller מנקה: 3 × 4 = 12
mov [ebp-4], eax ; x = ערך החזרה
leave
ret
calculate:
push ebp
mov ebp, esp
sub esp, 8 ; מקום ל-temp ו-result
; [EBP+8] = a=3, [EBP+12] = b=4, [EBP+16] = c=5
; [EBP-4] = temp, [EBP-8] = result
mov eax, [ebp+8] ; EAX = a = 3
imul eax, [ebp+12] ; EAX = a*b = 12
mov [ebp-4], eax ; temp = 12
add eax, [ebp+16] ; EAX = temp+c = 17
mov [ebp-8], eax ; result = 17
mov eax, [ebp-8] ; return result
leave
ret4.3 — מעקב Stack צעד-אחר-צעד
| שלב | פקודה | ESP | EBP | Stack |
|---|---|---|---|---|
| לפני CALL | push 5,4,3 | 0x0FF0 | (main) | [5][4][3]... |
| CALL | call calculate | 0x0FEC | (main) | [RetAddr][5][4][3] |
| PUSH EBP | push ebp | 0x0FE8 | (main) | [EBP_main][RetAddr][5][4][3] |
| MOV EBP,ESP | mov ebp,esp | 0x0FE8 | 0x0FE8 | EBP → ראש |
| SUB ESP,8 | sub esp,8 | 0x0FE0 | 0x0FE8 | [temp][result][EBP_m][RetAddr]... |
| LEAVE | leave | 0x0FEC | (main) | [RetAddr][5][4][3] |
| RET | ret | 0x0FF0 | (main) | חזרנו ל-main! |
| Cleanup | add esp,12 | 0x0FFC | (main) | Stack נקי |
1. מצאו את ה-CALL — ספרו כמה args נדחפו
2. ציירו: [argN]...[arg1][RetAddr][SavedEBP][local1]...
3. EBP מצביע תמיד ל-SavedEBP
4. +4=RetAddr, +8=arg1, +12=arg2, -4=local1, -8=local2
compute:
push ebp / mov ebp,esp / sub esp,4
mov eax, [ebp+8] ; a
mov ecx, [ebp+12] ; b
imul eax, ecx ; a*b
mov [ebp-4], eax ; temp
add eax, [ebp+8] ; temp + a
leave / retmy_func:
; Prologue
push ebp
mov ebp, esp
sub esp, 16 ; 4 locals × 4 bytes = 16
; [EBP+8] = arg1
; [EBP+12] = arg2
; [EBP+16] = arg3
; [EBP-4] = local1
; ...
; (לוגיקה כאן)
; Epilogue
leave
ret
Stack Smashing — למה Return Address הוא המטרה
עכשיו שמבינים איך Stack Frame בנוי, ניתן להבין את הפגיעות הקלאסית ביותר: Buffer Overflow. פרק זה נותן את הבסיס התיאורטי — שיעור 45 יפרט Exploitation מלא.
5.1 — Buffer ב-Stack
; C: void vulnerable(char *input) {
; char buf[16];
; strcpy(buf, input); // ללא בדיקת גודל!
; }
vulnerable:
push ebp
mov ebp, esp
sub esp, 16 ; הקצה 16 bytes ל-buf
; Stack Frame:
; [EBP + 8] = input (מצביע לסטרינג)
; [EBP + 4] = Return Address ← 🎯 המטרה!
; [EBP + 0] = Saved EBP
; [EBP - 4] = buf[12..15] ← buf מתחיל כאן
; ...
; [EBP - 16] = buf[0..3] ← ראש ה-Buffer
lea eax, [ebp-16] ; EAX = כתובת buf
push [ebp+8]
push eax
call strcpy ; 💀 מעתיק עד null — ללא בדיקה!
add esp, 8
leave
ret ; ← POP כתובת חזרה — מה אם שוכתבה?5.2 — מה קורה ב-Overflow
עבור char buf[16]: 16 bytes למלא Buffer + 4 bytes לדרוס Saved EBP = 20 bytes של זבל. הבתים הבאים (21-24) ידרסו את Return Address!
| Offset | מה נדרס | תוצאה |
|---|---|---|
| 0-15 | buf[0..15] | בתוך ה-Buffer — בסדר |
| 16-19 | Saved EBP | EBP של הקורא נהרס |
| 20-23 | Return Address ← 🎯 | EIP = ערך התוקף! |
5.3 — Stack Canary: ההגנה הקלאסית
ערך אקראי שהקומפיילר מכניס בין ה-Buffer לבין ה-Return Address. לפני RET, הפונקציה בודקת שהCanary לא שונה:
; Stack Frame עם Stack Canary (-fstack-protector):
; [EBP + 4] = Return Address
; [EBP + 0] = Saved EBP
; [EBP - 4] = *** CANARY *** (ערך אקראי) ← ← ←
; [EBP - 20] = buf[0..15]
; בדיקה לפני RET:
mov eax, [ebp-4] ; קרא Canary מה-Stack
xor eax, gs:0x14 ; השווה לערך המקורי
jz .ok
call __stack_chk_fail ; ← Buffer Overflow זוהה!2. Return Address נמצא מעל Buffer
3. Overflow כותב מה-Buffer כלפי מעלה — מגיע לSavedEBP ואז לRetAddr
4. שכתוב Return Address = שינוי לאן ה-CPU קופץ אחרי RET
הגנות: Stack Canary, ASLR, NX/DEP — נלמד בשיעור 45
אתגר מעשי — מנגנון ה-Stack — Offset
שיא השיעור: לרדת לרמת-המכונה — C, אסמבלי, היפוך. ההגשה נבדקת אוטומטית.
✓ מה למדנו בשיעור 28
- Stack גדל לכתובות נמוכות — PUSH = ESP-=4, POP = ESP+=4
- ESP תמיד מצביע לראש; EBP קבוע לאורך כל הפונקציה
- Prologue: push ebp / mov ebp,esp / sub esp,N
- Epilogue: leave (=mov esp,ebp + pop ebp) + ret
- Stack Frame: [EBP+0]=SavedEBP, [EBP+4]=RetAddr, [EBP+8]=arg1, [EBP-4]=local1
- cdecl: Caller מנקה (ADD ESP,N) — Linux, GCC
- stdcall: Callee מנקה (RET N) — Windows API
- x64 System V: args ב-RDI/RSI/RDX/RCX — לא ב-Stack
- Buffer ב-Stack נמצא מתחת ל-RetAddr — Overflow כותב כלפי מעלה
- Stack Canary: ערך אקראי בין Buffer ל-RetAddr — בדיקה לפני RET
שאלות תרגול ותשובות (7)
שאלה 1 · מה קורה למחסנית בזמן POP EAX?
pop eax
- א. הערך בכתובת ESP נטען ל-EAX ואז ESP גדל ב-4
- ב. ESP קטן ב-4 והערך נכתב למחסנית
- ג. ESP לא משתנה, רק EAX
- ד. EBP גדל ב-4
המחסנית גדלה כלפי כתובות נמוכות: PUSH מקטין את ESP ב-4, ו-POP קורא את הערך ואז מגדיל את ESP ב-4.
שאלה 2 · מה מבצעת CALL מאחורי הקלעים?
call func
- א. דוחפת את כתובת הפקודה הבאה למחסנית ואז קופצת ל-func
- ב. רק קופצת ל-func
- ג. דוחפת את EBP ואז קופצת
- ד. שומרת את כל הרגיסטרים ואז קופצת
כתובת החזרה נשמרת במחסנית כדי ש-RET תוכל לחזור אליה. זו בדיוק הכתובת שתוקף מנסה לדרוס ב-Buffer Overflow.
שאלה 3 · עבור void test(int x, int y) שנקראה כ-test(5, 7) במוסכמת cdecl — מה הערך ב-[EBP+8]?
- א. 5 — הפרמטר הראשון
- ב. 7 — הפרמטר השני
- ג. כתובת החזרה
- ד. ה-EBP השמור
ב-cdecl הארגומנטים נדחפים מימין לשמאל, ולכן הראשון יושב הכי קרוב: [ebp+8]=x ו-[ebp+12]=y. מתחת ל-EBP ([ebp-4] ומטה) יושבים המשתנים המקומיים.
שאלה 4 · מדוע הפונקציה הבאה קורסת ב-RET?
my_add: push ebp mov ebp, esp sub esp, 8 mov eax, [ebp+8] add eax, [ebp+12] ret
- א. היא לא שחזרה את ESP ואת EBP — חסר Epilogue לפני RET
- ב. חסר push לפרמטרים
- ג. אסור להשתמש ב-EAX לערך מוחזר
- ד. חסר ADD ESP, 8 אצל הקורא
🎯 דגש למבחן — LEAVE: ה-Prologue (push ebp / mov ebp,esp / sub esp,N) חייב Epilogue תואם. הפקודה LEAVE מבצעת פירוק מושלם של המסגרת בפעולה אחת — היא שקולה בדיוק ל-mov esp, ebp ואז pop ebp. בלעדיה ESP מצביע על המשתנים המקומיים, ו-RET "חוזרת" לזבל ומקריסה את התוכנית.
שאלה 5 · מה יהיה ESP מיד אחרי הקוד, אם לפניו ESP = 0x1000?
push 7 push 3 call add_func
- א. 0x0FF4 — שלוש דחיפות של 4 בתים כל אחת
- ב. 0x1000 — ללא שינוי
- ג. 0x0FF8 — רק שני הפרמטרים
- ד. 0x100C — המחסנית גדלה כלפי מעלה
שני פרמטרים (8 בתים) ועוד כתובת החזרה שדחפה CALL (4 בתים) — סה"כ 12 בתים כלפי מטה: 0x1000-0xC = 0x0FF4. ב-cdecl הקורא ינקה בעצמו עם add esp, 8 אחרי החזרה.
שאלה 6 · בפונקציה עם char buf[8], המסגרת בנויה כך: [buf 8 בתים][EBP שמור 4][כתובת חזרה 4]. כמה בתים צריך לכתוב כדי להגיע בדיוק לכתובת החזרה, ובאיזה סדר נכתבת הכתובת?
- א. 12 בתים מילוי ואז הכתובת ב-Little Endian — בייט נמוך ראשון
- ב. 12 בתים מילוי והכתובת כפי שהיא נקראת (Big Endian)
- ג. 8 בתים מילוי והכתובת ב-Big Endian
- ד. 16 בתים מילוי, הסדר לא משנה
🎯 דגש למבחן — Little Endian ב-Payload: אחרי 8+4=12 בתים מגיעים לכתובת החזרה. מעבד x86 שומר מספרים מהבייט הנמוך לגבוה, ולכן כתובת יעד 0xDEADBEEF נכתבת ב-Payload כרצף \xEF\xBE\xAD\xDE. כתיבה בסדר "הטבעי" תקפיץ את התוכנית לכתובת שגויה לגמרי.
שאלה 7 · ASLR מופעל, והתוקף דרס את כתובת החזרה בכתובת קבועה שחישב מראש. מה צפוי לקרות?
- א. התוכנית תקרוס — כתובות הזיכרון משתנות בכל הרצה והכתובת הקבועה כבר לא תקפה
- ב. ההתקפה תצליח כרגיל
- ג. ASLR מונע לחלוטין את הגלישה עצמה
- ד. המחסנית תהפוך לקריאה בלבד
ASLR לא מונע את הגלישה אלא את הניצול: מיקומי הזיכרון מוגרלים מחדש בכל הרצה. לכן תוקפים משלבים דליפת כתובות (Info Leak) או טכניקות יחסיות כמו ROP.