Skip to content

Commit 5dacd46

Browse files
committed
Prevent recursion attacks in EXT4Formatter.unlink
1 parent 747ea99 commit 5dacd46

5 files changed

Lines changed: 251 additions & 14 deletions

File tree

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: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
import ContainerizationArchive
2+
import ContainerizationEXT4
3+
import Foundation
4+
import SystemPackage
5+
import Testing
6+
7+
struct EXT4WhiteoutTests {
8+
9+
private func makeTempFileURL(prefix: String) throws -> URL {
10+
let base = FileManager.default.temporaryDirectory
11+
let url = base.appendingPathComponent("\(prefix)-\(UUID().uuidString)")
12+
return url
13+
}
14+
15+
private func writeLayerWithOpaqueWhiteout(to url: URL) throws {
16+
let writer = try ArchiveWriter(
17+
format: .pax,
18+
filter: .gzip,
19+
file: url
20+
)
21+
22+
let ts = Date()
23+
24+
let entry = WriteEntry()
25+
entry.modificationDate = ts
26+
entry.creationDate = ts
27+
entry.owner = 0
28+
entry.group = 0
29+
30+
entry.fileType = .directory
31+
entry.permissions = 0o755
32+
33+
entry.path = "usr"
34+
try writer.writeEntry(entry: entry, data: nil)
35+
36+
entry.path = "usr/local"
37+
try writer.writeEntry(entry: entry, data: nil)
38+
39+
entry.path = "usr/local/bin"
40+
try writer.writeEntry(entry: entry, data: nil)
41+
42+
entry.fileType = .regular
43+
entry.permissions = 0o644
44+
45+
let fooData = Data("hello\n".utf8)
46+
entry.path = "usr/local/bin/foo"
47+
entry.size = Int64(fooData.count)
48+
try writer.writeEntry(entry: entry, data: fooData)
49+
50+
entry.fileType = .regular
51+
entry.permissions = 0o000
52+
entry.size = 0
53+
entry.path = "usr//.wh..wh..opq"
54+
try writer.writeEntry(entry: entry, data: nil)
55+
56+
try writer.finishEncoding()
57+
}
58+
59+
private func withFormatter<T>(
60+
prefix: String = "ext4-whiteout",
61+
blockSize: UInt32 = 4096,
62+
minDiskSize: UInt64 = 16.mib(),
63+
_ body: (EXT4.Formatter, FilePath) throws -> T
64+
) throws -> T {
65+
let imageURL = try makeTempFileURL(prefix: prefix)
66+
let imagePath = FilePath(imageURL.path)
67+
68+
defer {
69+
try? FileManager.default.removeItem(at: imageURL)
70+
}
71+
72+
let formatter = try EXT4.Formatter(
73+
imagePath,
74+
blockSize: blockSize,
75+
minDiskSize: minDiskSize
76+
)
77+
78+
let result = try body(formatter, imagePath)
79+
return result
80+
}
81+
82+
@Test
83+
func unpack_with_opaque_whiteout_path_does_not_stack_overflow_and_cleans_directory() throws {
84+
let layerURL = try makeTempFileURL(prefix: "ext4-wh-layer")
85+
defer {
86+
try? FileManager.default.removeItem(at: layerURL)
87+
}
88+
89+
try writeLayerWithOpaqueWhiteout(to: layerURL)
90+
91+
try withFormatter { formatter, imagePath in
92+
try formatter.unpack(
93+
source: FilePath(layerURL.path).url,
94+
format: .pax,
95+
compression: .gzip,
96+
progress: nil
97+
)
98+
99+
try formatter.close()
100+
101+
let reader = try EXT4.EXT4Reader(blockDevice: FilePath(imagePath.description))
102+
103+
#expect(try reader.exists(FilePath("/usr/local/bin")) == false)
104+
105+
#expect(try reader.exists(FilePath("/usr/local/bin/foo")) == false)
106+
}
107+
}
108+
109+
@Test
110+
func directoryWhiteout_from_wh_opq_path_with_repeated_slashes_terminates() throws {
111+
try withFormatter { formatter, _ in
112+
try formatter.create(
113+
path: FilePath("/usr"),
114+
mode: EXT4.Inode.Mode(.S_IFDIR, 0o755)
115+
)
116+
try formatter.create(
117+
path: FilePath("/usr/local"),
118+
mode: EXT4.Inode.Mode(.S_IFDIR, 0o755)
119+
)
120+
try formatter.create(
121+
path: FilePath("/usr/local/bin"),
122+
mode: EXT4.Inode.Mode(.S_IFDIR, 0o755)
123+
)
124+
try formatter.create(
125+
path: FilePath("/usr/local/bin/foo"),
126+
mode: EXT4.Inode.Mode(.S_IFREG, 0o644)
127+
)
128+
try formatter.create(
129+
path: FilePath("/usr/local/bin/bar"),
130+
mode: EXT4.Inode.Mode(.S_IFREG, 0o644)
131+
)
132+
133+
let whiteoutEntry = FilePath("//usr//.wh..wh..opq")
134+
let directoryToWhiteout = whiteoutEntry.dir
135+
let normalized = directoryToWhiteout.lexicallyNormalized()
136+
#expect(normalized == FilePath("/usr"))
137+
try formatter.unlink(path: directoryToWhiteout, directoryWhiteout: true)
138+
}
139+
}
140+
141+
/// Test the exact recursion attack sequence:
142+
/// create /_d
143+
/// create symlink / -> /_
144+
/// create /_
145+
/// create symlink / -> /_
146+
///
147+
/// This creates a recursive symlink structure that can cause infinite recursion
148+
/// during directory traversal operations.
149+
@Test
150+
func recursion_attack_sequence_does_not_cause_infinite_recursion() throws {
151+
try withFormatter { formatter, _ in
152+
// Step 1: create /_d
153+
try formatter.create(
154+
path: FilePath("/_d"),
155+
mode: EXT4.Inode.Mode(.S_IFDIR, 0o755)
156+
)
157+
158+
// Step 2: create symlink / -> /_
159+
try formatter.create(
160+
path: FilePath("/"),
161+
link: FilePath("/_"),
162+
mode: EXT4.Inode.Mode(.S_IFLNK, 0o777)
163+
)
164+
165+
try formatter.create(
166+
path: FilePath("/_"),
167+
mode: EXT4.Inode.Mode(.S_IFDIR, 0o755)
168+
)
169+
170+
try formatter.create(
171+
path: FilePath("/"),
172+
link: FilePath("/_"),
173+
mode: EXT4.Inode.Mode(.S_IFLNK, 0o777)
174+
)
175+
}
176+
}
177+
178+
@Test
179+
func file_whiteouts_and_directory_whiteouts_interact_correctly() throws {
180+
try withFormatter { formatter, imagePath in
181+
// Lower‑layer content
182+
try formatter.create(
183+
path: FilePath("/opt"),
184+
mode: EXT4.Inode.Mode(.S_IFDIR, 0o755)
185+
)
186+
try formatter.create(
187+
path: FilePath("/opt/app"),
188+
mode: EXT4.Inode.Mode(.S_IFDIR, 0o755)
189+
)
190+
try formatter.create(
191+
path: FilePath("/opt/app/cache"),
192+
mode: EXT4.Inode.Mode(.S_IFDIR, 0o755)
193+
)
194+
try formatter.create(
195+
path: FilePath("/opt/app/cache/file"),
196+
mode: EXT4.Inode.Mode(.S_IFREG, 0o644)
197+
)
198+
try formatter.unlink(path: FilePath("/opt/app/cache/file"))
199+
try formatter.unlink(
200+
path: FilePath("/opt/app/cache"),
201+
directoryWhiteout: true
202+
)
203+
try formatter.close()
204+
205+
let reader = try EXT4.EXT4Reader(blockDevice: FilePath(imagePath.description))
206+
#expect(try reader.exists(FilePath("/opt")))
207+
#expect(try reader.exists(FilePath("/opt/app")))
208+
#expect(try reader.exists(FilePath("/opt/app/cache")))
209+
#expect(try reader.exists(FilePath("/opt/app/cache/file")) == false)
210+
}
211+
}
212+
}

0 commit comments

Comments
 (0)