diff --git a/include/MemoryFunction.h b/include/MemoryFunction.h index fca4a0f2..45a4b31b 100644 --- a/include/MemoryFunction.h +++ b/include/MemoryFunction.h @@ -24,6 +24,7 @@ class CMemoryFunction void operator()(void*); void* GetCode() const; + void* GetWritableCode() const; size_t GetSize() const; void BeginModify(); diff --git a/src/MemoryFunction.cpp b/src/MemoryFunction.cpp index 73a3dabf..99bf0b10 100644 --- a/src/MemoryFunction.cpp +++ b/src/MemoryFunction.cpp @@ -22,6 +22,15 @@ #if TARGET_CPU_ARM64 #define MEMFUNC_MMAP_REQUIRES_JIT_WRITE_PROTECT #endif + #elif TARGET_OS_IPHONE && TARGET_CPU_ARM64 + // iOS 26 on TXM/SPTM hardware (A15+): the process itself can never make a + // page executable - MAP_JIT returns EPERM without dynamic-codesigning, and + // mprotect silently strips PROT_EXEC. Only a write performed *through an + // attached debug connection* marks a page as JIT-executable. StikDebug's + // universal.js implements that debugger side; the app has to ask for it by + // trapping with brk #0xf00d (x16 = command). See StikJIT INTEGRATION.md. + #define MEMFUNC_USE_MMAP + #define MEMFUNC_IOS26_JIT_PROTOCOL #else #define MEMFUNC_USE_MACHVM #if TARGET_OS_IPHONE @@ -43,6 +52,241 @@ #elif defined(MEMFUNC_USE_MMAP) #include #include +#if defined(MEMFUNC_IOS26_JIT_PROTOCOL) +#include +#include +#include +#include +#include +#include +#include + +// --- iOS 26 TXM JIT protocol (StikDebug / StikJIT "universal" script) --------- +// On TXM/SPTM hardware (A15+) a process can never grant itself PROT_EXEC. The +// only mechanism is an out-of-process write performed by an attached debugger: +// for each 16K page of a region, debugserver writes one byte, and that write is +// what grants the page execute permission. StikDebug's universal.js implements +// that side; the app requests it with brk #0xf00d (command in x16, args x0/x1). +// +// Two details are load-bearing and easy to get wrong: +// * The region address passed in x0 MUST be null. That selects the debugger's +// "fresh allocation" branch (it allocates via GDB-remote _M,rx and then +// prepares it). Passing an address we allocated ourselves returns success but +// silently never grants execute permission. +// * The region handed back is execute-only - writing to it faults. A second, +// writable alias of the same physical pages is made locally with vm_remap. +// +// A brk with no debugger attached raises an unhandled SIGTRAP that kills the +// process, so every call is gated on CS_DEBUGGED. We never send JIT26Detach: +// execute permission is tied to the debugger staying attached. +extern "C" int csops(pid_t pid, unsigned int ops, void* useraddr, size_t usersize); + +static bool MemFunc_IsDebuggerAttached() +{ + uint32_t flags = 0; + if(csops(getpid(), 0 /*CS_OPS_STATUS*/, &flags, sizeof(flags)) != 0) return false; + return (flags & 0x10000000u) != 0; //CS_DEBUGGED +} + +__attribute__((noinline, optnone, naked)) +static void* JIT26PrepareRegion(void* address, size_t length) +{ + __asm__ volatile( + "mov x16, #1\n" + "brk #0xf00d\n" + "ret\n"); +} + +// One dual-mapped arena for the whole session: the executable side is obtained +// once from the debugger, the writable side is a local alias of it, and every +// block is sub-allocated out of the pair. +static const size_t MEMFUNC_JIT_ARENA_SIZE = 64 * 1024 * 1024; + +namespace +{ + struct MEMFUNC_FREE_CHUNK + { + size_t offset; + size_t size; + }; +} + +static uint8* g_jitArenaRx = nullptr; +static uint8* g_jitArenaRw = nullptr; +static size_t g_jitArenaBump = 0; +static bool g_jitArenaReady = false; +static bool g_jitArenaTried = false; +static std::mutex g_jitArenaMutex; +static std::vector g_jitArenaFree; +static char g_jitStatus[256] = "jit: not initialized"; + +//An unserviced request doesn't necessarily return null: the breakpoint encoding +//can be left behind in x0, so the result has to be validated before it's used as +//an address. +static bool MemFunc_IsUsableJitRegion(void* ptr) +{ + uintptr_t value = reinterpret_cast(ptr); + if(value == 0) return false; + if(value == static_cast(-1)) return false; + //Leftovers from an unserviced breakpoint (0x69 is the legacy brk immediate). + if(value == 0x690000e0ull) return false; + if(value == 0xcccccccc690000e0ull) return false; + //Anything the debugger hands back is page aligned. + if((value & 0x3FFFull) != 0) return false; + return true; +} + +static void MemFunc_ArenaInitLocked() +{ + if(g_jitArenaTried) return; + g_jitArenaTried = true; + + void* rx = nullptr; + const char* source = "none"; + + //Preferred path: ask the attached debugger for an executable region. This is + //the only thing that can work where TXM is enforced, and it works fine where + //it isn't, so we never branch on a TXM check of our own. Such a check goes + //stale as soon as Apple enables TXM on more devices in a point release, which + //sends the app down a path that can no longer produce executable memory. + if(MemFunc_IsDebuggerAttached()) + { + //The script can be momentarily busy or suspended, so don't give up on the + //first miss. + for(unsigned int attempt = 0; attempt < 3; attempt++) + { + void* candidate = JIT26PrepareRegion(nullptr, MEMFUNC_JIT_ARENA_SIZE); + if(MemFunc_IsUsableJitRegion(candidate)) + { + rx = candidate; + source = "debugger"; + break; + } + usleep(50 * 1000); + } + } + + //Where nothing is enforcing W^X the process can still map an executable + //region itself, so fall back to that rather than failing outright. + if(rx == nullptr) + { + void* mapped = mmap(nullptr, MEMFUNC_JIT_ARENA_SIZE, PROT_READ | PROT_EXEC, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if(mapped != MAP_FAILED) + { + rx = mapped; + source = "mmap"; + } + } + + if(rx == nullptr) + { + snprintf(g_jitStatus, sizeof(g_jitStatus), "jit: FAIL no region (dbg=%d, %zuMB)", + MemFunc_IsDebuggerAttached() ? 1 : 0, + static_cast(MEMFUNC_JIT_ARENA_SIZE >> 20)); + return; + } + + //The returned region is execute-only; alias it for writing. + vm_address_t rw = 0; + vm_prot_t curProt = VM_PROT_NONE; + vm_prot_t maxProt = VM_PROT_NONE; + kern_return_t kr = vm_remap(mach_task_self(), &rw, static_cast(MEMFUNC_JIT_ARENA_SIZE), + 0, VM_FLAGS_ANYWHERE, mach_task_self(), + reinterpret_cast(rx), FALSE, + &curProt, &maxProt, VM_INHERIT_NONE); + if(kr != KERN_SUCCESS) + { + snprintf(g_jitStatus, sizeof(g_jitStatus), "jit: FAIL vm_remap kr=%d (%s) rx=%p", static_cast(kr), source, rx); + return; + } + kr = vm_protect(mach_task_self(), rw, static_cast(MEMFUNC_JIT_ARENA_SIZE), FALSE, + VM_PROT_READ | VM_PROT_WRITE); + if(kr != KERN_SUCCESS) + { + snprintf(g_jitStatus, sizeof(g_jitStatus), "jit: FAIL vm_protect kr=%d", static_cast(kr)); + vm_deallocate(mach_task_self(), rw, static_cast(MEMFUNC_JIT_ARENA_SIZE)); + return; + } + + g_jitArenaRx = static_cast(rx); + g_jitArenaRw = reinterpret_cast(rw); + g_jitArenaReady = true; + snprintf(g_jitStatus, sizeof(g_jitStatus), "jit: OK via %s %zuMB rx=%p rw=%p", + source, static_cast(MEMFUNC_JIT_ARENA_SIZE >> 20), + static_cast(g_jitArenaRx), static_cast(g_jitArenaRw)); +} + +//Returns the EXECUTABLE address of a fresh block, or null when unavailable. +static void* MemFunc_ArenaAlloc(size_t size) +{ + std::lock_guard lock(g_jitArenaMutex); + MemFunc_ArenaInitLocked(); + if(!g_jitArenaReady) return nullptr; + size_t aligned = (size + (BLOCK_ALIGN - 1)) & ~static_cast(BLOCK_ALIGN - 1); + for(size_t i = 0; i < g_jitArenaFree.size(); i++) + { + if(g_jitArenaFree[i].size >= aligned) + { + size_t offset = g_jitArenaFree[i].offset; + if(g_jitArenaFree[i].size >= aligned + BLOCK_ALIGN) + { + g_jitArenaFree[i].offset += aligned; + g_jitArenaFree[i].size -= aligned; + } + else + { + g_jitArenaFree.erase(g_jitArenaFree.begin() + i); + } + return g_jitArenaRx + offset; + } + } + if((g_jitArenaBump + aligned) > MEMFUNC_JIT_ARENA_SIZE) return nullptr; + size_t offset = g_jitArenaBump; + g_jitArenaBump += aligned; + return g_jitArenaRx + offset; +} + +static bool MemFunc_ArenaOwns(void* ptr) +{ + return g_jitArenaReady && (ptr >= g_jitArenaRx) && (ptr < (g_jitArenaRx + MEMFUNC_JIT_ARENA_SIZE)); +} + +//Maps an executable arena address to its writable alias. +static void* MemFunc_ArenaToWritable(void* ptr) +{ + if(!MemFunc_ArenaOwns(ptr)) return ptr; + return g_jitArenaRw + (static_cast(ptr) - g_jitArenaRx); +} + +static void MemFunc_ArenaFree(void* ptr, size_t size) +{ + std::lock_guard lock(g_jitArenaMutex); + size_t aligned = (size + (BLOCK_ALIGN - 1)) & ~static_cast(BLOCK_ALIGN - 1); + g_jitArenaFree.push_back({static_cast(static_cast(ptr) - g_jitArenaRx), aligned}); +} + +//Called by the app during launch, while the JIT script is still attached. +extern "C" void MemFunc_InitJitArena() +{ + std::lock_guard lock(g_jitArenaMutex); + MemFunc_ArenaInitLocked(); +} + +extern "C" const char* MemFunc_GetJitStatus() +{ + return g_jitStatus; +} + +//True once an executable JIT region has actually been obtained. This is the +//authoritative "is JIT usable" signal on iOS 26 - process-level flags like a +//debugger being attached do not imply an executable region was granted. +extern "C" bool MemFunc_IsJitReady() +{ + std::lock_guard lock(g_jitArenaMutex); + return g_jitArenaReady; +} +#endif #elif defined(MEMFUNC_USE_WASM) EM_JS_DEPS(WasmMemoryFunction, "$addFunction,$removeFunction"); EM_JS(int, WasmCreateFunction, (emscripten::EM_VAL moduleHandle), @@ -119,12 +363,29 @@ CMemoryFunction::CMemoryFunction(const void* code, size_t size) additionalMapFlags = MEMFUNC_MMAP_ADDITIONAL_FLAGS; #endif m_size = size; - m_code = mmap(nullptr, size, PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANONYMOUS | additionalMapFlags, -1, 0); +#ifdef MEMFUNC_IOS26_JIT_PROTOCOL + // m_code is the EXECUTABLE address (sub-allocated from the debugger-prepared + // arena); the code itself is written through its writable alias. If the arena + // is unavailable we fall back to a plain mapping so the app still runs (it + // just won't be able to execute, which the caller reports via the status). + m_code = MemFunc_ArenaAlloc(size); + if(m_code == nullptr) + { + m_code = mmap(nullptr, size, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + assert(m_code != MAP_FAILED); + } +#else + m_code = mmap(nullptr, size, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANONYMOUS | additionalMapFlags, -1, 0); assert(m_code != MAP_FAILED); +#endif #ifdef MEMFUNC_MMAP_REQUIRES_JIT_WRITE_PROTECT pthread_jit_write_protect_np(false); #endif +#ifdef MEMFUNC_IOS26_JIT_PROTOCOL + memcpy(MemFunc_ArenaToWritable(m_code), code, size); +#else memcpy(m_code, code, size); +#endif #ifdef MEMFUNC_MMAP_REQUIRES_JIT_WRITE_PROTECT pthread_jit_write_protect_np(true); #endif @@ -164,7 +425,19 @@ void CMemoryFunction::Reset() #elif defined(MEMFUNC_USE_MACHVM) vm_deallocate(mach_task_self(), reinterpret_cast(m_code), m_size); #elif defined(MEMFUNC_USE_MMAP) + #ifdef MEMFUNC_IOS26_JIT_PROTOCOL + // Arena memory must never be unmapped - it can't be blessed again. + if(MemFunc_ArenaOwns(m_code)) + { + MemFunc_ArenaFree(m_code, m_size); + } + else + { + munmap(m_code, m_size); + } + #else munmap(m_code, m_size); + #endif #elif defined(MEMFUNC_USE_WASM) WasmDeleteFunction(reinterpret_cast(m_code)); #endif @@ -204,6 +477,18 @@ void* CMemoryFunction::GetCode() const return m_code; } +//Address to write generated code through. Same as GetCode() everywhere except +//iOS 26, where the executable mapping is not writable and a separate alias of +//the same physical pages must be used. +void* CMemoryFunction::GetWritableCode() const +{ +#ifdef MEMFUNC_IOS26_JIT_PROTOCOL + return MemFunc_ArenaToWritable(m_code); +#else + return m_code; +#endif +} + size_t CMemoryFunction::GetSize() const { return m_size; @@ -242,3 +527,10 @@ CMemoryFunction CMemoryFunction::CreateInstance() return CMemoryFunction(GetCode(), GetSize()); #endif } + +#if defined(__APPLE__) && TARGET_OS_IPHONE && !defined(MEMFUNC_IOS26_JIT_PROTOCOL) +//iOS targets that don't use the TXM JIT protocol still link against these. +extern "C" void MemFunc_InitJitArena() {} +extern "C" const char* MemFunc_GetJitStatus() { return "jit: not applicable"; } +extern "C" bool MemFunc_IsJitReady() { return false; } +#endif