mirror of
https://github.com/apple/container.git
synced 2026-10-13 17:49:16 +00:00
Prevent recursion attacks in EXT4Formatter.unlink (#415)
This issue doesn't affect any of our existing products. This is a preemptive fix for downstream consumers of EXT4.format where, in some platforms, leading `//` in the path could get resolved into a FileTree that looks like this ```sh / └── / └── usr ```
This commit is contained in:
1 parent
de075fff8a
commit
df125a2300
5 files changed
+282
-30
No files matched your search
@@ -199,42 +199,63 @@ extension EXT4 {
|
||||
//
|
||||
// - `path`: The `FilePath` specifying the path of the file or directory to delete.
|
||||
public func unlink(path: FilePath, directoryWhiteout: Bool = false) throws {
|
||||
guard let pathPtr = self.tree.lookup(path: path) else {
|
||||
guard let pathPtr = self.tree.lookup(path: path),
|
||||
let parentPtr = self.tree.lookup(path: path.dir)
|
||||
else {
|
||||
// We are being asked to unlink something that does not exist. Ignore
|
||||
return
|
||||
}
|
||||
let pathNode = pathPtr.pointee
|
||||
let inodeNumber = Int(pathNode.inode) - 1
|
||||
let pathInodePtr = self.inodes[inodeNumber]
|
||||
var pathInode = pathInodePtr.pointee
|
||||
let pathInode = pathInodePtr.pointee
|
||||
|
||||
if directoryWhiteout && !pathInode.mode.isDir() {
|
||||
throw Error.notDirectory(path)
|
||||
}
|
||||
|
||||
for childPtr in pathNode.children {
|
||||
try self.unlink(path: path.join(childPtr.pointee.name))
|
||||
// Iterative breath-first traversal of the FileTree to prevent recursion attacks
|
||||
var queue: [(parent: Ptr<FileTree.FileTreeNode>, entry: Ptr<FileTree.FileTreeNode>)] = pathNode.children.map { (pathPtr, $0) }
|
||||
var head: Int = 0
|
||||
while head < queue.count {
|
||||
let currNode = queue[head].entry
|
||||
for childPtr in currNode.pointee.children {
|
||||
queue.append((currNode, childPtr))
|
||||
}
|
||||
head += 1
|
||||
}
|
||||
|
||||
for (parent, entry) in queue.reversed() {
|
||||
try _unlink(parentNodePtr: parent, pathNodePtr: entry)
|
||||
}
|
||||
|
||||
guard !directoryWhiteout else {
|
||||
return
|
||||
}
|
||||
|
||||
if let parentNodePtr = self.tree.lookup(path: path.dir) {
|
||||
let parentNode = parentNodePtr.pointee
|
||||
let parentInodePtr = self.inodes[Int(parentNode.inode) - 1]
|
||||
var parentInode = parentInodePtr.pointee
|
||||
if pathInode.mode.isDir() {
|
||||
if parentInode.linksCount > 2 {
|
||||
parentInode.linksCount -= 1
|
||||
}
|
||||
try _unlink(parentNodePtr: parentPtr, pathNodePtr: pathPtr)
|
||||
}
|
||||
|
||||
private func _unlink(parentNodePtr: Ptr<FileTree.FileTreeNode>, pathNodePtr: Ptr<FileTree.FileTreeNode>) throws {
|
||||
let pathNode = pathNodePtr.pointee
|
||||
let pathComponent = pathNode.name
|
||||
let inodeNumber = Int(pathNode.inode) - 1
|
||||
let pathInodePtr = self.inodes[inodeNumber]
|
||||
var pathInode = pathInodePtr.pointee
|
||||
|
||||
let parentNode = parentNodePtr.pointee
|
||||
let parentInodePtr = self.inodes[Int(parentNode.inode) - 1]
|
||||
var parentInode = parentInodePtr.pointee
|
||||
if pathInode.mode.isDir() {
|
||||
if parentInode.linksCount > 2 {
|
||||
parentInode.linksCount -= 1
|
||||
}
|
||||
parentInodePtr.pointee = parentInode
|
||||
parentNode.children.removeAll { childPtr in
|
||||
childPtr.pointee.name == path.base
|
||||
}
|
||||
parentNodePtr.pointee = parentNode
|
||||
}
|
||||
parentInodePtr.pointee = parentInode
|
||||
parentNode.children.removeAll { childPtr in
|
||||
childPtr.pointee.name == pathComponent
|
||||
}
|
||||
parentNodePtr.pointee = parentNode
|
||||
|
||||
if let hardlink = pathNode.link {
|
||||
// the file we are deleting is a hardlink, decrement the link count
|
||||
@@ -352,6 +373,10 @@ extension EXT4 {
|
||||
guard mode.isLink() else { // unless it is a link, then it can be replaced by a dir
|
||||
throw Error.notFile(path)
|
||||
}
|
||||
// root cannot be replaced with a link
|
||||
if path.isRoot {
|
||||
throw Error.unsupportedFiletype
|
||||
}
|
||||
}
|
||||
try self.unlink(path: path)
|
||||
}
|
||||
|
||||
@@ -49,6 +49,10 @@ extension FilePath {
|
||||
self.components.map { $0.string }
|
||||
}
|
||||
|
||||
public var isRoot: Bool { // platform agnostic
|
||||
self.removingRoot().isEmpty
|
||||
}
|
||||
|
||||
public init(_ url: URL) {
|
||||
self.init(url.path(percentEncoded: false))
|
||||
}
|
||||
|
||||
@@ -71,12 +71,8 @@ public enum Endianness {
|
||||
|
||||
// returns current endianness
|
||||
public var Endian: Endianness {
|
||||
switch CFByteOrderGetCurrent() {
|
||||
case CFByteOrder(CFByteOrderLittleEndian.rawValue):
|
||||
return .little
|
||||
case CFByteOrder(CFByteOrderBigEndian.rawValue):
|
||||
return .big
|
||||
default:
|
||||
fatalError("impossible")
|
||||
var value: UInt32 = 0x0102_0304
|
||||
return withUnsafeBytes(of: &value) { buffer in
|
||||
buffer.first == 0x04 ? .little : .big
|
||||
}
|
||||
}
|
||||
@@ -330,12 +330,7 @@ extension Socket {
|
||||
|
||||
var cmsgBuf = [UInt8](repeating: 0, count: Int(CZ_CMSG_SPACE(Int(MemoryLayout<Int32>.size))))
|
||||
msg.msg_control = withUnsafeMutablePointer(to: &cmsgBuf[0]) { UnsafeMutableRawPointer($0) }
|
||||
|
||||
#if canImport(Glibc)
|
||||
msg.msg_controllen = size_t(cmsgBuf.count)
|
||||
#else
|
||||
msg.msg_controllen = socklen_t(cmsgBuf.count)
|
||||
#endif
|
||||
msg.msg_controllen = numericCast(cmsgBuf.count)
|
||||
|
||||
let recvResult = withUnsafeMutablePointer(to: &msg) { msgPtr in
|
||||
sysRecvmsg(handle.fileDescriptor, msgPtr, 0)
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
//
|
||||
|
||||
import ContainerizationArchive
|
||||
import ContainerizationEXT4
|
||||
import Foundation
|
||||
import SystemPackage
|
||||
import Testing
|
||||
|
||||
struct EXT4WhiteoutTests {
|
||||
|
||||
private func makeTempFileURL(prefix: String) throws -> URL {
|
||||
let base = FileManager.default.temporaryDirectory
|
||||
let url = base.appendingPathComponent("\(prefix)-\(UUID().uuidString)")
|
||||
return url
|
||||
}
|
||||
|
||||
private func writeLayerWithOpaqueWhiteout(to url: URL) throws {
|
||||
let writer = try ArchiveWriter(
|
||||
format: .pax,
|
||||
filter: .gzip,
|
||||
file: url
|
||||
)
|
||||
|
||||
let ts = Date()
|
||||
|
||||
let entry = WriteEntry()
|
||||
entry.modificationDate = ts
|
||||
entry.creationDate = ts
|
||||
entry.owner = 0
|
||||
entry.group = 0
|
||||
|
||||
entry.fileType = .directory
|
||||
entry.permissions = 0o755
|
||||
|
||||
entry.path = "usr"
|
||||
try writer.writeEntry(entry: entry, data: nil)
|
||||
|
||||
entry.path = "usr/local"
|
||||
try writer.writeEntry(entry: entry, data: nil)
|
||||
|
||||
entry.path = "usr/local/bin"
|
||||
try writer.writeEntry(entry: entry, data: nil)
|
||||
|
||||
entry.fileType = .regular
|
||||
entry.permissions = 0o644
|
||||
|
||||
let fooData = Data("hello\n".utf8)
|
||||
entry.path = "usr/local/bin/foo"
|
||||
entry.size = Int64(fooData.count)
|
||||
try writer.writeEntry(entry: entry, data: fooData)
|
||||
|
||||
entry.fileType = .regular
|
||||
entry.permissions = 0o000
|
||||
entry.size = 0
|
||||
entry.path = "usr//.wh..wh..opq"
|
||||
try writer.writeEntry(entry: entry, data: nil)
|
||||
|
||||
try writer.finishEncoding()
|
||||
}
|
||||
|
||||
private func withFormatter<T>(
|
||||
prefix: String = "ext4-whiteout",
|
||||
blockSize: UInt32 = 4096,
|
||||
minDiskSize: UInt64 = 16.mib(),
|
||||
_ body: (EXT4.Formatter, FilePath) async throws -> T
|
||||
) async throws -> T {
|
||||
let imageURL = try makeTempFileURL(prefix: prefix)
|
||||
let imagePath = FilePath(imageURL.path)
|
||||
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: imageURL)
|
||||
}
|
||||
|
||||
let formatter = try EXT4.Formatter(
|
||||
imagePath,
|
||||
blockSize: blockSize,
|
||||
minDiskSize: minDiskSize
|
||||
)
|
||||
|
||||
let result = try await body(formatter, imagePath)
|
||||
return result
|
||||
}
|
||||
|
||||
@Test
|
||||
func unpack_with_opaque_whiteout_path_does_not_stack_overflow_and_cleans_directory() async throws {
|
||||
let layerURL = try makeTempFileURL(prefix: "ext4-wh-layer")
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: layerURL)
|
||||
}
|
||||
|
||||
try writeLayerWithOpaqueWhiteout(to: layerURL)
|
||||
|
||||
try await withFormatter { formatter, imagePath in
|
||||
try await formatter.unpack(
|
||||
source: FilePath(layerURL.path).url,
|
||||
format: .pax,
|
||||
compression: .gzip,
|
||||
progress: nil
|
||||
)
|
||||
|
||||
try formatter.close()
|
||||
|
||||
let reader = try EXT4.EXT4Reader(blockDevice: FilePath(imagePath.description))
|
||||
|
||||
#expect(reader.exists(FilePath("/usr/local/bin")) == false)
|
||||
|
||||
#expect(reader.exists(FilePath("/usr/local/bin/foo")) == false)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func directoryWhiteout_from_wh_opq_path_with_repeated_slashes_terminates() async throws {
|
||||
try await withFormatter { formatter, _ in
|
||||
try formatter.create(
|
||||
path: FilePath("/usr"),
|
||||
mode: EXT4.Inode.Mode(.S_IFDIR, 0o755)
|
||||
)
|
||||
try formatter.create(
|
||||
path: FilePath("/usr/local"),
|
||||
mode: EXT4.Inode.Mode(.S_IFDIR, 0o755)
|
||||
)
|
||||
try formatter.create(
|
||||
path: FilePath("/usr/local/bin"),
|
||||
mode: EXT4.Inode.Mode(.S_IFDIR, 0o755)
|
||||
)
|
||||
try formatter.create(
|
||||
path: FilePath("/usr/local/bin/foo"),
|
||||
mode: EXT4.Inode.Mode(.S_IFREG, 0o644)
|
||||
)
|
||||
try formatter.create(
|
||||
path: FilePath("/usr/local/bin/bar"),
|
||||
mode: EXT4.Inode.Mode(.S_IFREG, 0o644)
|
||||
)
|
||||
|
||||
let whiteoutEntry = FilePath("//usr//.wh..wh..opq")
|
||||
let directoryToWhiteout = whiteoutEntry.dir
|
||||
let normalized = directoryToWhiteout.lexicallyNormalized()
|
||||
#expect(normalized == FilePath("/usr"))
|
||||
try formatter.unlink(path: directoryToWhiteout, directoryWhiteout: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Test the exact recursion attack sequence:
|
||||
/// create /_d
|
||||
/// create symlink / -> /_
|
||||
/// create /_
|
||||
/// create symlink / -> /_
|
||||
///
|
||||
/// This creates a recursive symlink structure that can cause infinite recursion
|
||||
/// during directory traversal operations.
|
||||
@Test
|
||||
func recursion_attack_sequence_does_not_cause_infinite_recursion() async throws {
|
||||
await #expect(throws: EXT4.Formatter.Error.unsupportedFiletype) {
|
||||
try await withFormatter { formatter, _ in
|
||||
// Step 1: create /_d
|
||||
try formatter.create(
|
||||
path: FilePath("/_d"),
|
||||
mode: EXT4.Inode.Mode(.S_IFDIR, 0o755)
|
||||
)
|
||||
|
||||
// Step 2: create symlink / -> /_
|
||||
try formatter.create(
|
||||
path: FilePath("/"),
|
||||
link: FilePath("/_"),
|
||||
mode: EXT4.Inode.Mode(.S_IFLNK, 0o777)
|
||||
)
|
||||
|
||||
try formatter.create(
|
||||
path: FilePath("/_"),
|
||||
mode: EXT4.Inode.Mode(.S_IFDIR, 0o755)
|
||||
)
|
||||
|
||||
try formatter.create(
|
||||
path: FilePath("/"),
|
||||
link: FilePath("/_"),
|
||||
mode: EXT4.Inode.Mode(.S_IFLNK, 0o777)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func file_whiteouts_and_directory_whiteouts_interact_correctly() async throws {
|
||||
try await withFormatter { formatter, imagePath in
|
||||
// Lower‑layer content
|
||||
try formatter.create(
|
||||
path: FilePath("/opt"),
|
||||
mode: EXT4.Inode.Mode(.S_IFDIR, 0o755)
|
||||
)
|
||||
try formatter.create(
|
||||
path: FilePath("/opt/app"),
|
||||
mode: EXT4.Inode.Mode(.S_IFDIR, 0o755)
|
||||
)
|
||||
try formatter.create(
|
||||
path: FilePath("/opt/app/cache"),
|
||||
mode: EXT4.Inode.Mode(.S_IFDIR, 0o755)
|
||||
)
|
||||
try formatter.create(
|
||||
path: FilePath("/opt/app/cache/file"),
|
||||
mode: EXT4.Inode.Mode(.S_IFREG, 0o644)
|
||||
)
|
||||
try formatter.unlink(path: FilePath("/opt/app/cache/file"))
|
||||
try formatter.unlink(
|
||||
path: FilePath("/opt/app/cache"),
|
||||
directoryWhiteout: true
|
||||
)
|
||||
try formatter.close()
|
||||
|
||||
let reader = try EXT4.EXT4Reader(blockDevice: FilePath(imagePath.description))
|
||||
#expect(reader.exists(FilePath("/opt")))
|
||||
#expect(reader.exists(FilePath("/opt/app")))
|
||||
#expect(reader.exists(FilePath("/opt/app/cache")))
|
||||
#expect(reader.exists(FilePath("/opt/app/cache/file")) == false)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user