From f5005fd43ba201f93a9a3b4fdea861f90b0bb48a Mon Sep 17 00:00:00 2001 From: Mobile Kevin Bot! Date: Sat, 10 Oct 2026 22:07:16 +0200 Subject: [PATCH 01/18] Add Android OkHttp network interception agent --- agents/android/java/InjectedAgent.java | 9 +- agents/android/java/JsonRpcSocketServer.java | 31 ++- agents/android/java/Network.java | 223 +++++++++++++++++++ agents/android/java/NetworkGlob.java | 24 ++ agents/android/java/OkHttpHook.java | 200 +++++++++++++++++ agents/android/jvmti_agent.c | 83 +++++++ 6 files changed, 560 insertions(+), 10 deletions(-) create mode 100644 agents/android/java/Network.java create mode 100644 agents/android/java/NetworkGlob.java create mode 100644 agents/android/java/OkHttpHook.java diff --git a/agents/android/java/InjectedAgent.java b/agents/android/java/InjectedAgent.java index 8e0a9522..9a9c3f98 100644 --- a/agents/android/java/InjectedAgent.java +++ b/agents/android/java/InjectedAgent.java @@ -16,17 +16,24 @@ public class InjectedAgent implements JsonRpcSocketServer.Dispatcher { // entry point called by jvmti_agent.c once the app's main looper is up public static void start() { AndroidBridge.init(); + OkHttpHook.install(); AndroidBridge.sMainHandler.postDelayed(InjectedAgent::startRpcServer, 500); } private static void startRpcServer() { String socket = "mobilecli." + AndroidBridge.getPackageName(); - new JsonRpcSocketServer(socket, new InjectedAgent()).startDaemon(); + new JsonRpcSocketServer(socket, new InjectedAgent(), true).startDaemon(); } @Override public Object dispatch(String method, JSONObject params) throws Exception { switch (method) { + case "network.enable": return Network.enable(params); + case "network.poll": return Network.poll(params); + case "network.continue": return Network.continueRequest(params); + case "network.fulfill": return Network.fulfill(params); + case "network.abort": return Network.abort(params); + case "network.disable": return Network.disable(params); case "device.dump.ui": return WebViews.dumpUi(); diff --git a/agents/android/java/JsonRpcSocketServer.java b/agents/android/java/JsonRpcSocketServer.java index b1d65b6f..c037ae6e 100644 --- a/agents/android/java/JsonRpcSocketServer.java +++ b/agents/android/java/JsonRpcSocketServer.java @@ -57,12 +57,18 @@ static String requireStringParam(JSONObject params, String key) throws RpcExcept private final String socketName; private final Dispatcher dispatcher; + private final boolean concurrent; private volatile boolean running = true; private LocalServerSocket serverSocket; JsonRpcSocketServer(String socketName, Dispatcher dispatcher) { + this(socketName, dispatcher, false); + } + + JsonRpcSocketServer(String socketName, Dispatcher dispatcher, boolean concurrent) { this.socketName = socketName; this.dispatcher = dispatcher; + this.concurrent = concurrent; } /** Binds and serves on the calling thread until stop() or a socket error. */ @@ -77,19 +83,26 @@ void start() throws Exception { if (running) Log.e(TAG, "accept failed on " + socketName, e); break; } - try { - handleConnection(conn); - } catch (Exception e) { - Log.e(TAG, "Error handling connection", e); - } finally { - try { - conn.close(); - } catch (Exception ignored) { - } + if (concurrent) { + Thread worker = new Thread(() -> serveConnection(conn), "jsonrpc-client-" + socketName); + worker.setDaemon(true); + worker.start(); + } else { + serveConnection(conn); } } } + private void serveConnection(LocalSocket conn) { + try { + handleConnection(conn); + } catch (Exception e) { + Log.e(TAG, "Error handling connection", e); + } finally { + try { conn.close(); } catch (Exception ignored) { } + } + } + /** Runs start() on a daemon thread; a bind failure surfaces as a log line there. */ void startDaemon() { Thread thread = new Thread(() -> { diff --git a/agents/android/java/Network.java b/agents/android/java/Network.java new file mode 100644 index 00000000..420185fd --- /dev/null +++ b/agents/android/java/Network.java @@ -0,0 +1,223 @@ +package com.mobilenext.mobilecli; + +import android.util.Base64; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; +import java.util.regex.Pattern; + +/** Request routing and the fail-open handoff between an app thread and RPC threads. */ +final class Network { + private static final long REQUEST_TIMEOUT_MS = 60_000; + private static final long POLL_TIMEOUT_MS = 30_000; + private static final long POLL_GRACE_MS = 15_000; + private static final int MAX_EVENTS = 256; + private static final int MAX_BODY_BYTES = 256 * 1024; + private static final Object LOCK = new Object(); + private static final AtomicLong IDS = new AtomicLong(); + private static final Map PAUSED = new HashMap<>(); + private static final ArrayDeque EVENTS = new ArrayDeque<>(); + private static List routes = new ArrayList<>(); + private static boolean enabled; + private static int pollers; + private static long lastPollEnd; + + static final class Verdict { + final String action; + final JSONObject params; + private Verdict(String action, JSONObject params) { + this.action = action; + this.params = params; + } + } + + private static final Verdict UNCHANGED = new Verdict("continue", new JSONObject()); + + private static final class Pause { + Verdict verdict; + JSONObject event; + } + + private static final class Route { + final Pattern url; + final String method; + Route(Pattern url, String method) { + this.url = url; + this.method = method; + } + boolean matches(String requestUrl, String requestMethod) { + return (method == null || method.equalsIgnoreCase(requestMethod)) + && url.matcher(requestUrl).matches(); + } + } + + private Network() { } + + static JSONObject enable(JSONObject params) throws Exception { + JSONArray definitions = params == null ? null : params.optJSONArray("routes"); + if (definitions == null) { + throw new RpcException(RpcException.INVALID_PARAMS, "routes must be an array"); + } + List next = new ArrayList<>(); + for (int i = 0; i < definitions.length(); i++) { + JSONObject definition = definitions.optJSONObject(i); + if (definition == null) { + throw new RpcException(RpcException.INVALID_PARAMS, "route must be an object"); + } + String glob = JsonRpcSocketServer.requireParam(definition, "url"); + String method = definition.optString("method", null); + next.add(new Route(NetworkGlob.compile(glob), method == null || method.isEmpty() ? null : method)); + } + // An empty route list means intercept every request. + if (next.isEmpty()) next.add(new Route(Pattern.compile(".*", Pattern.DOTALL), null)); + synchronized (LOCK) { + releaseAllLocked(); + EVENTS.clear(); + routes = next; + enabled = true; + lastPollEnd = System.currentTimeMillis(); + LOCK.notifyAll(); + } + return ok(); + } + + static JSONObject poll(JSONObject params) throws JSONException { + JSONArray batch = new JSONArray(); + long deadline = System.currentTimeMillis() + POLL_TIMEOUT_MS; + synchronized (LOCK) { + pollers++; + try { + while (enabled && EVENTS.isEmpty()) { + long remaining = deadline - System.currentTimeMillis(); + if (remaining <= 0) break; + try { LOCK.wait(remaining); } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + while (!EVENTS.isEmpty()) batch.put(EVENTS.removeFirst()); + } finally { + pollers--; + lastPollEnd = System.currentTimeMillis(); + } + } + return new JSONObject().put("events", batch); + } + + static JSONObject continueRequest(JSONObject params) throws Exception { + return decide(params, "continue"); + } + + static JSONObject fulfill(JSONObject params) throws Exception { + int status = params == null ? 0 : params.optInt("status", 0); + if (status < 100 || status > 599) { + throw new RpcException(RpcException.INVALID_PARAMS, "status must be an HTTP status code"); + } + return decide(params, "fulfill"); + } + + static JSONObject abort(JSONObject params) throws Exception { + return decide(params, "abort"); + } + + static JSONObject disable(JSONObject params) throws JSONException { + synchronized (LOCK) { + enabled = false; + routes = new ArrayList<>(); + EVENTS.clear(); + releaseAllLocked(); + LOCK.notifyAll(); + } + return ok(); + } + + /** Called only by the OkHttp interceptor on the app's request thread. */ + static Verdict await(String method, String url, JSONObject headers, byte[] body) { + Pause pause = new Pause(); + String id; + synchronized (LOCK) { + if (!enabled || !matchesLocked(url, method) || EVENTS.size() >= MAX_EVENTS) return UNCHANGED; + id = Long.toString(IDS.incrementAndGet()); + JSONObject event = new JSONObject(); + try { + event.put("type", "paused"); + event.put("requestId", id); + event.put("method", method); + event.put("url", url); + event.put("headers", headers); + event.put("body", body == null ? JSONObject.NULL : Base64.encodeToString( + body, 0, Math.min(body.length, MAX_BODY_BYTES), Base64.NO_WRAP)); + if (body != null && body.length > MAX_BODY_BYTES) event.put("bodyTruncated", true); + event.put("stack", "okhttp"); + } catch (JSONException e) { + return UNCHANGED; + } + PAUSED.put(id, pause); + pause.event = event; + EVENTS.addLast(event); + LOCK.notifyAll(); + } + long deadline = System.currentTimeMillis() + REQUEST_TIMEOUT_MS; + synchronized (LOCK) { + while (pause.verdict == null) { + long now = System.currentTimeMillis(); + if (!enabled || now >= deadline || (pollers == 0 && now - lastPollEnd >= POLL_GRACE_MS)) { + pause.verdict = UNCHANGED; + break; + } + long remaining = Math.min(deadline - now, 1000); + try { LOCK.wait(remaining); } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + pause.verdict = UNCHANGED; + } + } + PAUSED.remove(id); + EVENTS.remove(pause.event); + return pause.verdict; + } + } + + private static JSONObject decide(JSONObject params, String action) throws Exception { + String id = JsonRpcSocketServer.requireParam(params, "requestId"); + // Check payload before waking the app thread. + if (params.has("body") && !params.isNull("body")) { + try { Base64.decode(params.getString("body"), Base64.DEFAULT); } + catch (IllegalArgumentException e) { + throw new RpcException(RpcException.INVALID_PARAMS, "body must be base64"); + } + } + synchronized (LOCK) { + Pause pause = PAUSED.get(id); + if (pause == null || pause.verdict != null) { + throw new RpcException(RpcException.INVALID_PARAMS, "request is not paused: " + id); + } + pause.verdict = new Verdict(action, params); + PAUSED.remove(id); + LOCK.notifyAll(); + } + return ok(); + } + + private static boolean matchesLocked(String url, String method) { + for (Route route : routes) if (route.matches(url, method)) return true; + return false; + } + + private static void releaseAllLocked() { + for (Pause pause : PAUSED.values()) pause.verdict = UNCHANGED; + PAUSED.clear(); + LOCK.notifyAll(); + } + + private static JSONObject ok() throws JSONException { + return new JSONObject().put("status", "ok"); + } +} diff --git a/agents/android/java/NetworkGlob.java b/agents/android/java/NetworkGlob.java new file mode 100644 index 00000000..dd0121f5 --- /dev/null +++ b/agents/android/java/NetworkGlob.java @@ -0,0 +1,24 @@ +package com.mobilenext.mobilecli; + +import java.util.regex.Pattern; + +/** Converts CLI URL globs to full-string matchers. Independent of Android. */ +final class NetworkGlob { + private NetworkGlob() { } + + static Pattern compile(String glob) { + StringBuilder regex = new StringBuilder(); + for (int i = 0; i < glob.length(); i++) { + char c = glob.charAt(i); + if (c == '*') { + if (i + 1 < glob.length() && glob.charAt(i + 1) == '*') { regex.append(".*"); i++; } + else regex.append("[^/]*"); + } else if (c == '?') regex.append('.'); + else { + if ("\\.[]{}()+-^$|".indexOf(c) >= 0) regex.append('\\'); + regex.append(c); + } + } + return Pattern.compile(regex.toString(), Pattern.DOTALL); + } +} diff --git a/agents/android/java/OkHttpHook.java b/agents/android/java/OkHttpHook.java new file mode 100644 index 00000000..f4695d68 --- /dev/null +++ b/agents/android/java/OkHttpHook.java @@ -0,0 +1,200 @@ +package com.mobilenext.mobilecli; + +import android.util.Base64; +import android.util.Log; + +import org.json.JSONObject; + +import java.io.IOException; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.Iterator; + +/** Reflective OkHttp interceptor so the injected dex has no OkHttp dependency. */ +final class OkHttpHook { + private static final String TAG = "OkHttpHook"; + private static volatile Object installedInterceptor; + + private OkHttpHook() { } + + static synchronized void install() { + if (installedInterceptor != null) return; + try { + Object app = Class.forName("android.app.ActivityThread") + .getMethod("currentApplication").invoke(null); + if (app == null) throw new IllegalStateException("application unavailable"); + ClassLoader loader = (ClassLoader) app.getClass().getMethod("getClassLoader").invoke(app); + Class clientClass = Class.forName("okhttp3.OkHttpClient", false, loader); + Class interceptorClass = Class.forName("okhttp3.Interceptor", false, loader); + Object interceptor = Proxy.newProxyInstance(loader, new Class[]{interceptorClass}, + new Handler()); + if (!nativeInstall(clientClass, interceptor)) { + throw new IllegalStateException("JVMTI getter breakpoint unavailable"); + } + installedInterceptor = interceptor; + Log.i(TAG, "OkHttp interceptor installed"); + } catch (ClassNotFoundException e) { + Log.i(TAG, "OkHttp is not present in the app"); + } catch (Exception | LinkageError e) { + Log.e(TAG, "Failed to install OkHttp interceptor", e); + } + } + + // The attached JVMTI library registers this native method before start(). + private static native boolean nativeInstall(Class clientClass, Object interceptor); + + private static final class Handler implements InvocationHandler { + @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + if (!"intercept".equals(method.getName())) { + if ("hashCode".equals(method.getName())) return System.identityHashCode(proxy); + if ("equals".equals(method.getName())) return proxy == args[0]; + return "mobilecli network interceptor"; + } + return intercept(args[0]); + } + } + + static Object intercept(Object chain) throws Throwable { + Object request = call(chain, "request"); + String url = call(request, "url").toString(); + String method = (String) call(request, "method"); + JSONObject headers = headersToJson(call(request, "headers")); + Network.Verdict verdict = Network.await(method, url, headers, requestBodyBytes(request)); + switch (verdict.action) { + case "fulfill": return syntheticResponse(request, verdict.params); + case "abort": throw new IOException("mobilecli network abort: " + + verdict.params.optString("errorCode", "failed")); + case "continue": + if (verdict.params.has("url") || verdict.params.has("method") + || verdict.params.has("headers") || verdict.params.has("body")) { + request = overrideRequest(request, verdict.params); + } + return call(chain, "proceed", request); + default: throw new IOException("unknown network decision: " + verdict.action); + } + } + + private static JSONObject headersToJson(Object headers) throws Throwable { + JSONObject result = new JSONObject(); + int size = ((Number) call(headers, "size")).intValue(); + for (int i = 0; i < size; i++) { + String name = (String) call(headers, "name", i); + String value = (String) call(headers, "value", i); + result.put(name, value); + } + return result; + } + + private static byte[] requestBodyBytes(Object request) { + try { + Object body = call(request, "body"); + if (body == null) return null; + long length = ((Number) call(body, "contentLength")).longValue(); + if (length < 0 || length > 256 * 1024) return null; + try { if (Boolean.TRUE.equals(call(body, "isOneShot"))) return null; } + catch (NoSuchMethodException ignored) { } + try { if (Boolean.TRUE.equals(call(body, "isDuplex"))) return null; } + catch (NoSuchMethodException ignored) { } + ClassLoader loader = body.getClass().getClassLoader(); + Class bufferClass = Class.forName("okio.Buffer", true, loader); + Object buffer = bufferClass.getConstructor().newInstance(); + call(body, "writeTo", buffer); + return (byte[]) call(buffer, "readByteArray"); + } catch (Throwable e) { + // Reading a body is observational. A custom RequestBody may reject a + // second write; leave it intact and report an unavailable body. + return null; + } + } + + private static Object overrideRequest(Object request, JSONObject params) throws Throwable { + Object builder = call(request, "newBuilder"); + if (params.has("url")) call(builder, "url", params.getString("url")); + if (params.has("headers")) { + JSONObject headers = params.getJSONObject("headers"); + Object headerBuilder = Class.forName("okhttp3.Headers$Builder", true, + request.getClass().getClassLoader()).getConstructor().newInstance(); + Iterator names = headers.keys(); + while (names.hasNext()) { + String name = names.next(); + call(headerBuilder, "add", name, headers.getString(name)); + } + call(builder, "headers", call(headerBuilder, "build")); + } + String method = params.optString("method", (String) call(request, "method")); + Object body = call(request, "body"); + if (params.has("body")) body = responseBody(request, Base64.decode( + params.getString("body"), Base64.DEFAULT), true); + if (params.has("method") || params.has("body")) call(builder, "method", method, body); + return call(builder, "build"); + } + + private static Object syntheticResponse(Object request, JSONObject params) throws Throwable { + ClassLoader loader = request.getClass().getClassLoader(); + Object builder = Class.forName("okhttp3.Response$Builder", true, loader) + .getConstructor().newInstance(); + call(builder, "request", request); + Class protocolClass = Class.forName("okhttp3.Protocol", true, loader); + @SuppressWarnings({"unchecked", "rawtypes"}) + Object http11 = Enum.valueOf((Class) protocolClass.asSubclass(Enum.class), "HTTP_1_1"); + call(builder, "protocol", http11); + int status = params.getInt("status"); + call(builder, "code", status); + call(builder, "message", "mobilecli synthetic response"); + JSONObject headers = params.optJSONObject("headers"); + if (headers != null) { + Iterator names = headers.keys(); + while (names.hasNext()) { + String name = names.next(); + call(builder, "addHeader", name, headers.getString(name)); + } + } + byte[] bytes = params.has("body") ? Base64.decode(params.getString("body"), Base64.DEFAULT) : new byte[0]; + call(builder, "body", responseBody(request, bytes, false)); + return call(builder, "build"); + } + + private static Object responseBody(Object request, byte[] bytes, boolean requestBody) throws Throwable { + ClassLoader loader = request.getClass().getClassLoader(); + Class bodyClass = Class.forName(requestBody ? "okhttp3.RequestBody" : "okhttp3.ResponseBody", true, loader); + for (Method method : bodyClass.getMethods()) { + if (!method.getName().equals("create") || !java.lang.reflect.Modifier.isStatic(method.getModifiers())) continue; + Class[] types = method.getParameterTypes(); + if (types.length != 2) continue; + if (types[0] == byte[].class && types[1].getName().equals("okhttp3.MediaType")) { + return invoke(method, null, bytes, null); + } + if (types[1] == byte[].class && types[0].getName().equals("okhttp3.MediaType")) { + return invoke(method, null, null, bytes); + } + } + throw new IOException("unsupported OkHttp body factory"); + } + + private static Object call(Object target, String name, Object... args) throws Throwable { + for (Method method : target.getClass().getMethods()) { + if (!method.getName().equals(name) || method.getParameterTypes().length != args.length) continue; + Class[] types = method.getParameterTypes(); + boolean fits = true; + for (int i = 0; i < args.length; i++) { + if (args[i] != null && !boxed(types[i]).isInstance(args[i])) { fits = false; break; } + } + if (fits) return invoke(method, target, args); + } + throw new NoSuchMethodException(target.getClass().getName() + "." + name); + } + + private static Class boxed(Class type) { + if (type == int.class) return Integer.class; + if (type == long.class) return Long.class; + if (type == boolean.class) return Boolean.class; + return type; + } + + private static Object invoke(Method method, Object target, Object... args) throws Throwable { + try { return method.invoke(target, args); } + catch (InvocationTargetException e) { throw e.getCause(); } + } +} diff --git a/agents/android/jvmti_agent.c b/agents/android/jvmti_agent.c index 9fad4ee6..58582dd6 100644 --- a/agents/android/jvmti_agent.c +++ b/agents/android/jvmti_agent.c @@ -8,6 +8,76 @@ #define AGENT_CLASS "com.mobilenext.mobilecli.InjectedAgent" #define LOG(...) __android_log_print(ANDROID_LOG_DEBUG, TAG, __VA_ARGS__) +/* A breakpoint at OkHttpClient.interceptors() lets the agent replace the list + on both clients made before attach and clients made later. The getter then + returns that same field, with our interceptor appended once. */ +static jvmtiEnv *network_jvmti; +static jmethodID network_getter; +static jfieldID network_field; +static jobject network_interceptor; + +static void JNICALL network_breakpoint(jvmtiEnv *jvmti, JNIEnv *env, + jthread thread, jmethodID method, jlocation location) { + (void) location; + if (method != network_getter || network_interceptor == NULL) return; + jobject client = NULL; + jvmtiError local_err = (*jvmti)->GetLocalInstance(jvmti, thread, 0, &client); + if (local_err != JVMTI_ERROR_NONE || !client) { + LOG("OkHttp getter receiver unavailable: %d", local_err); + return; + } + jobject old_list = (*env)->GetObjectField(env, client, network_field); + if (!old_list || (*env)->ExceptionCheck(env)) goto done; + + jclass list_cls = (*env)->FindClass(env, "java/util/List"); + jmethodID contains = (*env)->GetMethodID(env, list_cls, "contains", "(Ljava/lang/Object;)Z"); + if ((*env)->CallBooleanMethod(env, old_list, contains, network_interceptor)) goto done; + if ((*env)->ExceptionCheck(env)) goto done; + + jclass array_cls = (*env)->FindClass(env, "java/util/ArrayList"); + jmethodID init = (*env)->GetMethodID(env, array_cls, "", "(Ljava/util/Collection;)V"); + jobject new_list = (*env)->NewObject(env, array_cls, init, old_list); + jmethodID add = (*env)->GetMethodID(env, array_cls, "add", "(Ljava/lang/Object;)Z"); + (*env)->CallBooleanMethod(env, new_list, add, network_interceptor); + if (!(*env)->ExceptionCheck(env)) { + (*env)->SetObjectField(env, client, network_field, new_list); + } + (*env)->DeleteLocalRef(env, new_list); +done: + if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + if (old_list) (*env)->DeleteLocalRef(env, old_list); + (*env)->DeleteLocalRef(env, client); +} + +static jboolean JNICALL network_install(JNIEnv *env, jclass unused, jclass client_cls, jobject interceptor) { + (void) unused; + if (!network_jvmti) return JNI_FALSE; + jmethodID getter = (*env)->GetMethodID(env, client_cls, "interceptors", "()Ljava/util/List;"); + jfieldID field = (*env)->GetFieldID(env, client_cls, "interceptors", "Ljava/util/List;"); + if (!getter || !field || (*env)->ExceptionCheck(env)) return JNI_FALSE; + jvmtiCapabilities caps = {0}; + caps.can_generate_breakpoint_events = 1; + caps.can_access_local_variables = 1; + jvmtiError err = (*network_jvmti)->AddCapabilities(network_jvmti, &caps); + if (err != JVMTI_ERROR_NONE) { LOG("network JVMTI capabilities failed: %d", err); return JNI_FALSE; } + if (network_getter) (*network_jvmti)->ClearBreakpoint(network_jvmti, network_getter, 0); + if (network_interceptor) (*env)->DeleteGlobalRef(env, network_interceptor); + network_interceptor = (*env)->NewGlobalRef(env, interceptor); + network_getter = getter; + network_field = field; + jvmtiEventCallbacks callbacks = {0}; + callbacks.Breakpoint = network_breakpoint; + err = (*network_jvmti)->SetEventCallbacks(network_jvmti, &callbacks, sizeof(callbacks)); + if (err == JVMTI_ERROR_NONE) err = (*network_jvmti)->SetBreakpoint(network_jvmti, getter, 0); + if (err == JVMTI_ERROR_NONE) err = (*network_jvmti)->SetEventNotificationMode( + network_jvmti, JVMTI_ENABLE, JVMTI_EVENT_BREAKPOINT, NULL); + if (err != JVMTI_ERROR_NONE) { + LOG("OkHttp getter breakpoint failed: %d", err); + return JNI_FALSE; + } + return JNI_TRUE; +} + /* ── load mobilecli.dex into the target process ─────────────────────────── */ /* dex_path — full path to mobilecli.dex on the device @@ -80,6 +150,14 @@ static void bootstrap(JNIEnv *env, const char *dex_path, const char *opt_dir) { return; } + jstring j_hook = (*env)->NewStringUTF(env, "com.mobilenext.mobilecli.OkHttpHook"); + jclass hook_cls = (jclass)(*env)->CallObjectMethod(env, loader, load, j_hook); + (*env)->DeleteLocalRef(env, j_hook); + if (hook_cls && !(*env)->ExceptionCheck(env)) { + JNINativeMethod methods[] = {{"nativeInstall", "(Ljava/lang/Class;Ljava/lang/Object;)Z", (void *)network_install}}; + if ((*env)->RegisterNatives(env, hook_cls, methods, 1) != JNI_OK) (*env)->ExceptionClear(env); + } else (*env)->ExceptionClear(env); + jmethodID start = (*env)->GetStaticMethodID(env, agentCls, "start", "()V"); if (!start || (*env)->ExceptionCheck(env)) { (*env)->ExceptionClear(env); @@ -138,6 +216,11 @@ static jint setup(JavaVM *vm, const char *opts) { } JNIEnv *env = NULL; + + if ((*vm)->GetEnv(vm, (void **)&network_jvmti, JVMTI_VERSION_1_2) != JNI_OK) { + LOG("JVMTI environment unavailable"); + return JNI_ERR; + } jint rc = (*vm)->AttachCurrentThread(vm, (void **) &env, NULL); if (rc != JNI_OK || env == NULL) { LOG("AttachCurrentThread failed (rc=%d, dex=%s)", rc, dex_path); From f1af856723932303be4a8853f932491280bc6b3f Mon Sep 17 00:00:00 2001 From: Mobile Kevin Bot! Date: Sat, 10 Oct 2026 22:32:37 +0200 Subject: [PATCH 02/18] feat(network): host side for network capture and interception CLI (network listen/continue/fulfill/abort), daemon stream method, device relay to the in-app agent, and acceptance tests. The agent poll is context-aware so listen stops immediately on disconnect. --- cli/network.go | 204 ++++++++++++++++++++++++++++++++ commands/network.go | 164 ++++++++++++++++++++++++++ commands/registry.go | 3 + devices/android_rpc.go | 15 ++- devices/common.go | 36 ++++++ devices/network.go | 99 ++++++++++++++++ server/daemon.go | 7 +- server/daemon_streams.go | 8 ++ test/network.spec.ts | 240 ++++++++++++++++++++++++++++++++++++++ test/playwright.config.ts | 3 + 10 files changed, 774 insertions(+), 5 deletions(-) create mode 100644 cli/network.go create mode 100644 commands/network.go create mode 100644 devices/network.go create mode 100644 test/network.spec.ts diff --git a/cli/network.go b/cli/network.go new file mode 100644 index 00000000..df1a2eec --- /dev/null +++ b/cli/network.go @@ -0,0 +1,204 @@ +package cli + +import ( + "encoding/base64" + "fmt" + "os" + "strings" + + "github.com/mobile-next/mobilecli/commands" + "github.com/mobile-next/mobilecli/devices" + "github.com/spf13/cobra" +) + +var ( + networkPauses []string + networkRequestID string + networkURL string + networkMethod string + networkHeaders []string + networkBody string + networkStatus int + networkError string +) + +var networkCmd = &cobra.Command{ + Use: "network", + Short: "Capture and intercept the HTTP traffic of a debuggable app", + Long: `Observe and intercept the network calls of a debuggable app you own +(Android debuggable build, or iOS get-task-allow / Debug build). + +'network listen' streams a paused event for each request that matches a --pause +glob; answer it from another terminal (or a coprocess) with 'network continue', +'network fulfill', or 'network abort', keyed by the requestId in the event. +Requests that match no --pause are never touched, and a paused request you never +answer continues unchanged.`, +} + +var networkListenCmd = &cobra.Command{ + Use: "listen", + Short: "Stream paused network requests from a debuggable app", + Long: `Enables interception and streams one JSON event per matched request. +Press Ctrl+C to stop; stopping releases any request still paused. + +Each --pause is a URL glob, optionally prefixed with an HTTP method: + --pause '**/api/**' + --pause 'POST **/login' +Repeat --pause for multiple routes. With no --pause, every request is paused.`, + RunE: func(cmd *cobra.Command, args []string) error { + routes, err := parseNetworkRoutes(networkPauses) + if err != nil { + return err + } + return streamViaDaemon("cli.network.listen", commands.NetworkListenRequest{ + DeviceID: deviceId, + Routes: routes, + }) + }, +} + +var networkContinueCmd = &cobra.Command{ + Use: "continue", + Short: "Let a paused request proceed, optionally with overrides", + RunE: func(cmd *cobra.Command, args []string) error { + headers, err := parseNetworkHeaders(networkHeaders) + if err != nil { + return err + } + body, err := readNetworkBody(networkBody) + if err != nil { + return err + } + return runViaDaemon("cli.network.continue", commands.NetworkContinueRequest{ + DeviceID: deviceId, + RequestID: networkRequestID, + URL: networkURL, + Method: networkMethod, + Headers: headers, + Body: body, + }) + }, +} + +var networkFulfillCmd = &cobra.Command{ + Use: "fulfill", + Short: "Answer a paused request with a synthetic response", + RunE: func(cmd *cobra.Command, args []string) error { + headers, err := parseNetworkHeaders(networkHeaders) + if err != nil { + return err + } + body, err := readNetworkBody(networkBody) + if err != nil { + return err + } + return runViaDaemon("cli.network.fulfill", commands.NetworkFulfillRequest{ + DeviceID: deviceId, + RequestID: networkRequestID, + Status: networkStatus, + Headers: headers, + Body: body, + }) + }, +} + +var networkAbortCmd = &cobra.Command{ + Use: "abort", + Short: "Fail a paused request", + RunE: func(cmd *cobra.Command, args []string) error { + return runViaDaemon("cli.network.abort", commands.NetworkAbortRequest{ + DeviceID: deviceId, + RequestID: networkRequestID, + ErrorCode: networkError, + }) + }, +} + +// parseNetworkRoutes turns "--pause" values into routes. A value may be a bare +// URL glob, or "METHOD glob" to restrict to one method. +func parseNetworkRoutes(pauses []string) ([]devices.NetworkRoute, error) { + routes := make([]devices.NetworkRoute, 0, len(pauses)) + for _, p := range pauses { + p = strings.TrimSpace(p) + if p == "" { + continue + } + method := "" + glob := p + if fields := strings.SplitN(p, " ", 2); len(fields) == 2 && isHTTPMethod(fields[0]) { + method = strings.ToUpper(fields[0]) + glob = strings.TrimSpace(fields[1]) + } + if glob == "" { + return nil, fmt.Errorf("invalid --pause %q: empty url pattern", p) + } + routes = append(routes, devices.NetworkRoute{URL: glob, Method: method}) + } + return routes, nil +} + +func isHTTPMethod(s string) bool { + switch strings.ToUpper(s) { + case "GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS", "TRACE", "CONNECT": + return true + } + return false +} + +// parseNetworkHeaders turns "--header K=V" values into a map. +func parseNetworkHeaders(headers []string) (map[string]string, error) { + if len(headers) == 0 { + return nil, nil + } + out := make(map[string]string, len(headers)) + for _, h := range headers { + k, v, ok := strings.Cut(h, "=") + if !ok || k == "" { + return nil, fmt.Errorf("invalid --header %q: expected key=value", h) + } + out[k] = v + } + return out, nil +} + +// readNetworkBody returns the base64 of the body. "@path" reads a file; anything +// else is taken literally. An empty value stays empty (no body override). +func readNetworkBody(body string) (string, error) { + if body == "" { + return "", nil + } + raw := []byte(body) + if strings.HasPrefix(body, "@") { + data, err := os.ReadFile(body[1:]) + if err != nil { + return "", fmt.Errorf("read --body file: %w", err) + } + raw = data + } + return base64.StdEncoding.EncodeToString(raw), nil +} + +func init() { + rootCmd.AddCommand(networkCmd) + networkCmd.AddCommand(networkListenCmd, networkContinueCmd, networkFulfillCmd, networkAbortCmd) + + networkListenCmd.Flags().StringVar(&deviceId, "device", "", "ID of the device to listen on") + networkListenCmd.Flags().StringArrayVar(&networkPauses, "pause", nil, "URL glob to pause on, optionally 'METHOD glob' (repeatable)") + + for _, c := range []*cobra.Command{networkContinueCmd, networkFulfillCmd, networkAbortCmd} { + c.Flags().StringVar(&deviceId, "device", "", "ID of the device") + c.Flags().StringVar(&networkRequestID, "id", "", "requestId of the paused request (required)") + _ = c.MarkFlagRequired("id") + } + + networkContinueCmd.Flags().StringVar(&networkURL, "url", "", "override the request URL") + networkContinueCmd.Flags().StringVar(&networkMethod, "method", "", "override the HTTP method") + networkContinueCmd.Flags().StringArrayVar(&networkHeaders, "header", nil, "override/add a header, key=value (repeatable)") + networkContinueCmd.Flags().StringVar(&networkBody, "body", "", "override the request body (literal, or @file)") + + networkFulfillCmd.Flags().IntVar(&networkStatus, "status", 200, "response status code") + networkFulfillCmd.Flags().StringArrayVar(&networkHeaders, "header", nil, "response header, key=value (repeatable)") + networkFulfillCmd.Flags().StringVar(&networkBody, "body", "", "response body (literal, or @file)") + + networkAbortCmd.Flags().StringVar(&networkError, "error", "", "failure code hint (e.g. failed, timedout)") +} diff --git a/commands/network.go b/commands/network.go new file mode 100644 index 00000000..c266472e --- /dev/null +++ b/commands/network.go @@ -0,0 +1,164 @@ +package commands + +import ( + "context" + "fmt" + + "github.com/mobile-next/mobilecli/devices" +) + +// ─── Request types ──────────────────────────────────────────── + +// NetworkListenRequest are the params for cli.network.listen (streaming). +type NetworkListenRequest struct { + DeviceID string `json:"deviceId"` + Routes []devices.NetworkRoute `json:"routes,omitempty"` +} + +// NetworkContinueRequest answers one paused request, letting it proceed +// (optionally with overrides). Body is base64. +type NetworkContinueRequest struct { + DeviceID string `json:"deviceId"` + RequestID string `json:"requestId"` + URL string `json:"url,omitempty"` + Method string `json:"method,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + Body string `json:"body,omitempty"` +} + +// NetworkFulfillRequest answers one paused request with a synthetic response. +// Body is base64. +type NetworkFulfillRequest struct { + DeviceID string `json:"deviceId"` + RequestID string `json:"requestId"` + Status int `json:"status"` + Headers map[string]string `json:"headers,omitempty"` + Body string `json:"body,omitempty"` +} + +// NetworkAbortRequest fails one paused request. ErrorCode is an optional hint +// (e.g. "failed", "timedout", "connectionrefused"). +type NetworkAbortRequest struct { + DeviceID string `json:"deviceId"` + RequestID string `json:"requestId"` + ErrorCode string `json:"errorCode,omitempty"` +} + +// ─── Shared helper ──────────────────────────────────────────── + +func networkInterceptor(deviceID string) (devices.NetworkInterceptable, error) { + device, err := FindDeviceOrAutoSelect(deviceID) + if err != nil { + return nil, fmt.Errorf("error finding device: %w", err) + } + provider, ok := device.(devices.NetworkInterceptorProvider) + if !ok { + return nil, fmt.Errorf("network interception is not supported on %s (%s)", device.ID(), device.Platform()) + } + return provider.NetworkInterceptor() +} + +// ─── Commands ───────────────────────────────────────────────── + +// NetworkListenCommand enables interception, then relays each agent event to the +// client until the context is cancelled (the client disconnected / pressed +// Ctrl-C). On exit it disables interception, so any request still paused in the +// app continues unchanged — the fail-open guarantee. +func NetworkListenCommand(ctx context.Context, req NetworkListenRequest, notify func(any) error) *CommandResponse { + interceptor, err := networkInterceptor(req.DeviceID) + if err != nil { + return NewErrorResponse(err) + } + if err := interceptor.NetworkEnable(req.Routes); err != nil { + return NewErrorResponse(fmt.Errorf("enable network interception: %w", err)) + } + defer func() { _ = interceptor.NetworkDisable() }() + + for { + if ctx.Err() != nil { + return NewSuccessResponse(OK) + } + events, err := interceptor.NetworkPoll(ctx) + if err != nil { + if ctx.Err() != nil { + return NewSuccessResponse(OK) + } + return NewErrorResponse(fmt.Errorf("poll network events: %w", err)) + } + for _, event := range events { + if err := notify(event); err != nil { + // client went away: stop and let the deferred disable release the app + return NewSuccessResponse(OK) + } + } + } +} + +// NetworkContinueCommand lets a paused request proceed, with optional overrides. +func NetworkContinueCommand(req NetworkContinueRequest) *CommandResponse { + if req.RequestID == "" { + return NewErrorResponse(fmt.Errorf("requestId is required")) + } + interceptor, err := networkInterceptor(req.DeviceID) + if err != nil { + return NewErrorResponse(err) + } + params := map[string]any{"requestId": req.RequestID} + if req.URL != "" { + params["url"] = req.URL + } + if req.Method != "" { + params["method"] = req.Method + } + if len(req.Headers) > 0 { + params["headers"] = req.Headers + } + if req.Body != "" { + params["body"] = req.Body + } + if err := interceptor.NetworkContinue(params); err != nil { + return NewErrorResponse(err) + } + return NewSuccessResponse(OK) +} + +// NetworkFulfillCommand answers a paused request with a synthetic response. +func NetworkFulfillCommand(req NetworkFulfillRequest) *CommandResponse { + if req.RequestID == "" { + return NewErrorResponse(fmt.Errorf("requestId is required")) + } + interceptor, err := networkInterceptor(req.DeviceID) + if err != nil { + return NewErrorResponse(err) + } + params := map[string]any{"requestId": req.RequestID, "status": req.Status} + if len(req.Headers) > 0 { + params["headers"] = req.Headers + } + if req.Body != "" { + params["body"] = req.Body + } + if err := interceptor.NetworkFulfill(params); err != nil { + return NewErrorResponse(err) + } + return NewSuccessResponse(OK) +} + +// NetworkAbortCommand fails a paused request. +func NetworkAbortCommand(req NetworkAbortRequest) *CommandResponse { + if req.RequestID == "" { + return NewErrorResponse(fmt.Errorf("requestId is required")) + } + interceptor, err := networkInterceptor(req.DeviceID) + if err != nil { + return NewErrorResponse(err) + } + params := map[string]any{"requestId": req.RequestID} + if req.ErrorCode != "" { + params["errorCode"] = req.ErrorCode + } + if err := interceptor.NetworkAbort(params); err != nil { + return NewErrorResponse(err) + } + return NewSuccessResponse(OK) +} diff --git a/commands/registry.go b/commands/registry.go index f98850f2..12df1096 100644 --- a/commands/registry.go +++ b/commands/registry.go @@ -86,6 +86,9 @@ func CLIRegistry() map[string]CLIHandler { "cli.webview.wait": adapt(WebViewWaitForLoadStateCommand), "cli.webview.content": adapt(WebViewContentCommand), "cli.webview.query": adapt(WebViewQueryCommand), + "cli.network.continue": adapt(NetworkContinueCommand), + "cli.network.fulfill": adapt(NetworkFulfillCommand), + "cli.network.abort": adapt(NetworkAbortCommand), "cli.screenshot": adapt(ScreenshotCommand), "cli.dump.ui": adapt(DumpUICommand), "cli.url": adapt(URLCommand), diff --git a/devices/android_rpc.go b/devices/android_rpc.go index bf3652af..4bfe7720 100644 --- a/devices/android_rpc.go +++ b/devices/android_rpc.go @@ -47,6 +47,13 @@ func agentRequest(port int, method string, params any) (json.RawMessage, error) } func agentRequestWithTimeout(port int, method string, params any, timeout time.Duration) (json.RawMessage, error) { + return agentRequestContext(context.Background(), port, method, params, timeout) +} + +// agentRequestContext is agentRequestWithTimeout with a caller-supplied context, +// so a long call (e.g. network.poll's long-poll) aborts as soon as the context +// is cancelled instead of blocking until the agent replies or the timeout. +func agentRequestContext(ctx context.Context, port int, method string, params any, timeout time.Duration) (json.RawMessage, error) { payload, err := json.Marshal(jsonRPCRequest{JSONRPC: "2.0", ID: "1", Method: method, Params: params}) if err != nil { return nil, fmt.Errorf("marshal request: %w", err) @@ -58,7 +65,7 @@ func agentRequestWithTimeout(port int, method string, params any, timeout time.D }() client := &http.Client{Timeout: timeout} - resp, err := postJSON(client, port, bytes.NewReader(payload)) + resp, err := postJSONContext(ctx, client, port, bytes.NewReader(payload)) if err != nil { if netErr := net.Error(nil); errors.As(err, &netErr) && netErr.Timeout() { return nil, fmt.Errorf("%w on port %d after %s: %v", errAgentTimedOut, port, timeout, err) @@ -100,7 +107,11 @@ func isAgentReady(port int) bool { } func postJSON(client *http.Client, port int, body io.Reader) (*http.Response, error) { - req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, fmt.Sprintf("http://localhost:%d/", port), body) + return postJSONContext(context.Background(), client, port, body) +} + +func postJSONContext(ctx context.Context, client *http.Client, port int, body io.Reader) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf("http://localhost:%d/", port), body) if err != nil { return nil, err } diff --git a/devices/common.go b/devices/common.go index 406d13ba..5c69f30b 100644 --- a/devices/common.go +++ b/devices/common.go @@ -263,6 +263,42 @@ type WebViewable interface { WebViewWaitForLoadState(webviewID, state string, timeoutMs int) error } +// NetworkInterceptorProvider is implemented by devices that can attach a network +// interceptor to the debuggable foreground app. Resolving the interceptor +// injects/locates the in-app agent, so it can fail when no debuggable app is in +// front; callers resolve it once per operation. +type NetworkInterceptorProvider interface { + NetworkInterceptor() (NetworkInterceptable, error) +} + +// NetworkInterceptable observes and intercepts the HTTP traffic of the app the +// agent is attached to. It is a thin relay over the in-app agent's network.* +// RPC methods; see docs/network-interception.md for the contract. Everything is +// fail-open: a paused request the host never answers continues unchanged. +type NetworkInterceptable interface { + // NetworkEnable turns on interception for the given routes. A request is + // paused only when it matches a route, so unmatched traffic is untouched. + NetworkEnable(routes []NetworkRoute) error + // NetworkPoll long-polls the agent for the next batch of events, returning + // each event as an opaque JSON object to relay to the client. It returns an + // empty slice (no error) when the poll window elapses with nothing pending. + NetworkPoll(ctx context.Context) ([]json.RawMessage, error) + // NetworkContinue, NetworkFulfill and NetworkAbort answer one paused request, + // keyed by requestId inside params. See docs/network-interception.md. + NetworkContinue(params map[string]any) error + NetworkFulfill(params map[string]any) error + NetworkAbort(params map[string]any) error + // NetworkDisable turns interception off; any still-paused request continues. + NetworkDisable() error +} + +// NetworkRoute is one URL glob (and optional HTTP method) the host asked to +// pause on. An empty Method matches any method. +type NetworkRoute struct { + URL string `json:"url"` + Method string `json:"method,omitempty"` +} + // GetAllControllableDevices aggregates all known devices with options func GetAllControllableDevices(includeOffline bool) ([]ControllableDevice, error) { diff --git a/devices/network.go b/devices/network.go new file mode 100644 index 00000000..c1532105 --- /dev/null +++ b/devices/network.go @@ -0,0 +1,99 @@ +package devices + +import ( + "context" + "encoding/json" + "fmt" + "time" +) + +// networkPollTimeout bounds one network.poll call. It must exceed the agent's +// own poll hold (~30s) so a quiet window returns empty rather than erroring. +const networkPollTimeout = 35 * time.Second + +// agentNetwork relays the NetworkInterceptable surface to the in-app agent over +// its forwarded RPC port, the same channel agentWebViews uses. It holds no +// interception logic itself — the agent does the work; this just forwards JSON. +type agentNetwork struct { + port int +} + +func (a agentNetwork) NetworkEnable(routes []NetworkRoute) error { + if routes == nil { + routes = []NetworkRoute{} + } + _, err := agentRequest(a.port, "network.enable", map[string]any{"routes": routes}) + return err +} + +func (a agentNetwork) NetworkPoll(ctx context.Context) ([]json.RawMessage, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + result, err := agentRequestContext(ctx, a.port, "network.poll", map[string]any{}, networkPollTimeout) + if err != nil { + return nil, err + } + var payload struct { + Events []json.RawMessage `json:"events"` + } + if err := json.Unmarshal(result, &payload); err != nil { + return nil, fmt.Errorf("parse network events: %w", err) + } + return payload.Events, nil +} + +func (a agentNetwork) NetworkContinue(params map[string]any) error { + _, err := agentRequest(a.port, "network.continue", params) + return err +} + +func (a agentNetwork) NetworkFulfill(params map[string]any) error { + _, err := agentRequest(a.port, "network.fulfill", params) + return err +} + +func (a agentNetwork) NetworkAbort(params map[string]any) error { + _, err := agentRequest(a.port, "network.abort", params) + return err +} + +func (a agentNetwork) NetworkDisable() error { + _, err := agentRequest(a.port, "network.disable", map[string]any{}) + return err +} + +// NetworkInterceptor attaches to the debuggable foreground app on Android and +// returns a relay to its in-app agent. It mirrors webViews()' foreground +// resolution but only takes the agent path (a browser cannot be intercepted this +// way). +func (d *AndroidDevice) NetworkInterceptor() (NetworkInterceptable, error) { + var foreground *ForegroundAppInfo + var err error + deadline := time.Now().Add(3 * time.Second) + for { + foreground, err = d.GetForegroundApp() + if err == nil { + break + } + if time.Now().After(deadline) { + return nil, fmt.Errorf("could not determine foreground app: %w", err) + } + time.Sleep(150 * time.Millisecond) + } + port, err := d.ensureAgentReady(foreground.PackageName) + if err != nil { + return nil, err + } + return agentNetwork{port: port}, nil +} + +// NetworkInterceptor attaches to the debuggable foreground app in the simulator +// and returns a relay to its in-app agent. +func (s *SimulatorDevice) NetworkInterceptor() (NetworkInterceptable, error) { + port, err := s.ensureIOSAgentReady() + if err != nil { + return nil, err + } + return agentNetwork{port: port}, nil +} diff --git a/server/daemon.go b/server/daemon.go index 5ffbae47..9b3f05f3 100644 --- a/server/daemon.go +++ b/server/daemon.go @@ -44,8 +44,9 @@ type cliStreamHandler func(ctx context.Context, params json.RawMessage, notify f // cliStreamRegistry holds CLI methods that emit stream.data notifications. func cliStreamRegistry() map[string]cliStreamHandler { return map[string]cliStreamHandler{ - "cli.device.logs": streamLogs, - "cli.screencapture": streamScreenCapture, - "cli.screenrecord": streamScreenRecord, + "cli.device.logs": streamLogs, + "cli.screencapture": streamScreenCapture, + "cli.screenrecord": streamScreenRecord, + "cli.network.listen": streamNetwork, } } diff --git a/server/daemon_streams.go b/server/daemon_streams.go index 9405e16c..c3c514fb 100644 --- a/server/daemon_streams.go +++ b/server/daemon_streams.go @@ -120,6 +120,14 @@ func streamScreenCapture(ctx context.Context, params json.RawMessage, notify fun return commands.NewSuccessResponse(commands.OK), nil } +func streamNetwork(ctx context.Context, params json.RawMessage, notify func(any) error) (any, error) { + var req commands.NetworkListenRequest + if err := json.Unmarshal(params, &req); err != nil { + return commands.NewErrorResponse(fmt.Errorf("invalid parameters: %w", err)), nil + } + return commands.NetworkListenCommand(ctx, req, notify), nil +} + func streamScreenRecord(ctx context.Context, params json.RawMessage, notify func(any) error) (any, error) { var req commands.ScreenRecordStreamRequest if err := json.Unmarshal(params, &req); err != nil { diff --git a/test/network.spec.ts b/test/network.spec.ts new file mode 100644 index 00000000..8e805d95 --- /dev/null +++ b/test/network.spec.ts @@ -0,0 +1,240 @@ +import {test, expect} from './fixtures'; +import {execFileSync, spawn, type ChildProcessWithoutNullStreams} from 'child_process'; +import * as path from 'path'; +import {coverageEnv} from './coverage'; +import {eventually} from './poll'; +import {flattenElements} from './webview'; +import type {UIElement, UIDumpResponse} from './types'; +import {downloadPlayground, PLAYGROUND_PACKAGE} from './playground'; + +const mobilecliBinary = path.join(__dirname, '..', 'mobilecli'); + +// the playground Network screen (playground://network) sends HTTP through the +// stacks a real app uses: OkHttp and HttpURLConnection on android, URLSession on +// ios. each button shows the outcome in one on-screen label, so a test can read +// what the app actually received after interception. +const GET_BUTTON: Record = { + android: 'OkHttp GET', + ios: 'URLSession.shared GET', +}; + +// the label every result line starts with, so the on-screen outcome is findable +// regardless of status or body +const RESULT_PREFIXES = ['okhttp', 'urlconnection', 'shared', 'custom']; + +type Device = {id: string; platform: string}; + +// one paused request as emitted by `network listen` (see docs/network-interception.md) +type PausedEvent = { + type: string; + requestId: string; + method: string; + url: string; +}; + +function firstDeviceOfType(deviceType: string): Device | null { + try { + const output = execFileSync(mobilecliBinary, ['devices', '--type', deviceType], { + encoding: 'utf8', + env: coverageEnv(), + }); + return JSON.parse(output)?.data?.devices?.[0] ?? null; + } catch { + return null; + } +} + +function runMobilecli(args: string[]): string { + return execFileSync(mobilecliBinary, args, {encoding: 'utf8', env: coverageEnv()}); +} + +// a command that returns an error envelope exits non-zero, so execFileSync +// throws even though it printed valid json on stdout; recover that stdout. +function runMobilecliJson(args: string[]): any { + try { + return JSON.parse(runMobilecli(args)); + } catch (error: unknown) { + const stdout = (error as {stdout?: string}).stdout ?? ''; + if (stdout.trim() === '') throw error; + return JSON.parse(stdout); + } +} + +// the text/label/name/value of an element, so one matcher works on both the +// android dump (text) and the ios dump (label/name) +function elementText(el: UIElement): string { + const anyEl = el as unknown as Record; + return anyEl.text || anyEl.label || anyEl.name || anyEl.value || ''; +} + +function dumpElements(deviceId: string): UIElement[] { + const dump: UIDumpResponse = runMobilecliJson(['dump', 'ui', '--device', deviceId]); + return flattenElements(dump.data.elements); +} + +function findByText(deviceId: string, text: string): UIElement | undefined { + return dumpElements(deviceId).find(el => elementText(el) === text); +} + +function tapButton(deviceId: string, text: string): void { + const el = findByText(deviceId, text); + if (!el) { + throw new Error(`button "${text}" not found`); + } + const {x, y, width, height} = el.rect; + runMobilecli(['io', 'tap', `${x + width / 2},${y + height / 2}`, '--device', deviceId]); +} + +// the on-screen outcome line, once the request has resolved (e.g. +// "okhttp GET 200 {...}" or "shared GET error: ...") +function readNetworkResult(deviceId: string): string { + const el = dumpElements(deviceId).find(e => + RESULT_PREFIXES.some(p => elementText(e).startsWith(p))); + return el ? elementText(el) : ''; +} + +function openNetworkScreen(deviceId: string): void { + runMobilecli(['url', 'playground://network', '--device', deviceId]); +} + +// `network listen` streams one json object per line on stdout. this starts it, +// parses paused events as they arrive, and hands back the collected list plus a +// stop() that ends the stream (releasing anything still paused). +function startListening(deviceId: string, pauses: string[]): {events: PausedEvent[]; stop: () => Promise} { + const args = ['network', 'listen', '--device', deviceId]; + for (const p of pauses) { + args.push('--pause', p); + } + const child: ChildProcessWithoutNullStreams = spawn(mobilecliBinary, args, {env: coverageEnv()}); + const events: PausedEvent[] = []; + let buffer = ''; + child.stdout.on('data', chunk => { + buffer += chunk.toString(); + let newline: number; + while ((newline = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, newline).trim(); + buffer = buffer.slice(newline + 1); + if (!line) continue; + try { + const parsed = JSON.parse(line); + if (parsed.type === 'paused') events.push(parsed); + } catch { + // a non-event line (e.g. an error envelope) is left for the caller's assertions + } + } + }); + const stop = () => new Promise(resolve => { + child.on('close', () => resolve()); + child.kill('SIGINT'); + }); + return {events, stop}; +} + +for (const platform of ['android', 'ios'] as const) { + test.describe(`network (${platform})`, () => { + let device: Device | null; + + test.beforeAll(async ({deviceType}) => { + device = firstDeviceOfType(deviceType); + if (device && device.platform !== platform) device = null; + if (device) { + await downloadPlayground(platform); + } + }); + + // ── host contract: these hold today, with or without the in-app agent ── + + test('continue/fulfill/abort require a requestId', () => { + test.skip(!device, `no ${platform} device`); + for (const action of ['continue', 'fulfill', 'abort']) { + const envelope = runMobilecliJson(['network', action, '--device', device!.id, '--id', '']); + expect(envelope.status).toBe('error'); + expect(envelope.error).toContain('requestId is required'); + } + }); + + test('listen attaches to a debuggable app and reaches its in-app agent', async () => { + test.skip(!device, `no ${platform} device`); + openNetworkScreen(device!.id); + await eventually(() => !!findByText(device!.id, GET_BUTTON[platform]), + 'network screen never appeared').toBe(true); + + const listener = startListening(device!.id, [`**/todos/**`]); + tapButton(device!.id, GET_BUTTON[platform]); + + // once the agent implements network.*, this becomes a paused event (see the + // skipped interception suite). until then it must at least reach the agent, + // proving injection + port-forward + rpc relay + streaming all work. + await eventually(() => listener.events.length > 0 || agentWasReached(device!.id), + 'listen never reached the in-app agent').toBe(true); + await listener.stop(); + }); + + // ── interception acceptance. Runs where the in-app agent implements the + // network.* methods: Android (agents/android/java) does; iOS (agents/ios) + // does not yet, so it stays skipped until that agent lands. ── + const interception = platform === 'android' ? test.describe : test.describe.skip; + interception('interception', () => { + test('fulfill replaces the response the app receives', async () => { + test.skip(!device, `no ${platform} device`); + openNetworkScreen(device!.id); + await eventually(() => !!findByText(device!.id, GET_BUTTON[platform]), 'no screen').toBe(true); + + const listener = startListening(device!.id, ['**/todos/**']); + tapButton(device!.id, GET_BUTTON[platform]); + await eventually(() => listener.events.length, 'no paused request').toBeGreaterThan(0); + + const body = Buffer.from('{"mocked":true}').toString('base64'); + runMobilecli(['network', 'fulfill', '--device', device!.id, + '--id', listener.events[0].requestId, '--status', '200', '--body', '{"mocked":true}']); + + await eventually(() => readNetworkResult(device!.id), 'mocked body never shown') + .toContain('mocked'); + await listener.stop(); + expect(body).toBeTruthy(); + }); + + test('abort makes the request fail in the app', async () => { + test.skip(!device, `no ${platform} device`); + openNetworkScreen(device!.id); + const listener = startListening(device!.id, ['**/todos/**']); + tapButton(device!.id, GET_BUTTON[platform]); + await eventually(() => listener.events.length, 'no paused request').toBeGreaterThan(0); + + runMobilecli(['network', 'abort', '--device', device!.id, + '--id', listener.events[0].requestId, '--error', 'failed']); + + await eventually(() => readNetworkResult(device!.id), 'request did not fail') + .toContain('error'); + await listener.stop(); + }); + + test('continue lets the real response through unchanged', async () => { + test.skip(!device, `no ${platform} device`); + openNetworkScreen(device!.id); + const listener = startListening(device!.id, ['**/todos/**']); + tapButton(device!.id, GET_BUTTON[platform]); + await eventually(() => listener.events.length, 'no paused request').toBeGreaterThan(0); + + runMobilecli(['network', 'continue', '--device', device!.id, '--id', listener.events[0].requestId]); + + await eventually(() => readNetworkResult(device!.id), 'real response never arrived') + .toContain('200'); + await listener.stop(); + }); + }); + }); +} + +// agentWasReached returns true when the foreground app is the debuggable +// playground, i.e. `network listen` had an agent to talk to. the "method not +// found: network.enable" error that listen prints today is itself proof it +// reached the agent; this is the positive-signal companion to that. +function agentWasReached(deviceId: string): boolean { + try { + const fg = runMobilecliJson(['apps', 'foreground', '--device', deviceId]); + return (fg?.data?.packageName ?? fg?.data?.bundleIdentifier) === PLAYGROUND_PACKAGE; + } catch { + return false; + } +} diff --git a/test/playwright.config.ts b/test/playwright.config.ts index 98f439e3..32a7613d 100644 --- a/test/playwright.config.ts +++ b/test/playwright.config.ts @@ -15,5 +15,8 @@ export default defineConfig<{}, DeviceTypeOptions>({ {name: 'simulator', testMatch: /simulator\.spec\.ts/}, {name: 'emulator', testMatch: /android\.spec\.ts/, use: {deviceType: 'emulator'}}, {name: 'android', testMatch: /android\.spec\.ts/, use: {deviceType: 'real'}}, + {name: 'network-simulator', testMatch: /network\.spec\.ts/, use: {deviceType: 'simulator'}}, + {name: 'network-emulator', testMatch: /network\.spec\.ts/, use: {deviceType: 'emulator'}}, + {name: 'network-android', testMatch: /network\.spec\.ts/, use: {deviceType: 'real'}}, ], }); From 2201bffd0f8731c1af5e704f365c1452eaee7039 Mon Sep 17 00:00:00 2001 From: Mobile Kevin Bot! Date: Sun, 11 Oct 2026 09:44:25 +0200 Subject: [PATCH 03/18] style(network): brace every if/for in the android agent --- agents/android/java/Network.java | 26 +++++-- agents/android/java/NetworkGlob.java | 17 +++-- agents/android/java/OkHttpHook.java | 104 +++++++++++++++++++-------- 3 files changed, 108 insertions(+), 39 deletions(-) diff --git a/agents/android/java/Network.java b/agents/android/java/Network.java index 420185fd..e35e97d2 100644 --- a/agents/android/java/Network.java +++ b/agents/android/java/Network.java @@ -77,7 +77,9 @@ static JSONObject enable(JSONObject params) throws Exception { next.add(new Route(NetworkGlob.compile(glob), method == null || method.isEmpty() ? null : method)); } // An empty route list means intercept every request. - if (next.isEmpty()) next.add(new Route(Pattern.compile(".*", Pattern.DOTALL), null)); + if (next.isEmpty()) { + next.add(new Route(Pattern.compile(".*", Pattern.DOTALL), null)); + } synchronized (LOCK) { releaseAllLocked(); EVENTS.clear(); @@ -97,7 +99,9 @@ static JSONObject poll(JSONObject params) throws JSONException { try { while (enabled && EVENTS.isEmpty()) { long remaining = deadline - System.currentTimeMillis(); - if (remaining <= 0) break; + if (remaining <= 0) { + break; + } try { LOCK.wait(remaining); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; @@ -144,7 +148,9 @@ static Verdict await(String method, String url, JSONObject headers, byte[] body) Pause pause = new Pause(); String id; synchronized (LOCK) { - if (!enabled || !matchesLocked(url, method) || EVENTS.size() >= MAX_EVENTS) return UNCHANGED; + if (!enabled || !matchesLocked(url, method) || EVENTS.size() >= MAX_EVENTS) { + return UNCHANGED; + } id = Long.toString(IDS.incrementAndGet()); JSONObject event = new JSONObject(); try { @@ -155,7 +161,9 @@ static Verdict await(String method, String url, JSONObject headers, byte[] body) event.put("headers", headers); event.put("body", body == null ? JSONObject.NULL : Base64.encodeToString( body, 0, Math.min(body.length, MAX_BODY_BYTES), Base64.NO_WRAP)); - if (body != null && body.length > MAX_BODY_BYTES) event.put("bodyTruncated", true); + if (body != null && body.length > MAX_BODY_BYTES) { + event.put("bodyTruncated", true); + } event.put("stack", "okhttp"); } catch (JSONException e) { return UNCHANGED; @@ -207,12 +215,18 @@ private static JSONObject decide(JSONObject params, String action) throws Except } private static boolean matchesLocked(String url, String method) { - for (Route route : routes) if (route.matches(url, method)) return true; + for (Route route : routes) { + if (route.matches(url, method)) { + return true; + } + } return false; } private static void releaseAllLocked() { - for (Pause pause : PAUSED.values()) pause.verdict = UNCHANGED; + for (Pause pause : PAUSED.values()) { + pause.verdict = UNCHANGED; + } PAUSED.clear(); LOCK.notifyAll(); } diff --git a/agents/android/java/NetworkGlob.java b/agents/android/java/NetworkGlob.java index dd0121f5..fc817550 100644 --- a/agents/android/java/NetworkGlob.java +++ b/agents/android/java/NetworkGlob.java @@ -11,11 +11,18 @@ static Pattern compile(String glob) { for (int i = 0; i < glob.length(); i++) { char c = glob.charAt(i); if (c == '*') { - if (i + 1 < glob.length() && glob.charAt(i + 1) == '*') { regex.append(".*"); i++; } - else regex.append("[^/]*"); - } else if (c == '?') regex.append('.'); - else { - if ("\\.[]{}()+-^$|".indexOf(c) >= 0) regex.append('\\'); + if (i + 1 < glob.length() && glob.charAt(i + 1) == '*') { + regex.append(".*"); + i++; + } else { + regex.append("[^/]*"); + } + } else if (c == '?') { + regex.append('.'); + } else { + if ("\\.[]{}()+-^$|".indexOf(c) >= 0) { + regex.append('\\'); + } regex.append(c); } } diff --git a/agents/android/java/OkHttpHook.java b/agents/android/java/OkHttpHook.java index f4695d68..5c069a95 100644 --- a/agents/android/java/OkHttpHook.java +++ b/agents/android/java/OkHttpHook.java @@ -20,11 +20,15 @@ final class OkHttpHook { private OkHttpHook() { } static synchronized void install() { - if (installedInterceptor != null) return; + if (installedInterceptor != null) { + return; + } try { Object app = Class.forName("android.app.ActivityThread") .getMethod("currentApplication").invoke(null); - if (app == null) throw new IllegalStateException("application unavailable"); + if (app == null) { + throw new IllegalStateException("application unavailable"); + } ClassLoader loader = (ClassLoader) app.getClass().getMethod("getClassLoader").invoke(app); Class clientClass = Class.forName("okhttp3.OkHttpClient", false, loader); Class interceptorClass = Class.forName("okhttp3.Interceptor", false, loader); @@ -48,8 +52,12 @@ static synchronized void install() { private static final class Handler implements InvocationHandler { @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { if (!"intercept".equals(method.getName())) { - if ("hashCode".equals(method.getName())) return System.identityHashCode(proxy); - if ("equals".equals(method.getName())) return proxy == args[0]; + if ("hashCode".equals(method.getName())) { + return System.identityHashCode(proxy); + } + if ("equals".equals(method.getName())) { + return proxy == args[0]; + } return "mobilecli network interceptor"; } return intercept(args[0]); @@ -63,16 +71,19 @@ static Object intercept(Object chain) throws Throwable { JSONObject headers = headersToJson(call(request, "headers")); Network.Verdict verdict = Network.await(method, url, headers, requestBodyBytes(request)); switch (verdict.action) { - case "fulfill": return syntheticResponse(request, verdict.params); - case "abort": throw new IOException("mobilecli network abort: " - + verdict.params.optString("errorCode", "failed")); + case "fulfill": + return syntheticResponse(request, verdict.params); + case "abort": + throw new IOException("mobilecli network abort: " + + verdict.params.optString("errorCode", "failed")); case "continue": if (verdict.params.has("url") || verdict.params.has("method") || verdict.params.has("headers") || verdict.params.has("body")) { request = overrideRequest(request, verdict.params); } return call(chain, "proceed", request); - default: throw new IOException("unknown network decision: " + verdict.action); + default: + throw new IOException("unknown network decision: " + verdict.action); } } @@ -90,13 +101,25 @@ private static JSONObject headersToJson(Object headers) throws Throwable { private static byte[] requestBodyBytes(Object request) { try { Object body = call(request, "body"); - if (body == null) return null; + if (body == null) { + return null; + } long length = ((Number) call(body, "contentLength")).longValue(); - if (length < 0 || length > 256 * 1024) return null; - try { if (Boolean.TRUE.equals(call(body, "isOneShot"))) return null; } - catch (NoSuchMethodException ignored) { } - try { if (Boolean.TRUE.equals(call(body, "isDuplex"))) return null; } - catch (NoSuchMethodException ignored) { } + if (length < 0 || length > 256 * 1024) { + return null; + } + try { + if (Boolean.TRUE.equals(call(body, "isOneShot"))) { + return null; + } + } catch (NoSuchMethodException ignored) { + } + try { + if (Boolean.TRUE.equals(call(body, "isDuplex"))) { + return null; + } + } catch (NoSuchMethodException ignored) { + } ClassLoader loader = body.getClass().getClassLoader(); Class bufferClass = Class.forName("okio.Buffer", true, loader); Object buffer = bufferClass.getConstructor().newInstance(); @@ -111,7 +134,9 @@ private static byte[] requestBodyBytes(Object request) { private static Object overrideRequest(Object request, JSONObject params) throws Throwable { Object builder = call(request, "newBuilder"); - if (params.has("url")) call(builder, "url", params.getString("url")); + if (params.has("url")) { + call(builder, "url", params.getString("url")); + } if (params.has("headers")) { JSONObject headers = params.getJSONObject("headers"); Object headerBuilder = Class.forName("okhttp3.Headers$Builder", true, @@ -125,9 +150,12 @@ private static Object overrideRequest(Object request, JSONObject params) throws } String method = params.optString("method", (String) call(request, "method")); Object body = call(request, "body"); - if (params.has("body")) body = responseBody(request, Base64.decode( - params.getString("body"), Base64.DEFAULT), true); - if (params.has("method") || params.has("body")) call(builder, "method", method, body); + if (params.has("body")) { + body = responseBody(request, Base64.decode(params.getString("body"), Base64.DEFAULT), true); + } + if (params.has("method") || params.has("body")) { + call(builder, "method", method, body); + } return call(builder, "build"); } @@ -160,9 +188,13 @@ private static Object responseBody(Object request, byte[] bytes, boolean request ClassLoader loader = request.getClass().getClassLoader(); Class bodyClass = Class.forName(requestBody ? "okhttp3.RequestBody" : "okhttp3.ResponseBody", true, loader); for (Method method : bodyClass.getMethods()) { - if (!method.getName().equals("create") || !java.lang.reflect.Modifier.isStatic(method.getModifiers())) continue; + if (!method.getName().equals("create") || !java.lang.reflect.Modifier.isStatic(method.getModifiers())) { + continue; + } Class[] types = method.getParameterTypes(); - if (types.length != 2) continue; + if (types.length != 2) { + continue; + } if (types[0] == byte[].class && types[1].getName().equals("okhttp3.MediaType")) { return invoke(method, null, bytes, null); } @@ -175,26 +207,42 @@ private static Object responseBody(Object request, byte[] bytes, boolean request private static Object call(Object target, String name, Object... args) throws Throwable { for (Method method : target.getClass().getMethods()) { - if (!method.getName().equals(name) || method.getParameterTypes().length != args.length) continue; + if (!method.getName().equals(name) || method.getParameterTypes().length != args.length) { + continue; + } Class[] types = method.getParameterTypes(); boolean fits = true; for (int i = 0; i < args.length; i++) { - if (args[i] != null && !boxed(types[i]).isInstance(args[i])) { fits = false; break; } + if (args[i] != null && !boxed(types[i]).isInstance(args[i])) { + fits = false; + break; + } + } + if (fits) { + return invoke(method, target, args); } - if (fits) return invoke(method, target, args); } throw new NoSuchMethodException(target.getClass().getName() + "." + name); } private static Class boxed(Class type) { - if (type == int.class) return Integer.class; - if (type == long.class) return Long.class; - if (type == boolean.class) return Boolean.class; + if (type == int.class) { + return Integer.class; + } + if (type == long.class) { + return Long.class; + } + if (type == boolean.class) { + return Boolean.class; + } return type; } private static Object invoke(Method method, Object target, Object... args) throws Throwable { - try { return method.invoke(target, args); } - catch (InvocationTargetException e) { throw e.getCause(); } + try { + return method.invoke(target, args); + } catch (InvocationTargetException e) { + throw e.getCause(); + } } } From f066cf8a277a68ef5e0c2d792f4068b3b7712195 Mon Sep 17 00:00:00 2001 From: Mobile Kevin Bot! Date: Sun, 11 Oct 2026 09:44:25 +0200 Subject: [PATCH 04/18] feat(network): iOS URLSession capture and interception agent Adds the in-app agent for the iOS simulator: a URLProtocol plus session-configuration and dataTask swizzles that pause, fulfill, abort, or rewrite matched requests (URLSession and Alamofire, HTTP and HTTPS). Fail-open mirrors the android agent. The host reuses an already-loaded agent after a daemon restart via lsof instead of injecting twice. --- agents/ios/Makefile | 12 +- agents/ios/agent.m | 2 + agents/ios/dispatcher.m | 8 + agents/ios/network.h | 9 + agents/ios/network.m | 526 ++++++++++++++++++++++++++++++ agents/ios/network_glob.h | 4 + agents/ios/network_glob.m | 27 ++ agents/ios/test/network_glob.m | 19 ++ devices/ios_webview.go | 36 ++ devices/ios_webview_agent_test.go | 16 + test/network.spec.ts | 149 ++++++++- 11 files changed, 794 insertions(+), 14 deletions(-) create mode 100644 agents/ios/network.h create mode 100644 agents/ios/network.m create mode 100644 agents/ios/network_glob.h create mode 100644 agents/ios/network_glob.m create mode 100644 agents/ios/test/network_glob.m create mode 100644 devices/ios_webview_agent_test.go diff --git a/agents/ios/Makefile b/agents/ios/Makefile index 1dd34dd6..df3b4e65 100644 --- a/agents/ios/Makefile +++ b/agents/ios/Makefile @@ -1,6 +1,6 @@ ARCH := arm64 MIN := 15.0 -SRCS := agent.m server.m bridge.m dispatcher.m +SRCS := agent.m server.m bridge.m dispatcher.m network.m network_glob.m FLAGS = -arch $(ARCH) \ -dynamiclib -fPIC -fobjc-arc \ @@ -8,12 +8,12 @@ FLAGS = -arch $(ARCH) \ all: agent-sim.dylib agent-dev.dylib -agent-sim.dylib: $(SRCS) server.h bridge.h dispatcher.h +agent-sim.dylib: $(SRCS) server.h bridge.h dispatcher.h network.h network_glob.h xcrun -sdk iphonesimulator clang $(FLAGS) \ -mios-simulator-version-min=$(MIN) \ -o $@ $(SRCS) -agent-dev.dylib: $(SRCS) server.h bridge.h dispatcher.h +agent-dev.dylib: $(SRCS) server.h bridge.h dispatcher.h network.h network_glob.h xcrun -sdk iphoneos clang $(FLAGS) \ -miphoneos-version-min=$(MIN) \ -o $@ $(SRCS) @@ -21,3 +21,9 @@ agent-dev.dylib: $(SRCS) server.h bridge.h dispatcher.h clean: rm -f agent-sim.dylib agent-dev.dylib + +test-glob: + clang -fobjc-arc -framework Foundation -o /tmp/mobilecli-network-glob-test network_glob.m test/network_glob.m + /tmp/mobilecli-network-glob-test + +.PHONY: test-glob diff --git a/agents/ios/agent.m b/agents/ios/agent.m index 43d2d759..c52970b0 100644 --- a/agents/ios/agent.m +++ b/agents/ios/agent.m @@ -1,6 +1,7 @@ #import #import "server.h" #import "dispatcher.h" +#import "network.h" static int mobilecli_port = 0; @@ -11,6 +12,7 @@ __attribute__((constructor)) static void on_load(void) { NSLog(@"[mobilecli] agent loaded"); + [MobileNetwork install]; MobileServer *server = [[MobileServer alloc] initWithHandler:^NSData *(NSData *body) { return dispatch_rpc(body); diff --git a/agents/ios/dispatcher.m b/agents/ios/dispatcher.m index 8b3221bd..019fcd28 100644 --- a/agents/ios/dispatcher.m +++ b/agents/ios/dispatcher.m @@ -1,5 +1,6 @@ #import "dispatcher.h" #import "bridge.h" +#import "network.h" static const int kRPCParseError = -32700; static const int kRPCMethodNotFound = -32601; @@ -40,6 +41,13 @@ NSString *method = req[@"method"]; NSDictionary *params = req[@"params"]; + if ([method hasPrefix:@"network."]) { + NSError *networkError = nil; + NSDictionary *value = [MobileNetwork perform:method params:params error:&networkError]; + if (networkError) return rpc_error(reqId, (int)networkError.code, networkError.localizedDescription); + return rpc_result(reqId, value); + } + if ([method isEqualToString:@"device.webview.list"]) { return rpc_result(reqId, [IosBridge listWebViews]); } diff --git a/agents/ios/network.h b/agents/ios/network.h new file mode 100644 index 00000000..712a6403 --- /dev/null +++ b/agents/ios/network.h @@ -0,0 +1,9 @@ +#import + +// Shared request state and URLSession hook for the simulator agent. +@interface MobileNetwork : NSObject ++ (void)install; ++ (nullable NSDictionary *)perform:(NSString * _Nonnull)method + params:(nullable NSDictionary *)params + error:(NSError * _Nullable * _Nullable)error; +@end diff --git a/agents/ios/network.m b/agents/ios/network.m new file mode 100644 index 00000000..73fe3850 --- /dev/null +++ b/agents/ios/network.m @@ -0,0 +1,526 @@ +#import "network.h" +#import "network_glob.h" +#import + +static const NSTimeInterval kDecisionTimeout = 60; +static const NSTimeInterval kPollTimeout = 30; +static const NSTimeInterval kPollGrace = 15; +static const NSUInteger kMaxEvents = 256; +static const NSUInteger kMaxEventBody = 256 * 1024; +static NSString *const kHandledKey = @"mobilecli.network.handled"; +static NSString *const kErrorDomain = @"mobilecli.network"; + +@interface MobilePause : NSObject +@property (nonatomic, strong) NSDictionary *event; +@property (nonatomic, strong) NSDictionary *verdict; +@end +@implementation MobilePause +@end + +@interface MobileURLProtocol : NSURLProtocol +@property (atomic, assign) BOOL stopped; +@property (atomic, strong) NSURLSession *forwardSession; +@property (atomic, strong) NSURLSessionDataTask *forwardTask; +@end + +@interface MobileNetwork (Internals) ++ (BOOL)matches:(NSURLRequest *)request; ++ (nullable NSString *)pauseRequest:(NSURLRequest *)request; ++ (NSDictionary *)waitFor:(NSString *)requestId; ++ (void)cancel:(nullable NSString *)requestId; +@end + +static NSCondition *stateLock; +static NSMutableArray *routes; +static NSMutableArray *events; +static NSMutableDictionary *paused; +static BOOL enabled; +static NSUInteger sequence; +static NSInteger pollers; +static NSDate *lastPollEnd; +static NSURLSession *captureSession; +static const void *kDelegateCloneKey = &kDelegateCloneKey; +static const void *kIsDelegateCloneKey = &kIsDelegateCloneKey; + +static NSError *invalid(NSString *message) { + return [NSError errorWithDomain:kErrorDomain code:-32602 + userInfo:@{NSLocalizedDescriptionKey: message}]; +} + +static BOOL matchesLocked(NSURLRequest *request) { + if (!enabled || !request.URL) { + return NO; + } + NSString *url = request.URL.absoluteString; + NSString *method = request.HTTPMethod ?: @"GET"; + for (NSDictionary *route in routes) { + NSString *wanted = route[@"method"]; + if (wanted && [wanted caseInsensitiveCompare:method] != NSOrderedSame) { + continue; + } + NSRegularExpression *pattern = route[@"pattern"]; + if ([pattern numberOfMatchesInString:url options:0 range:NSMakeRange(0, url.length)] > 0) { + return YES; + } + } + return NO; +} + +static void releaseAllLocked(void) { + for (MobilePause *pause in paused.allValues) { + pause.verdict = @{@"action": @"continue"}; + } + [paused removeAllObjects]; + [stateLock broadcast]; +} + +@interface NSURLSessionConfiguration (MobileNetworkConfiguration) ++ (NSURLSessionConfiguration *)mobile_defaultSessionConfiguration; ++ (NSURLSessionConfiguration *)mobile_ephemeralSessionConfiguration; +@end + +static void addProtocol(NSURLSessionConfiguration *configuration) { + NSMutableArray *classes = [configuration.protocolClasses mutableCopy] ?: [NSMutableArray array]; + if (![classes containsObject:[MobileURLProtocol class]]) { + [classes insertObject:[MobileURLProtocol class] atIndex:0]; + configuration.protocolClasses = classes; + } +} + +@implementation NSURLSessionConfiguration (MobileNetworkConfiguration) ++ (NSURLSessionConfiguration *)mobile_defaultSessionConfiguration { + NSURLSessionConfiguration *configuration = [self mobile_defaultSessionConfiguration]; + addProtocol(configuration); + return configuration; +} ++ (NSURLSessionConfiguration *)mobile_ephemeralSessionConfiguration { + NSURLSessionConfiguration *configuration = [self mobile_ephemeralSessionConfiguration]; + addProtocol(configuration); + return configuration; +} +@end + +@interface NSURLSession (MobileNetworkTasks) +- (NSURLSessionDataTask *)mobile_dataTaskWithURL:(NSURL *)url + completionHandler:(void (^)(NSData *, NSURLResponse *, NSError *))completionHandler; +- (NSURLSessionDataTask *)mobile_dataTaskWithRequest:(NSURLRequest *)request + completionHandler:(void (^)(NSData *, NSURLResponse *, NSError *))completionHandler; +- (NSURLSessionDataTask *)mobile_dataTaskWithRequest:(NSURLRequest *)request; +@end + +@implementation NSURLSession (MobileNetworkTasks) +- (NSURLSessionDataTask *)mobile_dataTaskWithURL:(NSURL *)url + completionHandler:(void (^)(NSData *, NSURLResponse *, NSError *))completionHandler { + if (self != captureSession && [MobileNetwork matches:[NSURLRequest requestWithURL:url]]) { + return [captureSession dataTaskWithURL:url completionHandler:completionHandler]; + } + return [self mobile_dataTaskWithURL:url completionHandler:completionHandler]; +} + +- (NSURLSessionDataTask *)mobile_dataTaskWithRequest:(NSURLRequest *)request + completionHandler:(void (^)(NSData *, NSURLResponse *, NSError *))completionHandler { + if (self != captureSession && [MobileNetwork matches:request]) { + return [captureSession dataTaskWithRequest:request completionHandler:completionHandler]; + } + return [self mobile_dataTaskWithRequest:request completionHandler:completionHandler]; +} + +- (NSURLSessionDataTask *)mobile_dataTaskWithRequest:(NSURLRequest *)request { + if (![objc_getAssociatedObject(self, kIsDelegateCloneKey) boolValue] && + [MobileNetwork matches:request]) { + NSURLSession *clone = objc_getAssociatedObject(self, kDelegateCloneKey); + if (!clone) { + @synchronized (self) { + clone = objc_getAssociatedObject(self, kDelegateCloneKey); + if (!clone) { + NSURLSessionConfiguration *configuration = [self.configuration copy]; + addProtocol(configuration); + clone = [NSURLSession sessionWithConfiguration:configuration + delegate:self.delegate delegateQueue:self.delegateQueue]; + objc_setAssociatedObject(clone, kIsDelegateCloneKey, @YES, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + objc_setAssociatedObject(self, kDelegateCloneKey, clone, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + } + } + } + return [clone dataTaskWithRequest:request]; + } + return [self mobile_dataTaskWithRequest:request]; +} +@end + +@implementation MobileNetwork ++ (void)initialize { + if (self != [MobileNetwork class]) { + return; + } + stateLock = [NSCondition new]; + routes = [NSMutableArray array]; + events = [NSMutableArray array]; + paused = [NSMutableDictionary dictionary]; + lastPollEnd = [NSDate date]; +} + ++ (void)install { + static dispatch_once_t once; + dispatch_once(&once, ^{ + [NSURLProtocol registerClass:[MobileURLProtocol class]]; + Class meta = object_getClass([NSURLSessionConfiguration class]); + method_exchangeImplementations( + class_getInstanceMethod(meta, @selector(defaultSessionConfiguration)), + class_getInstanceMethod(meta, @selector(mobile_defaultSessionConfiguration))); + method_exchangeImplementations( + class_getInstanceMethod(meta, @selector(ephemeralSessionConfiguration)), + class_getInstanceMethod(meta, @selector(mobile_ephemeralSessionConfiguration))); + NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration ephemeralSessionConfiguration]; + addProtocol(configuration); + captureSession = [NSURLSession sessionWithConfiguration:configuration]; + method_exchangeImplementations( + class_getInstanceMethod([NSURLSession class], @selector(dataTaskWithURL:completionHandler:)), + class_getInstanceMethod([NSURLSession class], @selector(mobile_dataTaskWithURL:completionHandler:))); + method_exchangeImplementations( + class_getInstanceMethod([NSURLSession class], @selector(dataTaskWithRequest:completionHandler:)), + class_getInstanceMethod([NSURLSession class], @selector(mobile_dataTaskWithRequest:completionHandler:))); + method_exchangeImplementations( + class_getInstanceMethod([NSURLSession class], @selector(dataTaskWithRequest:)), + class_getInstanceMethod([NSURLSession class], @selector(mobile_dataTaskWithRequest:))); + NSLog(@"[mobilecli] URLSession network hook installed"); + }); +} + ++ (BOOL)matches:(NSURLRequest *)request { + [stateLock lock]; + BOOL match = matchesLocked(request); + [stateLock unlock]; + return match; +} + ++ (nullable NSString *)pauseRequest:(NSURLRequest *)request { + [stateLock lock]; + if (!matchesLocked(request) || events.count >= kMaxEvents) { + [stateLock unlock]; + return nil; + } + NSString *requestId = [NSString stringWithFormat:@"%lu", (unsigned long)++sequence]; + NSData *body = request.HTTPBody; + NSMutableDictionary *event = [@{ + @"type": @"paused", @"requestId": requestId, + @"method": request.HTTPMethod ?: @"GET", + @"url": request.URL.absoluteString ?: @"", + @"headers": request.allHTTPHeaderFields ?: @{}, + @"body": body ? [[body subdataWithRange:NSMakeRange(0, MIN(body.length, kMaxEventBody))] + base64EncodedStringWithOptions:0] : [NSNull null], + @"stack": @"urlsession", + } mutableCopy]; + if (body.length > kMaxEventBody) { + event[@"bodyTruncated"] = @YES; + } + MobilePause *pause = [MobilePause new]; + pause.event = event; + paused[requestId] = pause; + [events addObject:event]; + [stateLock broadcast]; + [stateLock unlock]; + return requestId; +} + ++ (NSDictionary *)waitFor:(NSString *)requestId { + NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:kDecisionTimeout]; + [stateLock lock]; + MobilePause *pause = paused[requestId]; + while (pause && !pause.verdict) { + NSDate *now = [NSDate date]; + if (!enabled || [now compare:deadline] != NSOrderedAscending || + (pollers == 0 && [now timeIntervalSinceDate:lastPollEnd] >= kPollGrace)) { + pause.verdict = @{@"action": @"continue"}; + break; + } + [stateLock waitUntilDate:[NSDate dateWithTimeIntervalSinceNow:1]]; + } + NSDictionary *verdict = pause.verdict ?: @{@"action": @"continue"}; + [paused removeObjectForKey:requestId]; + if (pause.event) { + [events removeObject:pause.event]; + } + [stateLock unlock]; + return verdict; +} + ++ (void)cancel:(NSString *)requestId { + if (!requestId) { + return; + } + [stateLock lock]; + MobilePause *pause = paused[requestId]; + if (pause) { + pause.verdict = @{@"action": @"continue"}; + [paused removeObjectForKey:requestId]; + [events removeObject:pause.event]; + [stateLock broadcast]; + } + [stateLock unlock]; +} + ++ (nullable NSDictionary *)perform:(NSString *)method params:(NSDictionary *)params error:(NSError **)error { + if ([method isEqualToString:@"network.enable"]) { + id definitions = params[@"routes"]; + if (![definitions isKindOfClass:[NSArray class]]) { + if (error) { + *error = invalid(@"routes must be an array"); + } + return nil; + } + NSMutableArray *next = [NSMutableArray array]; + for (id definition in definitions) { + if (![definition isKindOfClass:[NSDictionary class]] || + ![definition[@"url"] isKindOfClass:[NSString class]] || + [definition[@"url"] length] == 0) { + if (error) { + *error = invalid(@"route.url must be a nonempty string"); + } + return nil; + } + NSRegularExpression *pattern = MobileCompileNetworkGlob(definition[@"url"]); + if (!pattern) { + if (error) { + *error = invalid(@"invalid route glob"); + } + return nil; + } + NSMutableDictionary *route = [@{@"pattern": pattern} mutableCopy]; + if ([definition[@"method"] isKindOfClass:[NSString class]] && [definition[@"method"] length]) { + route[@"method"] = definition[@"method"]; + } + [next addObject:route]; + } + if (!next.count) { + [next addObject:@{@"pattern": MobileCompileNetworkGlob(@"**")}]; + } + [stateLock lock]; + releaseAllLocked(); + [events removeAllObjects]; + routes = next; + enabled = YES; + lastPollEnd = [NSDate date]; + [stateLock broadcast]; + [stateLock unlock]; + return @{@"status": @"ok"}; + } + if ([method isEqualToString:@"network.poll"]) { + NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:kPollTimeout]; + [stateLock lock]; + pollers++; + while (enabled && !events.count && [[NSDate date] compare:deadline] == NSOrderedAscending) { + [stateLock waitUntilDate:deadline]; + } + NSArray *batch = [events copy]; + [events removeAllObjects]; + pollers--; + lastPollEnd = [NSDate date]; + [stateLock unlock]; + return @{@"events": batch}; + } + if ([method isEqualToString:@"network.disable"]) { + [stateLock lock]; + enabled = NO; + [routes removeAllObjects]; + [events removeAllObjects]; + releaseAllLocked(); + [stateLock unlock]; + return @{@"status": @"ok"}; + } + if ([method isEqualToString:@"network.continue"] || + [method isEqualToString:@"network.fulfill"] || + [method isEqualToString:@"network.abort"]) { + NSString *requestId = params[@"requestId"]; + if (![requestId isKindOfClass:[NSString class]] || !requestId.length) { + if (error) { + *error = invalid(@"missing params.requestId"); + } + return nil; + } + if ([method isEqualToString:@"network.fulfill"]) { + NSInteger status = [params[@"status"] integerValue]; + if (status < 100 || status > 599) { + if (error) { + *error = invalid(@"status must be an HTTP status code"); + } + return nil; + } + } + if (params[@"body"] && ![[NSData alloc] initWithBase64EncodedString:params[@"body"] options:0]) { + if (error) { + *error = invalid(@"body must be base64"); + } + return nil; + } + NSString *action = [method substringFromIndex:[@"network." length]]; + [stateLock lock]; + MobilePause *pause = paused[requestId]; + if (!pause || pause.verdict) { + [stateLock unlock]; + if (error) { + *error = invalid([NSString stringWithFormat:@"request is not paused: %@", requestId]); + } + return nil; + } + pause.verdict = @{@"action": action, @"params": params}; + [stateLock broadcast]; + [stateLock unlock]; + return @{@"status": @"ok"}; + } + if (error) { + *error = [NSError errorWithDomain:kErrorDomain code:-32601 + userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"method not found: %@", method]}]; + } + return nil; +} +@end + +@implementation MobileURLProtocol { + NSString *_requestId; + NSURLRequest *_sourceRequest; +} + +// URLSession may turn an HTTPBody into a stream before URLProtocol sees it. +// Buffer small, known-length streams so the event can report the bytes and +// continue can replay exactly what was sent. Leave large streams untouched. +static NSURLRequest *materializeSmallBody(NSURLRequest *request) { + if (request.HTTPBody || !request.HTTPBodyStream) { + return request; + } + NSString *lengthText = request.allHTTPHeaderFields[@"Content-Length"]; + NSUInteger length = (NSUInteger)[lengthText longLongValue]; + if (!length || length > kMaxEventBody) { + return request; + } + NSInputStream *stream = request.HTTPBodyStream; + [stream open]; + NSMutableData *data = [NSMutableData dataWithCapacity:length]; + uint8_t buffer[8192]; + while (data.length < length) { + NSInteger count = [stream read:buffer maxLength:MIN(sizeof(buffer), length - data.length)]; + if (count <= 0) { + break; + } + [data appendBytes:buffer length:(NSUInteger)count]; + } + [stream close]; + if (data.length != length) { + return request; + } + NSMutableURLRequest *copy = [request mutableCopy]; + copy.HTTPBody = data; + return copy; +} + ++ (BOOL)canInitWithRequest:(NSURLRequest *)request { + if (![request.URL.scheme.lowercaseString isEqualToString:@"http"] && + ![request.URL.scheme.lowercaseString isEqualToString:@"https"]) { + return NO; + } + if ([NSURLProtocol propertyForKey:kHandledKey inRequest:request]) { + return NO; + } + return [MobileNetwork matches:request]; +} + ++ (BOOL)canInitWithTask:(NSURLSessionTask *)task { + return [self canInitWithRequest:task.currentRequest ?: task.originalRequest]; +} + ++ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request { return request; } + +- (void)startLoading { + _sourceRequest = materializeSmallBody(self.request); + _requestId = [MobileNetwork pauseRequest:_sourceRequest]; + if (!_requestId) { + [self forward:_sourceRequest]; + return; + } + dispatch_async(dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{ + NSDictionary *verdict = [MobileNetwork waitFor:self->_requestId]; + if (self.stopped) { + return; + } + NSString *action = verdict[@"action"]; + NSDictionary *params = verdict[@"params"] ?: @{}; + if ([action isEqualToString:@"fulfill"]) { + [self fulfill:params]; + } else if ([action isEqualToString:@"abort"]) { + NSString *hint = params[@"errorCode"]; + NSInteger code = [hint isEqualToString:@"timedout"] ? NSURLErrorTimedOut : + [hint isEqualToString:@"connectionrefused"] ? NSURLErrorCannotConnectToHost : + NSURLErrorNetworkConnectionLost; + [self.client URLProtocol:self didFailWithError:[NSError errorWithDomain:NSURLErrorDomain + code:code userInfo:@{NSLocalizedDescriptionKey: hint ?: @"mobilecli network abort"}]]; + } else { + NSMutableURLRequest *request = [self->_sourceRequest mutableCopy]; + if ([params[@"url"] isKindOfClass:[NSString class]]) { + request.URL = [NSURL URLWithString:params[@"url"]]; + } + if ([params[@"method"] isKindOfClass:[NSString class]]) { + request.HTTPMethod = params[@"method"]; + } + if ([params[@"headers"] isKindOfClass:[NSDictionary class]]) { + request.allHTTPHeaderFields = params[@"headers"]; + } + if ([params[@"body"] isKindOfClass:[NSString class]]) { + request.HTTPBody = [[NSData alloc] initWithBase64EncodedString:params[@"body"] options:0]; + } + [self forward:request]; + } + }); +} + +- (void)forward:(NSURLRequest *)request { + if (self.stopped) { + return; + } + NSMutableURLRequest *marked = [request mutableCopy]; + [NSURLProtocol setProperty:@YES forKey:kHandledKey inRequest:marked]; + NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration ephemeralSessionConfiguration]; + NSMutableArray *classes = [configuration.protocolClasses mutableCopy] ?: [NSMutableArray array]; + [classes removeObject:[MobileURLProtocol class]]; + configuration.protocolClasses = classes; + self.forwardSession = [NSURLSession sessionWithConfiguration:configuration]; + __weak typeof(self) weakSelf = self; + self.forwardTask = [self.forwardSession dataTaskWithRequest:marked + completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + MobileURLProtocol *owner = weakSelf; + if (!owner || owner.stopped) { + return; + } + if (error) { + [owner.client URLProtocol:owner didFailWithError:error]; + } else { + [owner.client URLProtocol:owner didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageNotAllowed]; + if (data.length) { + [owner.client URLProtocol:owner didLoadData:data]; + } + [owner.client URLProtocolDidFinishLoading:owner]; + } + [owner.forwardSession finishTasksAndInvalidate]; + }]; + [self.forwardTask resume]; +} + +- (void)fulfill:(NSDictionary *)params { + NSData *body = [params[@"body"] isKindOfClass:[NSString class]] + ? [[NSData alloc] initWithBase64EncodedString:params[@"body"] options:0] : [NSData data]; + NSHTTPURLResponse *response = [[NSHTTPURLResponse alloc] initWithURL:self.request.URL + statusCode:[params[@"status"] integerValue] HTTPVersion:@"HTTP/1.1" + headerFields:[params[@"headers"] isKindOfClass:[NSDictionary class]] ? params[@"headers"] : @{}]; + [self.client URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageNotAllowed]; + if (body.length) { + [self.client URLProtocol:self didLoadData:body]; + } + [self.client URLProtocolDidFinishLoading:self]; +} + +- (void)stopLoading { + self.stopped = YES; + [MobileNetwork cancel:_requestId]; + [self.forwardTask cancel]; + [self.forwardSession invalidateAndCancel]; +} +@end diff --git a/agents/ios/network_glob.h b/agents/ios/network_glob.h new file mode 100644 index 00000000..b5894519 --- /dev/null +++ b/agents/ios/network_glob.h @@ -0,0 +1,4 @@ +#import + +// Playwright-style URL glob: * stops at '/', ** crosses '/', and ? matches one character. +NSRegularExpression * _Nullable MobileCompileNetworkGlob(NSString * _Nonnull glob); diff --git a/agents/ios/network_glob.m b/agents/ios/network_glob.m new file mode 100644 index 00000000..75923038 --- /dev/null +++ b/agents/ios/network_glob.m @@ -0,0 +1,27 @@ +#import "network_glob.h" + +NSRegularExpression *MobileCompileNetworkGlob(NSString *glob) { + NSMutableString *pattern = [NSMutableString stringWithString:@"^"]; + NSCharacterSet *meta = [NSCharacterSet characterSetWithCharactersInString:@"\\.[]{}()+-^$|"]; + for (NSUInteger i = 0; i < glob.length; i++) { + unichar c = [glob characterAtIndex:i]; + if (c == '*') { + if (i + 1 < glob.length && [glob characterAtIndex:i + 1] == '*') { + [pattern appendString:@".*"]; + i++; + } else { + [pattern appendString:@"[^/]*"]; + } + } else if (c == '?') { + [pattern appendString:@"."]; + } else { + if ([meta characterIsMember:c]) { + [pattern appendString:@"\\"]; + } + [pattern appendFormat:@"%C", c]; + } + } + [pattern appendString:@"$"]; + return [NSRegularExpression regularExpressionWithPattern:pattern + options:NSRegularExpressionDotMatchesLineSeparators error:nil]; +} diff --git a/agents/ios/test/network_glob.m b/agents/ios/test/network_glob.m new file mode 100644 index 00000000..507461ee --- /dev/null +++ b/agents/ios/test/network_glob.m @@ -0,0 +1,19 @@ +#import +#import "../network_glob.h" + +static BOOL matches(NSString *glob, NSString *url) { + NSRegularExpression *pattern = MobileCompileNetworkGlob(glob); + return [pattern numberOfMatchesInString:url options:0 range:NSMakeRange(0, url.length)] == 1; +} + +int main(void) { + @autoreleasepool { + NSCAssert(matches(@"**/todos/**", @"https://example.test/todos/1"), @"double star must cross slashes"); + NSCAssert(matches(@"https://*.test/todos/?", @"https://api.test/todos/1"), @"star and question mark"); + NSCAssert(!matches(@"https://*.test/todos/?", @"https://api.test/todos/12"), @"question mark is one character"); + NSCAssert(!matches(@"https://*.test/todos/?", @"https://api.test/deep/todos/1"), @"single star stops at slash"); + NSCAssert(matches(@"https://api.test/a.b", @"https://api.test/a.b"), @"dot is literal"); + NSCAssert(!matches(@"https://api.test/a.b", @"https://api.test/axb"), @"dot is not regex wildcard"); + } + return 0; +} diff --git a/devices/ios_webview.go b/devices/ios_webview.go index a44612a1..29c6953b 100644 --- a/devices/ios_webview.go +++ b/devices/ios_webview.go @@ -127,6 +127,7 @@ func writeIOSAgentDylib() (string, error) { } var portFromLLDB = regexp.MustCompile(`\$\d+\s*=\s*(\d+)`) +var simulatorListeningPort = regexp.MustCompile(`TCP 127\.0\.0\.1:(\d+) \(LISTEN\)`) // psLineRegex matches a ps aux line and captures PID (group 1) and the full // command string including spaces (group 2). @@ -135,6 +136,37 @@ var psLineRegex = regexp.MustCompile(`^\S+\s+(\d+)(?:\s+\S+){8}\s+(.+)$`) const lldbTimeout = 30 * time.Second +// findRunningSimulatorAgent finds the agent socket owned by this app process. +// The daemon's in-memory port cache is lost when it restarts, while the dylib +// stays loaded until the app exits. Loading another copy into that process +// would run its constructor and URLSession swizzles a second time. +func findRunningSimulatorAgent(pid int) (int, bool) { + out, err := exec.Command("lsof", "-nP", "-a", "-p", strconv.Itoa(pid), "-iTCP").Output() + if err != nil { + return 0, false + } + for _, port := range parseSimulatorListeningPorts(string(out)) { + if _, err := agentRequestWithTimeout(port, "device.webview.list", nil, time.Second); err == nil { + return port, true + } + } + return 0, false +} + +func parseSimulatorListeningPorts(output string) []int { + var ports []int + for _, line := range strings.Split(output, "\n") { + match := simulatorListeningPort.FindStringSubmatch(line) + if match == nil { + continue + } + if port, err := strconv.Atoi(match[1]); err == nil { + ports = append(ports, port) + } + } + return ports +} + // injectIOSAgent attaches lldb to the given PID, loads the dylib, reads the // bound port via mobilecli_get_port(), then detaches. Returns the port. func injectIOSAgent(pid int, dylibPath string) (int, error) { @@ -219,6 +251,10 @@ func (s *SimulatorDevice) ensureIOSAgentReady() (int, error) { if err != nil { return 0, fmt.Errorf("cannot attach to %s: app does not have get-task-allow entitlement — use a debug build", foreground.PackageName) } + if port, ok := findRunningSimulatorAgent(pid); ok { + setCachedAgentPort(s.UDID, foreground.PackageName, port) + return port, nil + } utils.Verbose("attaching to %s (pid %d, bundle %s)", appBundlePath, pid, foreground.PackageName) diff --git a/devices/ios_webview_agent_test.go b/devices/ios_webview_agent_test.go new file mode 100644 index 00000000..6c6da8c9 --- /dev/null +++ b/devices/ios_webview_agent_test.go @@ -0,0 +1,16 @@ +package devices + +import ( + "reflect" + "testing" +) + +func TestParseSimulatorListeningPorts(t *testing.T) { + output := "COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME\n" + + "playgroun 92664 gilm 11u IPv4 0x1 0t0 TCP 192.168.0.101:55975->13.225.245.14:443 (ESTABLISHED)\n" + + "playgroun 92664 gilm 13u IPv4 0x2 0t0 TCP 127.0.0.1:56022 (LISTEN)\n" + + "playgroun 92664 gilm 14u IPv4 0x3 0t0 TCP 0.0.0.0:3000 (LISTEN)\n" + if got := parseSimulatorListeningPorts(output); !reflect.DeepEqual(got, []int{56022}) { + t.Fatalf("ports = %v, want [56022]", got) + } +} diff --git a/test/network.spec.ts b/test/network.spec.ts index 8e805d95..19017b4d 100644 --- a/test/network.spec.ts +++ b/test/network.spec.ts @@ -10,8 +10,8 @@ import {downloadPlayground, PLAYGROUND_PACKAGE} from './playground'; const mobilecliBinary = path.join(__dirname, '..', 'mobilecli'); // the playground Network screen (playground://network) sends HTTP through the -// stacks a real app uses: OkHttp and HttpURLConnection on android, URLSession on -// ios. each button shows the outcome in one on-screen label, so a test can read +// stacks a real app uses: OkHttp and HttpURLConnection on android, URLSession and +// Alamofire on ios. each button shows the outcome in one on-screen label, so a test can read // what the app actually received after interception. const GET_BUTTON: Record = { android: 'OkHttp GET', @@ -20,7 +20,7 @@ const GET_BUTTON: Record = { // the label every result line starts with, so the on-screen outcome is findable // regardless of status or body -const RESULT_PREFIXES = ['okhttp', 'urlconnection', 'shared', 'custom']; +const RESULT_PREFIXES = ['okhttp', 'urlconnection', 'shared', 'custom', 'session', 'alamofire']; type Device = {id: string; platform: string}; @@ -30,6 +30,7 @@ type PausedEvent = { requestId: string; method: string; url: string; + body?: string | null; }; function firstDeviceOfType(deviceType: string): Device | null { @@ -44,6 +45,13 @@ function firstDeviceOfType(deviceType: string): Device | null { } } +function simulatorPlaygroundPID(deviceId: string): number | null { + const processes = execFileSync('ps', ['-axo', 'pid=,command='], {encoding: 'utf8'}); + const line = processes.split('\n').find(line => + line.includes(`/CoreSimulator/Devices/${deviceId}/`) && line.includes('/playground.app/playground')); + return line ? Number(line.trim().match(/^\d+/)?.[0]) : null; +} + function runMobilecli(args: string[]): string { return execFileSync(mobilecliBinary, args, {encoding: 'utf8', env: coverageEnv()}); } @@ -82,7 +90,7 @@ function tapButton(deviceId: string, text: string): void { throw new Error(`button "${text}" not found`); } const {x, y, width, height} = el.rect; - runMobilecli(['io', 'tap', `${x + width / 2},${y + height / 2}`, '--device', deviceId]); + runMobilecli(['io', 'tap', `${Math.round(x + width / 2)},${Math.round(y + height / 2)}`, '--device', deviceId]); } // the on-screen outcome line, once the request has resolved (e.g. @@ -124,12 +132,27 @@ function startListening(deviceId: string, pauses: string[]): {events: PausedEven } }); const stop = () => new Promise(resolve => { + if (child.exitCode !== null || child.signalCode !== null) { + resolve(); + return; + } child.on('close', () => resolve()); child.kill('SIGINT'); }); return {events, stop}; } +async function tapUntilPaused(deviceId: string, button: string, events: PausedEvent[]): Promise { + const previousCount = events.length; + const deadline = Date.now() + 20000; + while (Date.now() < deadline) { + tapButton(deviceId, button); + await new Promise(resolve => setTimeout(resolve, 800)); + if (events.length > previousCount) return events[previousCount]; + } + throw new Error(`${button} did not pause within 20 seconds`); +} + for (const platform of ['android', 'ios'] as const) { test.describe(`network (${platform})`, () => { let device: Device | null; @@ -170,11 +193,8 @@ for (const platform of ['android', 'ios'] as const) { await listener.stop(); }); - // ── interception acceptance. Runs where the in-app agent implements the - // network.* methods: Android (agents/android/java) does; iOS (agents/ios) - // does not yet, so it stays skipped until that agent lands. ── - const interception = platform === 'android' ? test.describe : test.describe.skip; - interception('interception', () => { + // ── interception acceptance for both in-app agents ── + test.describe('interception', () => { test('fulfill replaces the response the app receives', async () => { test.skip(!device, `no ${platform} device`); openNetworkScreen(device!.id); @@ -184,14 +204,12 @@ for (const platform of ['android', 'ios'] as const) { tapButton(device!.id, GET_BUTTON[platform]); await eventually(() => listener.events.length, 'no paused request').toBeGreaterThan(0); - const body = Buffer.from('{"mocked":true}').toString('base64'); runMobilecli(['network', 'fulfill', '--device', device!.id, '--id', listener.events[0].requestId, '--status', '200', '--body', '{"mocked":true}']); await eventually(() => readNetworkResult(device!.id), 'mocked body never shown') .toContain('mocked'); await listener.stop(); - expect(body).toBeTruthy(); }); test('abort makes the request fail in the app', async () => { @@ -222,6 +240,115 @@ for (const platform of ['android', 'ios'] as const) { .toContain('200'); await listener.stop(); }); + + test('iOS URLSession and Alamofire HTTP and HTTPS requests can be fulfilled', async () => { + test.skip(platform !== 'ios' || !device, 'requires the iOS simulator playground'); + openNetworkScreen(device!.id); + await eventually(() => !!findByText(device!.id, 'Alamofire HTTP GET'), 'no network screen').toBe(true); + const listener = startListening(device!.id, ['**/todos/**']); + try { + const buttons = [ + 'URLSession.shared GET', + 'URLSession HTTP GET', + 'Custom session GET', + 'Alamofire HTTPS GET', + 'Alamofire HTTP GET', + ]; + for (const [index, button] of buttons.entries()) { + const previousCount = listener.events.length; + tapButton(device!.id, button); + await eventually(() => listener.events.length, `${button} was not paused`) + .toBeGreaterThan(previousCount); + const event = listener.events[previousCount]; + const marker = `mock-${index}`; + const response = runMobilecliJson(['network', 'fulfill', '--device', device!.id, + '--id', event.requestId, '--status', '200', '--body', marker]); + expect(response.status).toBe('ok'); + await eventually(() => readNetworkResult(device!.id), `${button} did not show ${marker}`) + .toContain(marker); + } + } finally { + await listener.stop(); + } + }); + + test('iOS unmatched requests proceed without an event', async () => { + test.skip(platform !== 'ios' || !device, 'requires the iOS simulator playground'); + openNetworkScreen(device!.id); + const listener = startListening(device!.id, ['**/never-match/**']); + try { + tapButton(device!.id, 'Alamofire HTTPS GET'); + await eventually(() => readNetworkResult(device!.id), 'unmatched request did not complete') + .toContain('alamofire HTTPS GET 200'); + expect(listener.events).toHaveLength(0); + } finally { + await listener.stop(); + } + }); + + test('iOS listener exit releases a paused request unchanged', async () => { + test.skip(platform !== 'ios' || !device, 'requires the iOS simulator playground'); + openNetworkScreen(device!.id); + const listener = startListening(device!.id, ['**/todos/**']); + tapButton(device!.id, 'Alamofire HTTPS GET'); + await eventually(() => listener.events.length, 'request was not paused').toBeGreaterThan(0); + await listener.stop(); + await eventually(() => readNetworkResult(device!.id), 'request stayed paused after disconnect') + .toContain('alamofire HTTPS GET 200'); + }); + + test('iOS POST body is captured and can be replaced on continue', async () => { + test.skip(platform !== 'ios' || !device, 'requires the iOS simulator playground'); + openNetworkScreen(device!.id); + const listener = startListening(device!.id, ['POST **/posts']); + try { + tapButton(device!.id, 'URLSession.shared POST'); + await eventually(() => listener.events.length, 'POST was not paused').toBeGreaterThan(0); + const event = listener.events[0]; + expect(event.method).toBe('POST'); + expect(Buffer.from(event.body ?? '', 'base64').toString()).toContain('playground'); + runMobilecli(['network', 'continue', '--device', device!.id, + '--id', event.requestId, '--body', '{"title":"overridden","body":"ok","userId":2}']); + await eventually(() => readNetworkResult(device!.id), 'POST override did not arrive') + .toContain('overridden'); + } finally { + await listener.stop(); + } + }); + + test('iOS agent is reused after the daemon restarts', async () => { + test.skip(platform !== 'ios' || !device, 'requires the iOS simulator playground'); + openNetworkScreen(device!.id); + await eventually(() => !!findByText(device!.id, 'URLSession.shared GET'), 'no network screen').toBe(true); + const appPID = simulatorPlaygroundPID(device!.id); + expect(appPID).toBeTruthy(); + + const first = startListening(device!.id, ['**/todos/**']); + try { + const event = await tapUntilPaused(device!.id, 'URLSession.shared GET', first.events); + runMobilecli(['network', 'fulfill', '--device', device!.id, + '--id', event.requestId, '--status', '200', '--body', 'before-restart']); + await eventually(() => readNetworkResult(device!.id), 'first response did not arrive') + .toContain('before-restart'); + } finally { + await first.stop(); + } + + expect(runMobilecliJson(['daemon', 'stop']).status).toBe('ok'); + expect(simulatorPlaygroundPID(device!.id)).toBe(appPID); + + const second = startListening(device!.id, ['**/todos/**']); + try { + const event = await tapUntilPaused(device!.id, 'URLSession.shared GET', second.events); + runMobilecli(['network', 'fulfill', '--device', device!.id, + '--id', event.requestId, '--status', '200', '--body', 'after-restart']); + await eventually(() => readNetworkResult(device!.id), 'second response did not arrive') + .toContain('after-restart'); + expect(simulatorPlaygroundPID(device!.id)).toBe(appPID); + } finally { + await second.stop(); + } + }); }); }); } From 1c35c3cab6aa8e24cc809901d1abf9e9e90389f7 Mon Sep 17 00:00:00 2001 From: Mobile Kevin Bot! Date: Mon, 12 Oct 2026 13:20:37 +0200 Subject: [PATCH 05/18] style(network): one case per line in the android agent dispatcher --- agents/android/java/InjectedAgent.java | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/agents/android/java/InjectedAgent.java b/agents/android/java/InjectedAgent.java index 9a9c3f98..c1a6d289 100644 --- a/agents/android/java/InjectedAgent.java +++ b/agents/android/java/InjectedAgent.java @@ -28,12 +28,23 @@ private static void startRpcServer() { @Override public Object dispatch(String method, JSONObject params) throws Exception { switch (method) { - case "network.enable": return Network.enable(params); - case "network.poll": return Network.poll(params); - case "network.continue": return Network.continueRequest(params); - case "network.fulfill": return Network.fulfill(params); - case "network.abort": return Network.abort(params); - case "network.disable": return Network.disable(params); + case "network.enable": + return Network.enable(params); + + case "network.poll": + return Network.poll(params); + + case "network.continue": + return Network.continueRequest(params); + + case "network.fulfill": + return Network.fulfill(params); + + case "network.abort": + return Network.abort(params); + + case "network.disable": + return Network.disable(params); case "device.dump.ui": return WebViews.dumpUi(); From c73de7a765248df2448a50ab06c9c4383e2074d7 Mon Sep 17 00:00:00 2001 From: Mobile Kevin Bot! Date: Wed, 4 Nov 2026 20:28:36 +0100 Subject: [PATCH 06/18] ios: native signing-free Mach-O mapper for agent injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add devices/ios/machomap: a GDB-RSP-driven loader that maps a precompiled arm64 dylib into a running get-task-allow process over the on-device debugserver and runs it, with no code signing and no re-signing of the dylib or the app (the CS_DEBUGGED exemption lets the debugger execute pages it wrote). This is the injection primitive that works on AWS Device Farm, where dlopen and host toolchains are unavailable — replacing the lldb 'expr -l objc' source-JIT path. - macho.go/fixups.go: thin arm64 parse + chained-fixups walker - rsp.go/target.go: RSP client (RLE+escape decode) and borrow-thread calls - stubs.go: three in-process loop stubs (resolve imports / unique selrefs / register classes) so ~286 per-element vCont round-trips collapse to ~8, taking the map step from ~8s to ~0.4s - resolver_hostfiles.go: symbol resolution via Xcode symbol files (Mac parity) - exporttrie.go: host-independent resolver — finds shared-cache dylibs via dyld_all_image_infos and parses their export tries from device memory - cmd/mapprobe: throwaway on-device proof harness (not shipped) Verified on iPhone12,1 (iOS 26.5.2) against the network agent dylib: 483 rebases, 244 binds, 97 imports, 184 selrefs, 5 classes; export-trie resolver matches the host-files resolver bit-for-bit. Not yet wired into injectFreshAgent. --- devices/ios/machomap/cmd/mapprobe/main.go | 128 ++++++++ devices/ios/machomap/exporttrie.go | 337 +++++++++++++++++++++ devices/ios/machomap/fixups.go | 113 +++++++ devices/ios/machomap/macho.go | 180 +++++++++++ devices/ios/machomap/macho_test.go | 83 +++++ devices/ios/machomap/mapper.go | 229 ++++++++++++++ devices/ios/machomap/resolver_hostfiles.go | 133 ++++++++ devices/ios/machomap/rsp.go | 210 +++++++++++++ devices/ios/machomap/stubs.go | 42 +++ devices/ios/machomap/stubs_arm64.asm | 91 ++++++ devices/ios/machomap/target.go | 121 ++++++++ 11 files changed, 1667 insertions(+) create mode 100644 devices/ios/machomap/cmd/mapprobe/main.go create mode 100644 devices/ios/machomap/exporttrie.go create mode 100644 devices/ios/machomap/fixups.go create mode 100644 devices/ios/machomap/macho.go create mode 100644 devices/ios/machomap/macho_test.go create mode 100644 devices/ios/machomap/mapper.go create mode 100644 devices/ios/machomap/resolver_hostfiles.go create mode 100644 devices/ios/machomap/rsp.go create mode 100644 devices/ios/machomap/stubs.go create mode 100644 devices/ios/machomap/stubs_arm64.asm create mode 100644 devices/ios/machomap/target.go diff --git a/devices/ios/machomap/cmd/mapprobe/main.go b/devices/ios/machomap/cmd/mapprobe/main.go new file mode 100644 index 00000000..9e75ff0b --- /dev/null +++ b/devices/ios/machomap/cmd/mapprobe/main.go @@ -0,0 +1,128 @@ +// Command mapprobe is a throwaway on-device proof harness for the machomap +// package (Stage 1b). It dials the go-ios userspace tunnel forwarder exactly +// like the Python prototype (16-byte IPv6 + little-endian port), attaches to a +// pid, and maps a dylib with the host-files resolver so the Go result can be +// compared against inject_map.py. The production path (Stage 2) uses +// goios.ConnectTUNDevice instead of this forwarder and is wired into the device +// agent; this command is not shipped. +package main + +import ( + "encoding/binary" + "flag" + "fmt" + "net" + "os" + "time" + + "github.com/mobile-next/mobilecli/devices/ios/machomap" +) + +func main() { + host := flag.String("host", "", "device tunnel IPv6 address") + tunnelPort := flag.Int("tunnel-port", 0, "userspace tunnel forwarder local port") + port := flag.Int("port", 0, "device debugproxy port") + pid := flag.Int("pid", 0, "target process id") + dylibPath := flag.String("dylib", "/tmp/agent-dev.dylib", "dylib to map") + runInit := flag.Bool("init", false, "run the dylib initializer (starts the agent)") + compare := flag.Bool("compare", false, "resolve helpers via host-files AND export-trie and compare, then exit") + useTrie := flag.Bool("trie", false, "use the export-trie (host-independent) resolver for the map") + symbolRoot := flag.String("symbol-root", + "/Users/gilm/Library/Developer/Xcode/iOS DeviceSupport/iPhone12,1 26.5.2 (23F84)/Symbols/usr/lib", + "Xcode device symbols root") + flag.Parse() + + if *host == "" || *tunnelPort == 0 || *port == 0 || *pid == 0 { + fmt.Fprintln(os.Stderr, "need --host --tunnel-port --port --pid") + os.Exit(2) + } + + dylib, err := os.ReadFile(*dylibPath) + check(err) + + start := time.Now() + conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", *tunnelPort), 8*time.Second) + check(err) + defer func() { _ = conn.Close() }() + _ = conn.(*net.TCPConn).SetNoDelay(true) + + // userspace-tunnel forwarder header: 16-byte IPv6 + LE uint32 destination port + ip := net.ParseIP(*host).To16() + if ip == nil { + fatal("bad --host IPv6 %q", *host) + } + hdr := make([]byte, 20) + copy(hdr[:16], ip) + binary.LittleEndian.PutUint32(hdr[16:], uint32(*port)) + _, err = conn.Write(hdr) + check(err) + + c, err := machomap.NewRSP(conn) + check(err) + tConn := time.Now() + + if resp, err := c.Request("QSetDetachOnError:1"); err != nil || resp != "OK" { + fatal("QSetDetachOnError: resp=%q err=%v", resp, err) + } + stop, err := c.Request(fmt.Sprintf("vAttach;%x", *pid)) + check(err) + if len(stop) == 0 || stop[0] != 'T' { + fatal("vAttach failed: %q", stop) + } + fmt.Printf("CONNECT %.3fs vATTACH %.3fs\n", tConn.Sub(start).Seconds(), time.Since(tConn).Seconds()) + + helperNames := []string{"mmap", "mprotect", "dlsym", "sel_registerName", "objc_readClassPair"} + if *compare { + hf, err := machomap.NewHostFilesResolver(c, *symbolRoot).Resolve(helperNames) + check(err) + et, err := machomap.NewExportTrieResolver(c).Resolve(helperNames) + check(err) + allMatch := true + for _, n := range helperNames { + ok := hf[n] == et[n] + allMatch = allMatch && ok + tag := "MATCH" + if !ok { + tag = "MISMATCH" + } + fmt.Printf(" %-20s host-files=%#x export-trie=%#x %s\n", n, hf[n], et[n], tag) + } + if allMatch { + fmt.Println("ALL MATCH — export-trie resolver verified") + } else { + fmt.Println("MISMATCH — export-trie resolver is wrong") + } + _, _ = c.Request("D") + return + } + + var resolver machomap.Resolver = machomap.NewHostFilesResolver(c, *symbolRoot) + if *useTrie { + resolver = machomap.NewExportTrieResolver(c) + } + res, err := machomap.Map(c, dylib, resolver, machomap.Options{RunInit: *runInit}) + check(err) + + fmt.Printf("MAP base=%#x size=%#x\n", res.Base, res.ImageSize) + fmt.Printf("FIXUPS: %d rebases, %d binds (%d imports)\n", res.Rebases, res.Binds, res.Imports) + fmt.Printf("OBJC: %d selrefs, %d classes\n", res.Selrefs, res.Classes) + if len(res.Unresolved) > 0 { + fmt.Printf("UNRESOLVED (%d): %v\n", len(res.Unresolved), res.Unresolved) + } + if !*runInit { + fmt.Println("(initializer NOT run; --init to start the agent)") + } + _, _ = c.Request("D") + fmt.Printf("detached; total %.3fs\n", time.Since(start).Seconds()) +} + +func check(err error) { + if err != nil { + fatal("%v", err) + } +} + +func fatal(f string, a ...any) { + fmt.Fprintf(os.Stderr, "ERROR: "+f+"\n", a...) + os.Exit(1) +} diff --git a/devices/ios/machomap/exporttrie.go b/devices/ios/machomap/exporttrie.go new file mode 100644 index 00000000..3cca9c8a --- /dev/null +++ b/devices/ios/machomap/exporttrie.go @@ -0,0 +1,337 @@ +package machomap + +import ( + "encoding/binary" + "fmt" + "os" + "strings" +) + +var trieDebug = os.Getenv("MACHOMAP_DEBUG") != "" + +// ExportTrieResolver resolves symbols with no host symbol files: it finds the +// relevant shared-cache dylibs in the target's address space (via +// dyld_all_image_infos) and parses each one's export trie out of device memory. +// This is what makes the mapper work on AWS Device Farm, where the Mac-only +// HostFilesResolver's Xcode symbol files do not exist. +type ExportTrieResolver struct { + c *RSP + nameDylib map[string]string // symbol name -> dylib path suffix that exports it + loadAddr map[string]uint64 // dylib path suffix -> in-memory load address (cache) +} + +// NewExportTrieResolver maps the bootstrap helpers to the dylibs that export them. +func NewExportTrieResolver(c *RSP) *ExportTrieResolver { + return &ExportTrieResolver{ + c: c, + nameDylib: map[string]string{ + "mmap": "/usr/lib/system/libsystem_kernel.dylib", + "mprotect": "/usr/lib/system/libsystem_kernel.dylib", + "dlsym": "/usr/lib/system/libdyld.dylib", + "sel_registerName": "/usr/lib/libobjc.A.dylib", + "objc_readClassPair": "/usr/lib/libobjc.A.dylib", + "objc_getClass": "/usr/lib/libobjc.A.dylib", + "objc_msgSend": "/usr/lib/libobjc.A.dylib", + }, + loadAddr: map[string]uint64{}, + } +} + +// Resolve groups names by exporting dylib, locates each dylib once, and parses +// its export trie for the requested symbols. +func (r *ExportTrieResolver) Resolve(names []string) (map[string]uint64, error) { + byDylib := map[string][]string{} + for _, n := range names { + d, ok := r.nameDylib[n] + if !ok { + return nil, fmt.Errorf("export-trie resolver has no dylib mapping for %q", n) + } + byDylib[d] = append(byDylib[d], n) + } + // locate every needed dylib in one scan of the image list + var need []string + for d := range byDylib { + if _, ok := r.loadAddr[d]; !ok { + need = append(need, d) + } + } + if len(need) > 0 { + if err := r.locate(need); err != nil { + return nil, err + } + } + + out := map[string]uint64{} + for d, want := range byDylib { + base, ok := r.loadAddr[d] + if !ok || base == 0 { + return nil, fmt.Errorf("dylib %s not found in loaded images", d) + } + trie, err := r.readExportTrie(base) + if err != nil { + return nil, fmt.Errorf("%s export trie: %w", d, err) + } + for _, n := range want { + off, found, err := lookupExportTrie(trie, "_"+n) + if err != nil { + return nil, fmt.Errorf("%s: %w", n, err) + } + if !found { + return nil, fmt.Errorf("%q not exported by %s", n, d) + } + out[n] = base + off + } + } + return out, nil +} + +// locate scans dyld_all_image_infos.infoArray for each wanted dylib path suffix +// and records its in-memory load address. Stops once all are found. +func (r *ExportTrieResolver) locate(suffixes []string) error { + resp, err := r.c.Request("qShlibInfoAddr") + if err != nil { + return err + } + var aiaddr uint64 + if _, err := fmt.Sscanf(resp, "%x", &aiaddr); err != nil { + return fmt.Errorf("qShlibInfoAddr %q: %w", resp, err) + } + hdr, err := r.c.Memory(aiaddr, 16) + if err != nil { + return err + } + count := binary.LittleEndian.Uint32(hdr[4:]) + arrayPtr := binary.LittleEndian.Uint64(hdr[8:]) + if arrayPtr == 0 || count == 0 || count > 100000 { + return fmt.Errorf("implausible image info array: count=%d ptr=%#x", count, arrayPtr) + } + remaining := map[string]bool{} + for _, s := range suffixes { + remaining[s] = true + } + for i := uint32(0); i < count && len(remaining) > 0; i++ { + ent, err := r.c.Memory(arrayPtr+uint64(i)*24, 16) + if err != nil { + return err + } + loadAddr := binary.LittleEndian.Uint64(ent[0:]) + pathPtr := binary.LittleEndian.Uint64(ent[8:]) + if pathPtr == 0 { + continue + } + path := r.readCString(pathPtr, 256) + for s := range remaining { + if strings.HasSuffix(path, s) { + r.loadAddr[s] = loadAddr + delete(remaining, s) + } + } + } + if len(remaining) > 0 { + var miss []string + for s := range remaining { + miss = append(miss, s) + } + return fmt.Errorf("dylibs not found in image list: %v", miss) + } + return nil +} + +// readExportTrie reads a dylib's export trie from device memory by converting the +// LC_DYLD_EXPORTS_TRIE / LC_DYLD_INFO file offset into a mapped address using the +// image's own segment table (handles the shared cache's non-contiguous layout). +func (r *ExportTrieResolver) readExportTrie(loadAddr uint64) ([]byte, error) { + head, err := r.c.Memory(loadAddr, 32) + if err != nil { + return nil, err + } + if binary.LittleEndian.Uint32(head) != machoMagic64 { + return nil, fmt.Errorf("no Mach-O header at %#x", loadAddr) + } + ncmds := binary.LittleEndian.Uint32(head[16:]) + sizeofcmds := binary.LittleEndian.Uint32(head[20:]) + cmds, err := r.c.Memory(loadAddr+32, uint64(sizeofcmds)) + if err != nil { + return nil, err + } + + const ( + lcDyldInfo = 0x2 + lcDyldInfoOnly = 0x22 + lcDyldExportsTrie = 0x80000033 + ) + var textVM, leVM, leFileOff uint64 + var haveText, haveLE, haveTrie bool + var exportOff, exportSize uint64 + + off := 0 + for i := uint32(0); i < ncmds; i++ { + if off+8 > len(cmds) { + break + } + cmd := binary.LittleEndian.Uint32(cmds[off:]) + sz := binary.LittleEndian.Uint32(cmds[off+4:]) + if sz == 0 { + break + } + switch cmd { + case lcSegment64: + name := cstr16(cmds[off+8 : off+24]) + vmaddr := binary.LittleEndian.Uint64(cmds[off+24:]) + fileoff := binary.LittleEndian.Uint64(cmds[off+40:]) + if trieDebug { + vmsize := binary.LittleEndian.Uint64(cmds[off+32:]) + filesize := binary.LittleEndian.Uint64(cmds[off+48:]) + fmt.Fprintf(os.Stderr, "[seg] %-12s vmaddr=%#x vmsize=%#x fileoff=%#x filesize=%#x\n", + name, vmaddr, vmsize, fileoff, filesize) + } + switch name { + case "__TEXT": + textVM, haveText = vmaddr, true + case "__LINKEDIT": + leVM, leFileOff, haveLE = vmaddr, fileoff, true + } + case lcDyldExportsTrie: + // LC_DYLD_EXPORTS_TRIE is authoritative; a legacy LC_DYLD_INFO may also be + // present with a stale/tiny export_off, so this always wins. + exportOff = uint64(binary.LittleEndian.Uint32(cmds[off+8:])) + exportSize = uint64(binary.LittleEndian.Uint32(cmds[off+12:])) + haveTrie = true + if trieDebug { + fmt.Fprintf(os.Stderr, "[lc] LC_DYLD_EXPORTS_TRIE dataoff=%#x datasize=%#x\n", exportOff, exportSize) + } + case lcDyldInfo, lcDyldInfoOnly: + if !haveTrie { + exportOff = uint64(binary.LittleEndian.Uint32(cmds[off+40:])) + exportSize = uint64(binary.LittleEndian.Uint32(cmds[off+44:])) + } + if trieDebug { + fmt.Fprintf(os.Stderr, "[lc] LC_DYLD_INFO export_off=%#x export_size=%#x\n", + binary.LittleEndian.Uint32(cmds[off+40:]), binary.LittleEndian.Uint32(cmds[off+44:])) + } + } + off += int(sz) + } + if !haveText || !haveLE { + return nil, fmt.Errorf("missing __TEXT/__LINKEDIT in image at %#x", loadAddr) + } + if exportOff == 0 || exportSize == 0 { + return nil, fmt.Errorf("image at %#x has no export trie", loadAddr) + } + slide := loadAddr - textVM + trieAddr := leVM + slide + (exportOff - leFileOff) + if trieDebug { + fmt.Fprintf(os.Stderr, "[trie] load=%#x textVM=%#x leVM=%#x leFileOff=%#x exportOff=%#x size=%#x slide=%#x trieAddr=%#x\n", + loadAddr, textVM, leVM, leFileOff, exportOff, exportSize, slide, trieAddr) + } + trie, err := r.c.Memory(trieAddr, exportSize) + if err != nil { + return nil, err + } + if trieDebug { + n := len(trie) + if n > 48 { + n = 48 + } + fmt.Fprintf(os.Stderr, "[trie] first bytes: % x\n", trie[:n]) + } + return trie, nil +} + +func (r *ExportTrieResolver) readCString(addr uint64, max uint64) string { + data, err := r.c.Memory(addr, max) + if err != nil { + return "" + } + if i := indexByte(data, 0); i >= 0 { + return string(data[:i]) + } + return string(data) +} + +func indexByte(b []byte, c byte) int { + for i := range b { + if b[i] == c { + return i + } + } + return -1 +} + +// lookupExportTrie walks the dyld export trie for symbol and returns its address +// offset (from the image base) if found. Handles regular and absolute exports; +// errors on reexports (not expected for our bootstrap symbols). +func lookupExportTrie(trie []byte, symbol string) (uint64, bool, error) { + const ( + flagsKindMask = 0x03 + flagAbsolute = 0x02 + flagReexport = 0x08 + flagStubAndReso = 0x10 + ) + pos := 0 + rest := symbol + for { + if pos >= len(trie) { + return 0, false, nil + } + termSize, n := uleb(trie, pos) + pos += n + if rest == "" { + if termSize == 0 { + return 0, false, nil + } + flags, n2 := uleb(trie, pos) + p := pos + n2 + if flags&flagReexport != 0 || flags&flagStubAndReso != 0 { + return 0, false, fmt.Errorf("unsupported export kind (flags=%#x)", flags) + } + addr, _ := uleb(trie, p) + _ = flags & flagsKindMask + _ = flagAbsolute + return addr, true, nil + } + // skip this node's terminal payload to reach the child list + childPos := pos + int(termSize) + if childPos >= len(trie) { + return 0, false, nil + } + childCount := int(trie[childPos]) + cp := childPos + 1 + next := -1 + for i := 0; i < childCount; i++ { + edgeStart := cp + for cp < len(trie) && trie[cp] != 0 { + cp++ + } + edge := string(trie[edgeStart:cp]) + cp++ // skip NUL + childOff, m := uleb(trie, cp) + cp += m + if next == -1 && strings.HasPrefix(rest, edge) { + rest = rest[len(edge):] + next = int(childOff) + } + } + if next == -1 { + return 0, false, nil + } + pos = next + } +} + +// uleb reads an unsigned LEB128 at data[pos], returning the value and bytes read. +func uleb(data []byte, pos int) (uint64, int) { + var result uint64 + var shift uint + i := 0 + for pos+i < len(data) { + b := data[pos+i] + result |= uint64(b&0x7f) << shift + i++ + if b&0x80 == 0 { + break + } + shift += 7 + } + return result, i +} diff --git a/devices/ios/machomap/fixups.go b/devices/ios/machomap/fixups.go new file mode 100644 index 00000000..36d967a6 --- /dev/null +++ b/devices/ios/machomap/fixups.go @@ -0,0 +1,113 @@ +package machomap + +import ( + "encoding/binary" + "fmt" +) + +// Chained-fixup pointer formats we handle. Both reduce to "runtime address = +// load_base + target" because the dylib's preferred base is 0 (arm64, non-arm64e). +const ( + dyldChainedPtr64 = 2 // DYLD_CHAINED_PTR_64 + dyldChainedPtr64Offset = 6 // DYLD_CHAINED_PTR_64_OFFSET + chainedPtrStartNone = 0xFFFF +) + +// fixupSeg is one dyld_chained_starts_in_segment: where a segment's fixup chains +// begin and how to stride them. +type fixupSeg struct { + pageSize uint32 + ptrFormat uint16 + segmentOffset uint64 + pageStarts []uint16 +} + +// ParseChainedFixups returns the imported symbol names (in ordinal order) and the +// per-segment chain-start tables from the LC_DYLD_CHAINED_FIXUPS blob at hdrOff. +func ParseChainedFixups(data []byte, hdrOff uint32) (imports []string, segs []fixupSeg, err error) { + h := int(hdrOff) + if h+28 > len(data) { + return nil, nil, fmt.Errorf("chained-fixups header out of range") + } + startsOff := binary.LittleEndian.Uint32(data[h+4:]) + impOff := binary.LittleEndian.Uint32(data[h+8:]) + symOff := binary.LittleEndian.Uint32(data[h+12:]) + impCnt := binary.LittleEndian.Uint32(data[h+16:]) + // imports format 1: {lib_ordinal:8, weak:1, name_offset:23} + for i := uint32(0); i < impCnt; i++ { + v := binary.LittleEndian.Uint32(data[h+int(impOff)+int(i)*4:]) + nameOff := (v >> 9) & 0x7FFFFF + name := cstrAt(data, h+int(symOff)+int(nameOff), len(data)) + imports = append(imports, name) + } + segCount := binary.LittleEndian.Uint32(data[h+int(startsOff):]) + for s := uint32(0); s < segCount; s++ { + so := binary.LittleEndian.Uint32(data[h+int(startsOff)+4+int(s)*4:]) + if so == 0 { + continue + } + b := h + int(startsOff) + int(so) + pageSize := binary.LittleEndian.Uint16(data[b+4:]) + ptrFormat := binary.LittleEndian.Uint16(data[b+6:]) + segmentOffset := binary.LittleEndian.Uint64(data[b+8:]) + pageCount := binary.LittleEndian.Uint16(data[b+20:]) + starts := make([]uint16, pageCount) + for p := 0; p < int(pageCount); p++ { + starts[p] = binary.LittleEndian.Uint16(data[b+22+p*2:]) + } + segs = append(segs, fixupSeg{ + pageSize: uint32(pageSize), + ptrFormat: ptrFormat, + segmentOffset: segmentOffset, + pageStarts: starts, + }) + } + return imports, segs, nil +} + +// ApplyFixups patches the image buffer in place: rebases -> base+target+(high8<<56), +// binds -> importAddrs[ordinal]+addend. Each slot is read before it is overwritten +// so the chain's next-stride stays valid. Returns (rebases, binds). +func ApplyFixups(image []byte, segs []fixupSeg, base uint64, importAddrs []uint64) (int, int, error) { + rebases, binds := 0, 0 + for _, seg := range segs { + if seg.ptrFormat != dyldChainedPtr64 && seg.ptrFormat != dyldChainedPtr64Offset { + return 0, 0, fmt.Errorf("unsupported chained pointer format %d", seg.ptrFormat) + } + for pageIndex, start := range seg.pageStarts { + if start == chainedPtrStartNone { + continue + } + cur := seg.segmentOffset + uint64(pageIndex)*uint64(seg.pageSize) + uint64(start) + for { + if cur+8 > uint64(len(image)) { + return 0, 0, fmt.Errorf("fixup slot %#x out of image", cur) + } + v := binary.LittleEndian.Uint64(image[cur:]) + isBind := (v >> 63) & 1 + next := (v >> 51) & 0xFFF // stride to next fixup, in 4-byte units + var newval uint64 + if isBind == 1 { // {ordinal:24, addend:8, reserved:19, next:12, bind:1} + ordinal := v & 0xFFFFFF + addend := (v >> 24) & 0xFF + if ordinal >= uint64(len(importAddrs)) { + return 0, 0, fmt.Errorf("bind ordinal %d out of range", ordinal) + } + newval = importAddrs[ordinal] + addend + binds++ + } else { // {target:36, high8:8, reserved:7, next:12, bind:1} + target := v & 0xFFFFFFFFF + high8 := (v >> 36) & 0xFF + newval = base + target + (high8 << 56) + rebases++ + } + binary.LittleEndian.PutUint64(image[cur:], newval) + if next == 0 { + break + } + cur += next * 4 + } + } + } + return rebases, binds, nil +} diff --git a/devices/ios/machomap/macho.go b/devices/ios/machomap/macho.go new file mode 100644 index 00000000..a67ce9f8 --- /dev/null +++ b/devices/ios/machomap/macho.go @@ -0,0 +1,180 @@ +// Package machomap maps a precompiled arm64 Mach-O dylib into a running +// get-task-allow process over the on-device debugserver, with no code signing +// and no re-signing of the dylib or the app. The CS_DEBUGGED exemption (granted +// to a process an attached debugger controls) lets the debugger execute pages it +// wrote itself, so we can map, fix up, and run an unsigned dylib the same way a +// native loader would — the primitive mobilecli needs to run its instrumentation +// agent on real devices and on AWS Device Farm, where dlopen is impossible. +// +// This file parses the thin arm64 Mach-O: segments, symbols, sections, the +// offset-based initializer sections, and the offset of the chained-fixups blob. +// It is a direct port of the proven Python prototype (inject_map.py). +package machomap + +import ( + "encoding/binary" + "fmt" +) + +const ( + machoMagic64 = 0xFEEDFACF + lcSegment64 = 0x19 + lcSymtab = 0x02 + lcDyldChainedFixups = 0x80000034 + sInitFuncOffsets = 0x16 // section flags low byte: offsets (not pointers) to initializers +) + +// Segment is one LC_SEGMENT_64. InitProt's write bit decides which segments are +// remapped read-write after the whole image is reserved executable. +type Segment struct { + Name string + VMAddr uint64 + VMSize uint64 + FileOff uint64 + FileSize uint64 + InitProt int32 +} + +// Section records a section's vmaddr and size, keyed by name in Macho.Sections +// (e.g. __objc_classlist, __objc_selrefs, __objc_imageinfo). +type Section struct { + Addr uint64 + Size uint64 +} + +// InitSection is an S_INIT_FUNC_OFFSETS section: each uint32 is an offset from +// the image base to an initializer (the agent's C constructor lives here). +type InitSection struct { + Addr uint64 + Size uint64 +} + +// Macho is the parsed subset of a thin arm64 Mach-O the mapper needs. +type Macho struct { + Data []byte + Segments []Segment + Symbols map[string]uint64 // name -> n_value (unslid) + Sections map[string]Section + InitSections []InitSection + ChainedOff uint32 // file offset of the LC_DYLD_CHAINED_FIXUPS blob, 0 if none + HasChained bool +} + +// ParseMacho parses a thin arm64 64-bit Mach-O image. +func ParseMacho(data []byte) (*Macho, error) { + if len(data) < 32 || binary.LittleEndian.Uint32(data) != machoMagic64 { + return nil, fmt.Errorf("expected a thin arm64 64-bit Mach-O") + } + m := &Macho{ + Data: data, + Symbols: map[string]uint64{}, + Sections: map[string]Section{}, + } + ncmds := binary.LittleEndian.Uint32(data[16:]) + off := 32 + for i := uint32(0); i < ncmds; i++ { + if off+8 > len(data) { + return nil, fmt.Errorf("load command %d out of range", i) + } + cmd := binary.LittleEndian.Uint32(data[off:]) + size := binary.LittleEndian.Uint32(data[off+4:]) + if size == 0 || off+int(size) > len(data) { + return nil, fmt.Errorf("bad load command size at %d", off) + } + switch cmd { + case lcDyldChainedFixups: + m.ChainedOff = binary.LittleEndian.Uint32(data[off+8:]) + m.HasChained = true + case lcSegment64: + seg := Segment{ + Name: cstr16(data[off+8 : off+24]), + VMAddr: binary.LittleEndian.Uint64(data[off+24:]), + VMSize: binary.LittleEndian.Uint64(data[off+32:]), + FileOff: binary.LittleEndian.Uint64(data[off+40:]), + FileSize: binary.LittleEndian.Uint64(data[off+48:]), + InitProt: int32(binary.LittleEndian.Uint32(data[off+60:])), + } + m.Segments = append(m.Segments, seg) + nsects := binary.LittleEndian.Uint32(data[off+64:]) + soff := off + 72 + for s := uint32(0); s < nsects; s++ { + sectname := cstr16(data[soff : soff+16]) + addr := binary.LittleEndian.Uint64(data[soff+32:]) + ssize := binary.LittleEndian.Uint64(data[soff+40:]) + flags := binary.LittleEndian.Uint32(data[soff+64:]) + if flags&0xFF == sInitFuncOffsets { + m.InitSections = append(m.InitSections, InitSection{Addr: addr, Size: ssize}) + } + m.Sections[sectname] = Section{Addr: addr, Size: ssize} + soff += 80 + } + case lcSymtab: + symoff := binary.LittleEndian.Uint32(data[off+8:]) + nsyms := binary.LittleEndian.Uint32(data[off+12:]) + stroff := binary.LittleEndian.Uint32(data[off+16:]) + strsize := binary.LittleEndian.Uint32(data[off+20:]) + for n := uint32(0); n < nsyms; n++ { + base := int(symoff) + int(n)*16 + if base+16 > len(data) { + break + } + strx := binary.LittleEndian.Uint32(data[base:]) + value := binary.LittleEndian.Uint64(data[base+8:]) + name := cstrAt(data, int(stroff)+int(strx), int(stroff)+int(strsize)) + if name != "" { + m.Symbols[name] = value + } + } + } + off += int(size) + } + return m, nil +} + +// BuildImage lays the mapped segments out contiguously at their vmaddrs +// (__LINKEDIT is not mapped) and returns the image buffer, its size, and the +// mapped segments. +func (m *Macho) BuildImage() (image []byte, imageSize uint64, mapped []Segment) { + for _, s := range m.Segments { + if s.Name == "__LINKEDIT" { + continue + } + mapped = append(mapped, s) + if s.VMAddr+s.VMSize > imageSize { + imageSize = s.VMAddr + s.VMSize + } + } + image = make([]byte, imageSize) + for _, s := range mapped { + copy(image[s.VMAddr:s.VMAddr+s.FileSize], m.Data[s.FileOff:s.FileOff+s.FileSize]) + } + return image, imageSize, mapped +} + +// SectionCount returns the number of 8-byte pointer slots in a section (0 if +// the section is absent) — used for __objc_classlist / __objc_selrefs / etc. +func (m *Macho) SectionCount(name string) uint64 { + if s, ok := m.Sections[name]; ok { + return s.Size / 8 + } + return 0 +} + +func cstr16(b []byte) string { + n := 0 + for n < len(b) && b[n] != 0 { + n++ + } + return string(b[:n]) +} + +func cstrAt(data []byte, start, limit int) string { + if start < 0 || start >= len(data) || start >= limit { + return "" + } + end := start + for end < limit && end < len(data) && data[end] != 0 { + end++ + } + return string(data[start:end]) +} diff --git a/devices/ios/machomap/macho_test.go b/devices/ios/machomap/macho_test.go new file mode 100644 index 00000000..7c23dd2c --- /dev/null +++ b/devices/ios/machomap/macho_test.go @@ -0,0 +1,83 @@ +package machomap + +import ( + "encoding/hex" + "os" + "testing" +) + +// theGoParserReproducesThePythonPrototypeCounts loads the real network agent +// dylib and checks that the ported Mach-O/fixup logic yields exactly the numbers +// the proven Python prototype (inject_map.py) printed on-device: +// +// image_size=0x1c000 rebases=483 binds=244 imports=97 +// selrefs=184 classes=5 categories=2 initializers=1 +// +// Set AGENT_DYLIB to override the path (default /tmp/agent-dev.dylib). The test +// skips if the dylib is not present so CI without it stays green. +func TestGoParserMatchesPythonCounts(t *testing.T) { + path := os.Getenv("AGENT_DYLIB") + if path == "" { + path = "/tmp/agent-dev.dylib" + } + data, err := os.ReadFile(path) + if err != nil { + t.Skipf("agent dylib not available (%v); set AGENT_DYLIB to run", err) + } + + m, err := ParseMacho(data) + if err != nil { + t.Fatalf("ParseMacho: %v", err) + } + image, imageSize, _ := m.BuildImage() + if imageSize != 0x1c000 { + t.Errorf("image size = %#x, want 0x1c000", imageSize) + } + if !m.HasChained { + t.Fatal("expected chained fixups") + } + + imports, segs, err := ParseChainedFixups(data, m.ChainedOff) + if err != nil { + t.Fatalf("ParseChainedFixups: %v", err) + } + if len(imports) != 97 { + t.Errorf("imports = %d, want 97", len(imports)) + } + // dummy import addresses just to exercise the walker and count rebases/binds + rebases, binds, err := ApplyFixups(image, segs, 0x100000000, make([]uint64, len(imports))) + if err != nil { + t.Fatalf("ApplyFixups: %v", err) + } + if rebases != 483 { + t.Errorf("rebases = %d, want 483", rebases) + } + if binds != 244 { + t.Errorf("binds = %d, want 244", binds) + } + + if got := m.SectionCount("__objc_selrefs"); got != 184 { + t.Errorf("selrefs = %d, want 184", got) + } + if got := m.SectionCount("__objc_classlist"); got != 5 { + t.Errorf("classes = %d, want 5", got) + } + if got := m.SectionCount("__objc_catlist"); got != 2 { + t.Errorf("categories = %d, want 2", got) + } + if len(m.InitSections) != 1 || m.InitSections[0].Size != 4 { + t.Errorf("init sections = %+v, want one of size 4", m.InitSections) + } +} + +// TestStubBytesDecode guards the embedded stub machine code: length and the +// first instruction (stp x19,x20,[sp,#-64]! = 0xa9bc53f3) must be intact. +func TestStubBytesDecode(t *testing.T) { + if len(StubBytes) != 256 { + t.Fatalf("stub bytes = %d, want 256", len(StubBytes)) + } + first := hex.EncodeToString(StubBytes[:4]) + if first != "f353bca9" { + t.Errorf("first stub word = %s, want f353bca9", first) + } +} diff --git a/devices/ios/machomap/mapper.go b/devices/ios/machomap/mapper.go new file mode 100644 index 00000000..05685b10 --- /dev/null +++ b/devices/ios/machomap/mapper.go @@ -0,0 +1,229 @@ +package machomap + +import ( + "encoding/binary" + "fmt" +) + +// mmap/mprotect protection and flag bits, and the W^X-driven mapping strategy: +// iOS fixes a mapping's max_protection at mmap time and forbids a page that is +// both writable and executable, so we reserve the whole image r-x (executable) +// and MAP_FIXED-remap the writable segments rw; the debugger writes the r-x code +// pages itself. +const ( + protRead = 1 + protWrite = 2 + protExec = 4 + vmProtWrite = 2 + mapPrivate = 0x0002 + mapAnon = 0x1000 + mapFixed = 0x0010 + neg1 = ^uint64(0) // -1 (mmap fd, and RTLD_DEFAULT base below) + rtldDefault = ^uint64(0) - 1 +) + +// bootstrapNames are the runtime helpers the mapper itself needs; the Resolver +// supplies their in-target addresses without any remote round-trips. +var bootstrapNames = []string{"mmap", "mprotect", "dlsym", "sel_registerName", "objc_readClassPair"} + +// Resolver resolves symbol names to their runtime addresses in the target +// process. Two implementations exist: host Xcode symbol files + shared-cache +// slide (fast, Mac-only — for parity testing), and an in-memory export-trie +// reader (host-independent — for AWS Device Farm). +type Resolver interface { + Resolve(names []string) (map[string]uint64, error) +} + +// Options controls the map. +type Options struct { + RunInit bool // run the dylib's offset-based initializers (starts the agent) + WriteChunk int // M-packet chunk size; 0 => 1024 +} + +// Result reports what was mapped. +type Result struct { + Base uint64 + ImageSize uint64 + Rebases int + Binds int + Imports int + Selrefs int + Classes int + Unresolved []string +} + +// Map maps dylib into the already-attached process behind c and executes its +// initializers (unless Options.RunInit is false). The process must be attached +// (QSetDetachOnError + vAttach) by the caller; Map borrows a thread and restores +// it before returning. +func Map(c *RSP, dylib []byte, res Resolver, opt Options) (*Result, error) { + m, err := ParseMacho(dylib) + if err != nil { + return nil, err + } + image, imageSize, mapped := m.BuildImage() + chunk := opt.WriteChunk + if chunk == 0 { + chunk = 1024 + } + + helpers, err := res.Resolve(bootstrapNames) + if err != nil { + return nil, fmt.Errorf("resolve bootstrap helpers: %w", err) + } + for _, n := range bootstrapNames { + if helpers[n] == 0 { + return nil, fmt.Errorf("bootstrap helper %q unresolved", n) + } + } + + t, err := NewTarget(c) + if err != nil { + return nil, err + } + defer func() { _ = t.Restore() }() + + mmap := helpers["mmap"] + res0 := &Result{ImageSize: imageSize} + + // 1. reserve the image r-x + base, err := t.Call(mmap, 0, imageSize, protRead|protExec, mapAnon|mapPrivate, neg1, 0) + if err != nil { + return nil, err + } + if base == neg1 { + return nil, fmt.Errorf("reserve mmap failed (MAP_FAILED)") + } + res0.Base = base + + // 2. MAP_FIXED-remap writable segments rw + for _, s := range mapped { + if s.Name != "__TEXT" && s.InitProt&vmProtWrite != 0 { + got, err := t.Call(mmap, base+s.VMAddr, s.VMSize, protRead|protWrite, + mapAnon|mapPrivate|mapFixed, neg1, 0) + if err != nil { + return nil, err + } + if got != base+s.VMAddr { + return nil, fmt.Errorf("MAP_FIXED %s @ %#x -> %#x", s.Name, base+s.VMAddr, got) + } + } + } + + // 3. RW scratch for staged tables, and an r-x page for the loop stubs + scratch, err := t.Call(mmap, 0, 0x10000, protRead|protWrite, mapAnon|mapPrivate, neg1, 0) + if err != nil { + return nil, err + } + stubMem, err := t.Call(mmap, 0, 0x4000, protRead|protExec, mapAnon|mapPrivate, neg1, 0) + if err != nil { + return nil, err + } + if err := c.WriteMem(stubMem, StubBytes, chunk); err != nil { + return nil, err + } + cur := scratch + alloc := func(size uint64) uint64 { + cur = (cur + 7) &^ 7 + p := cur + cur += size + return p + } + + // 4. resolve all imports in ONE call via rstub_dlsym, then apply fixups locally + if m.HasChained { + imports, segs, err := ParseChainedFixups(dylib, m.ChainedOff) + if err != nil { + return nil, err + } + res0.Imports = len(imports) + // stage import name strings (one write) + a pointer table + an output table + var blob []byte + offs := make([]int, len(imports)) + for i, n := range imports { + offs[i] = len(blob) + blob = append(blob, stripOneUnderscore(n)...) + blob = append(blob, 0) + } + blobBase := alloc(uint64(len(blob))) + if err := c.WriteMem(blobBase, blob, chunk); err != nil { + return nil, err + } + namesTbl := alloc(uint64(len(imports)) * 8) + tbl := make([]byte, len(imports)*8) + for i := range imports { + binary.LittleEndian.PutUint64(tbl[i*8:], blobBase+uint64(offs[i])) + } + if err := c.WriteMem(namesTbl, tbl, chunk); err != nil { + return nil, err + } + outTbl := alloc(uint64(len(imports)) * 8) + if _, err := t.Call(stubMem+StubDlsymOff, helpers["dlsym"], uint64(len(imports)), + namesTbl, outTbl, rtldDefault); err != nil { + return nil, err + } + raw, err := c.Memory(outTbl, uint64(len(imports))*8) + if err != nil { + return nil, err + } + importAddrs := make([]uint64, len(imports)) + for i := range imports { + importAddrs[i] = binary.LittleEndian.Uint64(raw[i*8:]) + if importAddrs[i] == 0 { + res0.Unresolved = append(res0.Unresolved, imports[i]) + } + } + rb, bn, err := ApplyFixups(image, segs, base, importAddrs) + if err != nil { + return nil, err + } + res0.Rebases, res0.Binds = rb, bn + } + + // 5. write the fixed-up image + if err := c.WriteMem(base, image, chunk); err != nil { + return nil, err + } + + // 6. register ObjC metadata (selrefs then classes), one stub call each + if n := m.SectionCount("__objc_classlist"); n > 0 { + if sel := m.SectionCount("__objc_selrefs"); sel > 0 { + sr := m.Sections["__objc_selrefs"] + if _, err := t.Call(stubMem+StubSelrefOff, helpers["sel_registerName"], sel, base+sr.Addr); err != nil { + return nil, err + } + res0.Selrefs = int(sel) + } + cl := m.Sections["__objc_classlist"] + var imageinfo uint64 + if ii, ok := m.Sections["__objc_imageinfo"]; ok { + imageinfo = base + ii.Addr + } + if _, err := t.Call(stubMem+StubClassOff, helpers["objc_readClassPair"], n, base+cl.Addr, imageinfo); err != nil { + return nil, err + } + res0.Classes = int(n) + } + + // 7. run initializers (starts the agent) unless suppressed + if opt.RunInit { + for _, is := range m.InitSections { + for o := uint64(0); o < is.Size; o += 4 { + initOff := uint64(binary.LittleEndian.Uint32(image[is.Addr+o:])) + if _, err := t.Call(base + initOff); err != nil { + return nil, err + } + } + } + } + + return res0, nil +} + +// stripOneUnderscore removes exactly one leading linker underscore (not all). +func stripOneUnderscore(s string) string { + if len(s) > 0 && s[0] == '_' { + return s[1:] + } + return s +} diff --git a/devices/ios/machomap/resolver_hostfiles.go b/devices/ios/machomap/resolver_hostfiles.go new file mode 100644 index 00000000..3f9d2d96 --- /dev/null +++ b/devices/ios/machomap/resolver_hostfiles.go @@ -0,0 +1,133 @@ +package machomap + +import ( + "encoding/binary" + "fmt" + "os" + "strings" +) + +// HostFilesResolver resolves symbols from on-disk Xcode device-symbol Mach-O +// files plus the single shared-cache slide read from the device. This is the +// fast Mac-only resolver used to prove the Go mapper matches the Python +// prototype; AWS Device Farm uses the host-independent export-trie resolver. +type HostFilesResolver struct { + c *RSP + files []string + slide uint64 + haveSlide bool +} + +// NewHostFilesResolver builds a resolver over the standard device-symbol layout +// under symbolRoot (…/Symbols/usr/lib). +func NewHostFilesResolver(c *RSP, symbolRoot string) *HostFilesResolver { + return &HostFilesResolver{ + c: c, + files: []string{ + symbolRoot + "/system/libsystem_kernel.dylib", + symbolRoot + "/system/libdyld.dylib", + symbolRoot + "/libobjc.A.dylib", + }, + } +} + +// Resolve returns runtime addresses for names, searching each symbol file's +// LC_SYMTAB for "_name" and adding the shared-cache slide. +func (r *HostFilesResolver) Resolve(names []string) (map[string]uint64, error) { + if !r.haveSlide { + slide, err := readSharedCacheSlide(r.c) + if err != nil { + return nil, err + } + r.slide, r.haveSlide = slide, true + } + want := map[string]bool{} + for _, n := range names { + want["_"+n] = true + } + found := map[string]uint64{} + for _, f := range r.files { + data, err := os.ReadFile(f) + if err != nil { + continue // not every name lives in every file + } + scanSymtab(data, want, func(name string, value uint64) { + found[strings.TrimPrefix(name, "_")] = value + r.slide + }) + } + var missing []string + for _, n := range names { + if _, ok := found[n]; !ok { + missing = append(missing, n) + } + } + if len(missing) > 0 { + return nil, fmt.Errorf("symbols not found in host symbol files: %v", missing) + } + return found, nil +} + +// readSharedCacheSlide reads dyld_all_image_infos and returns the shared-cache +// slide (offset 152), requiring dyld info version >= 15 and libSystemInitialized. +func readSharedCacheSlide(c *RSP) (uint64, error) { + resp, err := c.Request("qShlibInfoAddr") + if err != nil { + return 0, err + } + var addr uint64 + if _, err := fmt.Sscanf(resp, "%x", &addr); err != nil { + return 0, fmt.Errorf("qShlibInfoAddr %q: %w", resp, err) + } + info, err := c.Memory(addr, 184) + if err != nil { + return 0, err + } + if binary.LittleEndian.Uint32(info) < 15 { + return 0, fmt.Errorf("unsupported dyld info layout") + } + if info[25] == 0 { + return 0, fmt.Errorf("libSystem not initialized yet") + } + return binary.LittleEndian.Uint64(info[152:]), nil +} + +// scanSymtab walks a thin Mach-O's LC_SYMTAB and calls hit(name, value) for each +// wanted symbol with a nonzero value. +func scanSymtab(data []byte, want map[string]bool, hit func(name string, value uint64)) { + if len(data) < 32 || binary.LittleEndian.Uint32(data) != machoMagic64 { + return + } + ncmds := binary.LittleEndian.Uint32(data[16:]) + off := 32 + for i := uint32(0); i < ncmds; i++ { + if off+8 > len(data) { + return + } + cmd := binary.LittleEndian.Uint32(data[off:]) + size := binary.LittleEndian.Uint32(data[off+4:]) + if size == 0 { + return + } + if cmd == lcSymtab { + symoff := binary.LittleEndian.Uint32(data[off+8:]) + nsyms := binary.LittleEndian.Uint32(data[off+12:]) + stroff := binary.LittleEndian.Uint32(data[off+16:]) + for n := uint32(0); n < nsyms; n++ { + base := int(symoff) + int(n)*16 + if base+16 > len(data) { + break + } + strx := binary.LittleEndian.Uint32(data[base:]) + value := binary.LittleEndian.Uint64(data[base+8:]) + if value == 0 { + continue + } + name := cstrAt(data, int(stroff)+int(strx), len(data)) + if want[name] { + hit(name, value) + } + } + } + off += int(size) + } +} diff --git a/devices/ios/machomap/rsp.go b/devices/ios/machomap/rsp.go new file mode 100644 index 00000000..d5c17fc4 --- /dev/null +++ b/devices/ios/machomap/rsp.go @@ -0,0 +1,210 @@ +package machomap + +import ( + "bufio" + "encoding/binary" + "encoding/hex" + "fmt" + "io" + "strings" +) + +// RSP is a GDB Remote Serial Protocol client for the on-device debugserver. It +// differs from devices/ios/debugserver.GDBServer in that it fully decodes +// responses — run-length compression ('*') and byte escaping ('}') — which is +// required when we consume register and memory reads ourselves rather than +// forwarding raw bytes to an external lldb. Mirrors the proven raw_attach.py. +type RSP struct { + w io.Writer + r *bufio.Reader + noAck bool +} + +// NewRSP negotiates no-ack mode on an already-connected debugserver stream. +func NewRSP(rw io.ReadWriter) (*RSP, error) { + c := &RSP{w: rw, r: bufio.NewReaderSize(rw, 1<<16)} + if _, err := c.w.Write([]byte("+")); err != nil { + return nil, err + } + resp, err := c.Request("QStartNoAckMode") + if err != nil { + return nil, err + } + if resp != "OK" { + return nil, fmt.Errorf("no-ack handshake failed: %q", resp) + } + c.noAck = true + return c, nil +} + +// Request sends a packet and returns the decoded response payload, skipping +// asynchronous console-output ('O...') packets. +func (c *RSP) Request(pkt string) (string, error) { + if err := c.send(pkt); err != nil { + return "", err + } + for { + resp, err := c.read() + if err != nil { + return "", err + } + if len(resp) > 1 && resp[0] == 'O' && resp != "OK" { + continue // console output; ignore + } + return resp, nil + } +} + +// OK sends a packet and requires the "OK" response. +func (c *RSP) OK(pkt string) error { + resp, err := c.Request(pkt) + if err != nil { + return err + } + if resp != "OK" { + return fmt.Errorf("%.40s: %q", pkt, resp) + } + return nil +} + +func (c *RSP) send(pkt string) error { + var b strings.Builder + b.WriteByte('$') + sum := byte(0) + for i := 0; i < len(pkt); i++ { + ch := pkt[i] + if ch == '#' || ch == '$' || ch == '*' || ch == '}' { + b.WriteByte('}') + b.WriteByte(ch ^ 0x20) + sum += '}' + (ch ^ 0x20) + } else { + b.WriteByte(ch) + sum += ch + } + } + fmt.Fprintf(&b, "#%02x", sum) + _, err := c.w.Write([]byte(b.String())) + return err +} + +// read reads one '$...#xx' packet and returns its decoded payload. +func (c *RSP) read() (string, error) { + // skip to '$' + for { + ch, err := c.r.ReadByte() + if err != nil { + return "", err + } + if ch == '$' { + break + } + } + var wire []byte + for { + ch, err := c.r.ReadByte() + if err != nil { + return "", err + } + if ch == '#' { + break + } + wire = append(wire, ch) + } + // consume the 2 checksum hex chars (not validated in no-ack mode, like raw_attach) + if _, err := c.r.Discard(2); err != nil { + return "", err + } + if !c.noAck { + if _, err := c.w.Write([]byte("+")); err != nil { + return "", err + } + } + return decodeRSP(wire), nil +} + +// decodeRSP expands '}' escaping and '*' run-length compression. +func decodeRSP(wire []byte) string { + out := make([]byte, 0, len(wire)) + for i := 0; i < len(wire); i++ { + ch := wire[i] + switch ch { + case '}': + i++ + out = append(out, wire[i]^0x20) + case '*': + i++ + repeat := int(wire[i]) - 29 + last := out[len(out)-1] + for r := 0; r < repeat; r++ { + out = append(out, last) + } + default: + out = append(out, ch) + } + } + return string(out) +} + +// Memory reads size bytes at addr. +func (c *RSP) Memory(addr, size uint64) ([]byte, error) { + resp, err := c.Request(fmt.Sprintf("m%x,%x", addr, size)) + if err != nil { + return nil, err + } + if strings.HasPrefix(resp, "E") { + return nil, fmt.Errorf("memory read @ %#x failed: %q", addr, resp) + } + data, err := hex.DecodeString(resp) + if err != nil { + return nil, fmt.Errorf("memory read @ %#x not hex: %w", addr, err) + } + if uint64(len(data)) != size { + return nil, fmt.Errorf("short memory read @ %#x: got %d want %d", addr, len(data), size) + } + return data, nil +} + +// WriteMem writes blob at addr in chunks (default 1024). The debugger can write +// pages the process itself cannot (e.g. r-x), the same W^X bypass it uses for +// breakpoints. +func (c *RSP) WriteMem(addr uint64, blob []byte, chunk int) error { + if chunk <= 0 { + chunk = 1024 + } + for i := 0; i < len(blob); i += chunk { + end := i + chunk + if end > len(blob) { + end = len(blob) + } + part := blob[i:end] + if err := c.OK(fmt.Sprintf("M%x,%x:%s", addr+uint64(i), len(part), hex.EncodeToString(part))); err != nil { + return err + } + } + return nil +} + +// ReadReg reads a register by DWARF-ish number on a thread. +func (c *RSP) ReadReg(num int, thread uint64) (uint64, error) { + resp, err := c.Request(fmt.Sprintf("p%x;thread:%x;", num, thread)) + if err != nil { + return 0, err + } + if strings.HasPrefix(resp, "E") || resp == "" { + return 0, fmt.Errorf("read reg %d: %q", num, resp) + } + raw, err := hex.DecodeString(resp) + if err != nil { + return 0, fmt.Errorf("read reg %d not hex: %w", num, err) + } + var buf [8]byte + copy(buf[:], raw) // little-endian, may be shorter + return binary.LittleEndian.Uint64(buf[:]), nil +} + +// WriteReg sets a register by number on a thread. +func (c *RSP) WriteReg(num int, thread, value uint64) error { + var buf [8]byte + binary.LittleEndian.PutUint64(buf[:], value) + return c.OK(fmt.Sprintf("P%x=%s;thread:%x;", num, hex.EncodeToString(buf[:]), thread)) +} diff --git a/devices/ios/machomap/stubs.go b/devices/ios/machomap/stubs.go new file mode 100644 index 00000000..4991897e --- /dev/null +++ b/devices/ios/machomap/stubs.go @@ -0,0 +1,42 @@ +package machomap + +import "encoding/hex" + +// In-process loop stubs (source: stubs_arm64.asm in this package — named .asm so +// the Go toolchain ignores it; identical to the prototype's stubs.s). Each +// replaces a per-element vCont round-trip with a +// single call that loops inside the target, collapsing ~286 remote calls to ~8 +// and taking the map step from ~8s to ~0.4s: +// +// rstub_dlsym (x0=dlsym, x1=count, x2=names[], x3=out[], x4=handle): out[i]=dlsym(handle,names[i]) +// rstub_selref(x0=sel_registerName, x1=count, x2=slots): slots[i]=sel_registerName(slots[i]) +// rstub_class (x0=readClassPair, x1=count, x2=classlist, x3=imginfo): readClassPair(classlist[i],imginfo) +// +// Regenerate: clang -arch arm64 -c stubs_arm64.asm -o stubs.o && extract __TEXT,__text. +const stubHex = "" + + "f353bca9f55b01a9f76302a9fd7b03a9f30300aaf40301aaf50302aaf60303aa" + + "f70304aa180080d21f0314ebea000054e00317aaa17a78f860023fd6c07a38f8" + + "18070091f9ffff17fd7b43a9f76342a9f55b41a9f353c4a8c0035fd6f353bca9" + + "f55b01a9f76302a9fd7b03a9f30300aaf40301aaf50302aa180080d21f0314eb" + + "ca000054a07a78f860023fd6a07a38f818070091faffff17fd7b43a9f76342a9" + + "f55b41a9f353c4a8c0035fd6f353bca9f55b01a9f76302a9fd7b03a9f30300aa" + + "f40301aaf50302aaf60303aa180080d21f0314ebca000054a07a78f8e10316aa" + + "60023fd618070091faffff17fd7b43a9f76342a9f55b41a9f353c4a8c0035fd6" + +// StubBytes is the assembled machine code for the three loop stubs. +var StubBytes = mustHex(stubHex) + +// Offsets of each stub within StubBytes. +const ( + StubDlsymOff = 0x00 + StubSelrefOff = 0x5c + StubClassOff = 0xac +) + +func mustHex(s string) []byte { + b, err := hex.DecodeString(s) + if err != nil { + panic(err) + } + return b +} diff --git a/devices/ios/machomap/stubs_arm64.asm b/devices/ios/machomap/stubs_arm64.asm new file mode 100644 index 00000000..9054e87f --- /dev/null +++ b/devices/ios/machomap/stubs_arm64.asm @@ -0,0 +1,91 @@ +// In-process loop stubs for the signing-free mapper. +// Each is called ONCE via the debugger instead of one vCont round-trip per element. +// Uniform frame: save x19-x24, x29/x30 (64 bytes). + +.text +.align 2 + +// rstub_dlsym(x0=fn_dlsym, x1=count, x2=names[], x3=out[], x4=handle) +// for i in 0..count: out[i] = dlsym(handle, names[i]) +.globl _rstub_dlsym +_rstub_dlsym: + stp x19, x20, [sp, #-64]! + stp x21, x22, [sp, #16] + stp x23, x24, [sp, #32] + stp x29, x30, [sp, #48] + mov x19, x0 // fn + mov x20, x1 // count + mov x21, x2 // names + mov x22, x3 // out + mov x23, x4 // handle + mov x24, #0 // i +1: + cmp x24, x20 + b.ge 2f + mov x0, x23 + ldr x1, [x21, x24, lsl #3] + blr x19 + str x0, [x22, x24, lsl #3] + add x24, x24, #1 + b 1b +2: + ldp x29, x30, [sp, #48] + ldp x23, x24, [sp, #32] + ldp x21, x22, [sp, #16] + ldp x19, x20, [sp], #64 + ret + +// rstub_selref(x0=fn_sel, x1=count, x2=slots) +// for i: slots[i] = sel_registerName(slots[i]) // slot holds the name ptr, replaced by SEL +.globl _rstub_selref +_rstub_selref: + stp x19, x20, [sp, #-64]! + stp x21, x22, [sp, #16] + stp x23, x24, [sp, #32] + stp x29, x30, [sp, #48] + mov x19, x0 // fn + mov x20, x1 // count + mov x21, x2 // slots + mov x24, #0 // i +1: + cmp x24, x20 + b.ge 2f + ldr x0, [x21, x24, lsl #3] + blr x19 + str x0, [x21, x24, lsl #3] + add x24, x24, #1 + b 1b +2: + ldp x29, x30, [sp, #48] + ldp x23, x24, [sp, #32] + ldp x21, x22, [sp, #16] + ldp x19, x20, [sp], #64 + ret + +// rstub_class(x0=fn_readpair, x1=count, x2=classlist, x3=imageinfo) +// for i: objc_readClassPair(classlist[i], imageinfo) +.globl _rstub_class +_rstub_class: + stp x19, x20, [sp, #-64]! + stp x21, x22, [sp, #16] + stp x23, x24, [sp, #32] + stp x29, x30, [sp, #48] + mov x19, x0 // fn + mov x20, x1 // count + mov x21, x2 // classlist + mov x22, x3 // imageinfo + mov x24, #0 // i +1: + cmp x24, x20 + b.ge 2f + ldr x0, [x21, x24, lsl #3] + mov x1, x22 + blr x19 + add x24, x24, #1 + b 1b +2: + ldp x29, x30, [sp, #48] + ldp x23, x24, [sp, #32] + ldp x21, x22, [sp, #16] + ldp x19, x20, [sp], #64 + ret diff --git a/devices/ios/machomap/target.go b/devices/ios/machomap/target.go new file mode 100644 index 00000000..bd9ef6e4 --- /dev/null +++ b/devices/ios/machomap/target.go @@ -0,0 +1,121 @@ +package machomap + +import ( + "fmt" + "strings" +) + +// ARM64 register numbers as debugserver exposes them. +const ( + regLR = 30 + regSP = 31 + regPC = 32 +) + +// Target borrows a stopped thread in the attached process so we can call +// functions in it: save its registers, back up a stack window, and plant a +// breakpoint at a known return address. Restore() puts everything back. +type Target struct { + c *RSP + thread uint64 + saved string + returnPC uint64 + sp uint64 + stackLo uint64 + stackBackup []byte +} + +// NewTarget borrows the first enumerated thread (the main thread on the apps we +// target) and prepares it for calls. +func NewTarget(c *RSP) (*Target, error) { + if err := c.OK("QThreadSuffixSupported"); err != nil { + return nil, err + } + threads, err := c.Request("qfThreadInfo") + if err != nil { + return nil, err + } + if !strings.HasPrefix(threads, "m") { + return nil, fmt.Errorf("cannot enumerate threads: %q", threads) + } + var thread uint64 + if _, err := fmt.Sscanf(strings.Split(threads[1:], ",")[0], "%x", &thread); err != nil { + return nil, fmt.Errorf("parse thread id %q: %w", threads, err) + } + t := &Target{c: c, thread: thread} + t.saved, err = c.Request(fmt.Sprintf("QSaveRegisterState;thread:%x;", thread)) + if err != nil { + return nil, err + } + if !isDigits(t.saved) { + return nil, fmt.Errorf("cannot save registers: %q", t.saved) + } + if t.returnPC, err = c.ReadReg(regPC, thread); err != nil { + return nil, err + } + if t.sp, err = c.ReadReg(regSP, thread); err != nil { + return nil, err + } + t.stackLo = (t.sp - 0x8000) &^ 15 + if t.stackBackup, err = c.Memory(t.stackLo, t.sp-t.stackLo); err != nil { + return nil, err + } + if err := c.OK(fmt.Sprintf("Z0,%x,4", t.returnPC)); err != nil { + return nil, err + } + return t, nil +} + +// Call invokes fn(args...) on the borrowed thread and returns x0. LR points at +// the planted breakpoint, so the function returns into a known stop. +func (t *Target) Call(fn uint64, args ...uint64) (uint64, error) { + for i, a := range args { + if err := t.c.WriteReg(i, t.thread, a); err != nil { + return 0, err + } + } + if err := t.c.WriteReg(regLR, t.thread, t.returnPC); err != nil { + return 0, err + } + if err := t.c.WriteReg(regSP, t.thread, t.sp-0x100); err != nil { + return 0, err + } + if err := t.c.WriteReg(regPC, t.thread, fn); err != nil { + return 0, err + } + resp, err := t.c.Request(fmt.Sprintf("vCont;c:%x", t.thread)) + if err != nil { + return 0, err + } + pc, err := t.c.ReadReg(regPC, t.thread) + if err != nil { + return 0, err + } + if !strings.HasPrefix(resp, "T05") || pc != t.returnPC { + return 0, fmt.Errorf("unexpected stop calling %#x: resp=%.60q pc=%#x", fn, resp, pc) + } + return t.c.ReadReg(0, t.thread) +} + +// Restore undoes the borrow: stack window, registers, breakpoint. +func (t *Target) Restore() error { + if err := t.c.WriteMem(t.stackLo, t.stackBackup, 1024); err != nil { + return err + } + if err := t.c.OK(fmt.Sprintf("QRestoreRegisterState:%s;thread:%x;", t.saved, t.thread)); err != nil { + return err + } + return t.c.OK(fmt.Sprintf("z0,%x,4", t.returnPC)) +} + +func isDigits(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return true +} From e3a3471c0964da4b7b4bb6ffe924a143836c5f83 Mon Sep 17 00:00:00 2001 From: Mobile Kevin Bot! Date: Wed, 4 Nov 2026 20:45:25 +0100 Subject: [PATCH 07/18] ios/machomap: apply ObjC categories + host-independent export-trie resolver - exporttrie.go: resolve symbols with no host files by locating shared-cache dylibs via dyld_all_image_infos and parsing their export tries from device memory (LC_DYLD_EXPORTS_TRIE preferred over legacy LC_DYLD_INFO). Verified on iPhone12,1 iOS 26.5.2: resolves mmap/mprotect/dlsym/sel_registerName/ objc_readClassPair identically to the host-files resolver. - categories.go: apply __objc_catlist categories (objc_readClassPair handles classes only, and there is no public API to attach a category from a mapped image) by reading each category_t + small/relative method_list_t and calling class_addMethod (instance methods on the class, class methods on the metaclass). The agent's 5 category methods apply onto NSURLSession/ NSURLSessionConfiguration. - mapper: run category application before the initializer; Result.Categories. - cmd/mapprobe: --trie, --compare, --bindtest, --servertest diagnostics. Full host-independent map of the network agent verified end-to-end: 483 rebases, 244 binds, 97 imports, 184 selrefs, 5 classes, 5 category methods; initializer (_on_load) runs and installs the NSURLProtocol swizzle. Open item: the agent's control server (MobileServer) does not retain its bound port after on_load; raw socket/bind/listen in the target all succeed, so this is agent/invocation-side, not a mapper issue. --- devices/ios/machomap/categories.go | 118 ++++++++++++++++++++++ devices/ios/machomap/cmd/mapprobe/main.go | 93 ++++++++++++++++- devices/ios/machomap/exporttrie.go | 6 ++ devices/ios/machomap/mapper.go | 15 +++ devices/ios/machomap/rsp.go | 9 ++ 5 files changed, 240 insertions(+), 1 deletion(-) create mode 100644 devices/ios/machomap/categories.go diff --git a/devices/ios/machomap/categories.go b/devices/ios/machomap/categories.go new file mode 100644 index 00000000..5b0cef6e --- /dev/null +++ b/devices/ios/machomap/categories.go @@ -0,0 +1,118 @@ +package machomap + +import ( + "encoding/binary" + "fmt" +) + +// categoryHelpers are the extra runtime functions needed only when the image has +// categories (resolved lazily so category-free dylibs don't require them). +var categoryHelpers = []string{"sel_registerName", "object_getClass", "class_addMethod"} + +// applyCategories registers each __objc_catlist category onto its target class. +// objc_readClassPair handles classes but not categories, and there is no public +// API to attach a category from a mapped image, so we read each category_t and +// its (small/relative) method lists out of the just-written image and add every +// method with class_addMethod — instance methods on the class, class methods on +// its metaclass. The target class is a bound framework class (e.g. NSURLSession), +// already realized, so adding methods is safe. Must run before the initializer, +// which may method-swizzle the methods the categories add. +func applyCategories(c *RSP, t *Target, base uint64, m *Macho, h map[string]uint64) (int, error) { + sec, ok := m.Sections["__objc_catlist"] + if !ok || sec.Size == 0 { + return 0, nil + } + selReg, getClass, addMethod := h["sel_registerName"], h["object_getClass"], h["class_addMethod"] + if selReg == 0 || getClass == 0 || addMethod == 0 { + return 0, fmt.Errorf("category helpers unresolved") + } + + applied := 0 + count := int(sec.Size / 8) + for i := 0; i < count; i++ { + // the catlist slot holds base+category_vmaddr after the image write + catAddr, err := c.readPtr(base + sec.Addr + uint64(i)*8) + if err != nil { + return applied, err + } + // category_t: name(+0) cls(+8) instanceMethods(+16) classMethods(+24) + clsAddr, err := c.readPtr(catAddr + 8) + if err != nil { + return applied, err + } + if clsAddr == 0 { + continue + } + for _, ml := range []struct { + fieldOff uint64 + isClass bool + }{{16, false}, {24, true}} { + listAddr, err := c.readPtr(catAddr + ml.fieldOff) + if err != nil { + return applied, err + } + if listAddr == 0 { + continue + } + target := clsAddr + if ml.isClass { + if target, err = t.Call(getClass, clsAddr); err != nil { // metaclass + return applied, err + } + } + n, err := applyMethodList(c, t, listAddr, target, selReg, addMethod) + if err != nil { + return applied, err + } + applied += n + } + } + return applied, nil +} + +// applyMethodList adds every method in a small (relative) method_list_t at +// listAddr to target. +func applyMethodList(c *RSP, t *Target, listAddr, target, selReg, addMethod uint64) (int, error) { + hdr, err := c.Memory(listAddr, 8) + if err != nil { + return 0, err + } + eaf := binary.LittleEndian.Uint32(hdr) + count := binary.LittleEndian.Uint32(hdr[4:]) + entsize := uint64(eaf & 0xFFFC) + if eaf&0x80000000 == 0 { + return 0, fmt.Errorf("non-relative method list (entsizeAndFlags=%#x) not supported", eaf) + } + applied := 0 + for k := uint32(0); k < count; k++ { + entry := listAddr + 8 + uint64(k)*entsize + rel, err := c.Memory(entry, 12) // {int32 name, int32 types, int32 imp}, each self-relative + if err != nil { + return applied, err + } + nameRel := int32(binary.LittleEndian.Uint32(rel)) + typesRel := int32(binary.LittleEndian.Uint32(rel[4:])) + impRel := int32(binary.LittleEndian.Uint32(rel[8:])) + nameSlot := relAddr(entry+0, nameRel) // -> selref slot (holds name cstring / SEL) + typesAddr := relAddr(entry+4, typesRel) // -> type-encoding cstring + impAddr := relAddr(entry+8, impRel) // -> IMP in our mapped __TEXT + namePtr, err := c.readPtr(nameSlot) + if err != nil { + return applied, err + } + sel, err := t.Call(selReg, namePtr) + if err != nil { + return applied, err + } + if _, err := t.Call(addMethod, target, sel, impAddr, typesAddr); err != nil { + return applied, err + } + applied++ + } + return applied, nil +} + +// relAddr adds a signed self-relative offset to a field address. +func relAddr(fieldAddr uint64, rel int32) uint64 { + return uint64(int64(fieldAddr) + int64(rel)) +} diff --git a/devices/ios/machomap/cmd/mapprobe/main.go b/devices/ios/machomap/cmd/mapprobe/main.go index 9e75ff0b..c3a9fd3d 100644 --- a/devices/ios/machomap/cmd/mapprobe/main.go +++ b/devices/ios/machomap/cmd/mapprobe/main.go @@ -27,6 +27,8 @@ func main() { runInit := flag.Bool("init", false, "run the dylib initializer (starts the agent)") compare := flag.Bool("compare", false, "resolve helpers via host-files AND export-trie and compare, then exit") useTrie := flag.Bool("trie", false, "use the export-trie (host-independent) resolver for the map") + bindtest := flag.Bool("bindtest", false, "test a loopback socket+bind in the target, then exit") + servertest := flag.Bool("servertest", false, "map, then drive MobileServer alloc/init/bind via the runtime") symbolRoot := flag.String("symbol-root", "/Users/gilm/Library/Developer/Xcode/iOS DeviceSupport/iPhone12,1 26.5.2 (23F84)/Symbols/usr/lib", "Xcode device symbols root") @@ -96,6 +98,82 @@ func main() { return } + if *servertest { + // map first so MobileServer is registered, then drive it via the runtime + if _, err := machomap.Map(c, dylib, machomap.NewExportTrieResolver(c), machomap.Options{RunInit: false}); err != nil { + fatal("map: %v", err) + } + h, err := machomap.NewExportTrieResolver(c).Resolve([]string{"mmap", "objc_getClass", "objc_msgSend", "sel_registerName"}) + check(err) + t, err := machomap.NewTarget(c) + check(err) + defer func() { _ = t.Restore() }() + scratch, err := t.Call(h["mmap"], 0, 0x1000, 3, 0x1002, ^uint64(0), 0) + check(err) + cur := scratch + put := func(s string) uint64 { + b := append([]byte(s), 0) + check(c.WriteMem(cur, b, 0)) + p := cur + cur += uint64(len(b)) + return p + } + call := func(fn uint64, a ...uint64) uint64 { v, e := t.Call(fn, a...); check(e); return v } + sel := func(s string) uint64 { return call(h["sel_registerName"], put(s)) } + he, _ := machomap.NewExportTrieResolver(c).Resolve([]string{"__error"}) + errnoNow := func() uint64 { + if ep, e := t.Call(he["__error"]); e == nil && ep != 0 { + if b, e2 := c.Memory(ep, 4); e2 == nil { + return uint64(b[0]) | uint64(b[1])<<8 + } + } + return 0 + } + cls := call(h["objc_getClass"], put("MobileServer")) + obj := call(h["objc_msgSend"], cls, sel("alloc")) + obj = call(h["objc_msgSend"], obj, sel("initWithHandler:"), 0) + bnd := call(h["objc_msgSend"], obj, sel("bind")) + bErrno := errnoNow() + port := call(h["objc_msgSend"], obj, sel("port")) + fmt.Printf("MobileServer cls=%#x alloc/init obj=%#x -bind=%d (errno=%d) -port=%d\n", + cls, obj, bnd&0xff, bErrno, port&0xffffffff) + _, _ = c.Request("D") + return + } + + if *bindtest { + h, err := machomap.NewExportTrieResolver(c).Resolve([]string{"mmap", "socket", "bind", "listen", "__error"}) + check(err) + t, err := machomap.NewTarget(c) + check(err) + defer func() { _ = t.Restore() }() + errnoNow := func() uint64 { + if ep, e := t.Call(h["__error"]); e == nil && ep != 0 { + if b, e2 := c.Memory(ep, 4); e2 == nil { + return uint64(b[0]) | uint64(b[1])<<8 + } + } + return 0 + } + scratch, err := t.Call(h["mmap"], 0, 0x1000, 3 /*RW*/, 0x1002 /*ANON|PRIVATE*/, ^uint64(0), 0) + check(err) + fd, err := t.Call(h["socket"], 2 /*AF_INET*/, 1 /*SOCK_STREAM*/, 0) + check(err) + // struct sockaddr_in: len=0, family=AF_INET(2), port=0, addr=127.0.0.1 + sa := []byte{0, 2, 0, 0, 0x7f, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0} + check(c.WriteMem(scratch, sa, 0)) + bret, err := t.Call(h["bind"], fd, scratch, 16) + check(err) + bErrno := errnoNow() + lret, err := t.Call(h["listen"], fd, 8) + check(err) + lErrno := errnoNow() + fmt.Printf("socket fd=%d bind ret=%d (errno=%d) listen ret=%d (errno=%d)\n", + int32(uint32(fd)), int32(uint32(bret)), bErrno, int32(uint32(lret)), lErrno) + _, _ = c.Request("D") + return + } + var resolver machomap.Resolver = machomap.NewHostFilesResolver(c, *symbolRoot) if *useTrie { resolver = machomap.NewExportTrieResolver(c) @@ -105,12 +183,25 @@ func main() { fmt.Printf("MAP base=%#x size=%#x\n", res.Base, res.ImageSize) fmt.Printf("FIXUPS: %d rebases, %d binds (%d imports)\n", res.Rebases, res.Binds, res.Imports) - fmt.Printf("OBJC: %d selrefs, %d classes\n", res.Selrefs, res.Classes) + fmt.Printf("OBJC: %d selrefs, %d classes, %d category methods\n", res.Selrefs, res.Classes, res.Categories) if len(res.Unresolved) > 0 { fmt.Printf("UNRESOLVED (%d): %v\n", len(res.Unresolved), res.Unresolved) } if !*runInit { fmt.Println("(initializer NOT run; --init to start the agent)") + } else { + // boot check: the agent constructor binds a server and exports its port + if m, err := machomap.ParseMacho(dylib); err == nil { + if off, ok := m.Symbols["_mobilecli_get_port"]; ok { + t, err := machomap.NewTarget(c) + check(err) + port, err := t.Call(res.Base + off) + check(err) + _ = t.Restore() + fmt.Printf("mobilecli_get_port() = %d (agent %s)\n", port&0xffffffff, + map[bool]string{true: "RUNNING", false: "did not bind"}[port&0xffffffff != 0]) + } + } } _, _ = c.Request("D") fmt.Printf("detached; total %.3fs\n", time.Since(start).Seconds()) diff --git a/devices/ios/machomap/exporttrie.go b/devices/ios/machomap/exporttrie.go index 3cca9c8a..bb22560c 100644 --- a/devices/ios/machomap/exporttrie.go +++ b/devices/ios/machomap/exporttrie.go @@ -27,11 +27,17 @@ func NewExportTrieResolver(c *RSP) *ExportTrieResolver { nameDylib: map[string]string{ "mmap": "/usr/lib/system/libsystem_kernel.dylib", "mprotect": "/usr/lib/system/libsystem_kernel.dylib", + "socket": "/usr/lib/system/libsystem_kernel.dylib", + "bind": "/usr/lib/system/libsystem_kernel.dylib", + "listen": "/usr/lib/system/libsystem_kernel.dylib", + "__error": "/usr/lib/system/libsystem_kernel.dylib", "dlsym": "/usr/lib/system/libdyld.dylib", "sel_registerName": "/usr/lib/libobjc.A.dylib", "objc_readClassPair": "/usr/lib/libobjc.A.dylib", "objc_getClass": "/usr/lib/libobjc.A.dylib", "objc_msgSend": "/usr/lib/libobjc.A.dylib", + "object_getClass": "/usr/lib/libobjc.A.dylib", + "class_addMethod": "/usr/lib/libobjc.A.dylib", }, loadAddr: map[string]uint64{}, } diff --git a/devices/ios/machomap/mapper.go b/devices/ios/machomap/mapper.go index 05685b10..16793796 100644 --- a/devices/ios/machomap/mapper.go +++ b/devices/ios/machomap/mapper.go @@ -49,6 +49,7 @@ type Result struct { Imports int Selrefs int Classes int + Categories int Unresolved []string } @@ -205,6 +206,20 @@ func Map(c *RSP, dylib []byte, res Resolver, opt Options) (*Result, error) { res0.Classes = int(n) } + // 6b. apply categories (objc_readClassPair does not, and there is no public API + // to attach one from a mapped image) — before init, which may swizzle them. + if m.SectionCount("__objc_catlist") > 0 { + ch, err := res.Resolve(categoryHelpers) + if err != nil { + return nil, fmt.Errorf("resolve category helpers: %w", err) + } + applied, err := applyCategories(c, t, base, m, ch) + if err != nil { + return nil, err + } + res0.Categories = applied + } + // 7. run initializers (starts the agent) unless suppressed if opt.RunInit { for _, is := range m.InitSections { diff --git a/devices/ios/machomap/rsp.go b/devices/ios/machomap/rsp.go index d5c17fc4..c4e799fa 100644 --- a/devices/ios/machomap/rsp.go +++ b/devices/ios/machomap/rsp.go @@ -184,6 +184,15 @@ func (c *RSP) WriteMem(addr uint64, blob []byte, chunk int) error { return nil } +// readPtr reads a little-endian 64-bit pointer at addr. +func (c *RSP) readPtr(addr uint64) (uint64, error) { + b, err := c.Memory(addr, 8) + if err != nil { + return 0, err + } + return binary.LittleEndian.Uint64(b), nil +} + // ReadReg reads a register by DWARF-ish number on a thread. func (c *RSP) ReadReg(num int, thread uint64) (uint64, error) { resp, err := c.Request(fmt.Sprintf("p%x;thread:%x;", num, thread)) From b969d22aef995fd6390b0c03ad15dd54dce0362b Mon Sep 17 00:00:00 2001 From: Mobile Kevin Bot! Date: Wed, 4 Nov 2026 20:52:57 +0100 Subject: [PATCH 08/18] ios/machomap: diagnostics for the agent control-server bind anomaly Add --bindtest / --servertest / listen-binding / ivar-fd probes to mapprobe and the libsystem/libobjc symbols they need to the export-trie resolver. Findings (iPhone12,1 iOS 26.5.2), control server does not retain its port after on_load: - raw socket+bind+listen in the target all succeed - MobileServer allocs; -bindPort returns YES and assigns an ephemeral port; object_getClass/respondsToSelector confirm the mapped class dispatches - the 'listen' import binds to the same address as the libsystem_kernel export (dlsym RTLD_DEFAULT == trie), and a direct listen() on the agent's own _serverFd succeeds - yet compiled -[MobileServer bind] returns NO Everything the method depends on works in isolation, so the remaining suspect is a compile-time vs runtime ObjC ivar-offset discrepancy in the mapped image (the _OBJC_IVAR_$ offset globals) affecting the compiled method's reads. Does NOT affect the mapper's map/fixups/bind/class/category/init correctness, and the network swizzle ([MobileNetwork install]) runs in on_load before the server. --- devices/ios/machomap/cmd/mapprobe/main.go | 39 ++++++++++++++++++++++- devices/ios/machomap/exporttrie.go | 28 ++++++++-------- 2 files changed, 53 insertions(+), 14 deletions(-) diff --git a/devices/ios/machomap/cmd/mapprobe/main.go b/devices/ios/machomap/cmd/mapprobe/main.go index c3a9fd3d..aa400ba9 100644 --- a/devices/ios/machomap/cmd/mapprobe/main.go +++ b/devices/ios/machomap/cmd/mapprobe/main.go @@ -129,13 +129,50 @@ func main() { } return 0 } + clearErrno := func() { + if ep, e := t.Call(he["__error"]); e == nil && ep != 0 { + _ = c.WriteMem(ep, []byte{0, 0, 0, 0}, 0) + } + } + // compare how the agent's imports bind (dlsym RTLD_DEFAULT) vs the specific + // libsystem_kernel export (trie) for listen — a mismatch means RTLD_DEFAULT + // picked the wrong image, which would break the agent's listen() call. + hd, _ := machomap.NewExportTrieResolver(c).Resolve([]string{"dlsym", "listen"}) + listenDlsym := call(hd["dlsym"], ^uint64(0)-1 /*RTLD_DEFAULT*/, put("listen")) + socketDlsym := call(hd["dlsym"], ^uint64(0)-1, put("socket")) + fmt.Printf("listen: trie=%#x dlsym(RTLD_DEFAULT)=%#x %s\n", hd["listen"], listenDlsym, + map[bool]string{true: "MATCH", false: "MISMATCH"}[hd["listen"] == listenDlsym]) + fmt.Printf("socket dlsym=%#x\n", socketDlsym) cls := call(h["objc_getClass"], put("MobileServer")) obj := call(h["objc_msgSend"], cls, sel("alloc")) obj = call(h["objc_msgSend"], obj, sel("initWithHandler:"), 0) + // sanity: a method that definitely returns YES, to validate BOOL reads + resp := call(h["objc_msgSend"], obj, sel("respondsToSelector:"), sel("bind")) + fmt.Printf("respondsToSelector(bind)=%d (want 1)\n", resp&0xff) + // decompose on a fresh object: -bindPort alone + o2 := call(h["objc_msgSend"], cls, sel("alloc")) + o2 = call(h["objc_msgSend"], o2, sel("initWithHandler:"), 0) + clearErrno() + bp := call(h["objc_msgSend"], o2, sel("bindPort")) + bpErr := errnoNow() + p2 := call(h["objc_msgSend"], o2, sel("port")) + fmt.Printf("-bindPort=%d (errno=%d) -port=%d\n", bp&0xff, bpErr, p2&0xffffffff) + // read the agent socket's fd via its _serverFd ivar, then listen() on it directly + hi, _ := machomap.NewExportTrieResolver(c).Resolve([]string{"class_getInstanceVariable", "ivar_getOffset", "listen"}) + ivar := call(hi["class_getInstanceVariable"], cls, put("_serverFd")) + ivoff := call(hi["ivar_getOffset"], ivar) + fdb, _ := c.Memory(o2+ivoff, 4) + fd := uint64(fdb[0]) | uint64(fdb[1])<<8 | uint64(fdb[2])<<16 | uint64(fdb[3])<<24 + clearErrno() + ld := call(hi["listen"], fd, 8) + fmt.Printf("agent _serverFd=%d direct listen(fd,8)=%d (errno=%d)\n", + int32(uint32(fd)), int32(uint32(ld)), errnoNow()) + + clearErrno() bnd := call(h["objc_msgSend"], obj, sel("bind")) bErrno := errnoNow() port := call(h["objc_msgSend"], obj, sel("port")) - fmt.Printf("MobileServer cls=%#x alloc/init obj=%#x -bind=%d (errno=%d) -port=%d\n", + fmt.Printf("MobileServer cls=%#x alloc/init obj=%#x -bind=%d (errno-after=%d) -port=%d\n", cls, obj, bnd&0xff, bErrno, port&0xffffffff) _, _ = c.Request("D") return diff --git a/devices/ios/machomap/exporttrie.go b/devices/ios/machomap/exporttrie.go index bb22560c..f14c28a8 100644 --- a/devices/ios/machomap/exporttrie.go +++ b/devices/ios/machomap/exporttrie.go @@ -25,19 +25,21 @@ func NewExportTrieResolver(c *RSP) *ExportTrieResolver { return &ExportTrieResolver{ c: c, nameDylib: map[string]string{ - "mmap": "/usr/lib/system/libsystem_kernel.dylib", - "mprotect": "/usr/lib/system/libsystem_kernel.dylib", - "socket": "/usr/lib/system/libsystem_kernel.dylib", - "bind": "/usr/lib/system/libsystem_kernel.dylib", - "listen": "/usr/lib/system/libsystem_kernel.dylib", - "__error": "/usr/lib/system/libsystem_kernel.dylib", - "dlsym": "/usr/lib/system/libdyld.dylib", - "sel_registerName": "/usr/lib/libobjc.A.dylib", - "objc_readClassPair": "/usr/lib/libobjc.A.dylib", - "objc_getClass": "/usr/lib/libobjc.A.dylib", - "objc_msgSend": "/usr/lib/libobjc.A.dylib", - "object_getClass": "/usr/lib/libobjc.A.dylib", - "class_addMethod": "/usr/lib/libobjc.A.dylib", + "mmap": "/usr/lib/system/libsystem_kernel.dylib", + "mprotect": "/usr/lib/system/libsystem_kernel.dylib", + "socket": "/usr/lib/system/libsystem_kernel.dylib", + "bind": "/usr/lib/system/libsystem_kernel.dylib", + "listen": "/usr/lib/system/libsystem_kernel.dylib", + "__error": "/usr/lib/system/libsystem_kernel.dylib", + "dlsym": "/usr/lib/system/libdyld.dylib", + "sel_registerName": "/usr/lib/libobjc.A.dylib", + "objc_readClassPair": "/usr/lib/libobjc.A.dylib", + "objc_getClass": "/usr/lib/libobjc.A.dylib", + "objc_msgSend": "/usr/lib/libobjc.A.dylib", + "object_getClass": "/usr/lib/libobjc.A.dylib", + "class_addMethod": "/usr/lib/libobjc.A.dylib", + "class_getInstanceVariable": "/usr/lib/libobjc.A.dylib", + "ivar_getOffset": "/usr/lib/libobjc.A.dylib", }, loadAddr: map[string]uint64{}, } From df8977adae1469234ab7ef94e68472656fab2bef Mon Sep 17 00:00:00 2001 From: Mobile Kevin Bot! Date: Wed, 4 Nov 2026 20:55:09 +0100 Subject: [PATCH 09/18] ios/machomap: rule out ivar-offset mismatch for the bind anomaly Add a compiled-vs-runtime ObjC ivar-offset check to --servertest. On device the _OBJC_IVAR_$_MobileServer._serverFd/_port offset globals match the runtime offsets (16/20), so the control-server -bind anomaly is not an ivar-offset problem. Combined with the earlier probes (bindings match, BOOL reads correct, the socket listens directly, -bindPort succeeds), every dependency of -[MobileServer bind] works in isolation; the failure is isolated to the compiled method under debugger-driven execution and is left as a documented open item. The mapper itself (map/fixups/bind/class/category/init) remains fully correct. --- devices/ios/machomap/cmd/mapprobe/main.go | 32 ++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/devices/ios/machomap/cmd/mapprobe/main.go b/devices/ios/machomap/cmd/mapprobe/main.go index aa400ba9..387d2b2b 100644 --- a/devices/ios/machomap/cmd/mapprobe/main.go +++ b/devices/ios/machomap/cmd/mapprobe/main.go @@ -100,9 +100,39 @@ func main() { if *servertest { // map first so MobileServer is registered, then drive it via the runtime - if _, err := machomap.Map(c, dylib, machomap.NewExportTrieResolver(c), machomap.Options{RunInit: false}); err != nil { + mres, err := machomap.Map(c, dylib, machomap.NewExportTrieResolver(c), machomap.Options{RunInit: false}) + if err != nil { fatal("map: %v", err) } + // compile-time ivar-offset globals (what the compiled methods read) vs runtime + if mm, e := machomap.ParseMacho(dylib); e == nil { + ht, _ := machomap.NewExportTrieResolver(c).Resolve([]string{"mmap", "class_getInstanceVariable", "ivar_getOffset", "objc_getClass"}) + tt, _ := machomap.NewTarget(c) + sc, _ := tt.Call(ht["mmap"], 0, 0x1000, 3, 0x1002, ^uint64(0), 0) + cu := sc + put := func(s string) uint64 { + b := append([]byte(s), 0) + _ = c.WriteMem(cu, b, 0) + p := cu + cu += uint64(len(b)) + return p + } + srvCls, _ := tt.Call(ht["objc_getClass"], put("MobileServer")) + for _, iv := range []string{"_serverFd", "_port"} { + off, ok := mm.Symbols["_OBJC_IVAR_$_MobileServer."+iv] + if !ok { + fmt.Printf("ivar %s: no symbol\n", iv) + continue + } + gb, _ := c.Memory(mres.Base+off, 8) + compiled := uint64(gb[0]) | uint64(gb[1])<<8 | uint64(gb[2])<<16 | uint64(gb[3])<<24 + ivar, _ := tt.Call(ht["class_getInstanceVariable"], srvCls, put(iv)) + rt, _ := tt.Call(ht["ivar_getOffset"], ivar) + fmt.Printf("ivar %-10s compiled-global=%d runtime=%d %s\n", iv, compiled, rt&0xffffffff, + map[bool]string{true: "MATCH", false: "MISMATCH"}[compiled == rt&0xffffffff]) + } + _ = tt.Restore() + } h, err := machomap.NewExportTrieResolver(c).Resolve([]string{"mmap", "objc_getClass", "objc_msgSend", "sel_registerName"}) check(err) t, err := machomap.NewTarget(c) From 0e7fa4d8be2207c85811ea61d9c69f1790b13370 Mon Sep 17 00:00:00 2001 From: Mobile Kevin Bot! Date: Wed, 4 Nov 2026 21:08:38 +0100 Subject: [PATCH 10/18] =?UTF-8?q?ios/machomap:=20dedicated=20return=20tram?= =?UTF-8?q?poline=20=E2=80=94=20agent=20initializer=20now=20boots?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Target.Call planted the return breakpoint at the borrowed thread's live PC and set LR to it. That is fine for leaf-ish calls but wrong for the agent's C constructor (_on_load): it re-enters NSLog/libdispatch/runloop code, which can execute the instruction at that live PC and trip the breakpoint mid-run, so the call returned early and on_load's body never took effect (no swizzle, server port 0) — even though every dependency (bindings, ivar offsets, __got, selrefs, classrefs) was correct and a direct objc_msgSend worked. Fix: Target.SetLanding points the return breakpoint at a private r-x address (past the loop stubs in the stub page) that callee code never naturally executes. Map sets it before running fixups/init. Result on iPhone12,1 iOS 26.5.2 (full host-independent map + init of the network agent): control server binds (mobilecli_get_port != 0) and the NSURLSession swizzle installs (+[NSURLSessionConfiguration defaultSessionConfiguration] IMP now points into the mapped agent). Resolves the open control-server item. Also trims cmd/mapprobe's --init path to a concise boot verification. --- devices/ios/machomap/cmd/mapprobe/main.go | 41 +++++++++++++++++------ devices/ios/machomap/exporttrie.go | 31 ++++++++--------- devices/ios/machomap/mapper.go | 6 ++++ devices/ios/machomap/target.go | 26 +++++++++++--- 4 files changed, 74 insertions(+), 30 deletions(-) diff --git a/devices/ios/machomap/cmd/mapprobe/main.go b/devices/ios/machomap/cmd/mapprobe/main.go index 387d2b2b..3cbb4d22 100644 --- a/devices/ios/machomap/cmd/mapprobe/main.go +++ b/devices/ios/machomap/cmd/mapprobe/main.go @@ -257,18 +257,37 @@ func main() { if !*runInit { fmt.Println("(initializer NOT run; --init to start the agent)") } else { - // boot check: the agent constructor binds a server and exports its port - if m, err := machomap.ParseMacho(dylib); err == nil { - if off, ok := m.Symbols["_mobilecli_get_port"]; ok { - t, err := machomap.NewTarget(c) - check(err) - port, err := t.Call(res.Base + off) - check(err) - _ = t.Restore() - fmt.Printf("mobilecli_get_port() = %d (agent %s)\n", port&0xffffffff, - map[bool]string{true: "RUNNING", false: "did not bind"}[port&0xffffffff != 0]) - } + // verify the agent booted: the constructor binds a control server (exported via + // mobilecli_get_port) and installs the NSURLSession swizzle, after which + // +[NSURLSessionConfiguration defaultSessionConfiguration]'s IMP points into our + // mapped image. + m, _ := machomap.ParseMacho(dylib) + hv, err := machomap.NewExportTrieResolver(c).Resolve([]string{"mmap", "objc_getClass", "object_getClass", "sel_registerName", "class_getMethodImplementation"}) + check(err) + t, err := machomap.NewTarget(c) + check(err) + defer func() { _ = t.Restore() }() + scratch, err := t.Call(hv["mmap"], 0, 0x1000, 3, 0x1002, ^uint64(0), 0) + check(err) + cur := scratch + put := func(s string) uint64 { + b := append([]byte(s), 0) + _ = c.WriteMem(cur, b, 0) + p := cur + cur += uint64(len(b)) + return p + } + call := func(fn uint64, a ...uint64) uint64 { v, e := t.Call(fn, a...); check(e); return v } + + if off, ok := m.Symbols["_mobilecli_get_port"]; ok { + port := call(res.Base+off) & 0xffffffff + fmt.Printf("control server: mobilecli_get_port() = %d (%s)\n", port, + map[bool]string{true: "UP", false: "not bound"}[port != 0]) } + meta := call(hv["object_getClass"], call(hv["objc_getClass"], put("NSURLSessionConfiguration"))) + imp := call(hv["class_getMethodImplementation"], meta, call(hv["sel_registerName"], put("defaultSessionConfiguration"))) + fmt.Printf("network swizzle: +[NSURLSessionConfiguration defaultSessionConfiguration] IMP=%#x %s\n", imp, + map[bool]string{true: "INSTALLED (points into mapped agent)", false: "NOT installed"}[imp >= res.Base && imp < res.Base+res.ImageSize]) } _, _ = c.Request("D") fmt.Printf("detached; total %.3fs\n", time.Since(start).Seconds()) diff --git a/devices/ios/machomap/exporttrie.go b/devices/ios/machomap/exporttrie.go index f14c28a8..3126d9bf 100644 --- a/devices/ios/machomap/exporttrie.go +++ b/devices/ios/machomap/exporttrie.go @@ -25,21 +25,22 @@ func NewExportTrieResolver(c *RSP) *ExportTrieResolver { return &ExportTrieResolver{ c: c, nameDylib: map[string]string{ - "mmap": "/usr/lib/system/libsystem_kernel.dylib", - "mprotect": "/usr/lib/system/libsystem_kernel.dylib", - "socket": "/usr/lib/system/libsystem_kernel.dylib", - "bind": "/usr/lib/system/libsystem_kernel.dylib", - "listen": "/usr/lib/system/libsystem_kernel.dylib", - "__error": "/usr/lib/system/libsystem_kernel.dylib", - "dlsym": "/usr/lib/system/libdyld.dylib", - "sel_registerName": "/usr/lib/libobjc.A.dylib", - "objc_readClassPair": "/usr/lib/libobjc.A.dylib", - "objc_getClass": "/usr/lib/libobjc.A.dylib", - "objc_msgSend": "/usr/lib/libobjc.A.dylib", - "object_getClass": "/usr/lib/libobjc.A.dylib", - "class_addMethod": "/usr/lib/libobjc.A.dylib", - "class_getInstanceVariable": "/usr/lib/libobjc.A.dylib", - "ivar_getOffset": "/usr/lib/libobjc.A.dylib", + "mmap": "/usr/lib/system/libsystem_kernel.dylib", + "mprotect": "/usr/lib/system/libsystem_kernel.dylib", + "socket": "/usr/lib/system/libsystem_kernel.dylib", + "bind": "/usr/lib/system/libsystem_kernel.dylib", + "listen": "/usr/lib/system/libsystem_kernel.dylib", + "__error": "/usr/lib/system/libsystem_kernel.dylib", + "dlsym": "/usr/lib/system/libdyld.dylib", + "sel_registerName": "/usr/lib/libobjc.A.dylib", + "objc_readClassPair": "/usr/lib/libobjc.A.dylib", + "objc_getClass": "/usr/lib/libobjc.A.dylib", + "objc_msgSend": "/usr/lib/libobjc.A.dylib", + "object_getClass": "/usr/lib/libobjc.A.dylib", + "class_addMethod": "/usr/lib/libobjc.A.dylib", + "class_getInstanceVariable": "/usr/lib/libobjc.A.dylib", + "ivar_getOffset": "/usr/lib/libobjc.A.dylib", + "class_getMethodImplementation": "/usr/lib/libobjc.A.dylib", }, loadAddr: map[string]uint64{}, } diff --git a/devices/ios/machomap/mapper.go b/devices/ios/machomap/mapper.go index 16793796..0f1cd66f 100644 --- a/devices/ios/machomap/mapper.go +++ b/devices/ios/machomap/mapper.go @@ -123,6 +123,12 @@ func Map(c *RSP, dylib []byte, res Resolver, opt Options) (*Result, error) { if err := c.WriteMem(stubMem, StubBytes, chunk); err != nil { return nil, err } + // Return called functions to a private r-x address in the stub page (well past + // the stub bytes) rather than the thread's live PC, so re-entrant callee code + // (NSLog/libdispatch in the initializer) can't trip the return breakpoint early. + if err := t.SetLanding(stubMem + 0x800); err != nil { + return nil, err + } cur := scratch alloc := func(size uint64) uint64 { cur = (cur + 7) &^ 7 diff --git a/devices/ios/machomap/target.go b/devices/ios/machomap/target.go index bd9ef6e4..5893267e 100644 --- a/devices/ios/machomap/target.go +++ b/devices/ios/machomap/target.go @@ -20,6 +20,7 @@ type Target struct { thread uint64 saved string returnPC uint64 + landing uint64 // address of the breakpoint a called function returns to sp uint64 stackLo uint64 stackBackup []byte @@ -60,12 +61,29 @@ func NewTarget(c *RSP) (*Target, error) { if t.stackBackup, err = c.Memory(t.stackLo, t.sp-t.stackLo); err != nil { return nil, err } - if err := c.OK(fmt.Sprintf("Z0,%x,4", t.returnPC)); err != nil { + // Default: a called function returns to a breakpoint planted at the thread's + // current PC. That is unsafe for functions that re-enter the code the thread + // was stopped in (NSLog/libdispatch/runloop), which can trip the breakpoint + // mid-execution. SetLanding switches to a private trampoline that callee code + // never naturally executes. + t.landing = t.returnPC + if err := c.OK(fmt.Sprintf("Z0,%x,4", t.landing)); err != nil { return nil, err } return t, nil } +// SetLanding moves the return-breakpoint to addr — a dedicated, never-naturally- +// executed address (e.g. a private r-x page). This avoids false early stops when +// a called function re-enters the code region the thread was stopped in. +func (t *Target) SetLanding(addr uint64) error { + if err := t.c.OK(fmt.Sprintf("z0,%x,4", t.landing)); err != nil { + return err + } + t.landing = addr + return t.c.OK(fmt.Sprintf("Z0,%x,4", addr)) +} + // Call invokes fn(args...) on the borrowed thread and returns x0. LR points at // the planted breakpoint, so the function returns into a known stop. func (t *Target) Call(fn uint64, args ...uint64) (uint64, error) { @@ -74,7 +92,7 @@ func (t *Target) Call(fn uint64, args ...uint64) (uint64, error) { return 0, err } } - if err := t.c.WriteReg(regLR, t.thread, t.returnPC); err != nil { + if err := t.c.WriteReg(regLR, t.thread, t.landing); err != nil { return 0, err } if err := t.c.WriteReg(regSP, t.thread, t.sp-0x100); err != nil { @@ -91,7 +109,7 @@ func (t *Target) Call(fn uint64, args ...uint64) (uint64, error) { if err != nil { return 0, err } - if !strings.HasPrefix(resp, "T05") || pc != t.returnPC { + if !strings.HasPrefix(resp, "T05") || pc != t.landing { return 0, fmt.Errorf("unexpected stop calling %#x: resp=%.60q pc=%#x", fn, resp, pc) } return t.c.ReadReg(0, t.thread) @@ -105,7 +123,7 @@ func (t *Target) Restore() error { if err := t.c.OK(fmt.Sprintf("QRestoreRegisterState:%s;thread:%x;", t.saved, t.thread)); err != nil { return err } - return t.c.OK(fmt.Sprintf("z0,%x,4", t.returnPC)) + return t.c.OK(fmt.Sprintf("z0,%x,4", t.landing)) } func isDigits(s string) bool { From 656bbc1b095a5347f781b4540a4f1dbc2644a1bd Mon Sep 17 00:00:00 2001 From: Mobile Kevin Bot! Date: Wed, 4 Nov 2026 21:22:22 +0100 Subject: [PATCH 11/18] ios: inject the agent via the native mapper, remove the LLDB path Real-device agent injection now maps the compiled agent dylib into the foreground app over the debugserver (devices/ios/machomap) and runs its initializer, instead of shelling out to lldb and evaluating the agent source with 'expr -l objc'. This is signing-free, needs no host toolchain, and works on AWS Device Farm. - agents: embed ios/agent-dev.dylib (IOSAgentDevDylib), like agent-sim - devices/ios_device_agent_native.go: injectAgentNative connects the go-ios debug proxy, attaches, maps + boots the agent, and returns the server port read from mobilecli_get_port; mapAgentOverConn splits the transport-agnostic core out for testing. Idempotency guard (objc_getClass MobileServer) refuses a second map into an already-injected process. - devices/ios_device_agent.go: injectFreshAgent calls injectAgentNative; injectServerViaLLDB, the lldb exec, the port regex, and now-unused imports are gone - machomap/runtime.go: CallFunction + ClassIsRegistered helpers - remove devices/ios/ios_device_lldbproxy.go, devices/ios/debugserver (vendored GDB bridge, now unused), agents/ios-real/agent.m (LLDB webview source) and its embed - add an env-gated end-to-end test (TestNativeAgentMapperBootsOverTunnel) Verified on iPhone12,1 iOS 26.5.2: fresh injection boots the agent (server binds a port, NSURLSession swizzle installs) and a repeat injection is refused by the guard. The dylib dispatcher is a superset of the old webview agent (same device.webview.* methods plus network.*), so webview features are preserved. --- agents/agents.go | 12 +- agents/ios-real/agent.m | 312 ------------------------ devices/ios/debugserver/LICENSE | 21 -- devices/ios/debugserver/gdbserver.go | 100 -------- devices/ios/machomap/rsp.go | 8 +- devices/ios/machomap/runtime.go | 43 ++++ devices/ios_device_agent.go | 61 +---- devices/ios_device_agent_native.go | 88 +++++++ devices/ios_device_agent_native_test.go | 65 +++++ devices/ios_device_lldbproxy.go | 181 -------------- 10 files changed, 209 insertions(+), 682 deletions(-) delete mode 100644 agents/ios-real/agent.m delete mode 100644 devices/ios/debugserver/LICENSE delete mode 100644 devices/ios/debugserver/gdbserver.go create mode 100644 devices/ios/machomap/runtime.go create mode 100644 devices/ios_device_agent_native.go create mode 100644 devices/ios_device_agent_native_test.go delete mode 100644 devices/ios_device_lldbproxy.go diff --git a/agents/agents.go b/agents/agents.go index 0c18774d..7006783f 100644 --- a/agents/agents.go +++ b/agents/agents.go @@ -22,10 +22,10 @@ var AndroidMobilecliDEX []byte //go:embed ios/agent-sim.dylib var IOSAgentSimDylib []byte -// IOSRealDeviceWebViewAgent is the Objective-C expression evaluated via LLDB to -// inject the webview agent into a foreground app on a real iOS device. It is an -// LLDB expression (top-level statements), not a standalone translation unit, so -// it is not compiled — only embedded. See devices/ios_device_webview.go. +// IOSAgentDevDylib is the real-device build of the compiled agent (server + +// bridge + dispatcher + network). It is mapped into a foreground app over the +// debugserver by devices/ios/machomap — no code signing, no re-signing — and +// supersedes the LLDB-evaluated webview source agent on real devices. // -//go:embed ios-real/agent.m -var IOSRealDeviceWebViewAgent string +//go:embed ios/agent-dev.dylib +var IOSAgentDevDylib []byte diff --git a/agents/ios-real/agent.m b/agents/ios-real/agent.m deleted file mode 100644 index ae411a1f..00000000 --- a/agents/ios-real/agent.m +++ /dev/null @@ -1,312 +0,0 @@ -@import Foundation; -// UIKit/WebKit class metadata avoided — calls cast result to silence LLDB strict mode -extern Class objc_getClass(const char *); -// Geometry helpers: UIKit headers aren't on the include path, so to call -// struct-returning methods (-bounds, -convertRect:toView:) we declare a -// CGRect-compatible struct and call through cast'd objc_msgSend (CGFloat is -// double on arm64; arm64 has no objc_msgSend_stret, so the plain symbol works). -extern void objc_msgSend(void); -extern SEL sel_registerName(const char *); -typedef double __mcfloat; -typedef struct { __mcfloat x, y; } __mcpoint; -typedef struct { __mcfloat width, height; } __mcsize; -typedef struct { __mcpoint origin; __mcsize size; } __mcrect; -// inline C declarations — LLDB remote-ios doesn't have SDK headers on include path -typedef unsigned int __socklen_t; -typedef unsigned short __in_port_t; -typedef unsigned int __in_addr_t; -struct __in_addr { __in_addr_t s_addr; }; -struct __sockaddr_in { - unsigned char sin_len; unsigned char sin_family; - __in_port_t sin_port; struct __in_addr sin_addr; char sin_zero[8]; -}; -struct __sockaddr { unsigned char sa_len; unsigned char sa_family; char sa_data[14]; }; -extern int socket(int, int, int); -extern int setsockopt(int, int, int, const void *, __socklen_t); -extern int bind(int, const struct __sockaddr *, __socklen_t); -extern int listen(int, int); -extern int accept(int, struct __sockaddr *, __socklen_t *); -extern long recv(int, void *, unsigned long, int); -extern long send(int, const void *, unsigned long, int); -extern int close(int); -extern int *__error(void); // Darwin errno is (*__error()) -extern __in_port_t htons(__in_port_t); -extern __in_addr_t htonl(__in_addr_t); -extern void *memset(void *, int, unsigned long); -extern char *strdup(const char *); -extern void free(void *); -extern unsigned long strlen(const char *); -#define __AF_INET 2 -#define __SOCK_STREAM 1 -#define __SOL_SOCKET 0xffff -#define __SO_REUSEADDR 0x0004 -#define __INADDR_LOOPBACK 0x7f000001UL -int __sfd = socket(__AF_INET, __SOCK_STREAM, 0); -int __opt = 1; -setsockopt(__sfd, __SOL_SOCKET, __SO_REUSEADDR, &__opt, sizeof(__opt)); -struct __sockaddr_in __sa; memset(&__sa, 0, sizeof(__sa)); -__sa.sin_family = __AF_INET; -__sa.sin_addr.s_addr = htonl(__INADDR_LOOPBACK); -int __port = 0; -__sa.sin_port = htons((unsigned short)12008); -if (bind(__sfd, (struct __sockaddr *)&__sa, sizeof(__sa)) == 0) { __port = 12008; } -if (__port > 0) { - listen(__sfd, 8); - int __srv = __sfd; - dispatch_async(dispatch_get_global_queue(0, 0), ^{ - id (^__findWV)(NSString *) = ^id(NSString *wvId) { - __block id found = nil; - id sem = dispatch_semaphore_create(0); - [[NSOperationQueue mainQueue] addOperationWithBlock:^{ - Class wk = NSClassFromString(@"WKWebView"); - Class wsCls = (Class)objc_getClass("UIWindowScene"); - id app = (id)[(Class)objc_getClass("UIApplication") sharedApplication]; - for (id sc in (NSArray *)[app connectedScenes]) - if ([(NSObject *)sc isKindOfClass:wsCls]) - for (id w in (NSArray *)[sc windows]) { - NSMutableArray *stk = [NSMutableArray arrayWithObject:w]; - while ([stk count]) { - id v = stk[0]; [stk removeObjectAtIndex:0]; - if (wk && [(NSObject *)v isKindOfClass:wk] && - [[NSString stringWithFormat:@"%p", v] isEqualToString:wvId]) - { found = v; break; } - [stk addObjectsFromArray:(NSArray *)[v subviews]]; - } - if (found) break; - } - dispatch_semaphore_signal(sem); - }]; - dispatch_semaphore_wait(sem, dispatch_time(0, 5000000000LL)); - return found; - }; - while (1) { - int cfd = accept(__srv, NULL, NULL); - if (cfd < 0) continue; - dispatch_async(dispatch_get_global_queue(0, 0), ^{ - NSMutableData *buf = [NSMutableData data]; - char tmp[4096]; long n; - NSData *crlf = [@"\r\n\r\n" dataUsingEncoding:NSASCIIStringEncoding]; - while ((n = recv(cfd, tmp, sizeof(tmp), 0)) > 0) { - [buf appendBytes:tmp length:(NSUInteger)n]; - NSRange sep = [buf rangeOfData:crlf options:0 range:NSMakeRange(0, buf.length)]; - if (sep.location != NSNotFound) { - NSString *hdr = [[NSString alloc] initWithData:[buf subdataWithRange:NSMakeRange(0, sep.location)] encoding:NSASCIIStringEncoding]; - NSInteger cl = 0; - for (NSString *__hdrLine in [hdr componentsSeparatedByString:@"\r\n"]) - if ([__hdrLine.lowercaseString hasPrefix:@"content-length:"]) - cl = [[__hdrLine substringFromIndex:15] integerValue]; - NSUInteger bs = sep.location + 4; - while ((NSInteger)(buf.length - bs) < cl && (n = recv(cfd, tmp, sizeof(tmp), 0)) > 0) - [buf appendBytes:tmp length:(NSUInteger)n]; - break; - } - } - NSRange hr = [buf rangeOfData:crlf options:0 range:NSMakeRange(0, buf.length)]; - NSData *body = (hr.location == NSNotFound) ? [NSData data] : - [buf subdataWithRange:NSMakeRange(hr.location + 4, buf.length - hr.location - 4)]; - NSDictionary *req = [NSJSONSerialization JSONObjectWithData:body options:0 error:nil]; - id rqId = req[@"id"] ?: [NSNull null]; - NSString *method = req[@"method"] ?: @""; - NSDictionary *params = req[@"params"]; - NSData *resp = nil; - if ([method isEqualToString:@"device.webview.list"]) { - __block NSMutableArray *wvs = [NSMutableArray array]; - id sem = dispatch_semaphore_create(0); - [[NSOperationQueue mainQueue] addOperationWithBlock:^{ - Class wk = NSClassFromString(@"WKWebView"); - Class wsCls = (Class)objc_getClass("UIWindowScene"); - id app = (id)[(Class)objc_getClass("UIApplication") sharedApplication]; - for (id sc in (NSArray *)[app connectedScenes]) - if ([(NSObject *)sc isKindOfClass:wsCls]) - for (id win in (NSArray *)[sc windows]) { - NSMutableArray *stk = [NSMutableArray arrayWithObject:win]; - while ([stk count]) { - id v = stk[0]; [stk removeObjectAtIndex:0]; - if (wk && [(NSObject *)v isKindOfClass:wk]) { - __mcrect (*__boundsMsg)(id, SEL) = (__mcrect (*)(id, SEL))objc_msgSend; - __mcrect (*__convMsg)(id, SEL, __mcrect, id) = (__mcrect (*)(id, SEL, __mcrect, id))objc_msgSend; - __mcrect __fr = __convMsg(v, sel_registerName("convertRect:toView:"), __boundsMsg(v, sel_registerName("bounds")), (id)0); - [wvs addObject:@{ - @"id": [NSString stringWithFormat:@"%p", v], - @"url": [(NSURL *)[v URL] absoluteString] ?: @"", - @"title": (NSString *)[v title] ?: @"", - @"bounds": @{@"x":@(__fr.origin.x),@"y":@(__fr.origin.y),@"width":@(__fr.size.width),@"height":@(__fr.size.height)}, - @"visible": @(!(BOOL)[v isHidden] && (id)[v window] != nil) - }]; - } - [stk addObjectsFromArray:(NSArray *)[v subviews]]; - } - } - dispatch_semaphore_signal(sem); - }]; - dispatch_semaphore_wait(sem, dispatch_time(0, 5000000000LL)); - resp = [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"result":wvs} options:0 error:nil]; - } else if ([method isEqualToString:@"device.webview.goto"]) { - NSString *wvId = params[@"id"], *url = params[@"url"]; - id wv = (wvId && url) ? __findWV(wvId) : nil; - if (!wvId || !url) resp = [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"error":@{@"code":@(-32602),@"message":@"missing id or url"}} options:0 error:nil]; - else if (!wv) resp = [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"error":@{@"code":@(-32000),@"message":@"webview not found"}} options:0 error:nil]; - else { - id sem = dispatch_semaphore_create(0); - [[NSOperationQueue mainQueue] addOperationWithBlock:^{ (void)[wv loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:url]]]; dispatch_semaphore_signal(sem); }]; - dispatch_semaphore_wait(sem, dispatch_time(0, 5000000000LL)); - resp = [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"result":@{@"status":@"ok"}} options:0 error:nil]; - } - } else if ([@[@"device.webview.reload",@"device.webview.goBack",@"device.webview.goForward"] containsObject:method]) { - NSString *wvId = params[@"id"]; - id wv = wvId ? __findWV(wvId) : nil; - if (!wvId) resp = [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"error":@{@"code":@(-32602),@"message":@"missing id"}} options:0 error:nil]; - else if (!wv) resp = [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"error":@{@"code":@(-32000),@"message":@"webview not found"}} options:0 error:nil]; - else { - id sem = dispatch_semaphore_create(0); - [[NSOperationQueue mainQueue] addOperationWithBlock:^{ - if ([method isEqualToString:@"device.webview.reload"]) (void)[wv reload]; - else if ([method isEqualToString:@"device.webview.goBack"]) (void)[wv goBack]; - else (void)[wv goForward]; - dispatch_semaphore_signal(sem); - }]; - dispatch_semaphore_wait(sem, dispatch_time(0, 5000000000LL)); - resp = [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"result":@{@"status":@"ok"}} options:0 error:nil]; - } - } else if ([method isEqualToString:@"device.webview.evaluate"]) { - NSString *wvId = params[@"id"], *expr = params[@"expression"]; - id wv = (wvId && expr) ? __findWV(wvId) : nil; - if (!wvId || !expr) resp = [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"error":@{@"code":@(-32602),@"message":@"missing id or expression"}} options:0 error:nil]; - else if (!wv) resp = [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"error":@{@"code":@(-32000),@"message":@"webview not found"}} options:0 error:nil]; - else { - // A heap (non-tagged) value returned from evaluateJavaScript is - // over-released when WebKit's delivery pool drains, crashing the - // app — so we must never hold WebKit's result object. Instead the - // JS JSON-stringifies [ok, value]; inside the handler we copy the - // resulting bytes to C memory (no ObjC retain) and rebuild the - // response from our own bytes. Arrays + a mutable dict avoid the - // single-entry immutable dictionaries seen in the crash. - NSString *wrapped = [NSString stringWithFormat:@"(function(){try{return JSON.stringify([1,(function(){%@})()])}catch(e){return JSON.stringify([0,''+e])}})()", expr]; - __block char *jbuf = NULL; - id sem = dispatch_semaphore_create(0); - [[NSOperationQueue mainQueue] addOperationWithBlock:^{ - (void)[wv evaluateJavaScript:wrapped completionHandler:^(id r, NSError *e) { - if ([(NSObject *)r isKindOfClass:[NSString class]]) { const char *u = [(NSString *)r UTF8String]; if (u) jbuf = strdup(u); } - dispatch_semaphore_signal(sem); - }]; - }]; - dispatch_semaphore_wait(sem, dispatch_time(0, 30000000000LL)); - if (!jbuf) { - resp = [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"error":@{@"code":@(-32000),@"message":@"no result from evaluate"}} options:0 error:nil]; - } else { - NSArray *parsed = [NSJSONSerialization JSONObjectWithData:[NSData dataWithBytes:jbuf length:strlen(jbuf)] options:0 error:nil]; - free(jbuf); - BOOL ok2 = [(NSObject *)parsed isKindOfClass:[NSArray class]] && [parsed count] == 2; - if (ok2 && [(NSNumber *)parsed[0] intValue] == 0) { - resp = [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"error":@{@"code":@(-32000),@"message":parsed[1]}} options:0 error:nil]; - } else { - NSMutableDictionary *rd = [NSMutableDictionary dictionary]; - rd[@"result"] = ok2 ? parsed[1] : [NSNull null]; - resp = [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"result":rd} options:0 error:nil]; - } - } - } - } else if ([method isEqualToString:@"device.webview.waitForLoadState"]) { - NSString *wvId = params[@"id"]; - id wv = wvId ? __findWV(wvId) : nil; - if (!wvId) resp = [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"error":@{@"code":@(-32602),@"message":@"missing id"}} options:0 error:nil]; - else if (!wv) resp = [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"error":@{@"code":@(-32000),@"message":@"webview not found"}} options:0 error:nil]; - else { - NSString *__lstate = params[@"state"] ?: @"load"; - NSInteger toMs = params[@"timeout"] ? [(NSNumber *)params[@"timeout"] integerValue] : 30000; - NSString *checkJS = [@"domcontentloaded" isEqualToString:__lstate] ? - @"return String(document.readyState==='interactive'||document.readyState==='complete')" : - @"return String(document.readyState==='complete')"; - NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:toMs / 1000.0]; - BOOL done = NO; - while (!done && [[NSDate date] compare:deadline] == NSOrderedAscending) { - __block id jsR = nil; - id sem2 = dispatch_semaphore_create(0); - NSString *wrapped = [NSString stringWithFormat:@"(function(){try{%@}catch(e){return null}})()", checkJS]; - [[NSOperationQueue mainQueue] addOperationWithBlock:^{ - (void)[wv evaluateJavaScript:wrapped completionHandler:^(id r, NSError *e) { jsR = r; dispatch_semaphore_signal(sem2); }]; - }]; - dispatch_semaphore_wait(sem2, dispatch_time(0, 5000000000LL)); - if ([@"true" isEqualToString:jsR]) done = YES; - else [NSThread sleepForTimeInterval:0.2]; - } - resp = done ? - [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"result":@{@"status":@"ok"}} options:0 error:nil] : - [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"error":@{@"code":@(-32000),@"message":@"timed out"}} options:0 error:nil]; - } - } else if ([method isEqualToString:@"device.flutter.vmServiceUri"]) { - // Return the running Flutter app's Dart VM service URI (with - // its auth token) by reading it in-process from the engine. - // Reached via the on-screen FlutterViewController -> engine -> - // its FlutterDartVMServicePublisher.url. `publisher` is a - // private ivar of FlutterEngine, read via KVC (the one - // version-sensitive point); `url` is a public property. A - // ClassNotFound / nil result means "not a Flutter app", which - // the caller treats as a signal to fall back to the a11y dump. - // (In-process mDNS was ruled out: DNS-SD needs C function- - // pointer callbacks an LLDB expression cannot define.) - __block NSString *uri = nil; - id sem = dispatch_semaphore_create(0); - [[NSOperationQueue mainQueue] addOperationWithBlock:^{ - @try { - Class fvcCls = NSClassFromString(@"FlutterViewController"); - Class wsCls = (Class)objc_getClass("UIWindowScene"); - id app = (id)[(Class)objc_getClass("UIApplication") sharedApplication]; - id fvc = nil; - for (id sc in (NSArray *)[app connectedScenes]) { - if (![(NSObject *)sc isKindOfClass:wsCls]) continue; - for (id win in (NSArray *)[sc windows]) { - NSMutableArray *stk = [NSMutableArray array]; - id root = [win rootViewController]; - if (root) [stk addObject:root]; - while ([stk count]) { - id vc = stk[0]; [stk removeObjectAtIndex:0]; - if (fvcCls && [(NSObject *)vc isKindOfClass:fvcCls]) { fvc = vc; break; } - NSArray *kids = (NSArray *)[vc childViewControllers]; - if (kids) [stk addObjectsFromArray:kids]; - id presented = [vc presentedViewController]; - if (presented) [stk addObject:presented]; - } - if (fvc) break; - } - if (fvc) break; - } - if (fvc) { - id engine = [fvc engine]; - id pub = engine ? [engine valueForKey:@"publisher"] : nil; - id url = pub ? [pub url] : nil; - if (url) uri = [(NSURL *)url absoluteString]; - } - } @catch (id __e) { uri = nil; } - dispatch_semaphore_signal(sem); - }]; - dispatch_semaphore_wait(sem, dispatch_time(0, 5000000000LL)); - if (uri) - resp = [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"result":@{@"uri":uri}} options:0 error:nil]; - else - resp = [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"error":@{@"code":@(-32602),@"message":@"not a flutter app"}} options:0 error:nil]; - } - if (!resp) resp = [NSJSONSerialization dataWithJSONObject:@{@"jsonrpc":@"2.0",@"id":rqId,@"error":@{@"code":@(-32601),@"message":[NSString stringWithFormat:@"method not found: %@", method]}} options:0 error:nil]; - if (!resp) resp = [@"{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603,\"message\":\"internal error\"}}" dataUsingEncoding:NSUTF8StringEncoding]; - NSString *hdrs = [NSString stringWithFormat:@"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: %lu\r\nConnection: close\r\n\r\n", (unsigned long)resp.length]; - NSData *hdrData = [hdrs dataUsingEncoding:NSASCIIStringEncoding]; - // send() can short-write (notably on EINTR); loop until the full - // header + body is written, advancing via unsigned char* arithmetic. - BOOL (^__sendAll)(const void *, unsigned long) = ^BOOL(const void *b, unsigned long n) { - const unsigned char *p = (const unsigned char *)b; - unsigned long left = n; - while (left > 0) { - long w = send(cfd, p, left, 0); - if (w > 0) { p += w; left -= (unsigned long)w; } - else if (w < 0 && (*__error()) == 4 /* EINTR */) { continue; } - else { return NO; } - } - return YES; - }; - if (__sendAll(hdrData.bytes, hdrData.length)) __sendAll(resp.bytes, resp.length); - close(cfd); - }); - } - }); -} -(int)__port diff --git a/devices/ios/debugserver/LICENSE b/devices/ios/debugserver/LICENSE deleted file mode 100644 index 460ebad7..00000000 --- a/devices/ios/debugserver/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2019 danielpaulus - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/devices/ios/debugserver/gdbserver.go b/devices/ios/debugserver/gdbserver.go deleted file mode 100644 index 7adfbc20..00000000 --- a/devices/ios/debugserver/gdbserver.go +++ /dev/null @@ -1,100 +0,0 @@ -// Package debugserver implements the wire-level GDB Remote Serial Protocol -// client used to talk to the on-device debugserver (via the CoreDevice debug -// proxy). -// -// Vendored from github.com/danielpaulus/go-ios/ios/debugserver -// (only the GDBServer type from lockdownconn.go, which is dependency-free). -// The sole change from upstream is raising the bufio.Scanner buffer limit: -// debugserver answers jGetLoadedDynamicLibrariesInfos with the full loaded-image -// list as a single packet that easily exceeds bufio's default 64 KB token size, -// which made LLDB load zero images (no symbols → expr injection failed). -// Original code is MIT licensed — see the LICENSE file in this directory. -package debugserver - -import ( - "bufio" - "bytes" - "encoding/hex" - "errors" - "io" -) - -var ErrInvalidGDBServerPayload = errors.New("invalid payload") - -// maxPacketSize bounds a single GDB-RSP packet we will read from the device. -// The image-list response (jGetLoadedDynamicLibrariesInfos) for a real app is -// hundreds of KB to a few MB, so we allow up to 64 MB. -const maxPacketSize = 64 * 1024 * 1024 - -type GDBServer struct { - rw io.ReadWriter - scanner *bufio.Scanner -} - -// Implements wire level GDBServer protocol -func NewGDBServer(rw io.ReadWriter) *GDBServer { - scanner := bufio.NewScanner(rw) - // Raise the token limit; the default (bufio.MaxScanTokenSize, 64 KB) is far - // too small for the image-list packet and yields "token too long". - scanner.Buffer(make([]byte, 0, 64*1024), maxPacketSize) - scanner.Split(func(data []byte, atEOF bool) (int, []byte, error) { - const lenPacketSuffix = 3 // len("#00") - - if atEOF && len(data) == 0 { - return 0, nil, nil - } - - start := bytes.IndexRune(data, '$') - end := bytes.IndexRune(data, '#') - // Need more data - if start < 0 || end < 0 || len(data) < end+lenPacketSuffix { - return 0, nil, nil - } - // Invalid data - if end < start { - return 0, nil, ErrInvalidGDBServerPayload - } - - // Strip the $ prefix before returning - return end + lenPacketSuffix, data[start+1 : end], nil - }) - - return &GDBServer{ - rw: rw, - scanner: scanner, - } -} - -func (*GDBServer) chksum(pck string) string { - sum := 0 - for _, b := range pck { - sum += int(b) - } - return hex.EncodeToString([]byte{byte(sum % 256)}) -} - -func (g *GDBServer) formatPacket(pck string) string { - return "+$" + pck + "#" + g.chksum(pck) -} - -func (g *GDBServer) Recv() (string, error) { - if !g.scanner.Scan() { - return "", g.scanner.Err() - } - return g.scanner.Text(), nil -} - -func (g *GDBServer) Send(req string) error { - pck := g.formatPacket(req) - if _, err := g.rw.Write([]byte(pck)); err != nil { - return err - } - return nil -} - -func (g *GDBServer) Request(req string) (string, error) { - if err := g.Send(req); err != nil { - return "", err - } - return g.Recv() -} diff --git a/devices/ios/machomap/rsp.go b/devices/ios/machomap/rsp.go index c4e799fa..861fba96 100644 --- a/devices/ios/machomap/rsp.go +++ b/devices/ios/machomap/rsp.go @@ -10,10 +10,10 @@ import ( ) // RSP is a GDB Remote Serial Protocol client for the on-device debugserver. It -// differs from devices/ios/debugserver.GDBServer in that it fully decodes -// responses — run-length compression ('*') and byte escaping ('}') — which is -// required when we consume register and memory reads ourselves rather than -// forwarding raw bytes to an external lldb. Mirrors the proven raw_attach.py. +// fully decodes responses — run-length compression ('*') and byte escaping +// ('}') — which is required when we consume register and memory reads ourselves +// (rather than forwarding raw bytes to an external lldb). Mirrors the proven +// raw_attach.py prototype. type RSP struct { w io.Writer r *bufio.Reader diff --git a/devices/ios/machomap/runtime.go b/devices/ios/machomap/runtime.go new file mode 100644 index 00000000..ee014153 --- /dev/null +++ b/devices/ios/machomap/runtime.go @@ -0,0 +1,43 @@ +package machomap + +// Helpers for driving the target after a map: calling an exported function, and +// checking whether a class is already registered (used to keep injection +// idempotent — mapping the same image twice fails on duplicate class pairs). + +// CallFunction borrows a thread and calls fn(args...) once, returning x0. Suitable +// for simple exported getters/setters; it uses the default return breakpoint, so +// callers should not use it for re-entrant functions (Map sets up a trampoline +// for those). +func CallFunction(c *RSP, fn uint64, args ...uint64) (uint64, error) { + t, err := NewTarget(c) + if err != nil { + return 0, err + } + defer func() { _ = t.Restore() }() + return t.Call(fn, args...) +} + +// ClassIsRegistered reports whether objc_getClass(name) is non-nil in the target. +func ClassIsRegistered(c *RSP, res Resolver, name string) (bool, error) { + h, err := res.Resolve([]string{"mmap", "objc_getClass"}) + if err != nil { + return false, err + } + t, err := NewTarget(c) + if err != nil { + return false, err + } + defer func() { _ = t.Restore() }() + scratch, err := t.Call(h["mmap"], 0, 0x1000, protRead|protWrite, mapAnon|mapPrivate, neg1, 0) + if err != nil { + return false, err + } + if err := c.WriteMem(scratch, append([]byte(name), 0), 0); err != nil { + return false, err + } + cls, err := t.Call(h["objc_getClass"], scratch) + if err != nil { + return false, err + } + return cls != 0, nil +} diff --git a/devices/ios_device_agent.go b/devices/ios_device_agent.go index edabee19..fd338a9b 100644 --- a/devices/ios_device_agent.go +++ b/devices/ios_device_agent.go @@ -5,15 +5,10 @@ import ( "encoding/json" "fmt" "net" - "os/exec" - "regexp" - "strconv" - "strings" "sync" "time" goios "github.com/danielpaulus/go-ios/ios" - "github.com/mobile-next/mobilecli/agents" iosutil "github.com/mobile-next/mobilecli/devices/ios" "github.com/mobile-next/mobilecli/utils" ) @@ -37,10 +32,6 @@ import ( // already-running agent without scanning or persisting state between runs. const iosDeviceAgentPort = 12008 -// deviceAgentPortRE extracts the bound port from LLDB's expression result line, -// e.g. "(int) $0 = 12008". -var deviceAgentPortRE = regexp.MustCompile(`\$\d+\s*=\s*(\d+)`) - // deviceAgentPortCache maps device UDID → local TCP port of its injected agent. // This is intentionally separate from the simulator's (udid, bundleID) cache in // ios_webview.go: a real device runs a single injected agent per device. @@ -164,17 +155,10 @@ func (d *IOSDevice) injectFreshAgent() (int, error) { return 0, fmt.Errorf("no foreground app (pid 0) — open an app first") } - utils.Verbose("injecting agent into %s (pid %d) via LLDB", activeApp.BundleID, activeApp.ProcessID) - lldbProxyPort, cancelProxy, err := startLLDBProxy(device, proxyPort, activeApp.ProcessID) - if err != nil { - return 0, fmt.Errorf("start lldb proxy: %w", err) - } - defer cancelProxy() - utils.Verbose("LLDB proxy listening on localhost:%d", lldbProxyPort) - - devicePort, err := injectServerViaLLDB(lldbProxyPort) + utils.Verbose("mapping agent dylib into %s (pid %d)", activeApp.BundleID, activeApp.ProcessID) + devicePort, err := injectAgentNative(device, proxyPort, activeApp.ProcessID) if err != nil { - return 0, fmt.Errorf("inject server via lldb: %w", err) + return 0, fmt.Errorf("inject agent: %w", err) } utils.Verbose("agent started on device port %d", devicePort) @@ -217,45 +201,6 @@ func iosDeviceDebugProxyPort(device goios.DeviceEntry) (int, error) { return p, nil } -// injectServerViaLLDB connects LLDB to the proxy (which has already attached to -// the target process), evaluates the agent expression to start a persistent HTTP -// server inside the app, and returns the device-side TCP port. -func injectServerViaLLDB(localProxyPort int) (int, error) { - const lldbTimeout = 120 * time.Second - ctx, cancel := context.WithTimeout(context.Background(), lldbTimeout) - defer cancel() - - utils.Verbose("running LLDB (timeout %s)", lldbTimeout) - cmd := exec.CommandContext(ctx, "lldb", - "-o", "settings set target.process.memory-cache-line-size 16384", - "-o", "platform select remote-ios", - "-o", fmt.Sprintf("process connect connect://localhost:%d", localProxyPort), - // The leading newline after "--" is required: it puts LLDB into multi-line - // expression mode so the whole agent source is treated as one expression - // (without it, LLDB runs only the first line and parses the rest as commands). - "-o", "expr -l objc -- \n"+agents.IOSRealDeviceWebViewAgent, - "-o", "detach", - "-o", "quit", - ) - out, err := cmd.CombinedOutput() - utils.Verbose("LLDB finished (err=%v), output:\n%s", err, out) - if err != nil { - return 0, fmt.Errorf("lldb: %w\noutput:\n%s", err, out) - } - - for _, line := range strings.Split(string(out), "\n") { - m := deviceAgentPortRE.FindStringSubmatch(line) - if m == nil { - continue - } - port, err := strconv.Atoi(m[1]) - if err == nil && port == iosDeviceAgentPort { - return port, nil - } - } - return 0, fmt.Errorf("could not parse port from lldb output:\n%s", out) -} - // freeLocalPort asks the kernel for an unused local TCP port (bind :0, read it // back, release it). Only the device-side agent port is fixed // (iosDeviceAgentPort, for cross-run reuse discovery); the local end of the diff --git a/devices/ios_device_agent_native.go b/devices/ios_device_agent_native.go new file mode 100644 index 00000000..1ed83ffc --- /dev/null +++ b/devices/ios_device_agent_native.go @@ -0,0 +1,88 @@ +package devices + +import ( + "fmt" + "io" + "strings" + + goios "github.com/danielpaulus/go-ios/ios" + "github.com/mobile-next/mobilecli/agents" + "github.com/mobile-next/mobilecli/devices/ios/machomap" + "github.com/mobile-next/mobilecli/utils" +) + +// injectAgentNative maps the compiled agent dylib into the foreground app over +// the developer debugserver and runs its initializer, with no code signing and +// no re-signing (the CS_DEBUGGED exemption lets the debugger execute the pages +// it wrote). It replaces the old LLDB `expr -l objc` source-injection path and +// works where dlopen does not (AWS Device Farm). Returns the device-side TCP port +// the agent's server bound. +func injectAgentNative(device goios.DeviceEntry, proxyPort, pid int) (int, error) { + conn, err := goios.ConnectTUNDevice(device.Address, proxyPort, device) + if err != nil { + return 0, fmt.Errorf("connect debug proxy: %w", err) + } + defer func() { _ = conn.Close() }() + return mapAgentOverConn(conn, pid) +} + +// mapAgentOverConn runs the attach + map + boot sequence on an already-opened +// debugserver stream. Split out from injectAgentNative so the logic can be tested +// over any transport (e.g. the go-ios userspace-tunnel forwarder). +func mapAgentOverConn(conn io.ReadWriter, pid int) (int, error) { + rsp, err := machomap.NewRSP(conn) + if err != nil { + return 0, fmt.Errorf("debugserver handshake: %w", err) + } + if r, err := rsp.Request("QSetDetachOnError:1"); err != nil || r != "OK" { + return 0, fmt.Errorf("QSetDetachOnError: resp=%q err=%v", r, err) + } + stop, err := rsp.Request(fmt.Sprintf("vAttach;%x", pid)) + if err != nil || !strings.HasPrefix(stop, "T") { + return 0, fmt.Errorf("vAttach pid %d: resp=%q err=%v", pid, stop, err) + } + defer func() { _, _ = rsp.Request("D") }() + + resolver := machomap.NewExportTrieResolver(rsp) + + // Idempotency: never map the same image twice into one process — duplicate + // ObjC class registration fails and leaves the image half-wired. If the agent + // is already present (e.g. after a daemon restart while the app kept running), + // the in-process port cache is gone and the agent's ephemeral port cannot be + // recovered, so ask the caller to relaunch the app. + if already, err := machomap.ClassIsRegistered(rsp, resolver, "MobileServer"); err != nil { + return 0, fmt.Errorf("probe existing agent: %w", err) + } else if already { + return 0, fmt.Errorf("agent already injected in pid %d; relaunch the app to re-inject", pid) + } + + res, err := machomap.Map(rsp, agents.IOSAgentDevDylib, resolver, machomap.Options{RunInit: true}) + if err != nil { + return 0, fmt.Errorf("map agent dylib: %w", err) + } + utils.Verbose("native map: base=%#x size=%#x rebases=%d binds=%d classes=%d categories=%d", + res.Base, res.ImageSize, res.Rebases, res.Binds, res.Classes, res.Categories) + if len(res.Unresolved) > 0 { + return 0, fmt.Errorf("agent has %d unresolved imports: %v", len(res.Unresolved), res.Unresolved) + } + + // Read the server's bound port from the agent's exported getter. + m, err := machomap.ParseMacho(agents.IOSAgentDevDylib) + if err != nil { + return 0, err + } + off, ok := m.Symbols["_mobilecli_get_port"] + if !ok { + return 0, fmt.Errorf("agent dylib missing mobilecli_get_port") + } + port, err := machomap.CallFunction(rsp, res.Base+off) + if err != nil { + return 0, fmt.Errorf("read agent port: %w", err) + } + p := int(uint32(port)) + if p == 0 { + return 0, fmt.Errorf("agent initializer ran but server did not bind") + } + utils.Verbose("native map: agent server bound device port %d", p) + return p, nil +} diff --git a/devices/ios_device_agent_native_test.go b/devices/ios_device_agent_native_test.go new file mode 100644 index 00000000..1cb22174 --- /dev/null +++ b/devices/ios_device_agent_native_test.go @@ -0,0 +1,65 @@ +package devices + +import ( + "encoding/binary" + "fmt" + "net" + "os" + "strconv" + "testing" + "time" +) + +// theNativeAgentMapperBootsTheAgentOverTheTunnel maps the embedded agent dylib +// into a live foreground app and confirms its server bound a port — the full +// injectAgentNative path minus the go-ios transport, exercised over the userspace +// tunnel forwarder so it needs no WDA/devicekit setup. +// +// Gated on NATIVE_INJECT_TEST; skipped otherwise so CI without a device stays +// green. Env: NATIVE_INJECT_HOST (tunnel IPv6), NATIVE_INJECT_TUNNEL_PORT (local +// forwarder), NATIVE_INJECT_PORT (device debugproxy), NATIVE_INJECT_PID (a FRESH +// foreground-app pid — the agent must not already be injected). +func TestNativeAgentMapperBootsOverTunnel(t *testing.T) { + if os.Getenv("NATIVE_INJECT_TEST") == "" { + t.Skip("set NATIVE_INJECT_TEST=1 with a connected device to run") + } + host := os.Getenv("NATIVE_INJECT_HOST") + tunnelPort := mustAtoi(t, "NATIVE_INJECT_TUNNEL_PORT") + proxyPort := mustAtoi(t, "NATIVE_INJECT_PORT") + pid := mustAtoi(t, "NATIVE_INJECT_PID") + + conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", tunnelPort), 8*time.Second) + if err != nil { + t.Fatalf("dial forwarder: %v", err) + } + defer func() { _ = conn.Close() }() + // go-ios userspace-tunnel forwarder header: 16-byte IPv6 + LE uint32 port + ip := net.ParseIP(host).To16() + if ip == nil { + t.Fatalf("bad NATIVE_INJECT_HOST %q", host) + } + hdr := make([]byte, 20) + copy(hdr[:16], ip) + binary.LittleEndian.PutUint32(hdr[16:], uint32(proxyPort)) + if _, err := conn.Write(hdr); err != nil { + t.Fatalf("write forwarder header: %v", err) + } + + port, err := mapAgentOverConn(conn, pid) + if err != nil { + t.Fatalf("mapAgentOverConn: %v", err) + } + if port == 0 { + t.Fatal("agent mapped but server bound no port") + } + t.Logf("agent booted; server bound device port %d", port) +} + +func mustAtoi(t *testing.T, env string) int { + t.Helper() + v, err := strconv.Atoi(os.Getenv(env)) + if err != nil { + t.Fatalf("%s: %v", env, err) + } + return v +} diff --git a/devices/ios_device_lldbproxy.go b/devices/ios_device_lldbproxy.go deleted file mode 100644 index a74e52d2..00000000 --- a/devices/ios_device_lldbproxy.go +++ /dev/null @@ -1,181 +0,0 @@ -package devices - -import ( - "context" - "fmt" - "io" - "net" - "strings" - - goios "github.com/danielpaulus/go-ios/ios" - "github.com/mobile-next/mobilecli/devices/ios/debugserver" - "github.com/mobile-next/mobilecli/utils" -) - -// This file is the low-level GDB Remote Serial Protocol bridge that lets the -// system `lldb` binary talk to a process on the device. The device's debugserver -// is reachable only through the CoreDevice debug proxy (go-ios); lldb expects to -// connect to a local debugserver. startLLDBProxy/lldbProxyConn bridge the two. -// -// It is feature-agnostic: any injected-agent feature (webview today; view-tree, -// network capture, etc. later) relies on it to evaluate code in the target. - -// startLLDBProxy pre-attaches to pid on the device via the debug proxy, then -// starts a local TCP listener for LLDB. Pre-attaching before listening ensures -// the proxy can respond to LLDB's handshake immediately upon connection. -// Returns the local port and a stop function. -func startLLDBProxy(device goios.DeviceEntry, proxyPort, pid int) (int, func(), error) { - utils.Verbose("lldb-proxy: connecting to device debug proxy port %d", proxyPort) - devConn, err := goios.ConnectTUNDevice(device.Address, proxyPort, device) - if err != nil { - return 0, nil, fmt.Errorf("lldb-proxy: connect to device: %w", err) - } - - devGDB := debugserver.NewGDBServer(devConn) - utils.Verbose("lldb-proxy: pre-attaching to pid %d", pid) - stopReply, err := devGDB.Request(fmt.Sprintf("vAttach;%x", pid)) - if err != nil || !strings.HasPrefix(stopReply, "T") { - _ = devConn.Close() - return 0, nil, fmt.Errorf("lldb-proxy: vAttach pid %d: err=%v resp=%q", pid, err, stopReply) - } - utils.Verbose("lldb-proxy: pre-attached, stop=%q", stopReply) - - ln, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", "127.0.0.1:0") - if err != nil { - _ = devConn.Close() - return 0, nil, fmt.Errorf("listen for lldb proxy: %w", err) - } - addr, ok := ln.Addr().(*net.TCPAddr) - if !ok { - _ = ln.Close() - _ = devConn.Close() - return 0, nil, fmt.Errorf("lldb proxy listener address is %T, not TCP", ln.Addr()) - } - localPort := addr.Port - go func() { - defer func() { _ = ln.Close() }() - defer func() { _ = devConn.Close() }() - conn, err := ln.Accept() - if err != nil { - return - } - lldbProxyConn(conn, devGDB, pid) - }() - return localPort, func() { _ = ln.Close() }, nil -} - -// lldbProxyConn is a GDB RSP bridge between LLDB and an already-attached -// device debugserver. Handles negotiation packets locally, forwards all others -// packet-by-packet with ack-mode translation (LLDB: no-ack; device: ack). -func lldbProxyConn(c net.Conn, devGDB *debugserver.GDBServer, pid int) { - defer func() { _ = c.Close() }() - - // debugserver sends '+' immediately upon accepting a connection; - // LLDB waits for this before sending the first packet. - c.Write([]byte("+")) //nolint:errcheck - - noAck := false - - sendToLLDB := func(pkt string) { - ck := gdbChecksum(pkt) - var s string - if !noAck { - s = "+" - } - s += fmt.Sprintf("$%s#%02x", pkt, ck) - c.Write([]byte(s)) //nolint:errcheck - } - - for { - pkt, err := readGDBPacket(c) - if err != nil { - return - } - utils.Verbose("lldb-proxy ← LLDB: %.300s", pkt) - - // switchToNoAck is set by QStartNoAckMode and applied AFTER sendToLLDB - // so the OK response goes out in ack mode (with '+') as LLDB expects. - switchToNoAck := false - var reply string - switch { - case pkt == "QStartNoAckMode": - reply = "OK" - switchToNoAck = true - - case strings.HasPrefix(pkt, "qSupported"): - reply = "PacketSize=65536;vContSupported+" - - case pkt == "QThreadSuffixSupported", - pkt == "QListThreadsInStopReply", - pkt == "qVAttachOrWaitSupported", - pkt == "QEnableErrorStrings": - reply = "OK" - - case strings.HasPrefix(pkt, "vCont?"): - reply = "vCont;c;C;s;S" - - case pkt == "k": - // LLDB wants to kill — detach instead so the app keeps running - devGDB.Request(fmt.Sprintf("D;%x", pid)) //nolint:errcheck - return - - case strings.HasPrefix(pkt, "D"): - devReply, err := devGDB.Request(pkt) - utils.Verbose("lldb-proxy → LLDB (detach): %d bytes, err=%v", len(devReply), err) - sendToLLDB(devReply) - return - - default: - // forward to device (devGDB uses ack mode: sends "+$pkt#XX") - devReply, err := devGDB.Request(pkt) - if err != nil { - utils.Verbose("lldb-proxy: device error for %q: %v", pkt[:min(len(pkt), 40)], err) - return - } - reply = devReply - } - - utils.Verbose("lldb-proxy → LLDB: %d bytes", len(reply)) - sendToLLDB(reply) - if switchToNoAck { - noAck = true - } - } -} - -func gdbChecksum(pkt string) byte { - var sum byte - for i := 0; i < len(pkt); i++ { - sum += pkt[i] - } - return sum -} - -// readGDBPacket reads one "$pkt#XX" packet and returns pkt; bytes before the -// '$' (acks) and the checksum are dropped. -func readGDBPacket(r io.Reader) (string, error) { - buf := make([]byte, 1) - for { - if _, err := io.ReadFull(r, buf); err != nil { - return "", err - } - if buf[0] == '$' { - break - } - } - var pkt strings.Builder - for { - if _, err := io.ReadFull(r, buf); err != nil { - return "", err - } - if buf[0] == '#' { - break - } - pkt.WriteByte(buf[0]) - } - cksumBuf := make([]byte, 2) - if _, err := io.ReadFull(r, cksumBuf); err != nil { - return "", err - } - return pkt.String(), nil -} From 37354830e5ce58f911f1a0754c4cf7836d3503ab Mon Sep 17 00:00:00 2001 From: Mobile Kevin Bot! Date: Wed, 4 Nov 2026 21:34:03 +0100 Subject: [PATCH 12/18] ios/machomap: review cleanups (naming, dead code) - Map: rename the Result local 'res0' -> 'result' and the Resolver param 'res' -> 'resolver' (the two were one character apart and easy to confuse) - lookupExportTrie: drop the unused flagsKindMask/flagAbsolute placeholders and the '_ =' lines; document that it assumes a regular export and errors on re-exports/stub-resolvers No behavior change; device injection still verified. --- devices/ios/machomap/exporttrie.go | 9 +++------ devices/ios/machomap/mapper.go | 24 ++++++++++++------------ 2 files changed, 15 insertions(+), 18 deletions(-) diff --git a/devices/ios/machomap/exporttrie.go b/devices/ios/machomap/exporttrie.go index 3126d9bf..7015d554 100644 --- a/devices/ios/machomap/exporttrie.go +++ b/devices/ios/machomap/exporttrie.go @@ -268,12 +268,11 @@ func indexByte(b []byte, c byte) int { } // lookupExportTrie walks the dyld export trie for symbol and returns its address -// offset (from the image base) if found. Handles regular and absolute exports; -// errors on reexports (not expected for our bootstrap symbols). +// offset (from the image base) if found. It assumes a regular export (which the +// bootstrap symbols all are) and errors on re-exports and stub-resolvers rather +// than resolving them incorrectly. func lookupExportTrie(trie []byte, symbol string) (uint64, bool, error) { const ( - flagsKindMask = 0x03 - flagAbsolute = 0x02 flagReexport = 0x08 flagStubAndReso = 0x10 ) @@ -295,8 +294,6 @@ func lookupExportTrie(trie []byte, symbol string) (uint64, bool, error) { return 0, false, fmt.Errorf("unsupported export kind (flags=%#x)", flags) } addr, _ := uleb(trie, p) - _ = flags & flagsKindMask - _ = flagAbsolute return addr, true, nil } // skip this node's terminal payload to reach the child list diff --git a/devices/ios/machomap/mapper.go b/devices/ios/machomap/mapper.go index 0f1cd66f..389bcd71 100644 --- a/devices/ios/machomap/mapper.go +++ b/devices/ios/machomap/mapper.go @@ -57,7 +57,7 @@ type Result struct { // initializers (unless Options.RunInit is false). The process must be attached // (QSetDetachOnError + vAttach) by the caller; Map borrows a thread and restores // it before returning. -func Map(c *RSP, dylib []byte, res Resolver, opt Options) (*Result, error) { +func Map(c *RSP, dylib []byte, resolver Resolver, opt Options) (*Result, error) { m, err := ParseMacho(dylib) if err != nil { return nil, err @@ -68,7 +68,7 @@ func Map(c *RSP, dylib []byte, res Resolver, opt Options) (*Result, error) { chunk = 1024 } - helpers, err := res.Resolve(bootstrapNames) + helpers, err := resolver.Resolve(bootstrapNames) if err != nil { return nil, fmt.Errorf("resolve bootstrap helpers: %w", err) } @@ -85,7 +85,7 @@ func Map(c *RSP, dylib []byte, res Resolver, opt Options) (*Result, error) { defer func() { _ = t.Restore() }() mmap := helpers["mmap"] - res0 := &Result{ImageSize: imageSize} + result := &Result{ImageSize: imageSize} // 1. reserve the image r-x base, err := t.Call(mmap, 0, imageSize, protRead|protExec, mapAnon|mapPrivate, neg1, 0) @@ -95,7 +95,7 @@ func Map(c *RSP, dylib []byte, res Resolver, opt Options) (*Result, error) { if base == neg1 { return nil, fmt.Errorf("reserve mmap failed (MAP_FAILED)") } - res0.Base = base + result.Base = base // 2. MAP_FIXED-remap writable segments rw for _, s := range mapped { @@ -143,7 +143,7 @@ func Map(c *RSP, dylib []byte, res Resolver, opt Options) (*Result, error) { if err != nil { return nil, err } - res0.Imports = len(imports) + result.Imports = len(imports) // stage import name strings (one write) + a pointer table + an output table var blob []byte offs := make([]int, len(imports)) @@ -177,14 +177,14 @@ func Map(c *RSP, dylib []byte, res Resolver, opt Options) (*Result, error) { for i := range imports { importAddrs[i] = binary.LittleEndian.Uint64(raw[i*8:]) if importAddrs[i] == 0 { - res0.Unresolved = append(res0.Unresolved, imports[i]) + result.Unresolved = append(result.Unresolved, imports[i]) } } rb, bn, err := ApplyFixups(image, segs, base, importAddrs) if err != nil { return nil, err } - res0.Rebases, res0.Binds = rb, bn + result.Rebases, result.Binds = rb, bn } // 5. write the fixed-up image @@ -199,7 +199,7 @@ func Map(c *RSP, dylib []byte, res Resolver, opt Options) (*Result, error) { if _, err := t.Call(stubMem+StubSelrefOff, helpers["sel_registerName"], sel, base+sr.Addr); err != nil { return nil, err } - res0.Selrefs = int(sel) + result.Selrefs = int(sel) } cl := m.Sections["__objc_classlist"] var imageinfo uint64 @@ -209,13 +209,13 @@ func Map(c *RSP, dylib []byte, res Resolver, opt Options) (*Result, error) { if _, err := t.Call(stubMem+StubClassOff, helpers["objc_readClassPair"], n, base+cl.Addr, imageinfo); err != nil { return nil, err } - res0.Classes = int(n) + result.Classes = int(n) } // 6b. apply categories (objc_readClassPair does not, and there is no public API // to attach one from a mapped image) — before init, which may swizzle them. if m.SectionCount("__objc_catlist") > 0 { - ch, err := res.Resolve(categoryHelpers) + ch, err := resolver.Resolve(categoryHelpers) if err != nil { return nil, fmt.Errorf("resolve category helpers: %w", err) } @@ -223,7 +223,7 @@ func Map(c *RSP, dylib []byte, res Resolver, opt Options) (*Result, error) { if err != nil { return nil, err } - res0.Categories = applied + result.Categories = applied } // 7. run initializers (starts the agent) unless suppressed @@ -238,7 +238,7 @@ func Map(c *RSP, dylib []byte, res Resolver, opt Options) (*Result, error) { } } - return res0, nil + return result, nil } // stripOneUnderscore removes exactly one leading linker underscore (not all). From 8813d6fe2368461fb43ed1cab1f76bfcc4db92ee Mon Sep 17 00:00:00 2001 From: Mobile Kevin Bot! Date: Wed, 4 Nov 2026 21:44:22 +0100 Subject: [PATCH 13/18] ios/machomap: decompose Map into a mapSession, name constants, drop probe Review follow-ups: - Map was a ~180-line, 7-step function mixing abstraction levels. Split it into a mapSession with one method per step (reserveImage, remapWritableSegments, setupScratchAndStubs, bindImports, writeImage, registerClasses, applyImageCategories, runInitializers); Map now reads as the step list. - Name the magic numbers: scratchSize, stubPageSize, landingPadOffset, defaultWriteChunk. - registerClasses looks up __objc_selrefs once instead of SectionCount + Sections. - Remove the throwaway cmd/mapprobe; TestNativeAgentMapperBootsOverTunnel is now the device-level check. No behavior change; re-verified on iPhone12,1 iOS 26.5.2 (agent boots, server binds, swizzle installs). --- devices/ios/machomap/cmd/mapprobe/main.go | 305 -------------------- devices/ios/machomap/mapper.go | 337 +++++++++++++--------- 2 files changed, 207 insertions(+), 435 deletions(-) delete mode 100644 devices/ios/machomap/cmd/mapprobe/main.go diff --git a/devices/ios/machomap/cmd/mapprobe/main.go b/devices/ios/machomap/cmd/mapprobe/main.go deleted file mode 100644 index 3cbb4d22..00000000 --- a/devices/ios/machomap/cmd/mapprobe/main.go +++ /dev/null @@ -1,305 +0,0 @@ -// Command mapprobe is a throwaway on-device proof harness for the machomap -// package (Stage 1b). It dials the go-ios userspace tunnel forwarder exactly -// like the Python prototype (16-byte IPv6 + little-endian port), attaches to a -// pid, and maps a dylib with the host-files resolver so the Go result can be -// compared against inject_map.py. The production path (Stage 2) uses -// goios.ConnectTUNDevice instead of this forwarder and is wired into the device -// agent; this command is not shipped. -package main - -import ( - "encoding/binary" - "flag" - "fmt" - "net" - "os" - "time" - - "github.com/mobile-next/mobilecli/devices/ios/machomap" -) - -func main() { - host := flag.String("host", "", "device tunnel IPv6 address") - tunnelPort := flag.Int("tunnel-port", 0, "userspace tunnel forwarder local port") - port := flag.Int("port", 0, "device debugproxy port") - pid := flag.Int("pid", 0, "target process id") - dylibPath := flag.String("dylib", "/tmp/agent-dev.dylib", "dylib to map") - runInit := flag.Bool("init", false, "run the dylib initializer (starts the agent)") - compare := flag.Bool("compare", false, "resolve helpers via host-files AND export-trie and compare, then exit") - useTrie := flag.Bool("trie", false, "use the export-trie (host-independent) resolver for the map") - bindtest := flag.Bool("bindtest", false, "test a loopback socket+bind in the target, then exit") - servertest := flag.Bool("servertest", false, "map, then drive MobileServer alloc/init/bind via the runtime") - symbolRoot := flag.String("symbol-root", - "/Users/gilm/Library/Developer/Xcode/iOS DeviceSupport/iPhone12,1 26.5.2 (23F84)/Symbols/usr/lib", - "Xcode device symbols root") - flag.Parse() - - if *host == "" || *tunnelPort == 0 || *port == 0 || *pid == 0 { - fmt.Fprintln(os.Stderr, "need --host --tunnel-port --port --pid") - os.Exit(2) - } - - dylib, err := os.ReadFile(*dylibPath) - check(err) - - start := time.Now() - conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", *tunnelPort), 8*time.Second) - check(err) - defer func() { _ = conn.Close() }() - _ = conn.(*net.TCPConn).SetNoDelay(true) - - // userspace-tunnel forwarder header: 16-byte IPv6 + LE uint32 destination port - ip := net.ParseIP(*host).To16() - if ip == nil { - fatal("bad --host IPv6 %q", *host) - } - hdr := make([]byte, 20) - copy(hdr[:16], ip) - binary.LittleEndian.PutUint32(hdr[16:], uint32(*port)) - _, err = conn.Write(hdr) - check(err) - - c, err := machomap.NewRSP(conn) - check(err) - tConn := time.Now() - - if resp, err := c.Request("QSetDetachOnError:1"); err != nil || resp != "OK" { - fatal("QSetDetachOnError: resp=%q err=%v", resp, err) - } - stop, err := c.Request(fmt.Sprintf("vAttach;%x", *pid)) - check(err) - if len(stop) == 0 || stop[0] != 'T' { - fatal("vAttach failed: %q", stop) - } - fmt.Printf("CONNECT %.3fs vATTACH %.3fs\n", tConn.Sub(start).Seconds(), time.Since(tConn).Seconds()) - - helperNames := []string{"mmap", "mprotect", "dlsym", "sel_registerName", "objc_readClassPair"} - if *compare { - hf, err := machomap.NewHostFilesResolver(c, *symbolRoot).Resolve(helperNames) - check(err) - et, err := machomap.NewExportTrieResolver(c).Resolve(helperNames) - check(err) - allMatch := true - for _, n := range helperNames { - ok := hf[n] == et[n] - allMatch = allMatch && ok - tag := "MATCH" - if !ok { - tag = "MISMATCH" - } - fmt.Printf(" %-20s host-files=%#x export-trie=%#x %s\n", n, hf[n], et[n], tag) - } - if allMatch { - fmt.Println("ALL MATCH — export-trie resolver verified") - } else { - fmt.Println("MISMATCH — export-trie resolver is wrong") - } - _, _ = c.Request("D") - return - } - - if *servertest { - // map first so MobileServer is registered, then drive it via the runtime - mres, err := machomap.Map(c, dylib, machomap.NewExportTrieResolver(c), machomap.Options{RunInit: false}) - if err != nil { - fatal("map: %v", err) - } - // compile-time ivar-offset globals (what the compiled methods read) vs runtime - if mm, e := machomap.ParseMacho(dylib); e == nil { - ht, _ := machomap.NewExportTrieResolver(c).Resolve([]string{"mmap", "class_getInstanceVariable", "ivar_getOffset", "objc_getClass"}) - tt, _ := machomap.NewTarget(c) - sc, _ := tt.Call(ht["mmap"], 0, 0x1000, 3, 0x1002, ^uint64(0), 0) - cu := sc - put := func(s string) uint64 { - b := append([]byte(s), 0) - _ = c.WriteMem(cu, b, 0) - p := cu - cu += uint64(len(b)) - return p - } - srvCls, _ := tt.Call(ht["objc_getClass"], put("MobileServer")) - for _, iv := range []string{"_serverFd", "_port"} { - off, ok := mm.Symbols["_OBJC_IVAR_$_MobileServer."+iv] - if !ok { - fmt.Printf("ivar %s: no symbol\n", iv) - continue - } - gb, _ := c.Memory(mres.Base+off, 8) - compiled := uint64(gb[0]) | uint64(gb[1])<<8 | uint64(gb[2])<<16 | uint64(gb[3])<<24 - ivar, _ := tt.Call(ht["class_getInstanceVariable"], srvCls, put(iv)) - rt, _ := tt.Call(ht["ivar_getOffset"], ivar) - fmt.Printf("ivar %-10s compiled-global=%d runtime=%d %s\n", iv, compiled, rt&0xffffffff, - map[bool]string{true: "MATCH", false: "MISMATCH"}[compiled == rt&0xffffffff]) - } - _ = tt.Restore() - } - h, err := machomap.NewExportTrieResolver(c).Resolve([]string{"mmap", "objc_getClass", "objc_msgSend", "sel_registerName"}) - check(err) - t, err := machomap.NewTarget(c) - check(err) - defer func() { _ = t.Restore() }() - scratch, err := t.Call(h["mmap"], 0, 0x1000, 3, 0x1002, ^uint64(0), 0) - check(err) - cur := scratch - put := func(s string) uint64 { - b := append([]byte(s), 0) - check(c.WriteMem(cur, b, 0)) - p := cur - cur += uint64(len(b)) - return p - } - call := func(fn uint64, a ...uint64) uint64 { v, e := t.Call(fn, a...); check(e); return v } - sel := func(s string) uint64 { return call(h["sel_registerName"], put(s)) } - he, _ := machomap.NewExportTrieResolver(c).Resolve([]string{"__error"}) - errnoNow := func() uint64 { - if ep, e := t.Call(he["__error"]); e == nil && ep != 0 { - if b, e2 := c.Memory(ep, 4); e2 == nil { - return uint64(b[0]) | uint64(b[1])<<8 - } - } - return 0 - } - clearErrno := func() { - if ep, e := t.Call(he["__error"]); e == nil && ep != 0 { - _ = c.WriteMem(ep, []byte{0, 0, 0, 0}, 0) - } - } - // compare how the agent's imports bind (dlsym RTLD_DEFAULT) vs the specific - // libsystem_kernel export (trie) for listen — a mismatch means RTLD_DEFAULT - // picked the wrong image, which would break the agent's listen() call. - hd, _ := machomap.NewExportTrieResolver(c).Resolve([]string{"dlsym", "listen"}) - listenDlsym := call(hd["dlsym"], ^uint64(0)-1 /*RTLD_DEFAULT*/, put("listen")) - socketDlsym := call(hd["dlsym"], ^uint64(0)-1, put("socket")) - fmt.Printf("listen: trie=%#x dlsym(RTLD_DEFAULT)=%#x %s\n", hd["listen"], listenDlsym, - map[bool]string{true: "MATCH", false: "MISMATCH"}[hd["listen"] == listenDlsym]) - fmt.Printf("socket dlsym=%#x\n", socketDlsym) - cls := call(h["objc_getClass"], put("MobileServer")) - obj := call(h["objc_msgSend"], cls, sel("alloc")) - obj = call(h["objc_msgSend"], obj, sel("initWithHandler:"), 0) - // sanity: a method that definitely returns YES, to validate BOOL reads - resp := call(h["objc_msgSend"], obj, sel("respondsToSelector:"), sel("bind")) - fmt.Printf("respondsToSelector(bind)=%d (want 1)\n", resp&0xff) - // decompose on a fresh object: -bindPort alone - o2 := call(h["objc_msgSend"], cls, sel("alloc")) - o2 = call(h["objc_msgSend"], o2, sel("initWithHandler:"), 0) - clearErrno() - bp := call(h["objc_msgSend"], o2, sel("bindPort")) - bpErr := errnoNow() - p2 := call(h["objc_msgSend"], o2, sel("port")) - fmt.Printf("-bindPort=%d (errno=%d) -port=%d\n", bp&0xff, bpErr, p2&0xffffffff) - // read the agent socket's fd via its _serverFd ivar, then listen() on it directly - hi, _ := machomap.NewExportTrieResolver(c).Resolve([]string{"class_getInstanceVariable", "ivar_getOffset", "listen"}) - ivar := call(hi["class_getInstanceVariable"], cls, put("_serverFd")) - ivoff := call(hi["ivar_getOffset"], ivar) - fdb, _ := c.Memory(o2+ivoff, 4) - fd := uint64(fdb[0]) | uint64(fdb[1])<<8 | uint64(fdb[2])<<16 | uint64(fdb[3])<<24 - clearErrno() - ld := call(hi["listen"], fd, 8) - fmt.Printf("agent _serverFd=%d direct listen(fd,8)=%d (errno=%d)\n", - int32(uint32(fd)), int32(uint32(ld)), errnoNow()) - - clearErrno() - bnd := call(h["objc_msgSend"], obj, sel("bind")) - bErrno := errnoNow() - port := call(h["objc_msgSend"], obj, sel("port")) - fmt.Printf("MobileServer cls=%#x alloc/init obj=%#x -bind=%d (errno-after=%d) -port=%d\n", - cls, obj, bnd&0xff, bErrno, port&0xffffffff) - _, _ = c.Request("D") - return - } - - if *bindtest { - h, err := machomap.NewExportTrieResolver(c).Resolve([]string{"mmap", "socket", "bind", "listen", "__error"}) - check(err) - t, err := machomap.NewTarget(c) - check(err) - defer func() { _ = t.Restore() }() - errnoNow := func() uint64 { - if ep, e := t.Call(h["__error"]); e == nil && ep != 0 { - if b, e2 := c.Memory(ep, 4); e2 == nil { - return uint64(b[0]) | uint64(b[1])<<8 - } - } - return 0 - } - scratch, err := t.Call(h["mmap"], 0, 0x1000, 3 /*RW*/, 0x1002 /*ANON|PRIVATE*/, ^uint64(0), 0) - check(err) - fd, err := t.Call(h["socket"], 2 /*AF_INET*/, 1 /*SOCK_STREAM*/, 0) - check(err) - // struct sockaddr_in: len=0, family=AF_INET(2), port=0, addr=127.0.0.1 - sa := []byte{0, 2, 0, 0, 0x7f, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0} - check(c.WriteMem(scratch, sa, 0)) - bret, err := t.Call(h["bind"], fd, scratch, 16) - check(err) - bErrno := errnoNow() - lret, err := t.Call(h["listen"], fd, 8) - check(err) - lErrno := errnoNow() - fmt.Printf("socket fd=%d bind ret=%d (errno=%d) listen ret=%d (errno=%d)\n", - int32(uint32(fd)), int32(uint32(bret)), bErrno, int32(uint32(lret)), lErrno) - _, _ = c.Request("D") - return - } - - var resolver machomap.Resolver = machomap.NewHostFilesResolver(c, *symbolRoot) - if *useTrie { - resolver = machomap.NewExportTrieResolver(c) - } - res, err := machomap.Map(c, dylib, resolver, machomap.Options{RunInit: *runInit}) - check(err) - - fmt.Printf("MAP base=%#x size=%#x\n", res.Base, res.ImageSize) - fmt.Printf("FIXUPS: %d rebases, %d binds (%d imports)\n", res.Rebases, res.Binds, res.Imports) - fmt.Printf("OBJC: %d selrefs, %d classes, %d category methods\n", res.Selrefs, res.Classes, res.Categories) - if len(res.Unresolved) > 0 { - fmt.Printf("UNRESOLVED (%d): %v\n", len(res.Unresolved), res.Unresolved) - } - if !*runInit { - fmt.Println("(initializer NOT run; --init to start the agent)") - } else { - // verify the agent booted: the constructor binds a control server (exported via - // mobilecli_get_port) and installs the NSURLSession swizzle, after which - // +[NSURLSessionConfiguration defaultSessionConfiguration]'s IMP points into our - // mapped image. - m, _ := machomap.ParseMacho(dylib) - hv, err := machomap.NewExportTrieResolver(c).Resolve([]string{"mmap", "objc_getClass", "object_getClass", "sel_registerName", "class_getMethodImplementation"}) - check(err) - t, err := machomap.NewTarget(c) - check(err) - defer func() { _ = t.Restore() }() - scratch, err := t.Call(hv["mmap"], 0, 0x1000, 3, 0x1002, ^uint64(0), 0) - check(err) - cur := scratch - put := func(s string) uint64 { - b := append([]byte(s), 0) - _ = c.WriteMem(cur, b, 0) - p := cur - cur += uint64(len(b)) - return p - } - call := func(fn uint64, a ...uint64) uint64 { v, e := t.Call(fn, a...); check(e); return v } - - if off, ok := m.Symbols["_mobilecli_get_port"]; ok { - port := call(res.Base+off) & 0xffffffff - fmt.Printf("control server: mobilecli_get_port() = %d (%s)\n", port, - map[bool]string{true: "UP", false: "not bound"}[port != 0]) - } - meta := call(hv["object_getClass"], call(hv["objc_getClass"], put("NSURLSessionConfiguration"))) - imp := call(hv["class_getMethodImplementation"], meta, call(hv["sel_registerName"], put("defaultSessionConfiguration"))) - fmt.Printf("network swizzle: +[NSURLSessionConfiguration defaultSessionConfiguration] IMP=%#x %s\n", imp, - map[bool]string{true: "INSTALLED (points into mapped agent)", false: "NOT installed"}[imp >= res.Base && imp < res.Base+res.ImageSize]) - } - _, _ = c.Request("D") - fmt.Printf("detached; total %.3fs\n", time.Since(start).Seconds()) -} - -func check(err error) { - if err != nil { - fatal("%v", err) - } -} - -func fatal(f string, a ...any) { - fmt.Fprintf(os.Stderr, "ERROR: "+f+"\n", a...) - os.Exit(1) -} diff --git a/devices/ios/machomap/mapper.go b/devices/ios/machomap/mapper.go index 389bcd71..977dd6bc 100644 --- a/devices/ios/machomap/mapper.go +++ b/devices/ios/machomap/mapper.go @@ -20,6 +20,13 @@ const ( mapFixed = 0x0010 neg1 = ^uint64(0) // -1 (mmap fd, and RTLD_DEFAULT base below) rtldDefault = ^uint64(0) - 1 + + scratchSize = 0x10000 // rw scratch for staged strings/tables + stubPageSize = 0x4000 // r-x page holding the loop stubs + landing pad + // landingPadOffset is the call-return breakpoint address within the stub + // page, placed well past the stub bytes so callee code never executes it. + landingPadOffset = 0x800 + defaultWriteChunk = 1024 ) // bootstrapNames are the runtime helpers the mapper itself needs; the Resolver @@ -37,7 +44,7 @@ type Resolver interface { // Options controls the map. type Options struct { RunInit bool // run the dylib's offset-based initializers (starts the agent) - WriteChunk int // M-packet chunk size; 0 => 1024 + WriteChunk int // M-packet chunk size; 0 => defaultWriteChunk } // Result reports what was mapped. @@ -63,10 +70,6 @@ func Map(c *RSP, dylib []byte, resolver Resolver, opt Options) (*Result, error) return nil, err } image, imageSize, mapped := m.BuildImage() - chunk := opt.WriteChunk - if chunk == 0 { - chunk = 1024 - } helpers, err := resolver.Resolve(bootstrapNames) if err != nil { @@ -84,161 +87,235 @@ func Map(c *RSP, dylib []byte, resolver Resolver, opt Options) (*Result, error) } defer func() { _ = t.Restore() }() - mmap := helpers["mmap"] - result := &Result{ImageSize: imageSize} + chunk := opt.WriteChunk + if chunk == 0 { + chunk = defaultWriteChunk + } + s := &mapSession{ + c: c, t: t, m: m, dylib: dylib, image: image, chunk: chunk, + helpers: helpers, resolver: resolver, + result: &Result{ImageSize: imageSize}, + } - // 1. reserve the image r-x - base, err := t.Call(mmap, 0, imageSize, protRead|protExec, mapAnon|mapPrivate, neg1, 0) + steps := []func() error{ + func() error { return s.reserveImage(imageSize) }, + func() error { return s.remapWritableSegments(mapped) }, + s.setupScratchAndStubs, + s.bindImports, + s.writeImage, + s.registerClasses, + s.applyImageCategories, + func() error { + if opt.RunInit { + return s.runInitializers() + } + return nil + }, + } + for _, step := range steps { + if err := step(); err != nil { + return nil, err + } + } + return s.result, nil +} + +// mapSession holds the state shared across the steps of a single Map call. +type mapSession struct { + c *RSP + t *Target + m *Macho + dylib []byte + image []byte + base uint64 + chunk int + scratch uint64 // next free offset in the rw scratch region + stub uint64 // base of the r-x stub page + helpers map[string]uint64 + resolver Resolver + result *Result +} + +// alloc hands out 8-byte-aligned chunks of the rw scratch region. +func (s *mapSession) alloc(size uint64) uint64 { + s.scratch = (s.scratch + 7) &^ 7 + p := s.scratch + s.scratch += size + return p +} + +// reserveImage mmaps the whole image r-x (executable). iOS fixes max_protection +// at mmap time, so EXEC must be requested now; the writable segments are then +// remapped rw over this reservation. +func (s *mapSession) reserveImage(imageSize uint64) error { + base, err := s.t.Call(s.helpers["mmap"], 0, imageSize, protRead|protExec, mapAnon|mapPrivate, neg1, 0) if err != nil { - return nil, err + return err } if base == neg1 { - return nil, fmt.Errorf("reserve mmap failed (MAP_FAILED)") + return fmt.Errorf("reserve mmap failed (MAP_FAILED)") } - result.Base = base + s.base = base + s.result.Base = base + return nil +} - // 2. MAP_FIXED-remap writable segments rw - for _, s := range mapped { - if s.Name != "__TEXT" && s.InitProt&vmProtWrite != 0 { - got, err := t.Call(mmap, base+s.VMAddr, s.VMSize, protRead|protWrite, - mapAnon|mapPrivate|mapFixed, neg1, 0) - if err != nil { - return nil, err - } - if got != base+s.VMAddr { - return nil, fmt.Errorf("MAP_FIXED %s @ %#x -> %#x", s.Name, base+s.VMAddr, got) - } +// remapWritableSegments MAP_FIXED-remaps each writable, non-__TEXT segment rw. +func (s *mapSession) remapWritableSegments(mapped []Segment) error { + for _, seg := range mapped { + if seg.Name == "__TEXT" || seg.InitProt&vmProtWrite == 0 { + continue + } + at := s.base + seg.VMAddr + got, err := s.t.Call(s.helpers["mmap"], at, seg.VMSize, protRead|protWrite, mapAnon|mapPrivate|mapFixed, neg1, 0) + if err != nil { + return err + } + if got != at { + return fmt.Errorf("MAP_FIXED %s @ %#x -> %#x", seg.Name, at, got) } } + return nil +} - // 3. RW scratch for staged tables, and an r-x page for the loop stubs - scratch, err := t.Call(mmap, 0, 0x10000, protRead|protWrite, mapAnon|mapPrivate, neg1, 0) +// setupScratchAndStubs maps the rw scratch region and the r-x stub page, writes +// the loop stubs, and points the call-return breakpoint at a private address in +// the stub page (rather than the thread's live PC) so re-entrant callee code — +// NSLog/libdispatch in the initializer — can't trip it early. +func (s *mapSession) setupScratchAndStubs() error { + scratch, err := s.t.Call(s.helpers["mmap"], 0, scratchSize, protRead|protWrite, mapAnon|mapPrivate, neg1, 0) if err != nil { - return nil, err + return err } - stubMem, err := t.Call(mmap, 0, 0x4000, protRead|protExec, mapAnon|mapPrivate, neg1, 0) + stub, err := s.t.Call(s.helpers["mmap"], 0, stubPageSize, protRead|protExec, mapAnon|mapPrivate, neg1, 0) if err != nil { - return nil, err + return err } - if err := c.WriteMem(stubMem, StubBytes, chunk); err != nil { - return nil, err + if err := s.c.WriteMem(stub, StubBytes, s.chunk); err != nil { + return err } - // Return called functions to a private r-x address in the stub page (well past - // the stub bytes) rather than the thread's live PC, so re-entrant callee code - // (NSLog/libdispatch in the initializer) can't trip the return breakpoint early. - if err := t.SetLanding(stubMem + 0x800); err != nil { - return nil, err + s.scratch, s.stub = scratch, stub + return s.t.SetLanding(stub + landingPadOffset) +} + +// bindImports resolves every imported symbol in one in-process rstub_dlsym call +// (names + a pointer table + an output table staged in scratch), then applies +// the chained fixups to the image buffer locally. +func (s *mapSession) bindImports() error { + if !s.m.HasChained { + return nil } - cur := scratch - alloc := func(size uint64) uint64 { - cur = (cur + 7) &^ 7 - p := cur - cur += size - return p + imports, segs, err := ParseChainedFixups(s.dylib, s.m.ChainedOff) + if err != nil { + return err } + s.result.Imports = len(imports) - // 4. resolve all imports in ONE call via rstub_dlsym, then apply fixups locally - if m.HasChained { - imports, segs, err := ParseChainedFixups(dylib, m.ChainedOff) - if err != nil { - return nil, err - } - result.Imports = len(imports) - // stage import name strings (one write) + a pointer table + an output table - var blob []byte - offs := make([]int, len(imports)) - for i, n := range imports { - offs[i] = len(blob) - blob = append(blob, stripOneUnderscore(n)...) - blob = append(blob, 0) - } - blobBase := alloc(uint64(len(blob))) - if err := c.WriteMem(blobBase, blob, chunk); err != nil { - return nil, err - } - namesTbl := alloc(uint64(len(imports)) * 8) - tbl := make([]byte, len(imports)*8) - for i := range imports { - binary.LittleEndian.PutUint64(tbl[i*8:], blobBase+uint64(offs[i])) - } - if err := c.WriteMem(namesTbl, tbl, chunk); err != nil { - return nil, err - } - outTbl := alloc(uint64(len(imports)) * 8) - if _, err := t.Call(stubMem+StubDlsymOff, helpers["dlsym"], uint64(len(imports)), - namesTbl, outTbl, rtldDefault); err != nil { - return nil, err - } - raw, err := c.Memory(outTbl, uint64(len(imports))*8) - if err != nil { - return nil, err - } - importAddrs := make([]uint64, len(imports)) - for i := range imports { - importAddrs[i] = binary.LittleEndian.Uint64(raw[i*8:]) - if importAddrs[i] == 0 { - result.Unresolved = append(result.Unresolved, imports[i]) - } - } - rb, bn, err := ApplyFixups(image, segs, base, importAddrs) - if err != nil { - return nil, err - } - result.Rebases, result.Binds = rb, bn + var blob []byte + offs := make([]int, len(imports)) + for i, n := range imports { + offs[i] = len(blob) + blob = append(blob, stripOneUnderscore(n)...) + blob = append(blob, 0) + } + blobBase := s.alloc(uint64(len(blob))) + if err := s.c.WriteMem(blobBase, blob, s.chunk); err != nil { + return err } - // 5. write the fixed-up image - if err := c.WriteMem(base, image, chunk); err != nil { - return nil, err + namesTbl := s.alloc(uint64(len(imports)) * 8) + tbl := make([]byte, len(imports)*8) + for i := range imports { + binary.LittleEndian.PutUint64(tbl[i*8:], blobBase+uint64(offs[i])) + } + if err := s.c.WriteMem(namesTbl, tbl, s.chunk); err != nil { + return err + } + outTbl := s.alloc(uint64(len(imports)) * 8) + if _, err := s.t.Call(s.stub+StubDlsymOff, s.helpers["dlsym"], uint64(len(imports)), namesTbl, outTbl, rtldDefault); err != nil { + return err } - // 6. register ObjC metadata (selrefs then classes), one stub call each - if n := m.SectionCount("__objc_classlist"); n > 0 { - if sel := m.SectionCount("__objc_selrefs"); sel > 0 { - sr := m.Sections["__objc_selrefs"] - if _, err := t.Call(stubMem+StubSelrefOff, helpers["sel_registerName"], sel, base+sr.Addr); err != nil { - return nil, err - } - result.Selrefs = int(sel) - } - cl := m.Sections["__objc_classlist"] - var imageinfo uint64 - if ii, ok := m.Sections["__objc_imageinfo"]; ok { - imageinfo = base + ii.Addr - } - if _, err := t.Call(stubMem+StubClassOff, helpers["objc_readClassPair"], n, base+cl.Addr, imageinfo); err != nil { - return nil, err + raw, err := s.c.Memory(outTbl, uint64(len(imports))*8) + if err != nil { + return err + } + importAddrs := make([]uint64, len(imports)) + for i := range imports { + importAddrs[i] = binary.LittleEndian.Uint64(raw[i*8:]) + if importAddrs[i] == 0 { + s.result.Unresolved = append(s.result.Unresolved, imports[i]) } - result.Classes = int(n) } + rebases, binds, err := ApplyFixups(s.image, segs, s.base, importAddrs) + if err != nil { + return err + } + s.result.Rebases, s.result.Binds = rebases, binds + return nil +} - // 6b. apply categories (objc_readClassPair does not, and there is no public API - // to attach one from a mapped image) — before init, which may swizzle them. - if m.SectionCount("__objc_catlist") > 0 { - ch, err := resolver.Resolve(categoryHelpers) - if err != nil { - return nil, fmt.Errorf("resolve category helpers: %w", err) - } - applied, err := applyCategories(c, t, base, m, ch) - if err != nil { - return nil, err +// writeImage writes the fixed-up image into the reserved mapping. +func (s *mapSession) writeImage() error { + return s.c.WriteMem(s.base, s.image, s.chunk) +} + +// registerClasses uniques the image's selrefs and registers its classes with +// objc_readClassPair — one in-process stub call each. +func (s *mapSession) registerClasses() error { + classlist, ok := s.m.Sections["__objc_classlist"] + if !ok || classlist.Size == 0 { + return nil + } + if selrefs, ok := s.m.Sections["__objc_selrefs"]; ok && selrefs.Size > 0 { + n := selrefs.Size / 8 + if _, err := s.t.Call(s.stub+StubSelrefOff, s.helpers["sel_registerName"], n, s.base+selrefs.Addr); err != nil { + return err } - result.Categories = applied + s.result.Selrefs = int(n) + } + var imageinfo uint64 + if ii, ok := s.m.Sections["__objc_imageinfo"]; ok { + imageinfo = s.base + ii.Addr } + n := classlist.Size / 8 + if _, err := s.t.Call(s.stub+StubClassOff, s.helpers["objc_readClassPair"], n, s.base+classlist.Addr, imageinfo); err != nil { + return err + } + s.result.Classes = int(n) + return nil +} + +// applyImageCategories attaches the image's __objc_catlist categories (which +// objc_readClassPair does not), before the initializer that may swizzle them. +func (s *mapSession) applyImageCategories() error { + if s.m.SectionCount("__objc_catlist") == 0 { + return nil + } + helpers, err := s.resolver.Resolve(categoryHelpers) + if err != nil { + return fmt.Errorf("resolve category helpers: %w", err) + } + applied, err := applyCategories(s.c, s.t, s.base, s.m, helpers) + if err != nil { + return err + } + s.result.Categories = applied + return nil +} - // 7. run initializers (starts the agent) unless suppressed - if opt.RunInit { - for _, is := range m.InitSections { - for o := uint64(0); o < is.Size; o += 4 { - initOff := uint64(binary.LittleEndian.Uint32(image[is.Addr+o:])) - if _, err := t.Call(base + initOff); err != nil { - return nil, err - } +// runInitializers runs each offset-based initializer (the agent's C constructor), +// which is what starts the agent. +func (s *mapSession) runInitializers() error { + for _, is := range s.m.InitSections { + for o := uint64(0); o < is.Size; o += 4 { + initOff := uint64(binary.LittleEndian.Uint32(s.image[is.Addr+o:])) + if _, err := s.t.Call(s.base + initOff); err != nil { + return err } } } - - return result, nil + return nil } // stripOneUnderscore removes exactly one leading linker underscore (not all). From d515d460530c35637ff1d7da5cb57d23ac3446bb Mon Sep 17 00:00:00 2001 From: Mobile Kevin Bot! Date: Wed, 4 Nov 2026 21:53:32 +0100 Subject: [PATCH 14/18] ios: enable network interception on real devices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The real IOSDevice did not implement NetworkInterceptorProvider, so 'mobilecli network listen --device ' failed with 'network interception is not supported'. Add IOSDevice.NetworkInterceptor: it ensures the agent (mapped via the native signing-free loader) and returns the same agentNetwork RPC relay Android and the simulator use — the agent's dispatcher routes network.* to MobileNetwork. Verified end-to-end on iPhone12,1 iOS 26.5.2: 'network listen' maps the agent (483 rebases, 244 binds, 5 classes, 5 categories), the server binds, and network.enable/poll/disable round-trip over the forwarded agent port. --- devices/network.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/devices/network.go b/devices/network.go index c1532105..c7c4f0e1 100644 --- a/devices/network.go +++ b/devices/network.go @@ -97,3 +97,14 @@ func (s *SimulatorDevice) NetworkInterceptor() (NetworkInterceptable, error) { } return agentNetwork{port: port}, nil } + +// NetworkInterceptor maps the agent dylib into the foreground app on a real iOS +// device (signing-free, over the debugserver) and returns a relay to its in-app +// agent. The agent's dispatcher routes network.* RPC to MobileNetwork. +func (d *IOSDevice) NetworkInterceptor() (NetworkInterceptable, error) { + port, err := d.ensureIOSDeviceAgentReady() + if err != nil { + return nil, err + } + return agentNetwork{port: port}, nil +} From f1db0db3300953960e8811b68a1d1b246a554cd9 Mon Sep 17 00:00:00 2001 From: Mobile Kevin Bot! Date: Wed, 4 Nov 2026 22:14:27 +0100 Subject: [PATCH 15/18] ios/machomap: bound agent injection with a timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mapAgentOverConn issues blocking RSP requests with no deadline, so a debugserver reply that never arrives could hang injectAgentNative — and the CLI/daemon request that starts the agent. Add a 60s ceiling: closing the connection on timeout unblocks the pending read, and injectAgentNative returns a clear timeout error. (CodeRabbit: stability/availability.) --- devices/ios_device_agent_native.go | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/devices/ios_device_agent_native.go b/devices/ios_device_agent_native.go index 1ed83ffc..62258471 100644 --- a/devices/ios_device_agent_native.go +++ b/devices/ios_device_agent_native.go @@ -4,6 +4,7 @@ import ( "fmt" "io" "strings" + "time" goios "github.com/danielpaulus/go-ios/ios" "github.com/mobile-next/mobilecli/agents" @@ -11,6 +12,11 @@ import ( "github.com/mobile-next/mobilecli/utils" ) +// injectTimeout bounds the whole debugserver map. The map normally takes a few +// seconds; this is only a ceiling so a wedged device (a debugserver reply that +// never arrives) cannot hang the CLI/daemon indefinitely. +const injectTimeout = 60 * time.Second + // injectAgentNative maps the compiled agent dylib into the foreground app over // the developer debugserver and runs its initializer, with no code signing and // no re-signing (the CS_DEBUGGED exemption lets the debugger execute the pages @@ -23,7 +29,19 @@ func injectAgentNative(device goios.DeviceEntry, proxyPort, pid int) (int, error return 0, fmt.Errorf("connect debug proxy: %w", err) } defer func() { _ = conn.Close() }() - return mapAgentOverConn(conn, pid) + + // Bound the blocking RSP exchanges: closing the connection when the deadline + // expires unblocks any pending read in mapAgentOverConn, which then returns an + // error instead of hanging. + timedOut := false + timer := time.AfterFunc(injectTimeout, func() { timedOut = true; _ = conn.Close() }) + defer timer.Stop() + + port, err := mapAgentOverConn(conn, pid) + if timedOut { + return 0, fmt.Errorf("agent injection timed out after %s (device unresponsive)", injectTimeout) + } + return port, err } // mapAgentOverConn runs the attach + map + boot sequence on an already-opened From cb199010c10a515cd7a495989a350f2e9042e1f5 Mon Sep 17 00:00:00 2001 From: Mobile Kevin Bot! Date: Wed, 4 Nov 2026 22:14:27 +0100 Subject: [PATCH 16/18] ios/machomap: fail fast on unresolved non-weak imports bindImports stored a 0 address for each unresolved import and continued; Map then wrote the image and ran the initializer, so a missing symbol the initializer uses would crash the app before the caller could report it. Now return an error before writing the image if any non-weak import is unresolved. ParseChainedFixups also preserves the weak_import bit (bit 8), so a missing weak import still resolves to 0 (null) as intended rather than failing. (CodeRabbit: stability.) --- devices/ios/machomap/fixups.go | 15 +++++++++------ devices/ios/machomap/macho_test.go | 2 +- devices/ios/machomap/mapper.go | 10 ++++++++-- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/devices/ios/machomap/fixups.go b/devices/ios/machomap/fixups.go index 36d967a6..2a9653c0 100644 --- a/devices/ios/machomap/fixups.go +++ b/devices/ios/machomap/fixups.go @@ -22,23 +22,26 @@ type fixupSeg struct { pageStarts []uint16 } -// ParseChainedFixups returns the imported symbol names (in ordinal order) and the -// per-segment chain-start tables from the LC_DYLD_CHAINED_FIXUPS blob at hdrOff. -func ParseChainedFixups(data []byte, hdrOff uint32) (imports []string, segs []fixupSeg, err error) { +// ParseChainedFixups returns the imported symbol names (in ordinal order), a +// parallel slice marking weak imports (which may legitimately resolve to 0), and +// the per-segment chain-start tables from the LC_DYLD_CHAINED_FIXUPS blob at +// hdrOff. +func ParseChainedFixups(data []byte, hdrOff uint32) (imports []string, weak []bool, segs []fixupSeg, err error) { h := int(hdrOff) if h+28 > len(data) { - return nil, nil, fmt.Errorf("chained-fixups header out of range") + return nil, nil, nil, fmt.Errorf("chained-fixups header out of range") } startsOff := binary.LittleEndian.Uint32(data[h+4:]) impOff := binary.LittleEndian.Uint32(data[h+8:]) symOff := binary.LittleEndian.Uint32(data[h+12:]) impCnt := binary.LittleEndian.Uint32(data[h+16:]) - // imports format 1: {lib_ordinal:8, weak:1, name_offset:23} + // imports format 1: {lib_ordinal:8, weak_import:1, name_offset:23} for i := uint32(0); i < impCnt; i++ { v := binary.LittleEndian.Uint32(data[h+int(impOff)+int(i)*4:]) nameOff := (v >> 9) & 0x7FFFFF name := cstrAt(data, h+int(symOff)+int(nameOff), len(data)) imports = append(imports, name) + weak = append(weak, (v>>8)&1 == 1) } segCount := binary.LittleEndian.Uint32(data[h+int(startsOff):]) for s := uint32(0); s < segCount; s++ { @@ -62,7 +65,7 @@ func ParseChainedFixups(data []byte, hdrOff uint32) (imports []string, segs []fi pageStarts: starts, }) } - return imports, segs, nil + return imports, weak, segs, nil } // ApplyFixups patches the image buffer in place: rebases -> base+target+(high8<<56), diff --git a/devices/ios/machomap/macho_test.go b/devices/ios/machomap/macho_test.go index 7c23dd2c..d1934e48 100644 --- a/devices/ios/machomap/macho_test.go +++ b/devices/ios/machomap/macho_test.go @@ -37,7 +37,7 @@ func TestGoParserMatchesPythonCounts(t *testing.T) { t.Fatal("expected chained fixups") } - imports, segs, err := ParseChainedFixups(data, m.ChainedOff) + imports, _, segs, err := ParseChainedFixups(data, m.ChainedOff) if err != nil { t.Fatalf("ParseChainedFixups: %v", err) } diff --git a/devices/ios/machomap/mapper.go b/devices/ios/machomap/mapper.go index 977dd6bc..b7ec41ff 100644 --- a/devices/ios/machomap/mapper.go +++ b/devices/ios/machomap/mapper.go @@ -205,7 +205,7 @@ func (s *mapSession) bindImports() error { if !s.m.HasChained { return nil } - imports, segs, err := ParseChainedFixups(s.dylib, s.m.ChainedOff) + imports, weak, segs, err := ParseChainedFixups(s.dylib, s.m.ChainedOff) if err != nil { return err } @@ -243,10 +243,16 @@ func (s *mapSession) bindImports() error { importAddrs := make([]uint64, len(imports)) for i := range imports { importAddrs[i] = binary.LittleEndian.Uint64(raw[i*8:]) - if importAddrs[i] == 0 { + // A weak import may legitimately resolve to 0 (the code null-checks it); + // a missing non-weak import would make the initializer call a null pointer + // and crash the app, so fail now, before the image is written. + if importAddrs[i] == 0 && !weak[i] { s.result.Unresolved = append(s.result.Unresolved, imports[i]) } } + if len(s.result.Unresolved) > 0 { + return fmt.Errorf("unresolved imports (missing symbols in target): %v", s.result.Unresolved) + } rebases, binds, err := ApplyFixups(s.image, segs, s.base, importAddrs) if err != nil { return err From 323f904b2c8d3a22b39f6271632c01da502d81d8 Mon Sep 17 00:00:00 2001 From: Mobile Kevin Bot! Date: Wed, 4 Nov 2026 22:33:17 +0100 Subject: [PATCH 17/18] agents(android): read request body only when a request will pause; keep Content-Type - OkHttpHook.intercept checks Network.shouldPause(method,url) before reading the body, so disabled/non-matching requests are not read at all. This avoids a needless 256 KiB copy per request and, for a streaming body that doesn't declare isOneShot/isDuplex, avoids the second writeTo from proceed truncating the app's own upload. - continue --body keeps the original body's media type (was null), so an overridden JSON POST still goes out with its Content-Type. - Wrap override/synthetic-response failures (bad url/method/headers, GET+body) in IOException so they fail the request cleanly instead of escaping on OkHttp's dispatcher thread and crashing the app. (CodeRabbit.) --- agents/android/java/Network.java | 12 +++++++++ agents/android/java/OkHttpHook.java | 40 ++++++++++++++++++++++++----- 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/agents/android/java/Network.java b/agents/android/java/Network.java index e35e97d2..e2dd1501 100644 --- a/agents/android/java/Network.java +++ b/agents/android/java/Network.java @@ -143,6 +143,18 @@ static JSONObject disable(JSONObject params) throws JSONException { return ok(); } + /** + * True if a request on this route would be paused. The interceptor checks this + * before reading the request body, so disabled or non-matching requests are not + * read at all — avoiding a needless body copy and a second writeTo that would + * corrupt a streaming upload whose body does not declare isOneShot/isDuplex. + */ + static boolean shouldPause(String method, String url) { + synchronized (LOCK) { + return enabled && matchesLocked(url, method) && EVENTS.size() < MAX_EVENTS; + } + } + /** Called only by the OkHttp interceptor on the app's request thread. */ static Verdict await(String method, String url, JSONObject headers, byte[] body) { Pause pause = new Pause(); diff --git a/agents/android/java/OkHttpHook.java b/agents/android/java/OkHttpHook.java index 5c069a95..369b525e 100644 --- a/agents/android/java/OkHttpHook.java +++ b/agents/android/java/OkHttpHook.java @@ -68,18 +68,25 @@ static Object intercept(Object chain) throws Throwable { Object request = call(chain, "request"); String url = call(request, "url").toString(); String method = (String) call(request, "method"); + // Read the body only for requests that will actually pause. Reading it for + // every request wastes a copy and, for a streaming body that does not + // declare isOneShot/isDuplex, the later writeTo from proceed would send a + // truncated upload — corrupting the app's own request just because we attached. + if (!Network.shouldPause(method, url)) { + return call(chain, "proceed", request); + } JSONObject headers = headersToJson(call(request, "headers")); Network.Verdict verdict = Network.await(method, url, headers, requestBodyBytes(request)); switch (verdict.action) { case "fulfill": - return syntheticResponse(request, verdict.params); + return failOpenToIO(request, verdict, true); case "abort": throw new IOException("mobilecli network abort: " + verdict.params.optString("errorCode", "failed")); case "continue": if (verdict.params.has("url") || verdict.params.has("method") || verdict.params.has("headers") || verdict.params.has("body")) { - request = overrideRequest(request, verdict.params); + request = failOpenToIO(request, verdict, false); } return call(chain, "proceed", request); default: @@ -87,6 +94,22 @@ static Object intercept(Object chain) throws Throwable { } } + // A malformed host verdict (bad url/method/headers, GET with a body) makes the + // OkHttp builders throw IllegalArgumentException/JSONException. Those are not + // IOExceptions, so on an async call OkHttp lets them escape on the dispatcher + // thread and crash the app. Wrap them so the request fails cleanly instead. + private static Object failOpenToIO(Object request, Network.Verdict verdict, boolean fulfill) throws IOException { + try { + return fulfill ? syntheticResponse(request, verdict.params) + : overrideRequest(request, verdict.params); + } catch (IOException e) { + throw e; + } catch (Throwable e) { + throw new IOException("mobilecli network " + (fulfill ? "fulfill" : "continue") + + " failed: " + e.getMessage(), e); + } + } + private static JSONObject headersToJson(Object headers) throws Throwable { JSONObject result = new JSONObject(); int size = ((Number) call(headers, "size")).intValue(); @@ -151,7 +174,10 @@ private static Object overrideRequest(Object request, JSONObject params) throws String method = params.optString("method", (String) call(request, "method")); Object body = call(request, "body"); if (params.has("body")) { - body = responseBody(request, Base64.decode(params.getString("body"), Base64.DEFAULT), true); + // Keep the original body's media type; otherwise OkHttp's bridge + // interceptor emits the request with no Content-Type and servers reject it. + Object mediaType = body == null ? null : call(body, "contentType"); + body = responseBody(request, Base64.decode(params.getString("body"), Base64.DEFAULT), true, mediaType); } if (params.has("method") || params.has("body")) { call(builder, "method", method, body); @@ -180,11 +206,11 @@ private static Object syntheticResponse(Object request, JSONObject params) throw } } byte[] bytes = params.has("body") ? Base64.decode(params.getString("body"), Base64.DEFAULT) : new byte[0]; - call(builder, "body", responseBody(request, bytes, false)); + call(builder, "body", responseBody(request, bytes, false, null)); return call(builder, "build"); } - private static Object responseBody(Object request, byte[] bytes, boolean requestBody) throws Throwable { + private static Object responseBody(Object request, byte[] bytes, boolean requestBody, Object mediaType) throws Throwable { ClassLoader loader = request.getClass().getClassLoader(); Class bodyClass = Class.forName(requestBody ? "okhttp3.RequestBody" : "okhttp3.ResponseBody", true, loader); for (Method method : bodyClass.getMethods()) { @@ -196,10 +222,10 @@ private static Object responseBody(Object request, byte[] bytes, boolean request continue; } if (types[0] == byte[].class && types[1].getName().equals("okhttp3.MediaType")) { - return invoke(method, null, bytes, null); + return invoke(method, null, bytes, mediaType); } if (types[1] == byte[].class && types[0].getName().equals("okhttp3.MediaType")) { - return invoke(method, null, null, bytes); + return invoke(method, null, mediaType, bytes); } } throw new IOException("unsupported OkHttp body factory"); From a8e76e188a8a712d4283f945adfc69659be747c9 Mon Sep 17 00:00:00 2001 From: Mobile Kevin Bot! Date: Wed, 4 Nov 2026 22:33:18 +0100 Subject: [PATCH 18/18] agents(ios): preserve the app session's cookies on intercepted requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matched requests were routed through a shared ephemeral session, dropping the app's cookies, additional headers, credential storage and delegate — so an app with cookie/session auth sent unauthenticated requests on matched routes during 'network listen'. - The completion-handler dataTask hooks now use the same per-session clone the non-completion hook uses (interceptClone): a copy of the originating session's configuration + delegate with MobileURLProtocol added, instead of the ephemeral captureSession (now removed). - forward: uses the default (shared) configuration instead of ephemeral, so a plain continue carries shared cookies/credentials and persists Set-Cookie. An NSURLProtocol cannot reach the originating session's own config, so a session with a private non-shared cookie store remains out of scope. (CodeRabbit.) Verified: agents compile; agent still maps and boots on iPhone12,1 iOS 26.5.2. --- agents/ios/network.m | 70 ++++++++++++++++++++++++++++---------------- 1 file changed, 45 insertions(+), 25 deletions(-) diff --git a/agents/ios/network.m b/agents/ios/network.m index 73fe3850..209bcdc7 100644 --- a/agents/ios/network.m +++ b/agents/ios/network.m @@ -38,7 +38,6 @@ + (void)cancel:(nullable NSString *)requestId; static NSUInteger sequence; static NSInteger pollers; static NSDate *lastPollEnd; -static NSURLSession *captureSession; static const void *kDelegateCloneKey = &kDelegateCloneKey; static const void *kIsDelegateCloneKey = &kIsDelegateCloneKey; @@ -108,40 +107,58 @@ - (NSURLSessionDataTask *)mobile_dataTaskWithRequest:(NSURLRequest *)request - (NSURLSessionDataTask *)mobile_dataTaskWithRequest:(NSURLRequest *)request; @end +// interceptClone returns a session whose configuration is copied from `session` +// — preserving its cookie storage, HTTPAdditionalHeaders, credential storage and +// timeouts — plus MobileURLProtocol, together with the original delegate and +// delegate queue. It is created once per session and reused. It returns nil when +// `session` is itself such a clone, so that clone's own tasks run normally (and +// are intercepted through the protocol already in its configuration). Routing a +// matched request through this clone, rather than a shared ephemeral session, +// keeps the app's session context (notably cookies) on intercepted requests. +static NSURLSession *interceptClone(NSURLSession *session) { + if ([objc_getAssociatedObject(session, kIsDelegateCloneKey) boolValue]) { + return nil; + } + NSURLSession *clone = objc_getAssociatedObject(session, kDelegateCloneKey); + if (clone) { + return clone; + } + @synchronized (session) { + clone = objc_getAssociatedObject(session, kDelegateCloneKey); + if (!clone) { + NSURLSessionConfiguration *configuration = [session.configuration copy]; + addProtocol(configuration); + clone = [NSURLSession sessionWithConfiguration:configuration + delegate:session.delegate delegateQueue:session.delegateQueue]; + objc_setAssociatedObject(clone, kIsDelegateCloneKey, @YES, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + objc_setAssociatedObject(session, kDelegateCloneKey, clone, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + } + } + return clone; +} + @implementation NSURLSession (MobileNetworkTasks) - (NSURLSessionDataTask *)mobile_dataTaskWithURL:(NSURL *)url completionHandler:(void (^)(NSData *, NSURLResponse *, NSError *))completionHandler { - if (self != captureSession && [MobileNetwork matches:[NSURLRequest requestWithURL:url]]) { - return [captureSession dataTaskWithURL:url completionHandler:completionHandler]; + NSURLSession *clone = interceptClone(self); + if (clone && [MobileNetwork matches:[NSURLRequest requestWithURL:url]]) { + return [clone dataTaskWithURL:url completionHandler:completionHandler]; } return [self mobile_dataTaskWithURL:url completionHandler:completionHandler]; } - (NSURLSessionDataTask *)mobile_dataTaskWithRequest:(NSURLRequest *)request completionHandler:(void (^)(NSData *, NSURLResponse *, NSError *))completionHandler { - if (self != captureSession && [MobileNetwork matches:request]) { - return [captureSession dataTaskWithRequest:request completionHandler:completionHandler]; + NSURLSession *clone = interceptClone(self); + if (clone && [MobileNetwork matches:request]) { + return [clone dataTaskWithRequest:request completionHandler:completionHandler]; } return [self mobile_dataTaskWithRequest:request completionHandler:completionHandler]; } - (NSURLSessionDataTask *)mobile_dataTaskWithRequest:(NSURLRequest *)request { - if (![objc_getAssociatedObject(self, kIsDelegateCloneKey) boolValue] && - [MobileNetwork matches:request]) { - NSURLSession *clone = objc_getAssociatedObject(self, kDelegateCloneKey); - if (!clone) { - @synchronized (self) { - clone = objc_getAssociatedObject(self, kDelegateCloneKey); - if (!clone) { - NSURLSessionConfiguration *configuration = [self.configuration copy]; - addProtocol(configuration); - clone = [NSURLSession sessionWithConfiguration:configuration - delegate:self.delegate delegateQueue:self.delegateQueue]; - objc_setAssociatedObject(clone, kIsDelegateCloneKey, @YES, OBJC_ASSOCIATION_RETAIN_NONATOMIC); - objc_setAssociatedObject(self, kDelegateCloneKey, clone, OBJC_ASSOCIATION_RETAIN_NONATOMIC); - } - } - } + NSURLSession *clone = interceptClone(self); + if (clone && [MobileNetwork matches:request]) { return [clone dataTaskWithRequest:request]; } return [self mobile_dataTaskWithRequest:request]; @@ -171,9 +188,6 @@ + (void)install { method_exchangeImplementations( class_getInstanceMethod(meta, @selector(ephemeralSessionConfiguration)), class_getInstanceMethod(meta, @selector(mobile_ephemeralSessionConfiguration))); - NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration ephemeralSessionConfiguration]; - addProtocol(configuration); - captureSession = [NSURLSession sessionWithConfiguration:configuration]; method_exchangeImplementations( class_getInstanceMethod([NSURLSession class], @selector(dataTaskWithURL:completionHandler:)), class_getInstanceMethod([NSURLSession class], @selector(mobile_dataTaskWithURL:completionHandler:))); @@ -478,7 +492,13 @@ - (void)forward:(NSURLRequest *)request { } NSMutableURLRequest *marked = [request mutableCopy]; [NSURLProtocol setProperty:@YES forKey:kHandledKey inRequest:marked]; - NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration ephemeralSessionConfiguration]; + // Use the default (not ephemeral) configuration so the forwarded request + // carries the app's shared cookies and credentials and so Set-Cookie + // responses are persisted — a plain `continue` must not change what reaches + // the server. Remove MobileURLProtocol to avoid intercepting our own forward. + // (An NSURLProtocol cannot reach the originating session's own config, so a + // session with a private, non-shared cookie store is not covered here.) + NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration]; NSMutableArray *classes = [configuration.protocolClasses mutableCopy] ?: [NSMutableArray array]; [classes removeObject:[MobileURLProtocol class]]; configuration.protocolClasses = classes;