CVE-2022-25175
Description
Jenkins Pipeline: Multibranch Plugin uses shared checkout directories for distinct SCMs, allowing attackers with Item/Configure permission to achieve OS command execution through crafted SCM contents.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Jenkins Pipeline: Multibranch Plugin uses shared checkout directories for distinct SCMs, allowing attackers with Item/Configure permission to achieve OS command execution through crafted SCM contents.
Vulnerability
The Jenkins Pipeline: Multibranch Plugin version 706.vd43c65dec013 and earlier uses the same checkout directories for distinct SCMs when performing the readTrusted step [1][2]. This allows attackers with Item/Configure permission to craft SCM contents that, when checked out, can invoke arbitrary OS commands on the Jenkins controller [1][2][3].
Exploitation
An attacker must have Item/Configure permission on a Jenkins job using the Multibranch Pipeline plugin [1][3]. The attacker crafts SCM contents (e.g., a malicious Git repository or Subversion source) that trigger OS command execution when the readTrusted step reuses the same checkout directory for a different SCM [1]. The attack does not require authentication beyond the existing permission, but it does require the ability to modify SCM configuration and trigger a pipeline run [1].
Impact
Successful exploitation allows the attacker to execute arbitrary OS commands on the Jenkins controller, leading to full compromise of the Jenkins instance [1][3]. This can result in disclosure of sensitive information, modification of data, or further lateral movement within the network [1].
Mitigation
Jenkins released Pipeline: Multibranch Plugin version 706.vd43c65dec013 and earlier is affected; the fix was included in version 707.v1c2b_15a_3f3a_7, released as part of the 2022-02-15 security advisory [1]. Users should update to this version or later to ensure distinct checkout directories are used per SCM [1]. There is no known workaround; updating is the recommended mitigation [1].
AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
org.jenkins-ci.plugins.workflow:workflow-multibranchMaven | < 707.v71c3f0a_6ccdb | 707.v71c3f0a_6ccdb |
Affected products
2- ghsa-coordsRange: < 707.v71c3f0a_6ccdb
- Jenkins project/Jenkins Pipeline: Multibranch Pluginv5Range: unspecified
Patches
171c3f0a6ccdb[SECURITY-2463][SECURITY-2491]
2 files changed · +216 −13
src/main/java/org/jenkinsci/plugins/workflow/multibranch/ReadTrustedStep.java+41 −13 modified@@ -39,6 +39,8 @@ import hudson.model.TopLevelItem; import hudson.scm.SCM; import hudson.slaves.WorkspaceList; + +import java.io.File; import java.io.IOException; import javax.inject.Inject; import jenkins.branch.Branch; @@ -48,6 +50,7 @@ import jenkins.scm.api.SCMRevision; import jenkins.scm.api.SCMRevisionAction; import jenkins.scm.api.SCMSource; +import jenkins.security.HMACConfidentialKey; import org.jenkinsci.plugins.workflow.cps.CpsScmFlowDefinition; import org.jenkinsci.plugins.workflow.cps.steps.LoadStepExecution; import org.jenkinsci.plugins.workflow.flow.FlowDefinition; @@ -69,6 +72,9 @@ */ public class ReadTrustedStep extends AbstractStepImpl { + // Intentionally using the same key as CpsScmFlowDefinition. + private static final HMACConfidentialKey CHECKOUT_DIR_KEY = new HMACConfidentialKey(CpsScmFlowDefinition.class, "filePathWithSuffix", 32); + private final String path; // TODO encoding @@ -122,31 +128,31 @@ public static class Execution extends AbstractSynchronousNonBlockingStepExecutio } } Node node = Jenkins.get(); - FilePath dir; + FilePath baseWorkspace; if (job instanceof TopLevelItem) { - FilePath baseWorkspace = node.getWorkspaceFor((TopLevelItem) job); + baseWorkspace = node.getWorkspaceFor((TopLevelItem) job); if (baseWorkspace == null) { throw new AbortException(node.getDisplayName() + " may be offline"); } - dir = getFilePathWithSuffix(baseWorkspace); } else { // should not happen, but just in case: throw new IllegalStateException(job + " was not top level"); } - FilePath file = dir.child(step.path); - if (!file.absolutize().getRemote().replace('\\', '/').startsWith(dir.absolutize().getRemote().replace('\\', '/') + '/')) { // TODO JENKINS-26838 - throw new IOException(file + " is not inside " + dir); - } Computer computer = node.toComputer(); if (computer == null) { throw new IOException(node.getDisplayName() + " may be offline"); } if (standaloneSCM != null) { + FilePath dir = getFilePathWithSuffix(baseWorkspace, standaloneSCM); + FilePath file = dir.child(step.path); try (WorkspaceList.Lease lease = computer.getWorkspaceList().acquire(dir)) { + dir.withSuffix("-scm-key.txt").write(standaloneSCM.getKey(), "UTF-8"); SCMStep delegate = new GenericSCMStep(standaloneSCM); delegate.setPoll(true); delegate.setChangelog(true); delegate.checkout(build, dir, listener, node.createLauncher(listener)); - if (!file.exists()) { + if (!isDescendant(file, dir)) { + throw new AbortException(file + " references a file that is not inside " + dir); + } else if (!file.exists()) { throw new AbortException(file + " not found"); } return file.readToString(); @@ -187,22 +193,30 @@ public static class Execution extends AbstractSynchronousNonBlockingStepExecutio listener.getLogger().println("Obtained " + step.path + " from " + trusted); } else { listener.getLogger().println("Checking out " + head.getName() + " to read " + step.path); + SCM trustedScm = scmSource.build(head, trusted); + FilePath dir = getFilePathWithSuffix(baseWorkspace, trustedScm); + FilePath file = dir.child(step.path); try (WorkspaceList.Lease lease = computer.getWorkspaceList().acquire(dir)) { + dir.withSuffix("-scm-key.txt").write(trustedScm.getKey(), "UTF-8"); if (trustCheck) { SCMStep delegate = new GenericSCMStep(scmSource.build(head, tip)); delegate.setPoll(false); delegate.setChangelog(false); delegate.checkout(build, dir, listener, node.createLauncher(listener)); - if (!file.exists()) { + if (!isDescendant(file, dir)) { + throw new AbortException(file + " references a file that is not inside " + dir); + } else if (!file.exists()) { throw new AbortException(file + " not found"); } untrustedFile = file.readToString(); } - SCMStep delegate = new GenericSCMStep(scmSource.build(head, trusted)); + SCMStep delegate = new GenericSCMStep(trustedScm); delegate.setPoll(true); delegate.setChangelog(true); delegate.checkout(build, dir, listener, node.createLauncher(listener)); - if (!file.exists()) { + if (!isDescendant(file, dir)) { + throw new AbortException(file + " references a file that is not inside " + dir); + } else if (!file.exists()) { throw new AbortException(file + " not found"); } content = file.readToString(); @@ -215,14 +229,28 @@ public static class Execution extends AbstractSynchronousNonBlockingStepExecutio return content; } - private FilePath getFilePathWithSuffix(FilePath baseWorkspace) { - return baseWorkspace.withSuffix(getFilePathSuffix() + "script"); + private FilePath getFilePathWithSuffix(FilePath baseWorkspace, SCM scm) { + return baseWorkspace.withSuffix(getFilePathSuffix() + "script").child(CHECKOUT_DIR_KEY.mac(scm.getKey())); } private String getFilePathSuffix() { return System.getProperty(WorkspaceList.class.getName(), "@"); } + /** + * Checks whether a given child path is a descendent of a given parent path using {@link File#getCanonicalFile}. + * + * If the child path does not exist, this method will canonicalize path elements such as {@code /../} and + * {@code /./} before comparing it to the parent path, and it will not throw an exception. If the child path + * does exist, symlinks will be resolved before checking whether the child is a descendant of the parent path. + */ + private static boolean isDescendant(FilePath child, FilePath parent) throws IOException, InterruptedException { + if (child.isRemote() || parent.isRemote()) { + throw new IllegalStateException(); + } + return new File(child.getRemote()).getCanonicalFile().toPath().startsWith(parent.absolutize().getRemote()); + } + private static final long serialVersionUID = 1L; }
src/test/java/org/jenkinsci/plugins/workflow/multibranch/ReadTrustedStepTest.java+175 −0 modified@@ -24,7 +24,11 @@ package org.jenkinsci.plugins.workflow.multibranch; +import hudson.Functions; import hudson.model.Result; +import hudson.scm.SubversionSCM; +import java.io.File; +import java.nio.charset.StandardCharsets; import jenkins.branch.BranchSource; import jenkins.plugins.git.GitSampleRepoRule; import jenkins.plugins.git.GitStep; @@ -40,11 +44,29 @@ import org.jvnet.hudson.test.Issue; import org.jvnet.hudson.test.JenkinsRule; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import jenkins.plugins.git.GitSCMSource; +import jenkins.scm.impl.subversion.SubversionSCMSource; +import jenkins.scm.impl.subversion.SubversionSampleRepoRule; +import org.apache.commons.io.FileUtils; +import org.junit.Ignore; +import org.jvnet.hudson.test.FlagRule; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.io.FileMatchers.anExistingFile; +import static org.junit.Assume.assumeFalse; + public class ReadTrustedStepTest { @ClassRule public static BuildWatcher buildWatcher = new BuildWatcher(); @Rule public JenkinsRule r = new JenkinsRule(); @Rule public GitSampleRepoRule sampleRepo = new GitSampleRepoRule(); + @Rule public SubversionSampleRepoRule sampleRepoSvn = new SubversionSampleRepoRule(); + @Rule public FlagRule<Boolean> heavyweightCheckoutFlag = new FlagRule<>(() -> SCMBinder.USE_HEAVYWEIGHT_CHECKOUT, v -> { SCMBinder.USE_HEAVYWEIGHT_CHECKOUT = v; }); @Test public void smokes() throws Exception { sampleRepo.init(); @@ -193,4 +215,157 @@ public class ReadTrustedStepTest { } } + @Test + public void pathTraversalRejected() throws Exception { + SCMBinder.USE_HEAVYWEIGHT_CHECKOUT = true; + sampleRepo.init(); + sampleRepo.write("Jenkinsfile", "node { checkout scm; echo \"${readTrusted '../../secrets/master.key'}\"}"); + Path secrets = Paths.get(sampleRepo.getRoot().getPath(), "secrets"); + Files.createSymbolicLink(secrets, Paths.get(r.jenkins.getRootDir() + "/secrets")); + sampleRepo.git("add", "."); + sampleRepo.git("commit", "-m", "init"); + + WorkflowMultiBranchProject mp = r.jenkins.createProject(WorkflowMultiBranchProject.class, "p"); + mp.getSourcesList().add(new BranchSource(new SCMBinderTest.WarySource(null, sampleRepo.toString(), "", "*", "", false))); + WorkflowJob p = WorkflowMultiBranchProjectTest.scheduleAndFindBranchProject(mp, "master"); + r.waitUntilNoActivity(); + + WorkflowRun b = p.getLastBuild(); + assertEquals(1, b.getNumber()); + r.assertLogContains("secrets/master.key references a file that is not inside " + r.jenkins.getWorkspaceFor(p).getRemote(), b); + } + + @Issue("SECURITY-2491") + @Test + public void symlinksInReadTrustedCannotEscapeWorkspaceContext() throws Exception { + SCMBinder.USE_HEAVYWEIGHT_CHECKOUT = true; + sampleRepo.init(); + sampleRepo.write("Jenkinsfile", "node { checkout scm; echo \"${readTrusted 'secrets/master.key'}\"}"); + Path secrets = Paths.get(sampleRepo.getRoot().getPath(), "secrets"); + Files.createSymbolicLink(secrets, Paths.get(r.jenkins.getRootDir() + "/secrets")); + sampleRepo.git("add", "."); + sampleRepo.git("commit", "-m", "init"); + + WorkflowMultiBranchProject mp = r.jenkins.createProject(WorkflowMultiBranchProject.class, "p"); + mp.getSourcesList().add(new BranchSource(new SCMBinderTest.WarySource(null, sampleRepo.toString(), "", "*", "", false))); + WorkflowJob p = WorkflowMultiBranchProjectTest.scheduleAndFindBranchProject(mp, "master"); + r.waitUntilNoActivity(); + + WorkflowRun run = p.getLastBuild(); + assertEquals(1, run.getNumber()); + r.assertLogContains("secrets/master.key references a file that is not inside " + r.jenkins.getWorkspaceFor(p).getRemote(), run); + } + + @Issue("SECURITY-2491") + @Test + public void symlinksInUntrustedRevisionCannotEscapeWorkspace() throws Exception { + SCMBinder.USE_HEAVYWEIGHT_CHECKOUT = true; + sampleRepo.init(); + sampleRepo.write("Jenkinsfile", "node { checkout scm; echo \"${readTrusted 'secrets/master.key'}\"}"); + sampleRepo.write("secrets/master.key", "secret info"); + sampleRepo.git("add", "."); + sampleRepo.git("commit", "-m", "init"); + sampleRepo.git("checkout", "-b", "feature"); + Path secrets = Paths.get(sampleRepo.getRoot().getPath(), "secrets"); + Files.delete(Paths.get(secrets.toString(), "master.key")); + Files.delete(secrets); + Files.createSymbolicLink(secrets, Paths.get(r.jenkins.getRootDir() + "/secrets")); + sampleRepo.git("add", "."); + sampleRepo.git("commit", "-m", "now with unsafe symlink"); + + WorkflowMultiBranchProject mp = r.jenkins.createProject(WorkflowMultiBranchProject.class, "p"); + mp.getSourcesList().add(new BranchSource(new SCMBinderTest.WarySource(null, sampleRepo.toString(), "", "*", "", false))); + WorkflowJob p = WorkflowMultiBranchProjectTest.scheduleAndFindBranchProject(mp, "feature"); + r.waitUntilNoActivity(); + + WorkflowRun run = p.getLastBuild(); + assertEquals(1, run.getNumber()); + r.assertLogContains("secrets/master.key references a file that is not inside ", run); + } + + @Issue("SECURITY-2491") + @Test + public void symlinksInNonMultibranchCannotEscapeWorkspaceContextViaReadTrusted() throws Exception { + SCMBinder.USE_HEAVYWEIGHT_CHECKOUT = true; + sampleRepo.init(); + sampleRepo.write("Jenkinsfile", "echo \"${readTrusted 'master.key'}\""); + Path secrets = Paths.get(sampleRepo.getRoot().getPath(), "master.key"); + Files.createSymbolicLink(secrets, Paths.get(r.jenkins.getRootDir() + "/secrets/master.key")); + sampleRepo.git("add", "."); + sampleRepo.git("commit", "-m", "init"); + + WorkflowJob p = r.jenkins.createProject(WorkflowJob.class, "p"); + GitStep step = new GitStep(sampleRepo.toString()); + p.setDefinition(new CpsScmFlowDefinition(step.createSCM(), "Jenkinsfile")); + WorkflowRun run = r.buildAndAssertStatus(Result.FAILURE, p); + + r.assertLogContains("master.key references a file that is not inside " + r.jenkins.getWorkspaceFor(p), run); + } + + @Ignore("There are two checkouts, one from CpsScmFlowDefinition via SCMBinder and one from ReadTrustedStep. Fixing the former requires an updated version of workflow-cps.") + @Issue("SECURITY-2463") + @Test public void multibranchCheckoutDirectoriesAreNotReusedByDifferentScms() throws Exception { + SCMBinder.USE_HEAVYWEIGHT_CHECKOUT = true; + assumeFalse(Functions.isWindows()); // Checkout hook is not cross-platform. + sampleRepo.init(); + sampleRepo.git("checkout", "-b", "trunk"); // So we end up using the same project for both SCMs. + sampleRepo.write("Jenkinsfile", "echo('git library'); readTrusted('Jenkinsfile')"); + sampleRepo.git("add", "Jenkinsfile"); + sampleRepo.git("commit", "--message=init"); + sampleRepoSvn.init(); + sampleRepoSvn.write("Jenkinsfile", "echo('svn library'); readTrusted('Jenkinsfile')"); + // Copy .git folder from the Git repo into the SVN repo as data. + File gitDirInSvnRepo = new File(sampleRepoSvn.wc(), ".git"); + FileUtils.copyDirectory(new File(sampleRepo.getRoot(), ".git"), gitDirInSvnRepo); + String jenkinsRootDir = r.jenkins.getRootDir().toString(); + // Add a Git post-checkout hook to the .git folder in the SVN repo. + Files.write(gitDirInSvnRepo.toPath().resolve("hooks/post-checkout"), ("#!/bin/sh\ntouch '" + jenkinsRootDir + "/hook-executed'\n").getBytes(StandardCharsets.UTF_8)); + sampleRepoSvn.svnkit("add", sampleRepoSvn.wc() + "/Jenkinsfile"); + sampleRepoSvn.svnkit("add", sampleRepoSvn.wc() + "/.git"); + sampleRepoSvn.svnkit("propset", "svn:executable", "ON", sampleRepoSvn.wc() + "/.git/hooks/post-checkout"); + sampleRepoSvn.svnkit("commit", "--message=init", sampleRepoSvn.wc()); + // Run a build using the SVN repo. + WorkflowMultiBranchProject mp = r.jenkins.createProject(WorkflowMultiBranchProject.class, "p"); + mp.getSourcesList().add(new BranchSource(new SubversionSCMSource("", sampleRepoSvn.prjUrl()))); + WorkflowJob p = WorkflowMultiBranchProjectTest.scheduleAndFindBranchProject(mp, "trunk"); + r.waitUntilNoActivity(); + // Run a build using the Git repo. It should be checked out to a different directory than the SVN repo. + mp.getSourcesList().clear(); + mp.getSourcesList().add(new BranchSource(new GitSCMSource("", sampleRepo.toString(), "", "*", "", false))); + WorkflowMultiBranchProjectTest.scheduleAndFindBranchProject(mp, "trunk"); + r.waitUntilNoActivity(); + assertThat(p.getLastBuild().getNumber(), equalTo(2)); + assertThat(new File(r.jenkins.getRootDir(), "hook-executed"), not(anExistingFile())); + } + + @Ignore("There are two checkouts, one from CpsScmFlowDefinition and one from ReadTrustedStep. Fixing the former requires an updated version of workflow-cps.") + @Issue("SECURITY-2463") + @Test public void checkoutDirectoriesAreNotReusedByDifferentScms() throws Exception { + SCMBinder.USE_HEAVYWEIGHT_CHECKOUT = true; + assumeFalse(Functions.isWindows()); // Checkout hook is not cross-platform. + sampleRepo.init(); + sampleRepo.write("Jenkinsfile", "echo('git library'); readTrusted('Jenkinsfile')"); + sampleRepo.git("add", "Jenkinsfile"); + sampleRepo.git("commit", "--message=init"); + sampleRepoSvn.init(); + sampleRepoSvn.write("Jenkinsfile", "echo('subversion library'); readTrusted('Jenkinsfile')"); + // Copy .git folder from the Git repo into the SVN repo as data. + File gitDirInSvnRepo = new File(sampleRepoSvn.wc(), ".git"); + FileUtils.copyDirectory(new File(sampleRepo.getRoot(), ".git"), gitDirInSvnRepo); + String jenkinsRootDir = r.jenkins.getRootDir().toString(); + // Add a Git post-checkout hook to the .git folder in the SVN repo. + Files.write(gitDirInSvnRepo.toPath().resolve("hooks/post-checkout"), ("#!/bin/sh\ntouch '" + jenkinsRootDir + "/hook-executed'\n").getBytes(StandardCharsets.UTF_8)); + sampleRepoSvn.svnkit("add", sampleRepoSvn.wc() + "/Jenkinsfile"); + sampleRepoSvn.svnkit("add", sampleRepoSvn.wc() + "/.git"); + sampleRepoSvn.svnkit("propset", "svn:executable", "ON", sampleRepoSvn.wc() + "/.git/hooks/post-checkout"); + sampleRepoSvn.svnkit("commit", "--message=init", sampleRepoSvn.wc()); + // Run a build using the SVN repo. + WorkflowJob p = r.createProject(WorkflowJob.class); + p.setDefinition(new CpsScmFlowDefinition(new SubversionSCM(sampleRepoSvn.trunkUrl()), "Jenkinsfile")); + r.buildAndAssertSuccess(p); + // Run a build using the Git repo. It should be checked out to a different directory than the SVN repo. + p.setDefinition(new CpsScmFlowDefinition(new GitStep(sampleRepo.toString()).createSCM(), "Jenkinsfile")); + WorkflowRun b2 = r.buildAndAssertSuccess(p); + assertThat(new File(r.jenkins.getRootDir(), "hook-executed"), not(anExistingFile())); + } }
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4News mentions
1- Jenkins Security Advisory 2022-02-15Jenkins Security Advisories · Feb 15, 2022