「攻防对抗」对 MacBook 翻盖模式进行逆向工程插图

在连接了外接显示器的情况下合上 MacBook 机盖可以关闭并禁用内部显示器。让我们弄清楚 macOS 是如何做到这一点并绕过盖子传感器的。

您刚刚为 MacBook 配备了超宽大显示器。您将其连接起来并惊叹于像素的数量。

您注意到您再也没有使用过 MacBook 的内置显示器,并且让您在较低的周边视野中看到它。

关闭盖子不是一种选择,因为您仍然使用键盘和触控板,有时甚至可能使用网络摄像头和 TouchID。所以你尝试:

  • 您尝试通过完全降低亮度来关闭显示器。?嗯好的,但现在:
    • 你的鼠标有时会游到那个屏幕
    • 一些窗户在那里迷路了
    • ..你仍然浪费 GPU 周期来渲染 600 万个未使用的像素
  • 您将显示器镜像到内置屏幕。很好,这解决了前两个问题!
    • 好的,但为什么分辨率改变了?我每次这样做都必须把它改回来吗??
    • 等等,为什么我不再收到通知了?!哦。有一个设置
  • 你离开办公桌,屏幕进入休眠状态
  • 你回来了,屏幕现在大约有 6% 的亮度,不再完全关闭
    • 好的,再按Brightness Down一次,我可以忍受
    • 哦,镜像也被禁用了……至少还有Cmd+Brightness Down

为什么没有办法实际禁用此屏幕?

停电

因为我的? Lunar应用程序的很多用户告诉我他们对无法在软件中关闭单个显示器的不满,所以我进入了显示镜像的兔子洞并自动化了上述所有操作。显示 BlackOut 功能的 Lunar 界面

现在,有人可以使用键盘快捷键随意关闭和打开任何显示器,甚至可以自动执行上述MacBook +显示器工作流程,以在连接和断开外接显示器时触发。

但它仍然困扰着我,不知何故 macOS 实际上可以完全禁用内部屏幕,但我们却被这种零亮度镜像所困扰。

翻盖模式

在显示器仍连接的情况下合上 MacBook 机盖时,内部屏幕会从屏幕列表中消失,而外部显示器仍然可用。

此功能在笔记本电脑世界中称为翻盖模式。恭喜,你价值 3000 美元的一体机现在只是一个带有一些 USB-C 端口的 SoC。好吧,你还得到了扬声器和低效的冷却系统。

在前厚实的 MacBook-Pro 时代,使用盖子中的磁铁和一些霍尔效应传感器检测到盖子已关闭。因此,您只需在其两侧放置两块强大的磁铁,就可以让 macOS 认为盖子已关闭。

在 2021 年的新设计中,MacBook 配备了铰链传感器,它不仅可以检测机盖是否合上,还可以检测合上的角度。磁铁再也无法欺骗他们了。

但是所有这些传感器可能只会触发软件中的一些事件,处理程序将在其中决定是否应该禁用显示,并调用一些disableScreenInClamshellMode函数。

那么那个函数在哪里,我们可以自己调用它吗?

软件方面

自 Apple Silicon 以来,大多数用户空间代码都位于一个名为 DYLD 共享缓存的文件中。自 Ventura 以来,它位于以下路径的Cryptex(只读卷)中:

/System/Cryptexes/OS/System/Library/dyld/dyld_shared_cache_arm64e

由于该文件主要是 macOS 框架的优化串联,我们可以使用keith/dyld-shared-cache-extractor提取二进制文件:

1 2mkdir -p ~/Temp/dyld && cd ~/Temp/dyld dyld-shared-cache-extractor /System/Cryptexes/OS/System/Library/dyld/dyld_shared_cache_arm64e $PWD

让我们以文本格式提取导出和未导出的符号,以便能够使用ripgrep之类的工具轻松搜索它们。

我使用/usr/bin/nmwith fd-x选项来利用并行化。我比parallel‘ 更喜欢它的语法,因为它集成了参数的基本名称/目录名称的插值(注意{/}

1 2 3 4 5 6 7 8mkdir symbols private-symbols fd --maxdepth 1 -t f \ . ./System/Library/*Frameworks/*.framework/Versions/A/ \ -x sh -c 'nm --demangle --defined-only --extern-only {} > symbols/{/}' fd --maxdepth 1 -t f \ . ./System/Library/*Frameworks/*.framework/Versions/A/ \ -x sh -c 'nm --demangle --defined-only {} > private-symbols/{/}'

搜索给clamshell了我们有趣的结果。最值得注意的是 SkyLight 中的这个:

1 2 3 4~/Temp/dyld ❯ rg -i clamshell symbols/SkyLight 1710:00000001d44bce70 S _kSLSDisplayControlRequestClamshellState

SkyLight.framework是在 macOS 中处理窗口和显示管理的东西,它通常会导出我们可以从 Swift 中使用的足够的符号,所以我倾向于遵循这条路。

让我们看看互联网是否对我们有用。我通常在SourceGraph上搜索代码,因为它用 dyld 转储索引了一些大型 macOS 存储库。寻找给RequestClamshellState了我们一些更有趣的东西:「攻防对抗」对 MacBook 翻盖模式进行逆向工程


貌似苹果开源了电源管理代码,nice!它甚至还有最近的 ARM64 代码,我们有那么幸运吗?

以下是与我们的事业相关的摘录:

SLSDisplayPowerControlClient *gSLPowerClient = nil; enum { kPMClamshellOpen = 1, kPMClamshellClosed = 2, kPMClamshellUnknown = 3, kPMClamshellDoesNotExist = 4 }; void handleSkylightCheckIn(void) { // ...  // create ws power control client  NSError *err = nil; gSLPowerClient = [[SLSDisplayPowerControlClient alloc] initAsyncPowerControlClient:&err notifyQueue:_getPMMainQueue() notificationType:kSLDCNotificationTypeNone notificationBlock:^(void *dict) { if (dict != nil) { handleSkylightNotification(dict); } else { ERROR_LOG("Received a nil dictionary from WindowServer callback"); } }]; // ... } void requestClamshellState(SLSClamshellState state) { /* Forward clamshell state to WindowServer A) a request with a clamshell state of close in interpreted as a turn off clamshell display (clamshell close) B) a request with a clamshell state of open in interpreted as a turn on internal and ANY external displays (clamshell open) */ if (!gSLCheckIn) { ERROR_LOG("WindowServer has not checked in. Refusing to change clamshell display state"); return; } NSError *err = nil; NSMutableDictionary *request = [[NSMutableDictionary alloc] initWithCapacity:1]; NSNumber *ns_state = [[NSNumber alloc] initWithUnsignedChar:state]; [request setValue:ns_state forKey:kSLSDisplayControlRequestClamshellState]; SLSDisplayControlRequestUUID uuid = [gSLPowerClient requestStateChange:(NSDictionary *const)request error:&err]; if ([err code] != 0) { ERROR_LOG("Clamshell requestStateChange returned error %{public}@", err); } else { INFO_LOG("requestClamshellState: state %u, Received uuid %llu", state, uuid); struct request_entry *entry = (struct request_entry *)malloc(sizeof(struct request_entry)); entry->uuid = uuid; entry->valid = true; STAILQ_INSERT_TAIL(&gRequestUUIDs, entry, entries); } if (request) { [ns_state release]; [request release]; } if (err) { [err release]; } }

所以它正在实例化SLSDisplayPowerControlClient然后调用它的requestStateChange方法。SLS是与 SkyLight 相关的前缀(可能代表 SkyLightServer),让我们看看我们的框架版本中是否有该代码。

我更喜欢使用Hopper及其从 DYLD 缓存中读取文件功能来做到这一点,该功能可以从当前使用的缓存中提取框架:显示从 DYLD 缓存中读取文件的 Hopper 菜单项显示 SLSDisplayPowerControlClient 的料斗

好的,类和方法都在那里,让我们看看使用它们的原因。因为它很可能是一个处理电源管理的守护进程,所以我会在/System/Library.

看起来powerd正是我们正在寻找的,它包含我们在 SourceGraph 上看到的代码。

❯ rg -uuu requestClamshellState /System/Library/ 2>/dev/null /System/Library/CoreServices/powerd.bundle/powerd: binary file matches (found "\0" byte around offset 4) ❯ hopperv4 -e /System/Library/CoreServices/powerd.bundle/powerd

Hopper 伪代码调用 requestStateChange

编写代码

要链接和使用SLSDisplayPowerControlClient我们需要一些标头,因为 Swift 没有可用的方法签名。

SLSDisplayPowerControlClient在 SourceGraph 上寻找给我们的比我们需要的更多。

让我们创建一个桥接标头,以便 Swift 可以链接到 Objective-C 符号,并创建一个 Swift 文件到我们将尝试复制其powerd功能的位置。

mkdir clamshell && cd clamshell touch Bridging-Header.h Clamshell.swift

桥接头.h

#import <Foundation/Foundation.h>  @interface SLSDisplayPowerControlClient {} - (id)initAsyncPowerControlClient:(id*)arg1 notifyQueue:(id)arg2 notificationType:(UInt8)arg3 notificationBlock:(void (^)(NSDictionary*))notificationBlock; - (id)initPowerControlClient:(id*)arg1 notifyQueue:(id)arg2 notificationType:(UInt8)arg3 notificationBlock:(void (^)(NSDictionary*))notificationBlock; - (unsigned long long)requestStateChange:(id)arg1 error:(id*)arg2; @end extern NSString* kSLSDisplayControlRequestClamshellState; UInt8 kSLDCNotificationTypeNone = 0;

翻盖.swift

import Foundation enum ClamshellState: Int { case open = 1 case closed = 2 case unknown = 3 case doesNotExist = 4 } var err: AnyObject? let skyLightPowerClient = SLSDisplayPowerControlClient(powerControlClient: &err, notifyQueue: DispatchQueue.main, notificationType: kSLDCNotificationTypeNone) { dict in print(dict as Any) } func requestClamshellState(_ state: ClamshellState) { // Send the request let request: [AnyHashable: Any] = [ kSLSDisplayControlRequestClamshellState: NSNumber(value: state.rawValue) ] var err: AnyObject? let uuid = skyLightPowerClient!.requestStateChange(request, error: &err) // Check the response if (err as! NSError?)?.code != 0 { print("Clamshell requestStateChange returned error", err?.localizedDescription ?? "") } else { print("requestClamshellState: state %u, Received uuid %llu", state, uuid) } } print(skyLightPowerClient!) requestClamshellState(.closed)

编译…

要编译二进制文件,swiftc我们必须将其指向 SkyLight.framework 的位置,该位置位于/System/Library/PrivateFrameworks

然后我们告诉它使用-framework SkyLight并导入我们的桥接标头来链接框架。然后我们运行生成的二进制文件。

我更喜欢运行它entr来观察文件的变化。使用左侧的代码编辑器和右侧的终端,我可以通过编辑和保存文件来更快地迭代和尝试,然后在右侧观察输出。

swiftc \ -F/System/Library/PrivateFrameworks \ -framework SkyLight \ -import-objc-header Bridging-Header.h \ Clamshell.swift -o Clamshell ./Clamshell # For faster iteration, watch file changes with entr: echo Clamshell.swift Bridging-Header.h | entr -rs '\ swiftc -F/System/Library/PrivateFrameworks \ -framework SkyLight \ -import-objc-header Bridging-Header.h \ Clamshell.swift -o Clamshell \ && ./Clamshell'

嗯..它不工作。该错误根本没有帮助,互联网上没有任何相关内容。

寻找错误

也许系统日志对我们有用。/usr/bin/log可以使用 Console.app 检查,但我更喜欢通过该实用程序在终端中查看它。

1log stream --predicate 'eventMessage contains "Clamshell"'

来自 AMFI 的关于二进制签名的信息。CMS 代表加密消息语法,它codesign添加到二进制文件中并使用证书对其进行签名。

我禁用了 GateKeeper 并从添加到Security & Privacy的特殊 Developer Tools 部分的终端运行二进制文件,因此这不会导致任何问题。

我检查只是为了确定,并用我每年 100 美元的 Apple Developer 证书对其进行签名可以消除CMS blob错误,但不会改变结果中的任何内容。


呼,让我们休息一下

我刚和妻子坐了很长的火车来到我正在重建的房子,想和你分享这个美景?

现在是一月,但阳光温暖了我们的脸庞,榛子树已经长出了黄色的柳絮。

十年前,这所房子以前主人的孩子们在膝盖深的雪中行走,并在他们的木雪橇上滑行下坡,在下山的过程中伤害了几棵小杉树。

一月的布雷扎太阳

季节在变化。


深层发掘

某些系统功能只有在二进制文件已由 Apple 签名并具有特定权利的情况下才能访问。检查powerd的权利让我们有些担心。

二进制文件似乎使用了com.apple.private.*权利。这通常意味着如果不存在所需的权利,某些 API 将失败。

我们可以尝试自己添加权利。我们只需要创建一个 plist 文件并将其用于codesign

权利.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.private.SkyLight.displaypowercontrol</key> <true/> </dict> </plist>

使用权利签署二进制文件并运行它:

❯ codesign -fs $CODESIGN_CERT --entitlements Entitlements.plist Clamshell ❯ ./Clamshell Job 1, './Clamshell' terminated by signal SIGKILL (Forced quit)

看来我们马上就要死了。日志流显示 AMFI 这样做是因为我们不是 Apple,我们不应该使用该权利。

kernel: mac_vnode_check_signature: /Users/alin/Temp/dyld/clamshell/Clamshell: code signature validation failed fatally: When validating /Users/alin/Temp/dyld/clamshell/Clamshell: Code has restricted entitlements, but the validation of its code signature failed. Unsatisfied Entitlements: com.apple.private.SkyLight.displaypowercontrol kernel: (AppleSystemPolicy) ASP: Security policy would not allow process: 57234, /Users/alin/Temp/dyld/clamshell/Clamshell amfid: /Users/alin/Temp/dyld/clamshell/Clamshell not valid: Error Domain=AppleMobileFileIntegrityError Code=-413 "No matching profile found" UserInfo={NSURL=file:///Users/alin/Temp/dyld/clamshell/Clamshell, unsatisfiedEntitlements=<CFArray 0x155e1b600 [0x1f0e613a8]>{type = immutable, count = 1, values = ( 0 : <CFString 0x155e12db0 [0x1f0e613a8]>{contents = "com.apple.private.SkyLight.displaypowercontrol"} )}, NSLocalizedDescription=No matching profile found}

AMFI

这个 AMFI 到底是什么?为什么它告诉我们在我们自己的设备上可以做什么和不能做什么?

该首字母缩写词代表Apple Mobile File Integrity,它是在系统级别强制执行代码签名的过程。

默认情况下,操作系统会锁定这些私有 API,因为如果我们能够使用它们,那么恶意软件或不良行为者也能够做到这一点。默认情况下锁定它,恶意软件作者无法尝试在重要性较低的目标上使用这些 API,因为这通常需要 0-day 漏洞利用。

最后它只是另一层安全,如果在极少数情况下有人需要绕过它,Apple 提供了一种方法来做到这一点。该过程涉及禁用系统完整性保护并添加amfi_get_out_of_my_way=1为引导参数。

我不建议这样做,因为这会给您带来很大的风险,因为系统卷不再是只读的,代码签名也不再强制执行。

我只在短时间内保持这种状态进行研究,然后重新打开 SIP 以进行正常的日常使用。

如果您需要还原上述更改:


没有更多的 AMFI?

不幸的是,即使在禁用 AMFI 之后,我们仍然会遇到CoreGraphicsError 1004. 的确,AMFI 不再抱怨权利,它们已被接受并且二进制文件未被SIGKILL编辑。

但是我们仍然无法仅使用软件进入翻盖模式。

弗里达

如果你还没有听说过,Frida 是一个很棒的工具,它可以让你将代码注入到已经运行的进程中,按名称(甚至按地址)挂钩函数,观察它们是如何以及何时被调用的,检查它们的参数,甚至使你自己的电话。

让我与您分享另一个我喜欢的 macOS 引导参数:

1sudo nvram boot-args=-arm64e_preview_abi

这一个启用代码注入。现在我们可以使用 Frida 挂钩 SkyLight 电源控制方法,看看它们在我们关闭和打开盖子时是如何调用的:

> sudo frida-trace -t SkyLight -m '-[SLSDisplayPowerControlClient *]' powerd // Closing the lid /* TID 0x5427 */ 4617 ms SLSDisplayControlRequestClamshellStateKey: 2 4617 ms -[SLSDisplayPowerControlClient requestStateChange:0x13cf06a60 error:0x16b8ca828] 4628 ms | -[SLSDisplayPowerControlClient service] 4628 ms | -[SLSDisplayPowerControlClient sendStateChangeRequest:0x13cf06a60 uuid:0x16b8ca7e0] 4628 ms | | -[SLSDisplayPowerControlClient service] // Opening the lid /* TID 0x8a17 */ 10537 ms SLSDisplayControlRequestClamshellStateKey: 1 10537 ms -[SLSDisplayPowerControlClient requestStateChange:0x13cc1e1c0 error:0x16b9567a8] 10538 ms | -[SLSDisplayPowerControlClient service] 10538 ms | -[SLSDisplayPowerControlClient sendStateChangeRequest:0x13cc1e1c0 uuid:0x16b956760] 10538 ms | | -[SLSDisplayPowerControlClient service]

我们至少得到了确认。关上盖子时powerd确实在呼唤。SLSDisplayPowerControlClient.requestStateChange(2)

让我们看看当我们尝试在Clamshell.swift.

readLine(strippingNewline: true)我们首先在文件顶部添加一行,Clamshell.swift让二进制文件等待我们按下Enter。这样我们就有了一个可以附加到 Frida 上的正在运行的进程。

一切看起来都一样,似乎我们看得不够深入。

请求方法似乎访问了service一个SLSXPCService. XPC 服务是 macOS 用于低级进程间通信的。

一个进程可以使用标签(例如 )公开 XPC 服务com.myapp.RemoteControlService并侦听通过的请求,其他进程可以使用相同的标签连接到它并发送请求。

系统处理路由部分。和身份验证部分。

看起来 XPC 服务也可以限制为特定的代码签名要求,这可能是我们在这里遇到的问题吗?

让我们也使用 Frida 跟踪SLSXPCService方法:

sudo frida-trace -t SkyLight -m '-[SLSDisplayPowerControlClient *]' -m '-[SLSXPCService *]' powerd // Closing the lid while observing powerd /* TID 0x518b */ 3029 ms -[SLSDisplayPowerControlClient requestStateChange:0x139621c60 error:0x16f0c2828] 3029 ms SLSDisplayControlRequestClamshellStateKey: 2 3043 ms | -[SLSDisplayPowerControlClient service] 3043 ms | -[SLSDisplayPowerControlClient sendStateChangeRequest:0x139621c60 uuid:0x16f0c27e0] 3043 ms | | -[SLSDisplayPowerControlClient service] 3043 ms | | -[SLSXPCService sendXPCDictionary:0x13a913be0] 3043 ms | | | -[SLSXPCService reinitConnection] 3043 ms | | | | -[SLSXPCService enabled] 3043 ms | | | | -[SLSXPCService enabled] 3043 ms | | | | -[SLSXPCService connected] 3043 ms | | | -[SLSXPCService connection] 3452 ms -[SLSXPCService handleXPCEvent:0x13ad0ea40] 3452 ms | -[SLSXPCService enabled] 3452 ms | -[SLSXPCService cfStringToCStringPtr:0x1f3133020] 3452 ms | -[SLSXPCService connected] > sudo frida-trace -t SkyLight -m '-[SLSDisplayPowerControlClient *]' -m '-[SLSXPCService *]' Clamshell // Trying to send the clamshell request in software 1435 ms -[SLSDisplayPowerControlClient requestStateChange:0x6000014d4030 error:0x16b123c90] 1435 ms SLSDisplayControlRequestClamshellStateKey: 2 1444 ms | -[SLSDisplayPowerControlClient service] 1444 ms | -[SLSDisplayPowerControlClient sendStateChangeRequest:0x6000014d4030 uuid:0x16b123a10] 1444 ms | | -[SLSDisplayPowerControlClient service] 1444 ms | | -[SLSXPCService sendXPCDictionary:0x600003ec4000] 1444 ms | | | -[SLSXPCService reinitConnection] 1444 ms | | | | -[SLSXPCService enabled] 1444 ms | | | | -[SLSXPCService connected] 1444 ms | | | | -[SLSXPCService autoreconnect] 1444 ms | | | | -[SLSXPCService enabled] Process terminated // ...we're missing this stuff // 3043 ms | | | -[SLSXPCService connection] // 3452 ms -[SLSXPCService handleXPCEvent:0x13ad0ea40] // 3452 ms | -[SLSXPCService enabled] // 3452 ms | -[SLSXPCService cfStringToCStringPtr:0x1f3133020] // 3452 ms | -[SLSXPCService connected]

伟大的!或不?

我不确定我是否应该为我们发现我们的 clamshell 请求不起作用因为我们没有 XPC 连接而感到高兴,或者我是否应该担心这意味着我们将无法完成这项工作启用 SIP。

我想是时候深入了解一下了。

XPC 服务

现在我们可以访问 Frida,我们可以使用方便的xpcspy工具来嗅探powerd.

我在想也许我们可以找到 XPC 侦听器的端点名称并连接到它并直接发送原始消息,而不是依赖 SkyLight 来做到这一点。

所以我们有name = (anonymous), listener = false, pid = 30630

一个匿名的听众,它会变得更糟吗?PID 一致,WindowServer --daemon因此这绝对是我们也试图发送的消息。但是对于匿名侦听器,我们只能依靠 SkyLight 的导出代码来访问它。

我想我们需要回去做一些老式的集会阅读。


填补空白

重命名 Hopper 中的一些子过程后,查看图表powerd可以Clamshell揭示SLSXPCService.reinitConnection.

电源

  1. 看到服务的enabledconnected属性是true
  2. 所以它离开了reinitConnection
  3. 并直接通过 available 发送 XPC 字典connection

翻盖

  • 看到了enabledconnected并且autoreconnectfalse
    • 所以它失败了CGError
  • 如果这些属性是true它会在右侧代码路径上
    • 检查属性是否0x200x28非零
    • 然后继续重新连接。

显示 reinitConnection 的料斗图

在内部添加一些Memory.readPointer调用__handlers__/SLSXPCService/reinitConnection.js向我们展示了 SkyLight 期望在0x20和处看到的内容0x28

在and属性NSMallocBlock之后的两个s。OS_xpc_connectionOS_dispatch_queue_serial

从SLSXPCService.h的内容来看,这些是 和 的闭errorBlocknotificationBlock

我正在一点一点地接近好的代码路径,但我似乎永远不会到达那里。

所以这是我Clamshell.swift在打电话之前所做的requestClamshellState

调用 后,代码在内部requestClamshellState崩溃,因为它分支到地址。SIGSEGVcreateNoSenderRecvPairWithQueue:errorHandler:eventHandler:0x0

放弃(暂时)

不幸的是我在这里有点迷路了。我会休息一下,希望解决方案会出现在梦中或像那些神话故事中那样长途跋涉。

这篇文章已经比我想读的要长了,所以如果有人读到这里,恭喜你,你有僧侣般的耐心。