Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ SWIFT ?= /usr/bin/swift
else
SWIFT ?= swift
endif
HAWKEYE ?= $(shell command -v hawkeye 2>/dev/null || printf '%s' .local/bin/hawkeye)

ROOT_DIR := $(shell git rev-parse --show-toplevel)
BUILD_BIN_DIR = $(shell $(SWIFT) build -c $(BUILD_CONFIGURATION) $(SWIFT_SCRATCH_FLAGS) --show-bin-path)
Expand Down Expand Up @@ -460,14 +461,14 @@ swift-fmt-check:
.PHONY: update-licenses
update-licenses:
@echo Updating license headers...
@./scripts/ensure-hawkeye-exists.sh
@.local/bin/hawkeye format --fail-if-unknown --fail-if-updated false
@HAWKEYE="$(HAWKEYE)" ./scripts/ensure-hawkeye-exists.sh
@"$(HAWKEYE)" format --fail-if-unknown --fail-if-updated false

.PHONY: check-licenses
check-licenses:
@echo Checking license headers existence in source files...
@./scripts/ensure-hawkeye-exists.sh
@.local/bin/hawkeye check --fail-if-unknown
@HAWKEYE="$(HAWKEYE)" ./scripts/ensure-hawkeye-exists.sh
@"$(HAWKEYE)" check --fail-if-unknown

.PHONY: pre-commit
pre-commit:
Expand Down
2 changes: 1 addition & 1 deletion Sources/Containerization/LinuxPod.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1111,9 +1111,9 @@ extension LinuxPod {
let creationConfig = StandardVMConfig(configuration: vmConfig)
let vm = try await self.vmm.create(config: creationConfig)
let relayManager = UnixSocketRelayManager(vm: vm)
try await vm.start()

do {
try await vm.start()
let containers = state.containers
let sharedNamespaces = self.config.sharedNamespaces
let pauseProcessHolder = Mutex<LinuxProcess?>(nil)
Expand Down
48 changes: 45 additions & 3 deletions Sources/ContainerizationEXT4/EXT4+Reader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -165,11 +165,17 @@ extension EXT4 {
}
}

private func getDirEntries(dirTree: Data) throws -> [(String, InodeNumber)] {
internal func getDirEntries(dirTree: Data) throws -> [(String, InodeNumber)] {
var children: [(String, InodeNumber)] = []
var offset = 0
let entrySize = MemoryLayout<DirectoryEntry>.size
while offset < dirTree.count {
// A malformed image may leave fewer than `entrySize` bytes at the
// tail of the block. Stop before reading a fixed-size header that
// would run past the buffer and trap in `subdata`.
guard offset + entrySize <= dirTree.count else {
break
}
let dirEntry = dirTree.subdata(in: offset..<offset + entrySize).withUnsafeBytes {
$0.loadLittleEndian(as: DirectoryEntry.self)
}
Expand All @@ -180,7 +186,17 @@ extension EXT4 {
offset += Int(dirEntry.recordLength)
continue
}
let nameData = dirTree.subdata(in: offset + 8..<offset + 8 + Int(dirEntry.nameLength))
// `nameLength` and `recordLength` are attacker-controlled. Reject an
// entry whose name would extend past its record or past the block,
// which would otherwise trap in `subdata` on the read below.
let nameStart = offset + entrySize
let nameLength = Int(dirEntry.nameLength)
guard Int(dirEntry.recordLength) >= entrySize + nameLength,
nameStart + nameLength <= dirTree.count
else {
break
}
let nameData = dirTree.subdata(in: nameStart..<nameStart + nameLength)
let name = String(data: nameData, encoding: .utf8) ?? ""
children.append((name, dirEntry.inode))
offset += Int(dirEntry.recordLength)
Expand All @@ -193,13 +209,27 @@ extension EXT4 {
func getExtents(inode: InodeNumber) throws -> [(start: UInt32, end: UInt32)]? {
let inode = try self.getInode(number: inode)
let inodeBlock = Data(tupleToArray(inode.block))
return try self.decodeExtents(inodeBlock: inodeBlock)
}

/// Decode the extent tree rooted in an inode's block region.
///
/// `inodeBlock` is untrusted (it comes from an ext4 image), so the entry
/// count in each extent header is validated against the bytes actually
/// available before every fixed-size read. Without these checks a crafted
/// `entries` count drives `subdata` past the end of the buffer and traps
/// the process.
internal func decodeExtents(inodeBlock: Data) throws -> [(start: UInt32, end: UInt32)] {
var offset = 0
var extents: [(start: UInt32, end: UInt32)] = []

let extentHeaderSize = MemoryLayout<ExtentHeader>.size
let extentIndexSize = MemoryLayout<ExtentIndex>.size
let extentLeafSize = MemoryLayout<ExtentLeaf>.size
// read extent header
guard offset + extentHeaderSize <= inodeBlock.count else {
return []
}
let header = inodeBlock.subdata(in: offset..<offset + extentHeaderSize).withUnsafeBytes {
$0.loadLittleEndian(as: ExtentHeader.self)
}
Expand All @@ -211,6 +241,9 @@ extension EXT4 {
case 0:
// When depth is 0 the extent header is followed by extent leaves
for _ in 0..<header.entries {
guard offset + extentLeafSize <= inodeBlock.count else {
throw Error.invalidExtents
}
let leaf = inodeBlock.subdata(in: offset..<offset + extentLeafSize).withUnsafeBytes {
$0.loadLittleEndian(as: ExtentLeaf.self)
}
Expand All @@ -220,6 +253,9 @@ extension EXT4 {
case 1:
// When depth is 1 the extent header is followed by extent indices which point to leaves
for _ in 0..<header.entries {
guard offset + extentIndexSize <= inodeBlock.count else {
throw Error.invalidExtents
}
let indexNode = inodeBlock.subdata(in: offset..<offset + extentIndexSize).withUnsafeBytes {
$0.loadLittleEndian(as: ExtentIndex.self)
}
Expand All @@ -228,14 +264,20 @@ extension EXT4 {
throw EXT4.Error.couldNotReadBlock(indexNode.leafLow)
}
var blockOffset = 0
let leafHeader = block.subdata(in: blockOffset..<extentHeaderSize).withUnsafeBytes {
guard blockOffset + extentHeaderSize <= block.count else {
throw Error.invalidExtents
}
let leafHeader = block.subdata(in: blockOffset..<blockOffset + extentHeaderSize).withUnsafeBytes {
$0.loadLittleEndian(as: ExtentHeader.self)
}
guard leafHeader.magic == EXT4.ExtentHeaderMagic else {
throw Error.invalidExtents
}
blockOffset += extentHeaderSize
for _ in 0..<leafHeader.entries {
guard blockOffset + extentLeafSize <= block.count else {
throw Error.invalidExtents
}
let leaf = block.subdata(in: blockOffset..<blockOffset + extentLeafSize).withUnsafeBytes {
$0.loadLittleEndian(as: ExtentLeaf.self)
}
Expand Down
19 changes: 10 additions & 9 deletions Sources/ContainerizationOCI/Client/RegistryClient+Token.swift
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,10 @@ struct TokenRequest: Sendable {

/// The realm against which the token should be requested.
let realm: String
/// The name of the service which hosts the resource.
let service: String
/// The name of the service which hosts the resource. Optional: the parameter is a Docker
/// registry convention, not a requirement of RFC 6750, and some registries (notably Google
/// Artifact Registry) omit it from their challenge entirely.
let service: String?
/// Whether to return a refresh token along with the bearer token.
let offlineToken: Bool
/// String identifying the client.
Expand All @@ -119,7 +121,7 @@ struct TokenRequest: Sendable {

init(
realm: String,
service: String,
service: String?,
clientId: String,
scope: String?,
offlineToken: Bool = false
Expand Down Expand Up @@ -202,9 +204,11 @@ extension RegistryClient {
}
try validateRealm(components)
components.queryItems = [
URLQueryItem(name: "client_id", value: request.clientId),
URLQueryItem(name: "service", value: request.service),
URLQueryItem(name: "client_id", value: request.clientId)
]
if let service = request.service {
components.queryItems?.append(URLQueryItem(name: "service", value: service))
}
var scope = ""
if let reqScope = request.scope {
scope = reqScope
Expand Down Expand Up @@ -328,11 +332,8 @@ extension RegistryClient {
guard let realm = bearerChallenge.realm else {
throw ContainerizationError(.invalidArgument, message: "cannot parse realm from \(TokenRequest.authenticateHeaderName) header")
}
guard let service = bearerChallenge.service else {
throw ContainerizationError(.invalidArgument, message: "cannot parse service from \(TokenRequest.authenticateHeaderName) header")
}
let scope = bearerChallenge.scope
let tokenRequest = TokenRequest(realm: realm, service: service, clientId: self.clientID, scope: scope)
let tokenRequest = TokenRequest(realm: realm, service: bearerChallenge.service, clientId: self.clientID, scope: scope)
return tokenRequest
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ private final class TokenTaskWaiter<Response: Sendable>: @unchecked Sendable {
actor RegistryTokenCache {
private struct Key: Hashable {
let realm: String
let service: String
let service: String?
let scope: String?

init(_ request: TokenRequest) {
Expand Down
7 changes: 6 additions & 1 deletion Sources/ContainerizationOS/Terminal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -140,9 +140,14 @@ extension Terminal {

extension Terminal {
/// Enable raw mode for the pty.
public func setraw() throws {
/// - Parameter preserveSignalGeneration: Keep `ISIG` enabled so control
/// characters such as Ctrl-C still signal the foreground process.
public func setraw(preserveSignalGeneration: Bool = false) throws {
var attr = try Self.getattr(descriptor)
cfmakeraw(&attr)
if preserveSignalGeneration {
attr.c_lflag |= tcflag_t(ISIG)
}
attr.c_oflag = attr.c_oflag | tcflag_t(OPOST)
try fromSyscall(tcsetattr(descriptor, TCSANOW, &attr))
}
Expand Down
137 changes: 137 additions & 0 deletions Tests/ContainerizationEXT4Tests/TestEXT4Reader+DirEntryBounds.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
//===----------------------------------------------------------------------===//
// 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 Foundation
import SystemPackage
import Testing

@testable import ContainerizationEXT4

/// Regression tests for directory-entry parsing against malformed/hostile ext4
/// directory blocks. `getDirEntries` runs on attacker-controlled bytes (an ext4
/// image is untrusted input); a crafted `nameLength`/`recordLength` must not be
/// able to drive `Data.subdata` past the block buffer and trap the process.
@Suite
struct EXT4DirEntryBoundsTests {

// MARK: - Byte helpers

private func le32(_ v: UInt32) -> [UInt8] {
[UInt8(v & 0xff), UInt8((v >> 8) & 0xff), UInt8((v >> 16) & 0xff), UInt8((v >> 24) & 0xff)]
}
private func le16(_ v: UInt16) -> [UInt8] {
[UInt8(v & 0xff), UInt8((v >> 8) & 0xff)]
}

/// Encode the fixed 8-byte on-disk directory-entry header
/// (inode:4, recordLength:2, nameLength:1, fileType:1), little-endian.
private func header(inode: UInt32, recordLength: UInt16, nameLength: UInt8, fileType: UInt8 = 1) -> [UInt8] {
le32(inode) + le16(recordLength) + [nameLength, fileType]
}

/// Build a throwaway valid ext4 image and return an open reader for it.
/// `getDirEntries` does not touch reader state, so any reader instance works.
private func makeReader() throws -> (EXT4.EXT4Reader, URL) {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("ext4-direntry-\(UUID().uuidString).img")
let fmt = try EXT4.Formatter(FilePath(url.path), minDiskSize: 4 * 1024 * 1024)
try fmt.close()
let reader = try EXT4.EXT4Reader(blockDevice: FilePath(url.path))
return (reader, url)
}

// MARK: - Tests

/// Well-formed block: both entries parse, guards do not false-reject.
@Test
func wellFormedBlockParsesAllEntries() throws {
let (reader, url) = try makeReader()
defer { try? FileManager.default.removeItem(at: url) }

var block = [UInt8]()
// entry "aa" (inode 11), record 16 = 8 header + 2 name + 6 pad
block += header(inode: 11, recordLength: 16, nameLength: 2)
block += Array("aa".utf8)
block += [UInt8](repeating: 0, count: 16 - 8 - 2)
// entry "bb" (inode 12), record 16
block += header(inode: 12, recordLength: 16, nameLength: 2)
block += Array("bb".utf8)
block += [UInt8](repeating: 0, count: 16 - 8 - 2)
#expect(block.count == 32)

let entries = try reader.getDirEntries(dirTree: Data(block))
#expect(entries.count == 2)
#expect(entries[0].0 == "aa")
#expect(entries[0].1 == 11)
#expect(entries[1].0 == "bb")
#expect(entries[1].1 == 12)
}

/// Fewer than a full header remain at the tail: must stop, not trap when
/// loading the fixed header past the end of the block.
@Test
func shortHeaderTailDoesNotTrap() throws {
let (reader, url) = try makeReader()
defer { try? FileManager.default.removeItem(at: url) }

var block = [UInt8]()
// one valid entry occupying bytes 0..<12
block += header(inode: 11, recordLength: 12, nameLength: 4)
block += Array("aaaa".utf8)
// 4 trailing bytes: offset lands at 12 with only 4 (< 8) bytes left
block += [UInt8](repeating: 0, count: 4)
#expect(block.count == 16)

let entries = try reader.getDirEntries(dirTree: Data(block))
#expect(entries.count == 1)
#expect(entries[0].0 == "aaaa")
#expect(entries[0].1 == 11)
}

/// `nameLength` larger than the entry's own `recordLength`: must be rejected
/// rather than reading a name that runs past the record/block.
@Test
func nameLongerThanRecordDoesNotTrap() throws {
let (reader, url) = try makeReader()
defer { try? FileManager.default.removeItem(at: url) }

// recordLength 16 (>= 8, passes the existing guard) but nameLength 200,
// so a name read would span 8..<208 and trap.
var block = header(inode: 12, recordLength: 16, nameLength: 200)
block += [UInt8](repeating: 0, count: 8) // pad block out to 16 bytes
#expect(block.count == 16)

let entries = try reader.getDirEntries(dirTree: Data(block))
#expect(entries.isEmpty)
}

/// `recordLength` claims room for the name but the block itself is too small:
/// the name read would run off the end of the buffer and trap.
@Test
func nameRunningPastBlockDoesNotTrap() throws {
let (reader, url) = try makeReader()
defer { try? FileManager.default.removeItem(at: url) }

// recordLength 512 admits nameLength 100 (512 >= 8 + 100), but the block
// is only 16 bytes, so name bytes 8..<108 lie outside the buffer.
var block = header(inode: 12, recordLength: 512, nameLength: 100)
block += [UInt8](repeating: 0, count: 8) // block is only 16 bytes total
#expect(block.count == 16)

let entries = try reader.getDirEntries(dirTree: Data(block))
#expect(entries.isEmpty)
}
}
Loading