深入Linux | 如何在任意进程中修改内存保护(含PoC)

存储 存储软件
在现代操作系统中,每个进程都有自己的虚拟地址空间(从虚拟地址映射到物理地址)。此虚拟地址空间由内存页(某些固定大小的连续内存块)组成,每个页都有保护标志,用于确定允许此页面访问的类型(读取,写入和执行)。

 前言

最近,我们遇到一个非常具体的问题:改变任意进程的内存区域的保护标志。这项任务看似微不足道,但是我们着实遇到了一些麻烦,在此过程中也学到了关于Linux机制和内核开发相关的东西。以下是一些简要概述,其中包括了当时采取的三种方案,每次也在寻求更好解决方案。

[[254657]]

 

概述

在现代操作系统中,每个进程都有自己的虚拟地址空间(从虚拟地址映射到物理地址)。此虚拟地址空间由内存页(某些固定大小的连续内存块)组成,每个页都有保护标志,用于确定允许此页面访问的类型(读取,写入和执行)。这种机制依赖于架构页表(有趣的是,在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内核模块的源代码。

责任编辑:武晓燕 来源: FreeBuf
相关推荐

2018-12-03 11:05:20

内存代码Linux

2019-12-16 10:43:38

Linux内存消耗进程

2019-11-06 15:58:54

Linux内存消耗进程

2018-07-27 05:08:58

2023-03-05 16:40:07

linux进程内存

2021-08-30 06:59:07

x86处理器内存

2023-01-30 14:27:14

Linux进程

2018-05-31 11:58:06

Linux进程Early OOM

2015-10-22 13:07:29

USB设备权限Linux

2010-08-05 16:08:12

轻松掌握DB2 9.5

2018-06-05 08:51:04

Linux结束进程中止程序

2019-12-16 09:10:38

Linux中央处理器进程

2019-12-16 11:00:04

LinuxCPU进程

2023-01-27 09:17:02

操作系统虚拟化内存

2022-05-27 11:59:22

Linux内存CPU

2018-10-08 08:20:25

Linux进程端口号

2011-01-26 13:26:32

Linux进程

2009-12-09 10:07:19

Linux静态路由

2013-06-20 10:25:56

2019-02-26 10:00:37

Linux命令内存
点赞
收藏

51CTO技术栈公众号