Bomb实验:
3.2 程序编码
Og优化提高程序性能,但过高的优化级别,使机器指令与源代码的关系难以理解
3.2.1 机器级代码
计算机系统使用多种抽象,对于机器级编程的抽象模型:
(1)ISA
- 指令集体系结构 或 指令集架构(ISA)
功能:
- 用于定义机器级程序的格式和行为(eg.处理器状态,指令的格式,每条指令对状态的影响
(2)虚拟内存
机器级程序使用的内存地址是虚拟地址
功能:
内存模型为很大的字节数组(存储器系统实际由多个存储器和操作系统软件组合
3.2.2 代码示例
(1)生成代码
产生汇编文件:
-
使用
gcc
命令:#gcc -Og -S prog.c
-Og
指的是编译器的优化选项。-S
将使得编译结果为.s
的汇编语言文件
反汇编:
可以使用反汇编器
-
先通过
#gcc -Og -c prog.c
得到prog.o
的机器代码文件(这个.o文件是二进制格式)
53 48 89 d3 e8 00 00 00 00 48 89 03 5b c3
-
然后通过
objdump
程序来反汇编,#objdump -d prog.o
得到汇编代码。
和反汇编prog文件区别:
1.链接器将这段代码移动到一段不同的地址范围
2.链接器为callq指令调用函数mult2需要使用的地址
(2)值得注意的特性
1.设计指令的方式:从某个给定位置开始,将字节唯一地解码成机器指令(eg.pushq %rbx是以字节值53开头的
2.反汇编器只是基于机器代码文件的字节序列确定汇编代码(
3.反汇编器使用的指令命名规则与GCC生成的汇编有细微差别(eg.很多指令省略了大小指示符'q'后缀,但又给call和ret加上q,同样省略这些后缀也没有问题
(3)ATT和intel汇编代码格式区别
注意
Linux
下gcc
得到的汇编代码是ATT
格式,和Intel
风格不同,
ATT
风格:movq %rbx, %rax
Intel
风格:mov rax, rbx
上面两条语句的功能完全一样,
但是
Intel
代码省略了mov
后面表示大小的后缀q
,省略了寄存器rax
等前面的%
,同时列出操作数的顺序相反,源操作数在后而目标操作数在前,这与
ATT
风格的源操作数->目标操作数的顺序相反。
3.3 数据格式
3.4 访问信息
3.4.1 操作数指示符
操作数
-
指令的操作数指示执行操作时要使用的源数据值和放置结果的目的位置。
-
有三种类型:
- 立即数:表示常数值。
- 寄存器:表示寄存器内容。
- 内存引用:根据计算出来的地址(通常称为有效地址)访问某个内存位置(内存引用寄存器必须是64位寄存器
寻址模式
练习题3.1 (寻址方式
3.4.2 数据传送指令
MOV
类指令
movb
、movw
、movl
、movq
和movabsq
将数据从源位置复制到目的位置,不做任何变化。除了movl
指令以寄存器为目的时,会把该寄存器的高位4字节设置为0。
数据移动指令
(1)功能:
将较小的源值赋值到较大的目的时使用
(2)原理:
MOVZ将目的中剩余的字节填充为0
MOVS通过符号扩展填充,将源操作字符最高位进行复制
(3)格式:
每条指令最后两个字符都是大小指示符(第一个源大小,第二个目的大小)
零扩展
符号扩展
练习题3.2(大小后缀
练习题3.3(操作数
注意:
(1)内存引用必须四字寄存器
(2)两个操作数不能都是内存引用
3.4.3 数据传送
src_t *sp;
dest_t *dp;
*dp = (dest_t) *sp;
练习题3.4 (类型转换,无有符号
注意:
(1)源有符号用movs,无符号用movz
(2)u_char -> long,movzbl (movzbq更新剩余的位置填充0,但是movzbl同样可以实现
如果更新寄存器的低4字节,那么高4字节会自动置为0,所以movzbl比movzbl却更加高效。
(3)大到小,可以直接截断(u_int -> u_char movl movb)
练习题3.5(汇编转C
将下面的汇编代码转成 C 语言,假设函数函数为 void decode1( long *xp, long *yp, long *zp);
汇编代码为:
void decode1(1ong *xp, long *yp, long *zp);
xp in %rdi, yp in %rsi, zp in %rdx
decode1:
movq (%rdi), %r8 --> %r8=*xp;
movq (%rsi) , %rcx --> %rcx=*yp
movq (%rdx), %rax --> %rax=*zp
movq %r8, (%rsi) --> *yp = *xp;
movq %rcx, (%rdx) --> *zp = *yp;
movq %rax, (%rdi) --> *xp = *zp;
ret
C 语言
void decode1(1ong *xp, long *yp, long *zp)
{
long x = *xp;
long y = *yp;
long z = *zp;
*yp = x;
*zp = y;
*xp = z;
}
3.4.4 压入和弹出栈数据
pushq %rbp
等价于以下两条指令:
subq $8,%rsp Decrement stack pointer
movq %rbp,(%rsp) Store %rbp on stack
popq %rax
等价于下面两条指令:
mova (%rsp), %rax Read %rax from stack
addq $8,%rsp Increment stack pointer
3.5 算术和逻辑操作
3.5.1 加载有效地址指令leal
leal (%eax, %eax, 2), %eax
eax = eax + eax * 2
功能:
将有效地址写入目的操作数(写入的都是地址,并不是内存引用)
原理:
这是因为Intel处理器有一个专门的地址运算单元,使得leal的执行不必经过ALU,而且只需要单个时钟周期。相比于imul来说要快得多。因此,对于大部分乘数为小常数的情况,编译器都会使用leal完成乘法操作。
3.5.2 一元和二元操作
练习题3.8(算数操作
3.5.3 移位操作
移位操作对w位长的数据值进行操作,移位量是由%cl寄存器的低m位决定的, ,高位会被忽略
eg. %cl为0xFF,salb移动7位,salw移动15位,sall移动31位,salq移动63位
1.
salb
移动 7 位(针对 8 位操作数)
- 操作数是 8 位(1 字节),因此
m = log2(8) = 3
位。%cl
的低 3 位为111
,即十进制 7。- 因此,实际的移位量是 7 位。
- 执行
salb
(sal
对 8 位寄存器的操作)时,操作数将向左移动 7 位,最高位的比特将被移出,而最低位的 7 个比特将被补 0。4.
salq
移动 63 位(针对 64 位操作数)
- 操作数是 64 位(1 四字),因此
m = log2(64) = 6
位。%cl
的低 6 位为111111
,即十进制 63。- 因此,实际的移位量是 63 位。
- 执行
salq
(sal
对 64 位寄存器的操作)时,操作数将向左移动 63 位,最高位的比特将被移出,而最低位的 63 个比特将被补 0。
移位指令
左移指令:SAL,SHL
算术右移指令:SAR(填上符号位)
逻辑右移指令:SHR(填上0)
练习题3.11(汇编指令性能分析
xorq %rdx, %rdx
- 效果: 将%rdx置为0 (任意x xor x = 0
- 更直接表达:
mov $0, %rdx
- 两个实现编码长度区别:
xorq %rdx, %rdx 只有3个字节
mov $0, %rdx 有7个字节
xorl %edx, %edx 只有2个字节
movl $0, %edx 有5个字节
3.5.5 特殊的算术操作
"双操作数"乘法指令
mulq(无符号)和imulq(补码)
格式:
要求一个操作数存储%rax,一个作为指令源操作数,
结果:
存放在%rdx(高64位),%rax(低64位)
"双操作数"除法指令
idivq(有符号)和divq(无符号)
格式:
%rax(高64位)和%rdx(低64位)作为被除数
结果:
%rax作为商,%rdx作为余数
cqto指令:可以隐含读出%rax的符号位,并将它复制到%rdx的所有位 (一般被除数也是64位,需要%rdx全0,或全%rax的符号位(有符号运算
使用场景: 通常用于对有符号的 64 位整数进行除法运算之前,将 %rax
的值符号扩展到 %rdx
,以便能够执行除法操作(例如 idivq
指令)。
练习题3.12(除法汇编
代码:
movq %rdx, %r8
movq %rdi, %rax
movq $0, %rdx
divq %rsi
movq %rax, %r8
movq %rdx, %rcx
3.6 控制
3.6.1 条件码
常用的条件码
CF:进位标志。最近的操作使最高位产生了进位。可用来检查无符号操作的溢出。
ZF:零标志。最近的操作得出的结果为0。
SF:符号标志。最近的操作得到的结果为负数。
OF:溢出标志。最近的操作导致一个补码溢出—正溢出或负溢出。
改变条件码,不会改变操作数的指令
- cmp指令根据两个操作数之差来设置条件码,常用来比较两个数,但是不会改变操作数。
- test指令用来测试这个数是正数还是负数,是零还是非零。两个操作数相同
test %rax,%rax //检查%rax是负数、零、还是正数(%rax && %rax) cmp %rax,%rdi //与sub指令类似,%rdi - %rax 。
下表中除了leap指令,其他指令都会改变条件码。
3.6.2 访问条件码的三种方式
1.可以根据条件码的某种组合,将一个字节设置为0或者1。
2.可以条件跳转到程序的某个其他的部分。
3.可以有条件地传送数据。
对于第一种情况,常使用set指令来设置,set指令如下图所示。
/*
计算a<b的汇编代码
int comp(data_t a,data_t b)
a in %rdi,b in %rsi
*/
comp:
cmpq %rsi,%rdi
setl %al
movzbl %al,%eax
ret
练习题3.14(set指令判断有无符号
3.6.3 跳转指令
直接跳转
jmp .L1 //直接给出标号,跳转到标号处
间接跳转
jmp *%rax //用寄存器%rax中的值作为跳转目标
jmp *(%rax) //以%rax中的值作为读地址,从内存中读出跳转目标
3.6.4 跳转指令的编码
执行PC相对寻址时,程序计数器的值是跳转指令后面的那条指令的地址,而不是跳转指令本身的地址。
练习题3.15(j跳转指令偏移量
3.6.5 条件控制实现条件分支
3.6.6 条件传送实现条件分支
条件控制和条件传送对比:
结论:
实际上,基于条件数据传送的代码会比基于条件控制转移的代码性能要好。
处理器原理:
处理器通过重叠连续指令(流水线)的步骤获得高性能,要求事先确定要执行的指令序列,这样才能保持流水线中充满待执行的指令
分支预测逻辑:
处理器采用非常精密的来猜测每条跳转指令是否会执行。
(1)正确预测一个跳转(现代微处理器猜测设计试图达到90%以上的成功率),指令流水线中就会充满着指令。
(2)错误预测一个跳转,要求处理器丢掉它为该跳转指令后所有指令已做的工作,然后再开始用从正确位置处起始的指令去填充流水线。这样一个错误预测会招致很严重的惩罚,浪费大约15~30个时钟周期,导致程序性能严重下降。
练习题3.19 (分支模式时钟周期运算
A.预测错误的处罚时间T(mp) T(avg) = T(ok) + p * T(mp) T(ok) = 16 T(mp) = ( 31 -16 ) * 2 = 30 B.分支预测错误时间 T(ok)+ T(mp) = 46
3.6.7 条件传送指令
- 处理器无需预测结果就可以执行条件传送。
- 处理器只是读源值,检测条件码,然后要么更新目的寄存器,要么保持不变
练习题3.20(移位与条件传送指令
OP
操作符被定义为除法/
将
x
加 7 的作用: 操作x + 7
用来调整除法的结果,确保当x
为负数时,右移操作依然能得到正确的结果。算术右移: 算术右移会保留符号位(最高位)。对于正数来说,这很简单,高位填充为零。而对于负数来说,高位填充为一,这样可以正确表示负数的除法结果。
3.6.8 循环
将循环翻译成汇编主要有两种方法,第一种我们称为跳转到中间,第二种方法叫guarded-do, 根据GCC不同的优化结果会得到不同的汇编代码
循环汇编的两种方法
1.跳转到中间
它执行一个无条件跳转跳到循环结尾处的测试,以此来执行初始的测试。
2.guarded-do
首先用条件分支,如果初始条件不成立就跳过循环,把代码变换为do-whie循环。当使用较髙优化等级编译时,例如使用命令行选项-O1,GCC会采用这种策略。
练习题3.26 (for循环逆向
3.6.9 switch语句
原理:
使用跳转表(数据结构)实现
- 跳转表是一个数组,表项 i 是一个代码段的地址,
- 这个代码段实现当开关索引值等于 i 时程序应该采取的动作
优点:
执行开关语句的时间和开关情况的数量无关
场景:
开关情况数量多(eg.4个以上),并且值的跨度比较小,就会使用跳转表
原始的C代码有针对值100、102,104和106的情况,但是开关变量n可以是任意整数。
编译器优化:
(1)编译器首先将n减去100,把取值范围移到0和6之间,创建一个新的程序变量(index)。补码表示的负数会映射成无符号表示的大正数,将 index看作无符号值,
(2)跳转表声明为一个有7个元素的数组,每个元素都是一个指向代码位置的指针(GCC作者创造&&运算符创建一个指向代码位置的指针)
在C和汇编代码中,根据 index的值,有五个不同的跳转位置:
- loc_A(.L3),
- loc_B(.L5),
- loc_C(.L6),
- loc_D(.L7),
- loc_def(.L8) (默认的目的地址。
在C和汇编代码中,程序都是将 index和6做比较,如果大于6就跳转到默认的代码处。
在汇编代码中,跳转表声明为如下形式:
利用汇编语言和跳转表的结构推断出switch的C语言结构(练习题)
- 开头ja .L8 ,可知L8为默认位置,跳转表中编号为1和5的都是跳转的默认位置(编号为1和5的为缺失
- 从.quad .L3开始,由上到下,依次编号为0,1,2,3,4,5,6。
3.7 过程
过程是一种重要抽象。
提供一种封装代码的方式,用一组指定的参数和一个可选的返回值实现某种功能
形式:
- 函数
- 方法
- 子例程
- 处理函数
3.7.1 运行时栈
大多数语言过程调用机制(关键特性):
使用了栈数据结构提供的后进先出的内存管理原则。
示例:
(栈顶总是最新的元素)
假设我们有两个函数,P
和 Q
,其中 P
调用了 Q
。
-
P 调用 Q:当
P
调用Q
时,CPU 将P
当前的执行状态(包括返回地址、局部变量、参数等)压入栈中,然后跳转到Q
的代码去执行。此时,栈的顶部是Q
的栈帧,下面是P
的栈帧。 -
Q 执行:在
Q
执行时,它会在自己的栈帧中分配空间来存储它的局部变量和参数。如果Q
内部再调用其他函数,则会继续往栈中压入新的栈帧。 -
Q 返回:当
Q
执行完毕时,它的栈帧会从栈顶移除(释放内存),然后控制权返回给P
,继续执行P
中调用Q
之后的代码。
3.7.2 转移控制
控制转移到函数Q时,call Q
指令将返回地址压入栈中,并设置PC为Q的起始地址。返回时,ret
指令从栈中弹出返回地址,并恢复PC以继续执行。
调用执行过程
3.7.3 数据传送
1.参数传递
(1)寄存器传递
X86-64中,可以通过寄存器来传递最多6个参数。寄存器的使用是有特殊顺序的,如下表所示,会根据参数的顺序为其分配寄存器。
(2)栈传递
当传递参数超过6个时,会把大于6个的部分放在栈上。
如下图所示的部分,红框内的参数就是存储在栈上的。
练习题3.33(判断类型
3.7.4 栈上的局部存储
通常来说,不需要超出寄存器大小的本地存储区域。不过有些时候,局部数据必须存放在内存中,常见的情况包括:
- 1.寄存器不足够存放所有的本地数据。
- 2.对一个局部变量使用地址运算符‘&‘,因此必须能够为它产生一个地址。
- 3.某些局部变量是数组或结构,因此必须能够通过数组或结构引用被访问到。
例子:
将函数加载到寄存器
( 为局部变量和函数建立栈帧
- 第二行的subq指令将栈指针减去32,实际上就是分配了32个字节的内存空间。
- 在栈指针的基础上,分别+24,+20,+18,+17,用来存放1,2,3,4的值。
- 在第7行中,使用leaq生成到17(%rsp)的指针并赋值给%rax。
- 接着在栈指针基础上+8和+16的位置存放参数7和参数8。
- 而参数1-参数6分别放在6个寄存器中。
栈帧的结构如下图所示。
汇编分析:
- 上述汇编中第2-15行都是在为调用proc做准备(为局部变量和函数建立栈帧,将函数加载到寄存器)。
- 当准备工作完成后,就会开始执行proc的代码。当程序返回call_proc时,代码会取出4个局部变量(第17~20行),并执行最终的计算。
- 在程序结束前,把栈指针加32,释放这个栈帧。
3.7.5 寄存器中的局部存储
寄存器组是唯一被所有过程共享的资源( 要不同过程调用的寄存器不能相互影响。
- 根据惯例,寄存器%rbx、%rbp和%r12~%r15被划分为被调用者保存寄存器。
- 当过程P调用过程Q时,Q必须保存这些寄存器的值,保证它们的值在Q返回到P时与Q被调用时是一样的 (过程Q保存一个寄存器的值不变,要么就是根本不去改变它,要么就是把原始值压入栈中)
- P的代码就能安全地把值存在被调用者保存寄存器中(当然,要先把之前的值保存到栈上),调用Q,然后继续使用寄存器中的值。
练习题3.34(被调用寄存器
- 首先存储a0 ~ a5 (6个被调用寄存器),
- 存储剩下的a6 ~ a7到栈上
- (6个局部已经用完被调用寄存器)
3.7.6 递归过程
多个过程调用在栈中都有自己的私有空间,多个未完成调用的局部变量不会相互影响,递归本质上也是多个过程的相互调用。如下所示为一个计算阶乘的递归调用。
(1)该次调用的结果会保存在寄存器号%rax中,(2)参数n的值仍然在寄存器各%rbx中。把这两个值相乘就能得到期望的结果。
3.8 数组分配和访问
3.8.1 基本原则
声明 T A[N]
创建一个连续内存区域,并将 A
指向数组起始位置。
元素通过索引 0 到 N-1 访问,其地址为 X_A + i * sizeof(T)
。
3.8.2 指针运算
假设整型数组 E
的起始地址在 %rdx
,索引 i
在 %rcx
。结果存放在寄存器号%eax(如果是数据)或寄存器号%rax(如果是指针)中。
练习题3.37(指针和数据运算
假设整型数组 S 的起始地址在 %rdx
,索引 i
在 %rcx
。结果存放在寄存器号%ax(如果是数据)或寄存器号%rax(如果是指针)中。
3.8.3 二维数组
对于二维数组 T A[R][C]
,元素 A[i][j]
的地址为 A + L * (C * i + j)
。(L为T类型元素大小)
例子:
假设 A、i
和 j
分别在 %rdi
、%rsi
和 %rdx
中(地址运算的伸缩和加法特性
/*A in %rdi, i in %rsi, and j in %rdx*/
leaq (%rsi,%rsi,2), %rax //Compute 3i
leaq (%rdi,%rax,4),%rax //Compute XA+ 12i
movl (%rax, rdx, 4), %eax //Read from M[XA+ 12i+4j]
练习题3.38(逆向地址计算
3.8.4 定长数组
练习题3.40(定长数组地址步长递增
3.8.5 变长数组
著作权归作者所有。 链接:Chapter 3 程序的机器级表示 IV:数据 | CSAPP Readnote
3.8.5 变长数组
ISO C99 引入一种功能,允许数组的维度是表达式,在数组被分配的时候才计算出来:
int A[expr1][expr2];
例如我们要访问 数组:
int var_ele(long n, int A[n][n], long i, long j) {
return A[i][j];
}
- 注意汇编第一行,由于每一列元素个数无法预知,所以在定位某行首元素位置时需要做乘法来动态计算,而不能使用 leaq 来实现计算了。
在循环中引用数组时,GCC 可通过识别访问模式的规律性,优化索引计算,利用步长递增避免每次乘法,从而提升代码效率。
3.9 异质的数据结构
3.9.1 结构体
C语言的 struct
聚合不同类型的数据在连续的内存区域内,指向结构的指针指向其首地址。编译器通过记录每个字段的字节偏移量,利用这些偏移来高效地引用结构体中的各个元素。
假设一个结构声明如下:
struct rec {
int i;
int j;
int a[2];
int *p;
};
其内存布局为:
结构的各个字段的选取完全是在编译时处理的,机器代码不包含关于字段声明或字段名字的任何信息。
3.9.2 联合
联合体允许在 C 语言中使用不同类型引用同一块内存,从而绕过类型系统。所有字段共享相同的内存位置,可以通过不同类型来访问相同的数据。
考虑下面的声明:
struct S3 {
char c;
int i[2];
double v;
};
union U3 {
char c;
int i[2];
double v;
};
其内存布局为:
可以观察到,一个联合的总和大小等于它最大字段的大小。
Union 的使用
1. 已事先知道一个数据结构中的不同字段的使用是互斥的
2.可以用来访问不同数据类型的位模式
位模式重新解读的意思是:不对数据进行值的转换,而是直接把存储在内存中的二进制位按另一种类型来解释(字节序可能导致数据解读方式的变化,跨平台需要注意
unsigned long double2bits(double d) {
union {
double d;
unsigned long u;
} temp;
temp.d = d;
return temp.u;
}
大小不同情况:
double
到int
的情况:当你将一个double
值赋给u.d
时,u.d
的 8 字节内存区域将被填充。然而,当你通过u.i
访问时,你只能读取这 8 字节中的前 4 字节。换句话说,你获取的只是double
类型值前 4 个字节的位模式,这可能是无意义的数据,因为它是一个部分数据。
int
到double
的情况:如果你首先将int
值赋给u.i
,然后尝试通过u.d
访问数据,u.d
将使用这 4 字节数据的位模式来解释成一个double
类型。由于double
需要 8 字节,而你只提供了 4 字节的数据,其余 4 字节的内容将取决于内存中剩余部分的值,这可能导致未定义的行为或产生一个非常不同的double
值。
练习题3.43(联合结构体访问元素
分析:
(1)up->t2.a[up -> t1.u] (up与up不同
movq (%rdi),%rax // 获取up->t1.u
movq (%rdi,%rax,4),%eax // a为int数组4字节
3.9.3 数据对齐
对齐限制简化处理器和内存系统之间接口的硬件设计(对齐可以提高内存系统的性能,但不对齐x86-64硬件仍能正确工作
对齐原则:
任何K字节的基本对象的地址必须是K的倍数
编译器在汇编代码中放入命令,指明全局数据所需要的对齐。例如跳转表声明时的
.align 8
就保证了他后面的数据的起始地址都是 8 的倍数。
结构体的对齐
对于结构,编译器可能需要在字段的分配中插入间隙,以保证每个结构元素都满足他的对其要求。结构体对象的地址对齐要求迁就于其中对齐要求最大的字段,使得对它的访问也满足对齐要求。
另外,编译器结构的末尾可能也需要一些填充,这样结构体数组中每个元素都会满足它的对齐要求。
练习题3.44(结构体内存对齐
练习题3.45
注意:
(1)最小化浪费空间,大小从大到小降序排列
强制对齐的情况
3.10 在机器级程序中将控制和数据结合起来
3.10.1 理解指针
指针类型不是机器代码的一部分,而是 C 语言提供的一种抽象,对不同指针类型的转换是对位模式的重新解读。
指针也可以指向函数。函数指针的值是该函数机器代码表示中的第一条指令的地址。
3.10.3 内存越界引用和缓冲区溢出
两个事实:
- C 对于数组不进行越界检查
- 局部变量和状态信息都存放在栈中
这两种情况结合到一起就能导致严重错误,对越界数组的写操作会破坏存储在栈中的状态信息。一种特别常见的状态破坏成为缓冲区溢出。
如下面危险代码:
void echo() {
char buf[4];
gets(buf);
puts(buf);
}
gets 的问题是它没有办法确定是否为保存整个字符串分配了足够的空间。
字符串到23个字符前没有严重的后果,但是超过以后,返回指针的值以及更多可能的保存状态会被破环 (ret指令可能跳转到完全意想不到的位置
练习题3.46(栈覆盖
分析:
(1)存储返回值,和%rbx
(2)更新%rsp(提高栈顶
(3)gets获取'数字字符串'覆盖数据(‘0’为0x30
代码注入攻击
代码注入攻击是一种利用缓冲区溢出漏洞的恶意攻击方式,使程序执行攻击者的代码。通过向程序输入特制的字符串,攻击者可以覆盖函数的返回地址,使其跳转到恶意代码段,进而执行未授权的操作。
攻击形式:
- 系统调用执行:攻击代码通过系统调用启动一个 shell,提供操作系统功能访问。
- 未授权任务执行:攻击代码执行任务后修复栈,并通过第二次
ret
指令掩盖攻击痕迹,伪装成正常返回。
3.10.4 对抗缓冲区溢出攻击
method 1 使用安全函数
- 替代不安全函数:将不安全的函数如
gets
、strcpy
替换为安全函数如fgets
、strncpy
。这些安全函数允许指定最大读取长度,防止超过缓冲区大小。 - 使用格式化输入输出:在使用
scanf
时,用%ns
格式指定最大输入长度,避免缓冲区溢出。
method 2 栈随机化
启用 ASLR(地址空间布局随机化):在系统层面启用 ASLR,使程序的栈、堆、全局变量和代码段的内存地址在每次运行时随机化,增加攻击者对内存地址猜测的难度。 栈地址偏移:在程序开始时,在栈上随机分配 0~n 字节的空间,不使用这段空间,造成栈地址的随机变化。 攻击方式:猜中某个地址,可以通过长段NOP(空操作雪橇),到达攻击代码(暴力破解
method 3 栈破坏检测
GCC 中加入了栈保护者机制来检测缓冲区越界。
思想:
(1)在栈帧中任何局部缓冲区与栈状态之间存储一个特殊的金丝雀值 (2)在恢复寄存器状态和从函数返回之前,程序检查这个金丝雀值是否被改变。如果是的,那么程序异常终止。
# method 4 限制可执行代码区域
- 启用 NX(No-Execute)技术:通过设置内存页面权限,将非代码内存区标记为不可执行。确保栈、堆等内存区域仅可读写,不可执行,防止执行注入的恶意代码。
- 使用硬件支持的内存保护:利用现代 CPU 提供的硬件支持,将内存页面的执行权限交由硬件控制,确保高效执行内存保护。
3.10.5 支持变长栈帧
有些函数需要的局部存储是变长的,比如调用了 alloca 时就会发生这种情况(alloca 是标准库函数,可以在栈上分配任意字节数量的存储),当代码声明一个局部变长数组时,也会发生这种情况。
区别:
- 普通栈帧:大小固定,通常不使用
%rbp
,局部变量通过栈指针%rsp
直接访问。 - 变长栈帧:大小动态,使用
%rbp
作为帧指针,局部变量相对于%rbp
被访问。
特点:
- 动态大小:变长栈帧的大小在运行时动态确定,例如当局部变量是变长数组(VLA)或通过
alloca
动态分配时。 - 需要使用帧指针
%rbp
:因为局部变量的大小在编译时无法确定,为了稳定地访问这些变量,通常使用%rbp
作为帧指针。
栈帧管理:
- 函数开始时,保存调用者的
%rbp
,并将当前的栈指针%rsp
复制到%rbp
。 - 在分配变长局部变量时,栈指针
%rsp
会根据需要动态向下移动。 - 所有局部变量的访问是基于
%rbp
的偏移量(在函数栈帧中,%rbp
通常指向栈帧的顶部,局部变量则分配在%rbp
之下的低地址处,因此它们的访问偏移量通常为负数。 - 函数结束时,通过
leave
指令恢复%rbp
和%rsp
,释放栈帧。
leave 指令不需要参数,它等价于执行下面两条指令:
movq %rbp, %rsp Set stack pointer to beginning of frame
popq %rbp Restore saved %rbp and set stack ptr to end of caller’s frame
也就是首先把栈指针设置为保存 %rbp 值的位置,然后把该值从栈中弹出到 %rbp,从而恢复 %rsp 和 %rbp。这个指令组合具有释放整个栈帧的效果。
现在只有栈帧长可变的情况下才会使用帧指针。