CSAPP 第三章:程序的机器级表示

CSAPP 第三章:程序的机器级表示

深入理解计算机系统(原书第三版).pdf

由于基础知识太差,xia0ji233师傅建议我来看一下CSAPP第三章。这本书看得好艰难!!!感觉自己大概都知道这些知识点,但是一些细节问题又不太了解,表达方式也和我之前学习的时候的表达方式不一样,最后加上这一章竟然有足足的一百一十页,真是看得我要晕字了。

程序编码

这一部分讲的其实就是如何将一个.c文件变成一个可执行的elf文件

比如,我们现在写了一个最简单的c程序

1
2
3
4
5
6
#include<stdio.h>

int main(){
printf("hello world");
return 0;
}

现在我们拥有了一个.c文件,但是.c文件在linux里面不能直接运行

image

所以我们需要将他编译为可执行文件(忘记写换行了)

image

将c文件变为可以执行的elf文件,使用的是以下的命令

1
$gcc -g hello.c -o hello

-g​保留一些调试信息,-o​重命名

在这一条指令背后,包含了四个过程

1.预处理:

1
$gcc -E hello.c -o hello1.i
  • #include​ 会将头文件内容插入到源代码中。

  • #define​ 会替换代码中的宏定义。

  • 还会处理注释的删除、行号标记等。

  • 预处理后,代码变成了一个单纯的、没有宏和头文件包含的标准 C 代码。

2.编译:

1
$gcc -S hello1.i -o hello1.s
  • 编译器会进行语法分析、语义分析、优化等工作:

    • 语法分析:检查代码是否符合 C 语言的语法规则。
    • 语义分析:检查代码的逻辑、变量类型等是否正确。
    • 生成汇编代码:将 C 代码转化为对应的汇编语言代码。
  • 如果没有出现错误,编译器将生成 .s​ 汇编文件

例如我之前函数输错了就会在编译阶段检查出来

image

3.汇编:

1
$gcc -c。/ hello1.s -o hello1.o
  • 编译阶段生成的汇编代码(.s​ 文件)会通过汇编器(as​)转换为目标文件(.o​ 文件)。
  • 汇编器会将汇编代码转化为机器代码(机器指令),并生成可重定向文件 hello.o​。这个文件包含了程序的二进制代码,但还没有完成链接,因此还无法执行。

4.链接:

1
$gcc -g hello1.o -o hello1
  • 链接器(ld​)将所有的目标文件和标准库链接在一起,生成最终的可执行文件。

对于生成的hello1.o文件和hello的可执行文件,由于是二进制文件,我们不能够在文本编辑器中显示,我们可以使用反汇编器使之根据机器代码生成一种类似于汇编代码的格式

1
2
$objdump -d xxx.o
$objdump -d ELFfile

反汇编后的样子类似于这样

image

机器级代码

对机器级编程来说,其中两种抽象尤为重要:

1.指令集体系结构或指令级架构来定义机器级程序的格式和行为——它定义了处理器,指令的格式,以及每条指令对状态的影响(翻译为不那么学术的说法就是当你编写 C 语言代码并编译成机器代码时,编译器会根据目标硬件的指令集生成适合的汇编或机器代码。)

2.机器级程序使用的内存地址是虚拟地址——提供的内存模型看起来是非常大的字节数组(这个得去看看虚拟内存机制)

然后在机器码里面,我们还能看到一些我们在写c的时候看不到的东西:

1.程序计数器

通常称为“pc”,在x86-64里面用寄存器rip表示,将下一条要执行的指令的地址放进这一个寄存器里面

2.寄存器

平常我们在汇编里面看到的寄存器,如下图这些

image

3.状态寄存器

用于控制跳转指令条件的寄存器

image

4.向量寄存器

可以存放一个或多个整数或浮点数值

image

虽然c语言提供了一种模型,可以在内存中声明和分配各种数据类型对象,到那时机器码只是简单的将内存看成一个很大的,按字节寻址的数组。

c语言中的聚合数据类型,例如数组和结构,在机器代码中用一段连续的字节来表示。

即使是对标量的数据类型,汇编代码也不区分有符号到无符号整数,不区分各种类型指针,甚至不区分指针和整数。

机器码具有如下特性:

  • 指令可以从任意地址编码,无对齐规则,只是任意地址开始的编码很可能产生错误的编码或者是运行产生错误的结果。
  • 指令长度从1-15个字节不等,运用了哈夫曼编码的思想:越常用的指令字节数越少,越不常用的指令字节数越多。
  • 反汇编器只是根据字节序列确定汇编代码,它完全不需要知道编译出该程序的源文件即可得到。

数据格式

image

如图所示,大多数gcc生成的汇编代码指令都有一个字符后缀,表明操作数大小。

我们可以看到使用“l”同时表示了四字节大小的int类型和八字节大小的double类型,那这在使用的时候会产生冲突吗?

答案是不会的,因为double使用的是一组和整数完全不同的指令的寄存器

访问信息

现在介绍一下16个通用寄存器

image

在32位机器的时候,本来只有8个通用寄存器(上图的前八个),到64位后,增加了r8 - r15,虽然这些寄存器都是8字节的,但是仍然可以表示低字节,例如:对于rax,rax表示八字节,eax则表示4字节,ax表示最低两字节的高位,al表示最低两字节的低位;可以根据上图查看寄存器的低字节表示

对于生成小于8字节结果指令,寄存器中字节生成有两条规则:

1.生成1字节和2字节数字的指令会保持剩下的字节;

2.生成4字节的指令会把高4位置零

操作数指示符

大多数指令会有一个到两个操作数(operand),操作数可以是立即数(immediate),寄存器(register)或者是内存地址。具有两个操作数的指令会分源操作数(source operand)和目的操作数(destination operand)。

在有两个操作数的情况下:源操作数可以是以上三种的任意,目的操作数不能为立即数,并且源操作数和目的操作数不能同时为内存地址。

对于地址引用,存在一个公式

image

数据传送指令

mov

mov的意思就是将源操作数的值复制给目的操作数,例如:

1
2
;已知rax=5,rdi=8
mov %rdi,%rax ;将rdi里面的内容复制到rax里面,现在rax=8,rdi=8

当时根据我们上文中关于寄存器的知识,我们了解到,寄存器除了有八字节大小的,还可以使用一些小字节的寄存器,类似于eax,ax,al等。

如果我们的源操作数比较端,但是目的寄存器比较长的时候,例如

现在rax=0x0000ffffffff4860,dl=0x3b,现在我们需要将dl的值放入rax中

如果使用mov dl,rax​;这是一个非法指令,mov​ 指令要求源操作数和目标操作数大小一致

但是如果使用mov dl,al​;这样就只能修改最低位的一个字节,现在rax里面的值就是rax=0x0000ffffffff483b

为了应对这种情况,我们就需要对源操作数进行扩展

这时候就有两种情况:

零(zero)扩展(movz):就是把高位全部变为0,使用零扩展,在上述场景中,rax=0x000000000000003b

对于扩展到不同大小,分为以下命令,寄存器部分大小必须和指令后两位相匹配

image

符号(sign)扩展(movs):就是把高位补全为需要复制数的最高位(可能有点抽象)

例如对于-42用二进制表示为11010110​,现在它就是8位(1字节)的负数,使用符号扩展为64位(8字节),由于这个二进制的最高位为1,所以将它的高位全补位为1,即

1
11111111 11111111 11111111 11111111 11111111 11111111 11111111 11010110

转换为16进制就是0xffffffffffffffd6

同理,如果最高位为0,就将高位补充为0

image

压入和弹出栈数据

push指令就是将数据压入栈中

类似于这两条指令:

1
2
3
4
pushq %rbp
=
subq $8,%rsp
movq %rbp,(%rsp)

先将栈顶减去8,再把数据放到栈顶

而pop指令则和push指令相反

pop指令是将栈上的数据放到寄存器中

类似于这两条指令

1
2
3
4
popq %rax
=
movq (%rsp),%rax
addq $8,%rsp

看一下书上的图解

image

注意:push 的源操作数只能是立即数或者是寄存器, pop 的目的操作数只能是寄存器

算数和逻辑操作

这些操作被分为四组:加载有效地址,一元操作(一个操作数),二元操作(两个操作数),位移

image

加载有效地址

加载有效地址指令lea是mov的变形。它的指令形式是从内存读取数据到寄存器,但是实际上它根本就没有引用内存。

lea的作用其实就是将括号里的值直接给目的数

例如:

1
lea (%rdi+8),%rax ;就是将rdi中存储的值加上8的值直接赋值给rax

现在就有一个疑问了,lea和mov的区别

1
2
3
4
5
6
7
;假设rdi=0x100 ,在0x108地址处的值为0x25
;如果使用mov
mov [%rdi+8],%rax
;此时,rax的值是0x25
;如果使用lea
lea [%rdi+8],%rax
;此时rax的值就是0x108

由此,可以看出,lea没有访问rdi+8所指向的内存

此时,我们就要开始想了,如果我mov不加[]不就和lea作用一样了吗

1
2
lea %rdi,%rax
mov %rdi,%rax

这两条指令的作用是完全一样的

但是如果是下面两条指令,他们的作用还是一样的吗

1
2
mov %rdi+8,%rax
lea (%rdi+8),%rax

按理来说应该是同一个作用,但是,mov不支持这样的指令格式,所以不能这么使用

(应该讲清楚了吧)

lea经常用于简单的算术表达式,采用书上的例子:

1
2
3
4
long scale(long x,long y,long z){
long t=x+4*y+12*z;
return t;
}

使用lea进行算数运算

1
2
3
4
5
;x in %rdi,y in %rsi,z in %rdx
leaq (%rdi,%rsi,4),%rax ;x+4*y
leaq (%rdx,%rdx,2),%rdx ;z+2*z=3*z
leaq (%rax,%rdx,4),%rax ;(x+4*y)+4*(3*z)=x+4*y+12*z
ret

如果使用mov,指令数就会变大很多

1
2
3
4
5
6
7
movq    %rsi, %rax        ; rax = y
imulq $4, %rax ; rax = 4*y
movq %rdx, %rcx ; rcx = z
imulq $12, %rcx ; rcx = 12*z
addq %rcx, %rax ; rax += 12*z
addq %rdi, %rax ; rax += x
ret

可以看到使用lea只需要三条指令就可以实现这个简单的计算,但是如果使用mov,实现这个简单的运算就需要六条指令,所以,lea经常用于简单计算。

一元和二元操作

一元操作和二元操作就是看操作数是几个

例如:

一元操作

image

可以看到以上指令只有一个操作数,所以他们叫做一元操作

二元操作

image

可以看到以上指令拥有两个操作数,所以他们叫做二元操作

移位操作

image

位移操作,先给出位移量,然后第二项给出的是要位移的数。位移量可以是一个立即数,或者放在单字节寄存器%cl中

位移分为逻辑位移和算数位移,算数位移会考虑符号位,逻辑位移则不会考虑符号位,但是符号位一般保存在高位,所以算数左移和逻辑左移的功能是一样的

算数左移(sal):左移一次,最低位补0

逻辑左移(shl):左移一次,最低位补0

算数右移(sar):右移一次,最高位补符号位

逻辑右移(shr):右移一次,最高位补0

可以看到,sal和shl的作用是一样的

特殊算数操作

当我们将两个64位数进行相乘的时候,就可能会出现128位数,但是目前的寄存器最大为64,该怎么保存128位的数呢

x86-64指令集对128位数的操作提供有限的支持

image

在我们之前看到的乘法和除法指令都是双操作数,他们代表所使用的数和产生的结果均没有超过64位,除此之外,x86-64指令集还提供单操作数的指令,如上图

对于乘法指令,要求一个参数必须放在寄存器%rax中,而另一个作为指令的源操作数给出,然后得出的结果高64位放在%rdx,低64位放在%rax中

对于除法指令,将寄存器%rdx放入被除数的高64位,%rax放入被除数的低64位,除数作为源操作数给出,将得出的商放入%rax,余数放在%rdx中

控制

在之前的内容中,我们只研究了指令顺序执行的情况,但是顺序执行并不能够覆盖所有需求,我们希望程序有选择的执行一些代码,这个时候程序就不再是顺序执行,而是跳转执行

跳转分条件跳转和无条件跳转,无条件跳转执行使用jump指令改变一组机器码的执行顺序,jump指令控制程序必定跳转到它指定的位置。但是这样,程序运行与之前的顺序运行没什么区别,要实现分支结构和循环结构需要用到条件跳转了。

条件码

除了整数寄存器,cpu还维护着一组单个位的条件码寄存器,他们描述了最近的算术或逻辑操作的属性。可以检查这些寄存器来执行条件分支指令。

常用的条件码如下:

  CF:进位标志寄存器。最近的操作是最高位产生了进位。它可以记录无符号操作的溢出,当溢出时会被设为1。

  ZF:零标志寄存器,最近的操作得出的结果为0。当计算结果为0时将会被设为1。

  SF:符号标志寄存器,最近的操作得到的结果为负数。当计算结果为负数时会被设为1。

  OF:溢出标志寄存器,最近的操作导致一个补码溢出(正溢出或负溢出)。当计算结果导致了补码溢出时,会被设为1。

lea指令不改变任何条件码,因为它是用来进行地址计算的。

inc和dec指令会设置溢出和零标志,但是不会改变进位标志。

除此之外,还有两个指令只用于修改条件码,但是不会修改操作数

cmp

cmp指令本质上是两个值的比较,类似于sub指令,但是不保留结果,只更新条件码寄存器

cmp的操作数可以是寄存器,内存或则是立即数

1
cmp S1,S2 ;S2-S1

这个具体的大小判断有一点点复杂,书上也只是简单的提了一下可以通过条件码寄存器判断出大小,由于之前一直没接触过条件码寄存器,对这一块很不了解,想深入看看,所以自己找资料补充一下。

CMP 指令(比较指令)

如果S2-S1=0,即S1=S2,将ZF设置为1,PF(奇偶标志寄存器)设置为1

如果S2-S1!=0,即S1!=S2

无符号数比较

如果S1!=S2,即ZF=0

如果S2<S1,则S2-S1会产生借位,所以CF=1

如果S2>S1,则S2-S1不会产生借位,所以CF=0

综上,就可以根据ZF和CF两个条件码寄存器判断大小

有符号比较

如果OF=0,说明没有产生溢出,说明此时的正负为真实的正负

如果S2<S1,则S2-S1<0,所以SF=1

如果S2>S1,则S2-S1>0,所以SF=0

如果OF=1,说明产生了溢出,此时的正负为真实正负的相反结果

1
2
3
例如此时int S2=-64=1100 0000B,int S1=85=0101 0101B
本来S2-S1=-149=1 0110 1011B
但是我们定义的为int类型,只能保留到0110 1011B,这么一看,最高位就为0,即SF=0

所以,当OF=0时,SF=1,说明S2<S1,SF=0,说明S2>S1

当OF=1时,SF=1,说明S2>S1,SF=0,说明S2<S1


test

test指令本质上时and指令,但是不保留结果,只更新条件码

cmp的操作数可以是寄存器,内存或则是立即数

test指令的典型用法是两个操作数是一样的用于判断操作数的情况,或则其中一个操作数是另一个掩码,用来指示哪些位应该被测试

访问条件码

条件码通常不会直接读取,尝试用的方法有三种:

1.根据条件码的某种组合,将一个字节设置位0或者1

2.可以根据条件跳转到程序的某个其他部分

3.可以有条件地传送数据

它们一般使用的常用后缀(下表是xia0ji233师傅整理出的,借鉴一下)

指令后缀 同义后缀 条件码 设置条件 英文释义
e z ZF==1 相等(零) equal(zero)
ne nz ZF==0 不相等(不为零) not equal(not zero)
s / SF==1 为负数 sign(with -)
ns / SF==0 为非负数 not sign(without -)
g nle (SF^OF)==0&ZF==0 有符号大于 greater(not [less or equal])
ge nl (SF^OF)==0 有符号大于等于 greater or equal(not less)
l nge (SF^OF)==1 有符号小于 less (not [greater or equal])
le ng (SF^OF)==1|ZF 有符号小于等于 less or equal (not greater)
a nbe CF==0&ZF==0 无符号大于 above (not [below or equal])
ae nb CF==0 无符号大于等于 above or equal (not below)
b nae CF==1 无符号小于 below (not [above or equal])
be na CF|ZF==1 无符号小于等于 below or equal (not above)

set

我们将第一种情况的指令设置为set指令

image

需要注意的是这些指令的后缀表示不同的条件而不是操作数的大小

一条set指令的目的操作数是低位单字节寄存器或则是一个字节的内存地址,指令会将这个字节设置为0或则1

跳转指令

无条件跳转:jmp

直接跳转,即跳转目标作为指令的一部分汇编

1
2
3
4
5
;例如
jmp .l1
pushq %rdx
.l1:
popq %rdx

间接跳转,即跳转目标是从寄存器或则内存位置读出,间接跳转的写法是’*’后跟一个操作数指示符

1
2
3
;例如
jmp *%rax ;以rax内的值作为地址跳转
jmp *(%rax) ;以rax指向地址中的值作为地址跳转

有条件跳转:

image

条件跳转只能为直接跳转

跳转指令编码

跳转指令表面上是要指定一个绝对地址,但是实际上编码的时候是采用了偏移量的方式去编码,它会根据跳转指令所编码的值去对自己的 %rip 寄存器加或者是减去一个偏移量,但是需要注意的是,偏移量是从下一条指令开始计算的,这也就是为什么 jmp 如果后面编码值为 0 不会产生一个死循环。

用条件控制来实现条件分支

对于一般的条件语句if

1
2
3
4
5
6
7
int cmp(int x,int y){
//x in %rdi,y in %rsi
if(x>y){
return 1;
}
return 0;
}

汇编代码为

1
2
3
4
5
6
7
8
9
10
.code
cmp proc
mov $0,%rax
cmp %rsi,%rdi
jbe L1
mov $1,%rax
L1:
ret
cmp endp
end

如果有 if … else … 语句的话,一般采用条件跳转和无条件跳转组合的方式去实现,比如如下的表达式

1
2
3
4
5
6
if(expr){
truepart
}
else{
falsepart
}

我们进行编译之后大概率会产生如下的汇编语句

1
2
3
4
5
6
7
8
test %rdi,%rdi
jz L1
//truepart
jmp done
L1:
//falsepart
done:
//over

条件传送语句实现条件分支

条件传送指令就和上面两类条件指令一模一样,符合条件时传送,不符合条件时不传送。

同样,如下语句

1
2
3
4
5
6
7
8
9
10
11
int main(){
//x in %rdi,y in %rsi
int z;
if(x>y){
z=x-y;
}
else{
z=y-x;
}
return z
}

我们使用条件跳转语句可以很轻松地实现上述逻辑,但是我们也可以使用条件传送语句,事先算出两个部分 z 的值,先赋值假设正确的值,再用条件传送语句判断错误的条件赋值。

image

比如

1
2
3
4
5
6
7
movq %rdi,%rax
subq %rsi,%rax;let %rax=x-y
movq %rsi,%rbx
subq %rdi,%rbx;let %rbx=y-x
cmp %rsi,%rdi
cmovle %rbx,%rax;if x<=y movq %rbx,%rax
ret

可能你会疑问,这样子效率会不会遍低,因为如果运算更复杂一点,我需要把两个值都算出来,执行的指令会变多。确实,条件跳转比条件转移比起来执行的指令少,而且能泛用性广,举个例子:如果 if … else … 里面有其它语句,比如输出语句,那么我们条件传送指令显然就不能用,而条件跳转能应对这种情况。

但是条件传送实现的条件分支比条件转移实现的分支运行速度快。你可能很惊讶,为什么执行的指令多反而运行速度还快了,那是因为当今的 CPU 都是流水作业,一个指令的执行分为五个步骤,它同一时间可以执行五条指令对应的不同操作。而流水作业的前提条件是我能清楚地知道我接下来要执行的代码,如果我都不知道我接下来要执行什么代码,那么我肯定不能很好的运用流水作业了。

条件控制语句在执行完毕之前,谁也不知道它是跳转或者不跳转,也就无法知道它接下来要执行的指令,我们只能选择等待条件跳转指令执行完毕再次填充指令流水作业,速度自然就慢了。而条件转移并不改变指令的执行顺序,因此它依然能利用 CPU 的流水作业。

实现分支的方式 优点 缺点
条件跳转 能应对所有分支情况,执行的指令较少 执行速度慢,不能很好的利用CPU的指令流水
条件传送 执行速度快,能很好的利用CPU的流水作业 不能应对所有的分支状况,需要把所有的情况提前计算,执行的指令较多

循环

高级语言中,除了顺序,选择以外,还有一种结构就是循环结构了,但是事实上我们依然可以通过条件转移的方式实现循环的效果,例如:

1
2
3
4
5
6
movq $10,%rcx;set loop time
do:
;do something
subq $1,%rcx
test %rcx,%rcx
jns do

以上例程可以循环10次执行,并且可以通过修改立即数 $10 来改变循环次数。

把它转为 C 语言来写就很像我们的 do-while 循环语句:

1
2
3
4
5
6
7
void test(){
int i=10;
do{
//something
i--;
}while(i>=0);
}

那么除了 do - while 我们还有 while,for 三种类型的循环。

它们的模板分别是什么样的呢,我们来看看。

while 的 C 写法:

1
2
3
4
5
6
7
void test(){
int i=10;
while(i>=0){
//something
i--;
}
}

对应的汇编代码:

1
2
3
4
5
6
7
8
9
    movq $10,%rcx
do:
subq $1,%rcx
test %rcx,%rcx
js end
;do something
jmp do
end:
ret

for 的 C 写法:

1
2
3
4
5
void test(){
for(int i=0;i<10;i++){
//something
}
}

对应的汇编代码:

1
2
3
4
5
6
movq %rcx,0
do:
;something
addq %rcx,$1
cmpq $10,%rcx
jl do

如果循环语句中有 continue,我们就跳过循环体中的所有逻辑,直接到循环条件判断和循环变量的修改,如果有 break 我们直接跳出循环即可。

要点:掌握循环结构的写法和循环的判断

switch语句

其实有人说,switch 就可以理解为 if elseif else if … 的形式,其实从实现来说,两者并没有太大的差别,但是执行效率上会有巨大差别,如果语句在第 n+1 个条件成立了,那么程序进行了 n+1 次判断。而 switch 语句在多数情况下(case语句超过四条,值的跨度小)并没有使用条件转移来区分各个值之间的跳转,当然它会区分一般值和默认值(default),一般值我们使用跳转表的形式实现。

比如如下的语句:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void test{
long val=1;
switch(n){
case 1:
val+=2;
break;
case 2:
val-=2;
break;
case 3:
val*=2;
break;
case 4:
val-=2;
break;
default:
val++;
}
return val;
}

将它翻译为汇编语言之后可能会出现这样的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
.L1:
.quad .L2
.quad .L3
.quad .L4
.quad .L5
.quad .L6

test proc:
movq $1,%rax
subq $1,%rdi
cmpq $3,%rdi
ja L6
jmp *.L1(,%rdi,8)
L2:
addq $2,%rax
jmp end
L3:
subq $2,%rax
jmp end
L4:
mul $2,%rax
jmp end
L5:
div $2,%rax
jmp end
L6:
addq $1,%rax
end:
ret
test endp

我们可以看到跳转表的一个优势所在,对于每一个条件,都可以只进行一次判断就跳转到指定位置运行,当然我们实际在用的时候,可能会出现空语句,或者是滑落的情况(即语句下面没有 break),如果出现滑落,我们就删除对应语句块的 jmp 命令,如果出现空语句(case 后面没有语句),我们不新增加标签,而是直接设置跳转表的位置为下一个有值的标签。

过程

过程是软件中一种很重要的抽象。他提供了一种封装代码的方式,用一组指定的参数和一个可选的返回值实现了某种功能。然后我们可以在程序中不同的地方调用这个函数。

运行时栈

image

我感觉学习pwn的没有不熟悉这张图的吧

C 语言调用机制的一个关键特性就是使用了栈结构提供的后进先出的管理原则。当P

调用Q的时候,我们需要保存P的状态,并为Q开辟出一块位置存放Q所需要的东西。当Q运行完返回P时,我们需要将Q占用的位置释放,并且恢复到调用Q函数之前的那种状态,除了rax用于保存返回值不需要恢复。

上面的图反映的状态就是调用Q时栈上的分布。

转移控制

在我们调用Q时,我们只需要把rip的值改为Q代码运行的地方就可以开始执行Q的指令,但是当Q执行结束,我们该继续执行P中的语句时,我们也该将rip中的值改到我们需要执行的P中的指令的地址。

这时候,就需要使用call指令

call指令的作用是先将当前指令下一步指令地址的值放入栈中(也就是我们说的返回地址),然后跳转到Q代码的地址开始执行,在Q执行结束之后调用ret,将之前放在栈上的返回地址放入到rip中

数据传送

众所周知,一般我们调用一个函数都是有参数的,例如int add(int a,int b),我们调用的时候格式就为add(3,4)

如果我们想运行这个函数,在调用到这个函数的时候就需要把参数也交给这个函数,所以我们得知道,我们是怎么将这个参数交给这个函数的

在x86-64架构的下,传参是由寄存器进行传参的:前六个参数分别传递给寄存器 %rdi,%rsi,%rdx,%rcx,%r8,%r9,若还有剩余参数,剩余参数从右往左依次入栈。

栈上的局部存储

有些时候,局部数据必须存放在内存中,常见的情况:

1.寄存器不足够存放本地所有的本地数据

2.对一个局部变量使用地址运算符’&’,因此必须能够为他产生一个地址

3.某些局部变量是数组或结构,因此必须能够通过数组或结构引用被访问到

寄存器中的局部存储空间

image

这是我们之前看过的图,在它上面写了调用者保存和被调用者保存,这是当我们在调用函数的时候,可能会修改一些寄存器的值,但是,当我们程序结束返回的时候,除了rax这些寄存器的值应该恢复到调用函数前的值,所以这些值是怎么保存的呢

调用者保存:调用函数的代码(调用者)负责保存那些在函数调用过程中可能被改变的寄存器。

在调用函数之前,调用者会先保存这些寄存器的值,通常是通过将寄存器的值压入栈中。被调用的函数执行时,会使用这些寄存器。如果被调用的函数修改了它们,调用者在返回时会从栈中恢复寄存器的值。

被调用者保存:被调用的函数负责保存它自己使用的寄存器

被调用的函数在开始时保存它需要使用的寄存器的值,通常将这些寄存器的值压入栈中。被调用的函数执行时,可以修改这些寄存器,但在函数返回之前,它必须恢复寄存器的原始值。

数组分配和访问

C语言中的数组是一种将标量数据集成更大数据类型的方式。

基本原则

对于数据类型T和整形常熟N,起始位置为X,L为数据类型T的大小

如果我申明T A[N]

这表示我申请了一块LN大小的连续空间,起始位置是X,我们可以用0~N-1对这一空间的所有数进行索引。元素i的地址为X+i*L

例如:

int a[10],我们知道int类型大小为4字节,所以开辟了40个字节的空间存放a数组

假设起始位置为0x400000,那么a数组中每一个元素位置为0x400000+4*i

指针运算

C语言允许对指针进行运算,而计算出来的值会根据该指针引用的数据类型的大小进行伸缩。p+i 相当于 Xp+i*sizeof(type) 的地址(Xp 表示数组 p 的基地址)。

此外,书上还介绍了&和*

&为取地址符,根据这个名字,我们也该知道这个符号的作用是取出变量的地址

1
2
例如
b=&a;//这是将a变量存在的地址交给b

*为解引用运算符,这个符号的作用是访问指针所指向的地址上的值

根据上个例子,现在b为a的地址,如果a的内容为5
那么*b就为5

嵌套数组

当我们定义了一个 int A[5][3] 时,相当于做了如下定义:

1
2
typedef int row3_t[3];
row3_t A[5];

将A看作一个有5个元素的数组,每个元素都是三个int的数组,所以关于A中元素的地址的计算公式为&A[i][j]=XA(起始地址)+4(int类型大小) *(5**i+j)

异质的数据结

C语言提供了两种将不同类型的对象组合到一起创建数据类型的机制:

1.结构(structure),用关键字 struct 来声明,将多个对象集合到一个单位中;

2.联合(union),用关键 字 union 来声明,允许用几种不同的类型来引用一个对象。

结构体

C语言的struct声明创建一个数据类型,将可能不同类型的对象聚合到一个对象中。用名字来引用结构的各个组成部分。类似于数组的实现,结构的所有组成部分都存放在内存中一段连续的区域内,而指向结构的指针就是结构第一个字节的地址。

例如:

1
2
3
4
5
6
struct rec {
int i;
int j;
int a [2];
int *p;
};

然后内存中的排列为

image9

我们看到,我们将声明的数组嵌套在这个结构体空间中,尽管这些并不是同一个类型的数据,但是我们仍然将他存储到一个对象中,通过偏移进行查找

联合

联合提供了一种方式,能够规避C语言的类型系统,允许以多种类型来引用一个对 象。联合声明的语法与结构的语法一样,只不过语义相差比较大。它们是用不同的字段来引用相同的内存块。

联合和结构体不同的点在于,结构体为它声明的每个变量都分配了一个空间,但是联合是是大家共用的一个空间

例如:

1
2
3
4
5
6
union rec {
int i;
int j;
int a [2];
int *p;
};

这个例子和上一个相似,但是这个分配的空间就只有八(最大的那一个数据所分配的空间)所以它同一时间只允许一个数据存在在这个空间中

数据对齐

对于 C 语言而言,为了方便内存管理会对数据类型做出这样的规定:大小为 n(n<=8) 的数据必须对齐在地址为 n 的倍数上,如果当前地址不符合要求则会往后顺延,中间的内存可能会因为填充产生浪费。

假设你有一个包含以下数据类型的结构体:

1
2
3
4
5
struct Data {
char a; // 1 byte
int b; // 4 bytes
char c; // 1 byte
};

按我们之前学习的知识来看,他应该在空间中占据6个字节的空间,但是事实上,它占据了12个字节

为什么会占据12个字节,为了提高访问效率,结构体的内存对齐要求通常是其最大成员的对齐方式。即一个结构体的对齐方式是它最大成员类型的对齐大小

在机器即程序中将控制与数据结合起来

好了,在前面学习了那么多的东西,到最后一点了,我们才开始看关于真正会在pwn里面利用的部分

理解指针

一些指针和它们映射到机器代码的关键原则:

  • 每个指针都有一个类型:指针的类型决定了它指向的数据类型及其访问方式。
  • 每个指针都有一个值:指针存储的是一个内存地址,该地址指向实际的数据。
  • 指针用&运算符创建&​ 运算符返回一个变量的地址,用于创建指针。
  • *操作符用于间接引用指针:通过 *​ 运算符,可以访问指针指向的值(解引用)。
  • 数组与指针紧密联系:数组名本质上是指向数组首元素的指针,指针可以通过索引访问数组元素。
  • 将指针从一种类型强制转换成另一种类型,只改变它的类型,而不改变它的值:通过强制类型转换,你可以改变指针的类型,但指向的内存地址不变。
  • 指针也可以指向函数: 这提供了一个很强大的存储和向代码传递引用的功能,这些引用 可以被程序的某个其他部分调用。

对于以下声明:

1
int (*f)(int*);

要从里(从“f”开始)往外读。因此,我们看到像“(*f)”​表明的那样,f是一个指针; 而“(*f)(int* )”​表明f是一个指向函数的指针,这个函数以一个int* 作为参数。 最后,我们看到,它是指向以 int *​ 为参数并返回 int 的函数的指针。

*f ​两边的括号是必需的,否则声明变成 int *f(int*);​ 它会被解读成 (int *) f(int*);​ 也就是说,它会被解释成一个函数原型,声明了一个函数,它以一个int * 作为参数 并返回一个int*。

gdb调试

GNU 调试器 GDB 提供了许多有用的特性,允许我们机器级分析,具体可以用

plaintext

1
$gdb file

来进入 gdb shell 调试程序,输入 help 可以查看具体命令和效果,常见的有

命令 效果
run 运行程序(在此给出参数)
quit 退出
kill 杀死程序
b xxx 下断点
delete 删除断点
s 单步运行
si 机器级单步运行
n 步过运行
ni 步过运行
c 继续运行
finish 步过运行直到ret
disass 查看反汇编代码
p 打印数据
info register 查看寄存器
help 调出手册

缓冲区溢出

由于C对于数组引用不进行任何边界检查,而且局部变量和状态信息(例 如保存的寄存器值和返回地址)都存放在栈中。这两种情况结合到一起就能导致严重的程 序错误,对越界的数组元素的写操作会破坏存储在栈中的状态信息。当程序使用这个被破坏的状态,试图重新加载寄存器或执行 ret 指令时,就会出现很严重的错误。

一种特别常见的状态破坏称为缓冲区溢出(buffer overflow)。通常,在栈中分配某个 字符数组来保存一个字符串,但是字符串的长度超出了为数组分配的空间。

又得拿出这张图片了

屏幕截图 2025-04-25 161956

我们在函数被调用时的输入放在局部变量中,当我们的输入超过为它所分配的位置,将逐步占据栈上的被保存寄存器和返回地址(为什么向上增长,数据由低地址向高地址增长)。通过我们之前的学习得知,返回地址为被调用函数执行结束时要运行的指令地址,如果这时候我们修改这个地址为我们自己想要执行的位置(比如后门)就可以获得shell了

对抗缓冲区溢出

为了对抗缓冲区溢出,研究出了几种对抗方法,这也是我们现在会遇到的保护机制

  1. 栈随机化
  2. 栈破坏检测
  3. 栈不可执行

一般情况下,程序每次运行的虚拟地址都是固定不变的,这样会使得程序更容易遭受攻击,栈随机化使得每次程序运行时栈的位置都有变化,这类技术我们成为 地址空间布局随机化(Address-Space Layout Randomization) 当然,有防御,就会有攻击方法。我们只需要在缓冲区中插入比较多的 nop 指令,那么我们只要任意命中一个 nop 都可以导致我们恶意代码的顺利执行。这个序列的常用术语是 nop sled 差不多就是一直滑过去。如果随机化的可能性达到了 2^21,那么如果命中位置只有一个的话,我们就要赌这 2^(-21) 的概率,这显然不太能被接受,假如我们插入了 255 字节的 nop,那么我们任意命中 256 中的其中一个,我们都可以执行成功,概率直接缩小到 2^13,这个数字大小能稍微被接受了。

第二个思路是在造成严重的后果之前尽可能检测到栈溢出了,思想就是往返回地址之前插入一段随机数,在函数即将返回的时候,检查这段随机数是否被更改,如果被更改,调用 __stack_chk_fail 函数进行异常处理终止程序运行。一般这个随机数我们称为 canary(金丝雀,实际在下矿的时候主要用于探测煤矿是否有毒),现在的 gcc 版本默认添加 canary,但是我们可以通过参数 -fno-stack-protector 参数关闭这个保护。

第三个思路就是NX(NO EXECUTE) 让栈中不可执行代码,我们往栈中插入代码的思想也就变的不可行了,但是我们仍然可以使用 ROP 的思想去绕过这一保护。

结尾

终于写完了,好累,第一次文章,还是这么长的文章,参考了很多xia0ji233师傅的内容和文章结构(师傅写得实在是太好了),我自己在读这一章的时候好多地方理解的不太清楚,参考了一些其他相关的文章和gpt给的解释,如果文章里面有什么错误,请一定要留言告诉我


CSAPP 第三章:程序的机器级表示
http://example.com/post/csapp-chapter-3-machinelevel-representation-of-programs-z17vqpx.html
作者
N1mbus
发布于
2025年4月25日
更新于
2025年4月25日
许可协议