ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

CVE-2024-23897深度解析:Jenkins任意文件读取漏洞原理与修复

CVE-2024-23897深度解析:Jenkins任意文件读取漏洞原理与修复

CVE-2024-23897这个编号,在2024年初刚公开的时候,很多人第一反应是又一轮“高危漏洞例行公事”。但等仔细看完细节,才发现这次情况不太一样——Jenkins的CLI入口居然能以未认证的方式读出服务器上的任意文件,CVSS 9.8的评分挂在通告栏上,不少运维团队当天就开始排班打补丁。这个漏洞之所以值得单独拾遗一遍,是因为它不像常见文件读取那样有明显入口可拦截,真正的根子藏在Java生态里一个很“正常”的参数解析行为中:args4j把以@开头的参数当作文件引用去读。

这篇文章把CVE-2024-23897从原理到复现再到修复完整盘一遍,重点聊几个公开通告里没细写、但实际踩坑时一定会遇到的细节:args4j到底怎么翻的车、CLI消息通道怎么利用、Windows环境和Linux环境有什么差异、以及修复之后怎么确认自己真的干净了。适合正在维护Jenkins的运维同事、做安全测试的工程师,还有对漏洞机理感兴趣的开发人员。不铺垫背景故事,直接上干货。

1. 漏洞全景:这个CVE到底做了什么

1.1 一次特殊的参数解析翻车现场

先说结论:CVE-2024-23897是一个存在于Jenkins内置命令解析器args4j中的任意文件读取漏洞,攻击者不需要登录,只要能在网络层访问到Jenkins的CLI端口,就可以让Jenkins进程读取服务器本地的任意文件,并把文件内容带出来。

漏洞的源头是args4j的“参数文件展开”功能。args4j是Java社区常用的命令行解析库,很多工具都在用。它有一个设计上的便利特性:当命令行参数以@开头时,args4j会把@后面的字符串当作文件名,读取这个文件的内容,并用文件内容替代该参数继续解析。这个功能原本是为了解决命令行参数过长的问题——把一大串参数写进文件里,命令行只写一个@配置文件的引用就行,比如Java自身也有类似的-XX:Flags=@file用法。

问题在于,Jenkins把args4j用在了它的命令行接口(CLI)上。CLI是Jenkins提供的远程管理通道,允许用户通过命令行工具执行系统命令,比如连接节点、列出任务、查看版本等。CLI的请求不需要登录就能到达命令解析层,很多内部命令甚至不检查认证。当攻击者构造一个以@开头的参数,比如@/etc/passwd,args4j就会老老实实去读取/etc/passwd文件,然后把文件内容当作参数值传给后续解析逻辑。如果某个命令的回显信息中包含参数值,文件内容就这样泄露出来了。

这里有个容易误解的点:这个漏洞不是Jenkins刻意提供了一个“文件读取命令”,而是“参数解析”行为越权了。所以在漏洞公开初期,很多安全公告里写的是“任意文件读取”,至于具体能读到什么程度、要不要认证、怎么构造请求,不同文章说法不一,实际操作时也确实存在不少变数。这也是我写这篇“拾遗”的原因——官方通告只给了版本号和CVSS分,真正动手验证时的细节,反而要靠实际测试摸出来。

1.2 官方通告里没细说的版本边界

Jenkins官方在2024年1月24日发布安全通告,同时修复了两个CVE,其中一个就是CVE-2024-23897。受影响的版本范围:

发行渠道受影响版本修复版本
每周版2.441及之前所有版本2.442
LTS长期支持版2.426.1及之前所有版本2.426.2(实际稳定修复为2.426.3)

这个版本边界划分有两个细节值得注意。

第一,LTS的修复版本写的是2.426.2,但官方后续又发布了2.426.3,原因是2.426.2在修复过程中引入了另一个问题,属于修复中的复修。如果你当时看到告警后第一时间升到了2.426.2,建议后续再跟一个补丁版本。

第二,所有2.426.1之前的LTS版本都受影响,这覆盖了相当大范围的老旧部署。很多企业内网的Jenkins常年停在2.3xx甚至2.1xx版本,这类环境恰好是这次漏洞的重灾区。我在一些实际排查案例中见过,有些团队连稳定维护周期都没概念,看到漏洞通告才发现自己的Jenkins版本已经落后了将近两年。

2. 原理拆解:从@符号到文件内容回显的完整链路

2.1 args4j的解析机制与参数替换

要真正理解这个漏洞,得顺着args4j的代码路径走一遍。args4j在解析命令行参数时,会先做一层预处理,扫描所有参数项。当发现某个参数以@开头时,它会执行一个文件读取操作:

// 简化后的args4j核心逻辑 String arg = currentArg; if (arg.startsWith("@")) { String filePath = arg.substring(1); String fileContent = FileUtils.readFileToString(filePath); // 读取文件 // 用文件内容替换当前参数 String[] replacedArgs = expandArguments(fileContent); // 继续解析替换后的参数 }

FileUtils.readFileToString这一步没有任何路径白名单,没有文件类型限制,直接按UTF-8编码读取文件内容。如果文件内容是二进制或者包含特殊字符,读取过程会抛异常,但异常信息同样会带到回显里。所以即使是读取失败的文件,也经常能在错误信息里看到文件的部分内容片段。

Jenkins的CLI在收到远程请求后,会把这个请求反序列化成内部命令对象,命令对象里的参数就是通过args4j解析出来的。攻击者控制的“命令行参数”,本质上是CLI协议消息中携带的字符串。也就是说,攻击者专门给Jenkins发送一个包含@/etc/passwd的CLI请求,Jenkins内部解析时就会去读/etc/passwd,读到的内容再通过各种命令的回显信息返回给攻击者。这个链路中,args4j的文件展开功能被当成了“读文件工具”,而CLI的未认证可达性则提供了“远程触发”的条件。

我打一个比方你就明白了:这就好比公司前台有一个自助访客登记终端,本意是让访客在屏幕上输名字,结果这个终端的输入框有一个功能——如果你输入的东西以某个符号开头,系统就去读你指定的文件来填充输入框。访客发现这个秘密后,不需要任何内部权限,只要知道这个符号,就能把公司服务器的配置文件一字不差地念出来。这显然不是终端设计者的本意,但问题确实就出在这个“智能填充”功能上。

2.2 三条消息通道与hex编码利用

CLI的通信协议并不简单,不是发一个HTTP POST带参数就行。Jenkins CLI底层走的是Remoting协议,数据经过二进制序列化后传输。公开的安全研究社区很快发现,构造这个漏洞的利用请求有一种标准姿势,核心是构造一个包含三个chunk的消息队列。

Jenkins CLI接收消息时,会把消息分成多个数据块顺序处理。第一个chunk通常放命令名,第二个chunk放命令参数,第三个chunk放编码后的结束标志或附加信息。攻击者构造payload时,需要把命令名和参数分别放在不同chunk中,参数部分写@/etc/passwd。由于整个消息是二进制的,直接通过命令行工具发送零食,所以POC里通常先用十六进制编码表示整个二进制流,再通过原始HTTP请求发送。

很多人第一次看到十六进制payload时会以为这是什么高深技巧,其实只是因为Remoting协议的二进制消息没法直接放在明文HTTP头里,所以先转成hex方便传输和调试。自己写测试脚本时,也可以用printf或者Python的bytes.fromhex来完成转换。

读取结果并不总是完整回显。实际测试发现,如果文件很小(比如单行内容),会一次性出现在命令输出中。如果文件很大或者存在换行符,有些命令只会取第一行。这引出一个进阶技巧:通过构造多个chunk,每个chunk里都传入不同的读取参数,可以在一次请求中读取文件的三行内容。社区POC里常见的-s参数和-p参数搭配,就是在控制chunk数量来扩大读取范围。

2.3 为什么它读取的是“任意文件”而不是某个指定路径

漏洞描述里“任意文件”这四个字,信息量比表面看起来大得多。这里至少要拆成三层理解。

第一层,路径不受限制。args4j读取文件时用的是绝对路径或相对路径,@/etc/passwd能读,@../../etc/shadow如果权限足够也能读。Jenkins进程本身运行在什么系统用户下,读取权限就达到什么程度。绝大多数部署直接用root或管理员账号跑Jenkins服务,那攻击者能读的范围就更广了。

第二层,文件类型不限制。因为args4j读取的是纯字节流然后按文本解析,所以二进制文件也能读,只是读取后可能因为编码问题导致解析异常。但异常信息仍然可能包含部分内容。比如读取一个未加密的私钥文件,哪怕只回显前几行,对攻击者来说已经拿到关键材料。

第三层,相对路径下的探索。攻击者在不知道当前工作目录的情况下,可以先用@/proc/self/cwd/...之类的方式去试探绝对路径映射。实际利用过程中,攻击者通常会先读/etc/passwd确认文件读取是否生效,再读Jenkins的配置文件、凭据文件、密钥文件等敏感目标。这也就是为什么这个漏洞被定性为严重级别而不是普通的目录遍历——它等于给了攻击者一把“读文件万能钥匙”。

3. 影响范围与实际风险评估

3.1 哪些资产最容易被盯上

从攻击者视角看,一个漏洞的价值上限取决于它暴露在什么位置。CVE-2024-23897的实际利用条件说苛刻也苛刻,说宽松也宽松。

先说宽松的一面:认证不是前置条件。Jenkins的CLI服务对未认证用户开放,不需要任何session或token,这是漏洞能打到9.8分的主要原因。只要Jenkins进程监听的端口能从网络抵达,就可以尝试利用。

再说苛刻的一面:这个漏洞需要网络可达性。如果Jenkins只监听在127.0.0.1或者内网隔离网段,外部攻击者根本够不着。所以真正容易出现实际受害的场景集中在三类:

  • 暴露在公网的Jenkins实例。这类资产是重灾区,几乎每天都被扫描器轮番问候。
  • 内网横向移动目标。攻击者打进内网后,用这个漏洞做权限提升或凭据收集,比找数据库弱口令高效得多。
  • 开发测试环境中的低版本Jenkins。测试环境安全水位低,往往裸奔在内部网段,却恰好保存着生产环境的连接凭据。

这里要强调一个很多人忽略的事实:Jenkins默认监听的端口是8080,很多团队不做访问控制就开放了。还有的Jenkins部署在Kubernetes集群中,通过NodePort暴露,实际暴露面比运维以为的大得多。

3.2 读取文件之后还能做什么

任意文件读取的落地价值,取决于服务器上存了什么。Jenkins作为持续集成平台,服务器上最有价值的东西集中在几类:

一是credentials.xml。这个文件里保存了Jenkins的全局凭据,包括各类账号密码和令牌。虽然没有密钥库密码时,凭据中的加密值无法直接解密,但攻击者可以先读出jenkins.security.ConfidentialStore相关配置,再结合后续攻击手段提取明文。

二是config.xml和secrets/目录。Jenkins主配置里经常包含数据库连接串、API Token、SMTP密码等敏感信息。secrets目录下是加密用的密钥文件,如果攻击者能同时拿到密钥文件和密钥库密码,凭据解密只是时间问题。

三是.ssh目录和known_hosts。很多Jenkins服务器配置了SSH免密登录,用于远程部署。如果攻击者能在Jenkins用户目录下读到私钥,可能直接横向连接到其他服务器。

四是构建日志和流水线脚本。即使没有凭据,从构建脚本里经常能翻出调试用的临时密码、测试账号、内部仓库地址等敏感数据。这些信息对后续定向攻击的价值不可低估。

五是密钥库文件。Jenkins的JENKINS_HOME目录下有secrets/master.key和hudson.util.Secret相关文件,配合credentials.xml,理论上可以解密所有凭据。这是这个漏洞最危险的利用方向之一。

还有一类容易被忽视的目标是环境变量文件。Jenkins在启动时可能加载/etc/environment或者systemd环境文件,里面往往写着数据库密码、云服务密钥之类的敏感配置。攻击者能读文件后,这类目标优先级很高。

3.3 CVSS 9.8的真实含义与误解

CVSS 9.8这个数字很容易让人产生两个方向的误解:要么觉得“这不就是个读文件吗,怎么这么高分”,要么觉得“9.8分的漏洞应该能直接拿shell”。

先说为什么评分这么高。CVSS 3.1计算时,这个漏洞的攻击向量是网络(AV:N),攻击复杂度低(AC:L),不需要权限(PR:N),不需要用户交互(UI:N),影响方面同时覆盖了机密性(C:H)、完整性(I:L)和可用性(A:L)。机密性影响被评了高,因为任意文件读取可以直接泄露凭据和密钥,而凭据一旦泄露,后续完整性影响也随之升高。

再说“能不能拿shell”。这个漏洞本身不直接执行代码,所以不是RCE。但如果攻击者利用文件读取拿到了Jenkins的密钥和凭据,配合其他已知漏洞或者管理员的失误,后续拿到RCE的路径并不难走。行业里对这类漏洞的普遍判断是:单独看是严重的信息泄露,组合利用时实际危害会进一步提升,所以按紧急漏洞处理完全合理。

4. 复现验证实操:在隔离环境里走一遍完整利用链

这一部分的目的是帮你在自己的测试环境里验证漏洞是否存在,以及确认修复是否生效。所有操作都应当在隔离网络、无业务流量的测试环境中进行,不要拿生产环境做实验。

4.1 验证环境搭建

最快的验证方式是用Docker跑一个旧版本Jenkins镜像。

# 拉取受影响版本镜像 docker pull jenkins/jenkins:2.426.1 # 启动容器,映射8080端口到宿主机,并设置JENKINS_HOME docker run -d --name jenkins-cve-lab -p 8080:8080 -p 50000:50000 \ -e JENKINS_HOME=/var/jenkins_home \ -v /tmp/jenkins-data:/var/jenkins_home \ jenkins/jenkins:2.426.1

容器起来后,访问http://你的测试机IP:8080,能看到初始化页面说明启动正常。注意,Docker镜像首次启动要初始化一些组件,耐心等一两分钟再确认。

验证环境里不需要配置任何任务、凭据,甚至不需要解锁Jenkins(首次启动时会有管理员密码页面,跳过就行),因为CLI入口在初始化阶段就已经可以使用了。

4.2 标准利用:读取/etc/passwd

这里需要准备jenkins-cli.jar。这个jar包可以直接从目标Jenkins实例下载:http://目标地址:8080/jnlpJars/jenkins-cli.jar。

在测试环境里执行以下命令:

# 利用标准输入重定向,构造CLI请求 echo 'connect-node @/etc/passwd' | java -jar jenkins-cli.jar -s http://127.0.0.1:8080/ connect-node @/etc/passwd

这个命令的意图是:通过echo向jenkins-cli.jar的标准输入写入一行命令,-s指定Jenkins地址,后面的connect-node @/etc/passwd是CLI的实际命令。其中@/etc/passwd会被args4j解析成读取文件。

执行后,正常情况下你会看到类似下面的输出:

root:x:0:0:root:/root:/bin/bash daemon:x:1:1:daemon:/usr/sbin:/bin/sh

这是/etc/passwd的前几行内容被回显了。如果输出为空但有回显连接信息,说明路径不对或者权限受限。

一个常见的坑是:有些环境里connect-node命令本身就要求认证,未认证时直接返回“用户ID缺失”之类的错误,导致看不到文件内容。这时候可以换一个更宽松的命令,比如who-am-i或version,有些版本对这类命令的认证要求更宽松。实测中version配合@/etc/passwd在很多版本上都能成功回显。

4.3 进阶技巧:读取多行内容与环境差异处理

标准利用姿势只能回显单行,因为CLI协议在解析时把文件内容作为单行参数传给命令。如果想读取文件的前几行,需要用十六进制编码构造三chunk消息。

社区公开的POC脚本里,核心思路是把CLI消息拆成三个chunk:

第一chunk:命令名,比如 connect-node 第二chunk:参数1,写入 @/etc/passwd 第三chunk:参数2,写入 -p @/etc/passwd

当args4j解析第二、第三chunk时,会分别读取两次文件,而一次CLI请求中可以包含多个参数展开,这样最终回显或许会出现多行内容。这个方式我实际测过,对不同版本效果有差异,有的版本只有参数1生效,有的版本两个参数都生效。关键是理解原理:每多一个以@开头的参数,就多一次文件读取机会。

还有一个很容易踩的坑是Windows环境差异。Jenkins如果部署在Windows服务器上,路径格式完全不同。直接传@C:\Users\...会被shell转义干扰,或者因为反斜杠被当成特殊字符而失败。正确做法是把整个参数用引号包起来,或者将路径转换成正斜杠格式,比如@C:/Users/xxx/AppData/...。另外Windows的Jenkins默认运行在LocalSystem账户下,能读的文件范围比Linux下的普通用户大得多,一旦成功利用,影响往往也更大。

4.4 复现过程中的典型翻车现场

我实际测试中遇到过几个反复出现的坑,整理下来给你们避雷。

第一个坑:版本踩错。有人拿2.346版本复现没成功,以为是POC有问题,其实这个版本在2023年就直接受到另一个类似问题影响,但CVE-2024-23897的触发路径在2.346和2.426.1之间并不完全相同。最好用官方通告中的2.426.1做验证。

第二个坑:docker容器端口映射没生效。-p 8080:8080没写完整,或者容器没起来,导致CLI连不上。排查时先看容器状态和端口监听。

第三个坑:jenkins-cli.jar版本不匹配。从目标实例下载的jar包通常是配套版本,但如果用本地别的版本,可能会出现协议版本不兼容的问题。建议直接从目标实例下载对应版本的jar包。

第四个坑:连接远程实例时未指定-s参数或使用了-webSocket选项。某些网络环境走HTTP长连接会被代理中断,导致请求发不完整。这种情况下可以尝试加-noCertificateCheck跳过证书校验,或者在命令行里明确指定-http模式。

第五个坑:文件路径写错导致读取失败。/etc/passwd在绝大多数Linux容器里都有,但如果你读取的是自定义路径,路径不存在时args4j会抛出异常,异常信息里不会带文件内容,看起来像“漏洞不存在”,实际只是路径没写对。复现前可以用ls验证一下文件是否存在。

5. 修复方案与事后检测

5.1 升级到安全版本

官方修复的核心思路就是对CLI参数解析做了限制,禁止args4j展开任意路径的@文件。修复后的版本中,CLI仍然支持从标准输入读取参数,但不再将@开头的参数作为文件路径读取。

升级路径建议按顺序来:

  1. 先备份JENKINS_HOME目录。这个目录包含所有配置、凭据、任务定义和密钥文件,升级前必须备份。
  2. 确认插件兼容性。部分老插件在新版本中可能不兼容,建议在测试环境先升级做冒烟测试。
  3. 执行升级。如果用的是官方安装包,替换war包并重启服务即可。如果是Docker,直接替换镜像Tag为修复后的版本。
  4. 升级后验证CLI行为。重跑上面第4.2节的命令,如果返回正常结果而不泄露文件内容,说明修复生效。

具体到版本,每周版升级到2.442及以上,LTS版升级到2.426.3及以上。如果你的版本低于2.346.1,建议考虑更大幅度的升级,因为中间隔了太多版本,潜在的其他问题也需要跟上前置修复。

5.2 无法立即升级时的应急缓解

如果业务方暂时不能停服,只能做应急缓解。以下几个手段按照推荐程度排列:

第一,禁用CLI。Jenkins从2017年开始支持通过系统属性禁用CLI入口。在Jenkins启动参数中加一行:

-Djenkins.CLI.disabled=true

修改启动脚本或systemd配置后重启Jenkins。这样CLI端口直接关闭,攻击面立刻消失。副作用是依赖CLI的自动化脚本会中断,需要提前告知运维和开发团队。

第二,配置网络访问控制。在Jenkins前面加防火墙规则,只允许可信IP段访问8080端口。这个手段能挡住公网扫描,但防不了内网横向,所以只能作为过渡措施。

第三,用反向代理过滤CLI路径。如果你的Jenkins前面有Nginx或Apache,可以配置拦截规则,阻止对/cli和cli/remoting路径的POST请求。示例规则:

location ^~ /cli { deny all; return 403; } location ^~ /jnlpJars/ { # jenkins-cli.jar 下载路径建议也限制或隐藏 deny all; }

不过要说明,这种方式只能缓解,不能根治。尤其是Jenkins的WebSocket模式,走的是HTTP升级协议,普通路径匹配不一定拦得住,所以能禁用CLI还是优先禁用。

5.3 漏洞特征与响应的检测规则

处理完漏洞之后,还要确认历史访问中是否已经有人尝试利用过。几个检测维度供参考。

网络层检测指标:

  • HTTP请求路径中包含/cli或/cli/remoting的POST请求,尤其是来自非管理IP的访问。
  • 请求体长度异常,比如CLI消息体超过1KB甚至数十KB的POST请求。
  • 同一IP在短时间内频繁发送CLI连接请求,可能是在批量探测。

应用层检测指标:

  • Jenkins日志中出现大量Failed to read或java.io.FileNotFoundException异常,且异常路径带有@字符。
  • Jenkins进程访问了往常不会访问的系统文件路径,可以通过审计日志或系统调用审计工具观察。
  • 用户在CLI连接记录中出现异常命令,比如connect-node和version等命令在短时间内频繁调用。

文件层验证手段:用比对工具检查/etc/passwd、/etc/hosts、Jenkins的config.xml等文件的访问时间戳,是否存在异常时间的读取行为。

我建议检测时把重点放在请求体积和路径特征上,因为攻击者如果想读取大文件,请求本身会留下很明显的痕迹。而很多自动化扫描器扫到这个漏洞时,只会发一个很小的探测包,这时候反而容易被忽略。所以日志排查要结合网络层和应用层一起看,不能只看单一维度。

6. 常见问题与排查技巧实录

最后集中回答几个我实际被问到最多的问题,直接整理成速查表。

问题可能原因解决方法
复现时命令回显是空白的命令本身要求认证或需要不同的命令名才能触发读取更换命令尝试,如version或who-am-i
读取中文内容乱码args4j按UTF-8读取,但某些配置文件的系统编码不同读取时先确认文件实际编码,用hex方式定位敏感片段
读取二进制文件导致CLI崩溃args4j解析异常被当作系统错误处理,命令中断改用读取文本型配置文件,或部分地分块读取
升级后CLI完全不可用升级后-Djenkins.CLI.disabled=true参数仍然生效确认Java环境变量配置并移除该参数
修复后仍然收到扫描告警扫描器缓存了旧请求特征或Jenkins版本号未变确认实际war包版本,手动请求一次/cli验证返回状态
Docker复现时CLI端口外网不可达容器网络模式限制或端口映射未生效用docker inspect确认端口绑定状态,改用host网络
Windows环境读取绝对路径失败反斜杠被shell解析转义路径改为正斜杠格式并用引号包裹
读取/etc/shadow返回为空Jenkins进程权限不足检查Jenkins启动用户权限,确认进程能否访问目标文件
无法回显第二行内容单chunk消息只展开一个参数构造包含多个@参数的三chunk消息

再补充几个排查时的经验:

  • 如果完全不确定漏洞是否存在,先在隔离环境用两个月前的镜像做对照测试。一个旧版本一个修复版,两者都跑同样的POC,有差异就说明漏洞存在。
  • 用curl直接发hex编码请求比用jenkins-cli.jar更便于控制细节,但对协议理解的难度高一些。想深入研究的可以看Jenkins的Remoting协议源码,官方在GitHub上有很多现成示例。
  • 如果你同时维护多套Jenkins环境,建议写一个自动化检查脚本,定期比对版本号和CLI状态,避免出现“别的环境修了,这个环境漏了”的情况。

我个人在实际处置这个漏洞时最深的体会是:它的传播速度比预想中快得多。通告发布后几个小时,扫描器特征更新就来了,很多未打补丁的实例在被运维注意到之前就已经被扫过一轮了。所以如果你还有未升级的Jenkins节点,不建议抱着“内网应该没问题”的心态,该升级升级、该禁用禁用,优先把CLI入口收起来。

最后再分享一个小技巧:升级完成后,除了跑POC验证修复,还可以主动检查一遍Jenkins的secrets目录权限,确认master.key和credentials.xml的读写权限只对运行Jenkins的系统用户开放。这个动作和本次漏洞没有直接关系,但很多时候漏洞只是一个导火索,真正被拿走的往往是后续权限控制没做到位的凭据文件。把这个习惯保持住,比单纯记住一个CVE编号有用得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进