commit 3e77fe422b32e2e052b61194cf8a06c5112ce7c0 Author: √(noham)² <100566912+NohamR@users.noreply.github.com> Date: Sun Feb 22 15:57:22 2026 +0100 Add initial RMHook-iOS project files diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d988680 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/src/.theos +/src/packages +.DS_Store +/rev diff --git a/IPAs/com.remarkable.mobile-3.17.0.ipa b/IPAs/com.remarkable.mobile-3.17.0.ipa new file mode 100644 index 0000000..f2af8aa Binary files /dev/null and b/IPAs/com.remarkable.mobile-3.17.0.ipa differ diff --git a/IPAs/com.remarkable.mobile-3.25.0.ipa b/IPAs/com.remarkable.mobile-3.25.0.ipa new file mode 100644 index 0000000..d5dee15 Binary files /dev/null and b/IPAs/com.remarkable.mobile-3.25.0.ipa differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..e263b3f --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# RMHook-iOS + +This project is an early proof of concept for hooking or modifying the reMarkable iOS app. It currently does not work, possibly due to SSL/TLS issues in the custom networking stack used by the app. + +## Project Structure +- `src/` – Contains the tweak source code and configuration files. +- `IPAs/` – Example reMarkable app IPA files for analysis. + +## Build & Usage + +### Build the Tweak +```sh +cd src && make clean && make package THEOS_PACKAGE_SCHEME=rootless +``` + +### Patch the IPA with the Tweak +```sh +cyan -i reMarkable.ipa \ + -o reMarkable_patched.ipa \ + -f xyz.noham.rmhook_0.0.1-1+debug_iphoneos-arm64.deb -u +``` + +### Debug Logging +```sh +log stream | grep RMHook +``` + +## Technical Notes +See [info.md](info.md) for detailed analysis of the app's networking stack and reverse engineering notes. + +## Disclaimer +This project is for educational and research purposes only. It is not affiliated with or endorsed by reMarkable. \ No newline at end of file diff --git a/docs/settings.png b/docs/settings.png new file mode 100644 index 0000000..d3e638b Binary files /dev/null and b/docs/settings.png differ diff --git a/docs/unsynced.png b/docs/unsynced.png new file mode 100644 index 0000000..9c56eb8 Binary files /dev/null and b/docs/unsynced.png differ diff --git a/info.md b/info.md new file mode 100644 index 0000000..78f2510 --- /dev/null +++ b/info.md @@ -0,0 +1,218 @@ + +# Technical Notes: RMHook-iOS + +For project overview and status, see [README.md](README.md). + +## Network Stack Analysis + +### URL Resolution +The app uses a custom function to select the target hostname based on environment flags: + +| Condition | Hostname | +|----------------------------|--------------------------------------------| +| Custom URL set (offset 136)| [custom].tectonic.remarkable.com | +| QA env flag | qa.internal.cloud.remarkable.com | +| Dev env flag | dev.internal.cloud.remarkable.com | +| Stage env flag | stage.internal.cloud.remarkable.com | +| Default (production) | internal.cloud.remarkable.com | + +### network::HttpManager::setupTransaction +Confirmed via RTTI: `network::HttpManager` (vtable entry 14). Source: `xochitl/src/xofm/libs/network/src/http-manager.cpp`. + +#### Flow +- Resolves hostname via custom resolver or appends `.tectonic.remarkable.com` to a base URL +- Constructs a `network::detail::HttpTransaction` object +- Sets up a `network::ReplyReader` for response handling +- Dispatches via a virtual call on a queue/scheduler object +- Logs through `rm.network.http.manager` using obfuscated string literals + +### Networking Library Used +This is a fully custom C++ HTTP client (`xofm/libs/network`), not NSURLSession, CFHTTPMessage, or Qt's QNetworkAccessManager. + +| Layer | Technology | +|-------------- |----------------------------------------------------------------------------| +| TLS/HTTPS | Apple SecureTransport (SSLCreateContext, SSLHandshake, etc.) | +| TCP transport | POSIX BSD sockets (_socket, _connect, _recv/_read, _write/_sendmsg) | +| DNS | _getaddrinfo / _freeaddrinfo | +| Event loop | CFSocket + CFRunLoop integration | +| Async exec | GCD (dispatch_queue_create, dispatch_async) | +| Cert pinning | SecTrustEvaluate, SecTrustSetAnchorCertificates, SecPKCS12Import | +| TLS ALPN | SSLSetALPNProtocols / SSLCopyALPNProtocols (HTTP/2 or gRPC support) | + +The app is Qt-based (QIOS* classes), but the networking layer bypasses Qt entirely. It talks directly to either `*.internal.cloud.remarkable.com` (REST API) or a tectonic-suffixed host with raw TLS sockets. + +## Hook Candidate Analysis +| Hook candidate | Pros | Cons | +| -------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| sub_1000C81B8 | Single purpose, small, all env variants go through it | Need to handle X8 output ABI | +| sub_1000AA31C | (setupTransaction) High level | Complex function, 0x738 bytes, hard to isolate the host field | +| sub_10180B1BC | (string ctor) Leaf function | Called from many places, need to filter callers | +| sub_1000AB478 | (URL builder for tectonic) | Catches tectonic-path specifically Only covers one of the two code paths (the custom-URL path) | + +```cpp +void __usercall sub_1000C81B8(__int64 a1@, _QWORD *a2@) +{ + unsigned int *v4; // x19 + __int64 v5; // x21 + __int64 v6; // x22 + unsigned int v7; // w8 + unsigned int v8; // w8 + unsigned int v9; // w8 + unsigned int *v10; // x8 + unsigned int v11; // w9 + unsigned int v12; // w9 + void *v13[2]; // [xsp+8h] [xbp-58h] BYREF + __int64 v14; // [xsp+18h] [xbp-48h] + __int128 v15; // [xsp+20h] [xbp-40h] BYREF + __int64 v16; // [xsp+30h] [xbp-30h] + + if ( *(_BYTE *)(a1 + 136) ) + { + (*(void (__fastcall **)(void **__return_ptr))(**(_QWORD **)(a1 + 128) + 112LL))(v13); + } + else + { + v13[0] = 0; + v13[1] = 0; + v14 = 0; + } + v4 = *(unsigned int **)(a1 + 240); + v5 = *(_QWORD *)(a1 + 248); + v6 = *(_QWORD *)(a1 + 256); + if ( v4 ) + { + do + v7 = __ldaxr(v4); + while ( __stlxr(v7 + 1, v4) ); + } + if ( v14 ) + { + *(_QWORD *)&v15 = v13; + *((_QWORD *)&v15 + 1) = ".tectonic.remarkable.com"; + sub_1000AB478(a2, &v15); + if ( !v4 ) + goto LABEL_25; + goto LABEL_22; + } + if ( v6 == qword_1028F6140 && (unsigned int)sub_10180246C(v6, v5, v6, *((_QWORD *)&xmmword_1028F6130 + 1)) ) + goto LABEL_20; + if ( v6 == qword_1028F6158 && (unsigned int)sub_10180246C(v6, v5, v6, qword_1028F6150) ) + { + sub_10180B1BC(&v15, 35, "stage.internal.cloud.remarkable.com"); + goto LABEL_21; + } + if ( v6 == qword_1028F6188 && (unsigned int)sub_10180246C(v6, v5, v6, qword_1028F6180) ) + { + sub_10180B1BC(&v15, 33, "dev.internal.cloud.remarkable.com"); + goto LABEL_21; + } + if ( v6 != qword_1028F6170 || !(unsigned int)sub_10180246C(v6, v5, v6, qword_1028F6168) ) +LABEL_20: + sub_10180B1BC(&v15, 29, "internal.cloud.remarkable.com"); + else + sub_10180B1BC(&v15, 32, "qa.internal.cloud.remarkable.com"); +LABEL_21: + *(_OWORD *)a2 = v15; + a2[2] = v16; + if ( !v4 ) + goto LABEL_25; + do + { +LABEL_22: + v8 = __ldaxr(v4); + v9 = v8 - 1; + } + while ( __stlxr(v9, v4) ); + if ( !v9 ) + j__free(v4); +LABEL_25: + v10 = (unsigned int *)v13[0]; + if ( v13[0] ) + { + do + { + v11 = __ldaxr(v10); + v12 = v11 - 1; + } + while ( __stlxr(v12, v10) ); + if ( !v12 ) + j__free(v13[0]); + } +} +``` + +```cpp +void __usercall sub_1000C8444(_QWORD *a1@, __int64 a2@) +{ + unsigned int *v3; // x19 + __int64 v4; // x21 + __int64 v5; // x22 + unsigned int v6; // w8 + unsigned int v7; // w8 + unsigned int v8; // w8 + __int128 v9; // [xsp+0h] [xbp-40h] BYREF + __int64 v10; // [xsp+10h] [xbp-30h] + + v3 = (unsigned int *)a1[30]; + v4 = a1[31]; + v5 = a1[32]; + if ( v3 ) + { + do + v6 = __ldaxr(v3); + while ( __stlxr(v6 + 1, v3) ); + } + if ( v5 == qword_1028F6140 && (unsigned int)sub_10180246C(v5, v4, v5, *((_QWORD *)&xmmword_1028F6130 + 1)) ) + goto LABEL_14; + if ( v5 == qword_1028F6158 && (unsigned int)sub_10180246C(v5, v4, v5, qword_1028F6150) ) + { + sub_10180B1BC(25, (__int64)"staging.my.remarkable.com", &v9); + goto LABEL_15; + } + if ( v5 == qword_1028F6188 && (unsigned int)sub_10180246C(v5, v4, v5, qword_1028F6180) ) + { + sub_10180B1BC(29, (__int64)"development.my.remarkable.com", &v9); + goto LABEL_15; + } + if ( v5 != qword_1028F6170 || !(unsigned int)sub_10180246C(v5, v4, v5, qword_1028F6168) ) +LABEL_14: + sub_10180B1BC(17, (__int64)"my.remarkable.com", &v9); + else + sub_10180B1BC(20, (__int64)"qa.my.remarkable.com", &v9); +LABEL_15: + *(_OWORD *)a2 = v9; + *(_QWORD *)(a2 + 16) = v10; + if ( v3 ) + { + do + { + v7 = __ldaxr(v3); + v8 = v7 - 1; + } + while ( __stlxr(v8, v3) ); + if ( !v8 ) + j__free(v3); + } +} +``` + +## Current Status + +```log +2026-02-22 15:53:36.296954+0100 0x736818 Default 0x0 46841 0 remarkable_mobile: (RMHook.dylib) [RMHook] 'remarkable_mobile' base = 0x102c28000 +2026-02-22 15:53:36.298221+0100 0x736818 Default 0x0 46841 0 remarkable_mobile: (RMHook.dylib) [RMHook] Hooked sub_1000C81B8 @ 0x102cf01b8 (offset 0xc81b8) +2026-02-22 15:53:36.298343+0100 0x736818 Default 0x0 46841 0 remarkable_mobile: (RMHook.dylib) [RMHook] Hooked sub_1000C8444 @ 0x102cf0444 (offset 0xc8444) +2026-02-22 15:53:36.416893+0100 0x736818 Default 0x0 46841 0 remarkable_mobile: (RMHook.dylib) [RMHook] >>> sub_1000C8444 ENTER a1=0x12070d5e0 x8(out)=0x16d1d2bf0 +2026-02-22 15:53:36.416985+0100 0x736818 Default 0x0 46841 0 remarkable_mobile: (RMHook.dylib) [RMHook] <<< sub_1000C8444 RETURN original (17 chars) = "my.remarkable.com" +2026-02-22 15:53:36.417039+0100 0x736818 Default 0x0 46841 0 remarkable_mobile: (RMHook.dylib) [RMHook] <<< sub_1000C8444 patched → "rm.noh.am" +2026-02-22 15:53:36.466471+0100 0x736818 Default 0x0 46841 0 remarkable_mobile: (RMHook.dylib) [RMHook] >>> sub_1000C8444 ENTER a1=0x12070d5e0 x8(out)=0x16d1d34b0 +2026-02-22 15:53:36.466558+0100 0x736818 Default 0x0 46841 0 remarkable_mobile: (RMHook.dylib) [RMHook] <<< sub_1000C8444 RETURN original (17 chars) = "my.remarkable.com" +2026-02-22 15:53:36.466604+0100 0x736818 Default 0x0 46841 0 remarkable_mobile: (RMHook.dylib) [RMHook] <<< sub_1000C8444 patched → "rm.noh.am" +2026-02-22 15:53:47.331051+0100 0x736818 Default 0x0 46841 0 remarkable_mobile: (RMHook.dylib) [RMHook] >>> sub_1000C81B8 ENTER a1=0x12070d5e0 x8(out)=0x16d1497e0 +2026-02-22 15:53:47.331226+0100 0x736818 Default 0x0 46841 0 remarkable_mobile: (RMHook.dylib) [RMHook] <<< sub_1000C81B8 RETURN original (29 chars) = "internal.cloud.remarkable.com" +2026-02-22 15:53:47.331350+0100 0x736818 Default 0x0 46841 0 remarkable_mobile: (RMHook.dylib) [RMHook] <<< patched → "rm.noh.am" +``` + +The above log shows that both hooks are successfully intercepting the hostname resolution and replacing it with "rm.noh.am". However, the app still fails to fetch documents.. +![docs/settings.png](docs/settings.png) +![docs/unsynced.png](docs/unsynced.png) \ No newline at end of file diff --git a/src/Makefile b/src/Makefile new file mode 100644 index 0000000..40f513e --- /dev/null +++ b/src/Makefile @@ -0,0 +1,13 @@ +TARGET = iphone:latest:14.0 +INSTALL_TARGET_PROCESSES = remarkable_mobile +ARCHS = arm64 arm64e + +include $(THEOS)/makefiles/common.mk + +TWEAK_NAME = RMHook + +RMHook_FILES = Tweak.x +RMHook_CFLAGS = -fobjc-arc +RMHook_FRAMEWORKS = Foundation + +include $(THEOS_MAKE_PATH)/tweak.mk diff --git a/src/RMHook.plist b/src/RMHook.plist new file mode 100644 index 0000000..6133d98 --- /dev/null +++ b/src/RMHook.plist @@ -0,0 +1 @@ +{ Filter = { Bundles = ( "com.remarkable.mobile" ); }; } diff --git a/src/Tweak.x b/src/Tweak.x new file mode 100644 index 0000000..dbbe0ef --- /dev/null +++ b/src/Tweak.x @@ -0,0 +1,169 @@ +// RMHook-iOS Tweak (POC) +#import +#import +#import +#import +#import + +// Target binary name inside the IPA +#define TARGET_MODULE "remarkable_mobile" +#define IDA_BASE 0x100000000ULL + +// --- sub_1000C81B8 --- +// void __usercall sub_1000C81B8(__int64 a1@, _QWORD *a2@) +// X8 is ARM64's indirect-result register (not a normal parameter). +// We use a naked trampoline to handle this. +static void (*orig_sub_1000C81B8)(int64_t a1); + +// Pre-call logger +__attribute__((used)) +static void rmhook_pre(int64_t a1, uint64_t *out) { + NSLog(@"[RMHook] >>> sub_1000C81B8 ENTER a1=0x%llx x8(out)=%p", + (unsigned long long)a1, (void *)out); +} + +// Post-call logger and patcher +__attribute__((used)) +static void rmhook_post(uint64_t *out) { + if (!out) { + NSLog(@"[RMHook] <<< sub_1000C81B8 RETURN (out=NULL)"); + return; + } + uint64_t base_ptr = out[0]; + uint64_t data_ptr = out[1]; + uint64_t char_count = out[2]; + if (data_ptr && char_count > 0 && char_count <= 4096) { + NSString *orig = [[NSString alloc] initWithBytes:(const void *)(uintptr_t)data_ptr + length:(NSUInteger)(char_count * 2) + encoding:NSUTF16LittleEndianStringEncoding]; + NSLog(@"[RMHook] <<< sub_1000C81B8 RETURN original (%llu chars) = \"%@\"", + char_count, orig ?: @""); + } + // Patch: replace returned string with custom value + NSString *replacement = @"rm.noh.am"; + NSUInteger newCount = [replacement length]; + if (data_ptr && newCount <= char_count) { + NSData *utf16 = [replacement dataUsingEncoding:NSUTF16LittleEndianStringEncoding]; + memcpy((void *)(uintptr_t)data_ptr, utf16.bytes, utf16.length); + out[2] = newCount; + *(uint64_t *)(uintptr_t)(base_ptr + 8) = newCount; + NSLog(@"[RMHook] <<< patched → \"%@\"", replacement); + } else { + NSLog(@"[RMHook] <<< patch skipped (replacement too long or no buffer)"); + } +} + +// Naked trampoline for sub_1000C81B8 +__attribute__((naked)) +static void hook_sub_1000C81B8(void) { + __asm__ volatile( + "sub sp, sp, #32 \n" + "stp x29, x30, [sp, #16] \n" + "add x29, sp, #16 \n" + "stp x0, x8, [sp, #0] \n" + "mov x1, x8 \n" + "bl _rmhook_pre \n" + "ldp x0, x8, [sp, #0] \n" + "adrp x9, _orig_sub_1000C81B8@PAGE \n" + "ldr x9, [x9, _orig_sub_1000C81B8@PAGEOFF] \n" + "blr x9 \n" + "ldr x0, [sp, #8] \n" + "bl _rmhook_post \n" + "ldp x29, x30, [sp, #16] \n" + "add sp, sp, #32 \n" + "ret \n" + ); +} + +// --- sub_1000C8444 --- +// void __usercall sub_1000C8444(_QWORD *a1@, __int64 a2@) +static void (*orig_sub_1000C8444)(int64_t a1); + +__attribute__((used)) +static void rmhook_pre_8444(int64_t a1, uint64_t *out) { + NSLog(@"[RMHook] >>> sub_1000C8444 ENTER a1=0x%llx x8(out)=%p", + (unsigned long long)a1, (void *)out); +} + +__attribute__((used)) +static void rmhook_post_8444(uint64_t *out) { + if (!out) { + NSLog(@"[RMHook] <<< sub_1000C8444 RETURN (out=NULL)"); + return; + } + uint64_t base_ptr = out[0]; + uint64_t data_ptr = out[1]; + uint64_t char_count = out[2]; + if (data_ptr && char_count > 0 && char_count <= 4096) { + NSString *orig = [[NSString alloc] initWithBytes:(const void *)(uintptr_t)data_ptr + length:(NSUInteger)(char_count * 2) + encoding:NSUTF16LittleEndianStringEncoding]; + NSLog(@"[RMHook] <<< sub_1000C8444 RETURN original (%llu chars) = \"%@\"", + char_count, orig ?: @""); + } + NSString *replacement = @"rm.noh.am"; + NSUInteger newCount = [replacement length]; + if (data_ptr && newCount <= char_count) { + NSData *utf16 = [replacement dataUsingEncoding:NSUTF16LittleEndianStringEncoding]; + memcpy((void *)(uintptr_t)data_ptr, utf16.bytes, utf16.length); + out[2] = newCount; + *(uint64_t *)(uintptr_t)(base_ptr + 8) = newCount; + NSLog(@"[RMHook] <<< sub_1000C8444 patched → \"%@\"", replacement); + } else { + NSLog(@"[RMHook] <<< sub_1000C8444 patch skipped (replacement too long or no buffer)"); + } +} + +__attribute__((naked)) +static void hook_sub_1000C8444(void) { + __asm__ volatile( + "sub sp, sp, #32 \n" + "stp x29, x30, [sp, #16] \n" + "add x29, sp, #16 \n" + "stp x0, x8, [sp, #0] \n" + "mov x1, x8 \n" + "bl _rmhook_pre_8444 \n" + "ldp x0, x8, [sp, #0] \n" + "adrp x9, _orig_sub_1000C8444@PAGE \n" + "ldr x9, [x9, _orig_sub_1000C8444@PAGEOFF] \n" + "blr x9 \n" + "ldr x0, [sp, #8] \n" + "bl _rmhook_post_8444 \n" + "ldp x29, x30, [sp, #16] \n" + "add sp, sp, #32 \n" + "ret \n" + ); +} + +// Helpers +static uintptr_t findModuleBase(const char *moduleName) { + uint32_t count = _dyld_image_count(); + for (uint32_t i = 0; i < count; i++) { + const char *name = _dyld_get_image_name(i); + if (name && strstr(name, moduleName)) + return (uintptr_t)_dyld_get_image_header(i); + } + return 0; +} + +// Constructor +%ctor { + @autoreleasepool { + uintptr_t base = findModuleBase(TARGET_MODULE); + if (!base) { + NSLog(@"[RMHook] Module '%s' not found – tweak inactive.", TARGET_MODULE); + return; + } + NSLog(@"[RMHook] '%s' base = 0x%lx", TARGET_MODULE, (unsigned long)base); + + uintptr_t offset = 0x1000C81B8ULL - IDA_BASE; + uintptr_t addr = base + offset; + MSHookFunction((void *)addr, (void *)hook_sub_1000C81B8, (void **)&orig_sub_1000C81B8); + NSLog(@"[RMHook] Hooked sub_1000C81B8 @ 0x%lx (offset 0x%lx)", (unsigned long)addr, (unsigned long)offset); + + uintptr_t offset2 = 0x1000C8444ULL - IDA_BASE; + uintptr_t addr2 = base + offset2; + MSHookFunction((void *)addr2, (void *)hook_sub_1000C8444, (void **)&orig_sub_1000C8444); + NSLog(@"[RMHook] Hooked sub_1000C8444 @ 0x%lx (offset 0x%lx)", (unsigned long)addr2, (unsigned long)offset2); + } +} diff --git a/src/control b/src/control new file mode 100644 index 0000000..d21a241 --- /dev/null +++ b/src/control @@ -0,0 +1,9 @@ +Package: xyz.noham.rmhook +Name: RMHook +Version: 0.0.1 +Architecture: iphoneos-arm +Description: An awesome MobileSubstrate tweak! +Maintainer: NohamR +Author: NohamR +Section: Tweaks +Depends: mobilesubstrate (>= 0.9.5000)