URI解析链语义鸿沟 - 深度解析

一次 CTF 实战的完整复盘:为什么”同一个 URI 在不同组件中被解释为不同含义”会成为高级 Web 手的分水岭。


题目设定

  • 技术栈:Jetty 12 + Spring Boot(嵌入式)
  • 目标:读取 /WEB-INF/flag.txt
  • 阻碍:① Jetty WEB-INF 保护 ② 自定义 ErrorController 吞掉所有异常

考点 1:WEB-INF 目录保护机制

为什么要有 WEB-INF?

这是 Java Servlet 规范规定的特殊目录,存放服务端资源:

1
2
3
4
5
WEB-INF/
├── web.xml ← 部署描述符(路由、过滤器、Servlet 配置)
├── classes/ ← 编译后的 Java 字节码(.class 文件)
├── lib/ ← 依赖 JAR 包
└── flag.txt ← 本题的 flag

核心规则:WEB-INF 下的文件只能由服务端代码访问ServletContext.getResource()、JSP 转发等),任何 HTTP 外部请求都不能直接命中

在 Jetty 里它怎么实现的?

关键在 ContextHandler.getResource()

1
2
3
4
5
6
7
8
9
10
11
12
public Resource getResource(String path) {
// 1. 先规范化路径(处理 . .. 百分号编码等)
path = URIUtil.canonicalPath(path);

// 2. 安全检查:命中保护前缀就拒绝
if (path.startsWith("/WEB-INF/") || path.startsWith("/META-INF/")) {
return null; // 返回 null → 上层 500
}

// 3. 通过检查才去文件系统找资源
return _baseResource.addPath(path);
}

为什么直接访问返回 500 而不是 404?

getResource() 返回 null 后,Jetty 把它当作内部错误(而不是”找不到”),于是走了 500 → Spring 的 ErrorController → 返回 {"status":999,"error":"None"}

关键认知:你看到的 500 不是应用代码抛的,是 Servlet 容器安全层抛的。


考点 2:CVE-2026-8384 — 状态机缺陷

什么是”状态机”?

URIUtil.canonicalPath()一个字符一个字符地扫描路径,用两个变量记录状态:

1
2
StringBuilder canonical = new StringBuilder();  // 规范化后的输出
boolean slash = true; // ← 关键状态:上一个处理的字符是不是 '/'

slash 的作用:判断 ... 是否应该被当作路径遍历。只有”紧跟在 / 后面”的 . 才是特殊段;在字母后面的 . 就是普通字符。

正常流程(无漏洞)

1
2
3
4
5
6
7
8
9
10
输入: /x/../WEB-INF/flag.txt

字符 slash(进入前) 行为 canonical
'/' true 记录 '/' "/"
'x' false 普通字符 "/x"
'/' true 记录 '/' "/x/"
'.' true ← 检查: 下一个也是'.'吗? 是 → 进入 .. 处理
'.' (.. 处理中) 删除上一段 "x" → "/"
'W' false 普通字符 "/W" ...
最终: /WEB-INF/flag.txt → 安全检查命中!→ 500

漏洞流程(; 介入)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
输入: /x;/../WEB-INF/flag.txt

字符 slash(进入前) 行为 canonical
'/' true 记录 '/' "/"
'x' false 普通字符 "/x"
';' false case ';': 跳过分号到下一个'/' "/x"
(把中间的 '..' 当作"路径参数的值"吃掉)
canonical.append('/') "/x/"
⚠️ BUG: 没有执行 slash = true!
slash 仍然是 false(上一轮 'x' 留下的)
'.' false ← 检查 slash: false!
不认为是路径遍历,当作普通字符 "/x/."
'.' false 同上 "/x/.."
'/' true 记录 '/' "/x/../"
'W' false 普通字符 "/x/../W" ...
最终: /x/../WEB-INF/flag.txt ← 没有规范化!

代码层面的 BUG 位置

1
2
3
4
5
6
7
8
9
10
case ';':
// 路径参数处理:';' 之后直到下一个 '/' 都是参数内容
while (++i < end && path.charAt(i) != '/') {
// 吃掉 '..'(把路径穿越当作参数值吞掉)
}
canonical.append('/'); // 追加 '/' 到输出
// ❌ 缺少: slash = true;
// 因为 slash 还停留在 'x' 处理后的 false,
// 下一个 '.' 就判断不了"我是路径段开头了"
break;

一句话总结case ';'../ 当参数吃掉后,追加了 / 却没更新 slash 标志,导致紧接着的 .. 逃过遍历检测

触发条件

1
2
3
4
5
6
;[^/]*/.     ← 正则描述
分号 + 任意非斜杠字符(可为空) + 斜杠 + 点

/x;/../WEB-INF/flag.txt ✅ 触发
/static;x/../WEB-INF/... ✅ 触发
/WEB-INF;/flag.txt ❌ 不触发(;后面直接是/,但/后面是f不是.)

考点 3:URI 解析链语义鸿沟 ⭐ 核心

一个 URI 要过 6 道”翻译官”

1
2
3
4
5
6
7
8
9
10
11
12
13
浏览器发送: GET /x;/../WEB-INF/flag.txt HTTP/1.1

① Jetty HttpParser 看到原始字节串, 不做任何理解

② URI 解码层 百分号解码 (%3b → ; 等)

③ canonicalPath() 状态机规范化 (CVE bug → .. 没被解析!)

④ ContextHandler 安全 基于 ③ 的结果做 WEB-INF 检查 → 绕过!

⑤ Spring DispatcherServlet 用 getRequestURI() 重新取路径

⑥ PathResourceResolver cleanPath() 解析 .. → createRelative() 拼路径

关键:每一层看到的都不一样

看到的路径 结果
③ canonicalPath /x;/../WEB-INF/flag.txt 因 BUG 未规范化
④ 安全检查 拿 ③ 的结果 → 不匹配 /WEB-INF/ 放行
⑥ Spring cleanPath.. 消掉 → WEB-INF/flag.txt static/ 找 → 404

这就是”不同组件对同一 URI 的不同解释”

  • Jetty 安全层看到的是脏路径(有 ;、有未消化的 ..
  • Spring 应用层看到的是干净路径.. 被解析)
  • 安全层放行了,应用层却找不到文件 → 404

这类漏洞的通用模式

1
攻击面 = | 安全层对路径的理解 - 应用层对路径的理解 |

任何让这两层理解不一致的输入,都可能造成:

  • 认证绕过(安全层认为没登录,应用层认为登录了)
  • 越权访问(安全层认为无权,应用层给了权限)
  • 文件读取(安全层认为路径安全,应用层读到了敏感文件)

可迁移的真实案例

场景 不一致点
Nginx alias 配置错误 location /laravel(无斜杠)+ alias /var/www/public/(有斜杠)→ /laravel../.env 能读到 .env
Spring 路由 vs 文件系统 @GetMapping("/{path}") 匹配了 /a/b,但文件系统认为是另一个文件
WAF vs 后端 WAF 解码一次 %252e,后端解码两次 → .. 穿透
反代 vs 源站 反代规范化路径,源站不规范化 → 访问控制被绕过

考点 4:JAR vs Exploded — 为什么部署方式决定成败

两种部署方式

1
2
3
4
5
6
7
JAR 模式:                Exploded 模式:
app.jar app/
├── BOOT-INF/classes/ ├── classes/ ← 直接是目录
│ ├── WEB-INF/flag.txt │ ├── WEB-INF/flag.txt
│ ├── static/index.html │ ├── static/index.html
│ └── ... │ └── ...
└── ... └── ...

ClassLoader.getResource() 的行为差异

1
2
3
4
5
6
7
8
9
10
11
12
// JAR 模式
getResource("static/../WEB-INF/flag.txt")
→ JarFile.getEntry("static/../WEB-INF/flag.txt")
→ 在 JAR 的目录表里查这个"精确字符串"
→ ❌ 不存在(JAR 表里只有 "WEB-INF/flag.txt"
→ 返回 null404

// Exploded 模式
getResource("static/../WEB-INF/flag.txt")
→ 文件系统查找 /path/classes/static/../WEB-INF/flag.txt
→ OS 内核解析 .. → /path/classes/WEB-INF/flag.txt
→ ✅ 存在!→ 200 + flag 内容

JAR 的 entry 名是”死”的——它不经过 OS 文件系统,所以 .. 不会被解析。Exploded 走真实文件系统.. 由内核处理。

实战意义

同一个 payload 在不同部署下结果完全不同

1
2
3
/x;/../WEB-INF/flag.txt
→ JAR 部署: 404(Spring 找不到 static/WEB-INF/flag.txt)
→ Exploded 部署: 200 + flag(文件系统解析 .. 成功)

这就是为什么 CTF 里有的题”网上说这样打能通,我却打不通”——先确认目标部署方式


考点 5:ErrorController 信息隐藏 — 安全设计的”第二道墙”

题目里的设计

1
2
3
4
5
6
7
8
// 自定义 ErrorController
@RequestMapping("/error")
public ResponseEntity<?> error(...) {
return ResponseEntity.status(999).body(Map.of(
"status", 999,
"error", "None"
));
}

它为什么是”双重防护”

1
2
第一道: Jetty WEB-INF 检查 → 拦截直接访问(500)
第二道: ErrorController → 把 500 统一伪装成 999 "None"

效果

  1. 无法区分文件存在与否/WEB-INF/flag.txt/WEB-INF/nonexistent 返回完全相同的响应 → 不能做存在性探测(oracle)
  2. HTML 模式 Content-Length: 0:连 Spring 默认的 Whitelabel 错误页都不给 → 彻底隐藏

攻击者的应对思路

这种”信息隐藏”设计逼迫攻击者走应用逻辑本身(本题就是绕过第一道墙后直接读文件),而不是靠错误信息猜路径。


总结:这一题教会你的”思维框架”

1
2
3
4
5
6
7
8
9
拿到一个 Web 题 →
1. 识别处理链:请求经过哪些组件?(代理→框架→容器→文件系统)
2. 找语义分歧点:哪些组件对同一输入的理解可能不同?
- 路径规范化(.. 解析时机)
- 编码解码(百分号解码次数)
- 大小写/Unicode 处理
- 分号/参数处理
3. 构造"双关"输入:让安全层看到 A,应用层看到 B
4. 验证差异:404 vs 500 vs 200 的变化就是信号

这道题难不在漏洞利用本身,而在于”理解整个处理管道”。能把”一个 URI 从网线到文件的旅程”讲清楚的人,才是真正懂 Web 安全的人。


参考

  • CVE-2026-8384:Jetty 12 路径参数规范化漏洞(URIUtil.canonicalPath() 状态机缺陷)
  • 受影响版本:Jetty 12.0.0–12.0.34 / 12.1.0–12.1.8
  • 修复版本:12.0.35 / 12.1.9