可写又可执行的内存WX常写作 RWX 内存。它能解释很多看似无关的问题为什么 Unity 在 iOS 上必须用 IL2CPP不能用 Mono 的 JIT为什么 LuaJIT 在 iOS 上会变慢为什么 C# 热更方案在 iOS 上只能解释执行为什么某些 Hook 框架、模拟器在新系统上突然崩溃为什么反作弊系统要扫描进程里的 RWX 内存这次用公司的文件管理制度做比喻内存页公司里的一张张文件可读R员工可以查阅可写W员工可以在上面涂改像草稿纸可执行X员工会照着上面写的内容去办事像盖了章的执行令CPU一个非常听话的员工看到执行令就照做从不质疑操作系统行政部负责给每张文件定性质、盖章攻击者混进公司、能往草稿纸上写字的外人说明不同操作系统、版本、芯片的细节差异较大下文介绍原理与主流做法具体 API 行为以所用平台文档为准。代码为简化示意。零、一句话解释进程的内存按页通常 4KB 或 16KB划分每一页都有权限可读、可写、可执行。可写又可执行指一页内存同时允许程序往里写数据、又允许 CPU 把它当作指令来运行。这种内存很危险谁能往里写谁就能让 CPU 执行任意代码。所以现代系统普遍遵循W^X 原则Write XOR Execute一页内存要么可写要么可执行不能同时兼得。比喻草稿纸谁都能写执行令员工必须照办。如果一张纸既能随便写、又会被照着执行那混进来的外人只要在上面写一句把保险柜打开员工就照做了。一、内存页的权限1.1 三种基本权限权限含义典型内容RRead可读取常量、只读数据WWrite可修改堆、栈、全局变量XExecuteCPU 可从这里取指令执行程序代码段.text一个普通程序加载后内存大致如下区域权限说明代码段.textR-X能读能执行不能写只读数据.rodataR–字符串常量等数据段.data/.bssRW-全局变量堆RW-malloc/new出来的内存栈RW-局部变量、返回地址注意正常程序里没有任何一块是 RWX 的。代码不可写数据不可执行。1.2 硬件如何保证权限记录在页表中由 CPU 的内存管理单元MMU在每次访问时检查x86-64 有NX 位No-eXecuteIntel 称 XDARM 有XN / PXN 位CPU 试图从一个没有 X 权限的页取指令时硬件直接触发异常操作系统把它变成崩溃信号平台表现Linux / AndroidSIGSEGVsi_code SEGV_ACCERRWindowsSTATUS_ACCESS_VIOLATIONDEP 拦截iOS / macOSEXC_BAD_ACCESS常见KERN_PROTECTION_FAILUREiOS 上还可能是代码签名失效Windows 把这套机制叫DEP数据执行保护本质是同一回事。比喻行政部给每张纸盖了不同颜色的章员工CPU拿到纸时门口的保安MMU先看章草稿纸想当执行令用直接拦下。1.3 权限可以修改程序可以向操作系统申请特定权限的内存或修改已有内存的权限平台分配修改权限Linux / Android / macOSmmap(..., PROT_READ | PROT_WRITE | PROT_EXEC, ...)mprotectWindowsVirtualAlloc(..., PAGE_EXECUTE_READWRITE)VirtualProtect能不能申请到 RWX取决于操作系统的策略。有的系统直接拒绝有的需要特殊授权有的默认允许但会被安全机制标记。这正是后面平台差异的来源。二、为什么会需要可写又可执行既然 RWX 这么危险为什么还有人要用因为有一类技术天生需要在运行时生成代码并执行技术为什么需要游戏中的例子JIT 编译即时编译运行时把字节码翻译成机器码写进内存再执行Mono JIT、LuaJIT、JavaScript 引擎V8、JavaScriptCore、.NET CoreCLR动态重编译把其他平台的机器码翻译成本机机器码主机/掌机模拟器、Rosetta 类转译层Inline Hook修改函数开头的指令跳转到自己的代码性能分析工具、部分崩溃 SDK、外挂、Hook 框架运行时代码修补热补丁、自修改代码一些老式加壳、保护方案Trampoline / Thunk动态生成小段跳板代码回调桥接、部分反射调用、FFI 库如 libffi以 JIT 为例最朴素的写法就是// 简化示意最朴素的 JIT —— 申请 RWX写入机器码直接调用#includesys/mman.h#includestring.htypedefint(*Func)(void);intrun_jit(void){// x86-64 机器码mov eax, 42 ; retunsignedcharcode[]{0xB8,0x2A,0x00,0x00,0x00,0xC3};void*memmmap(NULL,4096,PROT_READ|PROT_WRITE|PROT_EXEC,// ← RWXMAP_PRIVATE|MAP_ANONYMOUS,-1,0);memcpy(mem,code,sizeof(code));// 写return((Func)mem)();// 执行返回 42}这段代码在很多 Linux 桌面上能跑但在 iOS 上会直接失败在开启了严格策略的 Android、Windows 进程里也可能失败。比喻JIT 就像一个现场写执行令的秘书领导口述秘书当场写好员工立刻照办。效率极高但这张纸在写的那一刻是草稿执行的那一刻是执行令。问题在于如果这张纸一直同时具备两种身份外人也能趁机在上面加一句话。三、为什么 RWX 内存危险3.1 攻击者的经典路径大多数内存漏洞缓冲区溢出、释放后使用、越界写给攻击者的能力是往某个地址写入一段数据。光能写数据危害有限但如果能让 CPU 去执行这段数据就等于完全控制了进程。1. 找到漏洞获得任意写能力 2. 把恶意机器码shellcode写进内存 3. 劫持控制流覆盖返回地址、函数指针、虚表跳过去 4. CPU 执行 shellcode ──► 完全控制进程W^X 卡住的是第 2 步到第 4 步的衔接写得进去的地方不能执行能执行的地方写不进去。内存攻击者能写吗CPU 会执行吗结果堆 / 栈RW-能不会写了也没用代码段R-X不能会无法篡改RWX 内存能会攻击直接成功所以进程里只要存在一块长期的 RWX 内存它就是攻击者的现成跳板。3.2 攻击者的绕路ROPW^X 普及后攻击者改用ROP面向返回的编程不写新代码而是拼接程序里已有的代码片段。由于这非常麻烦攻击者最常见的 ROP 目标之一恰恰是调用mprotect/VirtualProtect把一块内存改成 RWX然后再回到老路。因此现代系统不仅禁止一开始就申请 RWX还进一步限制**“把可写内存改成可执行”**见第五节的 iOS、Windows ACG。比喻草稿纸不能当执行令以后外人改成剪贴现有执行令里的句子拼成新命令。最省事的拼法是拼出一句把这张草稿纸盖成执行令。所以行政部干脆规定草稿纸永远不能补盖执行章除非经过特批。四、合规地生成代码W^X 的两种做法JIT 不能放弃于是业界发展出既能动态生成代码、又不出现 RWX 的方法。4.1 做法一权限翻转Toggle同一块内存写的时候设为 RW-写完改成 R-X 再执行// 简化示意写时 RW执行时 RX任何时刻都不同时具备 W 和 Xvoid*memmmap(NULL,size,PROT_READ|PROT_WRITE,MAP_PRIVATE|MAP_ANONYMOUS,-1,0);memcpy(mem,code,code_size);// 阶段一可写mprotect(mem,size,PROT_READ|PROT_EXEC);// 阶段二可执行__builtin___clear_cache(mem,(char*)memcode_size);// ARM 上必须刷新指令缓存((Func)mem)();优点缺点简单每次改权限都是系统调用频繁 JIT 时开销大仍依赖允许把 W 改为 X严格平台上这一步本身被禁止多线程下一个线程在写另一个线程可能正好要执行同一页4.2 做法二双重映射Dual Mapping把同一块物理内存映射到两个虚拟地址一个地址只可写一个地址只可执行。往写视图写入从执行视图运行。┌──────────── 物理内存同一份 ────────────┐ 写视图 0x7f00_1000 RW- ─┘ └─ R-X 0x5a00_8000 执行视图 JIT 编译器往这里写 CPU 从这里取指令// 简化示意Linuxmemfd 两次 mmapintfdmemfd_create(jit,0);ftruncate(fd,size);void*rwmmap(NULL,size,PROT_READ|PROT_WRITE,MAP_SHARED,fd,0);void*rxmmap(NULL,size,PROT_READ|PROT_EXEC,MAP_SHARED,fd,0);memcpy(rw,code,code_size);// 通过写视图写入__builtin___clear_cache(rx,(char*)rxcode_size);((Func)rx)();// 通过执行视图执行优点缺点不需要反复改权限性能好实现复杂地址需要换算任意时刻都没有一个地址是 RWX写视图的地址仍需保密否则攻击者可以从写视图写入部分平台禁止或限制这种映射V8、.NETW^X 模式、部分模拟器都采用双重映射或其变体。比喻同一份内容做两份一份放在秘书桌上随便改一份锁在玻璃柜里只能看、只能照做。秘书改了桌上那份玻璃柜里的同步更新但谁也没法直接在玻璃柜那份上涂改。4.3 别忘了指令缓存在 ARM 上手机几乎都是 ARMCPU 的数据缓存和指令缓存是分开的。刚写入的机器码还在数据缓存里CPU 取指令时可能读到旧内容。动态生成代码后必须显式刷新指令缓存__builtin___clear_cache、sys_icache_invalidate等否则会出现偶发、难以复现的崩溃。x86 在这方面由硬件自动保证一致性所以很多桌面上没问题的 JIT 代码移植到手机上会诡异地崩溃。五、各平台的态度这是游戏开发者最需要知道的部分同一段代码在不同平台上能不能用 RWX差别巨大。平台对动态代码的态度说明iOS / iPadOS最严格普通 App 禁止代码签名强制所有可执行页必须带有效签名运行时生成的代码没有签名无法执行。只有系统组件如 Safari 的 JavaScriptCore和获得特殊授权的应用能 JITmacOS有条件允许开启 Hardened Runtime 后需声明com.apple.security.cs.allow-jit授权并用MAP_JIT申请Apple Silicon 上按线程切换写/执行状态Android普通 App 基本允许应用进程可以mmap可执行匿名内存ART 自身就有 JIT但系统对部分进程施加 SELinuxexecmem限制新版本对 W^X 也在收紧Windows默认允许可关闭DEP 默认开启但PAGE_EXECUTE_READWRITE仍可申请进程可启用ACG任意代码保护彻底禁止Edge 等浏览器就开了Linux 桌面默认允许SELinux、PaX 等加固方案可禁止游戏主机普通游戏禁止与 iOS 类似代码必须经过签名JIT 通常只开放给平台方自己的组件WebAssembly / 浏览器浏览器内部 JIT网页本身无法申请可执行内存5.1 macOS / Apple Silicon 的写法// 简化示意macOSApple Silicon上合规的 JIT#includepthread.h#includelibkern/OSCacheControl.hvoid*memmmap(NULL,size,PROT_READ|PROT_WRITE|PROT_EXEC,MAP_PRIVATE|MAP_ANONYMOUS|MAP_JIT,-1,0);// 必须带 MAP_JITpthread_jit_write_protect_np(0);// 当前线程切换为可写不可执行memcpy(mem,code,code_size);pthread_jit_write_protect_np(1);// 当前线程切换为可执行不可写sys_icache_invalidate(mem,code_size);((Func)mem)();看似申请了 RWX实际上硬件按线程强制 W^X同一时刻、同一线程这块内存要么可写要么可执行。这是权限翻转的硬件加速版切换几乎没有开销。5.2 iOS为什么这么严iOS 的规则可以概括为能执行的每一页都必须能在代码签名中找到。运行时生成的机器码天然没有签名所以无论你用mmap、mprotect还是双重映射都无法让它执行。比喻iOS 的行政部规定所有执行令必须是总部Apple审核盖章后发下来的。分公司自己写的执行令不管看起来多正规员工一律不认。这就是很多游戏技术选型的根源下一节展开。六、在游戏开发中它影响了什么6.1 UnityMono JIT 与 IL2CPP脚本后端原理iOS 能否使用MonoJIT运行时把 C# IL 编译成机器码不能需要可执行的动态内存IL2CPPAOT构建时把 IL 转成 C 再编译成原生代码能所有代码都在签名的二进制里这就是为什么 Unity iOS 一直只支持 IL2CPP早期 Mono 在 iOS 上也走的是 Full AOT而不是 JIT。AOT 带来的连锁影响现象原因泛型虚方法、某些泛型组合运行时报ExecutionEngineException/ 缺少 AOT 代码构建时没生成这份具体代码运行时又不能现场生成System.Reflection.Emit不可用它本质就是运行时生成代码依赖动态代码生成的序列化库、表达式树编译变慢或报错Expression.Compile()在 AOT 下退化为解释执行6.2 C# 热更新国内常用的 C# 热更方案在 iOS 上不可能把新代码 JIT 成机器码执行只能通过以下方式方案思路原理解释执行在 IL2CPP 运行时中内置一个 IL 解释器如 HybridCLR新增代码由解释器逐条执行翻译为脚本C# 转 Lua 等脚本由脚本虚拟机执行预埋桥接构建时预先生成所需的桥接函数这也是为什么这类方案需要补充元数据、生成桥接代码等构建步骤解释器只需要读写数据字节码和虚拟机状态都放在普通 RW- 内存里完全不需要可执行的动态内存因此合规。代价是性能比原生代码低一截这也是为什么热更代码通常建议放业务逻辑而不是战斗核心循环。6.3 LuaLuaJIT 在 iOS 上为何变慢运行环境LuaJIT 模式Android、PC可以开启 JIT热点代码编译成机器码速度很快iOS、主机只能用解释器模式JIT 被禁用LuaJIT 的解释器本身已经很快但项目如果在 Android 上依赖 JIT 做过性能评估到 iOS 上可能出现明显落差。此外 LuaJIT 在 ARM64 上的 JIT 稳定性、GC64 等问题也让不少项目在移动端统一关闭 JIT 或改用标准 Lua保证两端行为一致。6.4 JavaScript 与小游戏场景情况内嵌 V8 的游戏或工具iOS 上必须使用 jitless 模式或改用系统 JavaScriptCoreWKWebView 中的 H5 / 小游戏JS 在系统的 WebContent 进程中运行该进程有 JIT 授权所以速度正常自带 JSC 但不在系统进程中无法开启 JIT只有解释器6.5 模拟器与动态重编译模拟器靠把别的芯片的指令翻译成本机指令获得性能这就是典型的动态代码生成。所以在 Android、PC 上可以有高性能的动态重编译在 iOS 上普通应用只能用解释执行性能差一个数量级一些需要 JIT 的应用依赖调试器附加、特殊授权等手段才能开启并且随系统版本经常失效。6.6 Hook、性能工具与崩溃 SDKInline Hook 的原理是改写函数开头几条指令让它跳到自己的代码。代码段本来是 R-X所以 Hook 框架必须把目标页临时改为可写甚至 RWX写入跳转指令改回 R-X 并刷新指令缓存另外申请一块可执行内存存放被覆盖的原指令 跳回去的跳板。这带来的问题问题说明iOS 上普通应用无法 Inline Hook 系统函数或自身代码改写后的页失去签名执行即崩溃常见替代是 fishhook 这类改写符号表指针数据不是代码的方式新版 Android 上部分 Hook 框架失效系统对mprotect改写可执行页、对可执行匿名内存的限制在收紧改权限不是原子的多线程正在执行被改写的函数时可能崩溃成熟框架会暂停其他线程或采用特殊指令序列被反作弊误判见下一节6.7 反作弊RWX 内存是重要信号正常游戏进程中几乎不存在长期的 RWX 内存而外挂注入、代码注入通常会留下这类痕迹。所以反作弊系统常做的检查包括检查意义扫描进程中的 RWX 页很可能有注入的 shellcode扫描可执行但不属于任何已加载模块的内存手动映射Manual Map注入的 DLL / so校验代码段内容代码段被改写说明有 Inline Hook监控VirtualProtect/mprotect改为可执行运行时修改代码对游戏开发者的启示如果游戏自己或某个第三方 SDK在使用 RWX 内存就会产生大量误报噪声让反作弊难以区分正常与异常。因此大厂通常要求游戏代码和 SDK 中不使用 RWX 内存需要 JIT 的组件如脚本引擎要报备反作弊对其内存区域白名单处理新接入 SDK 时检查其是否会申请 RWX 或改写代码段。比喻公司里本来不该有既能涂改又会被执行的纸。安保巡查时只要发现一张就要追查是谁放的。如果某个部门SDK天天在用这种纸安保就会疲于奔命真正混进来的外人反而不容易发现。七、怎么查看进程里的内存权限7.1 Android / Linux/proc/pid/mapsadb shell run-as com.example.gamecat/proc/self/maps# 可调试包# 或 root 设备cat /proc/pid/maps输出示例节选地址范围 权限 偏移 设备 inode 路径 7a1c200000-7a1d800000 r-xp 00000000 fd:05 12345 /data/app/.../lib/arm64/libil2cpp.so 7a1d800000-7a1d9a0000 r--p 01600000 fd:05 12345 /data/app/.../lib/arm64/libil2cpp.so 7a1d9a0000-7a1da20000 rw-p 017a0000 fd:05 12345 /data/app/.../lib/arm64/libil2cpp.so 7b3e400000-7b3e600000 rw-p 00000000 00:00 0 [anon:scudo:primary] 7c0a000000-7c0a100000 rwxp 00000000 00:00 0 ← 匿名 RWX值得关注权限四个字符r读、w写、x执行、p私有s共享。# 快速找出所有 RWX 页grep rwx/proc/pid/maps7.2 其他平台平台工具WindowsSysinternalsVMMap、Process Hacker / System Informer 的内存视图代码中用VirtualQuery遍历macOSvmmap pid可以看到每个区域的当前权限和最大权限iOSXcode 内存调试器、vmmap模拟器 / 调试场景正常情况下基本不会出现可写可执行页7.3 代码中自检// 简化示意Android 上启动时自检是否存在 RWX 匿名映射作为上报与排查依据#includestdio.h#includestring.hintcount_rwx_regions(void){FILE*ffopen(/proc/self/maps,r);if(!f)return-1;charline[512];intcount0;while(fgets(line,sizeof(line),f)){charperms[5]{0};if(sscanf(line,%*s %4s,perms)1strncmp(perms,rwx,3)0)count;}fclose(f);returncount;}注意自检逻辑本身也可能被外挂篡改正式的反作弊需要多重校验和服务器侧判断这里只用于开发期排查是谁在申请 RWX。八、完整案例背景一款 Unity 手游Android 和 iOS 双端脚本层使用 LuaJITAndroid 开 JITiOS 解释模式C# 热更方案在 iOS 上走解释执行。上线前接入反作弊测试期间出现三个问题反作弊在所有Android 正常设备上都上报疑似代码注入部分新系统 Android 设备上游戏偶发启动即崩溃崩溃在 LuaJIT 内部同一段战斗 Lua 逻辑iOS 耗时约是 Android 的 3 倍。问题 1反作弊误报读取/proc/self/maps发现正常设备上也有多块rwxp匿名内存。逐个排查来源来源说明处理LuaJIT 机器码区LuaJIT 写代码与执行代码时使用的映射编译时启用其 W^X 相关选项按版本确认写入时 RW完成后 RX某性能监控 SDK启动时 Inline Hookmalloc等函数跳板区一直是 RWX正式包移除该 SDK 的 Hook 功能只在内部测试包启用某广告 SDK 内置的 JS 引擎开启了 JIT联系厂商提供关闭 JIT 的开关处理后正常设备上 RWX 区域为 0反作弊将存在 RWX 内存恢复为高置信度信号。问题 2新系统设备偶发崩溃崩溃信息为SIGSEGV / SEGV_ACCERR地址落在 LuaJIT 刚生成的机器码区内且只在 ARM64 设备上出现。排查发现两个原因叠加修改为 W^X 后某个代码路径在权限切回 RX 之前就发生了执行多线程Lua 协程调度与 JIT 编译线程交错个别路径写完机器码后没有刷新指令缓存在部分芯片上读到旧指令。修复统一权限切换与缓存刷新的入口确保写 → 刷新缓存 → 改 RX → 执行的顺序同时评估后决定移动端统一关闭 LuaJIT 的 JIT只用解释器牺牲 Android 部分峰值性能换取两端行为一致和稳定性。问题 3iOS 性能落差关闭 Android JIT 后两端 Lua 性能差距缩小到正常的硬件差异范围暴露出真正的热点战斗中每帧大量的 Lua ↔ C# 互调和临时 table 分配。把伤害计算、Buff 结算等核心循环下沉到 C#IL2CPP 原生代码Lua 只保留流程控制战斗逻辑耗时在两端都降低约 60%。复盘教训说明不要以有 JIT 的平台做性能基准iOS、主机都不能 JIT应以解释模式或 AOT 为准做预算RWX 内存要可追溯每一块可执行的动态内存都应该知道是谁申请的、为什么需要第三方 SDK 要审计很多 RWX 来自 SDK 而不是自己的代码ARM 上必须刷新指令缓存x86 上测不出这类 Bug九、常见问题速查现象可能原因解法iOS 上Reflection.Emit报错iOS 不允许运行时生成代码改为预生成代码或使用不依赖 Emit 的库IL2CPP 下泛型方法运行时报缺少 AOT 代码构建时没生成该泛型实例在代码中显式引用该泛型组合或使用热更方案的补充元数据Expression.Compile()在 iOS 上很慢退化为解释执行缓存委托或避免在热点路径使用LuaJIT 在 iOS 上比 Android 慢很多iOS 只能解释执行以解释模式做性能预算核心逻辑下沉原生mmap申请可执行内存失败平台策略禁止iOS、SELinux、ACG改用解释器或平台提供的合规 JIT 方式macOS 上 JIT 代码签名后崩溃Hardened Runtime 未声明 JIT 授权或未用MAP_JIT添加授权按线程切换写保护ARM 上动态代码偶发崩溃未刷新指令缓存写入后调用缓存刷新SEGV_ACCERR落在堆或栈地址跳转到了数据区执行多半是函数指针或虚表被破坏按内存破坏问题排查ASan、HWASanWindows 上STATUS_ACCESS_VIOLATION且 DEP 相关执行了不可执行的内存同上第三方注入工具也会导致Hook 框架在新系统上失效系统收紧了代码页修改与可执行内存改用符号表 / PLT 层的 Hook或升级框架反作弊在正常设备上频繁报注入游戏或 SDK 自身使用 RWX 内存排查来源消除或白名单总结用公司文件制度的话串起来公司里的每张纸都盖着章能看的、能改的、要照办的。员工CPU非常听话看到执行令就照做所以行政部操作系统立下铁律**草稿纸不能当执行令执行令