From 34d48fec66c4c296152a3f720bb8c027e123b503 Mon Sep 17 00:00:00 2001 From: osy <50960678+osy@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:25:01 -0700 Subject: [PATCH] download: never extract a symlink from a downloaded VM Bumping ZIPFoundation to 0.9.20 brought in its symlink containment guard, but that guard is not sufficient here. `URL.isContained(in:)` collapses repeated separators in a single pass, so a link target ending in `///../elsewhere` is accepted while the filesystem resolves it to `/../elsewhere`. A crafted archive could pair such a link with a later regular file underneath it, whose own path passes the containment check, and write outside the package again. Nothing in UTM ever stores a symlink inside a package, so reject them outright instead of trying to validate the target. Also remove the destination on failure. Extraction created the .utm directory up front and a mid-loop error left the partial contents in place, where the library would list them as a real virtual machine that survives a relaunch. Assisted-by: Claude:claude-fable-5-1 --- Platform/UTMDownloadVMTask.swift | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/Platform/UTMDownloadVMTask.swift b/Platform/UTMDownloadVMTask.swift index bc34fd33..958e7963 100644 --- a/Platform/UTMDownloadVMTask.swift +++ b/Platform/UTMDownloadVMTask.swift @@ -85,13 +85,24 @@ class UTMDownloadVMTask: UTMDownloadTask { let destinationURL = destinationFolder.appendingPathComponent(destinationUtmDirectory, isDirectory: true) /// create the .utm directory try fileManager.createDirectory(at: destinationURL, withIntermediateDirectories: false) - /// get and extract all files contained in the UTM directory, except the `__MACOSX` folder - let containedFiles = archive.filter({ $0.path.hasPrefix(utmFolderInZip.path) && !$0.path.hasSuffix(utmDirectoryEnding) && !$0.path.contains("__MACOSX") }) - for file in containedFiles { - let relativePath = String(file.path.dropFirst(utmFolderInZip.path.count)) - let isDirectory = file.path.hasSuffix("/") - let fileURL = try containedDestination(for: relativePath, in: destinationURL, isDirectory: isDirectory) - _ = try archive.extract(file, to: fileURL, skipCRC32: true) + do { + /// get and extract all files contained in the UTM directory, except the `__MACOSX` folder + let containedFiles = archive.filter({ $0.path.hasPrefix(utmFolderInZip.path) && !$0.path.hasSuffix(utmDirectoryEnding) && !$0.path.contains("__MACOSX") }) + for file in containedFiles { + /// we never store a symlink in a package, and a link target that survives a containment + /// check can still be resolved outside of the package when it is written through later + guard file.type != .symlink else { + throw UnzipUnsafePathError() + } + let relativePath = String(file.path.dropFirst(utmFolderInZip.path.count)) + let isDirectory = file.path.hasSuffix("/") + let fileURL = try containedDestination(for: relativePath, in: destinationURL, isDirectory: isDirectory) + _ = try archive.extract(file, to: fileURL, skipCRC32: true) + } + } catch { + /// a partially extracted package would still be picked up as a VM by the library + try? fileManager.removeItem(at: destinationURL) + throw error } return destinationURL } else {