imagepull: reject symlink ancestors during OCI flatten
safeJoin previously did textual cleaning + dest-prefix check only. That's enough to catch `../escape`, but not the symlink-ancestor attack: a malicious OCI layer plants `etc -> /tmp/probe`, a later layer writes/deletes/hardlinks against `etc/anything`, and the kernel silently dereferences the symlink so the operation lands at `/tmp/probe/anything` on the host. The daemon runs flatten as the owner UID, so anywhere that UID can write becomes a write target; anywhere it can delete (e.g. its own home) becomes a delete target. Whiteouts and hardlinks make this worse — a whiteout for `etc/.wh.victim` would `RemoveAll` the host file `/tmp/probe/victim`, and a TypeLink would expose host files inside the extracted rootfs. safeJoin now Lstat-walks every intermediate component of the joined path against the already-extracted tree, refusing if any ancestor is a symlink. Walking is race-free against the extraction loop because we process tar entries serially. Leaf components stay caller-owned (TypeSymlink writes legitimately want a symlink leaf; TypeReg RemoveAll's any prior leaf before opening; etc.). Three new tests pin the protection: write through a symlinked ancestor, whiteout through a symlinked ancestor, and hardlink target through a symlinked ancestor — each must fail and leave the host probe path untouched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8bfa525568
commit
0a079277ef
2 changed files with 143 additions and 1 deletions
|
|
@ -327,6 +327,103 @@ func TestFlattenRejectsRelativeSymlinkEscape(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestFlattenRejectsWriteThroughSymlinkAncestor exercises the OCI
|
||||
// extraction-escape attack: layer 1 plants `etc -> /tmp` (a directory
|
||||
// the daemon can write to), layer 2 writes `etc/probe`. Without the
|
||||
// ancestor walk in safeJoin the write would land at /tmp/probe on the
|
||||
// host. With it, the second layer's write is refused.
|
||||
func TestFlattenRejectsWriteThroughSymlinkAncestor(t *testing.T) {
|
||||
host := startRegistry(t)
|
||||
probeDir := t.TempDir() // a path the daemon user can write to
|
||||
ref := pushImage(t, host, "banger/test", "sym-ancestor",
|
||||
makeLayer(t, []tarMember{
|
||||
{name: "etc", symlink: true, link: probeDir},
|
||||
}),
|
||||
makeLayer(t, []tarMember{
|
||||
{name: "etc/probe", body: []byte("escaped")},
|
||||
}),
|
||||
)
|
||||
pulled, err := Pull(context.Background(), ref, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("Pull: %v", err)
|
||||
}
|
||||
dest := t.TempDir()
|
||||
_, err = Flatten(context.Background(), pulled, dest)
|
||||
if err == nil || !strings.Contains(err.Error(), "symlink") {
|
||||
t.Fatalf("Flatten: err=%v, want symlink-ancestor rejection", err)
|
||||
}
|
||||
// The escape file must NOT have been written outside dest.
|
||||
if _, statErr := os.Stat(filepath.Join(probeDir, "probe")); !errors.Is(statErr, os.ErrNotExist) {
|
||||
t.Fatalf("escape file at %s should not exist; got %v", filepath.Join(probeDir, "probe"), statErr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlattenRejectsWhiteoutThroughSymlinkAncestor pins the same
|
||||
// guarantee for the whiteout path: a symlinked ancestor must not let
|
||||
// the extractor RemoveAll on a host file outside dest.
|
||||
func TestFlattenRejectsWhiteoutThroughSymlinkAncestor(t *testing.T) {
|
||||
host := startRegistry(t)
|
||||
probeDir := t.TempDir()
|
||||
probeFile := filepath.Join(probeDir, "victim")
|
||||
if err := os.WriteFile(probeFile, []byte("preserved"), 0o644); err != nil {
|
||||
t.Fatalf("write probe: %v", err)
|
||||
}
|
||||
ref := pushImage(t, host, "banger/test", "wh-sym-ancestor",
|
||||
makeLayer(t, []tarMember{
|
||||
{name: "etc", symlink: true, link: probeDir},
|
||||
}),
|
||||
makeLayer(t, []tarMember{
|
||||
{name: "etc/.wh.victim"},
|
||||
}),
|
||||
)
|
||||
pulled, err := Pull(context.Background(), ref, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("Pull: %v", err)
|
||||
}
|
||||
dest := t.TempDir()
|
||||
_, err = Flatten(context.Background(), pulled, dest)
|
||||
if err == nil || !strings.Contains(err.Error(), "symlink") {
|
||||
t.Fatalf("Flatten: err=%v, want symlink-ancestor rejection on whiteout", err)
|
||||
}
|
||||
if _, statErr := os.Stat(probeFile); statErr != nil {
|
||||
t.Fatalf("probe file %s removed via whiteout escape: %v", probeFile, statErr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlattenRejectsHardlinkTargetThroughSymlinkAncestor covers the
|
||||
// hardlink-target validator: a symlinked ancestor on the link source
|
||||
// must not let `os.Link` resolve through it and hard-link a host file
|
||||
// (e.g. /etc/passwd) into the extraction tree.
|
||||
func TestFlattenRejectsHardlinkTargetThroughSymlinkAncestor(t *testing.T) {
|
||||
host := startRegistry(t)
|
||||
probeDir := t.TempDir()
|
||||
probeFile := filepath.Join(probeDir, "secret")
|
||||
if err := os.WriteFile(probeFile, []byte("hands off"), 0o644); err != nil {
|
||||
t.Fatalf("write probe: %v", err)
|
||||
}
|
||||
ref := pushImage(t, host, "banger/test", "ln-sym-ancestor",
|
||||
makeLayer(t, []tarMember{
|
||||
{name: "etc", symlink: true, link: probeDir},
|
||||
}),
|
||||
makeLayer(t, []tarMember{
|
||||
{name: "leaked", hardlink: true, link: "etc/secret"},
|
||||
}),
|
||||
)
|
||||
pulled, err := Pull(context.Background(), ref, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("Pull: %v", err)
|
||||
}
|
||||
dest := t.TempDir()
|
||||
_, err = Flatten(context.Background(), pulled, dest)
|
||||
if err == nil || !strings.Contains(err.Error(), "symlink") {
|
||||
t.Fatalf("Flatten: err=%v, want symlink-ancestor rejection on hardlink target", err)
|
||||
}
|
||||
// dest must not contain a hardlink to the host secret.
|
||||
if _, statErr := os.Lstat(filepath.Join(dest, "leaked")); !errors.Is(statErr, os.ErrNotExist) {
|
||||
t.Fatalf("hardlink leaked file should not exist in dest; got %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlattenTarRejectsDebugFSHostilePath(t *testing.T) {
|
||||
tarData := buildTar(t, []tarMember{
|
||||
{name: "etc/bad\tname", body: []byte("bad")},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue