kaniko has tar archive path traversal in build context extraction allows writing files outside destination directory
Description
kaniko is a tool to build container images from a Dockerfile, inside a container or Kubernetes cluster. Starting in version 1.25.4 and prior to version 1.25.10, kaniko unpacks build context archives using filepath.Join(dest, cleanedName) without enforcing that the final path stays within dest. A tar entry like ../outside.txt escapes the extraction root and writes files outside the destination directory. In environments with registry authentication, this can be chained with docker credential helpers to achieve code execution within the executor process. Version 1.25.10 uses securejoin for path resolution in tar extraction.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Kaniko 1.25.4–1.25.9 has a path traversal in tar extraction that can be chained with credential helpers for code execution.
Vulnerability
Kaniko, a tool for building container images inside containers or Kubernetes clusters, contains a path traversal vulnerability in its tar extraction logic. Starting in version 1.25.4 and prior to version 1.25.10, the ExtractFile function uses filepath.Join(dest, cleanedName) without verifying that the resulting path remains within the intended destination directory. A tar entry with a name like ../outside.txt can escape the extraction root and write files to arbitrary locations on the host filesystem [1][3].
Exploitation
An attacker who can supply a malicious build context archive (e.g., via a Dockerfile build request) can craft tar entries with parent-directory references in tar entry names. No special authentication is required to trigger the extraction; the vulnerability is reachable whenever kaniko processes a user-provided archive. In environments where registry authentication is configured, the attacker can chain this file write with docker credential helpers to overwrite sensitive files, potentially achieving code execution within the executor process [3].
Impact
Successful exploitation allows an attacker arbitrary file write outside the intended extraction directory. When combined with credential helpers, this can lead to arbitrary code execution in the context of the kaniko executor. The vulnerability does not require a Docker daemon and affects all users of kaniko versions 1.25.4 through 1.25.9 [1][3].
Mitigation
Version 1.25.10 resolves the issue by using securejoin for path resolution during tar extraction, ensuring that all extracted paths are confined to the destination directory. The fix was implemented in pull request #326 and includes additional test cases for path traversal scenarios [2][4]. Users should upgrade to kaniko 1.25.10 or later. No workaround is available for earlier versions has been documented.
- GitHub - chainguard-forks/kaniko: Build Container Images In Kubernetes
- fix(util): use securejoin for path resolution in tar extraction (#326) · chainguard-forks/kaniko@a370e4b
- NVD - CVE-2026-28406
- fix(util): use securejoin for path resolution in tar extraction by tdunlap607 · Pull Request #326 · chainguard-forks/kaniko
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.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/chainguard-dev/kanikoGo | >= 1.25.4, < 1.25.10 | 1.25.10 |
Affected products
2- chainguard-forks/kanikov5Range: >= 1.25.4, < 1.25.10
Patches
1a370e4b1f66efix(util): use securejoin for path resolution in tar extraction (#326)
2 files changed · +301 −4
pkg/util/fs_util.go+45 −4 modified@@ -33,6 +33,7 @@ import ( "github.com/chainguard-dev/kaniko/pkg/config" "github.com/chainguard-dev/kaniko/pkg/timing" + securejoin "github.com/cyphar/filepath-securejoin" "github.com/docker/docker/pkg/archive" v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/karrick/godirwalk" @@ -189,12 +190,22 @@ func GetFSFromLayers(root string, layers []v1.Layer, opts ...FSOpt) ([]string, e } cleanedName := filepath.Clean(hdr.Name) + if cleanedName == ".." || strings.HasPrefix(cleanedName, "../") { + return nil, fmt.Errorf("tar entry %q is not allowed: references parent directory", hdr.Name) + } path := filepath.Join(root, cleanedName) base := filepath.Base(path) - dir := filepath.Dir(path) if strings.HasPrefix(base, archive.WhiteoutPrefix) { - logrus.Tracef("Whiting out %s", path) + // SecureJoin resolves symlinks and ensures the result stays + // within root, which is needed because this code path calls + // os.RemoveAll. + securePath, err := securejoin.SecureJoin(root, cleanedName) + if err != nil { + return nil, fmt.Errorf("resolving whiteout path for %q: %w", hdr.Name, err) + } + dir := filepath.Dir(securePath) + logrus.Tracef("Whiting out %s", securePath) name := strings.TrimPrefix(base, archive.WhiteoutPrefix) path := filepath.Join(dir, name) @@ -300,7 +311,20 @@ func UnTar(r io.Reader, dest string) ([]string, error) { } func ExtractFile(dest string, hdr *tar.Header, cleanedName string, tr io.Reader) error { - path := filepath.Join(dest, cleanedName) + if cleanedName == ".." || strings.HasPrefix(cleanedName, "../") { + return fmt.Errorf("tar entry %q is not allowed: references parent directory", hdr.Name) + } + + path, err := securejoin.SecureJoin(dest, cleanedName) + if err != nil { + // During layer extraction, symlink chains may be incomplete, + // causing ELOOP. Fall back to the lexical path — the OS will + // encounter the same resolution failure if the path is used. + if !errors.Is(err, syscall.ELOOP) { + return fmt.Errorf("resolving path for %q: %w", hdr.Name, err) + } + path = filepath.Join(dest, cleanedName) + } base := filepath.Base(path) dir := filepath.Dir(path) mode := hdr.FileInfo().Mode() @@ -388,13 +412,30 @@ func ExtractFile(dest string, hdr *tar.Header, cleanedName string, tr io.Reader) return errors.Wrapf(err, "error removing %s to make way for new link", hdr.Name) } } - link := filepath.Clean(filepath.Join(dest, hdr.Linkname)) + cleanedLink := filepath.Clean(hdr.Linkname) + if cleanedLink == ".." || strings.HasPrefix(cleanedLink, "../") { + return fmt.Errorf("hardlink target %q is not allowed: references parent directory", hdr.Linkname) + } + link, err := securejoin.SecureJoin(dest, hdr.Linkname) + if err != nil { + return fmt.Errorf("invalid hardlink target %q: %w", hdr.Linkname, err) + } if err := os.Link(link, path); err != nil { return err } case tar.TypeSymlink: logrus.Tracef("Symlink from %s to %s", hdr.Linkname, path) + // Resolve the symlink target relative to the entry's parent directory + // to get the effective path from the extraction root, then verify it + // stays within the destination. + effectivePath := filepath.Clean(filepath.Join(filepath.Dir(cleanedName), hdr.Linkname)) + if filepath.IsAbs(hdr.Linkname) { + effectivePath = filepath.Clean(hdr.Linkname) + } + if effectivePath == ".." || strings.HasPrefix(effectivePath, "../") { + return fmt.Errorf("symlink target %q resolves outside destination", hdr.Linkname) + } // The base directory for a symlink may not exist before it is created. if err := os.MkdirAll(dir, 0o755); err != nil { return err
pkg/util/fs_util_test.go+256 −0 modified@@ -820,6 +820,262 @@ func TestExtractFile(t *testing.T) { } } +func TestExtractFile_PathTraversal(t *testing.T) { + defaultTestTime, err := time.Parse(time.RFC3339, "1912-06-23T00:00:00Z") + if err != nil { + t.Fatal(err) + } + + t.Run("regular file with dotdot", func(t *testing.T) { + dest := t.TempDir() + hdr := fileHeader("../outside.txt", "data", 0o644, defaultTestTime) + err := ExtractFile(dest, hdr, filepath.Clean(hdr.Name), bytes.NewReader([]byte("data"))) + if err == nil { + t.Fatal("expected error for parent directory reference, got nil") + } + if _, statErr := os.Stat(filepath.Join(dest, "..", "outside.txt")); statErr == nil { + t.Fatal("file was written outside dest") + } + }) + + t.Run("directory with dotdot", func(t *testing.T) { + dest := t.TempDir() + hdr := dirHeader("../outsidedir", 0o755) + err := ExtractFile(dest, hdr, filepath.Clean(hdr.Name), bytes.NewReader(nil)) + if err == nil { + t.Fatal("expected error for parent directory reference, got nil") + } + }) + + t.Run("nested dotdot", func(t *testing.T) { + dest := t.TempDir() + hdr := fileHeader("foo/../../outside.txt", "data", 0o644, defaultTestTime) + err := ExtractFile(dest, hdr, filepath.Clean(hdr.Name), bytes.NewReader([]byte("data"))) + if err == nil { + t.Fatal("expected error for parent directory reference, got nil") + } + }) + + t.Run("hardlink target outside dest", func(t *testing.T) { + dest := t.TempDir() + legitimateHdr := fileHeader("./legit.txt", "hello", 0o644, defaultTestTime) + if err := ExtractFile(dest, legitimateHdr, filepath.Clean(legitimateHdr.Name), bytes.NewReader([]byte("hello"))); err != nil { + t.Fatal(err) + } + hdr := hardlinkHeader("./link", "../somefile") + err := ExtractFile(dest, hdr, filepath.Clean(hdr.Name), bytes.NewReader(nil)) + if err == nil { + t.Fatal("expected error for hardlink target outside dest, got nil") + } + if !strings.Contains(err.Error(), "references parent directory") { + t.Fatalf("expected 'references parent directory' in error, got: %v", err) + } + }) + + t.Run("hardlink with absolute target is confined", func(t *testing.T) { + dest := t.TempDir() + // Create a file inside dest so there is something to link to after + // securejoin confines the absolute path. + legitimateHdr := fileHeader("etc/passwd", "confined", 0o644, defaultTestTime) + if err := ExtractFile(dest, legitimateHdr, filepath.Clean(legitimateHdr.Name), bytes.NewReader([]byte("confined"))); err != nil { + t.Fatal(err) + } + // Hardlink with absolute target — securejoin should confine it to dest. + hdr := hardlinkHeader("./link", "/etc/passwd") + err := ExtractFile(dest, hdr, filepath.Clean(hdr.Name), bytes.NewReader(nil)) + if err != nil { + t.Fatalf("hardlink with absolute target should be confined, not rejected: %v", err) + } + // The link must resolve inside dest, not to the real /etc/passwd. + got, err := os.ReadFile(filepath.Join(dest, "link")) + if err != nil { + t.Fatalf("reading link: %v", err) + } + if string(got) != "confined" { + t.Fatalf("link content = %q, want %q (should point inside dest)", got, "confined") + } + }) + + t.Run("file through symlink is confined", func(t *testing.T) { + dest := t.TempDir() + outsideDir := t.TempDir() + + // Create a symlink inside dest that points outside. + symlinkHdr := linkHeader("./extlink", outsideDir) + err := ExtractFile(dest, symlinkHdr, filepath.Clean(symlinkHdr.Name), bytes.NewReader(nil)) + if err != nil { + t.Fatalf("symlink creation should succeed (absolute target is allowed): %v", err) + } + + // Verify the symlink was actually created on disk. + target, err := os.Readlink(filepath.Join(dest, "extlink")) + if err != nil { + t.Fatalf("symlink was not created: %v", err) + } + if target != outsideDir { + t.Fatalf("symlink target = %q, want %q", target, outsideDir) + } + + // Try to write a file through the symlink. + writeHdr := fileHeader("./extlink/written.txt", "data", 0o644, defaultTestTime) + _ = ExtractFile(dest, writeHdr, filepath.Clean(writeHdr.Name), bytes.NewReader([]byte("data"))) + + // The file must not appear outside dest. + if _, statErr := os.Stat(filepath.Join(outsideDir, "written.txt")); statErr == nil { + t.Fatal("file was written outside dest through symlink") + } + }) + + t.Run("symlink target outside dest via relative path", func(t *testing.T) { + dest := t.TempDir() + hdr := linkHeader("./link", "../outside") + err := ExtractFile(dest, hdr, filepath.Clean(hdr.Name), bytes.NewReader(nil)) + if err == nil { + t.Fatal("expected error for symlink target outside dest, got nil") + } + if !strings.Contains(err.Error(), "resolves outside destination") { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("absolute symlink target resolves within dest", func(t *testing.T) { + dest := t.TempDir() + hdr := linkHeader("./link", "/subdir/target") + err := ExtractFile(dest, hdr, filepath.Clean(hdr.Name), bytes.NewReader(nil)) + if err != nil { + t.Fatalf("absolute symlink within dest should be allowed: %v", err) + } + }) + + t.Run("relative symlink within dest is allowed", func(t *testing.T) { + dest := t.TempDir() + os.MkdirAll(filepath.Join(dest, "foo"), 0o755) + hdr := linkHeader("./foo/link", "../bar") + err := ExtractFile(dest, hdr, filepath.Clean(hdr.Name), bytes.NewReader(nil)) + if err != nil { + t.Fatalf("symlink within dest should be allowed: %v", err) + } + }) + + t.Run("legitimate file succeeds", func(t *testing.T) { + dest := t.TempDir() + hdr := fileHeader("./subdir/file.txt", "content", 0o644, defaultTestTime) + err := ExtractFile(dest, hdr, filepath.Clean(hdr.Name), bytes.NewReader([]byte("content"))) + if err != nil { + t.Fatalf("legitimate extraction should succeed: %v", err) + } + got, err := os.ReadFile(filepath.Join(dest, "subdir", "file.txt")) + if err != nil { + t.Fatal(err) + } + if string(got) != "content" { + t.Fatalf("file contents = %q, want %q", got, "content") + } + }) +} + +func TestUnTar_PathTraversal(t *testing.T) { + makeTar := func(t *testing.T, hdrs ...tar.Header) *bytes.Buffer { + t.Helper() + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + for _, hdr := range hdrs { + h := hdr + if err := tw.WriteHeader(&h); err != nil { + t.Fatal(err) + } + if h.Size > 0 { + if _, err := tw.Write(make([]byte, h.Size)); err != nil { + t.Fatal(err) + } + } + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + return &buf + } + + t.Run("entry with dotdot is rejected", func(t *testing.T) { + buf := makeTar(t, tar.Header{ + Name: "../outside.txt", Size: 5, Mode: 0o644, Typeflag: tar.TypeReg, + }) + dest := t.TempDir() + if _, err := UnTar(buf, dest); err == nil { + t.Fatal("expected error for parent directory reference, got nil") + } + // Verify no file was written outside dest. + if _, err := os.Stat(filepath.Join(dest, "..", "outside.txt")); err == nil { + t.Fatal("file was written outside dest") + } + }) + + t.Run("symlink with dotdot target is rejected", func(t *testing.T) { + buf := makeTar(t, tar.Header{ + Name: "ext-link", Typeflag: tar.TypeSymlink, Linkname: "../../etc/passwd", + }) + dest := t.TempDir() + if _, err := UnTar(buf, dest); err == nil { + t.Fatal("expected error for symlink with dotdot target, got nil") + } + }) + + t.Run("hardlink with dotdot target is rejected", func(t *testing.T) { + buf := makeTar(t, + tar.Header{ + Name: "legit.txt", Size: 5, Mode: 0o644, Typeflag: tar.TypeReg, + Uid: os.Getuid(), Gid: os.Getgid(), + }, + tar.Header{ + Name: "link", Typeflag: tar.TypeLink, Linkname: "../etc/passwd", + }, + ) + dest := t.TempDir() + if _, err := UnTar(buf, dest); err == nil { + t.Fatal("expected error for hardlink with dotdot target, got nil") + } + }) + + t.Run("file through symlink is confined", func(t *testing.T) { + outsideDir := t.TempDir() + dest := t.TempDir() + + // Tar contains a symlink pointing to an absolute path outside dest, + // followed by a file written under that symlink name. + buf := makeTar(t, + tar.Header{ + Name: "extlink", Typeflag: tar.TypeSymlink, Linkname: outsideDir, + }, + tar.Header{ + Name: "extlink/written.txt", Size: 5, Mode: 0o644, Typeflag: tar.TypeReg, + Uid: os.Getuid(), Gid: os.Getgid(), + }, + ) + // Extraction may or may not return an error; either way the file + // must not appear outside dest. + UnTar(buf, dest) + + if _, err := os.Stat(filepath.Join(outsideDir, "written.txt")); err == nil { + t.Fatal("file was written outside dest through symlink") + } + }) + + t.Run("legitimate entries succeed", func(t *testing.T) { + buf := makeTar(t, tar.Header{ + Name: "subdir/file.txt", Size: 5, Mode: 0o644, Typeflag: tar.TypeReg, + Uid: os.Getuid(), Gid: os.Getgid(), + }) + dest := t.TempDir() + files, err := UnTar(buf, dest) + if err != nil { + t.Fatalf("legitimate extraction should succeed: %v", err) + } + if len(files) != 1 { + t.Fatalf("expected 1 file, got %d", len(files)) + } + }) +} + func TestCopySymlink(t *testing.T) { type tc struct { name string
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/advisories/GHSA-6rxq-q92g-4rmfghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-28406ghsaADVISORY
- github.com/chainguard-forks/kaniko/commit/a370e4b1f66e6e842b685c8f70ed507964c4b221ghsax_refsource_MISCWEB
- github.com/chainguard-forks/kaniko/pull/326ghsax_refsource_MISCWEB
- github.com/chainguard-forks/kaniko/releases/tag/v1.25.10ghsaWEB
- github.com/chainguard-forks/kaniko/security/advisories/GHSA-6rxq-q92g-4rmfghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.