VYPR
High severityNVD Advisory· Published Mar 18, 2026· Updated Mar 19, 2026

CVE-2026-33001

CVE-2026-33001

Description

Jenkins 2.554 and earlier, LTS 2.541.2 and earlier does not safely handle symbolic links during the extraction of .tar and .tar.gz archives, allowing crafted archives to write files to arbitrary locations on the filesystem, restricted only by file system access permissions of the user running Jenkins. This can be exploited to deploy malicious scripts or plugins on the controller by attackers with Item/Configure permission, or able to control agent processes.

AI Insight

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

Jenkins core tar extraction does not validate symbolic links, allowing attackers with Item/Configure permission to write files outside the target directory and achieve code execution.

Vulnerability

Overview

CVE-2026-33001 is a path traversal vulnerability in Jenkins core that arises from unsafe handling of symbolic links during the extraction of .tar and .tar.gz archives [1][2]. The FilePath class, which provides file operations for Jenkins agents and the controller, does not validate that symbolic-link resolution when extracting tar entries. This allows a crafted archive to contain symbolic links that point outside the intended extraction directory, enabling file writes to arbitrary locations on the filesystem, limited only by the permissions of the Jenkins process [2].

Exploitation

Prerequisites and Attack Surface

An attacker must have Item/Configure permission on a Jenkins job or be able to control an agent process [1][2]. The vulnerability is reachable through several features, most notably the "Archive the artifacts" post-build action and the archiveArtifacts and archive Pipeline steps when using the standard artifact manager that stores artifacts on the controller filesystem [2]. By crafting a malicious .tar or .tar.gz archive that includes symbolic links, an attacker can cause Jenkins to write files outside the designated extraction directory.

Impact

Successful exploitation can lead to arbitrary code execution on the Jenkins controller. For example, an attacker could write a malicious Groovy script to the JENKINS_HOME/init.groovy.d/ directory, which Jenkins executes during startup, or deploy a malicious plugin to JENKINS_HOME/plugins/ [2]. This effectively gives the attacker full control over the Jenkins controller.

Mitigation

Jenkins 2.555 and LTS 2.541.3 fix the vulnerability by refusing to extract files from .tar and .tar.gz archives whose real path is outside the target directory and preventing extraction through symbolic links in the path or at the target location [2][3]. The fix includes a system property (ALLOW_UNTAR_SYMLINK_RESOLUTION) that can be set to true to re-enable the vulnerable behavior if needed for compatibility, but this is strongly discouraged [3]. Users should upgrade to the patched versions immediately.

AI Insight generated on May 18, 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.main:jenkins-coreMaven
< 2.5552.555

Affected products

2

Patches

1
6dc99937605d

[SECURITY-3657]

https://github.com/jenkinsci/jenkinsDaniel BeckMar 10, 2026via ghsa
2 files changed · +506 6
  • core/src/main/java/hudson/FilePath.java+43 6 modified
    @@ -213,6 +213,15 @@
      */
     public final class FilePath implements SerializableOnlyOverRemoting {
     
    +    /**
    +     * Set to {@code true} to disable validation to ensure that we do not attempt to extract paths that may allow determining the path to the destination directory.
    +     */
    +    private static /* non-final for script console */ boolean ALLOW_REENTRY_PATH_TRAVERSAL = SystemProperties.getBoolean(FilePath.class.getName() + ".ALLOW_REENTRY_PATH_TRAVERSAL");
    +    /**
    +     * Set to {@code true} to disable the fix for SECURITY-3657 that prevents path traversal from crafted tar files.
    +     */
    +    private static /* non-final for script console */ boolean ALLOW_UNTAR_SYMLINK_RESOLUTION = SystemProperties.getBoolean(FilePath.class.getName() + ".ALLOW_UNTAR_SYMLINK_RESOLUTION");
    +
         public enum DisplayOption implements OpenOption, CopyOption {
             IGNORE_TMP_DIRS
         }
    @@ -3051,26 +3060,54 @@ private static void readFromTar(String name, File baseDir, InputStream in) throw
         /**
          * Reads from a tar stream and stores obtained files to the base dir.
          * Supports large files &gt; 10 GB since 1.627.
    +     * This prohibits any path traversal out of the base dir, as well as writing through any existing symlinks.
          */
         private static void readFromTar(String name, File baseDir, InputStream in, Charset filenamesEncoding) throws IOException {
    -
    +        final File absoluteBaseDir = baseDir.getAbsoluteFile();
    +        final Path normalizedAbsoluteBaseDir = absoluteBaseDir.toPath().normalize();
             try (TarInputStream t = new TarInputStream(in, filenamesEncoding.name())) {
                 TarEntry te;
                 while ((te = t.getNextEntry()) != null) {
    -                File f = new File(baseDir, te.getName());
    -                if (!f.toPath().normalize().startsWith(baseDir.toPath())) {
    -                    throw new IOException(
    -                            "Tar " + name + " contains illegal file name that breaks out of the target directory: " + te.getName());
    +                final String entryName = te.getName();
    +                if (!ALLOW_REENTRY_PATH_TRAVERSAL) {
    +                    if (new File(entryName).toPath().normalize().startsWith(Path.of(".."))) {
    +                        // catch relative path that would escape and then enter the destination dir again, like `../../../var/jenkins_home/...`
    +                        throw new IOException("Tar " + name + " contains entry that escapes destination directory: " + entryName);
    +                    }
                     }
    +
    +                // We cannot replace 'f' with its canonical path here, otherwise, if it is a symlink, it becomes its link target and attempting to overwrite 'f' will have unintended behavior (JENKINS-67063)
    +                File f = new File(baseDir, entryName).getAbsoluteFile();
    +                File parent = f.getParentFile();
    +                if (!f.toPath().normalize().startsWith(normalizedAbsoluteBaseDir)) {
    +                    // This covers both relative path traversal, and potential undefined File(String, String) constructor behavior when it takes a second argument that's absolute.
    +                    throw new IOException("Tar " + name + " contains entry that escapes destination directory: " + entryName);
    +                }
    +
    +                if (!ALLOW_UNTAR_SYMLINK_RESOLUTION) {
    +                    // getCanonicalFile doesn't follow symlinks on Windows, so do this the hard way: Check each ancestor up to the base dir for whether it's a symlink
    +                    File current = parent;
    +                    while (current != null && !current.equals(absoluteBaseDir)) {
    +                        if (Util.isSymlink(current)) {
    +                            throw new IOException("Tar " + name + " attempts to write to file with symlink in path: " + entryName);
    +                        }
    +                        current = current.getParentFile();
    +                    }
    +                }
    +
                     if (te.isDirectory()) {
                         mkdirs(f);
                     } else {
    -                    File parent = f.getParentFile();
                         if (parent != null) mkdirs(parent);
     
                         if (te.isSymbolicLink()) {
                             new FilePath(f).symlinkTo(te.getLinkName(), TaskListener.NULL);
                         } else {
    +                        if (!ALLOW_UNTAR_SYMLINK_RESOLUTION) {
    +                            if (Util.isSymlink(f)) {
    +                                throw new IOException("Tar '" + name + "' entry '" + entryName + "' would write through existing symlink: " + f);
    +                            }
    +                        }
                             IOUtils.copy(t, f);
     
                             Files.setLastModifiedTime(Util.fileToPath(f), FileTime.from(te.getModTime().toInstant()));
    
  • core/src/test/java/jenkins/security/Security3657Test.java+463 0 added
    @@ -0,0 +1,463 @@
    +package jenkins.security;
    +
    +import static jenkins.security.Security3657Test.Entry.fileOrDir;
    +import static jenkins.security.Security3657Test.Entry.symlink;
    +import static org.hamcrest.MatcherAssert.assertThat;
    +import static org.hamcrest.Matchers.containsString;
    +import static org.junit.jupiter.api.Assertions.assertFalse;
    +import static org.junit.jupiter.api.Assertions.assertThrows;
    +import static org.junit.jupiter.api.Assertions.assertTrue;
    +
    +import hudson.FilePath;
    +import hudson.Util;
    +import java.io.File;
    +import java.io.IOException;
    +import java.lang.reflect.Field;
    +import java.nio.charset.StandardCharsets;
    +import java.nio.file.Files;
    +import java.nio.file.Path;
    +import org.apache.tools.tar.TarConstants;
    +import org.apache.tools.tar.TarEntry;
    +import org.apache.tools.tar.TarOutputStream;
    +import org.junit.jupiter.api.Test;
    +import org.junit.jupiter.api.io.TempDir;
    +import org.jvnet.hudson.test.Issue;
    +
    +public class Security3657Test {
    +
    +    @Test
    +    void tarSymlinkPathTraversal(@TempDir File root) throws Exception {
    +        final FilePath tarfile = new FilePath(createTarFile(root, symlink("attacker", ".."), fileOrDir("attacker/pwned.txt")));
    +
    +        FilePath extractDir = new FilePath(new File(root, "extract"));
    +        extractDir.mkdirs();
    +
    +        IOException exception = assertThrows(IOException.class, () -> tarfile.untar(extractDir, FilePath.TarCompression.NONE));
    +
    +        // Symlink was created but file outside extraction dir was not
    +        assertTrue(extractDir.child("attacker").exists());
    +        assertFalse(new File(root, "pwned.txt").exists());
    +
    +        // Verify the error message mentions symlink in path
    +        String message = exception.getMessage();
    +        if (exception.getCause() != null) {
    +            message = exception.getCause().getMessage();
    +        }
    +        assertThat(message, containsString("symlink in path"));
    +    }
    +
    +    @Test
    +    void tarSymlinkPathTraversalEscapeHatch(@TempDir File root) throws Exception {
    +        final Field escapeHatch = FilePath.class.getDeclaredField("ALLOW_UNTAR_SYMLINK_RESOLUTION");
    +        escapeHatch.setAccessible(true);
    +        escapeHatch.setBoolean(null, true);
    +        try {
    +            final FilePath tarfile = new FilePath(createTarFile(root, symlink("attacker", ".."), fileOrDir("attacker/pwned.txt")));
    +
    +            FilePath extractDir = new FilePath(new File(root, "extract"));
    +            extractDir.mkdirs();
    +
    +            tarfile.untar(extractDir, FilePath.TarCompression.NONE);
    +
    +            // Symlink and file outside extraction dir were created
    +            assertTrue(extractDir.child("attacker").exists());
    +            assertTrue(new File(root, "pwned.txt").exists());
    +        } finally {
    +            escapeHatch.setBoolean(null, false);
    +        }
    +    }
    +
    +    @Test
    +    void recursiveLinks(@TempDir File root) throws Exception {
    +        final File extractDir = new File(root, "extract-base");
    +        assertTrue(extractDir.mkdirs());
    +        final FilePath symlinkTarFile = new FilePath(createTarFile(root, symlink("link-file", "other-link"), symlink("other-link", "link-file")));
    +        final FilePath extractFilePath = new FilePath(root).child("extract-base");
    +        extractFilePath.mkdirs();
    +        symlinkTarFile.untar(extractFilePath, FilePath.TarCompression.NONE);
    +        assertTrue(Util.isSymlink(extractDir.toPath().resolve("link-file")));
    +        assertTrue(Util.isSymlink(extractDir.toPath().resolve("other-link")));
    +    }
    +
    +    @Test
    +    void selfLink(@TempDir File root) throws Exception {
    +        final File extractDir = new File(root, "extract-base");
    +        assertTrue(extractDir.mkdirs());
    +        final FilePath symlinkTarFile = new FilePath(createTarFile(root, symlink("link-file", "link-file")));
    +        final FilePath extractFilePath = new FilePath(root).child("extract-base");
    +        extractFilePath.mkdirs();
    +        symlinkTarFile.untar(extractFilePath, FilePath.TarCompression.NONE);
    +        assertTrue(Util.isSymlink(extractDir.toPath().resolve("link-file")));
    +    }
    +
    +    @Test
    +    void selfLink2(@TempDir File root) throws Exception {
    +        final File extractDir = new File(root, "extract-base");
    +        assertTrue(extractDir.mkdirs());
    +        final FilePath symlinkTarFile = new FilePath(createTarFile(root, symlink("link-file", "link-file"), symlink("link-file", "link-file")));
    +        final FilePath extractFilePath = new FilePath(root).child("extract-base");
    +        extractFilePath.mkdirs();
    +        symlinkTarFile.untar(extractFilePath, FilePath.TarCompression.NONE);
    +        assertTrue(Util.isSymlink(extractDir.toPath().resolve("link-file")));
    +    }
    +
    +    @Test
    +    void allowNonExistentSymlinkTargets(@TempDir File root) throws Exception {
    +        final File extractDir = new File(root, "extract-base");
    +        assertTrue(extractDir.mkdirs());
    +        final FilePath symlinkTarFile = new FilePath(createTarFile(root, symlink("link-file", "src/main/whatever/non-existent-file"), fileOrDir("real-file")));
    +        final FilePath extractFilePath = new FilePath(root).child("extract-base");
    +        extractFilePath.mkdirs();
    +        symlinkTarFile.untar(extractFilePath, FilePath.TarCompression.NONE);
    +        assertTrue(Util.isSymlink(extractDir.toPath().resolve("link-file")));
    +        assertTrue(Files.isRegularFile(extractDir.toPath().resolve("real-file")));
    +    }
    +
    +    @Issue("JENKINS-67063")
    +    @Test
    +    void repeatedExtraction(@TempDir File root) throws Exception {
    +        final File extractDir = new File(root, "extract-base");
    +        assertTrue(extractDir.mkdirs());
    +        final FilePath symlinkTarFile = new FilePath(createTarFile(root, symlink("link-file", "src/main/whatever/some-file"), fileOrDir("src/main/whatever/some-file")));
    +        final FilePath extractFilePath = new FilePath(root).child("extract-base");
    +        extractFilePath.mkdirs();
    +        symlinkTarFile.untar(extractFilePath, FilePath.TarCompression.NONE);
    +        symlinkTarFile.untar(extractFilePath, FilePath.TarCompression.NONE);
    +        assertTrue(Util.isSymlink(extractDir.toPath().resolve("link-file")));
    +        assertTrue(Files.isRegularFile(extractDir.toPath().resolve("src/main/whatever/some-file")));
    +    }
    +
    +    @Issue("JENKINS-67063")
    +    @Test
    +    void differentExtraction(@TempDir File root) throws Exception {
    +        final File extractDir = new File(root, "extract-base");
    +        assertTrue(extractDir.mkdirs());
    +        final FilePath extractFilePath = new FilePath(root).child("extract-base");
    +        extractFilePath.mkdirs();
    +
    +        final FilePath symlinkTarFile = new FilePath(createTarFile(root, symlink("link-file", "src/main/whatever/some-file"), fileOrDir("src/main/whatever/some-file"), fileOrDir("regular-file")));
    +        symlinkTarFile.untar(extractFilePath, FilePath.TarCompression.NONE);
    +        assertTrue(Util.isSymlink(extractDir.toPath().resolve("link-file")));
    +        assertTrue(Files.isRegularFile(extractDir.toPath().resolve("src/main/whatever/some-file")));
    +        assertTrue(Files.isRegularFile(extractDir.toPath().resolve("regular-file")));
    +
    +        final FilePath otherTarFile = new FilePath(createTarFile(root, "other.tar", symlink("link-file", "src/main/whatever/some-file")));
    +        otherTarFile.untar(extractFilePath, FilePath.TarCompression.NONE);
    +        assertTrue(Util.isSymlink(extractDir.toPath().resolve("link-file")));
    +        assertTrue(Files.isRegularFile(extractDir.toPath().resolve("src/main/whatever/some-file")));
    +        assertTrue(Files.isRegularFile(extractDir.toPath().resolve("regular-file")));
    +    }
    +
    +    @Test
    +    void directWriteThroughRelative(@TempDir File root) throws Exception {
    +        // We can only have a link name up to 100 bytes, which won't be enough for local/CI build workspaces, so improvise: relative PT, direct, to existing parent
    +        assertTrue(new File(root, "plugins").mkdirs());
    +        final FilePath pathTraversalTarFile = new FilePath(createTarFile(root, symlink("link-file", "../plugins/evil.hpi"), fileOrDir("link-file")));
    +        final FilePath extractDir = new FilePath(root).child("extract-base");
    +        extractDir.mkdirs();
    +        final IOException expected = assertThrows(IOException.class, () -> pathTraversalTarFile.untar(extractDir, FilePath.TarCompression.NONE));
    +        assertThat(expected.getMessage(), containsString("Failed to extract crafted.tar"));
    +        assertThat(expected.getCause().getMessage(), containsString("Tar 'crafted.tar' entry 'link-file' would write through existing symlink:"));
    +
    +        assertFalse(new File(root, "plugins/evil.hpi").exists());
    +    }
    +
    +    @Test
    +    void directWriteThroughAbsolute(@TempDir File root) throws Exception {
    +        // We can only have a link name up to 100 bytes, which may not be enough for local/CI build workspaces, so improvise with system temp dir.
    +        // macOS 26.3 has temp dirs that look like /var/folders/sx/123456789012345678901234567890/T/ (49 chars), which is enough for this test.
    +        // It seems running this test in IntelliJ IDEA fails since that uses a different temp dir, but it works with command line `mvn`.
    +        final Path dir = Files.createTempDirectory("jenkins-test");
    +        try {
    +            final Path linkTargetPath = dir.resolve("evil.hpi");
    +            final FilePath pathTraversalTarFile = new FilePath(createTarFile(root, symlink("link-file", linkTargetPath.toString()), fileOrDir("link-file")));
    +            final FilePath extractDir = new FilePath(root).child("extract-base");
    +            extractDir.mkdirs();
    +            final IOException expected = assertThrows(IOException.class, () -> pathTraversalTarFile.untar(extractDir, FilePath.TarCompression.NONE));
    +            assertThat(expected.getMessage(), containsString("Failed to extract crafted.tar"));
    +            assertThat(expected.getCause().getMessage(), containsString("Tar 'crafted.tar' entry 'link-file' would write through existing symlink:"));
    +
    +            assertFalse(Files.exists(linkTargetPath));
    +        } finally {
    +            try {
    +                Files.deleteIfExists(dir);
    +            } catch (IOException ignored) {
    +            }
    +        }
    +    }
    +
    +    @Test
    +    void directoryCreationPathTraversal(@TempDir File root) throws Exception {
    +        final File extractDir = new File(root, "extract-base");
    +        assertTrue(extractDir.mkdirs());
    +        final FilePath symlinkTarFile = new FilePath(createTarFile(root, symlink("link-file", ".."), fileOrDir("link-file/bar/")));
    +        final FilePath extractFilePath = new FilePath(root).child("extract-base");
    +        extractFilePath.mkdirs();
    +        final IOException ioException = assertThrows(IOException.class, () -> symlinkTarFile.untar(extractFilePath, FilePath.TarCompression.NONE));
    +        assertThat(ioException.getMessage(), containsString("Failed to extract crafted.tar"));
    +        assertThat(ioException.getCause().getMessage(), containsString("Tar crafted.tar attempts to write to file with symlink in path: link-file/bar/"));
    +        assertFalse(Files.isDirectory(root.toPath().resolve("bar")));
    +    }
    +
    +    @Test
    +    void relativePathLegal(@TempDir Path root) throws Exception {
    +        // relative path from cwd to temp dir to ensure behavior is as expected with relative base dir
    +        final File relativeRoot = Path.of("").toAbsolutePath().relativize(root).toFile();
    +
    +        final File extractDir = new File(relativeRoot, "extract-base");
    +        assertTrue(extractDir.mkdirs());
    +        final FilePath symlinkTarFile = new FilePath(createTarFile(relativeRoot, fileOrDir("dir/file")));
    +        final FilePath extractFilePath = new FilePath(relativeRoot).child("extract-base");
    +        extractFilePath.mkdirs();
    +        symlinkTarFile.untar(extractFilePath, FilePath.TarCompression.NONE);
    +        assertTrue(Files.isDirectory(extractDir.toPath().resolve("dir")));
    +        assertTrue(Files.isRegularFile(extractDir.toPath().resolve("dir/file")));
    +    }
    +
    +    @Test
    +    void relativeBaseAllowedSymlinks(@TempDir Path root) throws Exception {
    +        // relative path from cwd to temp dir to ensure behavior is as expected with relative base dir
    +        final File relativeRoot = Path.of("").toAbsolutePath().relativize(root).toFile();
    +
    +        final File extractDir = new File(relativeRoot, "extract-base");
    +        assertTrue(extractDir.mkdirs());
    +        final FilePath symlinkTarFile = new FilePath(createTarFile(relativeRoot, symlink("path/to/link-file", "../../file.txt"), symlink("path/to/other-file", "../../path/to/link-file")));
    +        final FilePath extractFilePath = new FilePath(relativeRoot).child("extract-base");
    +        extractFilePath.mkdirs();
    +        symlinkTarFile.untar(extractFilePath, FilePath.TarCompression.NONE);
    +        assertTrue(Util.isSymlink(extractDir.toPath().resolve("path/to/link-file")));
    +        assertTrue(Util.isSymlink(extractDir.toPath().resolve("path/to/other-file")));
    +    }
    +
    +    @Test
    +    void relativeBaseDirectWriteThroughRelative(@TempDir Path root) throws Exception {
    +        // relative path from cwd to temp dir to ensure behavior is as expected with relative base dir
    +        final File relativeRoot = Path.of("").toAbsolutePath().relativize(root).toFile();
    +
    +        // We can only have a link name up to 100 bytes, which won't be enough for local/CI build workspaces, so improvise: relative PT, direct, to existing parent
    +        assertTrue(new File(relativeRoot, "plugins").mkdirs());
    +        final FilePath pathTraversalTarFile = new FilePath(createTarFile(relativeRoot, symlink("link-file", "../plugins/evil.hpi"), fileOrDir("link-file")));
    +        final FilePath extractDir = new FilePath(relativeRoot).child("extract-base");
    +        extractDir.mkdirs();
    +        final IOException expected = assertThrows(IOException.class, () -> pathTraversalTarFile.untar(extractDir, FilePath.TarCompression.NONE));
    +        assertThat(expected.getMessage(), containsString("Failed to extract crafted.tar"));
    +        assertThat(expected.getCause().getMessage(), containsString("Tar 'crafted.tar' entry 'link-file' would write through existing symlink:"));
    +
    +        assertFalse(new File(relativeRoot, "plugins/evil.hpi").exists());
    +    }
    +
    +    @Test
    +    void relativeBaseBasicRelativePathTraversal(@TempDir Path root) throws Exception {
    +        // relative path from cwd to temp dir to ensure behavior is as expected with relative base dir
    +        final File relativeRoot = Path.of("").toAbsolutePath().relativize(root).toFile();
    +
    +        final File extractDir = new File(relativeRoot, "extract-base");
    +        assertTrue(extractDir.mkdirs());
    +        final FilePath symlinkTarFile = new FilePath(createTarFile(relativeRoot, fileOrDir("../file.txt")));
    +        final FilePath extractFilePath = new FilePath(relativeRoot).child("extract-base");
    +        extractFilePath.mkdirs();
    +        final IOException ioException = assertThrows(IOException.class, () -> symlinkTarFile.untar(extractFilePath, FilePath.TarCompression.NONE));
    +        assertThat(ioException.getMessage(), containsString("Failed to extract crafted.tar"));
    +        assertFalse(Files.exists(extractDir.toPath().resolve("file.txt")));
    +    }
    +
    +    @Test
    +    void relativePathTraversalThroughSymlink(@TempDir Path root) throws Exception {
    +        // relative path from cwd to temp dir to ensure behavior is as expected with relative base dir
    +        final File relativeRoot = Path.of("").toAbsolutePath().relativize(root).toFile();
    +
    +        final File extractDir = new File(relativeRoot, "extract-base");
    +        assertTrue(extractDir.mkdirs());
    +        final FilePath symlinkTarFile = new FilePath(createTarFile(relativeRoot, symlink("link-file", ".."), fileOrDir("link-file/bar/")));
    +        final FilePath extractFilePath = new FilePath(relativeRoot).child("extract-base");
    +        extractFilePath.mkdirs();
    +        final IOException ioException = assertThrows(IOException.class, () -> symlinkTarFile.untar(extractFilePath, FilePath.TarCompression.NONE));
    +        assertThat(ioException.getMessage(), containsString("Failed to extract crafted.tar"));
    +        assertThat(ioException.getCause().getMessage(), containsString("Tar crafted.tar attempts to write to file with symlink in path: link-file/bar/"));
    +        assertFalse(Files.isDirectory(relativeRoot.toPath().resolve("bar")));
    +    }
    +
    +    @Test
    +    void directoryCreationDirect(@TempDir File root) throws Exception {
    +        final File extractDir = new File(root, "extract-base");
    +        assertTrue(extractDir.mkdirs());
    +        final FilePath symlinkTarFile = new FilePath(createTarFile(root, symlink("link-file", "../bar"), fileOrDir("link-file/")));
    +        final FilePath extractFilePath = new FilePath(root).child("extract-base");
    +        extractFilePath.mkdirs();
    +        final IOException ioException = assertThrows(IOException.class, () -> symlinkTarFile.untar(extractFilePath, FilePath.TarCompression.NONE));
    +        assertThat(ioException.getMessage(), containsString("Failed to extract crafted.tar"));
    +        assertFalse(Files.exists(root.toPath().resolve("bar")));
    +    }
    +
    +    @Test
    +    void basicAbsolutePathTraversal(@TempDir File root) throws Exception {
    +        // This test is weird since we cannot really assert anything based on java.io.File Javadoc and actual observed behavior. So just assert that the bad file doesn't get created.
    +        // We can only have a link name up to 100 bytes, which may not be enough for local/CI build workspaces, so improvise with system temp dir.
    +        // macOS 26.3 has temp dirs that look like /var/folders/sx/123456789012345678901234567890/T/ (49 chars), which is enough for this test.
    +        // It seems running this test in IntelliJ IDEA fails since that uses a different temp dir, but it works with command line `mvn`.
    +        final Path dir = Files.createTempDirectory("jenkins-test");
    +        try {
    +            final File extractDir = new File(root, "extract-base");
    +            assertTrue(extractDir.mkdirs());
    +            final FilePath symlinkTarFile = new FilePath(createTarFile(root, fileOrDir(dir.resolve("file.txt").toString())));
    +            final FilePath extractFilePath = new FilePath(root).child("extract-base");
    +            extractFilePath.mkdirs();
    +            try {
    +                symlinkTarFile.untar(extractFilePath, FilePath.TarCompression.NONE);
    +            } catch (Exception ignore) {
    +            }
    +            assertFalse(Files.exists(dir.resolve("file.txt")));
    +        } finally {
    +            try {
    +                Files.deleteIfExists(dir);
    +            } catch (IOException ignored) {
    +            }
    +        }
    +    }
    +
    +    @Test
    +    void basicRelativePathTraversal(@TempDir File root) throws Exception {
    +        final File extractDir = new File(root, "extract-base");
    +        assertTrue(extractDir.mkdirs());
    +        final FilePath symlinkTarFile = new FilePath(createTarFile(root, fileOrDir("../file.txt")));
    +        final FilePath extractFilePath = new FilePath(root).child("extract-base");
    +        extractFilePath.mkdirs();
    +        final IOException ioException = assertThrows(IOException.class, () -> symlinkTarFile.untar(extractFilePath, FilePath.TarCompression.NONE));
    +        assertThat(ioException.getMessage(), containsString("Failed to extract crafted.tar"));
    +        assertFalse(Files.exists(extractDir.toPath().resolve("file.txt")));
    +    }
    +
    +    @Test
    +    void allowedSymlinks(@TempDir File root) throws Exception {
    +        final File extractDir = new File(root, "extract-base");
    +        assertTrue(extractDir.mkdirs());
    +        final FilePath symlinkTarFile = new FilePath(createTarFile(root, symlink("path/to/link-file", "../../file.txt"), symlink("path/to/other-file", "../../path/to/link-file")));
    +        final FilePath extractFilePath = new FilePath(root).child("extract-base");
    +        extractFilePath.mkdirs();
    +        symlinkTarFile.untar(extractFilePath, FilePath.TarCompression.NONE);
    +        assertTrue(Util.isSymlink(extractDir.toPath().resolve("path/to/link-file")));
    +        assertTrue(Util.isSymlink(extractDir.toPath().resolve("path/to/other-file")));
    +    }
    +
    +    @Test
    +    void allowedPathTraversal(@TempDir File root) throws Exception {
    +        final File extractDir = new File(root, "extract-base");
    +        assertTrue(extractDir.mkdirs());
    +        final FilePath symlinkTarFile = new FilePath(createTarFile(root, fileOrDir("path/"), fileOrDir("path/../file")));
    +        final FilePath extractFilePath = new FilePath(root).child("extract-base");
    +        extractFilePath.mkdirs();
    +        symlinkTarFile.untar(extractFilePath, FilePath.TarCompression.NONE);
    +        assertTrue(Files.isRegularFile(extractDir.toPath().resolve("file")));
    +    }
    +
    +    @Test
    +    void escapeThenReEnterPathTraversal(@TempDir File root) throws Exception {
    +        final File extractDir = new File(root, "extract-base");
    +        assertTrue(extractDir.mkdirs());
    +        final FilePath symlinkTarFile = new FilePath(createTarFile(root, fileOrDir("../extract-base/foo")));
    +        final FilePath extractFilePath = new FilePath(root).child("extract-base");
    +        extractFilePath.mkdirs();
    +        final IOException ioException = assertThrows(IOException.class, () -> symlinkTarFile.untar(extractFilePath, FilePath.TarCompression.NONE));
    +        assertThat(ioException.getMessage(), containsString("Failed to extract crafted.tar"));
    +        assertThat(ioException.getCause().getMessage(), containsString("Tar crafted.tar contains entry that escapes destination directory: ../extract-base/foo"));
    +
    +        assertFalse(Files.exists(extractDir.toPath().resolve("foo")));
    +    }
    +
    +    @Test
    +    void escapeThenReEnterPathTraversalAllowed(@TempDir File root) throws Exception {
    +        final File extractDir = new File(root, "extract-base");
    +        assertTrue(extractDir.mkdirs());
    +        final FilePath symlinkTarFile = new FilePath(createTarFile(root, fileOrDir("../extract-base/foo")));
    +        final FilePath extractFilePath = new FilePath(root).child("extract-base");
    +        extractFilePath.mkdirs();
    +        final Field escapeHatch = FilePath.class.getDeclaredField("ALLOW_REENTRY_PATH_TRAVERSAL");
    +        escapeHatch.setAccessible(true);
    +        escapeHatch.setBoolean(null, true);
    +        try {
    +            symlinkTarFile.untar(extractFilePath, FilePath.TarCompression.NONE);
    +            assertTrue(Files.exists(extractDir.toPath().resolve("foo")));
    +        } finally {
    +            escapeHatch.setBoolean(null, false);
    +        }
    +    }
    +
    +    @Test
    +    void escapeThenReEnterPathTraversalAllowedRelativeBase(@TempDir Path root) throws Exception {
    +        // relative path from cwd to temp dir to ensure behavior is as expected with relative base dir
    +        final File relativeRoot = Path.of("").toAbsolutePath().relativize(root).toFile();
    +
    +        final File extractDir = new File(relativeRoot, "extract-base");
    +        assertTrue(extractDir.mkdirs());
    +        final FilePath symlinkTarFile = new FilePath(createTarFile(relativeRoot, fileOrDir("../extract-base/foo")));
    +        final FilePath extractFilePath = new FilePath(relativeRoot).child("extract-base");
    +        extractFilePath.mkdirs();
    +        final Field escapeHatch = FilePath.class.getDeclaredField("ALLOW_REENTRY_PATH_TRAVERSAL");
    +        escapeHatch.setAccessible(true);
    +        escapeHatch.setBoolean(null, true);
    +        try {
    +            symlinkTarFile.untar(extractFilePath, FilePath.TarCompression.NONE);
    +            assertTrue(Files.exists(extractDir.toPath().resolve("foo")));
    +        } finally {
    +            escapeHatch.setBoolean(null, false);
    +        }
    +    }
    +
    +    private static File createTarFile(File base, Entry... entries) throws IOException {
    +        return createTarFile(base, "crafted.tar", entries);
    +    }
    +
    +    private static File createTarFile(File base, String fileName, Entry... entries) throws IOException {
    +        File tarFile = new File(base, fileName);
    +
    +        try (TarOutputStream tar = new TarOutputStream(Files.newOutputStream(tarFile.toPath()))) {
    +            for (Entry entry : entries) {
    +                entry.add(tar);
    +            }
    +        }
    +        return tarFile;
    +    }
    +
    +    interface Entry {
    +        void add(TarOutputStream tar) throws IOException;
    +
    +        /**
    +         * @param name the name of the file or folder. For folder, add trailing /
    +         */
    +        static Entry fileOrDir(String name) {
    +            return new FileOrDirEntry(name);
    +        }
    +
    +        static Entry symlink(String name, String target) {
    +            return new SymlinkEntry(name, target);
    +        }
    +
    +    }
    +
    +    record FileOrDirEntry(String name) implements Entry {
    +        @Override
    +        public void add(TarOutputStream tar) throws IOException {
    +            TarEntry fileEntry = new TarEntry(name, true);
    +            byte[] content = "You've been pwned!".getBytes(StandardCharsets.UTF_8);
    +            if (!fileEntry.isDirectory()) {
    +                fileEntry.setSize(content.length);
    +            }
    +            tar.putNextEntry(fileEntry);
    +            if (!fileEntry.isDirectory()) {
    +                tar.write(content);
    +            }
    +            tar.closeEntry();
    +        }
    +    }
    +
    +    record SymlinkEntry(String name, String target) implements Entry {
    +        @Override
    +        public void add(TarOutputStream tar) throws IOException {
    +            TarEntry symlinkEntry = new TarEntry(name, true);
    +            symlinkEntry.setLinkFlag(TarConstants.LF_SYMLINK);
    +            symlinkEntry.setLinkName(target);
    +            tar.putNextEntry(symlinkEntry);
    +            tar.closeEntry();
    +        }
    +    }
    +}
    

Vulnerability mechanics

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

References

5

News mentions

1