ControlPlane 1.6.7 的助手会验证待安装工具的代码签名,但文件路径先经过 system() 启动的 shell。若路径包含命令替换,求值发生在 codesign 开始检查文件之前;签名验证失败也不会撤销已经发生的展开。
分析对象是 macOS x86_64 旧版应用与 CPHelperTool。以下将固定版本源码、历史运行记录和本地模型分开使用:先确认请求进入处理函数的授权条件,再解释字符串如何变成高权限操作,避免把一次成功运行扩大成所有用户状态下的结论。
版本、安装与运行身份
| 项目 | 值 |
|---|---|
| 应用版本 | 1.6.7 |
| 发布日期(UTC) | 2019-08-21 |
| 对应提交 | 9ae1973647d09d0078188b0cbd201bc00f3ddce5 |
| 助手 CFBundleVersion | 8 |
| 分析记录日期 | 2025-07-15 |
产品发布时间与分析日期分属两条时间线。官方发布页 对应的是 2019 年版本,后续源码讨论固定到上述提交,不以当前默认分支代替历史实现。本文未确认具体修复版本。
- 1
复制应用
将应用放入 Applications 只是部署客户端,尚未建立 root 服务。
- 2
初始化上下文
创建默认上下文属于应用配置,与安装助手是不同动作。
- 3
安装助手
客户端检查现有助手及版本,必要时请求 kSMRightBlessPrivilegedHelper,再调用 SMJobBless。

界面示意图:应用初始化与高权限组件安装是两个阶段。
安装实现 使用 SMJobCopyDictionary 查询已有服务,并检查版本及代码要求。安装信任关系决定哪个应用可以安装哪个助手,不自动替每个后续 Unix 套接字请求验证身份。
用 plist 与节区定位入口
- /Library/PrivilegedHelperTools
- com.dustinrue.CPHelperTool
- /var/run
- com.dustinrue.CPHelperTool.socket
运行记录中出现上述端点;套接字存在只说明服务入口可见,实际可达性仍需权限和连接结果支持。以下将 launchd 套接字配置与助手的 SMAuthorizedClients 合并展示,键值来自两个不同 plist,并非可直接替换的完整文件。
SockPathMode = 438 对应 0666。它没有按普通用户组收紧模式,但路径权限、对端检查和请求授权仍是独立条件。SMAuthorizedClients 中的要求用于安装关系,并不意味着自定义消息处理自动拥有同等的调用者校验。
- 架构
- x86_64
| 名称 | 地址 | 大小 | 文件偏移 |
|---|---|---|---|
| __TEXT.__info_plist | 0x1000073AB | 0x270 | 0x000073AB |
| __TEXT.__launchd_plist | 0x10000761B | 0x29C | 0x0000761B |
两个节区数据来自历史 otool 记录:0x270 = 624,29611 + 624 = 30235;后一节区大小 0x29c = 668。文件偏移与虚拟地址分别记录,偏移只属于这份二进制布局,提取时还应检查实际文件长度和对应架构切片。
客户端复用减少协议工作,不增加权利
| 对象 | 架构 | CodeDirectory flags | TeamIdentifier |
|---|---|---|---|
| ControlPlane | x86_64 | 0x0 | YV4RHGCYFA |
| CPHelperTool | x86_64 | 0x0 | YV4RHGCYFA |
两者记录中的签名身份为 Developer ID Application: Dustin Rue。主应用的 flags=0x0 与未启用 Hardened Runtime 相符;历史运行通过 DYLD_INSERT_LIBRARIES 加载动态库,但该 flags 值不保证任意系统、进程或启动方式都接受注入。
flowchart LR accTitle: 客户端复用与助手边界 accDescr: 动态库在客户端运行,复用助手初始化和 BAS 传输,助手仍独立执行权利检查和命令处理。 A["客户端中的动态库"] --> B["helperToolInit:"] B --> C["BAS 请求"] C --> D["Unix socket"] D --> E["逐项权利检查"] E --> F["特权处理函数"]
动态库替换 ToggleRemoteLoginAction 的 execute: 实现,并主动触发该方法。真正复用的是 helperToolInit:、助手标识和 BAS 请求路径,而不是远程登录这个业务动作;方法替换仍须匹配 Objective-C 的 self、_cmd 与显式参数 ABI。
helperToolInit: 创建 AuthorizationRef 并调用 BASSetDefaultRules。一个有效授权引用是会话入口,不等同于已获得所有命令权利。客户端中已有协议代码,也不会消除服务端的检查。
命令表决定授权分支
flowchart TD
accTitle: 助手分发与条件授权
accDescr: main 进入 BASHelperToolMain 和 HandleConnection;匹配到具有权利名称的命令时,先通过 AuthorizationCopyRights,再执行回调。
A["main"] --> B["BASHelperToolMain"]
B --> C["HandleConnection"]
C --> D["BASRead 外部授权表示"]
D --> E["AuthorizationCreateFromExternalForm"]
E --> F["BASReadDictionary"]
F --> G["FindCommand"]
G --> H{"匹配且存在 rightName?"}
H -->|"是"| I["AuthorizationCopyRights"]
H -->|"否"| J{"commandProcStatus == noErr?"}
I --> J
J -->|"是"| K["commandProcs[index]"]
J -->|"否"| L["错误响应"]
K --> M["响应与清理"]
L --> M
以上是 BAS 连接处理 的成功读取路径概括,读取或对象创建失败还会提前转入错误处理。命令表与处理函数表按索引对应;若匹配命令带有 rightName,先执行 AuthorizationCopyRights,只有 commandProcStatus == noErr 才调用回调。
| 命令 | rightName | 源码默认规则 |
|---|---|---|
| InstallTool | com.dustinrue.ControlPlane.InstallTool | default |
| SetDisplaySleepTime | com.dustinrue.ControlPlane.SetDisplaySleepTime | allow |
default 与 allow 是不同规则。源码中的默认规则用于缺失权利项的初始化,实际运行还要查看授权数据库;它不保证覆盖已有配置。没有 rightName 时,框架跳过这一层检查,处理函数需自行承担适用的授权责任。
检查标志包含 kAuthorizationFlagExtendRights | kAuthorizationFlagInteractionAllowed。客户端 BAS 路径还存在预授权步骤,因此应区分客户端预授权、服务端复查和用户是否看到认证提示。策略与缓存凭据影响交互;Apple 的授权说明 也明确区分身份认证与权利授予。
本地布尔模型覆盖了命令存在、权利名存在和权利获准的 8 种组合,仅验证上述分支关系。冷启动、缓存凭据过期及不同用户身份下的 macOS 结果尚未逐项运行,所以进入 DoInstallTool 必须保留“权利检查已经通过”这个前提。
签名验证前已经进入 shell
DoInstallTool 从请求取出 srcPath 与 toolName,将路径转换成 C 字符串。下列为命令构造摘录,换行经过整理;它展示数据边界,不是完整函数。
asprintf(&valCodeSignCmd,
"codesign -v -R=\"certificate leaf[subject.CN] = \\\"%s\\\" "
"and anchor apple generic\" \"%s\"",
kSigningCertCommonName, pFilename);asprintf 管理输出缓冲区分配,并不转义路径中的 shell 语法。代码随后把整串文本传给 system();双引号保留路径中的空格,却仍允许 $(...) 命令替换。
签名要求表达式由 codesign 解释,外层命令则先由 shell 解释;两种语法层次不要混在一起。certificate leaf[subject.CN] 与 anchor apple generic 是签名条件,后者的定义见 Apple 要求语言。它们没有保护进入 codesign 之前的 shell 展开。
下面用始终返回失败的普通函数代替验证器,只输出标记和参数,不调用系统助手或真正的签名工具:
#!/usr/bin/env bash
set -u
exec 2>&1
mock_codesign() {
printf 'verifier argument: <%s>\n' "$1"
return 1
}
mock_codesign "$(printf 'substitution ran\n' >&2; printf 'fixture.bin')"
status=$?
printf 'verifier status: %s\n' "$status"
test "$status" -eq 1
substitution ran
verifier argument: <fixture.bin>
verifier status: 1该模型在 Windows 的 MSYS Bash 5.1.16 运行,结果显示替换先执行,验证器随后收到展开后的 fixture.bin 并返回 1。它验证 shell 命令替换语义,不代表重新执行了 macOS 的 system() 或 root 助手。
传输成功也不等于安装成功
BASExecuteRequestInHelperTool 的返回值描述传输过程;响应内的 kBASErrorKey 描述命令状态,安装操作另外写入 Success。三者要分开读取。
bool success = true;
OSStatus retval = noErr;
/* String conversion and allocation succeeded in this excerpt. */
if (system(valCodeSignCmd) == 0) {
OSStatus fsret = FSPathCopyObjectSync(
pFilename, "/usr/local/bin", toolName,
NULL, kFSFileOperationOverwrite);
if (fsret != noErr)
success = false;
}
/* No else branch sets success to false here. */固定提交的 DoInstallTool 还有一个结果表达问题:在转换和分配成功的前提下,system() 返回非零时,函数跳过复制,却没有把 success 改成 false,也没有更新初始的 retval = noErr。本地控制流模型得到以下三种结果:
| 验证状态 | 文件复制 | retval | Success |
|---|---|---|---|
| 非零 | 未调用 | 0 | true |
| 0 | 返回错误 | 0 | false |
| 0 | 成功 | 0 | true |
这说明成功提示既可能掩盖没有复制,也可能与命令回调状态不同;它不是签名通过的独立证据。模型只覆盖列出的成功转换与分配路径,未把其他错误分支简化成同样行为。
$ id
uid=0(root) gid=0(wheel) ...历史终端另外记录了 uid=0(root),比客户端自报成功更接近实际执行身份;它仍只支持该次运行,不证明没有授权条件。交互通道和部署下载流程均不是解释根因所必需的环节。
修复落在解释器边界与结果传播
处理签名校验时,应直接使用代码签名 API,或以固定程序路径和参数数组启动验证器,让文件名保持数据。授权已经通过也不意味着参数可信:路径范围、文件所有权及验证后到复制前的对象一致性仍需审阅。
同时应把验证器失败、复制失败和成功分别传播到命令状态与业务响应;测试断言应包含实际文件结果,而非只有客户端的 success 文本。这个案例最重要的区别是:签名验证算法没有被证明失效,失守的是调用它之前的命令解释边界。