Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions include/MemoryFunction.h
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class CMemoryFunction
void operator()(void*);

void* GetCode() const;
void* GetWritableCode() const;
size_t GetSize() const;

void BeginModify();
Expand Down
294 changes: 293 additions & 1 deletion src/MemoryFunction.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -43,6 +52,241 @@
#elif defined(MEMFUNC_USE_MMAP)
#include <sys/mman.h>
#include <pthread.h>
#if defined(MEMFUNC_IOS26_JIT_PROTOCOL)
#include <unistd.h>
#include <mutex>
#include <vector>
#include <cstdio>
#include <errno.h>
#include <mach/mach.h>
#include <mach/vm_map.h>

// --- 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<len>,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<MEMFUNC_FREE_CHUNK> 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<uintptr_t>(ptr);
if(value == 0) return false;
if(value == static_cast<uintptr_t>(-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<size_t>(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<vm_size_t>(MEMFUNC_JIT_ARENA_SIZE),
0, VM_FLAGS_ANYWHERE, mach_task_self(),
reinterpret_cast<vm_address_t>(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<int>(kr), source, rx);
return;
}
kr = vm_protect(mach_task_self(), rw, static_cast<vm_size_t>(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<int>(kr));
vm_deallocate(mach_task_self(), rw, static_cast<vm_size_t>(MEMFUNC_JIT_ARENA_SIZE));
return;
}

g_jitArenaRx = static_cast<uint8*>(rx);
g_jitArenaRw = reinterpret_cast<uint8*>(rw);
g_jitArenaReady = true;
snprintf(g_jitStatus, sizeof(g_jitStatus), "jit: OK via %s %zuMB rx=%p rw=%p",
source, static_cast<size_t>(MEMFUNC_JIT_ARENA_SIZE >> 20),
static_cast<void*>(g_jitArenaRx), static_cast<void*>(g_jitArenaRw));
}

//Returns the EXECUTABLE address of a fresh block, or null when unavailable.
static void* MemFunc_ArenaAlloc(size_t size)
{
std::lock_guard<std::mutex> lock(g_jitArenaMutex);
MemFunc_ArenaInitLocked();
if(!g_jitArenaReady) return nullptr;
size_t aligned = (size + (BLOCK_ALIGN - 1)) & ~static_cast<size_t>(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<uint8*>(ptr) - g_jitArenaRx);
}

static void MemFunc_ArenaFree(void* ptr, size_t size)
{
std::lock_guard<std::mutex> lock(g_jitArenaMutex);
size_t aligned = (size + (BLOCK_ALIGN - 1)) & ~static_cast<size_t>(BLOCK_ALIGN - 1);
g_jitArenaFree.push_back({static_cast<size_t>(static_cast<uint8*>(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<std::mutex> 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<std::mutex> 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),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -164,7 +425,19 @@ void CMemoryFunction::Reset()
#elif defined(MEMFUNC_USE_MACHVM)
vm_deallocate(mach_task_self(), reinterpret_cast<vm_address_t>(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<int>(m_code));
#endif
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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