melange QEMU runner could write files outside workspace directory
Description
melange allows users to build apk packages using declarative pipelines. In version 0.11.3 to before 0.40.3, an attacker who can influence the tar stream from a QEMU guest VM could write files outside the intended workspace directory on the host. The retrieveWorkspace function extracts tar entries without validating that paths stay within the workspace, allowing path traversal via ../ sequences. This issue has been patched in version 0.40.3.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Melange's retrieveWorkspace function lacks path validation, allowing a malicious QEMU guest to write files outside the workspace directory via tar traversal.
The vulnerability resides in the retrieveWorkspace function of melange, a tool for building APK packages with declarative pipelines [1]. This function extracts tar streams from a QEMU guest VM without validating that tar entry paths remain within the intended workspace directory, enabling path traversal attacks using ../ sequences [2][4].
An attacker who can influence the tar stream from a QEMU guest VM—for example, by controlling the build process—can craft a malicious tar archive that writes files to arbitrary locations on the host filesystem. No authentication is required beyond the ability to execute a melange build with a QEMU runner [1][4].
The impact is arbitrary file write on the host, which could lead to code execution or system compromise. The issue affects melange versions 0.11.3 through 0.40.3 [2][4].
A fix was committed in 6e243d0 and released in version 0.40.3 [3][4]. Users should update to the latest patched version immediately.
AI Insight generated on May 19, 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 |
|---|---|---|
chainguard.dev/melangeGo | >= 0.11.3, < 0.40.3 | 0.40.3 |
Affected products
2- Range: >=0.11.3 <0.40.3
- chainguard-dev/melangev5Range: >= 0.11.3, < 0.40.3
Patches
16e243d0d4669Merge commit from fork
2 files changed · +377 −0
pkg/build/build.go+53 −0 modified@@ -1080,6 +1080,41 @@ func (b *Build) workspaceConfig(ctx context.Context) *container.Config { return b.containerConfig } +// isValidPath validates that a tar entry path doesn't escape the workspace directory. +// This prevents path traversal attacks via malicious tar archives. +// Based on validation logic from github.com/chainguard-dev/malcontent/pkg/archive +func isValidPath(targetPath, baseDir string) error { + // Check for null bytes + if strings.Contains(targetPath, "\x00") || strings.Contains(baseDir, "\x00") { + return fmt.Errorf("path contains null byte") + } + + // Clean and normalize paths + cleanTarget := filepath.Clean(targetPath) + cleanBase := filepath.Clean(baseDir) + + // Reject absolute paths + if filepath.IsAbs(cleanTarget) { + return fmt.Errorf("absolute paths not allowed: %s", targetPath) + } + + // Build the full target path + fullTarget := filepath.Join(cleanBase, cleanTarget) + + // Check that the path is within the base directory + relPath, err := filepath.Rel(cleanBase, fullTarget) + if err != nil { + return fmt.Errorf("invalid path: %w", err) + } + + // Reject any path that tries to escape via ../ + if strings.HasPrefix(relPath, ".."+string(filepath.Separator)) || relPath == ".." { + return fmt.Errorf("path traversal detected: %s", targetPath) + } + + return nil +} + // retrieveWorkspace retrieves the workspace from the container and unpacks it // to the workspace directory. The workspace retrieved from the runner is in a // tar stream containing the workspace contents rooted at ./melange-out @@ -1116,6 +1151,12 @@ func (b *Build) retrieveWorkspace(ctx context.Context, fs apkofs.FullFS) error { // Remove the leading "./" from LICENSE files in QEMU workspaces hdr.Name = strings.TrimPrefix(hdr.Name, "./") + // Validate the tar entry name to prevent path traversal attacks (CVE-PENDING: GHSA-qxx2-7h4c-83f4) + // This validation applies to ALL entry types: directories, regular files, symlinks, and hardlinks + if err := isValidPath(hdr.Name, b.WorkspaceDir); err != nil { + return fmt.Errorf("invalid tar entry path %q: %w", hdr.Name, err) + } + var uid, gid int fi := hdr.FileInfo() if stat, ok := fi.Sys().(*tar.Header); ok { @@ -1164,6 +1205,12 @@ func (b *Build) retrieveWorkspace(ctx context.Context, fs apkofs.FullFS) error { } case tar.TypeSymlink: + // Validate symlink target to prevent symlink attacks (CVE-PENDING: GHSA-qxx2-7h4c-83f4) + // Note: hdr.Name was already validated above; this validates the symlink destination + if err := isValidPath(hdr.Linkname, b.WorkspaceDir); err != nil { + return fmt.Errorf("invalid symlink target %q -> %q: %w", hdr.Name, hdr.Linkname, err) + } + if target, err := fs.Readlink(hdr.Name); err == nil && target == hdr.Linkname { continue } @@ -1173,6 +1220,12 @@ func (b *Build) retrieveWorkspace(ctx context.Context, fs apkofs.FullFS) error { } case tar.TypeLink: + // Validate hardlink target to prevent link attacks (CVE-PENDING: GHSA-qxx2-7h4c-83f4) + // Note: hdr.Name was already validated above; this validates the hardlink destination + if err := isValidPath(hdr.Linkname, b.WorkspaceDir); err != nil { + return fmt.Errorf("invalid hardlink target %q -> %q: %w", hdr.Name, hdr.Linkname, err) + } + if err := fs.Link(hdr.Linkname, hdr.Name); err != nil { return err }
pkg/build/path_validation_test.go+324 −0 added@@ -0,0 +1,324 @@ +// Copyright 2025 Chainguard, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package build + +import ( + "testing" +) + +// TestIsValidPath validates the path traversal protection added for GHSA-qxx2-7h4c-83f4 +func TestIsValidPath(t *testing.T) { + baseDir := "/workspace" + + tests := []struct { + name string + path string + wantError bool + errMsg string + }{ + { + name: "valid relative path", + path: "melange-out/package/file.txt", + wantError: false, + }, + { + name: "valid nested path", + path: "melange-out/package/subdir/file.txt", + wantError: false, + }, + { + name: "valid single file", + path: "file.txt", + wantError: false, + }, + { + name: "path traversal with ../", + path: "../../etc/passwd", + wantError: true, + errMsg: "path traversal detected", + }, + { + name: "path traversal in middle", + path: "melange-out/../../etc/passwd", + wantError: true, + errMsg: "path traversal detected", + }, + { + name: "absolute path", + path: "/etc/passwd", + wantError: true, + errMsg: "absolute paths not allowed", + }, + { + name: "absolute path to tmp", + path: "/tmp/malicious", + wantError: true, + errMsg: "absolute paths not allowed", + }, + { + name: "null byte injection", + path: "file\x00.txt", + wantError: true, + errMsg: "null byte", + }, + { + name: "just ..", + path: "..", + wantError: true, + errMsg: "path traversal detected", + }, + { + name: "multiple ../", + path: "../../../../../../../etc/passwd", + wantError: true, + errMsg: "path traversal detected", + }, + { + name: "path with ./ prefix (should be cleaned)", + path: "./melange-out/file.txt", + wantError: false, + }, + { + name: "tricky traversal with unicode", + path: "melange-out/../../../etc/passwd", + wantError: true, + errMsg: "path traversal detected", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := isValidPath(tt.path, baseDir) + if tt.wantError { + if err == nil { + t.Errorf("isValidPath(%q, %q) expected error containing %q, got nil", tt.path, baseDir, tt.errMsg) + } else if tt.errMsg != "" && !contains(err.Error(), tt.errMsg) { + t.Errorf("isValidPath(%q, %q) error = %v, want error containing %q", tt.path, baseDir, err, tt.errMsg) + } + } else { + if err != nil { + t.Errorf("isValidPath(%q, %q) unexpected error: %v", tt.path, baseDir, err) + } + } + }) + } +} + +// TestIsValidPath_SymlinkTargets tests validation of symlink targets +func TestIsValidPath_SymlinkTargets(t *testing.T) { + baseDir := "/workspace" + + tests := []struct { + name string + linkTarget string + wantError bool + errMsg string + }{ + { + name: "valid relative symlink", + linkTarget: "melange-out/other-file", + wantError: false, + }, + { + name: "symlink traversal attack", + linkTarget: "../../../etc/passwd", + wantError: true, + errMsg: "path traversal detected", + }, + { + name: "absolute symlink target", + linkTarget: "/etc/passwd", + wantError: true, + errMsg: "absolute paths not allowed", + }, + { + name: "symlink to /syft (real attack vector)", + linkTarget: "../../../../syft", + wantError: true, + errMsg: "path traversal detected", + }, + { + name: "symlink to /usr/bin/curl (real attack vector)", + linkTarget: "../../../../usr/bin/curl", + wantError: true, + errMsg: "path traversal detected", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := isValidPath(tt.linkTarget, baseDir) + if tt.wantError { + if err == nil { + t.Errorf("isValidPath(%q, %q) for symlink target expected error, got nil", tt.linkTarget, baseDir) + } else if tt.errMsg != "" && !contains(err.Error(), tt.errMsg) { + t.Errorf("isValidPath(%q, %q) error = %v, want error containing %q", tt.linkTarget, baseDir, err, tt.errMsg) + } + } else { + if err != nil { + t.Errorf("isValidPath(%q, %q) for symlink target unexpected error: %v", tt.linkTarget, baseDir, err) + } + } + }) + } +} + +// TestIsValidPath_RealWorldAttacks tests actual attack vectors from GHSA-qxx2-7h4c-83f4 +func TestIsValidPath_RealWorldAttacks(t *testing.T) { + baseDir := "/workspace" + + attacks := []struct { + name string + path string + target string // for symlinks + }{ + { + name: "overwrite /syft binary", + path: "../../../../syft", + }, + { + name: "overwrite /usr/bin/curl", + path: "../../../../usr/bin/curl", + }, + { + name: "overwrite /usr/bin/mal", + path: "../../../../usr/bin/mal", + }, + { + name: "overwrite /ko-app/entrypoint", + path: "../../../../ko-app/entrypoint", + }, + { + name: "overwrite /root/.bashrc", + path: "../../../../root/.bashrc", + }, + { + name: "write to /tmp", + path: "../../../../tmp/backdoor.sh", + }, + { + name: "write to /etc", + path: "../../../../etc/backdoor", + }, + { + name: "symlink to escape workspace", + path: "innocent-file", + target: "../../../etc/passwd", + }, + } + + for _, attack := range attacks { + t.Run(attack.name, func(t *testing.T) { + if attack.target != "" { + // For symlink attacks, the path itself may be innocent but target is malicious + err := isValidPath(attack.target, baseDir) + if err == nil { + t.Errorf("isValidPath(%q) should block symlink target for attack %q, but returned nil", attack.target, attack.name) + } + } else { + // For direct path attacks, test the path validation + err := isValidPath(attack.path, baseDir) + if err == nil { + t.Errorf("isValidPath(%q) should block attack vector %q, but returned nil", attack.path, attack.name) + } + } + }) + } +} + +// TestIsValidPath_EdgeCases tests edge cases and boundary conditions +func TestIsValidPath_EdgeCases(t *testing.T) { + tests := []struct { + name string + path string + baseDir string + wantError bool + }{ + { + name: "empty path", + path: "", + baseDir: "/workspace", + wantError: false, // Empty path is cleaned to "." which is valid + }, + { + name: "just dot", + path: ".", + baseDir: "/workspace", + wantError: false, + }, + { + name: "multiple slashes", + path: "melange-out///file.txt", + baseDir: "/workspace", + wantError: false, // Cleaned by filepath.Clean + }, + { + name: "Windows-style path with backslashes", + path: "melange-out\\..\\..\\file.txt", + baseDir: "/workspace", + wantError: false, // On Unix, backslash is valid filename char + }, + { + name: "trailing slash", + path: "melange-out/", + baseDir: "/workspace", + wantError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := isValidPath(tt.path, tt.baseDir) + if tt.wantError && err == nil { + t.Errorf("isValidPath(%q, %q) expected error, got nil", tt.path, tt.baseDir) + } else if !tt.wantError && err != nil { + t.Errorf("isValidPath(%q, %q) unexpected error: %v", tt.path, tt.baseDir, err) + } + }) + } +} + +// Helper function to check if a string contains a substring +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(substr) == 0 || + (len(s) > 0 && len(substr) > 0 && findSubstring(s, substr))) +} + +func findSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} + +// Benchmark the validation function +func BenchmarkIsValidPath(b *testing.B) { + baseDir := "/workspace" + testPaths := []string{ + "melange-out/package/file.txt", + "../../etc/passwd", + "/absolute/path", + "./relative/path", + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + for _, path := range testPaths { + _ = isValidPath(path, baseDir) + } + } +}
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4- github.com/advisories/GHSA-qxx2-7h4c-83f4ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-24843ghsaADVISORY
- github.com/chainguard-dev/melange/commit/6e243d0d46699f837d7c392397a694d2bcc7612bghsax_refsource_MISCWEB
- github.com/chainguard-dev/melange/security/advisories/GHSA-qxx2-7h4c-83f4ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.