CVE-2026-50567
Description
Fission versions prior to 1.25.0 are vulnerable to Zip Slip, allowing attackers to write files anywhere on the system.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Fission versions prior to 1.25.0 are vulnerable to Zip Slip, allowing attackers to write files anywhere on the system.
Vulnerability
Fission versions prior to 1.25.0 contain a Zip Slip vulnerability in the Unarchive function within pkg/utils/zip.go. This function joins archive entry names with the destination directory using filepath.Join without validating that the resolved path remains within the destination. This allows a crafted zip entry, such as ../../tmp/evil, to be written to /tmp/evil [2].
Exploitation
An attacker can exploit this vulnerability by controlling a Package.Spec.Source.URL or Deployment.URL that points to a malicious zip archive. The fission-fetcher sidecar, running as a per-environment pod, will download and extract this archive. The attacker needs to be an authenticated Kubernetes user with Package CRD create permission. The KeepArchive setting must be false, which is the default [2].
Impact
Successful exploitation allows an attacker to write files anywhere the fission-fetcher process has write access. This could include other tenants' /packages// directories, mounted secret or config volumes, or even the fetcher's own binary. This could lead to arbitrary file writes and potentially compromise the Kubernetes cluster [2].
Mitigation
This vulnerability has been fixed in Fission version 1.25.0, released on 2024-01-17 [1]. The fix involves confining zip extraction using os.Root, validating archive entry names to reject absolute paths and .. traversal, and refusing symlink entries upfront [3].
AI Insight generated on Jun 10, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
2Patches
155704aca1b8dfix(utils): confine zip extraction to the destination with os.Root (zip-slip) (#3444)
2 files changed · +142 −11
pkg/utils/zip.go+44 −11 modified@@ -10,6 +10,7 @@ import ( "io" "os" "path/filepath" + "strings" "github.com/mholt/archives" ) @@ -74,7 +75,13 @@ func Archive(ctx context.Context, src string, dst string) error { return err } -// Unarchive is a function that unzips a zip file to destination +// Unarchive unzips the zip file at src into dst. +// +// Extraction is confined to dst through an os.Root: the archive entry name +// arrives from a user-supplied package, so a crafted name (e.g. "../../etc/x" +// or an absolute path) must not write outside dst. os.Root enforces that in the +// kernel; we also reject escaping names and symlink entries up front for a +// clear error (zip-slip / CWE-22). func Unarchive(ctx context.Context, src string, dst string) error { var format archives.Zip file, err := os.Open(src) @@ -83,20 +90,46 @@ func Unarchive(ctx context.Context, src string, dst string) error { } defer file.Close() + // The destination must exist before we can open a root on it. Mode 0o755 is + // required so that other containers in the same pod (running under + // different UIDs / GIDs — fetcher sidecar at UID 10001 vs. builder + // running as root) can read this shared /packages volume. Tighter + // modes break cross-container access for the v2 builder flow. + if err := os.MkdirAll(dst, 0o755); err != nil { + return fmt.Errorf("failed to create destination directory: %w", err) + } + root, err := os.OpenRoot(dst) + if err != nil { + return fmt.Errorf("failed to open destination root: %w", err) + } + defer root.Close() + return format.Extract(ctx, file, func(ctx context.Context, f archives.FileInfo) error { - destPath := filepath.Join(dst, f.NameInArchive) + // Confine the archive entry to dst. + name := filepath.Clean(filepath.FromSlash(f.NameInArchive)) + if name == "." { + return nil // archive root; dst already exists + } + if filepath.IsAbs(name) || name == ".." || strings.HasPrefix(name, ".."+string(os.PathSeparator)) { + return fmt.Errorf("archive entry %q escapes destination", f.NameInArchive) + } + // Refuse symlink entries: function packages have no need for them, and + // they are an avenue for escaping the extraction root. + if f.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("archive entry %q is a symlink; refusing to extract", f.NameInArchive) + } + // check if the file is a directory if f.IsDir() { - return os.MkdirAll(destPath, f.Mode()) + return root.MkdirAll(name, f.Mode().Perm()) } - // check if parent directory exists for the file. Mode 0o755 is - // required so that other containers in the same pod (running under - // different UIDs / GIDs — fetcher sidecar at UID 10001 vs. builder - // running as root) can read this shared /packages volume. Tighter - // modes break cross-container access for the v2 builder flow. - if err := os.MkdirAll(filepath.Dir(destPath), os.ModeDir|0o755); err != nil { - return fmt.Errorf("failed to create parent directory: %w", err) + // Create the parent directory at 0o755 for the same cross-container + // access reason as the destination root above. + if dir := filepath.Dir(name); dir != "." { + if err := root.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("failed to create parent directory: %w", err) + } } // Open file in archive @@ -109,7 +142,7 @@ func Unarchive(ctx context.Context, src string, dst string) error { // Create file in destination with the archive entry's mode applied // at create time, so a concurrent observer never sees a wider mode. // Same overwrite semantics as os.Create. - destFile, err := os.OpenFile(destPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, f.Mode().Perm()) + destFile, err := root.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_TRUNC, f.Mode().Perm()) if err != nil { return fmt.Errorf("failed to create file in destination: %w", err) }
pkg/utils/zip_test.go+98 −0 modified@@ -5,13 +5,111 @@ package utils import ( + "archive/zip" "os" "path/filepath" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +type rawZipEntry struct { + name string + mode os.FileMode + body string +} + +// writeRawZip writes a zip whose entry names and modes are set verbatim, +// bypassing any archive-time sanitization so that malicious names (the kind an +// attacker-supplied package can contain) reach the extractor unchanged. +func writeRawZip(t *testing.T, path string, entries ...rawZipEntry) { + t.Helper() + f, err := os.Create(path) + require.NoError(t, err) + defer f.Close() + zw := zip.NewWriter(f) + for _, e := range entries { + hdr := &zip.FileHeader{Name: e.name, Method: zip.Deflate} + hdr.SetMode(e.mode) + w, err := zw.CreateHeader(hdr) + require.NoError(t, err) + _, err = w.Write([]byte(e.body)) + require.NoError(t, err) + } + require.NoError(t, zw.Close()) +} + +// TestUnarchiveZipSlip verifies that a malicious archive entry cannot write +// outside the destination directory (zip-slip / CWE-22) and that symlink +// entries are refused, while benign archives still extract. +func TestUnarchiveZipSlip(t *testing.T) { + t.Parallel() + ctx := t.Context() + + t.Run("parent traversal is refused", func(t *testing.T) { + t.Parallel() + tmp := t.TempDir() + sentinel := filepath.Join(tmp, "sentinel") + require.NoError(t, os.WriteFile(sentinel, []byte("intact"), 0o600)) + + zipPath := filepath.Join(tmp, "evil.zip") + writeRawZip(t, zipPath, rawZipEntry{name: "../escape.txt", mode: 0o644, body: "pwned"}) + + err := Unarchive(ctx, zipPath, filepath.Join(tmp, "dst")) + assert.Error(t, err) + assert.NoFileExists(t, filepath.Join(tmp, "escape.txt")) + + got, err := os.ReadFile(sentinel) + require.NoError(t, err) + assert.Equal(t, "intact", string(got)) + }) + + t.Run("absolute path is refused", func(t *testing.T) { + t.Parallel() + tmp := t.TempDir() + abs := filepath.Join(tmp, "abs-escape.txt") + zipPath := filepath.Join(tmp, "evil.zip") + writeRawZip(t, zipPath, rawZipEntry{name: abs, mode: 0o644, body: "pwned"}) + + err := Unarchive(ctx, zipPath, filepath.Join(tmp, "dst")) + assert.Error(t, err) + assert.NoFileExists(t, abs) + }) + + t.Run("symlink entry is refused", func(t *testing.T) { + t.Parallel() + tmp := t.TempDir() + zipPath := filepath.Join(tmp, "evil.zip") + writeRawZip(t, zipPath, rawZipEntry{name: "link", mode: 0o777 | os.ModeSymlink, body: "/etc/passwd"}) + + dst := filepath.Join(tmp, "dst") + err := Unarchive(ctx, zipPath, dst) + assert.Error(t, err) + _, lerr := os.Lstat(filepath.Join(dst, "link")) + assert.True(t, os.IsNotExist(lerr), "no symlink should be created") + }) + + t.Run("benign archive still extracts", func(t *testing.T) { + t.Parallel() + tmp := t.TempDir() + zipPath := filepath.Join(tmp, "ok.zip") + writeRawZip(t, zipPath, + rawZipEntry{name: "a.txt", mode: 0o644, body: "alpha"}, + rawZipEntry{name: "sub/b.txt", mode: 0o644, body: "beta"}, + ) + dst := filepath.Join(tmp, "dst") + require.NoError(t, Unarchive(ctx, zipPath, dst)) + + got, err := os.ReadFile(filepath.Join(dst, "a.txt")) + require.NoError(t, err) + assert.Equal(t, "alpha", string(got)) + got, err = os.ReadFile(filepath.Join(dst, "sub", "b.txt")) + require.NoError(t, err) + assert.Equal(t, "beta", string(got)) + }) +} + func TestIsZip(t *testing.T) { tests := []struct { name string
Vulnerability mechanics
Root cause
"The Unarchive function in pkg/utils/zip.go did not validate archive entry paths, allowing directory traversal."
Attack vector
An attacker with Package CRD create permission can create a Package with a Source.URL or Deployment.URL pointing to a malicious zip archive [ref_id=1]. When the fission-fetcher sidecar downloads and extracts this archive, it will write files outside the intended destination directory due to a lack of path validation [ref_id=1]. This can lead to overwriting sensitive files, other tenants' data, or even the fetcher's binary.
Affected code
The vulnerability resides in the Unarchive function located in pkg/utils/zip.go. Specifically, the code joins archive entry names with the destination directory using filepath.Join without verifying if the resolved path remains within the destination [ref_id=1].
What the fix does
The patch introduces path validation within the Unarchive function. It now opens an os.Root on the destination, rejects absolute paths and parent directory traversal entries (e.g., '../../'), and refuses symlink entries upfront [patch_id=5504362]. By confining all file operations to the destination directory at the kernel level, the fix prevents archive entries from being written outside the intended scope.
Preconditions
- authAuthenticated Kubernetes user with Package CRD create permission.
- inputUser creates a Package with Source.URL or Deployment.URL pointing to an attacker-hosted zip archive.
- configKeepArchive set to false (default).
Generated on Jun 10, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
3News mentions
1- Fission Kubernetes Serverless Framework: 17 Vulnerabilities Disclosed TogetherVypr Intelligence · Jun 10, 2026