Skip to content

crane export / mutate.Extract should reject cross-platform unsafe tar paths with Windows separators #2329

Description

@Federico1976

Description

mutate.Extract() and crane export currently preserve tar entries that use Windows-style backslash separators, including entries that would be interpreted as traversal paths by Windows-aware or cross-platform archive extractors.

For example, when mutate.Extract() / crane export is run on Linux, an image layer entry such as:

..\..\tmp\GCR_CRANE_EXPORT_WINDOWS_BACKSLASH

is preserved in the resulting flattened filesystem tar stream as:

..\\..\\tmp\\GCR_CRANE_EXPORT_WINDOWS_BACKSLASH

On Linux this is treated as a normal filename because \ is not a path separator. However, on Windows or in archive extraction code that normalizes backslashes as separators, the same entry may resolve as:

../../tmp/GCR_CRANE_EXPORT_WINDOWS_BACKSLASH

This can allow the exported tar stream to contain paths that are unsafe for downstream extraction workflows.

I understand that crane export and mutate.Extract() do not themselves extract files to the local filesystem, so this is not necessarily a direct vulnerability in go-containerregistry. I am opening this as a hardening / defense-in-depth issue to discuss whether archive-safe path validation should be applied before emitting flattened filesystem tar streams.

Why this matters

mutate.Extract() is documented as flattening an image filesystem into a single tar stream and being the underlying implementation of crane export.

Downstream users may reasonably treat the output of crane export / mutate.Extract() as a normalized flattened filesystem representation of an image. If that output is later extracted on Windows, or by a cross-platform extractor that treats backslashes as path separators, preserved Windows-style traversal entries can escape the intended extraction root.

This creates a cross-platform TarSlip risk in downstream workflows that consume crane export output.

Reproducer

The following local reproducer creates an image layer containing a Windows-style traversal path, then exports it through pkg/crane.Export().

package main

import (
	"archive/tar"
	"bytes"
	"compress/gzip"
	"io"
	"log"
	"os"

	cranelib "github.com/google/go-containerregistry/pkg/crane"
	"github.com/google/go-containerregistry/pkg/v1/empty"
	"github.com/google/go-containerregistry/pkg/v1/mutate"
	"github.com/google/go-containerregistry/pkg/v1/tarball"
)

func addFile(tw *tar.Writer, name, body string) error {
	h := &tar.Header{
		Name:     name,
		Typeflag: tar.TypeReg,
		Mode:     0644,
		Size:     int64(len(body)),
	}
	if err := tw.WriteHeader(h); err != nil {
		return err
	}
	_, err := tw.Write([]byte(body))
	return err
}

func makeLayer() ([]byte, error) {
	var raw bytes.Buffer
	tw := tar.NewWriter(&raw)

	if err := addFile(tw, `..\..\tmp\GCR_CRANE_EXPORT_WINDOWS_BACKSLASH`, "WINDOWS_BACKSLASH_TARSLIP\n"); err != nil {
		return nil, err
	}
	if err := addFile(tw, `safe\ok.txt`, "OK\n"); err != nil {
		return nil, err
	}

	if err := tw.Close(); err != nil {
		return nil, err
	}

	var gz bytes.Buffer
	zw := gzip.NewWriter(&gz)
	if _, err := zw.Write(raw.Bytes()); err != nil {
		return nil, err
	}
	if err := zw.Close(); err != nil {
		return nil, err
	}
	return gz.Bytes(), nil
}

func main() {
	layerBytes, err := makeLayer()
	if err != nil {
		log.Fatal(err)
	}

	layer, err := tarball.LayerFromOpener(func() (io.ReadCloser, error) {
		return io.NopCloser(bytes.NewReader(layerBytes)), nil
	})
	if err != nil {
		log.Fatal(err)
	}

	img, err := mutate.AppendLayers(empty.Image, layer)
	if err != nil {
		log.Fatal(err)
	}

	out, err := os.Create("/tmp/gcr_crane_export_windows_backslash.tar")
	if err != nil {
		log.Fatal(err)
	}
	defer out.Close()

	if err := cranelib.Export(img, out); err != nil {
		log.Fatal(err)
	}

	log.Println("wrote /tmp/gcr_crane_export_windows_backslash.tar via pkg/crane.Export")
}

Run:

go run /tmp/gcr_crane_export_windows_backslash_poc.go
tar -tvf /tmp/gcr_crane_export_windows_backslash.tar

Observed:

-rw-r--r-- 0/0 26 1970-01-01 01:00 ..\\..\\tmp\\GCR_CRANE_EXPORT_WINDOWS_BACKSLASH
-rw-r--r-- 0/0  3 1970-01-01 01:00 safe\\ok.txt

Expected behavior

It would be safer if mutate.Extract() / crane export rejected or normalized archive paths that are unsafe across platforms before emitting them into the flattened filesystem tar stream.

At minimum, validation could treat both / and \ as separators for safety checks and reject:

  • absolute paths
  • paths equal to .. or starting with ../ after normalization
  • Windows drive paths such as C:\foo or C:/foo
  • drive-relative paths such as C:..\foo
  • unsafe hardlink or symlink link targets, where applicable

Possible remediation

Use a platform-independent archive path validator before writing entries to the extracted/exported/flattened tar stream.

For example:

func unsafeArchivePath(p string) bool {
	p = strings.ReplaceAll(p, "\\", "/")
	clean := path.Clean(p)

	if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") {
		return true
	}
	if path.IsAbs(clean) {
		return true
	}
	if len(clean) >= 2 &&
		((clean[0] >= 'A' && clean[0] <= 'Z') || (clean[0] >= 'a' && clean[0] <= 'z')) &&
		clean[1] == ':' {
		return true
	}
	return false
}

Alternatively, the project could document clearly that mutate.Extract() / crane export output is not guaranteed to be safe for direct extraction by downstream tools without additional path validation.

Context

This was originally reported through Google VRP and was not considered severe enough to track as a security vulnerability because crane export and mutate.Extract() do not extract files to the local filesystem themselves. I am opening this public issue as a regular hardening / defense-in-depth request.

go_containerregistry_windows_tarslip_report_evidence.txt

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