VYPR
Moderate severityNVD Advisory· Published May 16, 2023· Updated Jan 23, 2025

CVE-2023-32981

CVE-2023-32981

Description

Arbitrary file write in Jenkins Pipeline Utility Steps Plugin allows attackers to create or replace files on agent via crafted archives.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Arbitrary file write in Jenkins Pipeline Utility Steps Plugin allows attackers to create or replace files on agent via crafted archives.

Vulnerability

Description

An arbitrary file write vulnerability exists in Jenkins Pipeline Utility Steps Plugin version 2.15.2 and earlier. The plugin fails to properly sanitize file paths within archive files during extraction, allowing an attacker to specify absolute paths or use symbolic links to write files outside the intended extraction directory [1][2].

Exploitation

Attackers who can provide crafted archives as parameters to pipeline steps (e.g., unzip, untar) can exploit this vulnerability. The attacker must have the ability to run pipeline jobs that use the affected plugin, but no additional authentication is required for the archive extraction [1]. The fix in commit [3] adds validation to reject archives containing absolute paths or paths that escape the extraction base.

Impact

Successful exploitation allows the attacker to create or replace arbitrary files on the Jenkins agent file system with attacker-controlled content. This can lead to remote code execution by overwriting critical files such as scripts, libraries, or configuration files [1][2].

Mitigation

The vulnerability is fixed in Pipeline Utility Steps Plugin version 2.15.3 [1]. Users should update to this version or later. No workarounds are known; upgrading is the recommended action.

AI Insight generated on May 20, 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.

PackageAffected versionsPatched versions
org.jenkins-ci.plugins:pipeline-utility-stepsMaven
< 2.15.32.15.3

Affected products

2

Patches

1
0ba4f329ee27

[SECURITY-2196]

8 files changed · +248 7
  • src/main/java/org/jenkinsci/plugins/pipeline/utility/steps/AbstractFileCallable.java+31 0 modified
    @@ -27,8 +27,12 @@
     import hudson.FilePath;
    
     import jenkins.MasterToSlaveFileCallable;
    
     
    
    +import java.io.File;
    
    +import java.io.IOException;
    
    +
    
     public abstract class AbstractFileCallable<T> extends MasterToSlaveFileCallable<T> {
    
         private FilePath destination;
    
    +    private boolean allowExtractionOutsideDestination = false;
    
     
    
         public FilePath getDestination() {
    
             return destination;
    
    @@ -37,4 +41,31 @@ public FilePath getDestination() {
         public void setDestination(FilePath destination) {
    
             this.destination = destination;
    
         }
    
    +
    
    +    /**
    
    +     * SECURITY-2169 escape hatch.
    
    +     * Controlled by {@link DecompressStepExecution#ALLOW_EXTRACTION_OUTSIDE_DESTINATION}.
    
    +     *
    
    +     * @return true if so.
    
    +     */
    
    +    public boolean isAllowExtractionOutsideDestination() {
    
    +        return allowExtractionOutsideDestination;
    
    +    }
    
    +
    
    +    public void setAllowExtractionOutsideDestination(boolean allowExtractionOutsideDestination) {
    
    +        this.allowExtractionOutsideDestination = allowExtractionOutsideDestination;
    
    +    }
    
    +
    
    +    protected boolean isDescendantOfDestination(FilePath f) throws IOException {
    
    +        if (allowExtractionOutsideDestination) {
    
    +            return true;
    
    +        }
    
    +        //Assumes destination and f is on the local host
    
    +        if (destination == null) {
    
    +            return false;
    
    +        }
    
    +        File dst = new File(destination.getRemote()).getCanonicalFile();
    
    +        File child = new File(f.getRemote()).getCanonicalFile();
    
    +        return child.toPath().startsWith(dst.toPath());
    
    +    }
    
     }
    \ No newline at end of file
    
  • src/main/java/org/jenkinsci/plugins/pipeline/utility/steps/DecompressStepExecution.java+18 0 modified
    @@ -25,8 +25,10 @@
     package org.jenkinsci.plugins.pipeline.utility.steps;
    
     
    
     import edu.umd.cs.findbugs.annotations.NonNull;
    
    +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
    
     import hudson.FilePath;
    
     import hudson.model.TaskListener;
    
    +import jenkins.util.SystemProperties;
    
     import org.apache.commons.lang.StringUtils;
    
     import org.jenkinsci.plugins.workflow.steps.StepContext;
    
     import org.jenkinsci.plugins.workflow.steps.SynchronousNonBlockingStepExecution;
    
    @@ -39,6 +41,13 @@
      * @author Robert Sandell &lt;rsandell@cloudbees.com&gt;.
    
      */
    
     public abstract class DecompressStepExecution extends SynchronousNonBlockingStepExecution<Object> {
    
    +
    
    +    /**
    
    +     * SECURITY-2169 escape hatch.
    
    +     */
    
    +    @SuppressFBWarnings(value={"MS_SHOULD_BE_FINAL"}, justification="Non final so that an admin can adjust the value through the groovy script console without restarting the instance.")
    
    +    public static /*almost final*/ boolean ALLOW_EXTRACTION_OUTSIDE_DESTINATION = SystemProperties.getBoolean(DecompressStepExecution.class.getName() + ".ALLOW_EXTRACTION_OUTSIDE_DESTINATION", false);
    
    +
    
         private transient AbstractFileCallable<? extends Object> callable;
    
         private transient final AbstractFileDecompressStep step;
    
     
    
    @@ -49,6 +58,9 @@ protected DecompressStepExecution(@NonNull AbstractFileDecompressStep step, @Non
     
    
         protected void setCallable(final AbstractFileCallable<? extends Object> callable) {
    
             this.callable = callable;
    
    +        if (callable != null) {
    
    +            callable.setAllowExtractionOutsideDestination(ALLOW_EXTRACTION_OUTSIDE_DESTINATION);
    
    +        }
    
         }
    
     
    
         @Override
    
    @@ -87,6 +99,12 @@ private Object test(TaskListener listener, FilePath workspace) throws IOExceptio
                 listener.error(source.getRemote() + " is a directory.");
    
                 return Boolean.FALSE;
    
             }
    
    +        FilePath destination = workspace;
    
    +        if (!StringUtils.isBlank(step.getDir())) {
    
    +            destination = workspace.child(step.getDir());
    
    +        }
    
    +
    
    +        callable.setDestination(destination);
    
             return source.act(callable);
    
         }
    
     }
    
    
  • src/main/java/org/jenkinsci/plugins/pipeline/utility/steps/tar/UnTarStepExecution.java+37 6 modified
    @@ -40,6 +40,7 @@
     
     import java.io.File;
     import java.io.FileInputStream;
    +import java.io.FileNotFoundException;
     import java.io.IOException;
     import java.io.InputStream;
     import java.io.OutputStream;
    @@ -95,17 +96,36 @@ public Void invoke(File tarFile, VirtualChannel channel) throws IOException, Int
                 PrintStream logger = listener.getLogger();
                 boolean doGlob = !StringUtils.isBlank(glob);
     
    -            InputStream fileStream = new FileInputStream(tarFile);
    +            FileInputStream fileStream = new FileInputStream(tarFile);
     
    +            FileChannel fileChannel = fileStream.getChannel();
    +
    +            byte[] signature = new byte[2];
                 try {
    -                //check if matches standard gzip magic number
    -                fileStream = new GzipCompressorInputStream(fileStream);
    +                int read = fileStream.read(signature);
    +                fileChannel.position(0);
    +                if (read <= 0) {
    +                    logger.println("File is empty.");
    +                }
                 } catch (IOException exception) {
    -                // Eat exception, may be not compressed file
    +                fileStream.close();
    +                throw new IOException("Error reading tar/tgz file: " + exception.getMessage(), exception);
    +            } finally {
    +                logger.flush();
    +            }
    +
    +            InputStream inputStream = fileStream;
    +            if(GzipCompressorInputStream.matches(signature, signature.length)) {
    +                try {
    +                    //check if matches standard gzip magic number
    +                    inputStream = new GzipCompressorInputStream(fileStream);
    +                } catch (IOException exception) {
    +                    // Eat exception, may be not compressed file
    +                }
                 }
     
                 getDestination().mkdirs();
    -            try (TarArchiveInputStream tarStream = new TarArchiveInputStream(fileStream)) {
    +            try (TarArchiveInputStream tarStream = new TarArchiveInputStream(inputStream)) {
                     logger.println("Extracting from " + tarFile.getAbsolutePath());
                     TarArchiveEntry entry;
                     Integer fileCount = 0;
    @@ -115,6 +135,9 @@ public Void invoke(File tarFile, VirtualChannel channel) throws IOException, Int
                         }
     
                         FilePath f = getDestination().child(entry.getName());
    +                    if (!isDescendantOfDestination(f)) {
    +                        throw new FileNotFoundException(f.getRemote() + " is out of bounds!");
    +                    }
                         if (entry.isDirectory()) {
                             f.mkdirs();
                         } else {
    @@ -151,7 +174,7 @@ boolean matches(String path, String glob) {
         }
     
         /**
    -     * Performs a test of a tar file on the slave where the file is.
    +     * Performs a test of a tar file on the agent where the file is.
          */
         static class TestTarFileCallable extends AbstractFileCallable<Boolean> {
             private TaskListener listener;
    @@ -205,6 +228,14 @@ public Boolean invoke(File f, VirtualChannel channel) throws IOException, Interr
                         if (!entry.isCheckSumOK()) {
                             throw new IOException("Not a tar archive");
                         }
    +                    FilePath destination = getDestination();
    +                    if (destination != null) {
    +                        FilePath ef = destination.child(entry.getName());
    +                        if (!isDescendantOfDestination(ef)) {
    +                            listener.error(ef.getRemote() + " is out of bounds!");
    +                            return false;
    +                        }
    +                    }
                     }
                 } catch (IOException exception) {
                     listener.error("Error validating tar file: " + exception.getMessage());
    
  • src/main/java/org/jenkinsci/plugins/pipeline/utility/steps/zip/UnZipStepExecution.java+12 0 modified
    @@ -36,6 +36,7 @@
     import org.jenkinsci.plugins.workflow.steps.StepContext;
     
     import java.io.File;
    +import java.io.FileNotFoundException;
     import java.io.IOException;
     import java.io.InputStream;
     import java.io.OutputStream;
    @@ -114,6 +115,9 @@ public Map<String, String> invoke(File zipFile, VirtualChannel channel) throws I
                             continue;
                         }
                         FilePath f = getDestination().child(entry.getName());
    +                    if (!isDescendantOfDestination(f)) {
    +                        throw new FileNotFoundException(f.getRemote() + " is out of bounds!");
    +                    }
                         if (entry.isDirectory()) {
                             if (!read) {
                                 f.mkdirs();
    @@ -191,6 +195,14 @@ public Boolean invoke(File f, VirtualChannel channel) throws IOException, Interr
     
                         ZipEntry entry = entries.nextElement();
                         if (!entry.isDirectory()) {
    +                        FilePath destination = getDestination();
    +                        if (destination != null) {
    +                            FilePath ef = destination.child(entry.getName());
    +                            if (!isDescendantOfDestination(ef)) {
    +                                listener.error(ef.getRemote() + " is out of bounds!");
    +                                return false;
    +                            }
    +                        }
                             try (InputStream inputStream = zip.getInputStream(entry)) {
                                 int length;
                                 while ((length = IOUtils.read(inputStream, buffer)) > 0) {
    
  • src/test/java/org/jenkinsci/plugins/pipeline/utility/steps/tar/UnTarStepTest.java+81 0 modified
    @@ -25,6 +25,8 @@
     package org.jenkinsci.plugins.pipeline.utility.steps.tar;
     
     import hudson.model.Label;
    +import hudson.model.Result;
    +import org.jenkinsci.plugins.pipeline.utility.steps.DecompressStepExecution;
     import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition;
     import org.jenkinsci.plugins.workflow.job.WorkflowJob;
     import org.jenkinsci.plugins.workflow.job.WorkflowRun;
    @@ -33,14 +35,18 @@
     import org.junit.Before;
     import org.junit.Rule;
     import org.junit.Test;
    +import org.jvnet.hudson.test.BuildWatcher;
    +import org.jvnet.hudson.test.Issue;
     import org.jvnet.hudson.test.JenkinsRule;
     
     import java.io.File;
     import java.net.URL;
     import java.net.URLDecoder;
    +import java.nio.charset.StandardCharsets;
     
     import static org.jenkinsci.plugins.pipeline.utility.steps.FilenameTestsUtils.separatorsToSystemEscaped;
     import static org.junit.Assert.assertFalse;
    +import static org.junit.Assume.assumeTrue;
     
     /**
      * Tests for {@link UnTarStep}.
    @@ -51,6 +57,8 @@ public class UnTarStepTest {
     
         @Rule
         public JenkinsRule j = new JenkinsRule();
    +    @Rule
    +    public BuildWatcher watcher = new BuildWatcher();
     
         @Before
         public void setup() throws Exception {
    @@ -273,4 +281,77 @@ public void untarKeepPermissions() throws Exception {
             WorkflowRun run = j.assertBuildStatusSuccess(p.scheduleBuild2(0));
             j.assertLogContains("Hello World!", run);
         }
    +
    +    @Test @Issue("SECURITY-2196")
    +    public void testingAbsolutePathsShouldFail() throws Exception {
    +        assumeTrue("Can only run in a gnu unix environment", File.pathSeparatorChar == ':');
    +        WorkflowJob p = j.jenkins.createProject(WorkflowJob.class, "p");
    +        URL resource = getClass().getResource("absolute.tar");
    +        String tgz = new File(URLDecoder.decode(resource.getPath(), StandardCharsets.UTF_8)).getAbsolutePath().replace('\\', '/');
    +        p.setDefinition(new CpsFlowDefinition(
    +                "node {\n" +
    +                        "  def result = untar file: '" + separatorsToSystemEscaped(tgz) + "', test: true\n" +
    +                        "  if (result)\n" +
    +                        "      error('Should be fail!')\n" +
    +                        "}", true));
    +        WorkflowRun run = j.buildAndAssertSuccess(p);
    +        j.assertLogContains("is out of bounds!", run);
    +    }
    +
    +    @Test @Issue("SECURITY-2196")
    +    public void testingAbsolutePathsShouldNotFailWithEscapeHatch() throws Exception {
    +        assumeTrue("Can only run in a gnu unix environment", File.pathSeparatorChar == ':');
    +        try {
    +            DecompressStepExecution.ALLOW_EXTRACTION_OUTSIDE_DESTINATION = true;
    +            j.createOnlineSlave(Label.get("bbb"));
    +            WorkflowJob p = j.jenkins.createProject(WorkflowJob.class, "p");
    +            URL resource = getClass().getResource("absolute.tar");
    +            String tgz = new File(URLDecoder.decode(resource.getPath(), StandardCharsets.UTF_8)).getAbsolutePath().replace('\\', '/');
    +            p.setDefinition(new CpsFlowDefinition(
    +                    "node('bbb') {\n" +
    +                            "  def result = untar file: '" + separatorsToSystemEscaped(tgz) + "', test: true\n" +
    +                            "  if (!result)\n" +
    +                            "      error('Should not be fail!')\n" +
    +                            "}", true));
    +            WorkflowRun run = j.buildAndAssertSuccess(p);
    +            j.assertLogNotContains("is out of bounds!", run);
    +        } finally {
    +            DecompressStepExecution.ALLOW_EXTRACTION_OUTSIDE_DESTINATION = false;
    +        }
    +    }
    +
    +    @Test @Issue("SECURITY-2196")
    +    public void absolutePathsShouldFailBuild() throws Exception {
    +        assumeTrue("Can only run in a gnu unix environment", File.pathSeparatorChar == ':');
    +        WorkflowJob p = j.jenkins.createProject(WorkflowJob.class, "p");
    +        URL resource = getClass().getResource("absolute.tar");
    +        String tgz = new File(URLDecoder.decode(resource.getPath(), StandardCharsets.UTF_8)).getAbsolutePath().replace('\\', '/');
    +        p.setDefinition(new CpsFlowDefinition(
    +                "node {\n" +
    +                        "  untar file: '" + separatorsToSystemEscaped(tgz) + "'\n" +
    +                        "}", true));
    +        WorkflowRun run = j.buildAndAssertStatus(Result.FAILURE, p);
    +        j.assertLogContains("is out of bounds!", run);
    +    }
    +
    +    @Test
    +    @Issue("SECURITY-2196")
    +    public void absolutePathsShouldNotFailBuildWithEscapeHatch() throws Exception {
    +        assumeTrue("Can only run in a gnu unix environment", File.pathSeparatorChar == ':');
    +        try {
    +            DecompressStepExecution.ALLOW_EXTRACTION_OUTSIDE_DESTINATION = true;
    +
    +            WorkflowJob p = j.jenkins.createProject(WorkflowJob.class, "p");
    +            URL resource = getClass().getResource("absolute.tar");
    +            String tgz = new File(URLDecoder.decode(resource.getPath(), StandardCharsets.UTF_8)).getAbsolutePath().replace('\\', '/');
    +            p.setDefinition(new CpsFlowDefinition(
    +                    "node {\n" +
    +                            "  untar file: '" + separatorsToSystemEscaped(tgz) + "'\n" +
    +                            "}", true));
    +            WorkflowRun run = j.buildAndAssertStatus(Result.SUCCESS, p);
    +            j.assertLogNotContains("is out of bounds!", run);
    +        } finally {
    +            DecompressStepExecution.ALLOW_EXTRACTION_OUTSIDE_DESTINATION = false;
    +        }
    +    }
     }
    
  • src/test/java/org/jenkinsci/plugins/pipeline/utility/steps/zip/UnZipStepTest.java+69 1 modified
    @@ -24,7 +24,11 @@
     
     package org.jenkinsci.plugins.pipeline.utility.steps.zip;
     
    +import hudson.Functions;
     import hudson.model.Label;
    +import hudson.model.Result;
    +import hudson.model.queue.QueueTaskFuture;
    +import org.jenkinsci.plugins.pipeline.utility.steps.DecompressStepExecution;
     import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition;
     import org.jenkinsci.plugins.workflow.job.WorkflowJob;
     import org.jenkinsci.plugins.workflow.job.WorkflowRun;
    @@ -33,14 +37,17 @@
     import org.junit.Before;
     import org.junit.Rule;
     import org.junit.Test;
    +import org.jvnet.hudson.test.Issue;
     import org.jvnet.hudson.test.JenkinsRule;
     
     import java.io.File;
    +import java.io.IOException;
     import java.net.URL;
     import java.net.URLDecoder;
     
     import static org.jenkinsci.plugins.pipeline.utility.steps.FilenameTestsUtils.separatorsToSystemEscaped;
     import static org.junit.Assert.assertFalse;
    +import static org.junit.Assume.assumeTrue;
     
     /**
      * Tests for {@link UnZipStep}.
    @@ -172,7 +179,7 @@ public void globReadingMore() throws Exception {
     
         @Test
         public void zipTest() throws Exception {
    -        Assume.assumeTrue("Can only run in a gnu unix environment", File.pathSeparatorChar == ':');
    +        assumeTrue("Can only run in a gnu unix environment", File.pathSeparatorChar == ':');
             WorkflowJob p = j.jenkins.createProject(WorkflowJob.class, "p");
             p.setDefinition(new CpsFlowDefinition(
                     "node('slaves') {\n" +
    @@ -279,4 +286,65 @@ public void unzipQuietReading() throws Exception {
             j.assertLogContains("Read: 2 files", run);
             j.assertLogContains("Text: Hello World!", run);
         }
    +
    +    @Test @Issue("SECURITY-2196")
    +    public void unZipMaliciousFailsTheTest() throws Exception {
    +        assumeTrue("Can only run in a gnu unix environment", File.pathSeparatorChar == ':');
    +        /*
    +         This test uses a prepared zip file with a malicious payload.
    +         */
    +        WorkflowJob p = j.jenkins.createProject(WorkflowJob.class, "p");
    +        URL resource = getClass().getResource("malicious.zip");
    +        String zip = new File(URLDecoder.decode(resource.getPath(), "UTF-8")).getAbsolutePath().replace('\\', '/');
    +        p.setDefinition(new CpsFlowDefinition(
    +                "node {\n" +
    +                        "  def result = unzip zipFile: '" + separatorsToSystemEscaped(zip) + "', test: true\n" +
    +                        "  if (result)\n" +
    +                        "      error('Should be failed!')\n" +
    +                        "}", true));
    +        WorkflowRun run = j.assertBuildStatusSuccess(p.scheduleBuild2(0));
    +        j.assertLogContains("is out of bounds!", run);
    +    }
    +
    +    @Test @Issue("SECURITY-2196")
    +    public void unZipMaliciousDoesNotFailTheTestWithEscapeHath() throws Exception {
    +        assumeTrue("Can only run in a gnu unix environment", File.pathSeparatorChar == ':');
    +        try {
    +            DecompressStepExecution.ALLOW_EXTRACTION_OUTSIDE_DESTINATION = true;
    +        /*
    +         This test uses a prepared zip file with a malicious payload.
    +         */
    +            j.createOnlineSlave(Label.get("bbb"));
    +            WorkflowJob p = j.jenkins.createProject(WorkflowJob.class, "p");
    +            URL resource = getClass().getResource("malicious.zip");
    +            String zip = new File(URLDecoder.decode(resource.getPath(), "UTF-8")).getAbsolutePath().replace('\\', '/');
    +            p.setDefinition(new CpsFlowDefinition(
    +                    "node('bbb') {\n" +
    +                            "  def result = unzip zipFile: '" + separatorsToSystemEscaped(zip) + "', test: true\n" +
    +                            "  if (!result)\n" +
    +                            "      error('Should not fail!')\n" +
    +                            "}", true));
    +            WorkflowRun run = j.assertBuildStatusSuccess(p.scheduleBuild2(0));
    +            j.assertLogNotContains("is out of bounds!", run);
    +        } finally {
    +            DecompressStepExecution.ALLOW_EXTRACTION_OUTSIDE_DESTINATION = false;
    +        }
    +    }
    +
    +    @Test @Issue("SECURITY-2196")
    +    public void unZipMaliciousFailsTheBuild() throws Exception {
    +        assumeTrue("Can only run in a gnu unix environment", File.pathSeparatorChar == ':');
    +        /*
    +         This test uses a prepared zip file with a malicious payload.
    +         */
    +        WorkflowJob p = j.jenkins.createProject(WorkflowJob.class, "p");
    +        URL resource = getClass().getResource("malicious.zip");
    +        String zip = new File(URLDecoder.decode(resource.getPath(), "UTF-8")).getAbsolutePath().replace('\\', '/');
    +        p.setDefinition(new CpsFlowDefinition(
    +                "node {\n" +
    +                        "  unzip zipFile: '" + separatorsToSystemEscaped(zip) + "'\n" +
    +                        "}", true));
    +        WorkflowRun run = j.buildAndAssertStatus(Result.FAILURE, p);
    +        j.assertLogContains("is out of bounds!", run);
    +    }
     }
    
  • src/test/resources/org/jenkinsci/plugins/pipeline/utility/steps/tar/absolute.tar+0 0 added
  • src/test/resources/org/jenkinsci/plugins/pipeline/utility/steps/zip/malicious.zip+0 0 added

Vulnerability mechanics

Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

4

News mentions

1