Skip to content

Commit c463c65

Browse files
authored
fix: prevent hardlink path traversal via process CWD (GHSA-fxhp-mv3v-67qp) (#1232)
## Summary Backport of the `main`-branch fix (b11f777) to the **v2** release line for **GHSA-fxhp-mv3v-67qp / CVE-2026-50163**. `ensureLinkPath` validated `TypeLink` (hardlink) targets by resolving relative paths against the link file's directory, but returned the *original, unresolved* target string. The caller passed this to `os.Link`, which resolves relative paths against the process **CWD** via `link(2)` — not the link file's directory. A crafted OCI artifact could exploit this to hardlink a CWD file into the extract tree, creating a shared inode with an arbitrary file outside the extraction directory. The fix landed on `main` (now the v3 module) but was never carried to the v2 line, so v2.6.1 and earlier remain vulnerable. This PR closes that gap. ## Fix Explicitly resolve relative hardlink targets against `filepath.Dir` of the link file before calling `os.Link`. Symlink handling is unaffected since `os.Symlink` stores the path verbatim and resolves at access time. ## Test Adds a regression test that sets the process CWD to a directory containing a sentinel file and verifies that a tar `TypeLink` entry with a relative `Linkname` cannot hardlink that file into the extract tree. The test fails without the fix ("hardlink escaped extract dir") and passes with it. Suggested release: **v2.6.2**. Signed-off-by: Terry Howe <terrylhowe@gmail.com>
1 parent 91c04ab commit c463c65

2 files changed

Lines changed: 60 additions & 0 deletions

File tree

content/file/utils.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,11 @@ func extractTarDirectory(dirPath, dirName string, r io.Reader, buf []byte, prese
188188
// This is a known limitation and will not be addressed.
189189
var target string
190190
if target, err = ensureLinkPath(dirPath, dirName, filePath, header.Linkname); err == nil {
191+
if !filepath.IsAbs(target) {
192+
// link(2) resolves relative paths against the process CWD, not
193+
// the link file's directory. Resolve explicitly to prevent escape.
194+
target = filepath.Join(filepath.Dir(filePath), target)
195+
}
191196
err = os.Link(target, filePath)
192197
}
193198
case tar.TypeSymlink:

content/file/utils_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,61 @@ func Test_extractTarDirectory_HardLink(t *testing.T) {
376376
t.Error("extractTarDirectory() error = nil, wantErr = true")
377377
}
378378
})
379+
380+
t.Run("hard link with relative linkname must not escape extract dir via process CWD", func(t *testing.T) {
381+
// GHSA-fxhp-mv3v-67qp: a tarball TypeLink entry with a relative Linkname
382+
// was passed verbatim to os.Link, which resolves relative paths against the
383+
// process CWD rather than the link file's directory. An attacker-controlled
384+
// registry could use this to hardlink a CWD file into the extract tree.
385+
cwdDir := t.TempDir()
386+
sentinelPath := filepath.Join(cwdDir, "sentinel.txt")
387+
if err := os.WriteFile(sentinelPath, []byte("secret"), 0600); err != nil {
388+
t.Fatal(err)
389+
}
390+
391+
origDir, err := os.Getwd()
392+
if err != nil {
393+
t.Fatal(err)
394+
}
395+
if err := os.Chdir(cwdDir); err != nil {
396+
t.Fatal(err)
397+
}
398+
defer os.Chdir(origDir) //nolint:errcheck
399+
400+
extractDir := t.TempDir()
401+
dirName := "base"
402+
dirPath := filepath.Join(extractDir, dirName)
403+
buf := make([]byte, 1024)
404+
405+
// The relative Linkname "sentinel.txt" would resolve against the process
406+
// CWD (cwdDir) via link(2) if not explicitly resolved first.
407+
tarData := createTar(t, []tarEntry{
408+
{name: "base/", mode: os.ModeDir | 0777},
409+
{name: "base/evil_link", linkname: "sentinel.txt", mode: 0666, isHardLink: true},
410+
})
411+
412+
err = extractTarDirectory(dirPath, dirName, bytes.NewReader(tarData), buf, false)
413+
if err != nil {
414+
// Expected: target resolves to <extractDir>/base/sentinel.txt which
415+
// doesn't exist, so os.Link returns an error. No escape occurred.
416+
return
417+
}
418+
419+
// If extraction succeeded, verify the hardlink does not share an inode
420+
// with the CWD sentinel file (i.e., it did not escape the extract dir).
421+
evilLinkPath := filepath.Join(dirPath, "evil_link")
422+
evilInfo, statErr := os.Lstat(evilLinkPath)
423+
if statErr != nil {
424+
return // link was not created; no escape
425+
}
426+
sentinelInfo, statErr := os.Lstat(sentinelPath)
427+
if statErr != nil {
428+
t.Fatal(statErr)
429+
}
430+
if os.SameFile(evilInfo, sentinelInfo) {
431+
t.Error("hardlink escaped extract dir: evil_link shares inode with CWD sentinel file")
432+
}
433+
})
379434
}
380435

381436
type tarEntry struct {

0 commit comments

Comments
 (0)