From 2bf2cedede09d29e44dbad64b20a910155875abd Mon Sep 17 00:00:00 2001 From: Danny Canter Date: Wed, 25 Feb 2026 14:29:34 -0800 Subject: [PATCH] Mount: Ensure symlinks stay scoped to rootfs (#559) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Today we don't resolve symlinks relative to the rootfs of the container as ContainerizationOS.Mount did not handle this. This means trying to mount something at /var/run (which typically is a symlink to /run in most cases) would resolve to /run in the root of the VM. This change adds some logic to handle this. ``` ➜ ./bin/cctl run --kernel bin/kernel.arm64 --mount /Users/dcantah/foo:/var/run/foo bash root@sandbox-vm:/# ls var/run/foo/ howdy ``` --- Sources/CShim/include/openat2.h | 37 ++++ Sources/CShim/openat2.c | 33 +++ Sources/ContainerizationOS/Mount/Mount.swift | 215 ++++++++++++++++--- 3 files changed, 259 insertions(+), 26 deletions(-) create mode 100644 Sources/CShim/include/openat2.h create mode 100644 Sources/CShim/openat2.c diff --git a/Sources/CShim/include/openat2.h b/Sources/CShim/include/openat2.h new file mode 100644 index 00000000..2c7bdb65 --- /dev/null +++ b/Sources/CShim/include/openat2.h @@ -0,0 +1,37 @@ +/* + * Copyright © 2026 Apple Inc. and the Containerization project authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#ifndef __OPENAT2_H +#define __OPENAT2_H + +#include + +#ifndef RESOLVE_IN_ROOT +#define RESOLVE_IN_ROOT 0x10 +#endif + +struct cz_open_how { + unsigned long long flags; + unsigned long long mode; + unsigned long long resolve; +}; + +/// openat2(2) wrapper. Musl does not provide openat2 so we invoke the syscall +/// directly. Requires Linux 5.6+. +int CZ_openat2(int dirfd, const char *pathname, struct cz_open_how *how, + size_t size); + +#endif diff --git a/Sources/CShim/openat2.c b/Sources/CShim/openat2.c new file mode 100644 index 00000000..3c116380 --- /dev/null +++ b/Sources/CShim/openat2.c @@ -0,0 +1,33 @@ +/* + * Copyright © 2026 Apple Inc. and the Containerization project authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#if defined(__linux__) + +#include +#include + +#include "openat2.h" + +#ifndef SYS_openat2 +#define SYS_openat2 437 +#endif + +int CZ_openat2(int dirfd, const char *pathname, struct cz_open_how *how, + size_t size) { + return syscall(SYS_openat2, dirfd, pathname, how, size); +} + +#endif diff --git a/Sources/ContainerizationOS/Mount/Mount.swift b/Sources/ContainerizationOS/Mount/Mount.swift index c5033d71..3a1a8c2a 100644 --- a/Sources/ContainerizationOS/Mount/Mount.swift +++ b/Sources/ContainerizationOS/Mount/Mount.swift @@ -14,6 +14,7 @@ // limitations under the License. //===----------------------------------------------------------------------===// +import CShim import Foundation #if canImport(Musl) @@ -116,13 +117,170 @@ extension Mount { } /// Mount the mount relative to `root` with the current set of data in the object. + /// /// Optionally provide `createWithPerms` to set the permissions for the directory that /// it will be mounted at. public func mount(root: String, createWithPerms: Int16? = nil) throws { - var rootURL = URL(fileURLWithPath: root) - rootURL = rootURL.resolvingSymlinksInPath() - rootURL = rootURL.appendingPathComponent(self.target) - try self.mountToTarget(target: rootURL.path, createWithPerms: createWithPerms) + let fd = try secureResolveInRoot(root: root) + defer { close(fd) } + + let realPath = try readlinkProc(fd: fd) + try self.mountToTarget(target: realPath, createWithPerms: createWithPerms, targetResolved: true) + } + + /// Open a path relative to `dirFd` using `openat2(2)` with `RESOLVE_IN_ROOT`. + /// + /// All symlink resolution is confined to the directory tree beneath `dirFd`. + /// Returns the file descriptor on success, or -1 on failure (with errno set). + private func openInRoot(dirFd: Int32, path: String, flags: Int32, mode: UInt64 = 0) -> Int32 { + path.withCString { cPath in + var how = cz_open_how( + flags: UInt64(flags), + mode: mode, + resolve: UInt64(RESOLVE_IN_ROOT) + ) + return CZ_openat2(dirFd, cPath, &how, MemoryLayout.size) + } + } + + private func secureResolveInRoot(root: String) throws -> Int32 { + let rootFd = open(root, O_RDONLY | O_DIRECTORY | O_CLOEXEC) + guard rootFd >= 0 else { + throw Error.errno(errno, "failed to open rootfs '\(root)'") + } + + // Determine if the leaf mount point should be a file or directory. + let opts = parseMountOptions() + let isBindMount = (opts.flags & Int32(MS_BIND)) != 0 + var leafIsFile = false + if isBindMount { + var sourceStat = stat() + if stat(self.source, &sourceStat) == 0 { + leafIsFile = (sourceStat.st_mode & S_IFMT) == S_IFREG + } + } + + // Normalize target to a relative path for openat2. + let relativePath = self.target + .split(separator: "/", omittingEmptySubsequences: true) + .joined(separator: "/") + + guard !relativePath.isEmpty else { + return rootFd + } + + // Fast path: try openat2 with RESOLVE_IN_ROOT for the full path. + let openFlags: Int32 = + leafIsFile + ? (O_RDONLY | O_CLOEXEC) + : (O_RDONLY | O_DIRECTORY | O_CLOEXEC) + let fd = openInRoot(dirFd: rootFd, path: relativePath, flags: openFlags) + if fd >= 0 { + close(rootFd) + return fd + } + + guard errno == ENOENT else { + let savedErrno = errno + close(rootFd) + throw Error.errno(savedErrno, "failed to resolve '\(self.target)' in rootfs") + } + + // Part of the path doesn't exist. Use openat2 to find the deepest + // existing ancestor, then create the missing components. + return try createMountTarget( + rootFd: rootFd, relativePath: relativePath, leafIsFile: leafIsFile + ) + } + + private func createMountTarget( + rootFd: Int32, + relativePath: String, + leafIsFile: Bool + ) throws -> Int32 { + let components = relativePath.split(separator: "/").map(String.init) + var currentFd = rootFd + var resultFd: Int32 = -1 + + // Centralized cleanup. On success resultFd holds the fd we return, + // so we avoid closing it. On error resultFd is -1 and we close + // everything. + defer { + if currentFd != rootFd && currentFd != resultFd { close(currentFd) } + if rootFd != resultFd { close(rootFd) } + } + + func fail(_ savedErrno: Int32, _ message: String) throws -> Never { + throw Error.errno(savedErrno, message) + } + + // Find the deepest existing directory using openat2 with RESOLVE_IN_ROOT. + var firstMissing = 0 + for i in 0..= 0 else { + try fail(errno, "failed to re-open mount point file '\(component)'") + } + resultFd = pathFd + return resultFd + } + + guard mkdirat(currentFd, component, 0o755) == 0 else { + try fail(errno, "failed to create directory '\(component)'") + } + + let dirFd = openat(currentFd, component, O_RDONLY | O_NOFOLLOW | O_DIRECTORY | O_CLOEXEC) + guard dirFd >= 0 else { + try fail(errno, "failed to open created directory '\(component)'") + } + + if isLast { + resultFd = dirFd + return resultFd + } + + if currentFd != rootFd { close(currentFd) } + currentFd = dirFd + } + + // All components already existed. + resultFd = currentFd + return resultFd + } + + /// Resolve the real filesystem path for an open fd via /proc/self/fd. + private func readlinkProc(fd: Int32) throws -> String { + let procPath = "/proc/self/fd/\(fd)" + var buffer = [CChar](repeating: 0, count: Int(PATH_MAX) + 1) + let len = readlink(procPath, &buffer, buffer.count - 1) + guard len > 0 else { + throw Error.errno(errno, "readlink failed for '\(procPath)'") + } + return buffer.prefix(len).withUnsafeBufferPointer { buf in + String(decoding: buf.map { UInt8(bitPattern: $0) }, as: UTF8.self) + } } /// Mount the mount with the current set of data in the object. Optionally @@ -132,7 +290,7 @@ extension Mount { try self.mountToTarget(target: self.target, createWithPerms: createWithPerms) } - private func mountToTarget(target: String, createWithPerms: Int16?) throws { + private func mountToTarget(target: String, createWithPerms: Int16?, targetResolved: Bool = false) throws { let pageSize = sysconf(Int32(_SC_PAGESIZE)) let opts = parseMountOptions() @@ -146,33 +304,38 @@ extension Mount { // Ensure propagation type change flags aren't included in other calls. let originalFlags = opts.flags & ~(propagationTypes) - let targetURL = URL(fileURLWithPath: target) - let targetParent = targetURL.deletingLastPathComponent().path - if let perms = createWithPerms { - try mkdirAll(targetParent, perms) - } - - // For bind mounts, check if the source is a file and create the target accordingly. - let isBindMount = (originalFlags & Int32(MS_BIND)) != 0 - if isBindMount { - var sourceIsFile = false - var sourceStat = stat() - if stat(self.source, &sourceStat) == 0 { - sourceIsFile = (sourceStat.st_mode & S_IFMT) == S_IFREG + // When targetResolved is true, the target path has already been securely + // resolved and the mount point created by secureResolveInRoot. Skip + // directory/file creation to avoid following symlinks in the target path. + if !targetResolved { + let targetURL = URL(fileURLWithPath: target) + let targetParent = targetURL.deletingLastPathComponent().path + if let perms = createWithPerms { + try mkdirAll(targetParent, perms) } - if sourceIsFile { - // Create parent directories and touch the target file - try mkdirAll(targetParent, 0o755) - let fd = open(target, O_WRONLY | O_CREAT, 0o644) - if fd >= 0 { - close(fd) + // For bind mounts, check if the source is a file and create the target accordingly. + let isBindMount = (originalFlags & Int32(MS_BIND)) != 0 + if isBindMount { + var sourceIsFile = false + var sourceStat = stat() + if stat(self.source, &sourceStat) == 0 { + sourceIsFile = (sourceStat.st_mode & S_IFMT) == S_IFREG + } + + if sourceIsFile { + // Create parent directories and touch the target file + try mkdirAll(targetParent, 0o755) + let fd = open(target, O_WRONLY | O_CREAT, 0o644) + if fd >= 0 { + close(fd) + } + } else { + try mkdirAll(target, 0o755) } } else { try mkdirAll(target, 0o755) } - } else { - try mkdirAll(target, 0o755) } if opts.flags & Int32(MS_REMOUNT) == 0 || !dataString.isEmpty {