惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

IntelliJ IDEA : IntelliJ IDEA – the Leading IDE for Professional Development in Java and Kotlin | The JetBrains Blog
IntelliJ IDEA : IntelliJ IDEA – the Leading IDE for Professional Development in Java and Kotlin | The JetBrains Blog
C
CXSECURITY Database RSS Feed - CXSecurity.com
博客园_首页
H
Hackread – Cybersecurity News, Data Breaches, AI and More
T
ThreatConnect
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
博客园 - 聂微东
H
Help Net Security
T
Threat Research - Cisco Blogs
Blog — PlanetScale
Blog — PlanetScale
A
Arctic Wolf
G
Google Developers Blog
量子位
U
Unit 42
I
InfoQ
V
V2EX
F
Fox-IT International blog
P
Privacy & Cybersecurity Law Blog
V
Visual Studio Blog
J
Java Code Geeks
大猫的无限游戏
大猫的无限游戏
C
CERT Recently Published Vulnerability Notes
博客园 - 三生石上(FineUI控件)
T
The Exploit Database - CXSecurity.com
T
Tailwind CSS Blog
SecWiki News
SecWiki News
Know Your Adversary
Know Your Adversary
MyScale Blog
MyScale Blog
宝玉的分享
宝玉的分享
The Hacker News
The Hacker News
Project Zero
Project Zero
Application and Cybersecurity Blog
Application and Cybersecurity Blog
月光博客
月光博客
Recent Commits to openclaw:main
Recent Commits to openclaw:main
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
G
GRAHAM CLULEY
C
Cisco Blogs
I
Intezer
Simon Willison's Weblog
Simon Willison's Weblog
O
OpenAI News
Recorded Future
Recorded Future
T
Tenable Blog
W
WeLiveSecurity
腾讯CDC
Stack Overflow Blog
Stack Overflow Blog
T
The Blog of Author Tim Ferriss
www.infosecurity-magazine.com
www.infosecurity-magazine.com
D
Docker
C
Cybersecurity and Infrastructure Security Agency CISA
PCI Perspectives
PCI Perspectives

文章列表

AI,AI,AI macOS 中 Chromium 中打开 Save File Dialog 后主进程cpu上涨到 20%+ 成为 Chromium Committer 我的个人经历 何以为我 - Being Leads Doing. LifeTimer - 记录生活、学习、工作 支持 Markdown 行级别评论 | 聊聊 AI 如何高效代码开发 内存指标与基础概念 工作三年感言 真相不会因我们的承受能力而改变(2024年终总结) handsome —— 一款typecho主题
为什么软件在 macOS 上启动很慢? - 友人C
友人C · 2025-05-31 · via

前言

有没有发现很多软件在 macOS 上启动很慢,比如 edge/ 微信 /chrome/ 会议软件等等。加载一个动态库时间甚至长达 10~20 秒,让人匪夷所思。你可能认为只有在 intel 芯片上才会如此,而在 Apple 芯片中仅首个动态库加载的时间也长达 5 秒,本文来一探究竟。

结论

  • macOS 中的 XProtect 是导致耗时长的主要原因(其他的因素比如 ocsp 签名校验、mac store 沙盒环境、是否有磁盘文件缓存无关不是主要因素)
  • 理论上只在 App 首次启动 /App 发生更改(在文件系统中)/XProtect 签名发生更新进行检查,实测电脑未重启动情况下一段时间后(间隔不确定,可能是天也可能是小时)重启软件 dll 加载时间很长,耗时堆栈均在 AppleSystemPolicy 中,应该是 XProtect 规则有更新导致
  • 恶意病毒扫描逻辑发生在 dlopen 内部加载 dll 文件时,内部执行 fcntl 函数并且参数是 F_CHECK_LV,此时会触发内核的安全机制(Mandatory Access Control AppleSystemPolicy),该检查逻辑最终运行在 syspolicyd 进程中通过 YARA 库逐个检查动态库是否包含病毒特征
  • 关闭 SIP 后会禁用 AppleSystemPolicy 策略,此时加载速度有明显的提升
题外话:macOS 上存在多种安全机制,包括 公证、gatekeeper(门禁)、MACF、AMFI 、SIP(LINK

探索

ocsp 抓包

当搜索 mac app 启动慢,搜到了这个帖子 https://v2ex.com/t/1023166,结合几年前 apple 因为 ocsp 服务器宕机导致。

macOS 推荐使用 Charles 抓包,启动后走完引导流程之后就可以抓到 http/https 协议的包,如果需要对 https 协议的数据进行解包需要安装证书并且信任。

此外需要配置 ssl 代理,这里需要对 apple 的域名放行,不然 apple store 打不开。

抓包中发现有 ocsp.apple.com 相关请求,但不是 GET 请求,且并不会阻塞启动:

同时在断网的环境下测试仍然存在启动慢的问题,基本上可以排除是网络原因

instruments 采样

instruments 是一个非常强大工具,不逊色于 WPA。这里分别选择采集 cpu 负载(Time Profiler)、dyld 日志(dyld Activity)、系统内核调用,比如 fcntl/mmap 等等接口(System Call Trace)、磁盘占用(Disk Usage)情况。

采样 chrome

这里以启动 chrome 为例。

查看耗时情况,可以看到主要耗时在 dlopen_from 加载 / Applications/Google Chrome.app/Contents/Frameworks/Google Chrome Framework.framework/Versions/136.0.7103.114/Google Chrome Framework 动态库,最终耗时在 fcntl 接口上。但是 __fcntl 后续没有具体实现的堆栈了。

这个主动态库文件大小 400MB 多,为啥这个动态库体积这么大,因为是 universal 构建,导致体积翻倍:

lipo -info "/Applications/Google Chrome.app/Contents/Frameworks/Google Chrome Framework.framework/Versions/136.0.7103.114/Google Chrome Framework"

Architectures in the fat file: /Applications/Google Chrome.app/Contents/Frameworks/Google Chrome Framework.framework/Versions/136.0.7103.114/Google Chrome Framework are: x86_64 arm64

一开始猜测只是因为 dll 体积大,首次加载时间长,但是 macOS SSD 硬盘加载 400MB,应该在 1s 左右的时间,并且可以看 instruments 中的 disk usage 视图,实际磁盘的时间只有几十 ms,因此排除了磁盘问题。

推测可能是签名校验逻辑,因为从 system call trace 视图里可以找到 fcntl 的调用参数,三个参数分别是:

  • 参数 1:0x3 ,对应文件描述符
  • 参数 2:0x62:10 进制 98,对应 F_CHECK_LV,会检查这个文件是否允许映射到当前的进程里
#define F_CHECK_LV              98      /* Check if Library Validation allows this Mach-O file to be mapped into the calling process */
  • 参数 3:是一个结构体:
typedef struct fchecklv {
    off_t           lv_file_start;
    size_t          lv_error_message_size;
    void            *lv_error_message;
} fchecklv_t;

trace 里看不到参数信息,在 lldb 中调试了 lv_file_start 一下是 0,剩下的两个用来存储 fcntl 错误信息的。

# 断点
breakpoint set -n fcntl -s dyld
# 参数1
p $x0
# 参数2
p $x1
# 参数3地址
register read ×3
x000000016dd011f8
# 参数3偏移16个字节就是lv_error_message其实字段位置地址
p*(void **)(0x000000016dd011f8 + 16)
(void *) 0x000000016ddgodb8
# 打印lv_error_message字段
p (char *) 0x000000016ddgodb8
(char *) 0x000000016ddoodb8 ""

因此需要进一步分析 fcntl 为什么耗时这么久,以及 F_CHECK_LV 具体做了什么事情?

一些弯路

当进一步分析问题的时候,这里走了几个弯路:

  • demo 分析:确认了是 dlopen->fcntl 链路的耗时之后,应该直接写一个 demo 进一步验证和分析原因,而不是在分析的原有的软件堆栈,因为原有堆栈可能混杂其他逻辑非常干扰
  • 内核堆栈:instruments 默认不会抓内核相关的堆栈的,需要打开 Record Kernel Callstacks 才可以。一开始以为 instruments 不支持抓内核堆栈,花费时间研究 dyld 源码后,发现 spindump 支持采集内核 stack,因此后面都是使用 spindump 采集堆栈分析(在阅读堆栈上比 instruments 麻烦了一点)。

  • 采样频率:Instruments 的 Time Profile 默认采样频率是 1ms,开启 High Freqency 后采样频率为 100us(0.01ms)100us 仍然可能会有一些堆栈抓不到,可以使用 spindump 最快可以支持 1us(注意即使设置了很小的采样频率,实际采样间隔可能不完全准确一致)。有些函数执行很快,实际采样频率可能达不到 1us,因此有些函数调用就会丢失,会误导我们以为逻辑变了。

spindump 常用的一些命令行参数:

# 和活动监视器效果一致
sudo spindump <pid/process name> -file ~/Desktop/app_launch_spindump_2.2.txt 

# 修改采样频率,10表示总采样时间,1表示1ms采样频率,最小是1ms的频率
sudo spindump <pid/process name> 10 1 -file ~/Desktop/app_launch_spindump_2.2.txt 

# 只针对当前进程采样,此时采集频率单位可以使用微秒!可以采集给完整的堆栈信息
sudo spindump fcntl_test 20 10u -onlyTarget -proc fcntl_test -file ~/Desktop/fcntl_test_spindump_1.txt

# 等待进程启动后再采样,并且只抓自己的进程
sudo spindump Chromium 20 1u -wait -onlyTarget -file ~/Desktop/chromium_spindump_1.txt

dyld 源码分析

可以先阅读源码进一步了解 dlopen 函数做了哪些事情。

首先获取自己系统 dyld 版本,我的版本是 PROJECT:dyld-1160.6

what /usr/lib/dyld

接着在 https://github.com/apple-oss-distributions/dyld 找到对应的 tag 下载。

这里抛砖引玉简单介绍 dlopen 这个函数的相关逻辑。

Loader

每个 mach-o 格式文件加载都需要先创建一个 loader,mach-o 文件的类别有:

  • 可执行文件
  • libsystem.dylib
  • libdyld.dylib
  • 其他三方 dylib

这里有一个概念是 dyld_shared_cache 的概念 LINK。macOS 中的系统库加载为了优化耗时:

  • 系统安装完成之后,系统动态库合入成一个大的 dyld_shared_cache 物理文件
  • 系统启动之后会把 dyld_shared_cache 映射到内存里(这里是内存映射并非完全将文件读入到磁盘上)。

其中 loader 作为基类有两个派生类:

  • PrebuiltLoader:预构建的 loader 包含了一些 mach-o 中的信息,比如依赖关系等等
  • JustInTimeLoader:每次都需要解析 mach-o 获取信息(通过 MachOAnalyzer)

选择 loader 的逻辑:

  • 当加载一个动态库的时候,首先判断是否在 dyld_shared_cache 文件中,如果在,则使用 PreBuiltLoader,否则使用 JustInTimeLoader。
  • Apple 还有一个机制是当可执行文件通过 JustInTimeLoader 加载,可执行文件依赖的所有动态库依赖加载完成后(不包括 dopen 动态加载的动态库),dyld 会将每个 JustInTimeLoader 克隆保存到 PrebuiltLoader,构建一个可执行文件级别的 PrebuiltLoaderSet,并写入磁盘,这样后续启动可执行文件的时候,启动速度就能能有一定的提升。

JustInTimeLoader 保存到 PrebuiltLoader 的机制判断条件见 RuntimeState::allowNonOsProgramsToSaveUpdatedClosures,目前 macos 不支持这种机制。

APIs::dlopen_from

chrome 启动场景下就是第四种,通过 dlopen 加载加载业务自己的动态库,因此一定是通过 JustInTimeLoader 加载的,因此这里代码删掉一些无关的逻辑:

  • 记录加载指定动态库之前进程已经 loaded 的 mach-o 文件数量
  • Loader::getLoader 加载动态库
  • loadDependents 加载该动态库的依赖
  • 对这个过程中的新创建的 loader 调用 applyFixups 进行一些位置信息的修正等。
void* APIs::dlopen_from(const char* path, int mode, void* addressInCaller)
{
    // ...
    locks.withLoadersWriteLock(memoryManager, [&] {
        // since we have the dyld lock, any appends to state.loaded will be from this dlopen
        // so record the length now, and cut it back to that point if dlopen fails
        // 1. 记录load之前的数目
        const uint64_t startLoaderCount = loaded.size();
        const uint64_t startPatchedObjCClassesCount = this->patchedObjCClasses.size();
        const uint64_t startPatchedSingletonsCount = this->patchedSingletons.size();
        Diagnostics     diag;
    
        // try to load specified dylib
        // 2. 加载动态库
        topLoader = Loader::getLoader(diag, *this, path, options);
    
        // load all dependents
        // 3. 加载该动态库的依赖
        ((Loader*)topLoader)->loadDependents(diag, *this, depOptions);
    
        // only do fixups and notifications if new dylibs are loaded (could be dlopen that just bumps the ref count)
        STACK_ALLOC_VECTOR(const Loader*, newLoaders, loaded.size() - startLoaderCount);
        
        for (uint64_t i = startLoaderCount; i != loaded.size(); ++i)
            newLoaders.push_back(loaded[i]);
            DyldCacheDataConstLazyScopedWriter cacheDataConst(*this);
            if ( diag.noError() && !newLoaders.empty() ) {
                // tell debugger about newly loaded images in case there is a crash during fixups
        
                notifyDebuggerLoad(newLoaders);
        
                // proactive weakDefMap means we update the weakDefMap with everything just loaded before doing any binding
                if ( config.process.proactivelyUseWeakDefMap ) {
                    Loader::addWeakDefsToMap(*this, newLoaders);
                }
        
                // do fixups
                // 4.位置信息修正
                {
                    dyld3::ScopedTimer fixupsTimer(DBG_DYLD_TIMING_APPLY_FIXUPS, 0, 0, 0);
        
                    for ( const Loader* ldr : newLoaders ) {
                        bool allowLazyBinds = ((mode & RTLD_NOW) == 0);
                        ldr->applyFixups(diag, *this, cacheDataConst, allowLazyBinds);
                        if ( diag.hasError() )
                            break;
        
        #if BUILDING_DYLD && !TARGET_OS_EXCLAVEKIT
        // Roots need to patch the uniqued GOTs in the cache
                        //FIXME: Is the right place to conditionalize this?
                        ldr->applyCachePatches(*this, cacheDataConst);
        #endif // BUILDING_DYLD && !TARGET_OS_EXCLAVEKIT
        
        }
        
                }
        
    return result;
}

locks.withLoadersWriteLock 函数 的参数有一个 callback,捕获了 &,体现在堆栈里就是 ::$_1::operator()()

65467  dyld4::APIs::dlopen_from(char const*, int, void*)::$_1::operator()() const + 244 (dyld + 264632) [0x19d0d89b8]

JustInTimeLoader::makeJustInTimeLoaderDisk

Loader::getLoader 内部创建 JustInTimeLoader,调用的是 JustInTimeLoader::makeJustInTimeLoaderDisk 函数,具体逻辑如下:

  • withReadOnlyMappedFile:mmap loadPath,获取到真正的 canonicalPath 路径
  • mapSegments:先校验 canonicalPath 路径的签名,再校验 canonicalPath 这个文件是否可以加载到进程里,最后再将 regions mmap 到内存中
Loader* JustInTimeLoader::makeJustInTimeLoaderDisk(Diagnostics& diag, RuntimeState& state, const char* loadPath,
const LoadOptions& options, bool overridesCache, uint32_t overridesCacheIndex,
                                               const mach_o::Layout* layout)

{

    __block Loader* result = nullptr;
    bool  checkIfOSBinary = state.config.process.archs->checksOSBinary();
    // 1. 获取真正的动态库 path: canonicalPath
    state.config.syscall.withReadOnlyMappedFile(diag, loadPath, checkIfOSBinary, ^(const void* mapping, size_t mappedSize, bool isOSBinary, const FileID& fileID, const char* canonicalPath) {
     JustInTimeLoader::withRegions(ma, ^(const Array<Region>& regions) {

        CodeSignatureInFile  codeSignature;
        bool hasCodeSignature = ma->hasCodeSignature(codeSignature.fileOffset, codeSignature.size);
        // 2. 将文件mmap到内存中
        if ( const MachOAnalyzer* realMA = Loader::mapSegments(diag, state, canonicalPath, vmSpace, codeSignature, hasCodeSignature, regions, neverUnload, false, fileValidation) ) {
            // 3. 构造 JustInTimeLoader 对象
            result = JustInTimeLoader::make(state, realMA, canonicalPath, fileID, fileValidation.sliceOffset, neverUnload, leaveMapped, overridesCache, overridesCacheIndex, layout);
        }
        
      });
      
    });
    
    return result;
}

state.config.syscall.withReadOnlyMappedFile 函数的参数没有捕获 &,体现在堆栈里就是 invocation function for block in

65433  invocation function for block in dyld4::JustInTimeLoader::makeJustInTimeLoaderDisk(Diagnostics&, dyld4::RuntimeState&, char const*, dyld4::Loader::LoadOptions const&, bool, unsigned int, mach_o::Layout const*) + 460 (dyld + 149292) [0x19d0bc72c]

withReadOnlyMappedFile

这里逻辑就是传入一个 path,返回一个最终要加载的 path 执行 callback。


void SyscallDelegate::withReadOnlyMappedFile(Diagnostics& diag, const char* path, bool checkIfOSBinary,
void (^handler)(const void* mapping, size_t mappedSize, bool isOSBinary, const FileID& fileID, const char* realPath)) const
{

    size_t   mappedSize;
    FileID   fileID = FileID::none();
    bool     isOSBinary = false;
    char     realerPath[PATH_MAX];
    if ( const void* mapping = this->mapFileReadOnly(diag, path, &mappedSize, &fileID, (checkIfOSBinary ? &isOSBinary : nullptr), realerPath) ) {
        handler(mapping, mappedSize, isOSBinary, fileID, realerPath);
        this->unmapFile(mapping, mappedSize);
    }
}

Loader::mapSegments

  • 先打开文件:通过 open 内核调用
  • fcntl + F_ADDFILESIGS_RETURN 判断签名是否正确
  • fcntl + F_CHECK_LV 判断文件是否可以加载到进程,这里涉及到 macOS 的安全机制

const MachOAnalyzer* Loader::mapSegments(Diagnostics& diag, RuntimeState& state, const char* path, uint64_t vmSpace,
const CodeSignatureInFile& codeSignature, bool hasCodeSignature,

                                     const Array<Region>& regions, bool neverUnloads, bool prebuilt, const FileValidationInfo& fileValidation)
{

    dyld3::ScopedTimer timer(DBG_DYLD_TIMING_MAP_IMAGE, path, 0, 0);

    // open file
    // 1. 打开文件
    int fd = state.config.syscall.open(path, O_RDONLY, 0);
    if ( fd == -1 ) {
        int openErr = errno;
        if ( (openErr == EPERM) && state.config.syscall.sandboxBlockedOpen(path) )
            diag.error("file system sandbox blocked open("%s", O_RDONLY)", path);
        else if ( openErr == ENOENT )
            diag.error("no such file");
        else
            diag.error("open("%s", O_RDONLY) failed with errno=%d", path, openErr);
        return nullptr;
    }
    
    #if BUILDING_DYLD
    // 2. 验证签名
    // register code signature
    uint64_t coveredCodeLength = UINT64_MAX;
    if ( hasCodeSignature && codeSignature.size != 0 ) {
        fsignatures_t siginfo;
        siginfo.fs_file_start = sliceOffset;                             // start of mach-o slice in fat file
        siginfo.fs_blob_start = (void*)(long)(codeSignature.fileOffset); // start of CD in mach-o file
        siginfo.fs_blob_size  = codeSignature.size;                      // size of CD
        int result            = state.config.syscall.fcntl(fd, F_ADDFILESIGS_RETURN, &siginfo);
        if ( result == -1 ) {
            char uuidStr[64];
            getUuidFromFd(state, fd, sliceOffset, uuidStr);
            int errnoCopy = errno;
            if ( (errnoCopy == EPERM) || (errnoCopy == EBADEXEC) ) {
                diag.error("code signature invalid in <%s> '%s' (errno=%d) sliceOffset=0x%08llX, codeBlobOffset=0x%08X, codeBlobSize=0x%08X",
                           uuidStr, path, errnoCopy, sliceOffset, codeSignature.fileOffset, codeSignature.size);
            }
            else {
                diag.error("fcntl(fd, F_ADDFILESIGS_RETURN) failed with errno=%d in <%s> '%s', sliceOffset=0x%08llX, codeBlobOffset=0x%08X, codeBlobSize=0x%08X",
                           errnoCopy, uuidStr, path, sliceOffset, codeSignature.fileOffset, codeSignature.size);
            }
            state.config.syscall.close(fd);
            return nullptr;
        }
        coveredCodeLength = siginfo.fs_file_start;
        // 比较文件开始的位置(codeSignature.fileOffset)和签名覆盖的长度(coveredCodeLength)
        if ( coveredCodeLength < codeSignature.fileOffset ) {
            char uuidStr[64];
            getUuidFromFd(state, fd, sliceOffset, uuidStr);
            diag.error("code signature does not cover entire file up to signature in <%s> '%s' (signed 0x%08llX, expected 0x%08X) for '%s'",
                       uuidStr, path, coveredCodeLength, codeSignature.fileOffset, path);
            state.config.syscall.close(fd);
            return nullptr;
        }
    }
    
    
    
    // <rdar://problem/41015217> dyld should use F_CHECK_LV even on unsigned binaries
    {
    
        // <rdar://problem/32684903> always call F_CHECK_LV to preflight
        fchecklv checkInfo;
        char     messageBuffer[512];
        messageBuffer[0]                = '0';
        checkInfo.lv_file_start         = sliceOffset;
        checkInfo.lv_error_message_size = sizeof(messageBuffer);
        checkInfo.lv_error_message      = messageBuffer;
        int res                         = state.config.syscall.fcntl(fd, F_CHECK_LV, &checkInfo);
        if ( res == -1 ) {
            // rdar://79796526 (include uuid of mis-signed binary to help debug)
            char uuidStr[64];
            getUuidFromFd(state, fd, sliceOffset, uuidStr);
            diag.error("code signature in <%s> '%s' not valid for use in process: %s", uuidStr, path, messageBuffer);
            state.config.syscall.close(fd);
            return nullptr;
        }
    }
    
    #endif
    
    
    // map each segment
    // 对每个region进行mmap,并且对第一个seg判断是否符合mach-o规则
    bool             mmapFailure               = false;
    const  bool      enableTpro                = state.config.process.enableTproDataConst;
    __block uint32_t segIndex                  = 0;
    for ( const Region& region : regions ) {
        // <rdar://problem/32363581> Mapping zero filled regions fails with mmap of size 0
        if ( region.isZeroFill || (region.fileSize == 0) )
            continue;
        if ( (region.vmOffset == 0) && (segIndex > 0) )
            continue;
        int perms = VM_PROT_READ;
        int flags = MAP_FIXED | MAP_PRIVATE;
    #if BUILDING_DYLD
    perms = region.perms;
    #endif
    
    if (enableTpro && region.readOnlyData) {
            flags |= MAP_TPRO;
        }
        void* segAddress = state.config.syscall.mmap((void*)(loadAddress + region.vmOffset), (size_t)region.fileSize, perms,
                                                     flags, fd, (size_t)(sliceOffset + region.fileOffset));
        int   mmapErr    = errno;
        // sanity check first segment is mach-o header
        if ( !mmapFailure && (segIndex == 0) ) {
            const MachOAnalyzer* ma = (MachOAnalyzer*)segAddress;
            if ( !ma->isMachO(diag, region.fileSize) ) {
                mmapFailure = true;
                break;
            }
    
        }
        ++segIndex;
    
    }
    
    // close file
    state.config.syscall.close(fd);
    return (MachOAnalyzer*)loadAddress;
}

fcntl 源码分析

可以进一步研究一下 fcntl 的源码,看看当参数是 F_CHECK_LV,内核的逻辑是什么。

这个源码可以在 XNU 内核中找到,首先确认自己的内核版本,从 github 上下载对应 tag:https://github.com/apple-oss-distributions/xnu/tree/xnu-10063.101.15

hewro@hewrodeMac-Studio ~ % uname -a
Darwin hewrodeMac-Studio.local 23.4.0 Darwin Kernel Version 23.4.0: Fri Mar 15 00:12:49 PDT 2024; root:xnu-10063.101.17~1/RELEASE_ARM64_T6020 arm64

我的系统版本 10063.101.17 在 github 上没有对应的 tag,只能下载了接近的一个版本。

sys_fcntl_nocancel

这里只关注 F_CHECK_LV 的逻辑,其中关键逻辑是 mac_file_check_library_validation 调用。

int

sys_fcntl_nocancel(proc_t p, struct fcntl_nocancel_args *uap, int32_t *retval)
{

case F_CHECK_LV: {
        struct fileglob *fg;
        fchecklv_t lv = {};
#if CONFIG_MACF
        error = mac_file_check_library_validation(p, fg, lv.lv_file_start,
            (user_long_t)lv.lv_error_message, lv.lv_error_message_size);
#endif
        break;
    }
}

mac_file_check_library_validation

int

mac_file_check_library_validation(struct proc *proc,
struct fileglob *fg, off_t slice_offset,
user_long_t error_message, size_t error_message_size)
{
    int error;
    MAC_CHECK(file_check_library_validation, proc, fg, slice_offset, error_message, error_message_size);
    return error;
}

#define MAC_POLICY_ITERATE(...) do {                                
    struct mac_policy_conf *mpc;                                    
    u_int i;                                                        
     
    for (i = 0; i < mac_policy_list.staticmax; i++) {               
        mpc = mac_policy_list.entries[i].mpc;                       
        if (mpc == NULL)                                            
                continue;                                                                                                                    
    }                                                               
    
    if (mac_policy_list_conditional_busy() != 0) {                  
        for (; i <= mac_policy_list.maxindex; i++) {                
                mpc = mac_policy_list.entries[i].mpc;               
                if (mpc == NULL)                                    
                        continue;                                   
    }                                                           
        mac_policy_list_unbusy();                                   
    }                                                               
    } while (0)
    
    #define MAC_CHECK(check, args...) do {                              
    error = 0;                                                      
    MAC_POLICY_ITERATE({                                            
        if (mpc->mpc_ops->mpo_ ## check != NULL) {              
                DTRACE_MACF3(mac__call__ ## check, void *, mpc, int, error, int, MAC_ITERATE_CHECK); 
                int *step_err = mpc->mpc_ops->mpo ## check (args); *
    _                DTRACE_MACF2(mac__rslt__ ## check, void *, mpc, int, __step_err); 
    error = mac_error_select(__step_err, error);         
        }                                                           
    });                                                             
    } while (0)

mac 的所有安全策略保存在 mac_policy_list 列表中,这里会调用每个 mac_policy_conf 中的 mpc_ops 中的 mpo_mac_file_check_library_validation 函数指针。

内核通过 mac_policy_register 接口提供了安全策略的注入,具体的安全策略本身不包含在内核源码中。

security/mac_base.c

demo 分析

demo 中启动程序后按下回车会执行 dlopen 函数加载动态库:

#include <iostream>
#include <fcntl.h>
#include <chrono>
#include <vector>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <sys/stat.h>
#include <mach-o/dyld.h>
#include <dlfcn.h>

void dlopentest(const char* file_to_test){
    auto start_time = std::chrono::high_resolution_clock::now();

    void* library = dlopen(file_to_test,
                           // 为了解决未声明标识符 'RTLD_LAZY' 的问题,包含 dlfcn.h 头文件
                           // 该头文件定义了 RTLD_LAZY、RTLD_LOCAL 和 RTLD_FIRST 等常量
                           RTLD_LAZY | RTLD_LOCAL | RTLD_FIRST);
    if ( !library )
        std::cerr << "dlopen error for " << file_to_test << ": " << dlerror() << std::endl;
    else {
        std::cout << "dlopen success for " << file_to_test << std::endl;
    }
    auto end_time = std::chrono::high_resolution_clock::now();
    auto duration = std::chrono::duration_cast<std::chrono::microseconds>(end_time - start_time);

    std::cout << "fcall took: " << duration.count() << " microseconds." << std::endl;

}
int main(int argc, char* argv[])
{
    char     path[PATH_MAX];
    uint32_t size = sizeof(path);
    if ( _NSGetExecutablePath(path, &size) != 0 ) {
        std::cerr << "Error: Could not get executable path." << std::endl;
        return 1;
    }

    const char* file_to_test = path;
    if ( argc > 1 ) {
        file_to_test = argv[1]; // Allow specifying a file as an argument
        std::cout << "Testing on file: " << file_to_test << std::endl;
    }
    else {
        std::cout << "Testing on self: " << file_to_test << std::endl;
    }

    std::cout << "Press Enter to call dlopen on: " << file_to_test << std::endl;
    std::cin.get(); // 等待用户按下回车键

    dlopentest(file_to_test);
    return 0;
}

这里需要注意一点的是如果这个动态库依赖了其他的库,需要编译的时候指定搜索路径(rpath):

clang++ -o fcntl_test_n_mas fcntl_test.cpp -Wl,-rpath,"/Applications/Google Chrome.app/Contents/Frameworks/Google Chrome Framework.framework/Versions/136.0.7103.114/Libraries"

./fcntl_test "/Applications/Google Chrome.app/Contents/Frameworks/Google Chrome Framework.framework/Versions/136.0.7103.114/Google Chrome Framework"

首次启动(SIP)

首先只抓自己这一个进程的 spindump,这样提交小,并且可以间隔很短(5us):

fcntl_test_spindump_500us_1.txt

先看基本信息,采集了 10s,一共抓了 71066 个 sample。

Duration:         10.13s
Duration Sampled: 10.13s
Steps:            71066 (5us sampling interval)

从堆栈上看,有 65447 次 sample(93%)都在 getLoader 中,其中内核堆栈是 AppleSystemPolicy::perform_malware_scan_if_necessary,表示在进行恶意软件扫描的工作。

在 kext 内核扩展中确实也找到了 AppleSystemPolicy 这个扩展。

非首次启动(SIP)

当应用退出后很快启动会发现这个时候启动非常快,几十到几百耗时级别,初步推测可能是因为文件内存缓存的原因。

但是发现一个疑点,即可执行文件 A 加载动态库 a 一次后,退出 A 重新加载动态库 a,很快。但是可执行文件 B 加载动态库 a 仍然很慢,这说明这里判断条件不仅仅是文件是否在内存缓存里,可能有别的判断逻辑。

一开始通过 instruments 抓取堆栈发现二次启动的时候没有 getLoader 的函数了,但这是不符合我们的源码分析的。从源码里看没有看到判断是否在内存缓存就跳过 loader 的流程。

通过 spindump 1us 频率采集会发现基本的调用栈和首次启动基本一致,但是区别在于没有看到 fcntl 调用。

fcntl_test_spindump_500us_2_2.txt

怀疑是不是用户态逻辑里是不是就存在某个条件判断分支。后来重复实验发现非首次启动有采集到 Loader::mapSegments 的堆栈,但是仍然没有 fcntl 的调用。

最后在 instruments 的 system call trace 面板下确认了非首次启动下仍然有 fcntl 调用,只是周期性采集中无法采集到,但是底层调用里没有 AppleSystemPolicy 的逻辑。

首次(非 SIP)

当关闭 SIP 之后,首次启动也变的非常的快。

app_launch_spindump_no_sip.txt

重新抓一个 spindump 对比发现,fcntl F_CHECK_LV 底层逻辑,只包含 _vnode_check_signature 的逻辑了(这个逻辑在开启 SIP 也是存在的),不再包含 AppleSystemPolicy 的逻辑。 (尽管此时在 kext 扩展中仍然可以找到 AppleSystemPolicy)。

安全机制

AppleSystemPolicy 是什么工作会如此耗时,从堆栈的函数参数找到线索:

ASPEvaluationManager::waitOnEvaluation(syspolicyd_evaluation*) + 48 (com.apple.AppleSystemPolicy + 13368) [0xfffffe0009e30d88]

syspolicyd_evaluation 中的 syspolicyd 就是一个系统进程。

因此在启动的过程中对这个进程抓一下 trace 确认一下这个进程的逻辑(或者 spindump 默认抓所有进程)。可以看到主要逻辑在执行 yr_rules_scan_file,即使用 yr 的规则扫描文件。

YARA 规则匹配

yr_rules_scan_file 函数堆栈中的 yr 就是 yara 的缩写,这个也是一个开源的库:

https://deepwiki.com/VirusTotal/yara

进而从 macOS 官网的恶意软件扫描上找到说明: https://support.apple.com/zh-cn/guide/security/sec469d47bd8/web

XProtect 会在以下时机扫描,这和我们实际的表现符合。

  • App 首次启动
  • App 发生更改(在文件系统中)
  • XProtect 签名发生更新

yara 扫描时间的影响因素:

  1. 文件大小:文件越大,扫描时间通常越长。
  2. 规则复杂度:规则越复杂(特别是包含大量正则表达式的规则),匹配过程越耗时。
  3. 规则数量:要应用的规则越多,扫描时间越长。
  4. 硬件资源:可用内存和 CPU 性能会影响扫描速度。

相关讨论