内联汇编对于pwn出题的一些心得

文章发布时间:

最后更新时间:

文章总字数:
2.2k

预计阅读时间:
8 分钟

前言

如果只运用c语言进行编写pwn题目 对于一些寄存器或者栈结构一类的没有办法操控

这时候利用内联汇编就可以巧妙的解决问题的所在

同时由于我想学习免杀 其中有一种利用方法为内联汇编花指令 刚好可以利用这个来打基础

本文会伴随着本人对于内联汇编的不断学习而更新 不全面也不严谨 仅供参考

内联函数

在编写c语言时 如果你在一个函数中调用另外一个函数 其是通过call指令来进行跳转 跳转到对应函数的地址开始执行函数内容

最后通过存储在bp寄存器中的原函数地址来返回

比如下面这个程序

1
2
3
4
5
6
7
#include <stdio.h>
void test(){
puts("hello world");
}
int main(){
test();
}

其汇编形式如下图所示

如果是一个循环调用函数 那么就会造成栈空间过于陈杂 这个时候就可以使用内联函数来解决问题

在对应函数定义时前缀加上inline

1
2
3
4
5
6
7
#include <stdio.h>
inline void test(){
puts("hello world");
}
int main(){
test();
}

此时当main函数执行到调用test函数的时候 会直接在当前栈中执行test函数 而非跳转

但是你会发现正常编译会报错未定义的函数 我们需要手动链接

先将其编译成.o格式的文件 再进行动态链接

1
2
gcc -O   -c -o test test.c
gcc test.o -lgmp -o test

内联汇编

基本内联汇编

基于上述你对内联函数的认识 那么显然易见 内联汇编就相当于我们直接往对应位置写入汇编代码 这就使得我们拥有了在程序编写的初期就拥有了操控寄存器值的能力 或者是修改栈结构 篡改程序执行流

对于pwn题来说 常用的编译是基于GCC的 而GCC采用的是AT&T/UNIX 汇编语法

不同的汇编语法对于内联汇编的编写格式要求不一样

正常的一个汇编语句 比如要使得rax寄存器赋值为1 需要这样编写

1
mov rax,1

而受汇编语法约束的内联汇编需要这样编写

1
mov $1,%rax

也就是使得源操作数和目的操作数调换位置

其中1是属于一个立即操作数 我们需要在其前面添加一个$符号 否则1会被识别为一个地址

1
00001131  488b042501000000   mov     rax, qword [__elf_header.ident.signature[1]]  {0x10102464c45}

可以看到其被识别为了ELF文件的文件头结构体中的第一个元素的第一个字节

1
2
3
4
5
6
7
8
9
10
11
12
00000000  struct Elf64_Header __elf_header = 
00000000 {
00000000 struct Elf64_Ident ident =
00000000 {
00000000 char signature[0x4] = "\x7fELF"
00000004 uint8_t file_class = 0x2
00000005 uint8_t encoding = 0x1
00000006 uint8_t version = 0x1
00000007 uint8_t os = 0x0
00000008 uint8_t abi_version = 0x0
00000009 char pad[0x7] = "\x00\x00\x00\x00\x00\x00", 0
00000010 }

我们需要使得计算机明白1在这里是一个即时操作数 而非地址 所以需要加上$符号

至于rax寄存器前面的%符号 是寄存器的固定格式

同时和常规汇编一致 在操作符后面加上特定字符 可以决定操作数的字节大小

诸如’b’ ‘w’ ‘l’

如果我们想要调用寄存器中的值 可以用括号将寄存器套起来

1
mov (%rbx),%rax
1
00001131  488b03             mov     rax, qword [rbx]

最后 对于每行的汇编语句结束以后 都需要加上’\t\n‘ 以此来区分每行汇编 并且每行汇编都需要加上双引号 如下:

1
2
3
4
5
6
#include <stdio.h>
int main(){
asm(
"mov (%rbx),%rax\n\t"
);
}

扩展内联汇编

上述的基本内联汇编 往往只局限于内联汇编中的数据操作 而在扩展形式中

我们还可以指定操作数 并且可以选择输入输出寄存器 以及指明要修改的寄存器列表

1
2
3
4
5
asm ( assembler template
: output operands /* optional */
: input operands /* optional */
: list of clobbered registers /* optional */
);

上面是扩展内联汇编的基本格式

第一个冒号后面指定的是输出操作数

第二个冒号指定的是输入操作数

第三个冒号解释起来复杂 由于内联汇编是直接插入在我们原本的函数汇编代码中 再加上我们对于寄存器的值进行了操作

这会对正常函数的执行造成影响 所以我们需要在这里列出损坏的寄存器列表 让系统执行完内联汇编后还原一下寄存器的值 使用memory可以还原所有寄存器

1
2
3
4
5
6
7
asm (
"mov %1, %%rax\n\t"
"mov %%rax, %0\n\t"
:"=m"(b) /* output */
:"m"(a) /* input */
:"memory" /* clobbered register */
);

下面我们基于上述的一段扩展内联汇编来帮助分析

开头的两行汇编属于汇编指令部分 语法问题和基本内联汇编一致 唯一要注意的是第一行的%1是什么

其代表的是我们引入的输入操作数a 其在整个扩展内联汇编中是第二个变量 也就是说我们想要引用输出变量b 就是%0 所以是从0开始递增的

接着来看”=m” 其中’=’是约束修饰符 用来指定其为输出操作数并且是可写的

‘m’也同为约束符 通常是用来指定这个操作数的存放

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
31
“a”	将输入变量放入eax
“b” 将输入变量放入ebx
“c” 将输入变量放入ecx
“d” 将输入变量放入edx
“S” 将输入变量放入esi
“D” 将输入变量放入edi
“q” 将输入变量放入eax,ebx ,ecx ,edx中的一个
“r” 将输入变量放入通用寄存器,也就是eax ,ebx,ecx,edx,esi,edi中的一个
“A” 放入eax和edx,把eax和edx,合成一个64位的寄存器(uselong longs)
“m” 内存变量
“o” 操作数为内存变量,但是其寻址方式是偏移量类型,也即是基址寻址,或者是基址加变址寻址
“V” 操作数为内存变量,但寻址方式不是偏移量类型
“,” 操作数为内存变量,但寻址方式为自动增量
“p” 操作数是一个合法的内存地址(指针)
“g” 将输入变量放入eax,ebx,ecx ,edx中的一个或者作为内存变量
“X” 操作数可以是任何类型
“I” 0-31 之间的立即数(用于32位移位指令)
“J” 0-63 之间的立即数(用于64 位移位指令)
“N” 0-255 ,之间的立即数(用于out 指令)
“i” 立即数
“n” 立即数,有些系统不支持除字以外的立即数,这些系统应该使用“n”而不是“i”
“=” 操作数在指令中是只写的(输出操作数)
“+” 操作数在指令中是读写类型的(输入输出操作数)
“f” 浮点数
“t” 第一个浮点寄存器
“u” 第二个浮点寄存器
“G” 标准的80387
% 该操作数可以和下一个操作数交换位置
# 部分注释
* 表示如果选用寄存器,则其后的字母被忽略
“&” 表示输入和输出操作数不能使用相同的寄存器

对于一个char数组 一般是用’m’来约束 其他的我测试是会报错 同时 只能操作一个字长的数据 超过了就不行了

运用在pwn中

泄露libc基址

这个一般是用来自己方便调试的 这样可以帮助我们快速获取libc基址

比如我堆系列的博客用到的示例程序就使用了相同的代码来方便我快速获取libc基址 从而可以在exp中更自由的调试

1
2
3
4
5
6
7
8
9
char a[0x20];
read(0,a,0x20);
asm(
"pop %rsi\n\t"
"mov $1,%rax\n\t"
"mov $1,%rdi\n\t"
"mov $8,%rdx\n\t"
"syscall\n\t"
);

原理就是利用向局部变量a中输入数据 其会被存放在栈上 同时rsp指针指向了输入的数据 这个时候调用pop指令出栈 就成功的把数据传到了寄存器rsi 这个时候调用write函数 就可以直接打印出函数真实地址了

调整寄存器值

这个办法比较简单 第一次被我利用是在出canary那一题的时候 用来调整函数结束时寄存器的值 引导解题者手写shellcode 或者进行合理的rop链构造

1
2
3
4
asm(
"mov $1,%rax\n\t"
"mov %rax,%rdi\n\t"
);

比较简单就不解释了