ELF(Executable and Linkable Format)是Unix及类Unix系统下可执行文件、共享库等二进制文件标准格式。为了提高动态分析的难度,我们往往需要对ELF文件增加反调试措施。本文便对相关技术进行了总结归纳。
1.背景知识
1.1 ELF文件布局
ELF文件主要由以下几部分组成:
(1) ELF header (2) Program header table,对应于segments (3) Section header table,对应于sections (4) 被Program header table或Sectionheader table指向的内容
注意这里segments与sections是两个不同的概念。之间的关系如下:
(1) 每个segments可以包含多个sections (2) 每个sections可以属于多个segments (3) segments之间可以有重合的部分
可以说,一个segment是若干个sections的组合。
下图是readelf工具输出的某ELF文件segments与sections信息:
可以看到,segments部分包含各segments的地址、偏移、属性等;而sections部分则依次列出每个segment所包含的sections。注意到,sections通常为全小写字母,而segments通常为全大写字母。
此外,这两者之间另一个关键不同点是,sections包含的是链接时需要的信息,而segments包含运行时需要的信息。即,在链接时,链接器通过section header table去寻找sections;在运行时,加载器通过program header table去寻找segments。可见下图:
一些比较重要的sections如下:
(1) .init_array: 动态库加载或可执行文件开始执行前调用的函数列表 (2) .text: 代码 (3) .got: Global offset table(GOT),包含加载时需要重定位的变量的地址 (4) .got.plt: 包含动态库中函数地址的GOT
一些比较重要的segments如下:
(1) LOAD: 运行时需要被加载进内存的segment (2) GNU_STACK: 决定运行时栈是否可执行 (3) DYNAMIC: 动态链接信息,对应于.dynamicsection
1.2常用工具
在静态、动态分析ELF文件时,经常用到以下工具:
(1) ptrace
#include <sys/ptrace.h> long ptrace(enum __ptrace_requestrequest, pid_t pid, void *addr,void *data);
系统调用。用于监控其他进程,被gdb, strace, ltrace等使用
(2) strace
命令行工具。用于追踪进程与内核的交互,如系统调用、信号传递
(3) ltrace
命令行工具。类似于strace,但主要用于追踪库函数调用
(4) readelf/objdump
静态分析工具。用于读取ELF文件信息。
2.反调试技术
一般地,反调试是通过比较程序在未被调试和被调试两种运行状况下的不同点,来进行检测或中止程序运行。具体地,这里介绍几种常见的反调试方法。
2.1 ptrace自身进程
在同一时间,进程最多只能被一个调试器进行调试。于是,我们可以通过调试进程自身,来判断是否已经有其他进程(调试器)的存在。
具体地,我们使用ptrace来调试自身。示例代码如下:
#include <stdio.h> #include <sys/ptrace.h> int main(int argc, char *argv[]) { if(ptrace(PTRACE_TRACEME, 0, 0, 0) == -1) { printf("Debugger detected"); return 1; } printf("All good"); return 0; }
这里我们使用'PTRACE_TRACEME'来指明进程将被调试,在此种情况下其他参数会被忽略。如果已经存在调试器,那么这次ptrace调用会失败,返回-1。由此我们可以实现对调试器的检测。实际运行结果如下图所示:
2.2检查父进程名称
通常,我们在使用gdb调试时,是通过gdb <TARGET>这种方式进行的。而这种方式是启动gdb,fork出子进程后执行目标二进制文件。因此,二进制文件的父进程即为调试器。我们可通过检查父进程名称来判断是否是由调试器fork。示例代码如下
#include <stdio.h> #include <string.h> int main(int argc, char *argv[]) { char buf0[32], buf1[128]; FILE* fin; snprintf(buf0, 24, "/proc/%d/cmdline", getppid()); fin = fopen(buf0, "r"); fgets(buf1, 128, fin); fclose(fin); if(!strcmp(buf1, "gdb")) { printf("Debugger detected"); return 1; } printf("All good"); return 0; }
这里我们通过getppid获得父进程的PID,之后由/proc文件系统获取父进程的命令内容,并通过比较字符串检查父进程是否为gdb。实际运行结果如下图所示:
2.3检查进程运行状态
2.2节所提到的反调试方法,前提是被调试程序由调试器启动。但调试器也可以通过attach到某个已有进程的方法进行调试。这种情况下,被调试进程的父进程便不是调试器了。
在这种情况下,我们可以通过直接检查进程的运行状态来判断是否被调试。而这里使用到的依然是/proc文件系统。具体地,我们检查/proc/self/status文件。当进程正常运行而未被调试时,该文件的内容如下图:
而当我们使用gdb <TARGET FILE> <TARGET PID>命令,attach到目标进程进行调试后,status文件内容变化如下图:
可见,进程状态由sleeping变为tracing stop,TracerPid也由0变为非0的数,即调试器的PID。由此,我们便可通过检查status文件中TracerPid的值来判断是否有正在被调试。示例代码如下:
#include <stdio.h> #include <string.h> int main(int argc, char *argv[]) { int i; scanf("%d", &i); char buf1[512]; FILE* fin; fin = fopen("/proc/self/status", "r"); int tpid; const char *needle = "TracerPid:"; size_t nl = strlen(needle); while(fgets(buf1, 512, fin)) { if(!strncmp(buf1, needle, nl)) { sscanf(buf1, "TracerPid: %d", &tpid); if(tpid != 0) { printf("Debuggerdetected"); return 1; } } } fclose(fin); printf("All good"); return 0; }
实际运行结果如下图所示:
值得注意的是,/proc目录下包含了进程的大量信息。我们在这里是读取status文件,此外,也可通过/proc/self/stat文件来获得进程相关信息,包括运行状态。
2.4设置程序运行最大时间
这种方法经常在CTF比赛中看到。由于程序在调试时的断点、检查修改内存等操作,运行时间往往要远大于正常运行时间。所以,一旦程序运行时间过长,便可能是由于正在被调试。
具体地,在程序启动时,通过alarm设置定时,到达时则中止程序。示例代码如下:
#include <stdio.h> #include <signal.h> #include <stdlib.h> void alarmHandler(int sig) { printf("Debugger detected"); exit(1); } void__attribute__((constructor))setupSig(void) { signal(SIGALRM, alarmHandler); alarm(2); } int main(int argc, char *argv[]) { printf("All good"); return 0; }
在此例中,我们通过__attribute__((constructor)),在程序启动时便设置好定时。实际运行中,当我们使用gdb在main函数下断点,稍候片刻后继续执行时,则触发了SIGALRM,进而检测到调试器。如下图所示:
顺便一提,这种方式可以轻易地被绕过。我们可以设置gdb对signal的处理方式,如果我们选择将SIGALRM忽略而非传递给程序,则alarmHandler便不会被执行,如下图所示:
2.5检查进程打开的filedescriptor
如2.2中所说,如果被调试的进程是通过gdb <TARGET>的方式启动,那么它便是由gdb进程fork得到的。而fork在调用时,父进程所拥有的fd(file descriptor)会被子进程继承。由于gdb在往往会打开多个fd,因此如果进程拥有的fd较多,则可能是继承自gdb的,即进程在被调试。
具体地,进程拥有的fd会在/proc/self/fd/下列出。于是我们的示例代码如下:
#include <stdio.h> #include <dirent.h> int main(int argc, char *argv[]) { struct dirent *dir; DIR *d = opendir("/proc/self/fd"); while(dir=readdir(d)) { if(!strcmp(dir->d_name, "5")) { printf("Debugger detected"); return 1; } } closedir(d); printf("All good"); return 0; }
这里,我们检查/proc/self/fd/中是否包含fd为5。由于fd从0开始编号,所以fd为5则说明已经打开了6个文件。如果程序正常运行则不会打开这么多,所以由此来判断是否被调试。运行结果见下图:
3.总结
以上列出的反调试技术中,往往只进行了一次检测。为了提高强度,我们可以fork得到一个新的进程,在这个子进程中,每隔一段时间对父进程进行一次检测。
然而,这些技术都面临存在另外一个致命弱点:可以通过反汇编静态分析,找到相应的检测代码并修改,从而绕过反调试检测。因此,我们通常还需要对二进制文件进行混淆以对抗静态分析,这样与反调试技术相结合,才能得到理想的保护效果。
* 作者:银河实验室(企业账号),转载请注明来自FreeBuf黑客与极客(FreeBuf.COM)