Skip to content

imaginator: Fix symlink resolution for copy and append to stay within image rootfs#254

Draft
narendraReddy45 wants to merge 16 commits into
Cloud-Foundations:masterfrom
narendraReddy45:issue_239_append_files_bugfix
Draft

imaginator: Fix symlink resolution for copy and append to stay within image rootfs#254
narendraReddy45 wants to merge 16 commits into
Cloud-Foundations:masterfrom
narendraReddy45:issue_239_append_files_bugfix

Conversation

@narendraReddy45
Copy link
Copy Markdown
Contributor

@narendraReddy45 narendraReddy45 commented Apr 30, 2026

Description & Motivation

This PR enhances a critical symlink resolution edgecase in the merge paths used by copy (files, post-install-files) and append (files.append, post-install-files.append).

Previously, symlinks were not safely resolved with image-root semantics. This allowed malicious or malformed symlinks within image layers to escape the image rootfs (destDir) and inadvertently overwrite or modify files on the host machine running the build.

This PR introduces a userspace chroot path resolver that makes resolution consistently rootfs-relative for both final targets and intermediate directory components.

The Problem

The curent implementation suffered from two main escape vectors:

  1. Final Symlink Targets: Both relative and absolute symlink targets could be evaluated against the host OS. A traversal through .. or an absolute path starting with / would resolve against the host filesystem instead of the virtual image rootfs.
  2. Intermediate Directory Symlinks: If a parent directory in the destination path was a symlink (e.g., writing to /var/lib/docker where /var/lib is a symlink to /tmp), the underlying Go os operations would follow it out of the destDir and operate on the host's /tmp directory.

I successfully reproduced both vectors on the current binary: copy and append operations were successfully tricking the builder into creating/modifying files on the host's /tmp directory instead of the <imageroot-fs>/tmp directory.

The Approach

Symlink resolution now treats destDir as a strict virtual root boundary throughout the entire traversal of the destination path.

In practice:

  • Component-by-component validation: Intermediate directories are verified before writes occur.
  • Absolute targets are safely rebased under destDir.
  • Relative targets are resolved hop-by-hop.
  • Leading .. segments are clamped to the image root (matching Linux chroot semantics).
  • Dangling chains and symlink loops instantly fail and return an error.
  • Valid symlinks are preserved, and operations cleanly target the safely resolved in-rootfs destination.

Why not Go 1.24 os.Root?

I strongly considered using Go 1.24's native os.Root, but it is fundamentally incompatible with standard Linux base images.

os.Root is designed for strict security sandboxing (like static web servers). If it encounters an absolute symlink, it strictly rejects it as an escape violation and returns a permission error. However, container image builders require absolute symlinks to function (e.g., modern distros where /bin -> /usr/bin).

A custom userspace chroot resolver was required to safely clamp absolute symlinks to the root boundary without throwing errors. The resolution logic implemented here is heavily inspired by cyphar/filepath-securejoin, which was built to solve this exact same container escape vulnerability for runc.

Related to: #239

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • Documentation update

How Has This Been Tested?

  • Built a new image with files.append and post-install-files.append directories. After successful creation, created a VM and verified the files natively.
  • Comprehensive unit testing of the fsutil path resolver.

Testing Approach

Reproduced the buggy behavior with the existing binary, then verified the new implementation protects against all edge cases.

Tested successfully for Final-Target Symlinks:

  • Absolute & Relative
  • Relative with ..
  • Multi-hop
  • Dangling absolute & Dangling relative
  • Escape via repeated .. (Clamping verified)
  • Loop detection (MAXSYMLINKS)

Tested successfully for Intermediate Destination Directory Symlinks:

  • Copy & Append through valid absolute and relative symlink dirs
  • Absolute and relative clamp cases
  • Dangling intermediate directories
  • Loops in intermediate directories (for both copy & append)
  • /var/run -> /run style parent symlink behavior

Checklist:

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works

@narendraReddy45 narendraReddy45 changed the title Fix symlink resolution for copy and append to stay within image rootfs imaginator: Fix symlink resolution for copy and append to stay within image rootfs May 1, 2026
Copy link
Copy Markdown
Member

@rgooch rgooch left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds a lot of code and more complexity. Consider a simpler approach, such as walking the source and destination trees and failing if there is a symlink in the way.

Comment thread lib/fsutil/api.go Outdated
// AppendFile is not safe to call concurrently for the same file.
func AppendFile(destFilename, sourceFilename string) error {
return appendFile(destFilename, sourceFilename)
func AppendFile(destDir, destFilename, sourceFilename string) error {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changes the library API in a non-backwards compatible way, which we can't do because this API has already been published (merged).

@narendraReddy45 narendraReddy45 force-pushed the issue_239_append_files_bugfix branch from 6c158b7 to 29a9ffb Compare May 4, 2026 19:28
@narendraReddy45 narendraReddy45 force-pushed the issue_239_append_files_bugfix branch from 29a9ffb to f90c09e Compare May 4, 2026 19:30
@narendraReddy45 narendraReddy45 marked this pull request as draft May 4, 2026 19:34
@narendraReddy45 narendraReddy45 marked this pull request as draft May 4, 2026 19:34
@narendraReddy45
Copy link
Copy Markdown
Contributor Author

narendraReddy45 commented May 5, 2026

This adds a lot of code and more complexity. Consider a simpler approach, such as walking the source and destination trees and failing if there is a symlink in the way.

Thanks @rgooch for the review. I agree the custom resolver adds too much complexity.

I looked into failing on destination symlinks, just like we do for source paths. But that makes the builder too restrictive. We do not control the source image file structure. Failing on destination symlinks breaks standard Linux setups like:

  • usrmerge (/bin -> /usr/bin)

  • /etc/alternatives

  • /etc/resolv.conf pointing to ../run/systemd/resolve/stub-resolv.conf

To support these safely without letting writes escape to the host, we need to do two things:

  • Rebase absolute symlinks to the image root.

  • Clamp relative parent traversals so they stay inside the root boundary.

Custom path resolution in userspace takes a lot of code and testing. Instead of the custom Go resolver, we can use the openat2 syscall with the RESOLVE_IN_ROOT flag. The Linux kernel enforces the boundary natively. This drastically reduces our code complexity. It only requires Linux kernel 5.6 or newer and not supported in other operating systems

Do you want to pivot to openat2 (https://man7.org/linux/man-pages/man2/openat2.2.html) to keep the code simple? Or should we stick with the userspace resolver to avoid the kernel version limit? Let me know.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants