Skip to content

Commit 3d2676c

Browse files
committed
Prevent recursion attacks in EXT4Formatter.unlink
1 parent 747ea99 commit 3d2676c

File tree

5 files changed

+269
-14
lines changed

5 files changed

+269
-14
lines changed

Sources/ContainerizationEXT4/EXT4+Formatter.swift

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -201,21 +201,42 @@ extension EXT4 {
201201
let pathNode = pathPtr.pointee
202202
let inodeNumber = Int(pathNode.inode) - 1
203203
let pathInodePtr = self.inodes[inodeNumber]
204-
var pathInode = pathInodePtr.pointee
204+
let pathInode = pathInodePtr.pointee
205205

206206
if directoryWhiteout && !pathInode.mode.isDir() {
207207
throw Error.notDirectory(path)
208208
}
209209

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

214225
guard !directoryWhiteout else {
215226
return
216227
}
217228

218-
if let parentNodePtr = self.tree.lookup(path: path.dir) {
229+
try _unlink(parentNodePtr: self.tree.lookup(path: path.dir), pathNodePtr: pathPtr)
230+
}
231+
232+
private func _unlink(parentNodePtr: Ptr<FileTree.FileTreeNode>?, pathNodePtr: Ptr<FileTree.FileTreeNode>) throws {
233+
let pathNode = pathNodePtr.pointee
234+
let pathComponent = pathNode.name
235+
let inodeNumber = Int(pathNode.inode) - 1
236+
let pathInodePtr = self.inodes[inodeNumber]
237+
var pathInode = pathInodePtr.pointee
238+
239+
if let parentNodePtr {
219240
let parentNode = parentNodePtr.pointee
220241
let parentInodePtr = self.inodes[Int(parentNode.inode) - 1]
221242
var parentInode = parentInodePtr.pointee
@@ -226,7 +247,7 @@ extension EXT4 {
226247
}
227248
parentInodePtr.initialize(to: parentInode)
228249
parentNode.children.removeAll { childPtr in
229-
childPtr.pointee.name == path.base
250+
childPtr.pointee.name == pathComponent
230251
}
231252
parentNodePtr.initialize(to: parentNode)
232253
}
@@ -347,6 +368,10 @@ extension EXT4 {
347368
guard mode.isLink() else { // unless it is a link, then it can be replaced by a dir
348369
throw Error.notFile(path)
349370
}
371+
// root cannot be replaced with a link
372+
if path.isRoot {
373+
throw Error.unsupportedFiletype
374+
}
350375
}
351376
try self.unlink(path: path)
352377
}
@@ -953,7 +978,7 @@ extension EXT4 {
953978
contentsOf: Array<UInt8>.init(repeating: 0, count: Int(EXT4.InodeSize) - inodeSize))
954979
}
955980
let tableSize: UInt64 = UInt64(EXT4.InodeSize) * blockGroups * inodesPerGroup
956-
let rest = tableSize - uint32(self.inodes.count) * EXT4.InodeSize
981+
let rest = tableSize - UInt32(self.inodes.count) * EXT4.InodeSize
957982
let zeroBlock = Array<UInt8>.init(repeating: 0, count: Int(self.blockSize))
958983
for _ in 0..<(rest / self.blockSize) {
959984
try self.handle.write(contentsOf: zeroBlock)

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 & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -329,7 +329,7 @@ extension Socket {
329329

330330
var cmsgBuf = [UInt8](repeating: 0, count: Int(CZ_CMSG_SPACE(Int(MemoryLayout<Int32>.size))))
331331
msg.msg_control = withUnsafeMutablePointer(to: &cmsgBuf[0]) { UnsafeMutableRawPointer($0) }
332-
msg.msg_controllen = socklen_t(cmsgBuf.count)
332+
msg.msg_controllen = numericCast(cmsgBuf.count)
333333

334334
let recvResult = withUnsafeMutablePointer(to: &msg) { msgPtr in
335335
sysRecvmsg(handle.fileDescriptor, msgPtr, 0)
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
//===----------------------------------------------------------------------===//
2+
// Copyright © 2025 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) throws -> T
82+
) 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 body(formatter, imagePath)
97+
return result
98+
}
99+
100+
@Test
101+
func unpack_with_opaque_whiteout_path_does_not_stack_overflow_and_cleans_directory() 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 withFormatter { formatter, imagePath in
110+
try 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(try reader.exists(FilePath("/usr/local/bin")) == false)
122+
123+
#expect(try reader.exists(FilePath("/usr/local/bin/foo")) == false)
124+
}
125+
}
126+
127+
@Test
128+
func directoryWhiteout_from_wh_opq_path_with_repeated_slashes_terminates() throws {
129+
try 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() throws {
169+
try withFormatter { formatter, _ in
170+
// Step 1: create /_d
171+
try formatter.create(
172+
path: FilePath("/_d"),
173+
mode: EXT4.Inode.Mode(.S_IFDIR, 0o755)
174+
)
175+
176+
// Step 2: create symlink / -> /_
177+
try formatter.create(
178+
path: FilePath("/"),
179+
link: FilePath("/_"),
180+
mode: EXT4.Inode.Mode(.S_IFLNK, 0o777)
181+
)
182+
183+
try formatter.create(
184+
path: FilePath("/_"),
185+
mode: EXT4.Inode.Mode(.S_IFDIR, 0o755)
186+
)
187+
188+
try formatter.create(
189+
path: FilePath("/"),
190+
link: FilePath("/_"),
191+
mode: EXT4.Inode.Mode(.S_IFLNK, 0o777)
192+
)
193+
}
194+
}
195+
196+
@Test
197+
func file_whiteouts_and_directory_whiteouts_interact_correctly() throws {
198+
try withFormatter { formatter, imagePath in
199+
// Lower‑layer content
200+
try formatter.create(
201+
path: FilePath("/opt"),
202+
mode: EXT4.Inode.Mode(.S_IFDIR, 0o755)
203+
)
204+
try formatter.create(
205+
path: FilePath("/opt/app"),
206+
mode: EXT4.Inode.Mode(.S_IFDIR, 0o755)
207+
)
208+
try formatter.create(
209+
path: FilePath("/opt/app/cache"),
210+
mode: EXT4.Inode.Mode(.S_IFDIR, 0o755)
211+
)
212+
try formatter.create(
213+
path: FilePath("/opt/app/cache/file"),
214+
mode: EXT4.Inode.Mode(.S_IFREG, 0o644)
215+
)
216+
try formatter.unlink(path: FilePath("/opt/app/cache/file"))
217+
try formatter.unlink(
218+
path: FilePath("/opt/app/cache"),
219+
directoryWhiteout: true
220+
)
221+
try formatter.close()
222+
223+
let reader = try EXT4.EXT4Reader(blockDevice: FilePath(imagePath.description))
224+
#expect(try reader.exists(FilePath("/opt")))
225+
#expect(try reader.exists(FilePath("/opt/app")))
226+
#expect(try reader.exists(FilePath("/opt/app/cache")))
227+
#expect(try reader.exists(FilePath("/opt/app/cache/file")) == false)
228+
}
229+
}
230+
}

0 commit comments

Comments
 (0)