今年的暑期挑战赛有一道题让我印象很深:入口是个写着 Sanic 的 Web 服务,绕到最后却变成了一次 Linux 内核提权实战。题面本身不复杂,但 Web 层和内核层的衔接非常自然,特别适合用来梳理一条完整的攻击链路——当你的业务代码把一个用户可控的嵌套请求“递归合并”进对象属性时,攻击者到底能走多远?这篇复盘会把整条路线完整拆开:从 Sanic 下的原型链污染如何一步步变成 RCE,到拿到容器 Shell 之后怎么判断“该打内核了”,再到内核漏洞利用的落地细节和调试记录。适合正在学 CTF Web 和 Pwn 的读者,也适合想理解这两种漏洞类型如何串联起来的人。这篇会比较长,因为中间每一步都有值得记录的判断过程。
1. 先说结论:这题的链路是一条清晰的“解题流水线”
1.1 题目给我的第一印象
刚打开题目的时候,页面是一个用 Sanic 写的在线临时文件服务。功能很简单:提交一段 JSON 配置,服务端会生成一个下载链接。路由不多,也没有明显的文件包含或者 SQL 注入点。源码里能看到一个自定义的配置更新逻辑,会在处理请求时把用户传进来的数据递归合并到当前用户对象上。
当时第一反应是“这就是个原型链污染题”,但真正开始构造 payload 时才意识到,这道题并不是污染一下就完事,还得先利用污染拿到命令执行,再在受限 Shell 里继续往下走。比赛里前半小时都在 Web 层折腾,拿到 Shell 之后又花了很长时间看内核模块,最终才把整条链打通。
1.2 为什么这道题值得完整复盘
我觉得这道题设计得好的地方在于:它没有把 Web 和 Pwn 割裂成两个独立考点。很多综合题是“Web 进去拿 flag 第一部分,Pwn 单独打一个服务拿第二部分”,但这次是真正的纵深链路——Web 层的对象属性污染决定了你能不能执行命令,执行命令后所处环境的隔离程度又决定了你必须去研究内核模块。
对选手来说,这类题的难点不在某一个漏洞本身有多刁钻,而在于链路每推进一层,都需要重新评估“当前目标的真实形态”。你拿到 Shell 之后第一件事不是急着找 flag,而是搞清楚自己在什么权限、什么隔离环境里、有哪些可用的攻击面。这个思维转换,比单独刷十道 Web 题或十道 Pwn 题都更有价值。
2. Sanic 服务下的原型链污染:从“改配置”到 RCE
2.1 Sanic 这个框架的请求处理模型有什么不同
先聊一下 Sanic。它是一个基于 Python asyncio 的异步 Web 框架,路由定义、请求对象、响应的使用方式跟 Flask 很像,但底层的请求处理完全是异步事件循环。在 CTF 里遇到 Sanic,通常意味着服务端代码比较“现代”,而且经常会配合自定义中间件、配置类、请求上下文这类机制。
Sanic 中每个请求都会构造一个Request实例,路由处理函数接收这个 request 对象,然后从中取参数、读请求体、返回响应。如果你在服务端代码里看到了类似merge(request.ctx, user_input)或update_config(user_obj, request.json)这种写法,就要格外警惕。因为这本质上是把一个受攻击者控制的嵌套 dict 往一个真实对象上合并,而 Python 对象的属性访问链比很多人想象的要长得多。
Sanic 官方扩展还提供了 session、config 这类全局对象,一旦合并逻辑处理不当,污染点可以一路延伸到app.config甚至 Sanic 内部全局变量。当时题目给的提示就是“JSON 配置的嵌套层级很深”,这基本就是告诉你攻击入口放在对象属性链上。
2.2 Python 里的“原型链污染”到底是怎么发生的
很多朋友对原型链污染的印象停留在 JavaScript:通过修改__proto__影响所有继承此原型的对象。JavaScript 的原型链是语言级别的机制,obj.__proto__可以直接拿到构造函数的原型,污染一处,全局受影响。
Python 里没有完全等价的__proto__,但类继承和实例属性链提供了类似的穿越能力。你可以利用__class__、__bases__、__init__、__globals__这些属性从任意一个对象出发,一路访问到它的类、类的构造函数、构造函数所在的模块全局命名空间。如果代码里存在“递归合并属性”的逻辑,并且合并过程中可以通过setattr修改属性,那么用户就可以用嵌套键名把这个属性链当成一条“污染路径”一直写下去。
举个例子,假设有这样一个递归更新函数:
def merge(dst, src): for key, value in src.items(): if isinstance(value, dict): if hasattr(dst, key): inner = getattr(dst, key) if isinstance(inner, dict): merge(inner, value) else: merge(inner.__dict__, value) else: setattr(dst, key, {}) merge(getattr(dst, key), value) else: setattr(dst, key, value)这里dst可以是一个普通对象,也可以是一个 dict。关键问题在于:当嵌套键是__class__时,getattr(obj, "__class__")会返回 obj 的类型对象;继续传__init__,会拿到类的构造函数;继续传__globals__,则会拿到该构造函数所在模块的全局字典。此时如果 value 里再放一个键值对,比如{"ALLOWED_CMD": "cat /flag"},最终效果就是直接修改了模块里的全局变量。
我本地写过一个最小复现,核心路径就是:
payload = { "__class__": { "__init__": { "__globals__": { "ALLOWED_CMD": "cat /flag" } } } } merge(user_obj, payload)其中user_obj是任意一个由题目自定义类创建的对象。这样user_obj.__class__是类对象,.__init__是类的构造函数,.__globals__是该函数定义所在模块的全局命名空间,最后写入ALLOWED_CMD。这个变量一旦被业务代码后面拼接执行,就完成了从“属性污染”到“命令注入”的转换。
2.3 为什么选择覆盖全局变量而不是改 session 或中间件
理论上原型链污染能做的事情很多,比如修改签名密钥伪造 session、覆盖中间件处理函数、替换模板渲染配置等等。但实际做题时,效率最高的方式往往是找“业务代码里一定会被读取的全局字符串变量”。原因很简单:这个方案对目标对象的结构要求最低,不需要你精确知道 app 内部属性名,也不需要重启或重新请求才能生效。业务代码只要在同一个模块里引用了这个全局变量,被你覆盖后下一跳就执行新的值。
我当时看到源码里有一段逻辑:某个接口会读取模块级变量SEARCH_ENGINE作为子进程启动命令的前缀。这就等于直接给了一个“污染后触发点”。payload 只需要覆盖这一个字符串,然后访问对应接口,就能把它当成 shell 命令执行。我在本地验证时也确认过,这种覆盖方式丝毫不影响服务主流程,隐蔽性也好。
需要注意一点:如果题目过滤了__class__、__globals__这类关键字,直接构造键名会被拦截。当时题目只对请求体里的个别 key 做了 set 过滤,并没有过滤嵌套键,所以这条路是通的。但如果遇到严格过滤,可以尝试观察过滤是发生在 JSON 解析前还是解析后、是否支持数组索引拼接、是否只过滤了顶层 key。很多情况下,过滤逻辑只检查了data.keys(),而嵌套 dict 的子键是漏网的。
2.4 从污染到命令执行:实际利用链还原
把整个 Web 环节串起来,流程是这样的:
- 先正常提交一次配置,观察服务端返回,确认 JSON 会被递归合并到当前用户对象。
- 构造
__class__.__init__.__globals__嵌套键,目标是一个模块级字符串变量。 - 提交 payload,把全局字符串改成一个待执行的命令。
- 访问触发该变量的接口,让服务端把它拼进子进程启动命令。
- 使用
curl外带或直接写文件的方式确认命令执行成功。
当时我为了确认污染是否生效,故意把全局变量改成touch /tmp/pwned,然后访问触发接口,再通过文件接口读/tmp/pwned是否存在。确认成立后才替换成反弹 Shell。这一步稳妥一点比什么都重要,直接上反弹 Shell 一旦失败,还得回头排查是污染不生效还是外带链路问题。
拿到反弹 Shell 之后,我的第一感觉是“舒服了”,但很快发现事情没那么简单。这就是下一段要说的:Shell 是有了,但你处在一个刻意隔离的环境里。
3. 拿到 Shell 之后:容器里的“假自由”
3.1 第一时间要看的东西
拿到一个非交互 Shell 后,不要急着翻目录、找 flag。先把下面这些信息看一遍,每一行都很关键:
id uname -a cat /proc/self/status | grep -E "Uid|Gid|Cap" cat /proc/mounts ls -la /dev | head -50 cat /proc/modules ls -la /root /home cat /proc/kallsyms | head -5id决定你当前的用户身份;CapEff决定你拥有哪些能力位;uname -a能看出内核版本和发行版编译时间;/dev下面的非常规设备节点经常是内核模块题的入口;/proc/modules如果能看到非标准模块名,基本就是在明示“内核层有东西可打”。
当时我执行完这串命令之后确认了几件事:当前 uid 是 1000,CapEff没有任何高价值能力位;/dev下除了标准设备之外多了一个/dev/ctf_device;/proc/modules里也对应有一个名为ctf_mod的模块。这就等于题目已经把内核攻击面摆在你面前了,接下来要做的就是研究这个模块的行为。
3.2 怎么判断“该打内核了”而不是继续搞容器逃逸
很多 Web 题拿到 Shell 后的常规思路是找 Docker 逃逸、挂载敏感目录、利用 sudo 配置错误、或者找 SUID 程序。但那次环境里这些路都被堵死了:/proc/1的 root 进程环境和你不同,没有可写的 Docker socket,sudo不存在,常见 SUID 文件也没找到可利用版本。这时候再看一眼/dev/ctf_device和/proc/modules,结论就很清晰:题目需要你进行内核提权。
判断依据其实很简单:
- 是否非 root,但能看到非常规设备节点;
- 内核模块列表里是否有非标准名字;
- 当前环境是否为容器且明显削减了能力位;
- Web 层拿到的 flag 是假的,真正的 flag 大概率放在 root 才能读的文件中。
如果上述条件满足,就别在用户态继续浪费时间。内核提权的思路不是“我要从零写一个漏洞利用”,而是“题目给了一个设备,我得先摸清它暴露了哪些操作”。
3.3 从设备节点开始的内核模块分析
/dev/ctf_device是一个 misc 设备。这类设备在内核模块里通常注册了 file_operations,用户态可以通过 open、read、write、ioctl 与它交互。拿到一个设备节点的第一件事,不是直接爆测 ioctl,而是先收集外围线索。
如果题目给了驱动源码,那直接看源码是最快的。当时的题目环境里,/sys/module/ctf_mod/目录下能看到sections/.text等内核模块信息,但源码没有直接给。这时候可以尝试读取模块二进制,或者用dmesg看有没有模块加载时留下的日志。那次 dmesg 被限制了,看不到内容,我只能在用户态盲测 ioctl 的功能。
盲测的方法也很笨:先写一个简单程序,open 设备,尝试不同的 ioctl cmd,观察返回值、设备行为有没有变化。配合strace看系统调用结果,能大致确定设备支持几种操作。当时很快就确定,这个设备支持分配一个对象、释放一个对象、从对象里读取内容。而释放之后,再次读取仍然能读到数据,说明释放操作没有把指针从文件结构里清除——典型的 UAF 悬垂指针。到这里,内核层漏洞点基本就锁定了。
4. 内核提权实战:从漏洞触发到替换 cred
4.1 漏洞点的本质:一个可反复触发的 UAF 窗口
这个内核模块的逻辑可以简化成下面这样:
- 用户通过 ioctl cmd A 分配一个对象,得到一个对象 id;
- 用户通过 read 传入 id,把该对象的内容拷贝到用户态缓冲区;
- 用户通过 ioctl cmd B 传入 id,释放对应对象;
- 释放后,驱动内部的指针没有置 NULL。
所以当你先分配、再释放、再 read 的时候,内核会把你已经释放的内存区域内容拷贝出来。更关键的是,如果释放后这块内存被重新分配并被其他数据填充,read 读取到的就是新数据的内容。这就是 UAF 泄露和利用的基础。
比赛时我一开始以为要自己构造一个完整的内核 ROP 链,后来发现题目的保护机制里 SMEP、SMAP、KASLR 都开着,直接 ret2usr 是行不通的,于是改用更常规的“对象喷射 + 改写内核全局变量”的思路。
4.2 利用链设计:从 UAF 到任意写,再到 modprobe_path
考虑到保护机制齐全,而且题目允许我们多次进行“释放后读取”和“释放后写入”的操作,我选择的最终目标是覆盖modprobe_path。这是一个全局字符串变量,存着内核执行模块自动加载时的程序路径,默认是/sbin/modprobe。如果我能把这个字符串改写成一个攻击者可控的脚本路径,再触发一次非法文件执行,内核就会以 root 身份去执行这个脚本。改一次就能提权,稳且简单。
要把 UAF 变成任意写,当时的路径是:利用 UAF 把悬垂指针引导到一个被我喷射出来的伪对象上,然后借助内核里某个自带对象的函数指针回调,完成一次内核写。CTF 里常见的选择是pipe_buffer对象喷射。原因是pipe_buffer结构体里包含指向pipe_buf_operations的指针,如果我们能通过 UAF 覆盖这个 ops 指针,让内核错误调用一个我们指定的函数,就有机会触发任意写。
比较稳妥的做法是分两步:
- 第一次利用 UAF 泄露:分配 victim 对象,释放后喷射大量可控对象,read 读取悬垂指针内容,确认我们喷射的数据确实占据了释放位置。
- 第二次利用 UAF 完成写:通过精心构造的伪
pipe_buffer内容,覆盖 ops 指针,触发内核执行pipe_buf_operations中的release回调,这个回调的执行流最终被导向一次内核任意地址写。
任意写一旦成立,就写modprobe_path。注意modprobe_path的长度有限制,路径不能太长,所以脚本路径要短,比如/tmp/x。
4.3 保护机制为什么没有拦住这条路
很多人一看到 SMEP、SMAP、KASLR 就觉得必须搞内核 ROP,其实不一定。SMEP 禁止内核执行用户地址,SMAP 禁止内核直接访问用户地址,但这两个保护针对的是“执行放在用户态的代码”和“直接读写用户态内存”的行为。而 modprobe_path 方案自始至终都在执行内核自己的程序路径加载逻辑,脚本内容由用户态提供但运行在用户态,内核只是启动这个程序,不涉及执行用户态代码,也不涉及内核读写用户态内存。
KASLR 对影响也不大,因为我们并不需要在利用链里硬编码某个内核函数的绝对地址。目标modprobe_path的地址需要确定,但如果题目模块里存在信息泄露,或者允许我们通过 UAF 先读取到内核某个已知结构的地址,就可以反推内核基址,再通过/proc/kallsyms里泄露的符号偏移算出目标地址。如果连 kallsyms 都不可读,也可以考虑通过固定的直接映射地址偏移来爆破,只是成功率会下降。
当时的实际情况比预想顺利:/proc/kallsyms虽然限制普通用户读取符号地址,但通过 UAF 泄露出的pipe_buffer指针足以推算出内核基址。有了基址,modprobe_path的偏移是固定的,直接加偏移就能得到目标地址。
4.4 提权验证与 root Shell 落地
任意写成功之后,我在/tmp下放了一个脚本,内容很简单:
#!/bin/sh cp /root/flag.txt /tmp/flag.txt chmod 777 /tmp/flag.txt然后随便创建一个格式无法识别的文件,执行它,触发内核走call_modprobe路径,用被覆盖后的/tmp/x路径去加载模块。内核以 root 权限执行脚本后,flag 就被复制出来了。
这一步有一个容易踩的细节:触发非法文件执行的命令要确保确实让内核走到了request_module流程,也就是说要执行一个文件格式未知的二进制,比如./fake或sh -c 'echo invalid > /tmp/fake; chmod +x /tmp/fake; /tmp/fake'。不是所有“执行未知文件”都会触发 modprobe,需要确认返回错误是ENOEXEC且执行时发生过内核的模块加载尝试。
我本地验证时第一版脚本路径写得太长,超过了modprobe_path的长度,导致启动脚本失败。后来把路径改成/tmp/x并把脚本放在/tmp/x后才成功。这也是为什么我建议地址写完之后立刻cat /proc/sys/kernel/modprobe看一眼是否真的改成了目标路径,不过普通用户可能没有读权限,那就通过最终结果来判断。
5. 复现路上的坑和排查实录
5.1 坑一:本地通远程挂,问题多半出在堆风水
内核利用和用户态利用有一个很大的区别:内核堆的布局受启动顺序、CPU 频率、并发线程、内存碎片等因素影响非常大。同一份 PoC,在本地 qemu 里一打一个准,到了远程可能跑几十次都触发不了。
当时遇到的情况是:本地用nokaslr参数启动,race 一次就成功;远程默认开启 KASLR 且 CPU 核心数更多,race 窗口变得很不稳定。我最初的 PoC 只开两个线程竞争,远程命中率极低。后来改成四线程同时参与竞争,并且用pthread_barrier保证所有线程尽可能在同一时刻开始操作,命中率才上来。
还有一个笨但有效的技巧:用sched_setaffinity把所有线程绑定到同一个 CPU 核上。因为内核对象的分配和释放如果在同一个核心上执行,缓存和锁的局部性更好,竞态被重复触发的概率反而更大。这一点和直觉相反,很多人以为多用几个核能提高竞争概率,实际测下来绑核明显更稳。
5.2 坑二:符号地址对不上,别急着硬调
内核利用里最烦的错误就是“没反应”,但其实没反应往往不是利用链逻辑错,而是地址没对准。modprobe_path这类全局变量的地址依赖内核基址,如果你通过 UAF 泄露的地址推导基址时差了 0x1000 的页对齐,写入就会落到错误页面,整个进程直接被 kill。
我的排查方式是:本地先用nokaslr把完整利用链跑通,确认逻辑没问题;然后再开 KASLR,在 PoC 里增加一步“先泄露 pipe_buffer 指针,再从指针减去固定偏移得到基址”的操作。远程如果还失败,就加打印,把计算出的基址和写入的目标地址都输出到调试文件,比对是否在一个合理范围内。这比盲目改 race 次数高效得多。
5.3 坑三:提权后 Shell 立即退出或者卡死
还有一个常见问题:利用成功后明明看到脚本执行了,但拿到的 Shell 要么立刻退出,要么卡住不动。我遇到过的情况是,触发 modprobe 的脚本路径写对了,但脚本内容里的cp命令没加chmod 777导致目标文件不可读;或者是 Shell 没有正确重定向,在非交互环境里直接挂起。
我的建议是,提权后不要马上去读 flag,而是先通过脚本把 flag 复制到一个全局可读的位置,或者直接改关键文件的权限,然后再从原有的 Web 文件接口去读。这样可以避免在交互性很差的反弹 Shell 里处理权限问题。真正的 root 提权验证,可以留下一个改动痕迹(比如/tmp/owned标记文件),确认成功后再进入最终读取。
5.4 一张可复用的排查速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| race 跑很多次都没有泄露 | 竞态窗口太窄;线程同步不好 | 增加线程数、绑定 CPU、加大循环次数 |
| 本地成功远程失败 | 堆布局差异;KASLR 偏移不对 | 先 nokaslr 验证逻辑,再补泄露基址 |
| 提权脚本执行了但文件读不出来 | 复制后权限不对 | 脚本里加 chmod 777 |
| 覆盖 modprobe_path 后触发无反应 | 触发方式不对;路径过长 | 确认非法文件执行触发 ENOEXEC |
| exploit 进程被 kill | 写入非法地址 | 加打印确认目标地址页对齐 |
这张表不只适用于这道题,换成其他内核题也能复用大部分条目。
6. 最后想说的几句实在话
这种串链路题打下来,我的一个深刻体会是:Web 和 Pwn 的分工不能太“井水不犯河水”。当时我们团队里,Web 队友能读懂内核模块伪代码,Pwn 队友也理解属性链污染的入口长什么样,所以整条链路衔接得很快。如果只让 Web 的人负责拿到 Shell 就收工,反而会卡在内核模块的 ioctl 分析上。
另外,内核利用的本地调试环境一定要提前搭成一键脚本。比赛时临时去配 qemu、编译内核模块、搞 gdb 断点,会消耗大量本该用于思考的时间。我当时把本地复现环境做成了一个run.sh,里面包含内核启动参数、共享目录挂载、调试端口映射,后面调参就快了。
最后再分享一个小技巧:遇到“原型链污染”不要只盯着能不能直接 RCE,多想想污染点背后还能覆盖哪些全局状态。Web 层的对象生命周期和内核层的对象生命周期,本质上都是在回答同一个问题——谁还引用着这块数据,以及攻击者能不能伪造这块数据。想明白这个,很多所谓的综合难题就只是换了一个场景的同一套思路。