Files

18 KiB
Raw Permalink Blame History

macOS 低层分支:KEXT / DriverKit(DEXT) / System Extension / 内核集合

macOS 的坑在于**三套完全不同的低层模型混在一起**:
模型 执行位置 时代
KEXT + IOKit 内核态 传统
DriverKit / DEXT 用户态 现代驱动
System Extension 用户态框架(可承载 DriverKit / EndpointSecurity / NetworkExtension) 现代系统能力

Apple 明确:KEXT 在内核态,DriverKit 与 System Extension 在用户态;macOS 11 起能用 System Extension/DriverKit 实现的功能通常不再走 KEXT 路径。所以第一条经验不是"先反汇编",而是先确认你在逆哪一代东西。

四个最容易让整条分析链跑偏的事实: ① codeless KEXT 可能根本没有代码; ② 磁盘上的 KEXT 与当前运行的可能是两个版本(AuxKC); ③ arm64e 指针不能按普通 64 位函数指针解释(PAC); ④ IOServiceOpen 失败可能停在 entitlement/activation 层,根本没走到你的 UserClient。 </CORE RULE>

一、先分类(决定后面全部)

KEXT / codeless KEXT / DEXT(DriverKit) / System Extension —— 四类模型不同,误判一次后面全错。

二、KEXT:Info.plist 先行,不要先反汇编

Foo.kext/Contents/ 下的 Info.plist 价值极高,因为 IOKitPersonalities 直接描述匹配条件:

IOClass / IOProviderClass / IONameMatch / IOPCIMatch / IOPCIPrimaryMatch / IOKitDebug / CFBundleIdentifier / OSBundleLibraries

IOKit 的 matching 模型就是"personality 字典决定哪个 driver 能绑定哪个 provider";PCI personality 甚至直接含 vendor/device ID 匹配规则。

正确顺序:

Info.plist → 确定 IOClass / Provider / hardware match → 确定依赖
  → Mach-O → 恢复 C++ class hierarchy → 找 Start / Stop / newUserClient / externalMethod

而不是从 _start 顺着 CFG 啃。

三、codeless KEXT:没有 Mach-O 不代表文件坏了

Apple 正式支持 codeless KEXT:可以完全没有 executable,只靠 Info.plist 做 hardware matching,把设备交给已有系统 driver。

Foo.kext/ └── Info.plist        (没有 Contents/MacOS/Foo —— 合法)

正确分析:IOKitPersonalities → IOProviderClass → matching properties → 最终匹配到哪个 Apple driver → 去分析真正执行的那个 driver。不要浪费时间寻找"不见了的 payload"。

四、磁盘上的 KEXT ≠ 当前正在运行的 KEXT(比 Linux 的 .ko 更严重)

  • macOS 11 起第三方 KEXT 不再按需 load,而是进入 Auxiliary Kernel Collection(AuxKC)、随 boot 加载
  • 更新 KEXT 后,即使 /Library/Extensions/Foo.kext 已是新版,reboot 前机器仍可能执行旧版本
  • 卸载同理:kmutil unload / kextunload 不意味着立即从运行中的内核拔掉——新的 kernel collection 要等重启才生效,旧 KEXT 可能一直活到重启
  • 所以"我明明 patch 了这个 KEXT,行为为什么还是旧的?"→ 优先怀疑 disk = version B / running AuxKC = version A,而不是"patch 没生效 / 分析错了 / 缓存没刷"
  • 运行态逆向至少要区分三份:磁盘 bundle / AuxKC 中的 image / 当前 boot 中真正映射的 image(可对比运行 UUID 与磁盘 UUID)
  • 推论:逆向实验若不考虑这点,极易得到假结果——"工具显示已请求 unload" ≠ "代码现在已经不存在"

五、Apple Silicon:arm64e 与 PAC 不是混淆

  • Apple Silicon 的 KEXT 必须支持 arm64e,于是频繁出现 pacibsp / autibsp / pacia / autia / braa / blraa / retab,以及"地址高位看起来不对"的指针
  • 不要把 64-bit raw value 直接当普通 canonical pointer——尤其是 vtable / function pointer / callback / return address
  • 常见误判:"function pointer 看起来不在 __TEXT_EXEC → pointer 被破坏了" —— 实际可能只是 PAC-signed pointer
  • 结论:class / vtable 重建必须 PAC-aware

六、KEXT 的重点是 IOKit class graph,不是普通 call graph

  • C++ IOKit 典型生命周期:OSObject → IORegistryEntry → IOService → YourDriver,主要围绕 init / probe / start / stop / free
  • 真正高价值的是:class hierarchy、vtable、OSMetaClass、IOService 继承链,而不是"哪个函数 call 次数最多"
  • 单独一个 KEXT 不一定够恢复完整类层级——它依赖 kernel 与其他 IOKit family 的 class/vtable;实践做法是先恢复 kernel/依赖的 class metadata,再把该 KEXT 的 class 接回完整层级
  • 所以"单独 KEXT 里大量 unresolved virtual calls"不一定是 stripped 太狠,可能只是你没有它的 kernel / superclass type universe

七、攻击面优先追 newUserClient

IOService → newUserClient() → IOUserClient subclass → externalMethod()

用户态入口:IOServiceOpen → IOConnectCallMethod / IOConnectCallScalarMethod / IOConnectCallStructMethod / IOConnectCallAsyncStructMethod。

实用策略:先恢复 selector → handler 映射,而不是先分析整个硬件实现。

八、selector 不是函数地址;dispatch 会在 handler 之前做 ABI 检查

  • DriverKit 保留同一核心模型:ExternalMethod(selector, arguments, dispatch, target, reference)(在驱动内调用时 dispatch 为 NULL;非 NULL 时先按 dispatch 校验 arguments 再调用其 function)
  • 字段名注意前缀:dispatch 结构里的字段是 function / checkCompletionExists / checkScalarInputCount / checkStructureInputSize / checkScalarOutputCount / checkStructureOutputSize(带 check);不带前缀的 scalarInputCount / structureInputSize / … 是 arguments(IOUserClientMethodArguments)的字段,两者别混
  • kext 侧的 dispatch 表是位置式(8 字节函数指针 + 四个 uint32);变长结构用 kIOUCVariableStructureSize
  • kIOReturnBadArgument 不一定是 handler 自己返回的:框架层会在 handler 真正执行之前按 dispatch 拒绝(scalar 数量 / 结构大小 / completion)。排查顺序:
selector 对吗 → dispatch entry 对吗 → scalar count 对吗 → structure size 对吗
  → async completion 对吗 → 才进入 handler 本身
  • 特别是 checkCompletionExists = true 时:completion 需要有效的通知 Mach port(IONotificationPortCreate + IONotificationPortGetMachPort 一类);传错端口(例如主/主控端口)同样会得到 kIOReturnBadArgument——不是 handler 的逻辑问题
  • DriverKit 异步:IOUserClient::AsyncCompletion 取代 kext 的 sendAsyncResult64();注意小 structureOutput(≤4096 字节)不能异步填充,必须由 ExternalMethod 立即返回(只有能走 structureOutputDescriptor 的大缓冲才能延后填)

九、IOServiceOpen 失败:先查授权链,最后才怀疑 selector

service 是否真的存在 → DEXT 是否 active → matching 是否成功
  → entitlement → Team ID / signature → sandbox → UserClient policy
  → 最后才是 selector / 协议问题
  • entitlement 名:客户端 com.apple.developer.driverkit.userclient-access(声明允许访问哪些 DriverKit service);驱动侧 com.apple.developer.driverkit.allow-any-userclient-access(允许任意应用连接)
  • 这类失败常见症状:provider entitlements check failed / IOUserServer(...)::exit(Entitlements check failed)

十、.dext 不要按 KEXT 逆(用户态模型)

DriverKit 驱动本质是用户态扩展,不是 ring-0 模块。分析模型:

Mach-O userspace executable → DriverKit 生成/运行时代码 → IOService subclass
  → IOUserClient → DriverKit IPC → kernel-mediated device access

立刻停止 KEXT 模型的信号:看到 Foundation/普通用户态运行时、普通 Mach ports、SystemExtension host app。

调试模型也随之不同:dext 可以按普通进程调(用户态调试器),不会因为断点把整机内核一起停掉。

十一、System Extension ≠ DriverKit 的同义词

DriverKit / EndpointSecurity / NetworkExtension 都可以作为 System Extension 运行。

看到 *.systemextension 不能推断"硬件驱动"——System Extension 本身只是"高权限 user-space extension"的安装/生命周期框架,业务语义可能属于完全不同的 API。先看:entitlements / linked frameworks / bundle metadata。

十二、Host App 与 DEXT 分离时,两边都要逆

Host.app/Contents/Library/SystemExtensions/Foo.dext

Host app 负责 activation(用 SystemExtensions framework 提交 activation request),DEXT 负责 driver。完整 RE 应同时分析:

  • Host app:installation/activation、IOService discovery、IOConnect 调用
  • DEXT:matching、ExternalMethod、实际驱动

selector 与输入结构常常在 client app 端更容易恢复。

十三、"DEXT 文件存在" ≠ "DEXT 已经运行"

System Extension 有独立 activation 生命周期,六态要分开:

bundle exists → registered → approved → activated → matched → started

.app 内存在 Foo.dext 只说明"安装包包含它";注册/审批/激活/匹配/启动都要各自核实(bundle identifier、filename、Team ID、签名与公证都要满足规则)。

十四、Apple Silicon 上 KEXT 不能靠 Rosetta

  • Apple 文档明确:Rosetta 不翻译 kernel extension(也不翻译虚拟化 x86_64 平台的 VM app)
  • x86_64 kext 在 Apple Silicon 上加载失败:
Error Domain=KMErrorDomain Code=71
"Incompatible architecture: Binary is for x86_64, but needed arch arm64e"
  • 这不是安全设置 / SIP / 权限问题——只能由开发者重编为 arm64e(或 universal)
  • 所以"host app 正常启动但 driver 永远加载不了"不是 UserClient bug,也不是你用错了 API

十五、Kernel Collection:运行态不是"一个 kernel + 一堆随时独立 load 的 bundle"

  • 现代 macOS 的运行态涉及 Kernel Collections:kernel / Apple KEXTs / 第三方 KEXTs(第三方走 AuxKC)
  • 做地址归属、KEXT inventory、symbolication、runtime-vs-disk 对比时都要把 KC 结构算进去
  • 对照:不是 Linux 那种 insmod → runtime relocate → 马上出现 的模型

十六、Panic 符号化:__TEXT vs __TEXT_EXEC

  • arm64/arm64e 上,panic 里的 KEXT load address 不能直接当代码 image base
  • 常见布局:__TEXT 从 0 开始,而可执行代码在 __TEXT_EXEC → 必须把 __TEXT_EXEC.vmaddr 加到 load address 再交给 atos -l
# 取 __TEXT_EXEC 的 vmaddr(例如 0x4000)
otool -arch arm64e -l Foo.kext/Contents/MacOS/Foo | grep -A4 __TEXT_EXEC
atos -arch arm64e -o Foo.kext.dSYM/Contents/Resources/DWARF/Foo \
     -l <load_address + __TEXT_EXEC.vmaddr> <panic_addr>
  • 不加这个偏移,atos / lldb image lookup 会返回垃圾(例如解析到 __TEXT.__cstring 而不是指令)
  • 内核回溯同构:先算 __TEXT_EXEC.vmaddr 与首段之差,再从 panic 的 "Kernel text exec base" 里减掉,用得到的基址跑 atos

十七、符号化必须匹配 UUID/build,不只是版本号

  • panic/crash report 记录相关 image 的 UUID 与地址范围;UUID 在 crash report 格式里就是"用于匹配对应符号文件/dSYM 的 build identifier"
  • 版本号(Foo.kext 1.2.3)相同仍然不够——要 UUID + architecture + exact build
  • 症状对照:"函数名大概对、行号很怪、offset 一直飘" → 多半是符号文件不是同一个 build

十八、KEXT 进不了内核:签名合法 ≠ 系统会加载

Apple Silicon 上传统 KEXT 受整套 boot policy 约束:

Reduced Security(1TR 模式降级 + 勾选允许用户管理 kernel extension + 管理员密码)
  + 用户 approval + UAKL + AuxKC rebuild + reboot
  • UAKL(User-Approved Kernel Extension List):用户授权的 kext 列表的 SHA384 哈希进 LocalPolicy;内核侧 kmd 校验只有 UAKL 内的 kext 才能进 AuxKC
  • SIP 与签名:SIP 开启时每个 kext 的签名在进入 AuxKC 前会被校验;SIP 关闭则不强制(permissive security,用于测试未签名 kext)
  • AuxKC 完整性:创建后其测量值由 Secure Enclave 签名进 Image4 结构、由 iBoot 在启动时评估;并生成 kext receipt(可能因禁用 kext 而小于 UAKL),哈希进 LocalPolicy
  • 所以"完全加载不了"要分开判断:architecture / code signature / user approval / UAKL / boot security policy / AuxKC inclusion

十九、SIP 关掉 ≠ Apple Silicon 的限制都没了

别把 macOS 当"关掉 SIP 就等于 Linux root"。这一层之上还有:secure boot policy / LocalPolicy / AuxKC measurement / KIP / PAC。

  • KIP(Kernel Integrity Protection):内核初始化完成后启用;iBoot 把 kernel 与 kexts 载入受保护的物理内存区,启动完成后内存控制器拒绝对该区的写入,MMU 同时阻止"从区外映射特权代码"与"区内可写映射";使能 KIP 的硬件在启动后被锁定(所以 csrutil disable 不等于"内核随便 patch")
  • 相关:PPL(iOS/iPadOS/watchOS)、SPTM/TXM(A15+/M2+ 及以后)、SCIP(协处理器固件)、KTRR
  • 做动态 patch/hook 实验前先搞清楚撞到的是哪一层:SIP / KIP / secure boot / PAC / code signing

二十、rootkit / malware 分析:落点不止 syscall hook,且强年代相关

  • 历史上 KEXT rootkit 落点:syscall、kauth、IOKit、process structures、network stack、inline patch、kernel symbol lookup(公开案例:恶意 KEXT 解析 kernel symbols、修改内核状态做进程隐藏)
  • 年代差异必须纳入判断:
10.x Intel          ≈ 传统 KEXT 世界
11+ Intel           ≈ KC / modern policy 过渡
11+ Apple Silicon   ≈ arm64e + PAC + AuxKC + 严格 boot policy
  • 在现代 Apple Silicon 上,"加载任意 KEXT → inline patch kernel"已比老 Intel 困难得多
  • 不能拿 2012 年的 KEXT rootkit 手法直接解释 2026 年的 Apple Silicon——样本行为与年代强相关

二十一、动态调试:KEXT 本来就该双机,DEXT 反而按普通进程调

  • Apple 明确:KEXT/kernel 不能像普通用户态进程那样本机调试(debugger 自己也依赖内核)。官方方案:日志 / 双机 KDP+LLDB / panic core dump / KDK(匹配 kernel 符号);需要早期或死锁现场时触发 NMI 让 target 停下等待远程调试器(Apple Silicon 的 NMI 组合键与 boot debug flags 有正式文档)
  • 所以"attach lldb 到 kernel 失败"不要沿普通 userspace anti-debug 方向排查
  • 反过来这是 DriverKit 最大的不同:dext 在用户态 → 优先 userspace debugger,不要一上来就搭 KDP 双机环境

决策树

拿到 macOS low-level target
├─ 先分类:KEXT / codeless KEXT / DEXT(DriverKit) / System Extension
├─ KEXT
│    ├─ Info.plist / IOKitPersonalities(IOClass/Provider/match)
│    ├─ Mach-O architecture(arm64e?PAC?)
│    ├─ class / vtable / OSMetaClass(需要 kernel/依赖的 type universe)
│    ├─ IOService 生命周期 init/probe/start/stop/free
│    └─ newUserClient → externalMethod(selector 表优先)
├─ 没有 executable → codeless KEXT → 找真正匹配的系统 driver
├─ 磁盘内容与运行行为不一致
│    ├─ AuxKC 仍是旧版本? reboot 完成了吗? disk UUID vs running UUID?
├─ Apple Silicon 约束
│    ├─ arm64e?PAC?KIP?Reduced Security?UAKL / AuxKC / LocalPolicy?
│    └─ x86_64 kext → KMErrorDomain 71(Rosetta 不翻译 kext)
├─ UserClient 调不通
│    ├─ service 是否 matched/started → selector → dispatch(count/size/completion)
│    ├─ entitlement → Team ID → sandbox
├─ symbolication 错
│    ├─ UUID / exact build(KDK) / architecture / __TEXT vs __TEXT_EXEC / runtime load address
└─ 动态调试
     ├─ KEXT/kernel → KDK + 双机 KDP/LLDB 或 panic core
     └─ DEXT → userspace debugger

工具与验证

  • 结构:otool -l(段与 __TEXT_EXEC.vmaddr)、vmmap、ioreg(IORegistry:谁绑定了什么设备)、codesign -d --entitlements :-
  • 集合与生命周期:kmutil(kext 与 kernel collection)、System Information / 系统设置里的扩展列表(UAKL / 已批准项)
  • 符号化:atos -arch arm64e -o <dSYM> -l <调整后基址>、lldb image lookup(先按 §16 调整基址)
  • 调试:KEXT → KDK + 双机 KDP/LLDB;DEXT → userspace debugger(lldb 直接 attach 进程)
  • 验证:能同时说清「哪一代模型 + 哪份版本(磁盘/AuxKC/运行)+ 该管线的授权链状态(signed/approved/activated)」

该平台的坑(汇总)

  • 把 dext 当"新版 kext":它跑在用户态,分析模型与调试模型都不同
  • 只分析磁盘上的 kext:先对齐 disk / AuxKC / running 三份版本与 UUID
  • 以为 kextunload 后代码就没了:重启前可能仍在运行
  • 把 codeless kext 当坏包:去逆它实际绑定的系统驱动
  • 把 arm64e 的 PAC 指令当混淆 / 把 PAC 指针当损坏
  • IOServiceOpen 连不上就怀疑 selector:先查 activated/entitlement/Team ID/sandbox
  • 把 kIOReturnBadArgument 当 handler 逻辑失败:先查 dispatch 的 count/size/completion(含通知端口)
  • 把 *.systemextension 一律当硬件驱动:先看 entitlements 与 linked frameworks
  • 只逆 .dext 不看 Host app:selector 与输入结构常在 client 端更好恢复
  • panic 符号化直接减 KEXT base:漏了 __TEXT_EXEC.vmaddr
  • 拿版本号当 build 匹配:要 UUID + arch + exact build(KDK)
  • 签名合法就以为能加载:还要 UAKL / 用户批准 / Reduced Security / AuxKC 重建 / 重启
  • 以为关掉 SIP 就能随便 patch 内核:还有 KIP / secure boot / LocalPolicy / PAC
  • 拿老 Intel 的 KEXT rootkit 手法套 Apple Silicon
  • 在 KEXT 上硬啃本机调试:KEXT 本就该双机(KDK + KDP);但 DEXT 可以直接用用户态调试器