资讯动态

绕过内核SLUB freelist加固:低字节覆盖与类型混淆实战

发布时间:2026/10/16 16:42:58 来源:尧图企业网站定制
做内核堆利用题的朋友应该没少被 CONFIG_SLAB_FREELIST_HARDENED 这个编译选项恶心过。以前打用户态堆题改一下 freelist 的 next 指针就能拿到任意地址分配到了内核里这套老剧本直接失效——写入的 next 指针会被校验、会被异或混淆随手一改就是内核 panic。我在 kernoob 靶场里把两种常见解法完整走了一遍之后才真正理解这个防护到底在拦什么、哪些攻击面是它管不到的。这篇文章不聊理论空架子直接把 SLUB 分配器的加固机制拆开再结合 kernoob 里的实际场景讲两种绕过路线一种是低字节部分覆盖把 freelist 头引到隔壁对象另一种是干脆不碰 freelist直接走对象类型混淆。适合已经在玩内核堆利用、但对防护机制还停留在“知道有这个东西”层面的朋友参考。1. 先搞清楚我们在对抗什么1.1 SLUB 的 freelist 结构一个由对象自己背着的链表内核里默认的 SLUB 分配器维护空闲对象的方式和用户态 glibc 的 bins 有点类似但更简单每个 slab 页上有一批固定大小的对象空闲对象之间通过一个单向链表串起来。关键点是这个链表的 next 指针不保存在分配器的元数据区而是直接写在空闲对象本身的开头。也就是说一个对象被释放之后它的第一个机器字就变成了指向下一个空闲对象的指针。分配路径也很直白。每个 CPU 上有一个 freelist 头指向当前 CPU 第一个可用的空闲对象分配时直接取出这个头然后从该对象开头读取下一个空闲对象的地址更新 freelist 头返回原头对象给调用者。因为对象数据本身就充当链表节点这比维护独立链表结构省内存但也埋下了隐患如果攻击者能往空闲对象里写数据就能改写这个 next 指针。在老的防护配置下把 next 改成任意地址之后下一次分配就能返回那个地址经典的任意地址分配就这么来的。1.2 HARDENED 做了什么异或混淆加上双重释放检测CONFIG_SLAB_FREELIST_HARDENED 这层防护核心是两件事。第一件是把写入空闲对象的 next 指针做异或混淆每个 slab cache 在创建时都会生成一个随机数当作密钥存储 next 指针时用“真实地址 XOR 密钥”的结果写进对象取出时再用同一个密钥 XOR 一次还原。这个密钥存放在 kmem_cache 结构体里攻击者单纯堆溢出是拿不到的。第二件是加入了双重释放检测释放对象进 freelist 时如果发现当前 freelist 头正好就是待释放对象说明这个对象已经在链表里了属于 double-free直接报错甚至 panic。很多初学者会把 CONFIG_SLAB_FREELIST_HARDENED 和 CONFIG_SLAB_FREELIST_RANDOM 搞混。后者做的是 slab 页初始化时把空闲对象顺序打乱让攻击者无法准确预判相邻对象在物理页里的排列属于增大风水难度的防护前者则是针对 freelist 指针本身的机密性和完整性做校验。两者经常同时开启但思路完全不同。遇到这类题第一步永远是用/proc/config.gz确认到底开了哪些选项别凭感觉猜。1.3 kernoob 靶场里这两种解法意味着什么kernoob 是一套面向内核漏洞利用的练习环境题目一般挂一个带漏洞的内核模块漏洞类型通常是 UAF、double-free 或越界读写目标是从普通用户态拿到 root 权限。靶场里的题普遍开启 KASLR、SMEP、SMAP、CONFIG_SLAB_FREELIST_RANDOM 和 CONFIG_SLAB_FREELIST_HARDENED属于比较贴近真实难度的配置。我在 kernoob 遇到的这类题目正好是同一个漏洞点可以走两条完全不同的利用链。一是直接攻 freelist虽然 next 被混淆了但攻击者仍然具备对空闲对象的部分写能力只要利用方式足够精细就能绕过异或校验二是放弃 freelist 攻击面转而利用同 cache 的对象重叠构造类型混淆改函数指针完成劫持。两条路各有各的难点也各有各的适用场景。下面的内容就把这两条路按实操顺序拆开讲。2. 加固之后还剩哪些缝2.1 低字节写是绕不开的突破口CONFIG_SLAB_FREELIST_HARDENED 的混淆逻辑是整字异或也就是说存储值的高位和低位都与真实地址的对应位做了 XOR。看起来无懈可击但有一个关键弱点如果攻击者不是覆盖整个 next 指针而是只覆盖它的一个字节或者两个字节那么异或密钥的高位部分依然参与运算但攻击者可以通过控制被覆盖字节的内容间接控制解码后该字节的取值。在小端序的 x86_64 上对象开头的第一个字是内存里低地址开始的 8 个字节其中最低有效字节位于最低地址。如果漏洞是向低地址往高地址的越界写或者 UAF 中我们能控制写入的长度那么恰恰可以只修改 next 指针的低位字节而保留高位。解码之后新指针的 high 部分不会变因为存储值高位没变密钥高位也没变只有低字节变成我们指定的值。这意味着我们能做的不是把 next 改成任意地址而是把 next“拨动”到与原指针非常近的地址上去比如同一 4KB slab 页内偏移差几个对象的位置。这个攻击面在加固之后依然是有效的因为校验机制没有对“next 必须指向本 slab 页内的合法对象”做强制约束。它只负责解码不负责验证解码结果的合法性。内核真正可以容忍这种同页偏移吗从攻击角度讲只要目标地址上恰好在下一个分配请求中被当作对象返回我们就可以通过后续写入完成劫持。所以低字节覆盖不是强攻防线而是顺着防线的逻辑找缝隙。2.2 不碰 freelist 也能完成利用类型混淆面freelist 加固只会保护“空闲对象的 next 指针”它不会保护“已分配对象内部的数据字段”。这是很多人忽略的一点。当一个漏洞允许我们往已分配对象里写数据时我们可以直接修改对象内部某些字段比如函数指针、ops 指针、长度字段而根本不用管 freelist 是什么状态。在内核堆利用里这就是对象类型混淆的常见形式漏洞对象和受害者对象落在同一个 kmem_cache 里分配器返回给我们一个我们以为是 A 类型、实际内存里已经被 B 类型对象占用的地址。A 对象里的可控字段恰好覆盖了 B 对象中一个会在后续操作中被调用的函数指针。整个过程里我们没碰任何空闲链表CONFIG_SLAB_FREELIST_HARDENED 再强也拦不到这一层。这种思路在真实内核漏洞利用里很常见也是为什么很多防护选项都开了的内核依然能被攻破。安全机制拦截的是具体某一种攻击模式只要换成另一种模式原先的防御就等于零。2.3 两条路线怎么选直接对比一下两种思路的适用场景维度部分覆盖 freelist对象类型混淆攻击目标freelist 头的 next 指针编码值已分配对象内部字段是否受 HARDENED 影响直接受影响但可利用低字节绕过基本不受影响关键前置条件能部分写空闲对象目标地址与原地址同页邻近能找到同 cache 且含函数指针的对象对堆风水要求极高需要精确控制分配释放顺序中高需要让两类对象重叠难度上限偏重技巧和偏移计算偏重利用链设计和提权方式我的经验是如果题目明确给了部分写能力且对象尺寸很小比如 0x20、0x40走部分覆盖最直接如果题目给的是较大的越界写或者 UAF 写对象类型混淆的容错更高后期调试也更省心。kernoob 相关题目之所以常出现“两种解法”正是因为题目代码留了足够的灵活性两种方式都能打通。3. 解法一保留校验位只改低字节——把 freelist 头引到隔壁对象3.1 核心原理异或运算的“局部可控”特性设某个 slab cache 的混淆密钥为 K对象原始存储的 next 编码值为 S next_addr ^ K。当我们只修改 S 的最低一个字节时S 的低字节变成 S而高 7 个字节不变。解码得到的新地址就是next_addr (S ~0xFF) | S_low ^ (K 0xFF)由于 S 的高位没变解码结果的高位部分与 next_addr 完全相同只有低字节变化。这意味着我们能得到的 next_addr 必定落在原 next_addr 所在的 256 字节范围内。如果原 next 指针指向的地址在某个 slab 页的中间那么目标地址也在这个 256 字节范围内通常覆盖同一页里的几个相邻对象。这也是为什么该方法需要堆风水配合的原因。直接随机修改低字节很可能把 next 指向一个不可控或已分配出去的地址导致分配器返回异常对象。要让攻击可控就必须让“原 next 地址”和“目标对象地址”在同一个 256 字节窗口内并且清楚知道目标对象与当前 freelist 头之间的偏移。3.2 堆风水让目标对象待在偏移 0x100 内我在复盘时设计过一个最简单的布局。假设漏洞对象是一个 0x40 大小的堆块漏洞点是 UAF 写。我先把 victim 对象和漏洞对象都申请出来让它们在同一个 slab 页里相邻排列。由于 CONFIG_SLAB_FREELIST_RANDOM 会打乱空闲顺序直接连续分配不一定相邻所以需要多喷几次通过观察实际地址来调整。具体顺序大致是连续申请一批 0x40 的对象填充一整页 slab。释放其中一个漏洞对象让它成为 freelist 头。再申请两个对象让 freelist 头位置的 next 指向我们预先释放的目标 victim这个目标在释放顺序上要排在漏洞对象之后且与原 freelist 头在同一页。利用 UAF 对漏洞对象写入只修改其 next 指针的低字节使其指向相邻的 victim 对象。这里的关键点在于弄清楚目标 victim 与 next 原地址的差值。如果差值小于 0x100一次释放/分配操作中修改低字节就够了。我在实际调试中更喜欢先故意泄漏一次原始的 next 编码值再通过让分配器多返回一次对象来观察真实分配顺序从而反推目标对象的偏移。纯猜低字节的话成功率只有 1/256虽然 CTF 里重启爆破可行但效率太低不推荐。3.3 一次典型操作的伪代码思路下面这个片段不是可以照抄的最终 exploit而是演示部分覆盖这一步的核心操作。重点是理解写入长度和写入内容之间的关系。// 假设 vuln_ptr 指向已释放的漏洞对象 // 目标是修改该对象开头的 next 指针的低字节 uint64_t encoded_next *(uint64_t *)vuln_ptr; // 读出编码后的 next uint64_t fake_low target_offset_low; // 目标低字节 // 只回写低字节保留高 7 字节 uint64_t new_encoded (encoded_next ~0xFFull) | fake_low; write_u64(vuln_ptr, new_encoded); // UAF 写如果只能写一个字节更简单write_u8(vuln_ptr 0, fake_low);写完后再分配两次第一次分配会返回原 freelist 头第二次分配就可能拿到被篡改后的假 next。拿到这个假对象后后续要做的是在这个假对象上伪造数据或者直接把它当成受害者对象的替身进行类型混淆操作。3.4 成功与失败的分界线这个方法最容易翻车的地方有三个。第一修改的低字节必须保证解码后的地址落在合法对象起始位置否则分配器可能直接因为对象内容异常而在后续释放时触发校验崩溃。第二目标对象必须处于空闲状态也就是它得在 freelist 里。如果目标对象已经被分配出去那么你引过去的 next 指向一个已占用对象后续分配会返回一个“假空闲对象”读写时容易踩到正在使用中的数据。第三SLUB 有 per-CPU partial 和 CPU partial 的逻辑freelist 头不一定就是全局链表里你释放的那个局部头。释放后如果有其他 CPU 的分配请求介入freelist 可能被换到另一个 CPU 上导致你的部分覆盖根本不在当前活跃 freelist 上。所以我在操作时养成了一个习惯在断点调试阶段先看一下当前 CPU 的 freelist 头地址确认它就是我们 UAF 的对象。内核里这个值存在每个 CPU 的 kmem_cache_cpu 结构体里用 gdb 直接查看即可比瞎猜稳得多。4. 解法二不碰 freelist 头让受害者对象替我们执行4.1 从“改指针”到“改字段”的思路转换第二种解法更直接既然改 freelist 指针会被混淆、会被校验那我就不去改它转而去改一个已分配对象内部的可执行字段。内核里很多对象都带有函数指针最常见的是各种 ops 结构体、timer_list 的回调、seq_operations 里的 show 指针。只要我们能对一个受害者对象写入可控数据这些字段全都能变成我们的跳板。这个思路的好处是它完全绕开 CONFIG_SLAB_FREELIST_HARDENED 的管辖范围。防护机制只负责空闲链表完整性不负责已分配对象内部的语义。因此只要漏洞点允许“写已分配对象”加固选项就形同虚设。内核开发者显然也知道这点所以后续又加了 struct 随机化、控制流完整性之类的防护但在老内核版本里类型混淆仍然是最稳的路径。4.2 在同 cache 里找函数指针对象要在同 cache 里找到带函数指针的对象先要看目标 cache 的尺寸。比如一个 0x40 的 cache很多常见内核对象都能落进去但并不是每个对象都适合当受害者。我的选择标准有三个一是对象开头或者偏移处存在一个会在特定触发路径中被调用的函数指针二是对象大小和漏洞对象一致能通过堆风水让两者重合三是触发调用所需的操作足够简单比如打开一个文件、读一个 proc 接口而不需要复杂的内核条件。以 seq_operations 为例它大小很小里面存了一组函数指针打开对应的 proc 文件时内核会填充它调用 read 时就会跳到里面的 show 指针。如果能让漏洞对象和它重叠我们只需要在重叠对象上把 show 字段改成目标地址然后 read 一次控制流就过去了。这个模式在内核 CTF 里快被用烂了但正因为触发条件简单它的稳定性非常好。4.3 完整利用链顺序完整走一遍类型混淆利用链大致分四步泄漏先通过漏洞读出 victim 对象里的内核地址用来绕过 KASLR。如果漏洞点支持任意读这一步最方便如果不支持需要借助其他信息泄漏接口。布局喷射大量 victim 对象让其中一个与漏洞对象落在同一 cache。这里可以直接看 /proc/slabinfo 确认 cache 名称和数量再用分配的先后顺序做粗略控制。覆盖通过 UAF 或越界写把 victim 对象内的函数指针字段改成我们想要的目标地址。这个目标地址可以是 ROP gadgets 地址也可以是某个函数地址。触发调用对应的操作read、write、ioctl 等让内核跳转到被篡改的函数指针。提权环节一般有两种选择一种是通过 ROP 链关闭 SMEP/SMAP 后执行用户态代码另一种是直接改写 modprobe_path 指向我们准备好的脚本然后触发内核执行。后面这种方式需要一次任意写原语更依赖漏洞能力。4.4 两种解法的能力对比和选择建议在 kernoob 相关题里我两种都试过。整体感觉是解法一更适合“小尺寸对象 精细部分写”的场景它最终的利用原语非常强——能把分配器引到任意附近对象等于给了任意地址分配但调试成本高偏移算错一个字节就 panic。解法二更依赖漏洞能写多少字节通常需要至少 8 字节的连续写但它生成的利用链更直观每一步都可以单独验证前后端解耦排查起来舒服很多。另外要提醒一句解法二并不是完全不受 SMEP/SMAP 影响。如果内核命令行了 SMEP 和 SMAP直接跳用户态代码是行不通的需要搭配内核 ROP 链或者选择修改 modprobe_path 这种不需要执行用户态代码的提权路径。这也是为什么很多解法二最终都落在改名脚本上而不是去弹 root shell 后再执行命令。5. 实操环境与步骤复盘5.1 最小复现环境怎么搭本地复现这类题目最省事的方式是用一个自定义内核加一个最小根文件系统跑在 QEMU 里。我在练习时用的是某个较新的 5.x 内核源码在 defconfig 基础上打开下面几项CONFIG_SLAB_FREELIST_HARDENEDy CONFIG_SLAB_FREELIST_RANDOMy CONFIG_KASANn CONFIG_PAGE_TABLE_ISOLATIONn调试阶段建议临时关掉随机化和 KASLR等思路跑通再全开验证。KASAN 必须关否则漏洞触发点很容易被 KASAN 先拦下来干扰对分配器行为的观察。编译完成后用 busybox 做根文件系统通过 initramfs 直接启动省去磁盘镜像的麻烦。QEMU 启动参数里建议加上-s -S用于 gdb 远程调试同时加nokaslr来固定内核布局方便在调试阶段直接使用符号地址。真正验证阶段再切回默认参数确保利用方式不依赖关闭 KASLR。5.2 验证防护开启的关键命令进入系统后第一件事就是确认防护状态zcat /proc/config.gz | grep SLAB_FREELIST cat /proc/slabinfo | head cat /sys/kernel/slab/kmalloc-64/aliases如果内核没开 proc config可以直接在内核源码目录里查.config文件。刚开始玩的时候我犯过一个低级错误以为自己开了 HARDENED结果编译时被覆盖成默认关闭导致前面调试的所有结论在正式环境里完全对不上。所以无论多着急只要换内核配置就必须重新确认一次。5.3 调试手段gdb 符号、断点位置、ftrace调试内核堆利用我最常用的还是 gdb 配合 vmlinux 符号。set_freepointer 和 get_freepointer 这两个函数是绕不开的断点位置。在 gdb 里分别下断break set_freepointer break get_freepointer分配对象时观察 get_freepointer 的参数和返回值就能直接看到解码前后的地址。如果伪造的 next 被解码成了奇怪的值这一个断点立刻暴露问题。释放对象时观察 set_freepointer 写入的编码值能确认我们 UAF 写的是不是恰好覆盖了这个区域。ftrace 也可以用来跟踪分配路径但我觉得在 CTF 级别题目里没必要gdb 单步观察就足够清晰了。唯一的坑是内核开了太多优化后set_freepointer 会被内联进 do_slab_free断点可能打不进去。解决办法是直接对__slab_free下断点再通过 disassemble 找到内联后的校验逻辑。5.4 复盘时踩过的三个坑第一个坑是 per-CPU partial 的干扰。SLUB 在每 CPU 上有一个 partial 链表对象释放后先进入 freelistfreelist 过长时才会批量移入 partial。如果我释放后立刻分配逻辑简单但如果中间插入其他分配操作freelist 头可能变成另外的对象部分覆盖就打偏了。解法是在漏洞触发前后尽量减少其他分配噪声必要时用同一个 CPU 执行整个序列。第二个坑是 kmalloc 的 cgroup 版本。开启了 CONFIG_MEMCG_KMEM 的内核里带 __GFP_ACCOUNT 标志的分配会走 kmalloc-cg 系列 cache和你以为的 kmalloc-64 并不在同一个 cache。如果漏洞对象用普通 GFP_KERNEL 分配受害者对象却用带 ACCOUNT 标志的分配方式两者永远不会重叠。这个问题在调试类型混淆时非常隐蔽我花了一整个晚上才定位到。第三个坑是翻译偏移时的字节序错误。x86_64 是小端我一度在计算低字节偏移时把地址高低位写反结果改了高字节而不是低字节整个 next 指针直接变成野指针内核立刻 panic。后来我把地址打印出来逐字节对了一遍才意识到是这个问题。遇到这种奇怪偏移先停下看内存里的原始字节不要靠手算。6. 常见问题快速排查表以下是我在实操中遇到过的典型问题整理成速查表遇到类似现象可以直接对号入座。现象可能原因排查方向分配时直接 panic报 invalid freelist pointer解码后的 next 不在合法页范围内或地址未对齐检查低字节计算是否覆盖了不该改的位确认目标对象是否在同一页UAF 读出的 next 值是乱码这是正常现象读出的是编码后结果不要直接把它当真实地址结合分配行为反推偏移部分覆盖后分配返回的对象不是想要的原 next 地址与目标对象偏移超过 0x100重新设计分配顺序缩小偏移窗口double-free 检测触发内核直接报错释放对象时发现当前 freelist 头就是该对象检查释放流程中是否重复释放了同一个对象避免让同一个对象连续进入 freelist受害者对象永远不重叠可能走了不同 cache如 kmalloc-cg检查分配标志里的 __GFP_ACCOUNT确保两者在同一 kmem_cache类型混淆后触发调用 panic函数指针被改成非法地址或 SMEP/SMAP 拦截确认目标地址是否属于内核态可执行区必要时换 ROP 或 modprobe_path 方案调试断点打不进去函数被内联对内联后的外层函数下断点或编译时关掉部分内联优化在 QEMU 里跑不通但本机可以内核配置不一致重新 diff 两份 .config重点看 SLAB、KASLR、SMEP/SMAP 等选项排查问题的时候有一条主线先确认漏洞点是否真的写到了目标内存再确认分配器状态是否如你所料。很多人卡住是因为漏洞点根本没命中后续全是在分析错误的地址。用 gdb 在写入位置打断点直接看内存布局可以省下大量时间。7. 一些关于学习顺序的心里话接到这类题目我最深的体会是不要一开始就背解法先把内核源码里对应分配器的那几段代码读明白。CONFIG_SLAB_FREELIST_HARDENED 的完整实现只有几十行set_freepointer、get_freepointer、freelist_ptr_encode、freelist_ptr_decode花半天读清楚比盲目调一星期 bug 都值。你只有知道校验发生在哪一步、校验的是哪个值才会明白为什么低字节覆盖能绕过为什么类型混淆能完全无视它。第二个经验是内核堆利用的调试效率往往取决于你对内存布局的“可视化程度”。我建议在本地环境里把打印内核指针的辅助函数写好每个关键步骤都输出地址、偏移和 cache 信息。等题目做多了你会有一种直觉看到一个偏移就能大致判断出这个对象在 slab 页里的位置面对 HARDENED 这种防护时下手会稳很多。如果你现在正卡在某道 kernoob 题上不妨先把工具链准备好再回到源头把 SLUB 的分配、释放、freeze 和 unfreeze 流程过一遍。题目本身是固定的但思路是可以复用的——下次再遇到开启 CONFIG_SLAB_FREELIST_HARDENED 的题目你就知道该从哪里切开防线了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑