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
|
|
@ -267,12 +267,57 @@ func applyEntry(tr *tar.Reader, hdr *tar.Header, dest string, meta *Metadata) er
|
|||
}
|
||||
}
|
||||
|
||||
// safeJoin returns dest+rel after verifying the result lies under dest.
|
||||
// safeJoin returns dest+rel after verifying:
|
||||
//
|
||||
// 1. The cleaned result lies textually under dest (catches "../escape").
|
||||
// 2. No INTERMEDIATE component of the result is a symlink (catches the
|
||||
// OCI extraction-escape attack: a layer plants `etc -> /etc`, then a
|
||||
// later layer writes `etc/passwd` — without this walk the kernel
|
||||
// would dereference the symlink and the operation would land at
|
||||
// /etc/passwd on the host, not at <dest>/etc/passwd).
|
||||
//
|
||||
// The leaf component is intentionally NOT Lstat'd here: it may legitimately
|
||||
// be a symlink (TypeSymlink entries), a missing file (TypeReg about to be
|
||||
// created), or an existing entry that the caller will RemoveAll before
|
||||
// re-creating. Leaf type is the caller's contract.
|
||||
//
|
||||
// Walking against the already-extracted tree is race-free in practice:
|
||||
// the only mutator is this same extraction loop, and we're processing
|
||||
// entries serially.
|
||||
func safeJoin(dest, rel string) (string, error) {
|
||||
joined := filepath.Join(dest, rel)
|
||||
if joined != dest && !strings.HasPrefix(joined, dest+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("unsafe path: %q escapes %q", rel, dest)
|
||||
}
|
||||
if joined == dest {
|
||||
return joined, nil
|
||||
}
|
||||
suffix := strings.TrimPrefix(joined, dest+string(filepath.Separator))
|
||||
segs := strings.Split(suffix, string(filepath.Separator))
|
||||
cur := dest
|
||||
for i, seg := range segs {
|
||||
if seg == "" {
|
||||
continue
|
||||
}
|
||||
cur = filepath.Join(cur, seg)
|
||||
if i == len(segs)-1 {
|
||||
break
|
||||
}
|
||||
info, err := os.Lstat(cur)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// Ancestor not yet materialised. Once an extraction
|
||||
// op creates it (via this same routed code), it can't
|
||||
// be a symlink — TypeSymlink writes go through this
|
||||
// validator too.
|
||||
return joined, nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return "", fmt.Errorf("unsafe path: ancestor %q of %q is a symlink", cur, rel)
|
||||
}
|
||||
}
|
||||
return joined, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue