汇编语言学习

想要理解栈溢出的最基本原理,汇编和栈是必不可少的,不然想我之前学了也是白学,原理都不知道

一、准备

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 rbprbp 原本的值保存在栈中,然后 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 指令的执行过程如下:

  1. 将 source 的值与 destination 的值进行按位与(AND)操作。
  2. 根据结果设置标志寄存器(特别是零标志 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 指令的下一条指令

相关推荐
不爱说话的采儿16 分钟前
UE5详细保姆教程(第四章)
笔记·ue5·游戏引擎·课程设计
weixin_4188138724 分钟前
Python-可视化学习笔记
笔记·python·学习
Haoea!27 分钟前
Flink-05学习 接上节,将FlinkJedisPoolConfig 从Kafka写入Redis
学习·flink·kafka
Vic1010144 分钟前
Java 开发笔记:多线程查询逻辑的抽象与优化
java·服务器·笔记
笑鸿的学习笔记1 小时前
qt-C++笔记之setCentralWidget的使用
c++·笔记·qt
丁满与彭彭2 小时前
嵌入式学习笔记-MCU阶段-DAY01
笔记·单片机·学习
呼啦啦--隔壁老王2 小时前
dexopt学习待整理
学习
无限远的弧光灯2 小时前
c语言学习_函数递归
c语言·开发语言·学习
海海不掉头发2 小时前
【计算机组成原理】-CPU章节学习篇—笔记随笔
笔记·单片机·学习·考研·计算机组成原理
胖大和尚3 小时前
C++项目学习计划
开发语言·c++·学习