目录
题外话:在我的理解,学习二进制分析技术就像是计算机领域的医学生,只不过我们诊治的对象不是人,是代码,而且是计算机最底层,和硬件打交道的代码。这章介绍的主题“简单的ELF代码注入技术”,就像是普外科的一台小手术,针对不同类型的病人,会采用不同的手术手段进行诊治,使病人尽快康复。那么接下来即将介绍的3种代码注入技术,也是适用于不同的简单的应用场景。对于复杂的代码注入技术,即插桩,请读者浏览本专栏的第9篇文章二进制插桩。
1.使用十六进制编辑器修改裸机二进制文件
修改现有二进制文件最直接的方法是,使用十六进制编辑器直接编辑二进制文件。编辑器以十六进制格式表示二进制文件的字节,并允许编辑这些字节。通常,首先会使用反汇编程序来识别要修改的代码或者数据字节,然后使用十六进制编辑器进行修改。
- 优点:简单,只需要基本的工具
- 缺点:只允许就地编辑(In- place):可以更改代码或数据字节,但是不能增添新的内容。因为插入字节会导致后面移位字节的引用。
- 适用场景:恶意软件使用反调试技术来检查它运行的环境是否有分析软件的迹象,可以用no p指令覆盖检查;另外,可以修复一个简单程序中的小错误。
实例分析:在操作中观察off-by-one漏洞
off-by-one漏洞通常出现在程序员错误地使用循环条件导致循环读取或者写入过少或者过多的字节。以下图xoc_encrypt.c为例,该程序对文件进行加密,但由于只在0~n-1内循环,意味着最后一个加密字节位于缓冲区的索引n-2处,因此最后一字节n-1处于未加密状态,这就是off-by-one漏洞。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdarg.h>
void
die(char const *fmt, ...)
{
va_list args;
va_start(args, fmt);
vfprintf(stderr, fmt, args);
va_end(args);
exit(1);
}
int
main(int argc, char *argv[])
{
FILE *f;
char *infile, *outfile;
unsigned char *key, *buf;
size_t i, j, n;
if(argc != 4)
die("Usage: %s <in file> <out file> <key>\n", argv[0]);
infile = argv[1];
outfile = argv[2];
key = (unsigned char*)argv[3];
f = fopen(infile, "rb");
if(!f) die("Failed to open file '%s'\n", infile);
fseek(f, 0, SEEK_END);
n = ftell(f);
fseek(f, 0, SEEK_SET);
buf = malloc(n);
if(!buf) die("Out of memory\n");
if(fread(buf, 1, n, f) != n)
die("Failed to read file '%s'\n", infile);
fclose(f);
j = 0;
for(i = 0; i < n-1; i++) { /* Oops! An off-by-one error! */
buf[i] ^= key[j];
j = (j+1) % strlen(key);
}
f = fopen(outfile, "wb");
if(!f) die("Failed to open file '%s'\n", outfile);
if(fwrite(buf, 1, n, f) != n)
die("Failed to write file '%s'\n", outfile);
fclose(f);
return 0;
}
使用该程序对源文件本身进行加密,观察该漏洞:
要修改以上程序,首先反汇编该程序,找到for循环的地址,如下图所示,0x4007fa处cmp指令将i(存储在rbx)与n-1(存储在r12)进行比较,如果需要再次循环,则jne指令跳回循环的开始,如果不需要,则进入下一条指令,结束循环。
由于jne指令表示‘不相等则跳转’:如果i不等于n-1,则跳回循环开始,为了修复off-by-one漏洞,应改为i<=n-1条件下运行,所以应改为jae指令(大于等于跳转),即n-1(r12)>=i(rbx)跳转。
4007d8: 41 0f b6 04 17 movzx eax,BYTE PTR [r15+rdx*1]
4007dd: 48 8d 6a 01 lea rbp,[rdx+0x1]
4007e1: 4c 89 ff mov rdi,r15
4007e4: 30 03 xor BYTE PTR [rbx],al
4007e6: 48 83 c3 01 add rbx,0x1
4007ea: e8 a1 fe ff ff call 400690 <strlen@plt>
4007ef: 31 d2 xor edx,edx
4007f1: 48 89 c1 mov rcx,rax
4007f4: 48 89 e8 mov rax,rbp
4007f7: 48 f7 f1 div rcx
4007fa: 49 39 dc cmp r12,rbx
4007fd: 75 d9 jne 4007d8 <main+0xb8>
4007ff: 48 8b 7c 24 08 mov rdi,QWORD PTR [rsp+0x8]
400804: be 66 0b 40 00 mov esi,0x400b66
使用十六进制编辑器hexedit替换违规字节:由上面反汇编代码可知,jne指令字节编码0x75d9,因此在目标文件中查找该字节编码,将jne操作吗0x75替换为jae字节编码0x73,具体如下图所示:
将上图的75改成73(jne改成jae)
现在已经修复了二进制文件中的off-by-one漏洞了,使用objdump确认
4007d8: 41 0f b6 04 17 movzx eax,BYTE PTR [r15+rdx*1]
4007dd: 48 8d 6a 01 lea rbp,[rdx+0x1]
4007e1: 4c 89 ff mov rdi,r15
4007e4: 30 03 xor BYTE PTR [rbx],al
4007e6: 48 83 c3 01 add rbx,0x1
4007ea: e8 a1 fe ff ff call 400690 <strlen@plt>
4007ef: 31 d2 xor edx,edx
4007f1: 48 89 c1 mov rcx,rax
4007f4: 48 89 e8 mov rax,rbp
4007f7: 48 f7 f1 div rcx
4007fa: 49 39 dc cmp r12,rbx
4007fd: 73 d9 jae 4007d8 <main+0xb8>
查看修复后的xor_encrypt程序结果
可看到最后一个字节已经被加密,不是0x0a,已变成0x65
2.使用LD_PRELOAD修改共享库行为
十六进制编辑器的限制是容易出错且无法添加新的代码或者数据。如果目标是共享库行为,可以使用LD_PRELOAD实现此目标。
LD_PRELOAD是影响动态链接器行为的环境变量的名称,该变量允许你指定一个或多个库文件以供链接器在其他库之前加载,包括标准的系统库。如果预加载库中的函数与稍后加载的库中的函数同名,则加载的第一个函数将是在运行时使用的函数,这允许你使用自己的函数重写库函数,甚至标准的库函数。
实例分析:堆溢出漏洞
堆由开发人员分配和释放,若开发人员不释放,程序结束时由OS回收。
heapoverflow.c包含堆溢出漏洞:该程序接收2个命令行参数,数字以及字符串。当字符串长度小于等于该数字,以下程序正常运行,当字符串长度大于该数字,就会发生堆溢出,这个堆溢出出现在strcpy操作中,因为没有对字符串长度进行检查,所以超长的字符串可能放入缓冲区中,会破坏堆上的其他数据,最终导致程序崩溃甚至被攻击利用。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int
main(int argc, char *argv[])
{
char *buf;
unsigned long len;
if(argc != 3) {
printf("Usage: %s <len> <string>\n", argv[0]);
return 1;
}
len = strtoul(argv[1], NULL, 0);
printf("Allocating %lu bytes\n", len);
buf = malloc(len);
if(buf && len > 0) {
memset(buf, 0, len);
strcpy(buf, argv[2]);
printf("%s\n", buf);
free(buf);
}
return 0;
}
给出良性输入时heapoverflow程序的行为,告诉heapoverflow分配一个13字节的缓冲区,然后将消息“Hello World”复制进去。
当输入消息太长时,heapoverflow程序会崩溃,如下图所示
检测堆溢出heapcheck.c,这里重写了库函数malloc、free以及strcpy,如以下代码所示
- 在重写的malloc库函数中使用allocs数据结构记录了分配缓冲区的地址和大小;
- 在重写的free函数中依旧调用了原始的free(orig_free),然后使allocs数组中释放的缓冲区元数据无效;
- 在重写的strcpy函数中,在调用原始的strcpy函数前,先检查缓冲区是否足够容纳字符串,如果足够,允许复制,否则输出错误消息并终止程序,防止攻击者利用此漏洞
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <dlfcn.h>
void* (*orig_malloc)(size_t);
void (*orig_free)(void*);
char* (*orig_strcpy)(char*, const char*);
typedef struct {
uintptr_t addr;
size_t size;
} alloc_t;
#define MAX_ALLOCS 1024
alloc_t allocs[MAX_ALLOCS];
unsigned alloc_idx = 0;
void*
malloc(size_t s)
{
if(!orig_malloc) orig_malloc = dlsym(RTLD_NEXT, "malloc");
void *ptr = orig_malloc(s);
if(ptr) {
allocs[alloc_idx].addr = (uintptr_t)ptr;
allocs[alloc_idx].size = s;
alloc_idx = (alloc_idx+1) % MAX_ALLOCS;
}
return ptr;
}
void
free(void *p)
{
if(!orig_free) orig_free = dlsym(RTLD_NEXT, "free");
orig_free(p);
for(unsigned i = 0; i < MAX_ALLOCS; i++) {
if(allocs[i].addr == (uintptr_t)p) {
allocs[i].addr = 0;
allocs[i].size = 0;
break;
}
}
}
char*
strcpy(char *dst, const char *src)
{
if(!orig_strcpy) orig_strcpy = dlsym(RTLD_NEXT, "strcpy");
for(unsigned i = 0; i < MAX_ALLOCS; i++) {
if(allocs[i].addr == (uintptr_t)dst) {
if(allocs[i].size <= strlen(src)) {
printf("Bad idea! Aborting strcpy to prevent heap overflow\n");
exit(1);
}
break;
}
}
return orig_strcpy(dst, src);
}
使用heapcheck.so库来防止堆溢出,如下图所示,当字符串长度超出数字时,会提示错误信息并退出
3.注入代码节
接下来讨论如何将一个全新的代码节注入ELF二进制文件中。如下图所示:左侧是原始的ELF二进制文件,右侧是添加了新节.injected后改动过的文件。向ELF添加新节的的过程:
- 首先如下图右侧的步骤1,将新节添加到ELF文件的末尾;
- 为注入的节创建程序头(步骤3),程序头一般位于ELF头部的后面,为了避免复杂的移位,可以简单地覆盖现有的程序头而不是添加一个新的程序头;
- 为注入的节创建节头(步骤2),同理,覆盖原有的节头,避免向二进制文件添加新的节头。虽然,节头在二进制程序的末尾,可以随意添加新的节头,但由于在程序头中覆盖了相应的节,相应的,可以在节头表中覆盖相应的节
3.1 理论介绍覆盖PT_NOTE段
由于PT_NOTE段覆盖了二进制文件的节的辅助信息,如这是一个GNU/Linux操作系统的二进制文件,该二进制文件所期望的内核版本信息。如果缺少这些信息,加载器只会假设它是本机二进制文件,因此可以安全覆盖PT_NOTE头而不必担心破坏二进制文件。(这种技巧通常被恶意病毒用来感染二进制文件,但它也适用于良性修改)
PT_NOTE段一般包含.note.ABI-tag和.note.gnu.build-id这两个节,上图中的修改,即覆盖.note.*节头,并将其替换为新的代码节(.injected),这里可以覆盖.note.ABI-tag节的头,同时修改节头表(参考本专栏的文章2)中的各个属性。同理,修改程序头表中该段的各个属性。更新了的字符串表将旧的note.ABI-tag节改为.injected以反映新代码节的添加。
重定向ELF入口点
如果需要重定向入口点,将ELF的头部e_entry字段修改为指向新的.injected节的地址。如果不重定向,新注入的代码将永远不会运行,除非将原始.text节中的某些调用重定向到注入的代码,使某些注入代码作为构造函数运行,或者运用另外的方法达到注入代码的目的。
3.2 使用elfinject注入ELF节
为了使上述介绍的PT_NOTE注入技术更实际,具体,接下来使用elfinject工具注入代码节。
这是原始的/bin/ls可执行程序的头部,从图中标红2的可看到.note.ABI-tag节
以及下图标红1的NOTE段,包括.note.ABI-tag和.note.gnu.build-id两个节(下图标配红2)
接下来,使用elfinject工具将hello.bin注入到ls可执行程序(下图标红1),“.injected”为注入的节的名称,0x800000为注入的地址,0表示重定向入口地址。标红2使用readelf显示注入.injected节后的头部
从下图标1处可看到.injected已注入到ls可执行程序中,段类型及相关属性也已修改(下图标红2)
测试注入代码节后的ls可执行程序,发现先输出hello world!再输出当前文件夹下的文件。因为二进制文件在启动时会运行注入的代码,输出hello world!然后注入的代码将执行权交给文件的原始入口点,以便恢复输出目录列表的正常行为。
3.3 调用注入的代码
3.3.1使用hexedit修改入口点
在3.2节中是使用elfject修改入口点地址,本小节介绍使用hexedit修改入口点。
首先,将一段手工编写的汇编代码hello.s注入到可执行文件/bin/ls,需要对其进行处理,以下是3.2节中注入到ls的汇编代码hello.s
为了使代码适合注入,需要将其汇编到包含经过二进制编码的汇编指令和数据的原始二进制文件中,即将该代码执行结束后,将执行权交给原始二进制文件。
将hello.s汇编到原始二进制文件中,可以使用nasm汇编程序的-f bin选项,使用如下命令行,
nasm -f bin hello.bin hello.s
hello.bin是输出的目标文件,其中包含适合注入的原始二进制指令和数据,使用elfject注入此文件,不过不同于3.2节的是,不适用该工具修改入口点,因此,不修改入口点地址的注入命令行是
发现,测试注入代码后的ls.entry1(ls的副本)没有变化,这是因为还没有修改入口点,接下来使用hexedit修改入口点
将原始的入口点4049a0,因为是小端格式存储,所以应搜索a04940,替换为注入代码的地址800e78,反转字节顺序则是780e80
入口点地址替换成功后,再测试ls.entry1
输出hello world,入口地址修改成功。
3.3.2劫持构造函数和析构函数
这周的工作到这里应该结束了,来不及写完的小节后续找时间补上~~~