Compare commits

..

20 commits
1.0.0 ... main

Author SHA1 Message Date
CelloSerenity
32287268fa fix: restore personalized DDI mounting 2026-09-27 14:58:13 -06:00
CelloSerenity
5d732f94b8 feat: support base64 custom scripts 2026-09-25 10:42:55 -06:00
CelloSerenity
ee457f2f40
chore: bump version to 1.7.0 2026-09-24 07:43:46 -06:00
CelloSerenity
ef4e105cd7
Update build environment to use Xcode 27 2026-09-24 07:39:24 -06:00
CelloSerenity
4ba4072748
Update build.yml 2026-09-23 22:46:24 -06:00
CelloSerenity
e6bbfe0399 chore: bump version to 1.6.0 2026-09-23 22:40:43 -06:00
CelloSerenity
4e08cecab9 feat: migrate DDI mounting to cryptex 2026-09-23 21:39:02 -06:00
CelloSerenity
709898d500
Merge pull request #4 from khanhduytran0/patch-1 2026-09-23 21:33:23 -06:00
Duy Tran
9ba3f85890
chore: strip resulting binary
Resulting StikJIT binary dropped from 18MB to 2.5MB
2026-09-23 14:48:29 +07:00
CelloSerenity
3623e72587 docs: restructure integration guide 2026-08-25 16:12:50 -06:00
CelloSerenity
640fac91de feat: restructure StikJIT integration workflow 2026-08-18 11:03:43 -06:00
CelloSerenity
dce417d893
Update checkout action version to v7 2026-07-06 16:22:05 -06:00
CelloSerenity
a9390e36db Batch JIT page bless commands to reduce debugserver round-trips 2026-07-06 16:16:41 -06:00
CelloSerenity
1230fc835b
Mark DeveloperDiskImageService as iOS 17.4+ to fix availability error (#3) 2026-07-06 09:56:35 -04:00
CelloSerenity
95b8e4d3f7
Add DDI mount support and document usage requirements (#2) 2026-07-05 22:14:46 -04:00
ee3d5836aa
Update README.md 2026-06-30 13:03:30 -04:00
CelloSerenity
2320badda8
Support for legacy script (#1) 2026-06-29 07:10:34 -04:00
Stephen
5bdecfcafa Update README.md 2026-06-23 12:59:55 -04:00
41498683f3
Update README.md 2026-06-23 12:54:54 -04:00
38762b8306
Update README.md 2026-06-23 12:54:20 -04:00
19 changed files with 5135 additions and 135 deletions

View file

@ -14,11 +14,11 @@ permissions:
jobs:
build:
name: Build XCFramework
runs-on: macos-15
runs-on: xcode-27
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Show toolchain
run: xcodebuild -version
@ -50,7 +50,7 @@ jobs:
run: zip -ry StikJIT.xcframework.zip StikJIT.xcframework
- name: Upload artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: StikJIT.xcframework
path: StikJIT.xcframework.zip
@ -58,7 +58,7 @@ jobs:
- name: Create release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
files: StikJIT.xcframework.zip
generate_release_notes: true

443
INTEGRATION.md Normal file
View file

@ -0,0 +1,443 @@
# Integrating StikJIT
This guide covers both the app-side iOS 26 JIT protocol and the available ways to acquire JIT. The process receiving JIT must have `get-task-allow`.
## Choose the path you need
This guide separates two different jobs:
- **Part 1: Add iOS 26 JIT support** changes the app's JIT memory allocator so it can cooperate with a script where TXM/SPTM is present.
- **Part 2: Integrate StikDebug and StikJIT** adds the StikDebug URL scheme and optional Built-in StikJIT to an app that already works with iOS 26 JIT and manual StikDebug activation.
If the app already enables JIT successfully after the user manually selects it and its script in StikDebug, skip Part 1. Record whether it uses `universal.js`, `legacy.js`, or a custom script file, then complete Part 2. Part 2 does not require changing a working JIT allocator.
If the app does not already have script-based iOS 26 JIT, complete Part 1 first and then Part 2.
# Part 1: Add iOS 26 JIT support
On a device where TXM/SPTM is not present, attaching and detaching the debugger is enough to enable JIT. Where TXM/SPTM is present, the debugger flag alone is not enough: each executable memory region must be prepared through the debug connection before the app executes code from it.
StikJIT and StikDebug handle the debugger side of this process. The host app's JIT memory allocator must implement the matching breakpoint protocol. Selecting `universal.js` does not retrofit that protocol into an arbitrary JIT engine.
## Implement the universal protocol (recommended)
The universal script recognizes these calls on arm64:
```c
__attribute__((noinline, optnone, naked))
void JIT26Detach(void) {
__asm__(
"mov x16, #0\n"
"brk #0xf00d\n"
"ret\n"
);
}
__attribute__((noinline, optnone, naked))
void* JIT26PrepareRegion(void* address, size_t length) {
__asm__(
"mov x16, #1\n"
"brk #0xf00d\n"
"ret\n"
);
}
```
The arguments arrive in `x0` and `x1`. `JIT26PrepareRegion` advances past the breakpoint, prepares the RX mapping, and returns its address in `x0`. Passing a null address asks the debug server to allocate the RX mapping. If the JIT engine creates the mapping itself, pass its address and size instead. The engine is still responsible for creating any writable alias it needs and for preserving W^X.
Use this order where TXM/SPTM is present:
1. Start the chosen JIT acquisition method. Its universal script attaches and waits for breakpoint calls.
2. Wait until `CS_DEBUGGED` appears in the host before executing any breakpoint call. A `brk` without the script attached will crash the process.
3. Allocate or request every initial RX region and call `JIT26PrepareRegion` for each one.
4. Create the writable aliases and finish initializing the JIT allocator.
5. Call `JIT26Detach`.
6. Mark JIT ready only after the prepare calls and detach call have returned successfully.
Preallocate executable regions before detaching. A region introduced later cannot be prepared by a script that has already detached; reconnect and repeat the protocol if the engine must add one.
> **Note:** We keep legacy script support only for backward compatibility with existing applications. If you are adding JIT support to an app now, use the universal script.
After Part 1 works with the matching script in StikDebug, continue with Part 2. The chosen script is developer-controlled and must not become a user setting.
# Part 2: Integrate StikDebug and StikJIT into an existing iOS 26 JIT app
Part 2 assumes all of the following already work:
- The app can wait for an external debugger and detect when JIT is ready.
- The app's JIT allocator implements its iOS 26 breakpoint and executable-region protocol.
- JIT works when the developer or user manually selects the app and matching script in StikDebug.
- The developer knows whether the app requires `universal.js`, `legacy.js`, or a custom script file.
If the app currently uses `legacy.js`, we recommend switching it to the universal method, but this is not required to complete Part 2.
Part 2 preserves that existing **Wait for Debugger** path and adds automatic StikDebug URL launching and optional Built-in StikJIT. It does not change the app's JIT allocator or script protocol. If any prerequisite above is missing, complete Part 1 first.
Complete the Built-in StikJIT subsections only if the app will embed the framework. A StikDebug-only integration can skip directly to **Configure the JIT methods**.
## Built-in StikJIT: Embed the framework
Built-in StikJIT uses two processes because a process that attaches a debugger to itself deadlocks:
```text
Host app (target PID, pairing-file settings, get-task-allow check)
↕ XPC or another IPC mechanism
Helper app extension (StikJIT, DDI cache, blocking work)
```
1. Add `StikJIT.xcframework` to the Xcode project.
2. Link and embed it in the helper extension, not the host app.
3. Set the helper extension deployment target to iOS 17.4 or later.
4. Make sure the final app contains and signs the framework.
5. Give the helper a way to receive the host PID, pairing-file data, settings, progress, and results. XPC is a good fit.
If the host app supports older iOS versions, do not strongly link StikJIT into it. Keeping StikJIT in the iOS 17.4 helper prevents the app from trying to load the framework on an older system.
Do not add IOKit to the host app's bridging header. The XCFramework links `IOKit.framework` and imports the required declarations internally.
StikJIT does not provide or launch the helper extension. The app owns that integration.
## Built-in StikJIT: Gate every entry point
Before launching the helper, preparing the device, or enabling JIT, the host app should check:
1. The device is running iOS 17.4 or later.
2. The host app has `get-task-allow` set to `true`.
3. A pairing file is available and readable.
```swift
guard #available(iOS 17.4, *) else {
showJITError("StikJIT requires iOS 17.4 or later.")
return
}
guard EntitlementChecker.hasGetTaskAllow else {
showJITError(
"This installation does not have get-task-allow. " +
"Reinstall the app using a signing method that preserves this entitlement."
)
return
}
guard let pairingData = try? Data(contentsOf: pairingFileURL) else {
showJITError("Import a valid pairing file before enabling JIT.")
return
}
launchHelper(targetPID: getpid(), pairingData: pairingData)
```
Perform these checks on both the manual **Prepare JIT** path and the automatic game or workload launch path. `get-task-allow` belongs to the host process identified by `targetPID`; checking from inside the helper checks the wrong executable.
One host-side implementation is:
```objc
void* SecTaskCreateFromSelf(CFAllocatorRef allocator);
CFTypeRef SecTaskCopyValueForEntitlement(
void* task,
CFStringRef entitlement,
CFErrorRef* error);
static bool HasGetTaskAllow(void) {
void* task = SecTaskCreateFromSelf(NULL);
if (task == NULL) {
return false;
}
CFTypeRef value = SecTaskCopyValueForEntitlement(
task,
CFSTR("get-task-allow"),
NULL);
bool result = value == kCFBooleanTrue;
if (value != NULL) {
CFRelease(value);
}
CFRelease(task);
return result;
}
```
Link `Security.framework` to the host target. This uses Security framework SPI on iOS and is not App Store-safe. StikJIT integrations already require a distribution environment that permits the helper and JIT behavior.
## Built-in StikJIT: Store and import the pairing file
The recommended host-app location is:
```text
Documents/StikJIT/pairingFile.plist
```
```swift
let documents = FileManager.default.urls(
for: .documentDirectory,
in: .userDomainMask
)[0]
let stikJITDirectory = documents.appendingPathComponent("StikJIT")
let pairingFileURL = stikJITDirectory.appendingPathComponent("pairingFile.plist")
```
Create the directory when needed. Let the user import a pairing file with `UIDocumentPickerViewController`, use security-scoped access while copying it, and replace the stored file atomically. Do not log its contents.
Accept pairing files obtained through AFC/Finder, `idevice_pair`, or other pairing methods. To let users copy one into the app's Documents directory through AFC/Finder, add this to the host app's `Info.plist`:
```xml
<key>UIFileSharingEnabled</key>
<true/>
```
You may also enable in-place access from the Files app:
```xml
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
```
Because pairing data crosses a process boundary, the host can read it into `Data`, send that over XPC, and let the helper write an extension-local temporary file. Delete the temporary file immediately after the operation. This avoids needing an App Group.
## Built-in StikJIT: Keep the DDI cache in the helper
The helper can use its persistent Library directory:
```swift
let library = FileManager.default.urls(
for: .libraryDirectory,
in: .userDomainMask
)[0]
let paths = DDIPaths.default(
in: library.appendingPathComponent("StikJIT")
)
```
Use the same `DDIPaths` for preparation, JIT enablement, and cache reset. Before iOS 26.4, StikJIT downloads and mounts the three-file personalized DDI. On iOS 26.4 and newer, it downloads and installs the five-file cryptex DDI, which additionally includes cryptex info and a root hash. Do not validate cached DDI versions or contents yourself; StikJIT selects and replaces the cache format required by the running OS.
A successful DDI mount persists until the device reboots, so it normally needs to be mounted only once per boot.
## Built-in StikJIT: Call the APIs at the right time
StikJIT's preparation and enablement APIs are synchronous and blocking. Run them on one dedicated serial background queue in the helper. Do not split their ordered work into concurrent operations or convert it to `Task`/`async` calls.
Pass `configuration:` when the integration needs to override the tunnel endpoint or five-second connection timeout. The default endpoint is `10.7.0.1:49152`.
### When settings opens
Use `StikJIT.isTXMPresent` to show `Present`, `Not Present`, or `Unknown`. This is informational; the app's single script remains developer-defined.
### When the user selects Prepare JIT
After the host preflight, ask the helper to call:
```swift
let readiness = StikJIT.prepareDevice(
pairingFile: temporaryPairingFile,
paths: paths
) { stage in
reportPreparationStage(stage)
}
```
Display the result as:
- `.ready` → **Ready**
- `.unreachable(reason)` → **Unreachable**, with the reason
- `.preparationFailed(reason)` → **Not Ready**, with the reason
Preparation checks reachability, checks whether the DDI is mounted, downloads missing files, mounts, verifies the mount, and determines whether TXM is present. Those steps execute in that order.
### When the user launches a game or workload
Run the host preflight, launch the helper, and call `enableJIT`. Do not require the user to press **Prepare JIT** first; `enableJIT` performs preparation itself.
```swift
private let selectedScript: StikJIT.Script = .universal
try StikJIT.enableJIT(
targetPID: hostPID,
pairingFile: temporaryPairingFile,
ddiPaths: paths,
script: selectedScript,
forceScript: forceScript,
preparationProgress: reportPreparationStage,
progress: reportJITLog
)
```
Configure exactly one script in backend code: `.universal` or `.legacy` for the corresponding bundled script, `.custom(URL)` for a script readable from the helper-extension process, or `.customBase64(String)` for base64-encoded UTF-8 script data held elsewhere. For StikDebug requests, send the built-in filename for universal or legacy, or send the base64-encoded contents of the custom script. Do not expose this configuration to the user. The user-facing `forceScript` toggle only bypasses TXM detection and runs the configured script regardless.
```swift
let selectedScript: StikJIT.Script = .customBase64(storedScriptBase64)
```
### When the user resets the cache
Provide a **Reset Developer Disk Image** action that calls:
```swift
try StikJIT.resetCachedDDI(at: paths)
```
This removes only the five possible cached DDI files. The next preparation downloads the three-file personalized DDI or five-file cryptex DDI required by the running OS if mounting is necessary.
## Configure the JIT methods
Apps can expose one **JIT Method** setting with three mutually exclusive choices:
- **Wait for Debugger** preserves the app's existing external-debugger flow.
- **StikDebug** opens StikDebug and requests JIT for the current process.
- **Built-in StikJIT** uses the app's helper extension and this framework.
Route all three choices through the same host-side coordinator. Check `get-task-allow` first, return immediately if JIT is already ready, start only the selected method, and do not launch the game or workload until readiness has been established. Do not silently fall through to a different method after a failure.
For **Wait for Debugger**, keep the app's existing waiting and readiness behavior. Where TXM/SPTM is present, it must continue through the existing breakpoint protocol before reporting success; an ordinary debugger attachment without the correct script is not sufficient.
For **StikDebug**, always construct the URL with the bundle ID and current PID. If TXM/SPTM is present, also send the app's JIT script. Each app must use exactly one developer-defined script; never add runtime script selection or expose it as a user setting. When TXM/SPTM is not present, omit the script parameter because debugger attachment alone enables JIT.
This example is for an app that uses the recommended universal script:
```swift
private let stikDebugScriptName = "universal.js"
guard let bundleID = Bundle.main.bundleIdentifier else {
showJITError("Could not determine the app's bundle ID.")
return
}
var components = URLComponents()
components.scheme = "stikdebug"
components.host = "enable-jit"
components.queryItems = [
URLQueryItem(name: "bundle-id", value: bundleID),
URLQueryItem(name: "pid", value: String(getpid())),
]
if isTXMPresent {
components.queryItems?.append(
URLQueryItem(name: "script-name", value: stikDebugScriptName)
)
}
guard let url = components.url else {
showJITError("Could not create the StikDebug request.")
return
}
UIApplication.shared.open(url)
```
An existing legacy app can use the same code with its fixed script name set to `legacy.js`. A custom-script app must remove `stikDebugScriptName` and replace the `if isTXMPresent` block with this fixed custom-script implementation:
```swift
if isTXMPresent {
do {
let scriptData = try Data(contentsOf: stikDebugScriptURL)
components.queryItems?.append(
URLQueryItem(
name: "script-data",
value: scriptData.base64EncodedString()
)
)
} catch {
showJITError("Could not read the StikDebug script: \(error.localizedDescription)")
return
}
}
```
`stikDebugScriptURL` must identify the app's single custom script in developer-controlled backend code. Sending it through `script-data` means users do not need to install it in StikDebug. Resolve TXM/SPTM presence before building the request rather than treating an unknown result as absent. StikDebug uses the PID to target the running process and the bundle ID to identify and return to the app.
If the app checks whether StikDebug is installed with `canOpenURL`, add this to its `Info.plist`:
```xml
<key>LSApplicationQueriesSchemes</key>
<array>
<string>stikdebug</string>
</array>
```
Opening the URL confirms only that iOS accepted the request. Use the common readiness path after the app resumes; where TXM/SPTM is present, require the script breakpoint protocol to complete successfully.
For **Built-in StikJIT**, follow the helper-extension flow in this guide. Keep `enableJIT` synchronous on the helper's serial queue while the host participates in the breakpoint protocol. If the app ships this complete integration, make it the recommended method; use StikDebug as the lightweight alternative and Wait for Debugger as the compatibility fallback.
### LiveContainer
Built-in StikJIT is unavailable when the host app is running inside LiveContainer because LiveContainer cannot create the required helper extension.
Detect the environment in the host process:
```swift
import Darwin
let isRunningInLiveContainer = getenv("LC_HOME_PATH") != nil
```
When this value is `true`:
- Hide or disable Built-in StikJIT and explain why it is unavailable.
- Do not launch the helper extension.
- If Built-in StikJIT was previously selected, require the user to choose another method instead of silently attempting it.
- Keep Wait for Debugger and StikDebug available.
- For StikDebug, tell the user to enable **Use LiveContainer's Bundle ID** in LiveContainer settings.
## Recommended settings
At minimum, provide:
- **JIT Method** picker with Wait for Debugger, StikDebug, and Built-in StikJIT
- For Built-in StikJIT, **Import Pairing File** action and imported filename/status
- For Built-in StikJIT, **Prepare JIT** action and readiness status/reason
- For Built-in StikJIT, **TXM/SPTM** status: `Present`, `Not Present`, or `Unknown`
- For Built-in StikJIT, **Force JIT Script** toggle, off by default
- For Built-in StikJIT, **Reset Developer Disk Image** recovery action
- For StikDebug, an installed/unavailable status and concise setup instructions
- For Wait for Debugger, concise instructions explaining that the app will wait for an external tool
Hide or disable Built-in StikJIT below iOS 17.4 and while running inside LiveContainer. Persist the selected method and the force-script toggle in the host app's preferences. Changing the pairing file should invalidate any displayed readiness state.
## Minimum Built-in StikJIT user instructions
Tell users all of the following before their first Built-in StikJIT attempt:
1. StikJIT requires iOS 17.4 or later.
2. The app must be installed with `get-task-allow`; otherwise it must be reinstalled using a compatible signing method.
3. Developer Mode must be enabled.
4. A pairing file obtained through AFC/Finder, `idevice_pair`, or another pairing method must be imported or placed at `StikJIT/pairingFile.plist` in the app's Documents folder.
5. LocalDevVPN must be connected.
6. Use a nearby Wi-Fi network when available. Without Wi-Fi, enable cellular data, connect LocalDevVPN, and then enable Airplane Mode before returning to the app.
7. If preparation repeatedly fails, reboot the device and try again. Use **Reset Developer Disk Image** when the cached files need to be downloaded again.
Always surface the actual unreachable, download, mount, pairing, and script errors. Do not reduce every failure to “DDI unmounted.”
## Request endorsement
After completing Part 1, Part 2, or both, please get in touch with us in the [idevice Discord server](https://discord.gg/qtgv6QtYbV) or leave a GitHub issue/pull request, so we can endorse your project and offer support to your users. We review app submissions on a case-by-case basis and generally decline projects without an established user base, low-quality substantially AI-generated projects, and forks of apps that already have our endorsement.
# Final checklist
## All methods
- [ ] New iOS 26 integrations implement the universal protocol.
- [ ] The host checks its own `get-task-allow` before starting JIT acquisition.
- [ ] The app has exactly one developer-configured script that matches its breakpoint protocol.
- [ ] Where TXM/SPTM is present, executable-region preparation succeeds before the workload starts.
- [ ] Settings offer one mutually exclusive JIT method selection and start only that method.
### StikDebug
- [ ] The URL always includes bundle ID and current PID.
- [ ] Where TXM/SPTM is present, the URL includes `script-name` for universal or legacy, or base64-encoded `script-data` for a custom script.
- [ ] Where TXM/SPTM is not present, the URL omits both script parameters.
- [ ] `stikdebug` is in `LSApplicationQueriesSchemes` if the app calls `canOpenURL`.
- [ ] The app does not treat successful URL opening as successful JIT acquisition.
- [ ] Inside LiveContainer, the app tells users to enable **Use LiveContainer's Bundle ID**.
### Built-in StikJIT
- [ ] StikJIT is linked and embedded only where the helper can load it.
- [ ] The helper deployment target is iOS 17.4 or later.
- [ ] The host gates every Built-in StikJIT entry point to iOS 17.4 or later.
- [ ] The host accepts pairing files obtained through AFC/Finder, `idevice_pair`, or other pairing methods and stores one at `Documents/StikJIT/pairingFile.plist`.
- [ ] Pairing data is sent to the helper without logging it.
- [ ] The helper uses one serial background queue.
- [ ] The helper owns one persistent DDI cache location.
- [ ] Game or workload launch calls `enableJIT` directly rather than requiring manual preparation first.
- [ ] The host disables Built-in StikJIT and avoids launching the helper inside LiveContainer.

View file

@ -1,24 +1,14 @@
# StikJIT
An iOS XCFramework that enables JIT for another process over the device's RSD tunnel. Based on StikDebug.
An iOS XCFramework that enables JIT for another process over the device's RSD tunnel.
A separate process (e.g. an app extension) attaches a debugserver to the target by PID, marks its JIT memory executable, then detaches. It is self-contained: the idevice FFI and `universal.js` are bundled inside. The target must carry the `get-task-allow` entitlement.
StikJIT is self-contained and bundles the idevice FFI and its JIT scripts.
## Use
## Integration
Add `StikJIT.xcframework` to your target (Embed & Sign), then call it off the main thread:
For iOS 26 JIT support, StikDebug URL integration, Built-in StikJIT setup, API usage, requirements, and recommended app settings, see [Integrating StikJIT](INTEGRATION.md).
```swift
import StikJIT
try StikJIT.enableJIT(
targetPID: hostPID, // process to enable JIT for
pairingFile: pairingFileURL, // device pairing file
progress: { print("[StikJIT] \($0)") }
)
```
It blocks until done and throws `StikJITError` on failure. Pass `configuration:` to override the tunnel endpoint (defaults to `10.7.0.1:49152`).
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/StephenDev0/StikJIT)
## Build
@ -33,4 +23,4 @@ xcodebuild -create-xcframework \
## License
StikJIT is licensed under the MPL-2.0 (see [`LICENSE`](LICENSE)). It uses StikDebug as a reference, with the bundled [idevice](https://github.com/jkcoxson/idevice) and universal.js retaining their own licenses.
StikJIT is licensed under the MPL-2.0 (see [`LICENSE`](LICENSE)). It uses StikDebug as a reference, with the bundled [idevice](https://github.com/jkcoxson/idevice), universal.js, and legacy.js retaining their own licenses.

98
Resources/legacy.js Normal file
View file

@ -0,0 +1,98 @@
function littleEndianHexStringToNumber(hexStr) {
const bytes = [];
for (let i = 0; i < hexStr.length; i += 2) {
bytes.push(parseInt(hexStr.substr(i, 2), 16));
}
let num = 0n;
for (let i = 4; i >= 0; i--) {
num = (num << 8n) | BigInt(bytes[i]);
}
return num;
}
function numberToLittleEndianHexString(num) {
const bytes = [];
for (let i = 0; i < 5; i++) {
bytes.push(Number(num & 0xFFn));
num >>= 8n;
}
while (bytes.length < 8) {
bytes.push(0);
}
return bytes.map(b => b.toString(16).padStart(2, '0')).join('');
}
function littleEndianHexToU32(hexStr) {
return parseInt(hexStr.match(/../g).reverse().join(''), 16);
}
function extractBrkImmediate(u32) {
return (u32 >> 5) & 0xFFFF;
}
function attach(breakpointcount) {
let pid = get_pid();
log(`pid = ${pid}`);
let attachResponse = send_command(`vAttach;${pid.toString(16)}`);
log(`attach_response = ${attachResponse}`);
let validBreakpoints = 0;
let totalBreakpoints = 0;
while (validBreakpoints < breakpointcount) {
totalBreakpoints++;
log(`Handling breakpoint ${totalBreakpoints} (looking for valid breakpoint ${validBreakpoints + 1}/${breakpointcount})`);
let brkResponse = send_command(`c`);
log(`brkResponse = ${brkResponse}`);
let tidMatch = /T[0-9a-f]+thread:(?<tid>[0-9a-f]+);/.exec(brkResponse);
let tid = tidMatch ? tidMatch.groups['tid'] : null;
let pcMatch = /20:(?<reg>[0-9a-f]{16});/.exec(brkResponse);
let pc = pcMatch ? pcMatch.groups['reg'] : null;
let x0Match = /00:(?<reg>[0-9a-f]{16});/.exec(brkResponse);
let x0 = x0Match ? x0Match.groups['reg'] : null;
let x1Match = /01:(?<reg>[0-9a-f]{16});/.exec(brkResponse);
let x1 = x1Match ? x1Match.groups['reg'] : null;
if (!tid || !pc || !x0 || !x1) {
log(`Failed to extract registers: tid=${tid}, pc=${pc}, x0=${x0}, x1=${x1}`);
continue;
}
const pcNum = littleEndianHexStringToNumber(pc);
const x0Num = littleEndianHexStringToNumber(x0);
const x1Num = littleEndianHexStringToNumber(x1);
log(`tid = ${tid}, pc = ${pcNum.toString(16)}, x0 = ${x0Num.toString(16)}, x1 = ${x1Num.toString(16)}`);
let instructionResponse = send_command(`m${pcNum.toString(16)},4`);
log(`instruction at pc: ${instructionResponse}`);
let instrU32 = littleEndianHexToU32(instructionResponse);
let brkImmediate = extractBrkImmediate(instrU32);
log(`BRK immediate: 0x${brkImmediate.toString(16)} (${brkImmediate})`);
if (brkImmediate !== 0x69) {
log(`Skipping breakpoint: brk immediate was not 0x69 (was 0x${brkImmediate.toString(16)})`);
continue;
}
log(`BRK immediate matches expected value 0x69 - processing valid breakpoint ${validBreakpoints + 1}/${breakpointcount}`);
log(`Allocated JIT page at address: 0x${x0Num.toString(16)}`);
let prepareJITPageResponse = prepare_memory_region(x0Num, x1Num);
log(`prepareJITPageResponse = ${prepareJITPageResponse}`);
let pcPlus4 = numberToLittleEndianHexString(pcNum + 4n);
let pcPlus4Response = send_command(`P20=${pcPlus4};thread:${tid};`);
log(`pcPlus4Response = ${pcPlus4Response}`);
validBreakpoints++;
log(`Completed valid breakpoint ${validBreakpoints}/${breakpointcount}`);
}
let detachResponse = send_command(`D`);
log(`detachResponse = ${detachResponse}`);
}
attach(1);

View file

@ -2,9 +2,40 @@ import Foundation
enum BundledScript {
static func universalJS() throws -> String {
static func source(for script: StikJIT.Script) throws -> String {
switch script {
case .custom(let url):
guard url.isFileURL else {
throw StikJITError.customScript("expected a file URL")
}
do {
let source = try String(contentsOf: url, encoding: .utf8)
guard !source.isEmpty else {
throw StikJITError.customScript("the file at \(url.path) is empty")
}
return source
} catch let error as StikJITError {
throw error
} catch {
throw StikJITError.customScript("could not read \(url.path): \(error.localizedDescription)")
}
case .customBase64(let encodedSource):
guard let data = Data(base64Encoded: encodedSource) else {
throw StikJITError.customScript("the provided script data is not valid base64")
}
guard let source = String(data: data, encoding: .utf8) else {
throw StikJITError.customScript("the decoded script data is not valid UTF-8")
}
guard !source.isEmpty else {
throw StikJITError.customScript("the decoded script is empty")
}
return source
case .universal, .legacy:
break
}
let bundle = Bundle(for: BundleToken.self)
guard let url = bundle.url(forResource: "universal", withExtension: "js"),
guard let url = bundle.url(forResource: script.name, withExtension: "js"),
let source = try? String(contentsOf: url, encoding: .utf8),
!source.isEmpty else {
throw StikJITError.scriptUnavailable

228
Sources/DDISession.swift Normal file
View file

@ -0,0 +1,228 @@
import Foundation
@_implementationOnly import idevice
private func ddiMountProgressCallback(progress: size_t, total: size_t, context: UnsafeMutableRawPointer?) {
guard let context else { return }
let progressHandler = Unmanaged<DDIProgressBox>.fromOpaque(context).takeUnretainedValue()
let fraction = total > 0 ? Double(progress) / Double(total) : 0
progressHandler.handler(fraction)
}
private final class DDIProgressBox {
let handler: (Double) -> Void
init(_ handler: @escaping (Double) -> Void) { self.handler = handler }
}
final class DDISession {
private struct Tunnel {
var adapter: OpaquePointer?
var handshake: OpaquePointer?
func free() {
if let handshake { rsd_handshake_free(handshake) }
if let adapter { adapter_free(adapter) }
}
}
private let pairingFilePath: String
private let configuration: StikJIT.Configuration
init(pairingFilePath: String, configuration: StikJIT.Configuration) {
self.pairingFilePath = pairingFilePath
self.configuration = configuration
}
func isMounted() throws -> Bool {
let tunnel = try makeTunnel()
defer { tunnel.free() }
if DDIMountMethod.current == .personalized {
return try isLegacyDDIMounted(over: tunnel)
}
var installed: UnsafeMutablePointer<InstalledCryptexC>?
let cryptexError = cryptexd_installed_ddi(tunnel.adapter, tunnel.handshake, &installed)
defer { cryptexd_free_installed_cryptex(installed) }
if installed != nil {
if let cryptexError { idevice_error_free(cryptexError) }
return true
}
if (try? isLegacyDDIMounted(over: tunnel)) == true {
if let cryptexError { idevice_error_free(cryptexError) }
return true
}
if let cryptexError {
try IdeviceFFI.check("failed to query installed DDI cryptex") { cryptexError }
}
return false
}
private func isLegacyDDIMounted(over tunnel: Tunnel) throws -> Bool {
var client: OpaquePointer?
try IdeviceFFI.check("failed to connect to image mounter") {
image_mounter_connect_rsd(tunnel.adapter, tunnel.handshake, &client)
}
guard let client else { throw StikJITError.debugProxyUnavailable }
defer { image_mounter_free(client) }
var devices: UnsafeMutablePointer<plist_t?>?
var deviceCount = 0
try IdeviceFFI.check("failed to fetch mounted devices") {
image_mounter_copy_devices(client, &devices, &deviceCount)
}
if let devices {
for index in 0..<deviceCount { plist_free(devices[index]) }
idevice_data_free(
UnsafeMutableRawPointer(devices).assumingMemoryBound(to: UInt8.self),
UInt(deviceCount * MemoryLayout<plist_t?>.stride))
}
return deviceCount > 0
}
func mountDDI(paths: DDIPaths, progress: @escaping (Double) -> Void) throws {
switch DDIMountMethod.current {
case .cryptex:
try installCryptexDDI(from: URL(fileURLWithPath: paths.manifestPath).deletingLastPathComponent().path, progress: progress)
case .personalized:
try mountPersonalizedDDI(paths: paths, progress: progress)
}
}
private func installCryptexDDI(from directoryPath: String, progress: @escaping (Double) -> Void) throws {
progress(0)
var assets: OpaquePointer?
try IdeviceFFI.check("failed to load DDI cryptex assets") {
directoryPath.withCString { cryptex1_assets_load($0, &assets) }
}
guard let assets else {
throw StikJITError.ddiFilesMissing("DDI cryptex assets could not be loaded from \(directoryPath)")
}
defer { cryptex1_assets_free(assets) }
let tunnel = try makeTunnel()
defer { tunnel.free() }
try IdeviceFFI.check("failed to install DDI cryptex") {
cryptexd_install_ddi(tunnel.adapter, tunnel.handshake, assets, nil)
}
progress(1)
}
private func mountPersonalizedDDI(paths: DDIPaths, progress: @escaping (Double) -> Void) throws {
let imageData = try mappedFileData(atPath: paths.imagePath, description: "developer disk image")
let trustcacheData = try mappedFileData(atPath: paths.trustcachePath, description: "developer disk image trust cache")
let manifestData = try mappedFileData(atPath: paths.manifestPath, description: "developer disk image manifest")
let tunnel = try makeTunnel()
defer { tunnel.free() }
let uniqueChipID = try fetchUniqueChipID(over: tunnel)
var client: OpaquePointer?
try IdeviceFFI.check("failed to connect to image mounter") {
image_mounter_connect_rsd(tunnel.adapter, tunnel.handshake, &client)
}
guard let client else { throw StikJITError.debugProxyUnavailable }
defer { image_mounter_free(client) }
let progressBox = DDIProgressBox(progress)
let context = Unmanaged.passUnretained(progressBox).toOpaque()
try IdeviceFFI.check("failed to mount personalized developer disk image") {
imageData.withUnsafeBytes { imageBuffer in
trustcacheData.withUnsafeBytes { trustcacheBuffer in
manifestData.withUnsafeBytes { manifestBuffer in
image_mounter_mount_personalized_with_callback_rsd(
client,
tunnel.adapter,
tunnel.handshake,
imageBuffer.bindMemory(to: UInt8.self).baseAddress,
imageData.count,
trustcacheBuffer.bindMemory(to: UInt8.self).baseAddress,
trustcacheData.count,
manifestBuffer.bindMemory(to: UInt8.self).baseAddress,
manifestData.count,
nil,
uniqueChipID,
ddiMountProgressCallback,
context)
}
}
}
}
}
private func mappedFileData(atPath path: String, description: String) throws -> Data {
guard FileManager.default.fileExists(atPath: path) else {
throw StikJITError.ddiFilesMissing("\(description) not found at \(path)")
}
let data = try Data(contentsOf: URL(fileURLWithPath: path), options: .mappedIfSafe)
guard !data.isEmpty else {
throw StikJITError.ddiFilesMissing("\(description) is empty at \(path)")
}
return data
}
private func fetchUniqueChipID(over tunnel: Tunnel) throws -> UInt64 {
var client: OpaquePointer?
try IdeviceFFI.check("failed to connect to lockdownd") {
lockdownd_connect_rsd(tunnel.adapter, tunnel.handshake, &client)
}
guard let client else { throw StikJITError.debugProxyUnavailable }
defer { lockdownd_client_free(client) }
var uniqueChipIDPlist: plist_t?
try IdeviceFFI.check("failed to query UniqueChipID") {
"UniqueChipID".withCString { lockdownd_get_value(client, $0, nil, &uniqueChipIDPlist) }
}
guard let uniqueChipIDPlist else {
throw StikJITError.device(code: -1, subCode: 0, message: "UniqueChipID was not returned by lockdownd")
}
defer { plist_free(uniqueChipIDPlist) }
var value: UInt64 = 0
plist_get_uint_val(uniqueChipIDPlist, &value)
guard value != 0 else {
throw StikJITError.device(code: -1, subCode: 0, message: "Failed to decode UniqueChipID")
}
return value
}
private func openPairingFile() throws -> OpaquePointer {
guard !pairingFilePath.isEmpty, FileManager.default.fileExists(atPath: pairingFilePath) else {
throw StikJITError.pairingFile("not found at \(pairingFilePath)")
}
var handle: OpaquePointer?
try IdeviceFFI.check("failed to read pairing file") {
pairingFilePath.withCString { rp_pairing_file_read($0, &handle) }
}
guard let handle else { throw StikJITError.pairingFile("unreadable at \(pairingFilePath)") }
return handle
}
private func makeTunnel() throws -> Tunnel {
let pairing = try openPairingFile()
defer { rp_pairing_file_free(pairing) }
var address = sockaddr_in()
address.sin_family = sa_family_t(AF_INET)
address.sin_port = configuration.rsdPort.bigEndian
_ = configuration.deviceAddress.withCString { inet_pton(AF_INET, $0, &address.sin_addr) }
var tunnel = Tunnel()
try IdeviceFFI.check("failed to create RSD tunnel") {
"StikJIT".withCString { hostname in
withUnsafePointer(to: &address) { pointer in
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
tunnel_create_rppairing(
sa, socklen_t(MemoryLayout<sockaddr_in>.stride),
hostname, pairing, nil, nil,
&tunnel.adapter, &tunnel.handshake)
}
}
}
}
return tunnel
}
}

View file

@ -0,0 +1,162 @@
import Foundation
public struct DDIPaths: Sendable {
public var imagePath: String
public var trustcachePath: String
public var manifestPath: String
public var cryptexInfoPath: String
public var rootHashPath: String
public init(imagePath: String,
trustcachePath: String,
manifestPath: String,
cryptexInfoPath: String? = nil,
rootHashPath: String? = nil) {
self.imagePath = imagePath
self.trustcachePath = trustcachePath
self.manifestPath = manifestPath
self.cryptexInfoPath = cryptexInfoPath ?? "\(imagePath).cryptex_info"
self.rootHashPath = rootHashPath ?? "\(imagePath).root_hash"
}
public static func `default`(in directory: URL) -> DDIPaths {
DDIPaths(
imagePath: directory.appendingPathComponent("DDI/Image.dmg").path,
trustcachePath: directory.appendingPathComponent("DDI/Image.dmg.trustcache").path,
manifestPath: directory.appendingPathComponent("DDI/BuildManifest.plist").path)
}
var allFilesUsable: Bool {
let fileManager = FileManager.default
let requiredPaths = DDIMountMethod.current == .cryptex ? allPaths : personalizedPaths
let requiredFilesUsable = requiredPaths.allSatisfy { path in
guard fileManager.isReadableFile(atPath: path),
let attributes = try? fileManager.attributesOfItem(atPath: path),
attributes[.type] as? FileAttributeType == .typeRegular,
let size = attributes[.size] as? NSNumber else {
return false
}
return size.int64Value > 0
}
if DDIMountMethod.current == .personalized {
return requiredFilesUsable && cryptexOnlyPaths.allSatisfy { !fileManager.fileExists(atPath: $0) }
}
return requiredFilesUsable
}
var allPaths: [String] {
personalizedPaths + cryptexOnlyPaths
}
var personalizedPaths: [String] {
[imagePath, trustcachePath, manifestPath]
}
var cryptexOnlyPaths: [String] {
[cryptexInfoPath, rootHashPath]
}
func removeCachedFiles() throws {
let fileManager = FileManager.default
for path in Set(allPaths) where fileManager.fileExists(atPath: path) {
try fileManager.removeItem(atPath: path)
}
}
func removeCryptexOnlyFiles() throws {
let fileManager = FileManager.default
for path in Set(cryptexOnlyPaths) where fileManager.fileExists(atPath: path) {
try fileManager.removeItem(atPath: path)
}
}
}
enum DDIMountMethod: String {
case cryptex
case personalized
static var current: DDIMountMethod {
let version = ProcessInfo.processInfo.operatingSystemVersion
return version.majorVersion > 26 || (version.majorVersion == 26 && version.minorVersion >= 4)
? .cryptex
: .personalized
}
}
struct DDIDownloadItem {
let name: String
let destinationPath: String
let url: URL
}
enum DDIDownloadCatalog {
static func items(for paths: DDIPaths) -> [DDIDownloadItem] {
let directory = DDIMountMethod.current == .cryptex ? "Xcode_iOS_DDI_Cryptex" : "Xcode_iOS_DDI_Personalized"
let baseURL = URL(string: "https://github.com/doronz88/DeveloperDiskImage/raw/refs/heads/main/PersonalizedImages/\(directory)")!
var items = [
DDIDownloadItem(name: "BuildManifest.plist", destinationPath: paths.manifestPath, url: baseURL.appendingPathComponent("BuildManifest.plist")),
DDIDownloadItem(name: "Image.dmg", destinationPath: paths.imagePath, url: baseURL.appendingPathComponent("Image.dmg")),
DDIDownloadItem(name: "Image.dmg.trustcache", destinationPath: paths.trustcachePath, url: baseURL.appendingPathComponent("Image.dmg.trustcache")),
]
if DDIMountMethod.current == .cryptex {
items.append(DDIDownloadItem(name: "Image.dmg.cryptex_info", destinationPath: paths.cryptexInfoPath, url: baseURL.appendingPathComponent("Image.dmg.cryptex_info")))
items.append(DDIDownloadItem(name: "Image.dmg.root_hash", destinationPath: paths.rootHashPath, url: baseURL.appendingPathComponent("Image.dmg.root_hash")))
}
return items
}
}
@available(iOS 17.4, *)
public actor DeveloperDiskImageService {
private static let sharedInstance = DeveloperDiskImageService()
public static var shared: DeveloperDiskImageService { sharedInstance }
private let session: URLSession
public init(session: URLSession = .shared) {
self.session = session
}
public func downloadIfNeeded(to paths: DDIPaths, progress: @escaping (Double, String) -> Void = { _, _ in }) async throws {
guard !paths.allFilesUsable else { return }
try await download(to: paths, progress: progress)
}
public func download(to paths: DDIPaths, progress: @escaping (Double, String) -> Void = { _, _ in }) async throws {
let items = DDIDownloadCatalog.items(for: paths)
let total = Double(items.count)
for (index, item) in items.enumerated() {
progress(Double(index) / total, "Downloading \(item.name)...")
try await downloadFile(from: item.url, to: URL(fileURLWithPath: item.destinationPath))
progress(Double(index + 1) / total, "\(item.name) ready")
}
if DDIMountMethod.current == .personalized {
try paths.removeCryptexOnlyFiles()
}
}
private func downloadFile(from url: URL, to destinationURL: URL) async throws {
let (temporaryURL, response) = try await session.download(from: url)
guard let httpResponse = response as? HTTPURLResponse else {
throw StikJITError.ddiDownload("invalid response for \(url.absoluteString)")
}
guard (200..<300).contains(httpResponse.statusCode) else {
throw StikJITError.ddiDownload("HTTP \(httpResponse.statusCode) for \(url.absoluteString)")
}
let fileManager = FileManager.default
try fileManager.createDirectory(at: destinationURL.deletingLastPathComponent(), withIntermediateDirectories: true)
if fileManager.fileExists(atPath: destinationURL.path) {
try fileManager.removeItem(at: destinationURL)
}
try fileManager.moveItem(at: temporaryURL, to: destinationURL)
}
}

View file

@ -0,0 +1,56 @@
import Foundation
import Network
enum EndpointProbe {
private static let queue = DispatchQueue(label: "com.stik.StikJIT.endpoint-probe")
static func failureReason(address: String, port: UInt16, timeout: TimeInterval) -> String? {
guard !address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
let endpointPort = NWEndpoint.Port(rawValue: port) else {
return "The configured device endpoint is invalid."
}
let state = ProbeState()
let semaphore = DispatchSemaphore(value: 0)
let connection = NWConnection(host: NWEndpoint.Host(address), port: endpointPort, using: .tcp)
connection.stateUpdateHandler = { connectionState in
switch connectionState {
case .ready:
if state.finish(reason: nil) { semaphore.signal() }
case .failed(let error):
if state.finish(reason: error.localizedDescription) { semaphore.signal() }
default:
break
}
}
connection.start(queue: queue)
if semaphore.wait(timeout: .now() + max(timeout, 0.1)) == .timedOut {
_ = state.finish(reason: "Timed out connecting to \(address):\(port).")
}
connection.stateUpdateHandler = nil
connection.cancel()
return state.reason
}
}
private final class ProbeState: @unchecked Sendable {
private let lock = NSLock()
private var isFinished = false
private var storedReason: String?
var reason: String? {
lock.lock()
defer { lock.unlock() }
return storedReason
}
func finish(reason: String?) -> Bool {
lock.lock()
defer { lock.unlock() }
guard !isFinished else { return false }
isFinished = true
storedReason = reason
return true
}
}

View file

@ -29,7 +29,11 @@ final class JITSession {
self.configuration = configuration
}
func enableJIT(targetPID: Int32, progress: @escaping (String) -> Void) throws {
func enableJIT(targetPID: Int32,
script: StikJIT.Script,
forceScript: Bool,
txmPresence: TXMPresence,
progress: @escaping (String) -> Void) throws {
let tunnel = try makeTunnel()
defer { tunnel.free() }
@ -42,15 +46,18 @@ final class JITSession {
_ = try? sendCommand("QStartNoAckMode", over: debugProxy)
debug_proxy_set_ack_mode(debugProxy, 0)
if ProcessInfo.processInfo.hasTXM {
try ScriptRunner(targetPID: targetPID, debugProxy: debugProxy, progress: progress).run()
} else {
switch (forceScript, txmPresence) {
case (true, _), (false, .present):
try ScriptRunner(targetPID: targetPID, debugProxy: debugProxy, script: script, progress: progress).run()
case (false, .absent):
try attachWithoutScript(targetPID: targetPID, debugProxy: debugProxy, progress: progress)
case (false, .unknown):
throw StikJITError.txmDetectionUnavailable
}
}
private func attachWithoutScript(targetPID: Int32, debugProxy: OpaquePointer, progress: (String) -> Void) throws {
progress("Attaching to pid \(targetPID). This device has no TXM, so the attach alone enables JIT and the script is skipped.")
progress("Attaching to pid \(targetPID). TXM is not present, so the attach alone enables JIT and the script is skipped.")
_ = try sendCommand("vAttach;\(String(targetPID, radix: 16))", over: debugProxy)
_ = try? sendCommand("D", over: debugProxy)
progress("JIT enabled (debugger attached and detached).")

View file

@ -1,58 +1,42 @@
import Foundation
@_implementationOnly import StikJITIOKit
extension ProcessInfo {
var hasTXM: Bool {
let hardware = Self.hardwareIdentifier()
enum TXMPresence {
case present
case absent
case unknown
if Self.isIOS27OrNewer {
return hardware != "iPad8,11" && hardware != "iPad8,12"
init(isPresent: Bool?) {
guard let isPresent else {
self = .unknown
return
}
if Self.isIOS26OrNewer {
return Self.hasTXMSupport(hardwareIdentifier: hardware)
}
return false
self = isPresent ? .present : .absent
}
private static func hasTXMSupport(hardwareIdentifier: String) -> Bool {
let firstTXM = 14.2
let iPadTXM = 14.5
guard let version = deviceVersion(from: hardwareIdentifier) else {
return false
var isPresent: Bool? {
switch self {
case .present: return true
case .absent: return false
case .unknown: return nil
}
if hardwareIdentifier.hasPrefix("iPad") {
return version >= iPadTXM
}
return version >= firstTXM
}
private static func deviceVersion(from identifier: String) -> Double? {
func version(forPrefix prefix: String) -> Double? {
guard identifier.hasPrefix(prefix) else { return nil }
let parts = identifier.dropFirst(prefix.count).split(separator: ",")
guard parts.count == 2, let major = Int(parts[0]), let minor = Int(parts[1]) else { return nil }
return Double("\(major).\(minor)")
}
return version(forPrefix: "iPhone") ?? version(forPrefix: "iPad")
}
private static func hardwareIdentifier() -> String {
var size = 0
sysctlbyname("hw.machine", nil, &size, nil, 0)
guard size > 0 else { return "" }
var buffer = [CChar](repeating: 0, count: size)
sysctlbyname("hw.machine", &buffer, &size, nil, 0)
return String(cString: buffer)
}
private static var isIOS26OrNewer: Bool {
if #available(iOS 26.0, *) { return true }
return false
}
private static var isIOS27OrNewer: Bool {
if #available(iOS 27.0, *) { return true }
return false
}
}
extension ProcessInfo {
var txmPresence: TXMPresence {
let memoryMap = IORegistryEntryFromPath(kIOMainPortDefault, "IODeviceTree:/chosen/memory-map")
guard memoryMap != 0 else { return .unknown }
let keysCF = IORegistryEntryCreateCFProperty(
memoryMap,
kIORegistryEntryPropertyKeysKey as CFString,
kCFAllocatorDefault,
0
)?.takeRetainedValue()
IOObjectRelease(memoryMap)
guard let keys = keysCF as? [String] else { return .unknown }
return keys.contains("TXM") ? .present : .absent
}
}

View file

@ -5,26 +5,32 @@ import JavaScriptCore
final class ScriptRunner {
private static let jitPageSize: UInt64 = 16384
private static let jitPageCommandLength = 19
private static let commandsPerBatch = 128
private let targetPID: Int32
private let debugProxy: OpaquePointer
private let script: StikJIT.Script
private let progress: (String) -> Void
private var context: JSContext?
private var executionError: StikJITError?
init(targetPID: Int32, debugProxy: OpaquePointer, progress: @escaping (String) -> Void) {
init(targetPID: Int32, debugProxy: OpaquePointer, script: StikJIT.Script, progress: @escaping (String) -> Void) {
self.targetPID = targetPID
self.debugProxy = debugProxy
self.script = script
self.progress = progress
}
func run() throws {
let source = try BundledScript.universalJS()
let source = try BundledScript.source(for: script)
guard let context = JSContext() else { throw StikJITError.scriptUnavailable }
self.context = context
context.exceptionHandler = { [weak self] _, value in
self?.progress("JS exception: \(value?.toString() ?? "<unknown>")")
let detail = value?.toString() ?? "unknown JavaScript exception"
self?.recordExecutionError(.scriptExecution(detail))
}
let getPID: @convention(block) () -> Int = { [targetPID] in Int(targetPID) }
@ -34,7 +40,7 @@ final class ScriptRunner {
}
let prepare: @convention(block) (Double, Double) -> String = { [weak self] address, length in
guard let self else { return "" }
return self.prepareMemoryRegion(UInt64(address), length: UInt64(length)) ?? "OK"
return self.prepareMemoryRegion(UInt64(address), length: UInt64(length)) ?? ""
}
let log: @convention(block) (JSValue?) -> Void = { [weak self] value in
self?.progress(value?.toString() ?? "")
@ -45,8 +51,9 @@ final class ScriptRunner {
context.setObject(prepare, forKeyedSubscript: "prepare_memory_region" as NSString)
context.setObject(log, forKeyedSubscript: "log" as NSString)
progress("Running universal.js against pid \(targetPID)…")
progress("Running \(script.name) against pid \(targetPID)…")
context.evaluateScript(source)
if let executionError { throw executionError }
progress("JIT script finished (region blessed, detached).")
}
@ -55,7 +62,7 @@ final class ScriptRunner {
defer { debugserver_command_free(handle) }
var response: UnsafeMutablePointer<CChar>?
if let error = debug_proxy_send_command(debugProxy, handle, &response) {
progress("send_command error: \(IdeviceFFI.consume(error, fallback: "send_command").localizedDescription)")
recordExecutionError(IdeviceFFI.consume(error, fallback: "send_command"))
return nil
}
guard let response else { return "" }
@ -65,17 +72,87 @@ final class ScriptRunner {
private func prepareMemoryRegion(_ address: UInt64, length: UInt64) -> String? {
guard length > 0 else { return "OK" }
let pageCount = (length - 1) / Self.jitPageSize + 1
for page in 0..<pageCount {
let pageAddress = address + page * Self.jitPageSize
let pageCount = Int((length - 1) / Self.jitPageSize + 1)
let response = sendCommand("M\(String(pageAddress, radix: 16)),1:69")
if let response, !(response.hasPrefix("OK") || response.isEmpty) {
progress("page bless failed @0x\(String(pageAddress, radix: 16)): \(response)")
let commandBuffer = Self.makeBlessCommands(startAddress: address, pageCount: pageCount)
for batchStart in stride(from: 0, to: pageCount, by: Self.commandsPerBatch) {
let commandsInBatch = min(Self.commandsPerBatch, pageCount - batchStart)
let byteOffset = batchStart * Self.jitPageCommandLength
let byteCount = commandsInBatch * Self.jitPageCommandLength
let sendError = commandBuffer.withUnsafeBytes { rawBuffer -> UnsafeMutablePointer<IdeviceFfiError>? in
let base = rawBuffer.bindMemory(to: UInt8.self).baseAddress!
return debug_proxy_send_raw(debugProxy, base.advanced(by: byteOffset), UInt(byteCount))
}
if let sendError {
recordExecutionError(IdeviceFFI.consume(sendError, fallback: "debug_proxy_send_raw"))
return nil
}
for _ in 0..<commandsInBatch {
var response: UnsafeMutablePointer<CChar>?
let readError = debug_proxy_read_response(debugProxy, &response)
if let response {
idevice_string_free(response)
}
if let readError {
recordExecutionError(IdeviceFFI.consume(readError, fallback: "debug_proxy_read_response"))
return nil
}
}
}
progress("Blessed \(pageCount) JIT page(s) at 0x\(String(address, radix: 16))")
return "OK"
}
private func recordExecutionError(_ error: StikJITError) {
if executionError == nil {
executionError = error
progress(error.localizedDescription)
}
}
private static func makeBlessCommands(startAddress: UInt64, pageCount: Int) -> [UInt8] {
var buffer = [UInt8](repeating: 0, count: pageCount * jitPageCommandLength)
for page in 0..<pageCount {
let pageAddress = startAddress + UInt64(page) * jitPageSize
let start = page * jitPageCommandLength
buffer[start + 0] = UInt8(ascii: "$")
buffer[start + 1] = UInt8(ascii: "M")
writeHexAddress(pageAddress, into: &buffer, at: start + 2)
buffer[start + 11] = UInt8(ascii: ",")
buffer[start + 12] = UInt8(ascii: "1")
buffer[start + 13] = UInt8(ascii: ":")
buffer[start + 14] = UInt8(ascii: "6")
buffer[start + 15] = UInt8(ascii: "9")
buffer[start + 16] = UInt8(ascii: "#")
writeChecksum(into: &buffer, bodyStart: start + 1, hashIndex: start + 16)
}
return buffer
}
private static func writeHexAddress(_ address: UInt64, into buffer: inout [UInt8], at index: Int) {
for nibble in 0..<9 {
let shift = UInt64((8 - nibble) * 4)
buffer[index + nibble] = hexDigit(UInt8((address >> shift) & 0xf))
}
}
private static func writeChecksum(into buffer: inout [UInt8], bodyStart: Int, hashIndex: Int) {
var checksum: UInt8 = 0
for index in bodyStart..<hashIndex {
checksum &+= buffer[index]
}
buffer[hashIndex + 1] = hexDigit((checksum & 0xf0) >> 4)
buffer[hashIndex + 2] = hexDigit(checksum & 0x0f)
}
private static func hexDigit(_ value: UInt8) -> UInt8 {
value < 10 ? value + UInt8(ascii: "0") : value - 10 + UInt8(ascii: "a")
}
}

View file

@ -2,25 +2,188 @@ import Foundation
public enum StikJIT {
public enum Script: Sendable {
case universal
case legacy
case custom(URL)
case customBase64(String)
var name: String {
switch self {
case .universal: return "universal"
case .legacy: return "legacy"
case .custom(let url): return url.lastPathComponent
case .customBase64: return "custom"
}
}
}
public struct DeviceSecurityState: Sendable {
public let isTXMPresent: Bool?
public init(isTXMPresent: Bool?) {
self.isTXMPresent = isTXMPresent
}
}
public enum PreparationStage: Sendable {
case checkingReachability
case checkingDDI
case downloadingDDI(fraction: Double, status: String)
case mountingDDI(fraction: Double)
case verifyingDDI
case ready
}
public enum JITReadiness: Sendable {
case unreachable(reason: String)
case preparationFailed(reason: String)
case ready(DeviceSecurityState)
public var isReady: Bool {
if case .ready = self { return true }
return false
}
}
public struct Configuration: Sendable {
public var deviceAddress: String
public var rsdPort: UInt16
public init(deviceAddress: String = "10.7.0.1", rsdPort: UInt16 = 49152) {
public var connectionTimeout: TimeInterval
public init(deviceAddress: String = "10.7.0.1",
rsdPort: UInt16 = 49152,
connectionTimeout: TimeInterval = 5) {
self.deviceAddress = deviceAddress
self.rsdPort = rsdPort
self.connectionTimeout = connectionTimeout
}
public static let `default` = Configuration()
public static var `default`: Configuration { Configuration() }
}
@available(*, deprecated, message: "Use enableJIT with ddiPaths to prepare the device before enabling JIT.")
public static func enableJIT(targetPID: Int32,
pairingFile: URL,
configuration: Configuration = .default,
script: Script = .universal,
forceScript: Bool = false,
progress: @escaping (String) -> Void = { _ in }) throws {
let txmPresence = ProcessInfo.processInfo.txmPresence
let session = JITSession(pairingFilePath: pairingFile.path, configuration: configuration)
try session.enableJIT(targetPID: targetPID,
script: script,
forceScript: forceScript,
txmPresence: txmPresence,
progress: progress)
}
public static func enableJIT(targetPID: Int32,
pairingFile: URL,
ddiPaths: DDIPaths,
configuration: Configuration = .default,
script: Script = .universal,
forceScript: Bool = false,
preparationProgress: @escaping (PreparationStage) -> Void = { _ in },
progress: @escaping (String) -> Void = { _ in }) throws {
let readiness = prepareDevice(pairingFile: pairingFile,
paths: ddiPaths,
configuration: configuration,
progress: preparationProgress)
let securityState: DeviceSecurityState
switch readiness {
case .ready(let readySecurityState):
securityState = readySecurityState
case .unreachable(let reason), .preparationFailed(let reason):
throw StikJITError.deviceNotReady(reason)
}
let session = JITSession(pairingFilePath: pairingFile.path, configuration: configuration)
try session.enableJIT(targetPID: targetPID, progress: progress)
try session.enableJIT(targetPID: targetPID,
script: script,
forceScript: forceScript,
txmPresence: TXMPresence(isPresent: securityState.isTXMPresent),
progress: progress)
}
public static var isTXMPresent: Bool? {
ProcessInfo.processInfo.txmPresence.isPresent
}
public static func prepareDevice(pairingFile: URL,
paths: DDIPaths,
configuration: Configuration = .default,
progress: @escaping (PreparationStage) -> Void = { _ in }) -> JITReadiness {
progress(.checkingReachability)
if let reason = EndpointProbe.failureReason(address: configuration.deviceAddress,
port: configuration.rsdPort,
timeout: configuration.connectionTimeout) {
return .unreachable(reason: reason)
}
do {
progress(.checkingDDI)
let session = DDISession(pairingFilePath: pairingFile.path, configuration: configuration)
if try !session.isMounted() {
if !paths.allFilesUsable {
try SynchronousDDIDownloader.download(to: paths) { fraction, status in
progress(.downloadingDDI(fraction: fraction, status: status))
}
}
try session.mountDDI(paths: paths) { fraction in
progress(.mountingDDI(fraction: fraction))
}
progress(.verifyingDDI)
guard try session.isMounted() else {
throw StikJITError.ddiNotMounted
}
}
let securityState = DeviceSecurityState(isTXMPresent: isTXMPresent)
progress(.ready)
return .ready(securityState)
} catch {
return .preparationFailed(reason: error.localizedDescription)
}
}
public static func resetCachedDDI(at paths: DDIPaths) throws {
try paths.removeCachedFiles()
}
public static func isDDIMounted(pairingFile: URL,
configuration: Configuration = .default) throws -> Bool {
try DDISession(pairingFilePath: pairingFile.path, configuration: configuration).isMounted()
}
@available(iOS 17.4, *)
public static func downloadDDIIfNeeded(to paths: DDIPaths,
progress: @escaping (Double, String) -> Void = { _, _ in }) async throws {
try await DeveloperDiskImageService.shared.downloadIfNeeded(to: paths, progress: progress)
}
public static func mountDDI(pairingFile: URL,
configuration: Configuration = .default,
paths: DDIPaths,
progress: @escaping (Double) -> Void = { _ in }) throws {
let session = DDISession(pairingFilePath: pairingFile.path, configuration: configuration)
try session.mountDDI(paths: paths, progress: progress)
}
}

View file

@ -10,16 +10,44 @@ public enum StikJITError: Error, LocalizedError {
case device(code: Int32, subCode: Int32, message: String)
case ddiDownload(String)
case ddiFilesMissing(String)
case ddiNotMounted
case deviceNotReady(String)
case txmDetectionUnavailable
case customScript(String)
case scriptExecution(String)
public var errorDescription: String? {
switch self {
case .pairingFile(let detail):
return "Pairing file error: \(detail)."
case .scriptUnavailable:
return "The bundled universal.js could not be loaded from the StikJIT framework."
return "The bundled JIT script could not be loaded from the StikJIT framework."
case .debugProxyUnavailable:
return "Failed to establish a debugserver connection to the target process."
case .device(let code, let subCode, let message):
return "\(message) (idevice code \(code)/\(subCode))."
case .ddiDownload(let detail):
return "Failed to download Developer Disk Image: \(detail)."
case .ddiFilesMissing(let detail):
return "Developer Disk Image files missing: \(detail)."
case .ddiNotMounted:
return "The Developer Disk Image was not mounted after the mount operation completed."
case .deviceNotReady(let detail):
return "The device is not ready for JIT: \(detail)"
case .txmDetectionUnavailable:
return "Whether TXM is present could not be determined. Enable forced script execution to bypass this check."
case .customScript(let detail):
return "Custom JIT script error: \(detail)."
case .scriptExecution(let detail):
return "JIT script failed: \(detail)."
}
}
}

View file

@ -0,0 +1,60 @@
import Foundation
enum SynchronousDDIDownloader {
static func download(to paths: DDIPaths,
progress: @escaping (Double, String) -> Void) throws {
let configuration = URLSessionConfiguration.ephemeral
configuration.timeoutIntervalForRequest = 120
configuration.timeoutIntervalForResource = 600
let session = URLSession(configuration: configuration)
defer { session.finishTasksAndInvalidate() }
let items = DDIDownloadCatalog.items(for: paths)
let total = Double(items.count)
for (index, item) in items.enumerated() {
progress(Double(index) / total, "Downloading \(item.name)...")
try download(item, session: session)
progress(Double(index + 1) / total, "\(item.name) ready")
}
if DDIMountMethod.current == .personalized {
try paths.removeCryptexOnlyFiles()
}
}
private static func download(_ item: DDIDownloadItem, session: URLSession) throws {
let semaphore = DispatchSemaphore(value: 0)
let result = DownloadResult()
let task = session.downloadTask(with: item.url) { temporaryURL, response, error in
defer { semaphore.signal() }
do {
if let error { throw error }
guard let temporaryURL, let httpResponse = response as? HTTPURLResponse else {
throw StikJITError.ddiDownload("invalid response for \(item.url.absoluteString)")
}
guard (200..<300).contains(httpResponse.statusCode) else {
throw StikJITError.ddiDownload("HTTP \(httpResponse.statusCode) for \(item.url.absoluteString)")
}
let destinationURL = URL(fileURLWithPath: item.destinationPath)
let fileManager = FileManager.default
try fileManager.createDirectory(at: destinationURL.deletingLastPathComponent(), withIntermediateDirectories: true)
if fileManager.fileExists(atPath: destinationURL.path) {
try fileManager.removeItem(at: destinationURL)
}
try fileManager.moveItem(at: temporaryURL, to: destinationURL)
} catch {
result.error = error
}
}
task.resume()
semaphore.wait()
if let error = result.error {
if let stikJITError = error as? StikJITError { throw stikJITError }
throw StikJITError.ddiDownload(error.localizedDescription)
}
}
}
private final class DownloadResult: @unchecked Sendable {
var error: Error?
}

File diff suppressed because it is too large Load diff

Binary file not shown.

1
iokit/StikJITIOKit.h Normal file
View file

@ -0,0 +1 @@
#include <IOKit/IOKitLib.h>

4
iokit/module.modulemap Normal file
View file

@ -0,0 +1,4 @@
module StikJITIOKit [system] {
header "StikJITIOKit.h"
export *
}

View file

@ -8,8 +8,8 @@ options:
settings:
base:
SWIFT_VERSION: "5.0"
MARKETING_VERSION: "1.0.0"
CURRENT_PROJECT_VERSION: "1"
MARKETING_VERSION: "1.9.0"
CURRENT_PROJECT_VERSION: "10"
CLANG_CXX_LIBRARY: "libc++"
targets:
@ -26,6 +26,7 @@ targets:
- sdk: Security.framework
- sdk: CFNetwork.framework
- sdk: SystemConfiguration.framework
- sdk: IOKit.framework
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.stik.StikJIT
@ -33,12 +34,18 @@ targets:
DEFINES_MODULE: YES
BUILD_LIBRARY_FOR_DISTRIBUTION: YES
SKIP_INSTALL: NO
# idevice FFI: modulemap/headers to compile `import idevice`, and force_load the
# static lib so all its symbols are embedded in the framework binary (self-contained).
SWIFT_INCLUDE_PATHS: "$(SRCROOT)/idevice"
HEADER_SEARCH_PATHS: "$(SRCROOT)/idevice"
# Embed only the idevice code reachable from StikJIT. Keep archive symbols
# private so unused FFI/Rust exports do not become dead-strip roots.
SWIFT_INCLUDE_PATHS: "$(SRCROOT)/idevice $(SRCROOT)/iokit"
HEADER_SEARCH_PATHS: "$(SRCROOT)/idevice $(SRCROOT)/iokit"
LIBRARY_SEARCH_PATHS: "$(SRCROOT)/idevice"
OTHER_LDFLAGS: "-force_load $(SRCROOT)/idevice/libidevice_ffi.a"
OTHER_LDFLAGS: "$(inherited) -Wl,-hidden-lidevice_ffi"
DEAD_CODE_STRIPPING: YES
configs:
Release:
# Remove private symbol names from the shipped binary; keep the dSYM.
STRIP_INSTALLED_PRODUCT: YES
STRIP_STYLE: non-global
schemes:
StikJIT: