Linux进程注入

前言

将代码注入到特定进程空间内执行,是一种十分geek的行为,可用于破解、安全防护等。不像Windows有CreateRemoteThread可以直接在目标进程内创建线程,在Linux下进行进程注入会显得复杂些。
目前比较常用的手段,就是计算dlopen函数在libc.so中的位置,然后利用ptrace连接目标进程,更改PC地址使其运行该函数,但是该方式只能针对非静态编译的程序。

mandibule

我在github上发现了项目mandibule,该项目不使用任何C标准库(-nostdlib),类似于strcmp之类的函数都用C重写一遍,所以编译出来的产物就是一个无依赖shellcode。

虚拟地址空间

作者巧妙地使用编译器默认不进行函数排序的特点,在唯一的源文件mandibule.c开头编写函数:

1
2
3
4
5
6
unsigned long mandibule_beg(int aligned)
{
if(!aligned)
return (unsigned long)mandibule_beg;
return (unsigned long)mandibule_beg - ((unsigned long)mandibule_beg % 0x1000);
}

因为项目只有这一个源文件,所以编译出来只有一个object文件,也就是说该函数会在程序代码段的最开头。在mandibule.c的末尾编写函数:

1
2
3
4
5
6
unsigned long mandibule_end(void)
{
uint8_t * p = (uint8_t*)"-= end_rodata =-";
p += 0x1000 - ((unsigned long)p % 0x1000);
return (unsigned long)p;
}

编译后该函数当然会在代码段的末尾,作者编写这两个函数的目的是为了在该程序运行时,可以通过调用mandibule_beg(1)、mandibule_end获得该程序的虚拟地址范围。
一般来说ELF加载到内存中时,ELF头部之后就是TEXT代码段,又因为这些段是按页对齐的,所以调用mandibule_beg(1)返回自身函数地址向下对齐页,大概率获得ELF头部的虚拟地址,也就是程序虚拟空间的开始。
但是获取程序虚拟空间末尾地址却没这么简单,因为在TEXT段后面还有DATA、RODATA等数据段。mandibule_end内使用了一个用不到字符串,该字符串肯定会在RODATA中,所以使用该字符串地址向上对齐页能够获得末尾的虚拟地址。
根据这两个地址,我们可以确定程序运行所需的代码、数据地址空间,你甚至可以将该范围的数据全部拷贝到一个堆空间,更改页属性为可执行,然后直接跳转到入口点运行起来,当然,这需要你编译的代码是位置无关的(-PIC)。

注入流程

mandibule进行代码注入的步骤:

  • 使用ptrace附加到目标进程
  • 备份进程寄存器、内存数据
  • 将自身运行时地址空间内的所有数据拷贝到目标进程内
  • 修改目标进程的寄存器,其实跳转到入口地址
  • 等待目标进程调用exit系统调用
  • 恢复寄存器、内存数据
  • 恢复进程的运行

第三步用到的地址范围就是mandibule_beg、mandibule_end所获得的,也相当于将程序自身拷贝到目标进程中运行。程序在目标进程中运行时,会使用一个自己编写ELF loader加载需要注入的代码。主要是使用mmap将程序段映射到进程空间,如果依赖动态库则还需要映射interpreter,最后生成一个假栈并设置好argv、env等信息,最后直接跳转到程序入口地址。

缺陷

使用mandibule可以对静态编译的程序进行注入,但是项目的设计有些缺陷:

  • 没有将shellcode与ptrace注入剥离,导致耦合严重
  • 项目不使用C标准库,进行二次开发较为麻烦

另外我在使用中发现了许多的bug,可见项目issue,我在推特上联系了作者,但他并没有要接着进行维护的意思。

pangolin

最后我决定对项目进行重构,将ELF loader编写成独立的shellcode模块,而ptrace注入部分完全可以用熟悉的C++开发。具体的代码细节不赘述,可以自行在github上查看项目

链接脚本

在我的设计中,将shellcode部分编译成独立的so文件,然后根据导出符号的地址获取所有shellcode数据,将其写入目标进程中运行。但是在实现中遇到了问题,因为代码编译后不仅仅只有代码段,还包括RODATA等数据段,我们需要将所有数据都包括进来,而导出符号只会出现在TEXT中。
我知道在链接阶段可以指定所有符号的地址,让编译器根据我们的要求进行符号分段、排序。最后通过搜索学习,我编写了链接脚本:

1
2
3
4
5
6
7
8
9
10
SECTIONS
{
.text :
{
*(.begin);
*(.text.*);
*(.rodata.*);
*(.end);
}
}

使用该脚本进行链接,最后生成的程序只会有一个TEXT段,字符串等数据都会在该段中。另外我编写了三个导出函数:

1
2
3
void __attribute__ ((section (".begin"))) shellcode_begin();
void shellcode_start();
void __attribute__ ((section (".end"))) shellcode_end();

可以看到我指定了两个section分配给shellcode_begin、shellcode_end,也对应了链接脚本中的begin、end。所以最后生成的程序中,符号shellcode_begin会在TEXT段的最开始,shellcode_end在TEXT的末尾。
我们只需要将shellcode_begin至shellcode_end之间的数据拷贝到目标进程空间,然后跳转到shellcode_start - shellcode_begin的入口偏移处,就可以在进程中运行我们编写的shellcode。

多线程

mandibule使用ptrace附加到进程主线程,此时别的线程依旧在运行中,在将数据拷贝到目标进程中时,覆盖的范围一般从ELF头部开始。如果覆盖的范围较大,就可能覆盖到代码段,而恰好此时其余的线程调用了一个被覆盖了的函数,就会导致程序崩溃。
为了减少多线程进程crash的可能性,我对内存覆盖进行了优化,先覆盖一小段shellcode,在目标进程中调用mmap系统调用申请内存,用申请的内存来储存较大的ELF loader。并且在ptrace阶段,会附加到所有线程,使其临时暂停运行。

栈空间

shellcode在目标进程中运行时,使用的是线程暂停时的栈,对于普通的程序,栈空间足够,所以不需要担心出现意外情况。但是对于golang这类自己管理栈的程序,栈空间十分小,shellcode在进程中运行时可能会溢出栈空间,覆盖掉程序数据最终导致crash。
另外,如果想在目标进程中驻留,可以在注入后创建新线程,但是在程序恢复运行后,新线程调用getenv等API运行会导致crash。因为argv、env等数据存在于我们注入时构建的栈上,但是该栈是别的线程拥有的,可能数据早已丢失。
为了解决这两个问题,我在ELF loader的开头使用mmap申请一块足够大的栈空间,并且修改SP寄存器更换栈空间。

1
2
3
4
5
6
7
8
void loader_main(void *ptr) {
LOG("elf loader start");

char *stack = malloc(STACK_SIZE);
char *stack_top = stack + STACK_SIZE;

FIX_SP_JMP(stack_top, elf_loader, ptr);
}

还有许多的小细节,都存在于代码中,如果感兴趣可以自行阅读。