iOS 26.4 如何限制系统进程使用 JavaScriptCore

· 2026-06-07 09:04 · 0 阅读

原创 0xcc 2026-06-07 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>


    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 restrictions
        printf("JSGlobalContextCreate -> %p\n", ctx);
        void *src = JSStringCreateWithUTF8CString("40 + 2");
        void *exc = NULL;
        void *res = JSEvaluateScript(ctx, src, NULLNULL0, &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_jsc
        codesign -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) r
          Process 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)=off
          dlopen JSC=0x365fa85a0  JSGlobalContextCreate=0x1a786a9bc
          Process 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) + 304
          JavaScriptCore`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_SYSSPACEFALSE);
                  imgp->ip_applec++;
                  }



                  #if HAS_MTE || HAS_MTE_EMULATION_SHIMS
                  if (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_SYSSPACEFALSE);
                  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_SYSSPACEFALSE);
                  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,                                 // 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,
                                           0x40000,
                                           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 在用。直接上源码:

                      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, 0false, 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 初始化失败。

                        参考资料

                        跳转微信打开