前言
最近,我们遇到一个非常具体的问题:改变任意进程的内存区域的保护标志。这项任务看似微不足道,但是我们着实遇到了一些麻烦,在此过程中也学到了关于Linux机制和内核开发相关的东西。以下是一些简要概述,其中包括了当时采取的三种方案,每次也在寻求更好解决方案。
概述
在现代操作系统中,每个进程都有自己的虚拟地址空间(从虚拟地址映射到物理地址)。此虚拟地址空间由内存页(某些固定大小的连续内存块)组成,每个页都有保护标志,用于确定允许此页面访问的类型(读取,写入和执行)。这种机制依赖于架构页表(有趣的是,在x64架构中,你不能使页面只写(write-only),就算你特意从操作系统请求,它也总是可读的)。
在Windows中,你可以使用VirtualProtect或VirtualProtectEx这两个API更改内存区域的保护。后者让我们的任务变得非常简单:它的第一个参数hProcess是“要改变内存保护的进程的句柄”(参见MSDN.aspx))。
另一方面,在Linux中,我们并不那么幸运:更改内存保护的API是系统调用mprotect或pkey_mprotect,并且两者始终在当前进程的地址空间上运行。 我们现在回顾一下在x64架构上的Linux中解决此任务的方法(我们假设是root权限)。
而在Linux中,我们就没那么幸运了,更改内存保护的API是系统调用(mprotect或pkey_mprotect),并且两者始终在当前进程的地址空间上运行。所以现在我们来回顾一下在Linux x64架构上解决此问题的方法(假设是root权限)。
方案一:代码注入
如果mprotect总是作用于当前进程,那么我们就需要让目标进程从它自己的上下文中调用它。这称为代码注入,可以通过许多不同的方式实现。我们选择使用ptrace机制实现它,其允许一个进程“观察并控制另一个进程的执行”(参见手册),包括更改目标进程的内存的能力。此机制用于调试器(如gdb)和跟踪程序(如strace)。使用ptrace注入代码所需的步骤如下:
1. 通过ptrace附加到目标进程。如果进程中有多个线程,那就终止所有其他线程
2. 找到可执行内存区域(通过检查/proc/PID/maps)并在那里写操作码(hex:0f 05)
3.根据调用约定修改寄存器:首先将rax更改为mprotect的系统调用号(即10)。然后三个参数(起始地址,长度和所需的保护)分别存储在rdi,rsi和rdx中。最后,将rip更改为步骤2中使用的地址
4. 恢复进程直到系统调用返回(ptrace允许你跟踪系统调用的进入和退出)
5. 恢复被覆盖的内存和寄存器,从进程中分离并恢复正常执行
这种方法是第一个也是最直观的方法,但是我们之后发现Linux中的另一种叫seccomp的机制会工作得更好。它是Linux内核中的一个安全工具,允许进程自己进入某种封闭状态,除了read,write,_exit和sigreturn之外,它不能调用任何系统调用。不过也可以选择任意系统调用及其参数来仅仅过滤指定的系统调用。
因此,如果进程启用了seccomp模式并且我们尝试将mprotect调用到其中,那么内核将终止进程,因为不允许此系统调用。所以我们要寻求更好的解决方案……
方案二:模仿内核模块中的mprotect
由于seccomp,用户态中每个解决方案都不可行,因此下一个方法肯定存在于内核态中。在Linux内核中,每个线程(用户线程和内核线程)都由名为task_struct的结构表示,并且当前线程(任务)可通过指针访问。内核中mprotect的内部实现使用指针current,所以我们首先想到的是将mprotect的代码复制粘贴到我们的内核模块,并用指向目标线程的task_struct的指针替换每次出现的current。
可能你已经猜到了,复制C代码并不是那么简单,其中有大量我们无法访问的,未导出的函数,变量和宏。某些函数声明在头文件中导出,但内核不会导出它们的实际地址。如果内核是由kallsyms支持编译的,那么这个特定的问题就可以解决,然后通过文件/proc/kallsysm导出所有内部符号。
尽管存在这些问题,我们仍以mprotect的本质进行尝试,甚至仅用于教育目的。因此,我们开始编写一个内核模块,它获取mprotect目标PID和参数,并模仿其行为。首先,我们需要获取所需的内存映射对象,它表示线程的地址空间:
现在我们有了内存映射对象,就需要深入挖掘。Linux内核实现了一个抽象层来管理内存区域,每个区域由结构vm_area_struct表示。为了找到正确的内存区域,我们使用函数find_vma,它通过所需的地址搜索内存映射。
vm_area_struct包含字段vm_flags,其以与结构无关的方式表示存储器区域的保护标志,vm_page_prot以体系结构相关的方式表示。单独更改这些字段不会真正地影响页表(但会影响proc/PID/maps的输出,我们已经尝试过)。 你可以点击这里获取更多内容。
在深入研究内核代码之后,我们发现了真正改变内存区域保护所需的最基本工作:
1. 将字段vm_flags更改为所需的保护
2. 调用函数vma_set_page_prot_func来根据vm_flags字段更新vm_page_prot
3. 调用函数change_protection_func更新页表中的保护位。
这段代码虽然有效,但它有很多问题,首先,我们只实现了mprotect的基本部分,但原始函数比我们做的要多得多(例如通过保护标志分割和连接内存区域)。其次,我们使用两个内核函数,这些函数不是由内核导出的(vma_set_page_prot_func和change_protection_func)。我们可以使用kallsyms来调用它们,但是这很容易出问题(将来可能会更改它们的名称,或者会改变内存区域的整个内部实现)。所以我们想要一个更通用的解决方案,不考虑内部结构。
方案三:使用目标进程的内存映射
这种方法与第一种方法非常相似,因为我们希望在目标进程的上下文中执行代码。但在这里,我们会用自己的线程中执行代码,同时使用目标进程的“内存上下文”,这意味着:我们会使用其地址空间。
通过几个API可以在内核态下更改地址空间,我们使用了use_mm。如文档明确指出的那样,“此例程仅用于从内核线程上下文中调用”。这些是在内核中创建的线程,不需要任何用户地址空间,因此可以更改其地址空间(地址空间内的内核区域在每个任务中以相同的方式映射)。
在内核线程中运行代码有一种简单方法,就是内核的工作队列接口,它允许你使用特定例程和特定参数来安排工作。我们的例程获取所需进程的内存映射对象和mprotect的参数,并执行以下操作(do_mprotect_pkey是内核中实现mprotect和pkey_mprotect系统调用的内部函数):
当我们的内核模块在某个进程(通过一个特殊的IOCTL)获得更改保护的请求时,它首先找到所需的内存映射对象,然后使用正确的参数来调度工作。这个方案仍有一个小问题:函数do_mprotect_pkey_func不由内核导出,需要使用kallsyms获取。与前一个解决方案不同,这个内部函数不太容易发生变化,因为它与系统调用pkey_mprotect有关,而且我们无需处理内部结构。
如果你有兴趣,可以在github中找到这个PoC内核模块的源代码。