iOS 26.4 如何限制系统进程使用 JavaScriptCore
背景
JavaScriptCore 是 Apple 系统自带的 js 引擎。它不只服务于 Safari 和 WebKit,也可以被原生程序通过链接 JavaScriptCore.framework 直接调用执行 js。
原本正常的系统框架,攻击者却可以把 JavaScriptCore 当成系统内置解释器,通过代码注入技术把后利用逻辑寄生在已有进程里。通过脚本访问原生接口,不需要下载可执行文件,也不会产生新的进程。
这种借助系统自带脚本解释器运行代码的思路并不是新鲜事。哪怕只看 iOS 平台的公开研究,Google Project Zero 在 2019 年演示 iMessage 零点击利用的时候就已经用过了。
DarkSword 是比较近的现实案例。2026 年 3 月,iVerify、Google TAG 和 Lookout 联合披露了 DarkSword 工具包针对 iOS 用户大批量发起攻击的事件。它在完成沙箱逃逸和系统提权之后,并不启动独立 payload 进程,而是借用系统中已经存在的服务,在其中运行基于 JavaScriptCore 的代码来实现 C2 逻辑和提取系统敏感信息。甚至提权利用本身也是用 js 实现的。
所谓的无文件代码执行大体步骤如下:
-
使用 dlopen 动态链接 JavaScriptCore.framework -
调用 JSContext 创建 js 执行环境 -
绑定系统 native API(bridge)和内存读写能力到上下文 -
运行 js
上面的步骤在浏览器、混合式应用或者需要脚本能力的系统组件里并不奇怪。但对某些关键系统进程(比如用户态核心的 launchd)来说,如果它们开始运行 js,那肯定是非预期行为。苹果的思路就是在预设的系统进程列表当中彻底禁用 JavaScriptCore。
根据 WebKit 的历史记录来看,在 2025 年 8 月的提交 d5e7d2a3eeeeab55e93553b2fc91fc61327a6ffb 就引入了针对性的防御,应该是早于 DarkSword 活动的时间。而它恰好是 Apple 对这种滥用解释器的利用技术的防御,应该是早已设计好的加固能力的一部分,只是到 26.4 才正式发布。
本文就来分析一下这个防御机制的具体实现。
新的 Entitlement
从 iOS 26.4 开始,launchd 以及若干高风险进程的 entitlement 就多了一个 com.apple.security.script-restrictions
注:entitlement 是苹果操作系统的机制。将一段 property list——键值对数据,与代码签名绑定后保存在可执行文件中,用来为操作系统标记特殊的权限


这些进程多与缩略图生成和 BlastDoor 有关
在苹果官方文档当中已经提到了如下 entitlement 可以启用包括 MTE 在内的多种加固措施,甚至对第三方开发者也开放了:
-
com.apple.security.hardened-process -
com.apple.security.hardened-process.enhanced-security-version-string -
com.apple.security.hardened-process.checked-allocations -
com.apple.security.hardened-process.platform-restrictions-string -
com.apple.security.hardened-process.dyld-ro
它们可以单独展开写各自的文章,篇幅限制就不深入了。
这个 script-restrictions 并没有出现在文档中。尝试在 Xcode iOS 工程中添加,构建之后会被悄悄移除;如果手工调用 codesign 命令签名 app 加上,安装过程会被真机拒绝。
所以目前这个机制不仅文档没有写,也不对第三方 app 开发者开放。不开放就算了,这个加固对第三方应用确实没任何作用,反而不少开发者都喜欢业务逻辑用 js 写,一套代码跑多端。禁了不是给自己找不快。
来实测验证一下。虽然 iOS 不让测试 app 用这个 entitlement,但是 macOS 26.5 没问题。
test_jsc.c
#include<stdio.h>#include<stdint.h>#include<dlfcn.h>intmain(void){uint64_t (*os_security_config_get)(void) =(uint64_t (*)(void))dlsym(RTLD_DEFAULT, "os_security_config_get");if (!os_security_config_get) {fprintf(stderr, "system too old\n");return 1;}uint64_t cfg = os_security_config_get();printf("os_security_config_get() = 0x%llx | SCRIPT_RESTRICTIONS(0x40)=%s"" HARDENED_HEAP(0x1)=%s TPRO(0x2)=%s GUARD_OBJECTS(0x100)=%s\n",(unsigned long long)cfg,(cfg & 0x40) ? "ON" : "off",(cfg & 0x1) ? "on" : "off",(cfg & 0x2) ? "on" : "off",(cfg & 0x100) ? "on" : "off");void *jsc = dlopen("/System/Library/Frameworks/JavaScriptCore.framework/JavaScriptCore", RTLD_NOW);if (!jsc) { printf("dlopen(JavaScriptCore) FAILED: %s\n", dlerror()); return 2; }void* (*JSGlobalContextCreate)(void*) = dlsym(jsc, "JSGlobalContextCreate");void* (*JSStringCreateWithUTF8CString)(const char*) = dlsym(jsc, "JSStringCreateWithUTF8CString");void* (*JSEvaluateScript)(void*,void*,void*,void*,int,void**) = dlsym(jsc, "JSEvaluateScript");double (*JSValueToNumber)(void*,void*,void**) = dlsym(jsc, "JSValueToNumber");printf("dlopen JSC=%p JSGlobalContextCreate=%p\n", jsc, (void*)JSGlobalContextCreate);void *ctx = JSGlobalContextCreate(NULL); // <- traps here under restrictionsprintf("JSGlobalContextCreate -> %p\n", ctx);void *src = JSStringCreateWithUTF8CString("40 + 2");void *exc = NULL;void *res = JSEvaluateScript(ctx, src, NULL, NULL, 0, &exc);double out = JSValueToNumber(ctx, res, NULL);printf("40 + 2 = %g\n", out);return 0;}
ent.plist
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE plistPUBLIC"-//Apple//DTD PLIST 1.0//EN""http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plistversion="1.0"><dict><key>com.apple.security.script-restrictions</key><true/></dict></plist>
编译后加上自签名的 entitlement:
cc test_jsc.c -o test_jsccodesign -s - --entitlements ent.plist --force test_jsc
预期的行为是进程会抛异常,所以挂 lldb 跑:
➜ jsc_test lldb test_jsc(lldb) target create "test_jsc"Current executable set to '/Users/cc/Projects/phrack/jsc_test/test_jsc' (arm64).(lldb) rProcess 49313 launched: '/Users/cc/Projects/phrack/jsc_test/test_jsc' (arm64)os_security_config_get() = 0x40 | SCRIPT_RESTRICTIONS(0x40)=ON HARDENED_HEAP(0x1)=off TPRO(0x2)=off GUARD_OBJECTS(0x100)=offdlopen JSC=0x365fa85a0 JSGlobalContextCreate=0x1a786a9bcProcess 49313 stopped* thread #1, queue = 'com.apple.main-thread', stop reason = EXC_BREAKPOINT (code=1, subcode=0x1a7ac6854)frame #0: 0x00000001a7ac6854 JavaScriptCore`WTF::makePagesFreezable(void*, unsigned long) + 304JavaScriptCore`WTF::makePagesFreezable:-> 0x1a7ac6854 <+304>: brk #0xc4710x1a7ac6858 <+308>: brk #0x10x1a7ac685c <+312>: adrp x1, 59700x1a7ac6860 <+316>: add x1, x1, #0xb44 ; "/AppleInternal/Library/BuildRoots/4~CN9QugCEa5fya5kkZXSbadtTe9oVa3sO3gsEwzc/Library/Caches/com.apple.xbs/TemporaryDirectory.sbZNe3/Sources/WTF/Source/WTF/wtf/PageBlock.cpp"Target 0: (test_jsc) stopped.
程序没有打印 42,而是触发崩溃。去掉 entitlement 之后则一切正常。
到这里这个演示就结束了。也就是如果进程签名里有这个非公开的 com.apple.security.script-restrictions,JavaScriptCore 库可以被动态链接,但无法创建可用的执行上下文。
如果读者想知道是如何实现的,可以继续看下去。
实现
状态查询 API
上面的测试代码出现了一个 iOS 26 新加入的 API os_security_config_get。
实际上一共有三个 API:
-
os_security_config_t os_security_config_get();:获取当前进程信息 -
int os_security_config_get_for_proc(pid_t pid, os_security_config_t *config);:获取pid对应进程,可能需要 root 权限 -
int os_security_config_get_for_task(task_t task, os_security_config_t *config);:获取 task 对应进程,需要先task_for_pid,权限要求更高
它们都属于苹果官网文档一个很不起眼的模块 os,一共就没几个 API。
这个 API 返回一个 os_security_config_t,也就是一个 64 位的无符号整数:
/*!* @enum os_security_config_t** @discussion* Supported security configurations that a process/task can have.* This is a bitmask type, allowing multiple configurations to be active.** @constant OS_SECURITY_CONFIG_NONE* No security config flags set.** @constant OS_SECURITY_CONFIG_HARDENED_HEAP* Indicates that the Hardened Heap configuration is enabled for the process/task.* This implies security-critical settings for the system memory allocator.** @constant OS_SECURITY_CONFIG_TPRO* Indicates that Trusted Path Read-Only (TPRO) is enabled for the process/task.** @constant OS_SECURITY_CONFIG_SCRIPT_RESTRICTIONS* Indicates Script Restrictions are enabled for the process/task.** @constant OS_SECURITY_CONFIG_GUARD_OBJECTS* Indicates that the Guard Objects configuration is enabled for the process/task.*/__API_AVAILABLE(macos(26.0), ios(26.0), tvos(26.0), watchos(26.0), visionos(26.0), driverkit(25.0))OS_OPTIONS(os_security_config, uint64_t,OS_SECURITY_CONFIG_NONE = 0x0,OS_SECURITY_CONFIG_HARDENED_HEAP OS_SWIFT_NAME(hardenedHeap) = 0x1,OS_SECURITY_CONFIG_TPRO OS_SWIFT_NAME(trustedPathReadOnly) = 0x2,OS_SECURITY_CONFIG_MTE OS_SWIFT_NAME(memoryTaggingExtension) = 0x4,OS_SECURITY_CONFIG_SCRIPT_RESTRICTIONS OS_SWIFT_NAME(scriptRestrictions) = 0x40,OS_SECURITY_CONFIG_GUARD_OBJECTS OS_SWIFT_NAME(guardObjects) = 0x100,);
在 SDK 里的注释写得挺清楚。眼尖的读者应该发现这几个 bit 正好对应了前文提到的各种 entitlement,甚至还有 MTE 的配置。这也是我为什么没法展开的原因,真写起来可太长了。
这组枚举对应了 XNU 内核的 task_security_config(对应版本 xnu-12377):
struct task_security_config {union {struct {uint16_t hardened_heap: 1,tpro: 1,#if HAS_MTE || HAS_MTE_EMULATION_SHIMSsec: 1,#else/* HAS_MTE || HAS_MTE_EMULATION_SHIMS */reserved: 1,#endifplatform_restrictions_version: 3,script_restrictions: 1,ipc_containment_vessel: 1,guard_objects: 1;uint8_t hardened_process_version;};uint32_t value;};};
在 XNU 开源代码里并没有找到这结构初始化的部分,只能去 kernelcache 里反编译。伪代码太长就不贴了,直接交叉引用字符串 com.apple.security.hardened-process.dyld-ro 就能定位到。
这个函数除了以上提到的加固 entitlement,还检测如下几个键,并针对性地调整防御等级:
-
第三方浏览器引擎相关(感谢欧盟):com.apple.developer.web-browser-engine.{host,rendering,networking,webcontent} -
driverkit:com.apple.developer.driverkit
标志位传递到用户态有三种途径:
-
当前进程:启动时由 applev[]传递,一次性初始化,缓存在全局变量 -
获取 pid:通过 proc_pidinfo调用 -
获取 task:通过 task_info调用
反编译 libsystem_platform.dylib!os_security_config_get 仅有一行代码:
uint64_t os_security_config_get(){return __restrictions_config & 0x40 | __security_config & 0x107;}
这两个全局变量(注:其实有一个不可写)由 __os_security_config_init 负责初始化。
内核参数传递
XNU 内核在启动进程时,除了常用的 argv[] 和 envp[] 之外还会通过栈传递一个 apple[] 字符串数组。如果当前进程有安全相关的配置,就会以 security_config=0x??? 的格式传递给用户态:
{#define SECURITY_CONFIG_KEY "security_config="char security_config_str[strlen(SECURITY_CONFIG_KEY) + HEX_STR_LEN + 1];snprintf(security_config_str, sizeof(security_config_str),SECURITY_CONFIG_KEY "0x%x", task_get_security_config(task));error = exec_add_user_string(imgp, CAST_USER_ADDR_T(security_config_str), UIO_SYSSPACE, FALSE);imgp->ip_applec++;}#if HAS_MTE || HAS_MTE_EMULATION_SHIMSif (task_has_sec(task)) {const char *sec_transition_shims = "has_sec_transition=1";error = exec_add_user_string(imgp, CAST_USER_ADDR_T(sec_transition_shims), UIO_SYSSPACE, FALSE);if (error) {printf("Failed to add security translation shims notification\n");goto bad;}imgp->ip_applec++;/* Push down MTE-specific configuration options that allocators may be interested into. */#define SEC_TRANSITION_POLICY_KEY "sec_transition_policy="char sec_transition_policy[strlen(SEC_TRANSITION_POLICY_KEY) + HEX_STR_LEN + 1];snprintf(sec_transition_policy, sizeof(sec_transition_policy),SEC_TRANSITION_POLICY_KEY "0x%x", task_get_sec_policy(task));error = exec_add_user_string(imgp, CAST_USER_ADDR_T(sec_transition_policy), UIO_SYSSPACE, FALSE);imgp->ip_applec++;}#endif/* HAS_MTE || HAS_MTE_EMULATION_SHIMS */
针对 MTE 还专门有一个 has_sec_transition=1 的配置。
dyld 在启动进程时负责初始化栈和启动参数(argc、argv、envp 和 apple 等),并传递给主程序的入口点,以及在每次载入 dylib 框架的时候调用其 _mod_init_func 注册的初始化函数。
apple[] 字符串数组最终传递顺序:
-
dyld -
libSystem.B.dylib!libSystem_initializer -
libsystem_platform.dylib!__libplatform_init -
libsystem_platform.dylib!__os_security_config_init
也就是每个进程全局初始化一次。
进程状态初始化
有趣的点在 __os_security_config_init 函数内部的实现。其使用 simple_getenv 提取先前提到来自内核的字符串参数,然后解析十六进制字符串作为初始的配置值,也就是我们之前提到最终会存入的 os_security_config_t。
这里有一个有意思的分支:
if (v7 & OS_SECURITY_CONFIG_GUARD_OBJECTS)__security_config |= OS_SECURITY_CONFIG_GUARD_OBJECTS;if (v7 & OS_SECURITY_CONFIG_SCRIPT_RESTRICTIONS ){address = (mach_vm_address_t)&os_script_config_storage;if ( mach_vm_map(mach_task_self_, &address,0x4000, //0, // maskVM_FLAGS_OVERWRITE | VM_FLAGS_PERMANENT,MEMORY_OBJECT_NULL, 0, FALSE, // object / offset / copyVM_PROT_NONE, // cur_protectionVM_PROT_NONE, // max_protectionVM_INHERIT_COPY) ) // inheritance (== VM_INHERIT_DEFAULT)__os_security_config_init_cold_2();address = (mach_vm_address_t)&__restrictions_config;if ( mach_vm_map(mach_task_self_, &address,0x4000, 0,VM_FLAGS_OVERWRITE | VM_FLAGS_PERMANENT,MEMORY_OBJECT_NULL, 0, FALSE,VM_PROT_READ | VM_PROT_WRITE, // cur_protectionVM_PROT_READ | VM_PROT_WRITE, // max_protectionVM_INHERIT_COPY) )__os_security_config_init_cold_3();__restrictions_config = OS_SECURITY_CONFIG_SCRIPT_RESTRICTIONS;result = mach_vm_protect(mach_task_self_, address,0x4000,/*set_maximum=*/ TRUE,VM_PROT_READ);if ( (kern_return_t)result )__os_security_config_init_cold_4();}
代码没有简单地把值存到全局变量,而是使用位运算拆成了两个部分:__security_config 和 __restrictions_config。
目前 __restrictions_config 仅仅用来保存 OS_SECURITY_CONFIG_SCRIPT_RESTRICTIONS 状态,而一旦检测到这个状态启用,libsystem_platform 会锁定其所在的内存页面的访问权限为只读。不仅如此,还有另一个名为 os_script_config_storage 的符号,直接连读权限都取消了。什么鬼?
WebKit 的处理
我们刚才绕了一大圈从内核到链接器到系统库,主要为了演示如何禁用 JavaScriptCore。所以这个符号当然是 WebKit 在用。直接上源码:
staticboolscriptingIsForbidden(){return processHasEntitlement("com.apple.security.script-restrictions"_s);}voidinitialize(){...WTF::makePagesFreezable(&os_script_config_storage, OpcodeConfigSizeToProtect);if (g_jscConfig.vmEntryDisallowed || scriptingIsForbidden()) [[unlikely]] {g_jscConfig.vmEntryDisallowed = true;WTF::permanentlyFreezePages(&os_script_config_storage, OpcodeConfigSizeToProtect,WTF::FreezePagePermission::None);return;}WTF::compilerFence();...}
乍一看 WebKit 也是检测标准的 entitlement 来拒绝初始化,但实际上压根走不到那个分支。
早在上一行 WTF::makePagesFreezable 调用,如果当前进程禁止 JavaScriptCore,libsystem_platform.dylib 就会把 os_script_config_storage 所在内存标记为不可访问。
// Works together with permanentlyFreezePages().voidmakePagesFreezable(void* base, size_t size){RELEASE_ASSERT(roundUpToMultipleOf(pageSize(), size) == size);#if PLATFORM(COCOA)mach_vm_address_t addr = std::bit_cast<uintptr_t>(base);auto flags = VM_FLAGS_FIXED | VM_FLAGS_OVERWRITE | VM_FLAGS_PERMANENT;auto attemptVMMapping = [&] {auto result = mach_vm_map(mach_task_self(), &addr, size, pageSize() - 1, flags, MEMORY_OBJECT_NULL, 0, false, VM_PROT_READ | VM_PROT_WRITE, VM_PROT_READ | VM_PROT_WRITE, VM_INHERIT_DEFAULT);return result;};auto result = attemptVMMapping();#if PLATFORM(IOS_FAMILY_SIMULATOR)if (result != KERN_SUCCESS) {flags &= ~VM_FLAGS_PERMANENT; // See rdar://75747788.result = attemptVMMapping();}#endifRELEASE_ASSERT(result == KERN_SUCCESS);#elseUNUSED_PARAM(base);UNUSED_PARAM(size);#endif}
因此 makePagesFreezable 直接就抛异常(RELEASE_ASSERT)了。
以上的内存权限也是为了防止漏洞利用在拥有读写原语之后修改 __restrictions_config。照这样看来 Apple 认为剩余的几个 flag 并不需要只读保护。可能是因为读取时机的区别,其余的 bit 主要影响内存分配器(对应开源代码的 libmalloc)的行为,而它们早在进程初始化阶段就处理完了。
小结
一个一句话能说清的需求:禁止某些系统进程使用 JavaScriptCore,牵扯到了一堆组件来实现。
内核将 entitlement 的设定转换成 task_security_config,再通过 apple[] 数组传递给用户态初始化逻辑,而用户态的状态消费者则有被动的内存访问失败和显式检查 processHasEntitlement两层把关,让 JavaScriptCore 初始化失败。
参考资料
-
https://developer.apple.com/documentation/Xcode/enabling-enhanced-security-for-your-app -
https://developer.apple.com/documentation/javascriptcore/jscontext -
https://developer.apple.com/documentation/os/os_security_config_get -
https://github.com/apple-oss-distributions/xnu -
https://github.com/apple-oss-distributions/dyld -
https://github.com/apple-oss-distributions/libsystem
夜雨聆风