Hiểu Stack Frame Trong Assembly: Từ Code C++ Đến Memory Address

Khi học Assembly, bạn thường gặp khái niệm stack frame nhưng khó hình dung cách nó hoạt động trong thực tế. Tại sao tham số lại ở [ebp+8]? Tại sao local variables ở [ebp-4]? Địa chỉ bộ nhớ thực sự trông như thế nào?

Bài viết này sẽ đưa bạn từ code C++ đơn giản, qua Assembly x86, đến phân tích stack frame với memory address cụ thể (như 0x0012FF78). Bạn sẽ thấy rõ cách compiler xây dựng stack frame, EBP/ESP thay đổi thế nào, và hiểu calling convention từ góc nhìn thực tế.

#1. Memory Layout - Stack Và Heap Nằm Ở Đâu?

Trước khi đi sâu vào stack frame, bạn cần hiểu bộ nhớ của process được tổ chức thế nào. Hiểu rõ memory layout giúp bạn:

  • Phân biệt stack và heap - Biết khi nào dùng từng loại, tránh stack overflow
  • Debug memory corruption - Xác định lỗi truy cập sai vùng nhớ (segmentation fault)
  • Reverse malware hiệu quả - Nhận biết shellcode, buffer overflow, ROP chains
  • Tối ưu hiệu năng - Hiểu chi phí của dynamic allocation vs stack allocation

Mỗi process có vùng nhớ riêng, chia thành nhiều segments khác nhau.

#1.1. Memory Layout Của Process

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Địa chỉ cao (0xFFFFFFFF) ← HIGH ADDRESS
+---------------------------+
|    Kernel Space           |  ← OS kernel (không truy cập được)
+---------------------------+
|    Stack                  |  ← Mở rộng XUỐNG ↓ (giảm địa chỉ)
|         ↓                 |     ESP/RSP trỏ đây
+---------------------------+
|    Memory Mapped Files    |  ← Shared libraries, DLLs
+---------------------------+
|         ↑                 |
|    Heap                   |  ← Mở rộng LÊN ↑ (tăng địa chỉ)
+---------------------------+     new/malloc cấp phát ở đây
|    .bss (uninitialized)   |  ← Biến global chưa khởi tạo
+---------------------------+     int globalVar;
|    .data (initialized)    |  ← Biến global đã khởi tạo
+---------------------------+     int globalVar = 10;
|    .text (code)           |  ← Code thực thi (instructions)
+---------------------------+  0x00400000 (Windows) / 0x08048000 (Linux)
Địa chỉ thấp (0x00000000) ← LOW ADDRESS

#1.2. Tại Sao Stack Mở Rộng Xuống?

Lý do lịch sử:

  • CPU architecture ban đầu thiết kế stack “grows down” (từ địa chỉ cao xuống thấp)
  • Heap “grows up” (từ địa chỉ thấp lên cao)
  • Tạo khoảng trống giữa stack và heap → tránh va chạm

Ví dụ cụ thể:

1
2
3
4
5
6
7
8
9
10
11
Ban đầu (process vừa start):
0xFFFFFFFF
    |
0xBFFFFFFF  ← Stack bắt đầu (ESP)
    |
    | (khoảng trống ~3GB)
    |
0x08050000  ← Heap bắt đầu
    |
0x08048000  ← .text (code)
0x00000000

Sau khi gọi nhiều hàm và malloc:

1
2
3
4
5
6
7
8
9
10
11
12
0xFFFFFFFF
    |
0xBFFFE000  ← Stack đã mở rộng XUỐNG (ESP giảm)
    |         Push nhiều frame
    | (khoảng trống còn ~2.9GB)
    |
0x08070000  ← Heap đã mở rộng LÊN (malloc tăng)
    |
0x08050000
    |
0x08048000
0x00000000

#1.3. So Sánh Stack Và Heap

Đặc điểmStackHeap
Vị tríĐịa chỉ cao (gần 0xFFFFFFFF)Địa chỉ thấp (sau .data segment)
Hướng mở rộngXuống ↓ (giảm địa chỉ)Lên ↑ (tăng địa chỉ)
Kích thướcNhỏ (1-8MB)Lớn (chỉ giới hạn bởi RAM)
Tốc độRất nhanh (ESP +/-)Chậm hơn (malloc phức tạp)
Quản lýTự động (hàm return = giải phóng)Thủ công (phải free/delete)
Thread-safeMỗi thread có stack riêngShared giữa threads
FragmentationKhông cóCó thể bị phân mảnh

#1.4. Khi Nào Dùng Stack Hay Heap?

Dùng Stack khi:

1
2
3
4
5
void process() {
    int count = 0;          // Stack - OK (vòng đời ngắn)
    char name[32];          // Stack - OK (dữ liệu nhỏ)
    int small[100];         // Stack - OK (400 byte)
}

Dùng Heap khi:

1
2
3
4
5
6
7
8
9
10
11
void loadImage() {
    // Heap - PHẢI dùng cho dữ liệu lớn
    int* bigArray = new int[1000000];  // 4MB
    delete[] bigArray;
}

// Modern C++ - Smart pointer
void loadImageModern() {
    auto bigArray = std::make_unique<int[]>(1000000);
    // Tự động delete khi ra khỏi scope
}

Quyết định dùng Stack hay Heap:

View Mermaid diagram code
flowchart TD
    Start([Cần cấp phát bộ nhớ]) --> Size{Kích thước<br/>dữ liệu?}
    Size -->|"< 1KB"| Lifetime{Vòng đời<br/>dữ liệu?}
    Size -->|"> 1KB"| UseHeap([Dùng Heap])
    Lifetime -->|Chỉ trong hàm| Scope{Cần trả về<br/>pointer?}
    Lifetime -->|Qua nhiều hàm| UseHeap
    Scope -->|Không| UseStack([Dùng Stack<br/>Biến local tự động])
    Scope -->|Có| UseHeap
    UseHeap --> Modern{C++11+?}
    Modern -->|Có| SmartPtr["std::unique_ptr<br/>std::shared_ptr"]
    Modern -->|Không| NewDelete[new/delete<br/>Nhớ phải delete!]

Bây giờ bạn đã hiểu stack nằm ở đâu trong memory và khi nào nên dùng, hãy cùng xem cách compiler sử dụng stack để gọi hàm và xây dựng stack frame.

#2. Gọi Hàm Trong Assembly - Từ C++ Đến Stack Frame (x86)

Mỗi hàm C++ được compile thành Assembly sẽ có function prologue (khởi tạo) và epilogue (dọn dẹp) do compiler tự động tạo ra để thiết lập và thu hồi stack frame. Hiểu rõ cách này hoạt động giúp bạn:

  • Debug crash chính xác - Biết chính xác hàm nào gây lỗi và tại sao
  • Phân tích malware - Reverse engineer để hiểu logic của mã độc
  • Tối ưu performance - Nhận biết overhead của function calls
  • Tránh lỗ hổng bảo mật - Phát hiện buffer overflow và stack corruption

Phần này sẽ phân tích từng bước từ code C++ → Assembly x86 → Stack frame với ví dụ thực tế.

#Lưu ý

Ví dụ này sử dụng x86 (32-bit) với cdecl calling convention - tham số truyền qua stack. Nếu bạn làm việc với x64 (64-bit), 4 tham số đầu được truyền qua thanh ghi (RCX, RDX, R8, R9) thay vì stack. Xem chi tiết tại Calling Conventions trong Windows x86 và x64.

#2.1. Các Thành Phần Chính Của Stack Frame

Trước khi đi vào code, bạn cần hiểu một stack frame bao gồm những gì. Mỗi khi hàm được gọi, một stack frame mới được tạo ra chứa:

Thành phầnVị tríMô tả
Local Variables[ebp-4], [ebp-8], …Biến cục bộ của hàm (int temp, char buffer[], …)
Saved EBP[ebp]EBP của hàm gọi (caller’s EBP) - dùng để khôi phục sau
Return Address[ebp+4]Địa chỉ để quay về caller sau khi hàm kết thúc
Arguments[ebp+8], [ebp+12], …Tham số truyền vào hàm (a, b, c, …)

Sơ đồ stack frame điển hình:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Địa chỉ cao (High Address)
+-------------------------------+
0x0012FF7C:  | Argument 2 (b)   |  [ebp+12]  ← Tham số 2 (0x0012FF70 + 12 = 0x0012FF7C)
+-------------------------------+
0x0012FF78:  | Argument 1 (a)   |  [ebp+8]   ← Tham số 1 (0x0012FF70 + 8 = 0x0012FF78)
+-------------------------------+
0x0012FF74:  | Return Address   |  [ebp+4]   ← Địa chỉ quay về caller (0x0012FF70 + 4 = 0x0012FF74)
+-------------------------------+
0x0012FF70:  | Saved EBP        |  [ebp]     ← EBP của caller, EBP = 0x0012FF70
+-------------------------------+
0x0012FF6C:  | Local Variable 1 |  [ebp-4]   ← Biến local 1 (0x0012FF70 - 4 = 0x0012FF6C)
+-------------------------------+
0x0012FF68:  | Local Variable 2 |  [ebp-8]   ← Biến local 2 (0x0012FF70 - 8 = 0x0012FF68)
+-------------------------------+   ← ESP = 0x0012FF68 (đỉnh stack)
Địa chỉ thấp (Low Address)

Tại sao cần EBP làm “anchor”?

ESP liên tục thay đổi khi push/pop, khó truy cập arguments và locals. EBP cố định suốt hàm như một “mốc tọa độ”:

  • Arguments ở địa chỉ cao hơn EBP: [ebp+8], [ebp+12], … (địa chỉ tăng lên)
  • Locals ở địa chỉ thấp hơn EBP: [ebp-4], [ebp-8], … (địa chỉ giảm xuống)
  • EBP không đổi suốt hàm, nên offset luôn chính xác
  • Saved EBP tạo “chain”: Mỗi hàm lưu EBP của caller → debugger theo chuỗi này để hiển thị call stack

#2.2. Liên Kết C++ + Assembly + Stack Frame

Bắt đầu với hàm tính lương sau thuế. Qua ví dụ này bạn sẽ thấy cách compiler chuyển C++ sang Assembly và xây dựng stack frame với local variables.

#2.2.1. Code C++

1
2
3
4
5
6
7
8
9
10
int calculateNetSalary(int baseSalary, int bonus) {
    int grossPay = baseSalary + bonus;  // Tổng lương trước thuế
    int tax = grossPay / 10;            // Thuế 10%
    return grossPay - tax;              // Lương sau thuế
}

int main() {
    int salary = calculateNetSalary(1000, 200);  // salary = 1080
    return 0;
}

#2.2.2. Assembly Code

Compiler sẽ chuyển đổi sang Assembly như sau. Chú ý các bước prologue (thiết lập frame) và epilogue (dọn dẹp frame):

Hàm calculateNetSalary (callee) - Hàm được gọi:

Phần 1: Prologue - Thiết lập stack frame

1
2
3
4
calculateNetSalary:
    push ebp             ; [1] Lưu EBP của main lên stack
    mov  ebp, esp        ; [2] EBP = ESP (tạo mốc cố định)
    sub  esp, 8          ; [3] Cấp phát 8 byte cho 2 biến local

Chi tiết:

  • push ebp - Lưu EBP của caller (main) để khôi phục sau
  • mov ebp, esp - Tạo “anchor point” cố định (EBP = ESP)
  • sub esp, 8 - Cấp phát 8 byte trên stack (2 biến × 4 byte = grossPay và tax)

Phần 2: Function Body - Tính toán grossPay

1
2
3
4
; int grossPay = baseSalary + bonus
mov  eax, [ebp+8]    ; eax = baseSalary (tham số 1)
add  eax, [ebp+12]   ; eax = baseSalary + bonus (tham số 2)
mov  [ebp-4], eax    ; Lưu kết quả vào grossPay

Chi tiết:

  • [ebp+8] = baseSalary - Tham số đầu tiên ở [ebp+8] (vì [ebp] = saved EBP, [ebp+4] = return address)
  • [ebp+12] = bonus - Tham số thứ 2 ở vị trí kế tiếp (+4 byte)
  • [ebp-4] = grossPay - Biến local đầu tiên ở phía dưới EBP

Phần 3: Function Body - Tính toán tax

1
2
3
4
5
6
; int tax = grossPay / 10
mov  eax, [ebp-4]    ; eax = grossPay
cdq                  ; Mở rộng EAX thành EDX:EAX cho phép chia
mov  ecx, 10         ; ecx = 10
idiv ecx             ; eax = grossPay / 10
mov  [ebp-8], eax    ; Lưu kết quả vào tax

Chi tiết:

  • cdq - Sign-extend EAX vào EDX:EAX (cần cho idiv với số có dấu)
  • [ebp-8] = tax - Biến local thứ 2 ở vị trí -8 byte từ EBP

Phần 4: Return Value - Tính kết quả cuối cùng

1
2
3
4
; return grossPay - tax
mov  eax, [ebp-4]    ; eax = grossPay
sub  eax, [ebp-8]    ; eax = grossPay - tax
                     ; Return value luôn đặt trong EAX

Chi tiết:

  • eax = return value - Convention chuẩn: hàm trả về integer qua thanh ghi EAX

Phần 5: Epilogue - Dọn dẹp và quay về

1
2
3
mov  esp, ebp        ; [4] Giải phóng local variables (ESP trở về EBP)
pop  ebp             ; [5] Khôi phục EBP của main từ stack
ret                  ; [6] Pop return address và nhảy về main

Chi tiết:

  • mov esp, ebp - Giải phóng toàn bộ local variables (ESP tăng từ 0x0012FF70 → 0x0012FF78)
  • pop ebp - Khôi phục EBP của caller
  • ret - Pop return address và nhảy về caller

Hàm main (caller) - Hàm gọi:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
main:
    ; === Prologue của main ===
    push ebp
    mov  ebp, esp
    sub  esp, 4          ; Cấp phát 4 byte cho biến local 'salary'

    ; === Chuẩn bị gọi calculateNetSalary(1000, 200) ===
    push 200             ; Đẩy tham số 2 (bonus) - push từ PHẢI sang TRÁI
    push 1000            ; Đẩy tham số 1 (baseSalary)
    call calculateNetSalary  ; Gọi hàm (CPU tự động push return address)
    add  esp, 8          ; Dọn dẹp 2 tham số (8 byte = 2 * 4 byte)
                         ; cdecl: CALLER dọn dẹp stack

    ; === Sử dụng return value ===
    mov  [ebp-4], eax    ; salary = eax (1080)

    ; === Epilogue của main ===
    mov  esp, ebp
    pop  ebp
    ret

Tại sao push từ phải sang trái?

C/C++ convention: tham số push theo thứ tự ngược (right-to-left) để đảm bảo tham số đầu tiên luôn ở vị trí cố định [ebp+8]. Điều này giúp compiler dễ dàng sinh code và hỗ trợ variadic functions (hàm với số tham số thay đổi) - tham số đầu tiên luôn biết trước vị trí dù có bao nhiêu tham số sau đó.

#2.2.3. Stack Frame

Hãy theo dõi stack thay đổi thế nào khi main() gọi calculateNetSalary(1000, 200). Điều này rất quan trọng khi debug crash hoặc phân tích malware.

Bước 1: main() đẩy tham số lên stack

1
2
3
4
5
6
7
8
9
10
11
; Trong main: push 200
;             push 1000

Địa chỉ cao (High Address)
+------------------------------+
0x0012FF84:  | 200 (bonus)     |  [esp+4]  ← Tham số 2
+------------------------------+
0x0012FF80:  | 1000 (baseSal.) |  [esp]    ← Tham số 1, ESP = 0x0012FF80
+------------------------------+
         ... (main's stack frame)
Địa chỉ thấp (Low Address)

Bước 2: call calculateNetSalary - CPU tự động push return address

1
2
3
4
5
6
7
8
9
10
11
12
13
; CPU thực thi: push EIP (return address)
;               jmp calculateNetSalary

Địa chỉ cao (High Address)
+------------------------------+
0x0012FF84:  | 200 (bonus)     |  [esp+8]
+------------------------------+
0x0012FF80:  | 1000 (baseSal.) |  [esp+4]
+------------------------------+
0x0012FF7C:  | Return address  |  [esp]    ← ESP = 0x0012FF7C (địa chỉ trong main)
+------------------------------+  ← Đầu hàm calculateNetSalary
         ... (main's stack frame)
Địa chỉ thấp (Low Address)

Lúc này EIP (Instruction Pointer) nhảy đến hàm calculateNetSalary, CPU bắt đầu thực thi prologue. Lưu ý ESP giảm từ 0x0012FF800x0012FF7C (giảm 4 byte) sau khi call push return address.

Bước 3: Prologue của calculateNetSalary() - push ebp; mov ebp, esp; sub esp, 8

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
; calculateNetSalary: push ebp
;                     mov ebp, esp
;                     sub esp, 8

Địa chỉ cao (High Address)
+------------------------------+
0x0012FF84:  | 200 (bonus)     |  [ebp+12]  ← Tham số 2 (0x0012FF78 + 12 = 0x0012FF84)
+------------------------------+
0x0012FF80:  | 1000 (baseSal.) |  [ebp+8]   ← Tham số 1 (0x0012FF78 + 8 = 0x0012FF80)
+------------------------------+
0x0012FF7C:  | Return address  |  [ebp+4]   ← Địa chỉ quay về main (0x0012FF78 + 4)
+------------------------------+
0x0012FF78:  | Saved EBP       |  [ebp]     ← EBP của main, EBP = 0x0012FF78
+------------------------------+
0x0012FF74:  | grossPay (?)    |  [ebp-4]   ← Local var 1 (0x0012FF78 - 4 = 0x0012FF74)
+------------------------------+
0x0012FF70:  | tax (?)         |  [ebp-8]   ← Local var 2 (0x0012FF78 - 8 = 0x0012FF70)
+------------------------------+  ← ESP = 0x0012FF70
0x0012FF6C:  | main's frame    |
+------------------------------+
Địa chỉ thấp (Low Address)

Bây giờ bạn có thể hiểu:

  • Stack grows down: ESP giảm từ 0x0012FF7C0x0012FF78 (push ebp) → 0x0012FF70 (sub esp, 8)
  • Tại sao [ebp+8]? Vì EBP = 0x0012FF78, nên [ebp+8] = 0x0012FF80 (baseSalary)
  • Tại sao [ebp-4]?0x0012FF78 - 4 = 0x0012FF74 (grossPay)
  • Arguments ở địa chỉ cao hơn EBP (hướng lên +4, +8, +12…)
  • Local variables ở địa chỉ thấp hơn EBP (hướng xuống -4, -8, -12…)

Bước 4: Epilogue của calculateNetSalary() - mov esp, ebp; pop ebp; ret

1
2
3
4
5
6
7
8
9
10
11
12
13
; calculateNetSalary: mov esp, ebp (giải phóng local variables)
;                     pop ebp (khôi phục EBP của main)
;                     ret (pop return address và nhảy về)

Địa chỉ cao (High Address)
+------------------------------+
0x0012FF84:  | 200 (bonus)     |  [esp+4]  ← Rác, sẽ bị ghi đè sau
+------------------------------+
0x0012FF80:  | 1000 (baseSal.) |  [esp]    ← ESP = 0x0012FF80
+------------------------------+  ← EIP quay về main, EAX = 1080
0x0012FF7C:  | main's frame    |
+------------------------------+
Địa chỉ thấp (Low Address)

Sau ret, ESP trở về vị trí trước khi call (trỏ vào arguments). Lưu ý:

  • mov esp, ebp: ESP tăng từ 0x0012FF700x0012FF78 (giải phóng local vars)
  • pop ebp: ESP tăng từ 0x0012FF780x0012FF7C (khôi phục EBP)
  • ret: ESP tăng từ 0x0012FF7C0x0012FF80 (pop return address)
  • Return value (1080) nằm trong thanh ghi EAX
  • main phải dọn dẹp arguments bằng add esp, 8

Bước 5: main() dọn dẹp stack - add esp, 8

1
2
3
4
5
6
7
; main: add esp, 8 (bỏ qua 2 tham số)

Địa chỉ cao (High Address)
+------------------------------+
0x0012FF88:  | main's frame    |  ← ESP = 0x0012FF88 (trở về vị trí ban đầu)
+------------------------------+
Địa chỉ thấp (Low Address)

Stack trở về trạng thái như trước khi gọi calculateNetSalary(). Lưu ý:

  • add esp, 8: ESP tăng từ 0x0012FF800x0012FF88 (bỏ qua 2 tham số)
  • Return value (1080) nằm trong EAX và được lưu vào biến salary tại [ebp-4]
  • Stack đã được dọn dẹp hoàn toàn, sẵn sàng cho lần gọi hàm tiếp theo

#2.3. Function Prologue & Epilogue

Bây giờ bạn đã thấy toàn bộ quá trình, hãy tập trung vào prologue và epilogue - hai đoạn code quan trọng nhất trong mỗi hàm.

#Function Prologue - Thiết Lập Frame

Mục đích: Tạo một stack frame mới để hàm có thể hoạt động độc lập với caller.

1
2
3
4
myFunction:
    push ebp             ; [1] Lưu EBP của caller
    mov  ebp, esp        ; [2] Tạo mốc cố định (EBP = ESP)
    sub  esp, 16         ; [3] Cấp phát 16 byte cho local variables

Từng bước:

  1. push ebp - Lưu EBP của caller để khôi phục sau. Tạo “chain” giúp debugger unwind call stack và hiển thị toàn bộ chuỗi hàm gọi
  2. mov ebp, esp - Đặt EBP = ESP. Từ giờ EBP là “anchor point” cố định
  3. sub esp, N - Cấp phát N byte cho biến cục bộ. Ví dụ: 2 biến int → cần 8 byte

Stack sau prologue:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Địa chỉ cao (High Address)
+------------------------------+
0x0012FF84:  | Argument 2      |  [ebp+12]
+------------------------------+
0x0012FF80:  | Argument 1      |  [ebp+8]
+------------------------------+
0x0012FF7C:  | Return address  |  [ebp+4]
+------------------------------+
0x0012FF78:  | Saved EBP       |  [ebp]     ← EBP = 0x0012FF78
+------------------------------+
0x0012FF74:  | Local var 1     |  [ebp-4]
+------------------------------+
0x0012FF70:  | Local var 2     |  [ebp-8]
+------------------------------+  ← ESP = 0x0012FF70
0x0012FF6C:  | Caller's frame  |
+------------------------------+
Địa chỉ thấp (Low Address)

#Function Epilogue - Dọn Dẹp Frame

Mục đích: “Phá hủy” stack frame và quay về caller, giữ return value trong EAX.

Cách 1: Thủ công

1
2
3
4
mov  eax, [ebp-4]    ; Đặt return value vào EAX (nếu có)
mov  esp, ebp        ; [1] Giải phóng local variables
pop  ebp             ; [2] Khôi phục caller's EBP
ret                  ; [3] Nhảy về caller

Cách 2: Dùng lệnh leave (recommended)

1
2
3
mov  eax, [ebp-4]    ; Đặt return value vào EAX
leave                ; Tương đương mov esp,ebp; pop ebp
ret                  ; Nhảy về caller

Tại sao dùng leave?

  • Ngắn gọn hơn (1 lệnh thay vì 2)
  • Ít lỗi hơn - không thể quên mov esp, ebp
  • Compiler hiện đại thường dùng leave

#3. Nguyên Nhân Hàm Crash Và Cách Phân Tích

Khi làm việc với Assembly hoặc reverse malware, bạn sẽ thường gặp các lỗi crash liên quan đến stack. Hiểu rõ nguyên nhân giúp bạn:

  • Debug nhanh hơn - Nhận diện lỗi stack corruption ngay lập tức
  • Phân tích crash dumps - Đọc call stack để tìm hàm gây lỗi
  • Tránh lỗi khi viết Assembly - Đảm bảo prologue/epilogue đúng
  • Phát hiện buffer overflow - Nhận biết dấu hiệu bị exploit

Phần này phân tích hai lỗi phổ biến nhất: stack pointer corruptionstack overflow.

#3.1. Stack Pointer Corruption - ESP/EBP Không Đồng Bộ

Lỗi phổ biến:

1
2
3
4
5
6
7
8
9
10
myFunction:
    push ebp
    mov  ebp, esp
    sub  esp, 16      ; Cấp phát 16 byte

    ; ... code ...

    ; THIẾU: mov esp, ebp (quên khôi phục ESP!)
    pop  ebp
    ret               ; ESP sai → crash

Phân tích:

Nếu thiếu mov esp, ebp, ESP vẫn trỏ vào vùng local variables (0x0012FF70). Khi pop ebp, sẽ lấy sai giá trị từ local variable thay vì saved EBP → EBP bị hỏng → các hàm tiếp theo crash.

Stack frame khi bị lỗi:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Địa chỉ cao (High Address)
+------------------------------+
0x0012FF7C:  | Return address  |  [ebp+4]
+------------------------------+
0x0012FF78:  | Saved EBP       |  [ebp]     ← EBP = 0x0012FF78 (✅ ĐÚNG)
+------------------------------+
0x0012FF74:  | Local var 1     |  [ebp-4]
+------------------------------+
0x0012FF70:  | Local var 2     |  [ebp-8]   ← ESP = 0x0012FF70 (🚨 SAI!)
+------------------------------+                ⚠️ Thiếu mov esp, ebp
Địa chỉ thấp (Low Address)

⚠️ Khi thực thi pop ebp:
- pop ebp sẽ lấy giá trị tại [ESP] = 0x0012FF70
- Giá trị này là local variable (rác), KHÔNG PHẢI saved EBP!
- EBP bị ghi đè bằng giá trị rác → hàm tiếp theo crash
- ESP tăng lên 0x0012FF74 (vẫn sai)
- ret sẽ lấy return address từ 0x0012FF74 (local var) thay vì 0x0012FF7C → crash!

Stack frame ĐÚNG (có mov esp, ebp):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Địa chỉ cao (High Address)
+------------------------------+
0x0012FF7C:  | Return address  |  [ebp+4]
+------------------------------+
0x0012FF78:  | Saved EBP       |  [ebp]     ← EBP = 0x0012FF78
+------------------------------+              ← ESP = 0x0012FF78 (✅ sau mov esp, ebp)
0x0012FF74:  | Local var 1     |  [ebp-4]      (đã giải phóng)
+------------------------------+
0x0012FF70:  | Local var 2     |  [ebp-8]      (đã giải phóng)
+------------------------------+
Địa chỉ thấp (Low Address)

✅ Khi thực thi pop ebp:
- ESP đã được khôi phục về 0x0012FF78 (bằng EBP)
- pop ebp lấy giá trị ĐÚNG từ [ESP] = saved EBP
- ESP tăng lên 0x0012FF7C (trỏ vào return address)
- ret lấy return address từ 0x0012FF7C → quay về caller thành công!

Trong thực tế:

Compiler hiện đại thường dùng lệnh leave (tương đương mov esp, ebp; pop ebp) để tránh lỗi này. Tuy nhiên khi viết Assembly thủ công, bạn phải cẩn thận!

#3.2. Stack Overflow - Đệ Quy Quá Sâu

Code gây overflow:

1
2
3
4
void infinite() {
    int arr[1000];  // 4000 byte mỗi lần gọi
    infinite();     // Đệ quy vô hạn
}

Phân tích:

Mỗi lần gọi infinite() tốn ~4KB stack. Nếu gọi 2000 lần → tốn 8MB → vượt quá stack limit (thường 1-8MB) → Stack Overflow

Cách fix:

  • Chuyển sang iterative (vòng lặp)
  • Dùng đệ quy đuôi (tail recursion)
  • Chuyển mảng lớn sang heap (malloc/new)

Vậy là bạn đã hiểu cách phân tích stack frame với địa chỉ cụ thể, tìm nguyên nhân crash, và phân biệt stack vs heap. Kiến thức này cực kỳ quan trọng khi debug Assembly, reverse malware, hoặc phân tích crash dumps.

Đặc biệt khi làm việc với legacy code hoặc viết shellcode, việc hiểu rõ stack layout giúp bạn tránh được 90% lỗi memory corruption. Hãy thử mở debugger và phân tích stack của chương trình bạn đang làm ngay!