想要理解栈溢出的最基本原理,汇编和栈是必不可少的,不然想我之前学了也是白学,原理都不知道
一、准备
1.安装gcc
sudo apt-get build-dep gcc
这里显示版本不对,我用的是国内镜像源,需要换一下配置
sudo nano /etc/apt/sources.list
依次在下面加入以下内容
deb-src https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ noble main restricted universe multiverse
deb-src https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ noble-updates main restricted universe multiverse
deb-src https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ noble-backports main restricted universe multiverse
然后再次安装试试
成功了,记得配置完后要更新软件源,命令如下
sudo apt-get update
2.汇编语言与pwn
1>关系
PWN需要对逆向了解,调试模式中会产生大量的汇编代码解读,需要对汇编有一定的基础
2>常用知识点
8086的CPU通用寄存器均为16位,可以存放两个字节,AX,BX,CX,DX四个寄存器存一般性数据
ah:AX高位寄存器 al:AX地位寄存器
字节(byte):8位bit,存在8位寄存器中
字(word):两个字节,16位bit,存于16位通用寄存器中
段寄存器:CS/DS/SS/ES
段寄存器的由来:已知寄存器位数16,而地址总线为20位,要利用16位寄存器来访问20位的地址时,需要进行(16*段寄存器+偏移地址)=地址总线 的方式来进行寻址。
CS与IP寄存器:指示当前CPU要读取的指令地址,CS:代码段寄存器 IP:指令指针寄存器
设CS中为M,IP为N,8086CPU将从Mx16+N读取指令开始执行,也称为:CS:IP指向内容作为指令执行。
指令运行的全过程:
CS:IP通过地址加法器,经过输入输出电路、20位地址总线进入内存找到地址,提取机器指令,返回到指令缓冲器及执行控制器,执行指令后根据指令长度,IP加值长度。
jump指令:jump CS:IP 相当于执行CS:IP的命令
二、hello word
写一个简单的c语言文件
#include<stdio.h>
int main(){
printf("hello world");
return 0;
}
用 GCC 等编译器将其编译为二进制文件,然后就可以在计算机上运行了,而在这个编译过程中,就有一个中间步骤:将 C 语言源码转化为汇编语言
操作如下
gcc -S 1.c -o 1.s -masm=intel
c 语言源码 1.c 会被编译,并输出等价的 intel 语法的汇编语言源码在 1.s 中
可以打开看一下,然后把不需要的代码删除
.LC0:
.string "hello world"
main:
lea rdi, .LC0[rip]
mov eax, 0
call printf@PLT
mov eax, 0
ret
只留下这些
.LC0 可以看做是一个常量,其内容是字符串的 hello world,而下面的 main: 就是 main 函数了
这里补充一下main函数中用到的这些指令
lea:计算有效地址,在这里,我们可以看做是将 .LC0[rip] 的地址,即 hello world 字符串的地址转移至 rdi 寄存器中,这里提到了寄存器,就是一个位于 CPU 内的储存结构,里边可以存一些变量啥的,而这里的 rdi 寄存器就是第一个参数的寄存器
rdi寄存器:是通用寄存器中的一个,用于存储函数参数的值。在函数调用时,参数值会被传递到%rdi寄存器中,供被调用的函数使用,在函数调用过程中,%rdi寄存器起到了传递参数的作用。当一个函数被调用时,函数的参数值会被依次放入%rdi、%rsi、%rdx、%rcx、%r8和%r9这六个寄存器中(如果参数个数超过六个,就会使用堆栈传递参数)
mov eax, 0:mov 是 move 的缩写,这里的意思也就是将 0 复制(转移)到 eax 寄存器中, eax 这个寄存器也比较特殊,它是返回值寄存器,任何函数的返回值都会被储存在这个寄存器中,举个例子,在我们 call printf 以后,eax 寄存器内的值就会变成 printf 的返回值,而我们 main 函数在返回的时候是有一个 return 0 的,所以在 ret(return 返回)指令前,有一条 mov eax, 0 的指令,这样在 return 的时候才能保证我们的返回值是 0,至于前面那个 mov eax, 0 其实没啥用
eax寄存器:"累加器"(accumulator), 它是很多加法乘法指令的缺省寄存器
call printf :调用printf函数
三、函数调用流程和栈
1.栈
写一个稍微复杂一点的程序
#include<stdio.h>
int add(int a, int b){
return a + b;
}
int main(){
printf("%d", add(2, 3));
return 0;
}
和刚才一样用gcc编译
打开查看并删除不需要的代码,只留下面这些
add:
push rbp
mov rbp, rsp
mov DWORD PTR -4[rbp], edi
mov DWORD PTR -8[rbp], esi
mov edx, DWORD PTR -4[rbp]
mov eax, DWORD PTR -8[rbp]
add eax, edx
pop rbp
ret
main:
push rbp
mov rbp, rsp
mov esi, 3
mov edi, 2
call add
mov esi, eax
lea rdi, .LC0[rip]
mov eax, 0
call printf@PLT
mov eax, 0
pop rbp
ret
接下来先看add函数起什么作用
将 edi 寄存器内的值通过 mov 指令复制到了 DWORD PTR -4[rbp](这里暂时不知道是什么),并且将 esi 寄存器内的值复制到了 DWORD PTR -8[rbp](与上面一样),又将这两个地方的值转移回了 edx 寄存器和 eax 寄存器(上面说了eax寄存器是个累加寄存器,而edx寄存器总是被用来放整数除法产生的余数),这里的edi和esi其实是 add 函数第一个参数和第二个参数,但是上面说了第一个参数是rdi,二者其实是一样的(edi 其实就是 rdi ,只不过他们的范围不太一样, edi 寄存器的范围为 rdi 寄存器的低 32 位,而 rdi 寄存器是 64 位的,同样的,还有 di 寄存器和 dil 寄存器,分别表示 rdi 寄存器的低 16 位与低 8 位)
这里附上一张寄存器的图,包含了各个寄存器
接着解释add函数,由于两个参数都是 int 类型的,只占 32 位,所以使用了 edi 寄存器和 esi 寄存器,先将两个参数分别复制到了两个不知道是什么的地方,然后又将他们复制到了 eax 寄存器和 edx 寄存器中,现在假设我们调用这个函数的时候这两个参数分别是 7 和 9,那么现在 eax 和 edx 寄存器内就是 7 和 9 了。下一步,执行 add eax, edx,这条指令是做加法的意思,其具体含义是 eax = eax + edx,那么也就是说将 edx 寄存器内的值加到 eax 上,所以现在 eax 就是这个加法函数的结果了,正好 eax 寄存器是返回值寄存器,所以下面就直接 return 了(先不管 pop 指令)
补充:
相对应 add 的,还有 sub(减法),mul(乘法),divl(除法),sall(左移),salr(右移),neg(取补),not(取反)等基础计算指令
接下来就了解以下什么是栈
从搜出来的简介可以知道对于每一个程序,其启动的时候,内核会为其分配一段内存,称为栈。
在上面这个add程序中,假设启动后内核为其分配的栈空间为 0xff00 - 0x10000,那么在启动的时候,rsp 寄存器就会被赋值为 0x10000,也就是栈顶的位置
补充:
rsp 寄存器储存的总是当前栈顶的位置
接下来,在 main 函数启动的时候,会执行 push rbp 指令,这个指令就相当于下面这两条指令
sub rsp, 8
mov QWORD PTR [rsp], rbp
首先 sub 指令将栈顶向下移了 8 个字节,也就是对 rsp 减个 8,然后将 rbp 寄存器内的值复制到 rsp 所指的地址上,前面的 QWORD PTR 表明我们要复制 8 个字节,也就是说将 rbp 寄存器内的 8 个字节(64 位)复制到了我们刚刚“开辟”出来的 8 字节在栈上的空间。
补充:
QWORD(8 字节)、 DWORD(4 字节)、WORD(2 字节)、BYTE(1 字节)
前面说过,rsp 总是指向栈顶的位置,假设在进入 main 函数的时候(main 并不是真正的程序入口),rsp 寄存器指向 0xff80 的位置,那么执行了 push rbp 以后,栈就变成了这个样子
到这里push 的含义其实就很明确了,就是将一个值给压到栈里面去(栈顶地址更高),在 main 函数中,push rbp 的作用其实是将 rbp 寄存器的值临时储存到栈里面,这样就可以拿 rbp 寄存器去干别的事了,只需要在返回之前将 rbp 寄存器的值还回去就好了
那么现在就知道上面那两个东西是什么了——栈,先将传进来的两个参数作为临时变量储存在了栈中
现在搞懂了那两个东西是栈,那add函数的完整过程就是由 push rbp 将 rbp 原本的值保存在栈中,然后 mov rbp, rsp ,rsp移动到rbp的位置,rsp
指向的是刚刚被压入栈中的 rbp
值,使用 rbp 寄存器来储存当前栈顶的位置(这个位置是栈帧的基地址,用于访问栈帧中的局部变量、函数参数和返回地址),再将传入的两个参数(esi,edi)保存到栈中, -4[rbp] 指的是 rbp 所指的地址减 4 后的地址,同理 -8[rbp] 就是 rbp 所指的地址减 8 后的地址(这些偏移量指定了栈帧中的位置),因为这两个参数都是 int,都是 4 字节(整数参数通常是四字节大小),所以对于每个参数就只需要给 4 个字节的栈空间即可,再然后,将这两个值复制到了 edx 和 eax 寄存器中,并完成加法,在返回前还需要 pop rbp ,pop 和 push 是对应的,push 是压栈,pop 就是出栈, pop rbp 就是将 rbp 原本的值还给 rbp 寄存器,这样可以保证在这个函数调用的过程中原本的环境(即一些变量等)没有发生改变,最后再通过 ret 指令返回,返回到main函数中 call add 指令的下一条指令,对于调用 add 函数的 main 函数而言,它也拿到了它想要的 add 的结果,储存在 eax 寄存器中,他只需要从这个寄存器内拿结果就好了
2.32位传参补充
在 32 位的 Linux 程序下,gcc 并不会默认使用寄存器来传递参数,而是会使用栈,第一个参数就第一个 push 到栈中
例如
int add(int a, int b){
return a + b;
}
但是在32位汇编中call add是下面这个样子
push 1
push 2
call add
等价于 add(1, 2)
3.逻辑控制
这里在前面的过程中就好奇 ret 是依靠什么记住返回地址在哪的?它怎么知道要返回到 call add 的下一条指令?
在这之前,需要对 JMP 指令和 CMP/TEST 指令有个基本了解
CMP:CMP 表示比较两个寄存器或者内存中的值,比较的结果会影响到标志寄存器
标志位寄存器:标志位寄存器是一个 64 位的寄存器,其内部有很多标志位,什么是标志位?这里先把 64 位的寄存器看成 64 个二进制位,然后,先考虑只用其中的 3 个位,其中第一位表示a,第二位表示b,第三位表示c,那么如果我今天什么都没干,就可以用 000 表示我今天的状态,而如果我今天只有c,就可以用 001 表示我今天的状态,这样就可以用这三个位来表示我今天做了什么,而这三个位就是标志位,而标志位寄存器就是用来储存这些标志位的,CMP 指令就是用来改变标志位的,比如说,如果两个值相等,那么 ZF(零标志位)就会被置为 1,如果两个值不相等,那么 ZF 就会被置为 0,这个 ZF 就是一个标志位,用来标志两个值是否相等
跳转:有了标志位,就可以根据标志位来决定是否跳转了,假设要求是如果相等的话就跳转,那么可以这么写
je 0x12345678
其中 je
表示 JUMP IF EQUAL
,即相等就跳转,其等价于 JUMP IF ZF = 1
,即如果 ZF 标志位为 1,就跳转到 0x12345678 这个地址,而这个地址就是我们要跳转到的地址,这个地址可以是一个函数的地址,也可以是一个标签的地址
补充:
函数地址:函数的地址是指函数在内存中的起始位置,这是一个内存地址,指向函数的第一条指令。当你调用一个函数时,你实际上是在告诉程序跳转到这个内存地址去执行函数的代码。在汇编语言中,函数的地址可以通过函数的名称来引用,前提是该名称已经被正确地链接到其内存地址。
标签地址:标签的地址就是它所标记位置的内存地址。在汇编程序中,你可以使用标签来跳转到代码中的特定位置,或者作为数据的偏移量。
这里说一下 CMP 指令具体是怎么比较两个数的:
CMP eax, ebx
等价于 SUB eax, ebx
,即 eax - ebx,但是不会将结果放回 eax,并同时会影响标志位,如果说现在减完的结果为 0,那么 ZF
就会被置为 1,如果不为 0,那么 ZF
就会被置为 0
接下来就可以写一个if语句来看看了
#include<stdio.h>
int main(){
int a = 1;
int b = 2;
if(a == b){
printf("a == b");
}
return 0;
}
用gcc编译
删除掉用不到的,就是下面这个样子
.LC0:
.string "a == b"
main:
push rbp
mov rbp, rsp
mov DWORD PTR -4[rbp], 1
mov DWORD PTR -8[rbp], 2
mov eax, DWORD PTR -4[rbp]
cmp eax, DWORD PTR -8[rbp]
jne .L2
lea rdi, .LC0[rip]
mov eax, 0
call printf@PLT
.L2:
mov eax, 0
pop rbp
ret
可以看到,实现的原理其实就是,先将 a 和 b 的值分别复制到 eax 和 edx 寄存器中,然后比较 eax 和 edx 寄存器中的值,如果相等就跳转到 .L2 这个标签所在的位置,如果不相等就继续往下执行
这个表是人家列出的常用跳转指令
4.循环
有了跳转,就可以实现循环了,比如说,要实现一个让程序一直输出 hello world 的循环,那么可以这么写
#include<stdio.h>
int main(){
while(1){
printf("hello world");
}
return 0;
}
然后用gcc编译并打开
删掉没用的就是下面这个样子
.LC0:
.string "hello world"
main:
push rbp
mov rbp, rsp
.L2:
lea rdi, .LC0[rip]
mov eax, 0
call printf@PLT
jmp .L2
mov eax, 0
pop rbp
ret
本质上就是 JMP 指令的使用,而像 for 循环,本质上就是 CMP 套 JMP,仍然是一样
#include<stdio.h>
int main(){
for(int i = 0; i < 10; i++){
printf("hello world");
}
return 0;
}
依旧用gcc编译并打开
删掉多余的
.LC0:
.string "hello world"
main:
push rbp
mov rbp, rsp
mov DWORD PTR -4[rbp], 0 // int i = 0
.L2:
cmp DWORD PTR -4[rbp], 9 // i < 10
jg .L3 // 不满足条件就跳转到.L3,即跳出循环
lea rdi, .LC0[rip]
mov eax, 0
call printf@PLT
add DWORD PTR -4[rbp], 1 // i++
jmp .L2 // 返回到for循环的开始
.L3:
mov eax, 0
pop rbp
ret
最后,是 TEST 指令,其实和 CMP 指令差不多,只不过其是等价于 AND
指令,即 TEST eax, ebx
等价于 AND eax, ebx
,其会将 eax 和 ebx 寄存器内的值进行与操作,并同时会影响标志位,如果说现在与完的结果为 0,那么 ZF(零标志位)就会被置为 1,如果不为 0,那么 ZF 就会被置为 0
补充:
test
指令是一种位操作指令,它主要用于检查一个或多个特定位是否被设置(即为1)。test
指令的工作原理与 and
指令类似,但它不存储结果,只设置标志寄存器。这使得 test
指令非常适合用于条件判断,因为它可以快速检查位状态而不影响原始数据。
test
指令的执行过程如下:
- 将
source
的值与destination
的值进行按位与(AND)操作。 - 根据结果设置标志寄存器(特别是零标志 ZF、符号标志 SF、辅助进位标志 AF 和奇偶标志 PF)。
- 零标志 (ZF):如果 AND 操作的结果为零,则设置为1,否则为0。
- 符号标志 (SF):如果结果的最高位(即符号位)为1,则设置为1,表示结果为负。
- 辅助进位标志 (AF):如果 AND 操作的结果在低4位中有进位,则设置为1。
- 奇偶标志 (PF):如果结果中的1的个数为偶数,则设置为1。
最后提一下标志位,实际上标志位是很多的,因为 SUB ADD 等操作是会产生溢出的,以及会有负数处理的情况,比如说 2222-3333=-1111,这是导致了正数被减为了负数,这种情况就会影响标志位,比如说,如果是正数减为了负数,那么 SF(符号标志位)就会被置为 1,如果是负数减为了正数,那么 SF 就会被置为 0,而 OF(溢出标志位)就会被置为 1,如果没有溢出,那么 OF 就会被置为 0
5.函数调用
在上面已经认识到了函数调用的过程,但是返回具体是怎么返回的呢?其实只需要拆解 call 指令和 ret 指令即可,先看 call
push rip
jmp func
rip 寄存器是受到硬件控制,永远指向下一条指令的地址,所以,先将 rip 寄存器内的值压栈,然后跳转到 func 函数,这样,func 函数就可以把要返回的地址储存在栈里
pop rip
将栈顶的值弹出到 rip 寄存器中,这样就可以返回到 call 指令的下一条指令了
其实函数调用的过程就是将返回地址压栈,然后跳转到函数,然后函数执行完毕后,再将返回地址弹出到 rip 寄存器中,这样就可以返回到 call 指令的下一条指令