[AltJIT] Adds AltJIT CLI tool to enable JIT on devices running iOS 17+

Commands:

altjit enable [app/pid] --udid [udid]
* Enables JIT for given app/process

altjit mount --udid [udid]
* Mounts personalized developer disk
This commit is contained in:
Riley Testut
2023-09-07 18:00:53 -05:00
parent 1a42aaeae8
commit d846445448
20 changed files with 1503 additions and 7 deletions

View File

@@ -0,0 +1,9 @@
//
// Use this file to import your target's public headers that you would like to expose to Swift.
//
#import "ALTErrorKeys.h"
// Shared
#import "ALTWrappedError.h"
#import "NSError+ALTServerError.h"

18
AltJIT/AltJIT.swift Normal file
View File

@@ -0,0 +1,18 @@
//
// AltJIT.swift
// AltJIT
//
// Created by Riley Testut on 8/29/23.
// Copyright © 2023 Riley Testut. All rights reserved.
//
import OSLog
import ArgumentParser
@main
struct AltJIT: AsyncParsableCommand
{
static let configuration = CommandConfiguration(commandName: "altjit",
abstract: "Enable JIT for sideloaded apps.",
subcommands: [EnableJIT.self, MountDisk.self])
}

View File

@@ -0,0 +1,452 @@
//
// EnableJIT.swift
// AltPackage
//
// Created by Riley Testut on 8/29/23.
// Copyright © 2023 Riley Testut. All rights reserved.
//
import Foundation
import OSLog
import RegexBuilder
import ArgumentParser
struct EnableJIT: PythonCommand
{
static let configuration = CommandConfiguration(commandName: "enable", abstract: "Enable JIT for a specific app on your device.")
@Argument(help: "The name or PID of the app to enable JIT for.", transform: AppProcess.init)
var process: AppProcess
@Option(help: "Your iOS device's UDID.")
var udid: String
// PythonCommand
var pythonPath: String?
mutating func run() async throws
{
// Use local variables to fix "escaping autoclosure captures mutating self parameter" compiler error.
let process = self.process
let udid = self.udid
do
{
do
{
Logger.main.info("Enabling JIT for \(process, privacy: .private(mask: .hash)) on device \(udid, privacy: .private(mask: .hash))...")
try await self.prepare()
let rsdTunnel = try await self.startRSDTunnel()
defer { rsdTunnel.process.terminate() }
print("Connected to device \(self.udid)!", rsdTunnel)
let port = try await self.startDebugServer(rsdTunnel: rsdTunnel)
print("Started debugserver on port \(port).")
print("Attaching debugger...")
let lldb = try await self.attachDebugger(ipAddress: rsdTunnel.ipAddress, port: port)
defer { lldb.terminate() }
print("Attached debugger to \(process).")
try await self.detachDebugger(lldb)
print("Detached debugger from \(process).")
print("✅ Successfully enabled JIT for \(process) on device \(udid)!")
}
catch let error as ProcessError
{
if let output = error.output
{
print(output)
}
throw error
}
}
catch
{
print("❌ Unable to enable JIT for \(process) on device \(udid).")
print(error.localizedDescription)
Logger.main.error("Failed to enable JIT for \(process, privacy: .private(mask: .hash)) on device \(udid, privacy: .private(mask: .hash)). \(error, privacy: .public)")
throw ExitCode.failure
}
}
}
private extension EnableJIT
{
func startRSDTunnel() async throws -> RemoteServiceDiscoveryTunnel
{
do
{
Logger.main.info("Starting RSD tunnel...")
let process = try Process.launch(.python3, arguments: ["-u", "-m", "pymobiledevice3", "remote", "start-quic-tunnel", "--udid", self.udid], environment: self.processEnvironment)
do
{
let rsdTunnel = try await withTimeout(seconds: 20) {
let regex = Regex {
"--rsd"
OneOrMore(.whitespace)
Capture {
OneOrMore(.anyGraphemeCluster)
}
OneOrMore(.whitespace)
TryCapture {
OneOrMore(.digit)
} transform: { match in
Int(match)
}
}
for try await line in process.outputLines
{
if let match = line.firstMatch(of: regex)
{
let rsdTunnel = RemoteServiceDiscoveryTunnel(ipAddress: String(match.1), port: match.2, process: process)
return rsdTunnel
}
}
throw ProcessError.unexpectedOutput(executableURL: .python3, output: process.output)
}
// MUST close standardOutput in order to stream output later.
process.stopOutput()
return rsdTunnel
}
catch is TimedOutError
{
process.terminate()
let error = ProcessError.timedOut(executableURL: .python3, output: process.output)
throw error
}
catch
{
process.terminate()
throw error
}
}
catch let error as NSError
{
let localizedFailure = NSLocalizedString("Could not connect to device \(self.udid).", comment: "")
throw error.withLocalizedFailure(localizedFailure)
}
}
func startDebugServer(rsdTunnel: RemoteServiceDiscoveryTunnel) async throws -> Int
{
do
{
Logger.main.info("Starting debugserver...")
return try await withTimeout(seconds: 10) {
let arguments = ["-u", "-m", "pymobiledevice3", "developer", "debugserver", "start-server"] + rsdTunnel.commandArguments
let output = try await Process.launchAndWait(.python3, arguments: arguments, environment: self.processEnvironment)
let port = Reference(Int.self)
let regex = Regex {
"connect://"
OneOrMore(.anyGraphemeCluster, .eager)
":"
TryCapture(as: port) {
OneOrMore(.digit)
} transform: { match in
Int(match)
}
}
if let match = output.firstMatch(of: regex)
{
return match[port]
}
throw ProcessError.unexpectedOutput(executableURL: .python3, output: output)
}
}
catch let error as NSError
{
let localizedFailure = NSLocalizedString("Could not start debugserver on device \(self.udid).", comment: "")
throw error.withLocalizedFailure(localizedFailure)
}
}
func attachDebugger(ipAddress: String, port: Int) async throws -> Process
{
do
{
Logger.main.info("Attaching debugger...")
let processID: Int
switch self.process
{
case .pid(let pid): processID = pid
case .name(let name):
guard let pid = try await self.getPID(for: name) else { throw JITError.processNotRunning(self.process) }
processID = pid
}
let process = try Process.launch(.lldb, environment: self.processEnvironment)
do
{
try await withThrowingTaskGroup(of: Void.self) { taskGroup in
// // Throw error if program terminates.
// taskGroup.addTask {
// try await withCheckedThrowingContinuation { continuation in
// process.terminationHandler = { process in
// Task {
// // Should NEVER be called unless an error occurs.
// continuation.resume(throwing: ProcessError.terminated(executableURL: .lldb, exitCode: process.terminationStatus, output: process.output))
// }
// }
// }
// }
taskGroup.addTask {
do
{
try await self.sendDebuggerCommand("platform select remote-ios", to: process, timeout: 5) {
ChoiceOf {
"SDK Roots:"
"unable to locate SDK"
}
}
let ipAddress = "[\(ipAddress)]"
let connectCommand = "process connect connect://\(ipAddress):\(port)"
try await self.sendDebuggerCommand(connectCommand, to: process, timeout: 10)
try await self.sendDebuggerCommand("settings set target.memory-module-load-level minimal", to: process, timeout: 5)
let attachCommand = "attach -p \(processID)"
let failureMessage = "attach failed"
let output = try await self.sendDebuggerCommand(attachCommand, to: process, timeout: 120) {
ChoiceOf {
failureMessage
Regex {
"Process "
OneOrMore(.digit)
" stopped"
}
}
}
if output.contains(failureMessage)
{
throw ProcessError.failed(executableURL: .lldb, exitCode: -1, output: process.output)
}
}
catch is TimedOutError
{
let error = ProcessError.timedOut(executableURL: .lldb, output: process.output)
throw error
}
}
// Wait until first child task returns
_ = try await taskGroup.next()!
// Cancel remaining tasks
taskGroup.cancelAll()
}
return process
}
catch
{
process.terminate()
throw error
}
}
catch let error as NSError
{
let localizedFailure = String(format: NSLocalizedString("Could not attach debugger to %@.", comment: ""), self.process.description)
throw error.withLocalizedFailure(localizedFailure)
}
}
func detachDebugger(_ process: Process) async throws
{
do
{
Logger.main.info("Detaching debugger...")
try await withThrowingTaskGroup(of: Void.self) { taskGroup in
// // Throw error if program terminates.
// taskGroup.addTask {
// try await withCheckedThrowingContinuation { continuation in
// process.terminationHandler = { process in
// if process.terminationStatus == 0
// {
// continuation.resume()
// }
// else
// {
// continuation.resume(throwing: ProcessError.terminated(executableURL: .lldb, exitCode: process.terminationStatus, output: process.output))
// }
// }
// }
// }
taskGroup.addTask {
do
{
try await self.sendDebuggerCommand("c", to: process, timeout: 10) {
"Process "
OneOrMore(.digit)
" resuming"
}
try await self.sendDebuggerCommand("detach", to: process, timeout: 10) {
"Process "
OneOrMore(.digit)
" detached"
}
}
catch is TimedOutError
{
let error = ProcessError.timedOut(executableURL: .lldb, output: process.output)
throw error
}
}
// Wait until first child task returns
_ = try await taskGroup.next()!
// Cancel remaining tasks
taskGroup.cancelAll()
}
}
catch let error as NSError
{
let localizedFailure = NSLocalizedString("Could not detach debugger from \(self.process).", comment: "")
throw error.withLocalizedFailure(localizedFailure)
}
}
}
private extension EnableJIT
{
func getPID(for name: String) async throws -> Int?
{
Logger.main.info("Retrieving PID for \(name, privacy: .private(mask: .hash))...")
let arguments = ["-m", "pymobiledevice3", "processes", "pgrep", name, "--udid", self.udid]
let output = try await Process.launchAndWait(.python3, arguments: arguments, environment: self.processEnvironment)
let regex = Regex {
"INFO"
OneOrMore(.whitespace)
TryCapture {
OneOrMore(.digit)
} transform: { match in
Int(match)
}
OneOrMore(.whitespace)
name
}
if let match = output.firstMatch(of: regex)
{
Logger.main.info("\(name, privacy: .private(mask: .hash)) PID is \(match.1)")
return match.1
}
return nil
}
@discardableResult
func sendDebuggerCommand(_ command: String, to process: Process, timeout: TimeInterval,
@RegexComponentBuilder regex: @escaping () -> (some RegexComponent<Substring>)? = { Optional<Regex<Substring>>.none }) async throws -> String
{
guard let inputPipe = process.standardInput as? Pipe else { preconditionFailure("`process` must have a Pipe as its standardInput") }
defer {
inputPipe.fileHandleForWriting.writeabilityHandler = nil
}
let initialOutput = process.output
let data = (command + "\n").data(using: .utf8)! // Will always succeed.
Logger.main.info("Sending lldb command: \(command, privacy: .public)")
let output = try await withTimeout(seconds: timeout) {
// Wait until process is ready to receive input.
try await withCheckedThrowingContinuation { continuation in
inputPipe.fileHandleForWriting.writeabilityHandler = { fileHandle in
inputPipe.fileHandleForWriting.writeabilityHandler = nil
let result = Result { try fileHandle.write(contentsOf: data) }
continuation.resume(with: result)
}
}
// Wait until we receive at least one line of output.
for try await _ in process.outputLines
{
break
}
// Keep waiting until output doesn't change.
// If regex is provided, we keep waiting until a match is found.
var previousOutput = process.output
while true
{
try await Task.sleep(for: .seconds(0.2))
let output = process.output
if output == previousOutput
{
guard let regex = regex() else {
// No regex, so break as soon as output stops changing.
break
}
if output.contains(regex)
{
// Found a match, so exit while loop.
break
}
else
{
// Output hasn't changed, but regex does not match (yet).
continue
}
}
previousOutput = output
}
return previousOutput
}
// Subtract initialOutput from output to get just this command's output.
let commandOutput = output.replacingOccurrences(of: initialOutput, with: "")
return commandOutput
}
}

View File

@@ -0,0 +1,75 @@
//
// MountDisk.swift
// AltPackage
//
// Created by Riley Testut on 8/31/23.
// Copyright © 2023 Riley Testut. All rights reserved.
//
import Foundation
import OSLog
import ArgumentParser
typealias MountError = MountErrorCode.Error
enum MountErrorCode: Int, ALTErrorEnum
{
case alreadyMounted
var errorFailureReason: String {
switch self
{
case .alreadyMounted: return NSLocalizedString("A personalized Developer Disk is already mounted.", comment: "")
}
}
}
struct MountDisk: PythonCommand
{
static let configuration = CommandConfiguration(commandName: "mount", abstract: "Mount a personalized developer disk image onto an iOS device.")
@Option(help: "The iOS device's UDID.")
var udid: String
// PythonCommand
var pythonPath: String?
mutating func run() async throws
{
do
{
print("Mounting personalized developer disk...")
try await self.prepare()
let output = try await Process.launchAndWait(.python3, arguments: ["-m", "pymobiledevice3", "mounter", "auto-mount", "--udid", self.udid])
if !output.contains("DeveloperDiskImage")
{
throw ProcessError.unexpectedOutput(executableURL: .python3, output: output)
}
if output.contains("already mounted")
{
throw MountError(.alreadyMounted)
}
print("✅ Successfully mounted personalized Developer Disk!")
}
catch let error as MountError where error.code == .alreadyMounted
{
// Prepend since this is not really an error.
let localizedDescription = "⚠️ " + error.localizedDescription
print(localizedDescription)
throw ExitCode.success
}
catch
{
// Output failure message first before error.
print("❌ Unable to mount personalized Developer Disk.")
print(error.localizedDescription)
throw ExitCode.failure
}
}
}

View File

@@ -0,0 +1,16 @@
//
// Logger+AltJIT.swift
// AltJIT
//
// Created by Riley Testut on 8/29/23.
// Copyright © 2023 Riley Testut. All rights reserved.
//
import OSLog
public extension Logger
{
static let altjitSubsystem = Bundle.main.bundleIdentifier!
static let main = Logger(subsystem: altjitSubsystem, category: "AltJIT")
}

View File

@@ -0,0 +1,57 @@
//
// Task+Timeout.swift
// AltPackage
//
// Created by Riley Testut on 8/31/23.
// Copyright © 2023 Riley Testut. All rights reserved.
//
// Based heavily on https://forums.swift.org/t/running-an-async-task-with-a-timeout/49733/13
//
import Foundation
struct TimedOutError: LocalizedError
{
var duration: TimeInterval
public var errorDescription: String? {
//TODO: Change pluralization for 1 second.
let errorDescription = String(format: NSLocalizedString("The task timed out after %@ seconds.", comment: ""), self.duration.formatted())
return errorDescription
}
}
///
/// Execute an operation in the current task subject to a timeout.
///
/// - Parameters:
/// - seconds: The duration in seconds `operation` is allowed to run before timing out.
/// - operation: The async operation to perform.
/// - Returns: Returns the result of `operation` if it completed in time.
/// - Throws: Throws ``TimedOutError`` if the timeout expires before `operation` completes.
/// If `operation` throws an error before the timeout expires, that error is propagated to the caller.
func withTimeout<R>(seconds: TimeInterval, file: StaticString = #file, line: Int = #line, operation: @escaping @Sendable () async throws -> R) async throws -> R
{
return try await withThrowingTaskGroup(of: R.self) { group in
let deadline = Date(timeIntervalSinceNow: seconds)
// Start actual work.
group.addTask {
return try await operation()
}
// Start timeout child task.
group.addTask {
let interval = deadline.timeIntervalSinceNow
if interval > 0 {
try await Task.sleep(for: .seconds(interval))
}
try Task.checkCancellation()
// Weve reached the timeout.
throw TimedOutError(duration: seconds)
}
// First finished child task wins, cancel the other task.
let result = try await group.next()!
group.cancelAll()
return result
}
}

View File

@@ -0,0 +1,15 @@
//
// URL+Tools.swift
// AltJIT
//
// Created by Riley Testut on 9/3/23.
// Copyright © 2023 Riley Testut. All rights reserved.
//
import Foundation
extension URL
{
static let python3 = URL(fileURLWithPath: "/usr/bin/python3")
static let lldb = URL(fileURLWithPath: "/usr/bin/lldb")
}

View File

@@ -0,0 +1,14 @@
//
// ALTErrorKeys.h
// AltJIT
//
// Created by Riley Testut on 9/1/23.
// Copyright © 2023 Riley Testut. All rights reserved.
//
@import Foundation;
// Can't import AltSign (for reasons), so re-declare these constants here instead.
extern NSErrorUserInfoKey const ALTSourceFileErrorKey;
extern NSErrorUserInfoKey const ALTSourceLineErrorKey;
extern NSErrorUserInfoKey const ALTAppNameErrorKey;

View File

@@ -0,0 +1,13 @@
//
// ALTErrorKeys.m
// AltJIT
//
// Created by Riley Testut on 9/1/23.
// Copyright © 2023 Riley Testut. All rights reserved.
//
#import "ALTErrorKeys.h"
NSErrorUserInfoKey const ALTSourceFileErrorKey = @"ALTSourceFile";
NSErrorUserInfoKey const ALTSourceLineErrorKey = @"ALTSourceLine";
NSErrorUserInfoKey const ALTAppNameErrorKey = @"appName";

View File

@@ -0,0 +1,58 @@
//
// PythonCommand.swift
// AltJIT
//
// Created by Riley Testut on 9/6/23.
// Copyright © 2023 Riley Testut. All rights reserved.
//
import ArgumentParser
protocol PythonCommand: AsyncParsableCommand
{
var pythonPath: String? { get set }
}
extension PythonCommand
{
var processEnvironment: [String: String] {
var environment = ProcessInfo.processInfo.environment
if let pythonPath
{
environment["PYTHONPATH"] = pythonPath
}
return environment
}
mutating func prepare() async throws
{
let pythonPath = try await self.readPythonPath()
self.pythonPath = pythonPath.path(percentEncoded: false)
}
}
private extension PythonCommand
{
func readPythonPath() async throws -> URL
{
let processOutput: String
do
{
processOutput = try await Process.launchAndWait(.python3, arguments: ["-m", "site", "--user-site"])
}
catch let error as ProcessError where error.exitCode == 2
{
// Ignore exit code 2.
guard let output = error.output else { throw error }
processOutput = output
}
let sanitizedOutput = processOutput.trimmingCharacters(in: .whitespacesAndNewlines)
let pythonURL = URL(filePath: sanitizedOutput)
return pythonURL
}
}

View File

@@ -0,0 +1,41 @@
//
// RemoteServiceDiscoveryTunnel.swift
// AltJIT
//
// Created by Riley Testut on 9/3/23.
// Copyright © 2023 Riley Testut. All rights reserved.
//
import Foundation
final class RemoteServiceDiscoveryTunnel
{
let ipAddress: String
let port: Int
let process: Process
var commandArguments: [String] {
["--rsd", self.ipAddress, String(self.port)]
}
init(ipAddress: String, port: Int, process: Process)
{
self.ipAddress = ipAddress
self.port = port
self.process = process
}
deinit
{
self.process.terminate()
}
}
extension RemoteServiceDiscoveryTunnel: CustomStringConvertible
{
var description: String {
"\(self.ipAddress) \(self.port)"
}
}