最近,我们在进行一项安全研究时,需要在任意进程中修改内存空间的保护标志。起初,我们发现这项任务看起来很简单,但在实际操作中,却发现困难重重,还好这些都不是什么大问题。在解决这些问题的过程中,我们还学到了一些新的东西,主要是关于 Linux 机制和内核开发的。在以下的详解中,我们会介绍我们所采取的三种方法以及每次寻求更好解决方案的原因。
背景介绍
在现代操作系统中,每个进程都有自己的虚拟地址空间(从虚拟地址到物理地址的映射)。此虚拟地址空间由内存页面(某些固定大小的连续内存块)组成,且每个页面都有保护标志,这些保护标志决定了允许对该页面的访问类型 ( 读取、写入和执行 ) 。不过,这种机制依赖于架构页表(architecture page table)。不过要注意的是,在 x64 的架构中,你不能只进行页面写入,即使你是特意从操作系统请求的,也都同时具有页面写入和可读的功能。
在 Windows 中,你可以使用 API 函数 VirtualProtect 或 VirtualProtectEx 修改内存空间的保护。VirtualProtectEx 使我们的修改任务变得非常简单:因为它的第一个参数 hProcess 是 "要修改其内存保护的进程的句柄"。
不过,在 Linux 中,修改过程就没有这么简单了,因为修改内存保护的 API 是系统调用 mprotect 或 pkey_mprotect 的结果,并且这两个函数始终在当前进程的地址空间上运行。现在让我们想办法解决一下如何在 x64 架构上的 Linux 中解决修改的问题,不过前提条件是,我们具有修改设备的 root 权限。
方法一:代码注入
如果 mprotect 总是在当前进程中运行,我们需要让目标进程从它自己的上下文中调用它。这时就要用到代码注入了,该方法可以通过许多不同的方式实现。我们可以选择使用 ptrace 机制实现它,该机制允许一个进程 " 观察和控制另一个进程的执行 ",包括修改目标进程的内存和寄存器的能力。这种机制用于调试器 ( 如 gdb ) 和跟踪实用程序 ( 如 strace ) ,使用 ptrace 注入代码所需的步骤如下 :
1. 使用 ptrace 附加到目标进程,如果进程中有多个线程,那么最好停止所有其他线程;
2. 找到一个可执行的内存空间(通过检查 / proc / PID / maps),并在这个空间编写操作码 syscall ( 十六进制 :0f05 ) ;
3. 根据调用约定来修改寄存器,首先,将 rax 修改为 mprotect 的系统调用号(即 10);然后,前三个参数 ( 即起始地址、长度和所需的保护 ) 分别存储在 rdi、rsi 和 rdx 中;最后,将 rip 修改为步骤 2 中使用的地址;
4. 继续这个过程,直到系统调用返回 ( ptrace 允许你跟踪系统调用的进入和退出 ) ;
5. 恢复被修改的内存和寄存器,从进程中将其分离并恢复正常执行;
这种方法是我们的采用的第一个也是最直观的方法,并且非常有效。不过在我们发现了 Linux 中的另一种完全破坏机制:利用 seccomp 进行破坏之后,该方法就不是我们的最优选择了。基本上,它是 Linux 内核中的一个安全工具,允许进程输入某种形式的 " 监狱 ",除了 read,write,_exit 和 sigreturn 之外,它不能进行任何系统调用。还有一个选项,可以指定任意的系统调用及针对它们的过滤参数。
因此,如果进程启用了 seccomp 模式并且我们尝试将一个对 mprotect 的调用注入其中,那么内核将终止进程,因为该进程是不允许使用此系统调用的。因此,要对这些进程进行调用,就要采用方法二。
方法二:在内核模块中模拟 mprotect 系统调用
seccomp(全称 securecomputing mode)是 linuxkernel 从 2.6.23 版本开始所支持的一种安全机制。
在 Linux 系统里,大量的系统调用直接暴露给用户态程序。但是,并不是所有的系统调用都被需要,而且不安全的代码滥用系统调用会对系统造成安全威胁。通过 seccomp,我们限制程序使用某些系统调用,这样可以减少系统的暴露面,同时是程序进入一种 " 安全 " 的状态。
由于 Linux 中存在另一种完全破坏机制:利用 seccomp 进行破坏,因此这个方法肯定要在内核模式中进行。在 Linux 内核中,每个线程(包括用户线程和内核线程)都由一个名为 task_struct 的结构表示,并且当前线程 ( 任务 ) 可以通过 pointer current 访问。内核中 mprotect 的内部实现使用了 pointer current,因此我们的第一个想法是,只要将 mprotect 的代码复制粘贴到内核模块中,并将每次出现的 current 替换为指向目标线程 task_struct 的指针,不就可以了吗 ?
接下来的事情你可能已经猜到了,就是复制 C 代码,不过复制过程并不是你想的那么简单,因为其中存在大量使用我们无法访问的未导出的函数、变量和宏。某些函数说明会在标头文件中导出,但是它们的实际地址不是由内核导出的。如果内核是用 linux 内核符号表 kallsyms 编译的,那么通过文件 / proc / kallsysm 导出所有内部符号,这个特定的问题就可以解决。因为 kallsyms 在进行源码调试时具有相当重要的作用,它可以描述所有不处在堆栈上的内核符号。linux 内核在编译的过程中,将内核中所有的符号(所有的内核函数以及已经装载的模块)及符号的地址以及符号的类型信息都保存在了 /proc/kallsyms 文件中。
尽管存在这个特定问题,我们仍然试图实现 mprotect 调用。为此,我们特意编写一个内核模块,利用该模块获取目标 PID 和参数以进行 mprotect,并模仿其调用行为。首先,我们需要获取所需的内存映射对象,用它表示线程的地址空间:
/* Find the task by the pid */ pid_struct = find_get_pid ( params.pid ) ; if ( !pid_struct ) return -ESRCH; task = get_pid_task ( pid_struct, PIDTYPE_PID ) ; if ( !task ) { ret = -ESRCH; goto out; } /* Get the mm of the task */ mm = get_task_mm ( task ) ; if ( !mm ) { ret = -ESRCH; goto out; } … … out: if ( mm ) mmput ( mm ) ; if ( task ) put_task_struct ( task ) ; if ( pid_struct ) put_pid ( pid_struct ) ;
现在我们已经获得了内存映射对象,这大大方便了以后的操作。 Linux 内核实现了一个抽象层来管理内存空间,每个空间由结构 vm_area_struct 表示。为了找到正确的内存空间,我们使用函数 find_vma,该函数会根据所需地址搜索内存映射。
vm_area_struct 包含字段 vm_flags,它以独立于架构的方式来表示内存空间的保护标志,vm_page_prot 也以独立于架构的方式来表示内存空间的保护标志。单独修改这些字段并不会真正影响页表(但会影响 /proc/PID/maps 的输出,我们已经尝试过了),详情请点击这里。
在对内核代码进行了一些阅读和深入研究之后,我们发现要真正攻破内存空间的保护,最重要的工作是以下 3 方面 :
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。正如 use_mm 的介绍中明确指出的那样 " 此例程仅会被用于从内核线程上下文中进行调用 "。由于这些线程是在内核中创建的,不需要任何用户地址空间,因此可以修改它们的地址空间(地址空间内的内核区域在每个任务中都以相同的方式映射)。
在内核线程中运行代码的一种简单方法就是通过内核的运行队列接口(queue interface),它允许你使用特定例程和特定参数来进行进程调用。我们的工作例程也非常简单,它会获取所需进程的内存映射对象和 mprotect 的参数,并执行以下操作(do_mprotect_pkey 是内核中实现 mprotect 和 pkey_mprotect 系统调用的内部函数):
use_mm ( suprotect_work->mm ) ; suprotect_work->ret_value = do_mprotect_pkey ( suprotect_work->start, suprotect_work->len, suprotect_work->prot, -1 ) ; unuse_mm ( suprotect_work->mm ) ;
当我们的内核模块在某个进程(通过一个特殊的 IOCTL)获得修改保护的请求时,该请求首先会找到所需的内存映射对象(正如我们在前面的方法中所解释的那样),然后再使用正确的参数来调用进程。
不过这个解决方案仍有一个小问题,即函数 do_mprotect_pkey_func 不会由内核导出,需要使用 kallsyms 获取。与第一个解决方案不同,这个解决方案中的内部函数不太容易被修改,因为该函数与系统调用 pkey_mprotect 有关,而且我们也不用处理内部结构,因此我们只能将其称为 " 小问题 "。
我们希望你在这篇文章中找到一些有趣的信息和技巧,学会如何在任意进程中修改内存保护属性。如果你有兴趣,可以在github中找到这个概念验证内核模块的源代码。