(我已经对这个答案中的所有代码做了一个要点,以防你想玩它)
在 2003 年的 CS101 课程中,我只在 asm 中做过最基本的事情。而且我从来没有真正“了解”asm 和堆栈的工作原理,直到我意识到这一切基本上就像用 C 或 C++ 编程......但没有局部变量、参数和函数。可能听起来还不容易 :) 让我向您展示(对于带有Intel 语法的 x86 asm )。
1.什么是栈
堆栈通常是在每个线程启动之前为其分配的连续内存块。你可以在那里存储任何你想要的东西。用 C++ 术语(代码片段 #1):
const int STACK_CAPACITY = 1000;
thread_local int stack[STACK_CAPACITY];
2.堆栈的顶部和底部
stack
原则上,您可以将值存储在数组的随机单元格中(片段 #2.1):
stack[333] = 123;
stack[517] = 456;
stack[555] = stack[333] + stack[517];
stack
但是想象一下,记住哪些单元已经在使用以及哪些单元是“免费的”将是多么困难。这就是为什么我们将新值存储在彼此相邻的堆栈上。
关于 (x86) asm 的堆栈的一个奇怪的事情是,您从最后一个索引开始添加东西并移动到较低的索引:堆栈 [999],然后是堆栈 [998] 等等(片段 #2.2):
stack[999] = 123;
stack[998] = 456;
stack[997] = stack[999] + stack[998];
并且(小心,你现在会感到困惑)“官方”名称stack[999]
仍然是bottom of the stack。
最后使用的单元格(stack[997]
在上面的示例中)称为堆栈顶部(请参阅堆栈顶部在 x86 上的位置)。
3. 堆栈指针(SP)
出于讨论的目的,我们假设 CPU 寄存器表示为全局变量(请参阅通用寄存器)。
int AX, BX, SP, BP, ...;
int main(){...}
有一个特殊的 CPU 寄存器 (SP) 跟踪堆栈的顶部。SP 是一个指针(保存一个像 0xAAAABBCC 这样的内存地址)。但出于本文的目的,我将使用它作为数组索引 (0, 1, 2, ...)。
当一个线程启动时,SP == STACK_CAPACITY
程序和操作系统会根据需要对其进行修改。规则是您不能写入超出堆栈顶部的堆栈单元,并且任何小于 SP 的索引都是无效且不安全的(因为系统中断),因此您
首先减少 SP,然后将值写入新分配的单元。
当您想将多个值连续推送到堆栈中时,您可以预先为所有这些值保留空间(片段 #3):
SP -= 3;
stack[999] = 12;
stack[998] = 34;
stack[997] = stack[999] + stack[998];
笔记。现在您可以看到为什么堆栈上的分配如此之快 - 它只是单个寄存器递减。
4.局部变量
让我们看一下这个简单的函数(片段 #4.1):
int triple(int a) {
int result = a * 3;
return result;
}
并在不使用局部变量的情况下重写它(片段 #4.2):
int triple_noLocals(int a) {
SP -= 1; // move pointer to unused cell, where we can store what we need
stack[SP] = a * 3;
return stack[SP];
}
看看它是如何被调用的(片段#4.3):
// SP == 1000
someVar = triple_noLocals(11);
// now SP == 999, but we don't need the value at stack[999] anymore
// and we will move the stack index back, so we can reuse this cell later
SP += 1; // SP == 1000 again
5.推/弹出
在栈顶添加一个新元素是一个非常频繁的操作,CPU 有一个特殊的指令,push
. 我们将像这样实现它(片段 5.1):
void push(int value) {
--SP;
stack[SP] = value;
}
同样,取堆栈的顶部元素(片段 5.2):
void pop(int& result) {
result = stack[SP];
++SP; // note that `pop` decreases stack's size
}
push/pop 的常见使用模式是暂时节省一些价值。比如说,我们在变量中有一些有用的东西myVar
,出于某种原因,我们需要进行覆盖它的计算(片段 5.3):
int myVar = ...;
push(myVar); // SP == 999
myVar += 10;
... // do something with new value in myVar
pop(myVar); // restore original value, SP == 1000
6.功能参数
现在让我们使用堆栈(片段#6)传递参数:
int triple_noL_noParams() { // `a` is at index 999, SP == 999
SP -= 1; // SP == 998, stack[SP + 1] == a
stack[SP] = stack[SP + 1] * 3;
return stack[SP];
}
int main(){
push(11); // SP == 999
assert(triple(11) == triple_noL_noParams());
SP += 2; // cleanup 1 local and 1 parameter
}
7.return
声明
让我们在 AX 寄存器中返回值(片段 #7):
void triple_noL_noP_noReturn() { // `a` at 998, SP == 998
SP -= 1; // SP == 997
stack[SP] = stack[SP + 1] * 3;
AX = stack[SP];
SP += 1; // finally we can cleanup locals right in the function body, SP == 998
}
void main(){
... // some code
push(AX); // save AX in case there is something useful there, SP == 999
push(11); // SP == 998
triple_noL_noP_noReturn();
assert(triple(11) == AX);
SP += 1; // cleanup param
// locals were cleaned up in the function body, so we don't need to do it here
pop(AX); // restore AX
...
}
8.栈基指针(BP)(也称帧指针)和栈帧
让我们使用更多“高级”函数并用我们的 asm-like C++ 重写它(片段 #8.1):
int myAlgo(int a, int b) {
int t1 = a * 3;
int t2 = b * 3;
return t1 - t2;
}
void myAlgo_noLPR() { // `a` at 997, `b` at 998, old AX at 999, SP == 997
SP -= 2; // SP == 995
stack[SP + 1] = stack[SP + 2] * 3;
stack[SP] = stack[SP + 3] * 3;
AX = stack[SP + 1] - stack[SP];
SP += 2; // cleanup locals, SP == 997
}
int main(){
push(AX); // SP == 999
push(22); // SP == 998
push(11); // SP == 997
myAlgo_noLPR();
assert(myAlgo(11, 22) == AX);
SP += 2;
pop(AX);
}
现在想象一下,我们决定在返回之前引入新的局部变量来存储结果,就像我们在tripple
(片段#4.1)中所做的那样。函数的主体将是(片段 #8.2):
SP -= 3; // SP == 994
stack[SP + 2] = stack[SP + 3] * 3;
stack[SP + 1] = stack[SP + 4] * 3;
stack[SP] = stack[SP + 2] - stack[SP + 1];
AX = stack[SP];
SP += 3;
你看,我们必须更新对函数参数和局部变量的每一个引用。为了避免这种情况,我们需要一个锚索引,它不会随着堆栈的增长而改变。
我们将通过将当前顶部(SP 的值)保存到 BP 寄存器中来在函数进入时(在我们为局部变量分配空间之前)创建锚点。片段#8.3:
void myAlgo_noLPR_withAnchor() { // `a` at 997, `b` at 998, SP == 997
push(BP); // save old BP, SP == 996
BP = SP; // create anchor, stack[BP] == old value of BP, now BP == 996
SP -= 2; // SP == 994
stack[BP - 1] = stack[BP + 1] * 3;
stack[BP - 2] = stack[BP + 2] * 3;
AX = stack[BP - 1] - stack[BP - 2];
SP = BP; // cleanup locals, SP == 996
pop(BP); // SP == 997
}
属于并完全控制函数的堆栈切片称为函数的堆栈帧。例如myAlgo_noLPR_withAnchor
的堆栈帧是stack[996 .. 994]
(包括两个 idex)。
帧从函数的 BP 开始(在我们在函数内部更新它之后)并持续到下一个堆栈帧。所以栈上的参数是调用者栈帧的一部分(见注8a)。
注释:
8a。 维基百科对参数另有说明,但在这里我坚持英特尔软件开发人员手册,请参阅第 1 卷。1,第6.2.4.1 节 Stack-Frame Base Pointer和第6.3.2 节 Far CALL 和 RET 操作中的图 6-2 。函数的参数和堆栈帧是函数激活记录的一部分(请参阅函数 perilogues 的 gen)。
8b。BP 的正偏移指向函数参数,负偏移指向局部变量。
这对于调试8c 非常方便。stack[BP]
存储前一个堆栈帧的地址,stack[stack[BP]]
存储前一个堆栈帧等。沿着这条链,你可以发现程序中所有函数的帧,这些帧还没有返回。这就是调试器显示您调用堆栈
8d 的方式。的前 3 条指令myAlgo_noLPR_withAnchor
,我们设置框架(保存旧 BP,更新 BP,为本地人保留空间)称为函数序言
9. 调用约定
在代码片段 8.1 中,我们将参数myAlgo
从右向左推送,并在AX
. 我们也可以从左到右传递参数并返回BX
。或者在 BX 和 CX 中传递参数并在 AX 中返回。显然,调用者(main()
)和被调用函数必须同意所有这些东西的存储位置和顺序。
调用约定是一组关于如何传递参数和返回结果的规则。
在上面的代码中,我们使用了cdecl 调用约定:
- 参数在堆栈上传递,第一个参数在调用时位于堆栈的最低地址(最后推送 <...>)。调用者负责在调用后将参数从堆栈中弹出。
- 返回值放在 AX
- EBP 和 ESP 必须由被调用者(
myAlgo_noLPR_withAnchor
在我们的例子中是函数)保存,以便调用者(main
函数)可以依赖那些未被调用更改的寄存器。
- 所有其他寄存器(EAX,<...>)都可以由被调用者自由修改;如果调用者希望在函数调用之前和之后保存一个值,它必须将这个值保存在其他地方(我们使用 AX 执行此操作)
(来源:来自 Stack Overflow 文档的示例“32 位 cdecl”;icktoofay和Peter Cordes拥有 2016 年版权;根据 CC BY-SA 3.0 许可。可在 archive.org 上找到完整的 Stack Overflow 文档内容的存档,其中此示例由主题 ID 3261 和示例 ID 11196 索引。)
10.函数调用
现在是最有趣的部分。就像数据一样,可执行代码也存储在内存中(与堆栈的内存完全无关),每条指令都有一个地址。
如果没有其他命令,CPU 会按照指令存储在内存中的顺序一个接一个地执行指令。但是我们可以命令 CPU “跳转”到内存中的另一个位置并从那里执行指令。在 asm 中,它可以是任何地址,而在 C++ 等更高级的语言中,您只能跳转到由标签标记的地址(有一些变通方法,但至少可以说它们并不漂亮)。
让我们使用这个函数(片段 #10.1):
int myAlgo_withCalls(int a, int b) {
int t1 = triple(a);
int t2 = triple(b);
return t1 - t2;
}
而不是调用tripple
C++ 方式,请执行以下操作:
- 将代码复制
tripple
到myAlgo
正文的开头
- 在
myAlgo
入口处跳过tripple
的代码goto
- 当我们需要执行
tripple
的代码时,将调用后的代码行的堆栈地址保存起来tripple
,以便稍后返回这里继续执行(PUSH_ADDRESS
下面的宏)
- 跳转到第一行的地址(
tripple
函数)并执行到最后(3.和4.一起是CALL
宏)
- 在结束时
tripple
(在我们清理了局部变量之后),从堆栈顶部获取返回地址并跳转到那里(RET
宏)
因为在 C++ 中没有简单的方法可以跳转到特定的代码地址,所以我们将使用标签来标记跳转的位置。我不会详细介绍下面的宏是如何工作的,相信我他们会按照我说的去做(片段 #10.2):
// pushes the address of the code at label's location on the stack
// NOTE1: this gonna work only with 32-bit compiler (so that pointer is 32-bit and fits in int)
// NOTE2: __asm block is specific for Visual C++. In GCC use https://gcc.gnu.org/onlinedocs/gcc/Labels-as-Values.html
#define PUSH_ADDRESS(labelName) { \
void* tmpPointer; \
__asm{ mov [tmpPointer], offset labelName } \
push(reinterpret_cast<int>(tmpPointer)); \
}
// why we need indirection, read https://stackoverflow.com/a/13301627/264047
#define TOKENPASTE(x, y) x ## y
#define TOKENPASTE2(x, y) TOKENPASTE(x, y)
// generates token (not a string) we will use as label name.
// Example: LABEL_NAME(155) will generate token `lbl_155`
#define LABEL_NAME(num) TOKENPASTE2(lbl_, num)
#define CALL_IMPL(funcLabelName, callId) \
PUSH_ADDRESS(LABEL_NAME(callId)); \
goto funcLabelName; \
LABEL_NAME(callId) :
// saves return address on the stack and jumps to label `funcLabelName`
#define CALL(funcLabelName) CALL_IMPL(funcLabelName, __LINE__)
// takes address at the top of stack and jump there
#define RET() { \
int tmpInt; \
pop(tmpInt); \
void* tmpPointer = reinterpret_cast<void*>(tmpInt); \
__asm{ jmp tmpPointer } \
}
void myAlgo_asm() {
goto my_algo_start;
triple_label:
push(BP);
BP = SP;
SP -= 1;
// stack[BP] == old BP, stack[BP + 1] == return address
stack[BP - 1] = stack[BP + 2] * 3;
AX = stack[BP - 1];
SP = BP;
pop(BP);
RET();
my_algo_start:
push(BP); // SP == 995
BP = SP; // BP == 995; stack[BP] == old BP,
// stack[BP + 1] == dummy return address,
// `a` at [BP + 2], `b` at [BP + 3]
SP -= 2; // SP == 993
push(AX);
push(stack[BP + 2]);
CALL(triple_label);
stack[BP - 1] = AX;
SP -= 1;
pop(AX);
push(AX);
push(stack[BP + 3]);
CALL(triple_label);
stack[BP - 2] = AX;
SP -= 1;
pop(AX);
AX = stack[BP - 1] - stack[BP - 2];
SP = BP; // cleanup locals, SP == 997
pop(BP);
}
int main() {
push(AX);
push(22);
push(11);
push(7777); // dummy value, so that offsets inside function are like we've pushed return address
myAlgo_asm();
assert(myAlgo_withCalls(11, 22) == AX);
SP += 1; // pop dummy "return address"
SP += 2;
pop(AX);
}
注释:
10a。因为返回地址是存储在栈上的,原则上我们可以改变它。这就是堆栈粉碎攻击的工作原理
10b。triple_label
(cleanup locals, restore old BP, return) “结束”的最后 3 条指令称为函数的结尾
11. 组装
现在让我们看看真正的 asm for myAlgo_withCalls
. 在 Visual Studio 中执行此操作:
- 将构建平台设置为 x86(不是x86_64)
- 构建类型:调试
- 在 myAlgo_withCalls 中的某处设置断点
- 运行,当执行在断点处停止时按Ctrl+ Alt+D
与我们类似 asm 的 C++ 的一个区别是 asm 的堆栈对字节而不是整数进行操作。所以要为 1 保留空间int
,SP 将减少 4 个字节。
我们开始(片段#11.1,注释中的行号来自要点):
; 114: int myAlgo_withCalls(int a, int b) {
push ebp ; create stack frame
mov ebp,esp
; return address at (ebp + 4), `a` at (ebp + 8), `b` at (ebp + 12)
sub esp,0D8h ; reserve space for locals. Compiler can reserve more bytes then needed. 0D8h is hexadecimal == 216 decimal
push ebx ; cdecl requires to save all these registers
push esi
push edi
; fill all the space for local variables (from (ebp-0D8h) to (ebp)) with value 0CCCCCCCCh repeated 36h times (36h * 4 == 0D8h)
; see https://stackoverflow.com/q/3818856/264047
; I guess that's for ease of debugging, so that stack is filled with recognizable values
; 0CCCCCCCCh in binary is 110011001100...
lea edi,[ebp-0D8h]
mov ecx,36h
mov eax,0CCCCCCCCh
rep stos dword ptr es:[edi]
; 115: int t1 = triple(a);
mov eax,dword ptr [ebp+8] ; push parameter `a` on the stack
push eax
call triple (01A13E8h)
add esp,4 ; clean up param
mov dword ptr [ebp-8],eax ; copy result from eax to `t1`
; 116: int t2 = triple(b);
mov eax,dword ptr [ebp+0Ch] ; push `b` (0Ch == 12)
push eax
call triple (01A13E8h)
add esp,4
mov dword ptr [ebp-14h],eax ; t2 = eax
mov eax,dword ptr [ebp-8] ; calculate and store result in eax
sub eax,dword ptr [ebp-14h]
pop edi ; restore registers
pop esi
pop ebx
add esp,0D8h ; check we didn't mess up esp or ebp. this is only for debug builds
cmp ebp,esp
call __RTC_CheckEsp (01A116Dh)
mov esp,ebp ; destroy frame
pop ebp
ret
和 asm for tripple
(片段#11.2 ):
push ebp
mov ebp,esp
sub esp,0CCh
push ebx
push esi
push edi
lea edi,[ebp-0CCh]
mov ecx,33h
mov eax,0CCCCCCCCh
rep stos dword ptr es:[edi]
imul eax,dword ptr [ebp+8],3
mov dword ptr [ebp-8],eax
mov eax,dword ptr [ebp-8]
pop edi
pop esi
pop ebx
mov esp,ebp
pop ebp
ret
希望,在阅读了这篇文章之后,汇编看起来不像以前那么神秘了 :)
以下是帖子正文的链接和一些进一步阅读: