Skip to content

Commit df125a2

Browse files
authored
Prevent recursion attacks in EXT4Formatter.unlink (apple#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 ```
1 parent de075ff commit df125a2

5 files changed

Lines changed: 282 additions & 30 deletions

File tree

Sources/ContainerizationEXT4/EXT4+Formatter.swift

Lines changed: 42 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -199,42 +199,63 @@ extension EXT4 {
199199
//
200200
// - `path`: The `FilePath` specifying the path of the file or directory to delete.
201201
public func unlink(path: FilePath, directoryWhiteout: Bool = false) throws {
202-
guard let pathPtr = self.tree.lookup(path: path) else {
202+
guard let pathPtr = self.tree.lookup(path: path),
203+
let parentPtr = self.tree.lookup(path: path.dir)
204+
else {
203205
// We are being asked to unlink something that does not exist. Ignore
204206
return
205207
}
206208
let pathNode = pathPtr.pointee
207209
let inodeNumber = Int(pathNode.inode) - 1
208210
let pathInodePtr = self.inodes[inodeNumber]
209-
var pathInode = pathInodePtr.pointee
211+
let pathInode = pathInodePtr.pointee
210212

211213
if directoryWhiteout && !pathInode.mode.isDir() {
212214
throw Error.notDirectory(path)
213215
}
214216

215-
for childPtr in pathNode.children {
216-
try self.unlink(path: path.join(childPtr.pointee.name))
217+
// Iterative breath-first traversal of the FileTree to prevent recursion attacks
218+
var queue: [(parent: Ptr<FileTree.FileTreeNode>, entry: Ptr<FileTree.FileTreeNode>)] = pathNode.children.map { (pathPtr, $0) }
219+
var head: Int = 0
220+
while head < queue.count {
221+
let currNode = queue[head].entry
222+
for childPtr in currNode.pointee.children {
223+
queue.append((currNode, childPtr))
224+
}
225+
head += 1
226+
}
227+
228+
for (parent, entry) in queue.reversed() {
229+
try _unlink(parentNodePtr: parent, pathNodePtr: entry)
217230
}
218231

219232
guard !directoryWhiteout else {
220233
return
221234
}
222235

223-
if let parentNodePtr = self.tree.lookup(path: path.dir) {
224-
let parentNode = parentNodePtr.pointee
225-
let parentInodePtr = self.inodes[Int(parentNode.inode) - 1]
226-
var parentInode = parentInodePtr.pointee
227-
if pathInode.mode.isDir() {
228-
if parentInode.linksCount > 2 {
229-
parentInode.linksCount -= 1
230-
}
231-
}
232-
parentInodePtr.pointee = parentInode
233-
parentNode.children.removeAll { childPtr in
234-
childPtr.pointee.name == path.base
236+
try _unlink(parentNodePtr: parentPtr, pathNodePtr: pathPtr)
237+
}
238+
239+
private func _unlink(parentNodePtr: Ptr<FileTree.FileTreeNode>, pathNodePtr: Ptr<FileTree.FileTreeNode>) throws {
240+
let pathNode = pathNodePtr.pointee
241+
let pathComponent = pathNode.name
242+
let inodeNumber = Int(pathNode.inode) - 1
243+
let pathInodePtr = self.inodes[inodeNumber]
244+
var pathInode = pathInodePtr.pointee
245+
246+
let parentNode = parentNodePtr.pointee
247+
let parentInodePtr = self.inodes[Int(parentNode.inode) - 1]
248+
var parentInode = parentInodePtr.pointee
249+
if pathInode.mode.isDir() {
250+
if parentInode.linksCount > 2 {
251+
parentInode.linksCount -= 1
235252
}
236-
parentNodePtr.pointee = parentNode
237253
}
254+
parentInodePtr.pointee = parentInode
255+
parentNode.children.removeAll { childPtr in
256+
childPtr.pointee.name == pathComponent
257+
}
258+
parentNodePtr.pointee = parentNode
238259

239260
if let hardlink = pathNode.link {
240261
// the file we are deleting is a hardlink, decrement the link count
@@ -352,6 +373,10 @@ extension EXT4 {
352373
guard mode.isLink() else { // unless it is a link, then it can be replaced by a dir
353374
throw Error.notFile(path)
354375
}
376+
// root cannot be replaced with a link
377+
if path.isRoot {
378+
throw Error.unsupportedFiletype
379+
}
355380
}
356381
try self.unlink(path: path)
357382
}

Sources/ContainerizationEXT4/FilePath+Extensions.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ extension FilePath {
4949
self.components.map { $0.string }
5050
}
5151

52+
public var isRoot: Bool { // platform agnostic
53+
self.removingRoot().isEmpty
54+
}
55+
5256
public init(_ url: URL) {
5357
self.init(url.path(percentEncoded: false))
5458
}

Sources/ContainerizationEXT4/UnsafeLittleEndianBytes.swift

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -71,12 +71,8 @@ public enum Endianness {
7171

7272
// returns current endianness
7373
public var Endian: Endianness {
74-
switch CFByteOrderGetCurrent() {
75-
case CFByteOrder(CFByteOrderLittleEndian.rawValue):
76-
return .little
77-
case CFByteOrder(CFByteOrderBigEndian.rawValue):
78-
return .big
79-
default:
80-
fatalError("impossible")
74+
var value: UInt32 = 0x0102_0304
75+
return withUnsafeBytes(of: &value) { buffer in
76+
buffer.first == 0x04 ? .little : .big
8177
}
8278
}

Sources/ContainerizationOS/Socket/Socket.swift

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -330,12 +330,7 @@ extension Socket {
330330

331331
var cmsgBuf = [UInt8](repeating: 0, count: Int(CZ_CMSG_SPACE(Int(MemoryLayout<Int32>.size))))
332332
msg.msg_control = withUnsafeMutablePointer(to: &cmsgBuf[0]) { UnsafeMutableRawPointer($0) }
333-
334-
#if canImport(Glibc)
335-
msg.msg_controllen = size_t(cmsgBuf.count)
336-
#else
337-
msg.msg_controllen = socklen_t(cmsgBuf.count)
338-
#endif
333+
msg.msg_controllen = numericCast(cmsgBuf.count)
339334

340335
let recvResult = withUnsafeMutablePointer(to: &msg) { msgPtr in
341336
sysRecvmsg(handle.fileDescriptor, msgPtr, 0)
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
//===----------------------------------------------------------------------===//
2+
// Copyright © 2026 Apple Inc. and the Containerization project authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// https://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
//===----------------------------------------------------------------------===//
16+
17+
//
18+
19+
import ContainerizationArchive
20+
import ContainerizationEXT4
21+
import Foundation
22+
import SystemPackage
23+
import Testing
24+
25+
struct EXT4WhiteoutTests {
26+
27+
private func makeTempFileURL(prefix: String) throws -> URL {
28+
let base = FileManager.default.temporaryDirectory
29+
let url = base.appendingPathComponent("\(prefix)-\(UUID().uuidString)")
30+
return url
31+
}
32+
33+
private func writeLayerWithOpaqueWhiteout(to url: URL) throws {
34+
let writer = try ArchiveWriter(
35+
format: .pax,
36+
filter: .gzip,
37+
file: url
38+
)
39+
40+
let ts = Date()
41+
42+
let entry = WriteEntry()
43+
entry.modificationDate = ts
44+
entry.creationDate = ts
45+
entry.owner = 0
46+
entry.group = 0
47+
48+
entry.fileType = .directory
49+
entry.permissions = 0o755
50+
51+
entry.path = "usr"
52+
try writer.writeEntry(entry: entry, data: nil)
53+
54+
entry.path = "usr/local"
55+
try writer.writeEntry(entry: entry, data: nil)
56+
57+
entry.path = "usr/local/bin"
58+
try writer.writeEntry(entry: entry, data: nil)
59+
60+
entry.fileType = .regular
61+
entry.permissions = 0o644
62+
63+
let fooData = Data("hello\n".utf8)
64+
entry.path = "usr/local/bin/foo"
65+
entry.size = Int64(fooData.count)
66+
try writer.writeEntry(entry: entry, data: fooData)
67+
68+
entry.fileType = .regular
69+
entry.permissions = 0o000
70+
entry.size = 0
71+
entry.path = "usr//.wh..wh..opq"
72+
try writer.writeEntry(entry: entry, data: nil)
73+
74+
try writer.finishEncoding()
75+
}
76+
77+
private func withFormatter<T>(
78+
prefix: String = "ext4-whiteout",
79+
blockSize: UInt32 = 4096,
80+
minDiskSize: UInt64 = 16.mib(),
81+
_ body: (EXT4.Formatter, FilePath) async throws -> T
82+
) async throws -> T {
83+
let imageURL = try makeTempFileURL(prefix: prefix)
84+
let imagePath = FilePath(imageURL.path)
85+
86+
defer {
87+
try? FileManager.default.removeItem(at: imageURL)
88+
}
89+
90+
let formatter = try EXT4.Formatter(
91+
imagePath,
92+
blockSize: blockSize,
93+
minDiskSize: minDiskSize
94+
)
95+
96+
let result = try await body(formatter, imagePath)
97+
return result
98+
}
99+
100+
@Test
101+
func unpack_with_opaque_whiteout_path_does_not_stack_overflow_and_cleans_directory() async throws {
102+
let layerURL = try makeTempFileURL(prefix: "ext4-wh-layer")
103+
defer {
104+
try? FileManager.default.removeItem(at: layerURL)
105+
}
106+
107+
try writeLayerWithOpaqueWhiteout(to: layerURL)
108+
109+
try await withFormatter { formatter, imagePath in
110+
try await formatter.unpack(
111+
source: FilePath(layerURL.path).url,
112+
format: .pax,
113+
compression: .gzip,
114+
progress: nil
115+
)
116+
117+
try formatter.close()
118+
119+
let reader = try EXT4.EXT4Reader(blockDevice: FilePath(imagePath.description))
120+
121+
#expect(reader.exists(FilePath("/usr/local/bin")) == false)
122+
123+
#expect(reader.exists(FilePath("/usr/local/bin/foo")) == false)
124+
}
125+
}
126+
127+
@Test
128+
func directoryWhiteout_from_wh_opq_path_with_repeated_slashes_terminates() async throws {
129+
try await withFormatter { formatter, _ in
130+
try formatter.create(
131+
path: FilePath("/usr"),
132+
mode: EXT4.Inode.Mode(.S_IFDIR, 0o755)
133+
)
134+
try formatter.create(
135+
path: FilePath("/usr/local"),
136+
mode: EXT4.Inode.Mode(.S_IFDIR, 0o755)
137+
)
138+
try formatter.create(
139+
path: FilePath("/usr/local/bin"),
140+
mode: EXT4.Inode.Mode(.S_IFDIR, 0o755)
141+
)
142+
try formatter.create(
143+
path: FilePath("/usr/local/bin/foo"),
144+
mode: EXT4.Inode.Mode(.S_IFREG, 0o644)
145+
)
146+
try formatter.create(
147+
path: FilePath("/usr/local/bin/bar"),
148+
mode: EXT4.Inode.Mode(.S_IFREG, 0o644)
149+
)
150+
151+
let whiteoutEntry = FilePath("//usr//.wh..wh..opq")
152+
let directoryToWhiteout = whiteoutEntry.dir
153+
let normalized = directoryToWhiteout.lexicallyNormalized()
154+
#expect(normalized == FilePath("/usr"))
155+
try formatter.unlink(path: directoryToWhiteout, directoryWhiteout: true)
156+
}
157+
}
158+
159+
/// Test the exact recursion attack sequence:
160+
/// create /_d
161+
/// create symlink / -> /_
162+
/// create /_
163+
/// create symlink / -> /_
164+
///
165+
/// This creates a recursive symlink structure that can cause infinite recursion
166+
/// during directory traversal operations.
167+
@Test
168+
func recursion_attack_sequence_does_not_cause_infinite_recursion() async throws {
169+
await #expect(throws: EXT4.Formatter.Error.unsupportedFiletype) {
170+
try await withFormatter { formatter, _ in
171+
// Step 1: create /_d
172+
try formatter.create(
173+
path: FilePath("/_d"),
174+
mode: EXT4.Inode.Mode(.S_IFDIR, 0o755)
175+
)
176+
177+
// Step 2: create symlink / -> /_
178+
try formatter.create(
179+
path: FilePath("/"),
180+
link: FilePath("/_"),
181+
mode: EXT4.Inode.Mode(.S_IFLNK, 0o777)
182+
)
183+
184+
try formatter.create(
185+
path: FilePath("/_"),
186+
mode: EXT4.Inode.Mode(.S_IFDIR, 0o755)
187+
)
188+
189+
try formatter.create(
190+
path: FilePath("/"),
191+
link: FilePath("/_"),
192+
mode: EXT4.Inode.Mode(.S_IFLNK, 0o777)
193+
)
194+
}
195+
}
196+
}
197+
198+
@Test
199+
func file_whiteouts_and_directory_whiteouts_interact_correctly() async throws {
200+
try await withFormatter { formatter, imagePath in
201+
// Lower‑layer content
202+
try formatter.create(
203+
path: FilePath("/opt"),
204+
mode: EXT4.Inode.Mode(.S_IFDIR, 0o755)
205+
)
206+
try formatter.create(
207+
path: FilePath("/opt/app"),
208+
mode: EXT4.Inode.Mode(.S_IFDIR, 0o755)
209+
)
210+
try formatter.create(
211+
path: FilePath("/opt/app/cache"),
212+
mode: EXT4.Inode.Mode(.S_IFDIR, 0o755)
213+
)
214+
try formatter.create(
215+
path: FilePath("/opt/app/cache/file"),
216+
mode: EXT4.Inode.Mode(.S_IFREG, 0o644)
217+
)
218+
try formatter.unlink(path: FilePath("/opt/app/cache/file"))
219+
try formatter.unlink(
220+
path: FilePath("/opt/app/cache"),
221+
directoryWhiteout: true
222+
)
223+
try formatter.close()
224+
225+
let reader = try EXT4.EXT4Reader(blockDevice: FilePath(imagePath.description))
226+
#expect(reader.exists(FilePath("/opt")))
227+
#expect(reader.exists(FilePath("/opt/app")))
228+
#expect(reader.exists(FilePath("/opt/app/cache")))
229+
#expect(reader.exists(FilePath("/opt/app/cache/file")) == false)
230+
}
231+
}
232+
}

0 commit comments

Comments
 (0)