简单记录一下一些GCC的特性、开关选项在CTF中的一些应用,有时候还是能给做题带来一些便利的。主要是与shellcode编写相关。

内联汇编

有些时候在写C程序时需要直接编写汇编来实现一些底层的功能,比如执行一些特殊的没有API的指令等等,这时候就需要用到GCC内联汇编的功能了。

内联汇编语句一般以asm开头,可以使用volatile关键字来禁止编译器进行优化,一个简单的内联汇编片段如下

1
2
3
4
5
6
7
int a=10, b;
asm ( "movl %1, %%eax;
movl %%eax, %0;"
:"=r"(b) /* output */
:"r"(a) /* input */
:"%eax" /* clobbered register */
);

这段汇编的作用是将变量a的值赋给b,此时b作为输出替代汇编语句中的%0,而a作为输入替代汇编语句中的%1。注意此时由于使用%来表示变量,在表示寄存器时就需要使用两个%。最后的clobbered register中%eax是告诉GCC在这段汇编中%eax的值被改掉了。

由上面的例子我们可以看到基本的内联汇编格式

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

在汇编中默认是AT&T语法,也可以指定使用Intel语法

1
2
3
asm(".intel_syntax noprefix;\n"
"mov rax, fs:0\n"
".att_syntax prefix;");

注意实际上内联汇编是将我们自己写的汇编代码嵌入到编译器生成的汇编代码中,而GCC生成的默认是AT&T语法的汇编。当我们切换到Intel语法后,写完自己的代码,需要切换回AT&T语法,否则之后编译器生成的汇编代码就会因为语法不对导致汇编器报错。

在进行变量命名时,除了按照顺序使用%0%1…之外,也可以直接使用名字,下面有一个相对复杂的例子。来自Breaking the x86 ISA这篇演讲源代码中的injector.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
__asm__ __volatile__ ("\
mov %[eax], %%eax \n\
mov %[ebx], %%ebx \n\
mov %[ecx], %%ecx \n\
mov %[edx], %%edx \n\
mov %[esi], %%esi \n\
mov %[edi], %%edi \n\
mov %[ebp], %%ebp \n\
mov %[esp], %%esp \n\
jmp *%[packet] \n\
"
:
:
[eax]"m"(inject_state.eax),
[ebx]"m"(inject_state.ebx),
[ecx]"m"(inject_state.ecx),
[edx]"m"(inject_state.edx),
[esi]"m"(inject_state.esi),
[edi]"m"(inject_state.edi),
[ebp]"m"(inject_state.ebp),
[esp]"i"(&dummy_stack.dummy_stack_lo),
[packet]"m"(packet)
);

这段内联汇编就直接使用了eax, ebx, ecx这样的变量名称,将相关寄存器保存到inject_state结构体的相应成员之中。

内联汇编这块的相关资料还是比较丰富的,也可以参考文末的链接进行了解。

用C编写shellcode

很多时候我们需要使用shellcode来实现一些复杂的功能,例如与上层hypervisor进行交互、发送RPC、列目录等等,此时如果纯手写汇编的话就显得有些复杂。尽管pwntools中提供了shellcraft这样的生成模块简化了编写过程,但诸如内存状态和寄存器状态的维持还是需要手工进行。

在最近的几场CTF中,我都使用了gcc直接编译c代码生成shellcode的方式来生成,相对来说开发难度降低了不少。

基本流程

一个简单的示例

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
// sc.c
asm("entry:\n"
"call main\n");

int read(int fd, char *buf, long sz);
int write(int fd, char *buf, long sz);
int exit(int no);

int main() {
char msg[] = "Hello world!\n";
write(1, msg, 13);
exit(0);
}

asm("read:\n" \
"movl $0, %eax\n" \
"syscall\nret\n");

asm("write:\n" \
"movl $1, %eax\n" \
"syscall\nret\n");

asm("exit:\n" \
"movl $60, %eax\n" \
"syscall\n");

这段c代码没有包含任何头文件,所有的功能都是自己实现的,最终能够输出Hello world!的字样。由于内联汇编是直接在汇编代码中插入代码块,所以我们也可以直接用汇编设置label,这里read函数就是用c定义,而用汇编实现的,这样可以避免gcc在函数头尾加入prologueepilogue,简化函数代码。

GCC的编译过程默认是会链接glibc的,即使使用-static也会将glibc那一套都链接进elf中,这样生成的elf就相当大。可以使用-nostdlib选项来禁止gcc链接glibc. 例如上面的代码可以这样编译:

1
gcc -nostdlib -e entry sc.c -o sc

-e选项的作用是指定elf的入口点。编译完成后就能够直接生成一个仅包含上面c代码的elf文件了,运行之后就能输出相应的字符串。接下来我们要做的就是通过objcopy将其.text段提取出来

1
objcopy --dump-section .text=sc.bin sc

这时我们就可以直接使用sc.bin作为shellcode了.

rodata与栈字符串

上面的代码所有的操作都只涉及了.text段的内容,但如果我们这样写

1
write(1, "Hello World!\n", 13);

字符串就会被放到.rodata段,而不是被直接放到栈上。这样编译出来的代码在寻址时可能会直接使用绝对地址去寻找相应的字符串,此时我们就需要开启pie来进行间接寻址。

1
gcc -nostdlib -fpie -e entry sc.c -o sc

但这样一来我们就需要同时提取.text.rodata两个段并将他们拼接在一起,还要注意.text段的对齐问题,稍微有点复杂,所以还是直接使用栈字符串比较省事。

全局变量

一般来说,全局变量是被放在.bss.data段中的,而这两个段由于需要可读可写不可能和.text和放到同一个segment,将会导致更加麻烦的寻址问题。

考虑到这一点,我们可以将全局变量直接放到.text段中,并将.text段设为可读可写可执行,用下面的代码作为示例

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
asm("entry:\n"
"call main\n");

int read(int fd, char *buf, long sz);
int write(int fd, char *buf, long sz);
int exit(int no);

char msg[100] __attribute__((section(".text")));

int main() {
read(0, msg, 100);
write(1, msg, 100);
exit(0);
}

asm("read:\n" \
"movl $0, %eax\n" \
"syscall\nret\n");

asm("write:\n" \
"movl $1, %eax\n" \
"syscall\nret\n");

asm("exit:\n" \
"movl $60, %eax\n" \
"syscall\n");

这里使用了__attribute__告诉gcc将msg变量放入.text段中,编译时使用-Wl,--omagic.text变为可写

1
gcc -nostdlib -fpie -Wl,--omagic -e entry sc.c -o sc

这时就可以对msg进行读写了,这时msg变量实际上在main函数的前面。不过头部的entry保证了shellcode是从0地址进入的,这样就不用关心入口点的问题了。

产生Header File供IDA使用

我们在使用IDA进行逆向时,经常需要标一些库中的特定structure,此时导入头文件是一个比较好的选择。然而一般的库头文件中总有很多的依赖问题,导致不能直接使用IDA导入。以前我会找到所有报错的地方,然后手动解决,比如删掉不需要的结构体,自己添加typedef定义一些数据类型,将不认识的指针都改成void *等等,还是有些繁琐。

GCC有一些选项能够输出编译过程的中间结果,例如-E能够输出预处理之后的结果,-C能输出编译后的汇编代码. 我们可以编写一个特别简单的C代码

1
#include <linux/kvm.h>

然后使用,gcc -E test.c -S test.out进行预处理,此时生成的test.out就是已经包含了所有依赖的头文件的内容了。

不过现在生成的文件还不能直接导入ida,需要将所有以#开头的行都去掉或者注释掉,并手动修改一些还是会报错的地方。例如去掉所有的__extension__,以及将__signed__改成signed等等。不过总体来说对于复杂的头文件依赖还是比手动调整要简便多了。

Reference

Linux中x86的内联汇编

GCC Extended Asm

Declaring Attributes of Functions