昨天我們介紹了 Function Call,包含:
call
ret
也知道當 CPU 執行:
call foo
時,除了跳到 foo 之外,還會把 Return Address 保存起來。
但是問題來了:
Return Address 到底被放在哪裡?
答案就是今天的主角:
Stack。
Stack 是 Process Memory 中的一塊記憶體區域。
它最重要的特性是:
LIFO(Last In, First Out)
也就是:
最後放進去的東西,最先被拿出來。
可以想像成疊盤子:
┌───────────┐
│ C │ ← 最後放進來
├───────────┤
│ B │
├───────────┤
│ A │
└───────────┘
如果現在要拿東西:
C → B → A
這就是 Stack。
在 Binary 裡,Stack 常被拿來保存:
等等。
而這些東西也是之後學 Pwn 時非常重要的基礎。
這邊有一個很容易搞混的地方。
在 x86-64 中,Stack 是:
從高位址往低位址成長
假設目前:
High Address
0x7fffffffe000
0x7fffffffdff8
0x7fffffffdff0
0x7fffffffdfe8
0x7fffffffdfe0
Low Address
Stack 新增資料時,會往下面長:
High Address
┌─────────────────┐
│ │
├─────────────────┤
│ Return Address │
├─────────────────┤
│ Saved RBP │
├─────────────────┤
│ Local Variable │
├─────────────────┤
│ Local Variable │ ← Stack 繼續往下
└─────────────────┘
Low Address
也就是:
Stack grows downward
↓
所以當 Stack 裡增加資料時,位址反而會變小。
講到 Stack,就一定要先認識:
RSP
也就是:
Stack Pointer
rsp 會指向目前 Stack 的頂端。
例如:
High Address
0x7fffffffe000
0x7fffffffdff8
0x7fffffffdff0 ← RSP
Low Address
如果 Stack 又放進去一個 8 Bytes 的資料:
High Address
0x7fffffffe000
0x7fffffffdff8
0x7fffffffdff0
0x7fffffffdfe8 ← RSP
Low Address
可以看到:
RSP
0x7fffffffdff0
↓
0x7fffffffdfe8
減少了:
8 Bytes
這就是因為 Stack 是往低位址成長。
x86 Assembly 裡有一個很常見的指令:
push
例如:
push rax
意思就是:
把
rax的內容放進 Stack。
假設:
RSP = 0x1000
RAX = 0x12345678
執行:
push rax
大致上可以理解成:
sub rsp, 8
mov [rsp], rax
所以:
RSP
0x1000
↓
0x0ff8
然後:
0x0ff8 = 0x12345678
Stack:
High Address
0x1000
0x0ff8 0x12345678 ← RSP
Low Address
既然有 push,當然也有相反的操作:
pop
例如:
pop rbx
意思就是:
把 Stack 最上面的資料取出來放進
rbx。
概念上可以理解成:
mov rbx, [rsp]
add rsp, 8
例如原本:
0x0ff8 0x12345678 ← RSP
執行:
pop rbx
之後:
RBX = 0x12345678
RSP = 0x1000
所以可以先把它記成:
push → Stack 增加資料 → RSP 下降
pop → Stack 取出資料 → RSP 上升
昨天有提到:
call foo
其實 call 做的事情可以簡化成:
1. 保存 Return Address
2. 跳到 foo
那 Return Address 存在哪裡?
就是:
Stack
假設程式:
0x401000: call foo
0x401005: mov eax, 1
執行:
call foo
之後,CPU 會把:
0x401005
保存到 Stack。
大概像:
High Address
┌──────────────────┐
│ 0x401005 │ ← Return Address
└──────────────────┘
↑
RSP
Low Address
接著 RIP 跳去 foo。
當 foo 執行完:
ret
CPU 就會從 Stack 取出:
0x401005
然後放到:
RIP
所以:
RIP = 0x401005
程式就可以回到:
mov eax, 1
繼續執行。
因此昨天講的:
call
↓
function
↓
ret
現在可以畫得更完整:
main
│
│ call foo
▼
Push Return Address
│
▼
foo()
│
│ ret
▼
Pop Return Address
│
▼
main
這也是為什麼 Stack 對 Function Call 非常重要。
除了 rsp 之外,逆向時還會一直看到另一個 Register:
RBP
也就是:
Base Pointer
rsp 的位置會一直改變。
例如:
push rax
push rbx
push rcx
每執行一次:
RSP
↓
↓
↓
因此如果我們想要有一個比較固定的位置,用來存取 Function 裡面的資料,就可以使用:
RBP
我們先看一個非常經典的 Function:
int add(int a, int b) {
int result = a + b;
return result;
}
使用:
gcc -O0 -fno-omit-frame-pointer test.c -o test
編譯。
在 Assembly 裡可能會看到類似:
add:
push rbp
mov rbp, rsp
...
pop rbp
ret
其中這兩行:
push rbp
mov rbp, rsp
就是非常經典的 Function Prologue。
首先:
push rbp
把上一個 Function 的 rbp 保存起來。
Stack 可能變成:
High Address
┌──────────────────┐
│ Return Address │
├──────────────────┤
│ Saved RBP │ ← RSP
└──────────────────┘
Low Address
接著:
mov rbp, rsp
讓:
RBP = RSP
因此:
RBP
↓
┌──────────────────┐
│ Saved RBP │
└──────────────────┘
↑
RSP
這個位置就可以當作這個 Function 的參考點。
接下來 Function 裡通常還會有:
int a;
int b;
這些 Local Variable 可能會被放在 Stack。
例如:
sub rsp, 0x10
這代表:
RSP = RSP - 0x10
也就是在 Stack 上保留:
16 Bytes
的空間。
Stack 可能變成:
High Address
┌────────────────────┐
│ Return Address │
├────────────────────┤
│ Saved RBP │ ← RBP
├────────────────────┤
│ │
│ Local Variable │
│ │
├────────────────────┤
│ │ ← RSP
└────────────────────┘
Low Address
所以我們可能會看到:
mov DWORD PTR [rbp-0x4], 5
意思就是:
rbp - 4
的位置放入:
5
換成 C 的概念可能就是:
int a = 5;
這也是 Reverse 很常看到的東西。
例如:
mov DWORD PTR [rbp-0x4], 0x5
mov DWORD PTR [rbp-0x8], 0xa
可能對應:
int a = 5;
int b = 10;
Stack 看起來大概就是:
High Address
┌────────────────────┐
│ Return Address │
├────────────────────┤
│ Saved RBP │ ← RBP
├────────────────────┤
│ int a │ ← rbp - 0x4
├────────────────────┤
│ int b │ ← rbp - 0x8
└────────────────────┘
Low Address
這時候就可以開始理解:
mov eax, DWORD PTR [rbp-0x4]
add eax, DWORD PTR [rbp-0x8]
其實就是:
a + b
這也是為什麼理解 Stack 之後,Assembly 會突然比較容易閱讀。
來看一個簡單的例子:
#include <stdio.h>
int add(int a, int b) {
int result = a + b;
return result;
}
int main() {
int x = add(10, 20);
printf("%d\n", x);
}
昨天介紹 System V AMD64 Calling Convention 時有提到:
前幾個參數會透過 Register 傳遞。
所以:
add(10, 20);
大致會是:
RDI = 10
RSI = 20
接著:
call add
進入 add。
Function 一開始可能看到:
push rbp
mov rbp, rsp
建立 Stack Frame。
接著 Compiler 可能把參數暫時存進 Stack:
mov DWORD PTR [rbp-0x14], edi
mov DWORD PTR [rbp-0x18], esi
然後:
mov edx, DWORD PTR [rbp-0x14]
mov eax, DWORD PTR [rbp-0x18]
add eax, edx
mov DWORD PTR [rbp-0x4], eax
如果轉回 C:
int result = a + b;
最後:
mov eax, DWORD PTR [rbp-0x4]
pop rbp
ret
因為昨天有提到:
RAX
通常用來保存 Return Value。
所以:
mov eax, [rbp-0x4]
就是把:
result
當作 Function Return Value。
最後:
ret
回到 main。
把目前的東西全部放在一起:
High Address
↑
┌─────────────────────┐
│ Return Address │
├─────────────────────┤
│ Saved RBP │ ← RBP
├─────────────────────┤
│ │
│ Local Variable │
│ │
├─────────────────────┤
│ Local Variable │
├─────────────────────┤
│ │ ← RSP
└─────────────────────┘
↓
Low Address
現在應該就比較能理解:
push rbp
mov rbp, rsp
sub rsp, 0x20
是在做什麼。
可以把它想成:
保存上一個 Stack Frame
↓
建立新的 Stack Fr