文章总结: iOS26.4引入非公开entitlementcom.apple.security.script-restrictions限制系统进程动态加载JavaScriptCore框架,旨在防御无文件代码执行攻击。该机制通过进程签名验证实现,当高风险进程尝试创建JS执行上下文时触发崩溃。文章通过macOS环境实测验证防御有效性,并解析底层ossecurityconfig_getAPI的检测逻辑。 综合评分: 85 文章分类: 移动安全,漏洞分析,应用安全,安全建设,技术标准
iOS 26.4 如何限制系统进程使用 JavaScriptCore
原创
0xcc 0xcc
非尝咸鱼贩
2026年6月7日 09:04 瑞士
在小说阅读器读本章
去阅读
背景
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>
int main(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 restrictions printf("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 plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plist version="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 #0xc471 0x1a7ac6858 <+308>: brk #0x1 0x1a7ac685c <+312>: adrp x1, 5970 0x1a7ac6860 <+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_SHIMS sec: 1,#else /* HAS_MTE || HAS_MTE_EMULATION_SHIMS */ reserved: 1,#endif platform_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[] 字符串数组最终传递顺序:
dyldlibSystem.B.dylib!libSystem_initializerlibsystem_platform.dylib!__libplatform_initlibsystem_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, // mask VM_FLAGS_OVERWRITE | VM_FLAGS_PERMANENT, MEMORY_OBJECT_NULL, 0, FALSE, // object / offset / copy VM_PROT_NONE, // cur_protection VM_PROT_NONE, // max_protection VM_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_protection VM_PROT_READ | VM_PROT_WRITE, // max_protection VM_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 在用。直接上源码:
static bool scriptingIsForbidden() { return processHasEntitlement("com.apple.security.script-restrictions"_s);}
void initialize() { ... 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().void makePagesFreezable(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(); }#endif RELEASE_ASSERT(result == KERN_SUCCESS);#else UNUSED_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
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:非尝咸鱼贩 0xcc 0xcc《iOS 26.4 如何限制系统进程使用 JavaScriptCore》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。









评论