macOS 版 CCleaner 1.18.30 的特权助手暴露了一个值得单独审阅的接口:本地进程提交可执行文件路径,root 助手负责启动。风险不在 JSON 或 Unix 套接字本身,而在于连接能力被扩展成了通用的高权限任务执行能力。
以下把安装授权、连接可达性、消息分发和子进程身份分开核对。样本范围仅限 1.18.30;代码与运行结果来自该版本的分析记录,离线模型用于核对字节和参数语义,并未重新运行 macOS 提权实验。其他版本和具体修复版本没有据此确认。
安装同意不等于请求授权
SMPrivilegedExecutables 中的助手标识是 com.piriform.ccleaner.CCleanerAgent。应用请求管理员批准安装后,后续本地客户端是否有权调用每项功能,仍需由服务独立判断。安装时输入一次密码,不会为未来每条消息自动补上身份校验。

界面示意图:展示安装授权,不表示本次运行记录。
完整磁盘访问与 root 身份也分属不同层次:前者涉及 TCC 保护的数据类别,后者涉及 Unix 凭据;两者都不是其他系统保护的统一开关。界面显示的系统版本字符串同样不足以证明精确 OS 构建。
连接入口与高权限端点
/Library/LaunchDaemons/com.piriform.ccleaner.CCleanerAgent.plist 将 Program 指向 /Library/PrivilegedHelperTools/com.piriform.ccleaner.CCleanerAgent。相关的套接字配置如下;这里把实际 plist 键整理为表,而不是给出可直接安装的完整配置。
| 键或观察 | 值 | 含义 |
|---|---|---|
| SockFamily | Unix | 文件系统命名的本地 IPC |
| SockType | Stream | 字节流,不自带应用帧边界 |
| SockPathMode | 438 | 八进制 0666 |
| SockPathName | /var/run/com.piriform.ccleaner.CCleanerAgent.socket | 客户端连接位置 |
| 助手运行身份 | root | 历史运行记录中的身份 |
flowchart LR accTitle: 本地请求跨入特权进程 accDescr: 本地进程经 Unix 套接字向 CCleanerAgent 发送封装 JSON,助手分发动作 8 并启动子进程。 A["本地进程"] --> B["Unix Stream socket"] B --> C["CCleanerAgent · root"] C --> D["AgentAction = 8"] D --> E["NSTask 子进程"]
438 十进制等于 0666 八进制。这个模式没有按普通用户组收紧套接字权限,但实际连接还受路径、ACL 与运行状态影响;即使连接成功,服务仍可检查对端凭据和操作权限。因此,宽松模式是入口证据,不是独立的提权结论。字段定义见 Apple 的 launchd.plist 手册。
两个标记各占十一字节
CCleanerAgent::sendMessageToRunningAgent: 按顺序追加固定前缀、NSJSONSerialization 生成的 JSON 和固定后缀,再写入 remoteFH。两个标记分别是 --613493r-- 与 --r394316--,开头和结尾都保留两个连字符。
PREFIX0x00–0x0ASUFFIX0x0B–0x15
上图为了比较标记,直接排列两组常量;它不是完整报文,第二组的偏移 11 也不是其在实际帧中的偏移。真实布局是 PREFIX || JSON || SUFFIX,后缀位置取决于 JSON 的 UTF-8 字节数。
import json
PREFIX = b'--613493r--'
SUFFIX = b'--r394316--'
message = {
'AgentAction': 8,
'Command': '/usr/bin/id',
'CommandArgumentsString': '',
}
wire = PREFIX + json.dumps(message, separators=(',', ':')).encode() + SUFFIX
assert len(PREFIX) == len(SUFFIX) == 11
assert json.loads(wire[len(PREFIX):-len(SUFFIX)]) == message
这条紧凑 JSON 的完整模型报文是 91 字节:前后标记各 11 字节,中间 JSON 为 69 字节。离线校验覆盖全部 91 个真前缀截断、4 组字符串往返和模式换算;这些结果验证模型,不代表服务端已经处理了同样的边界。
流式套接字可能短读,也可能一次读到多个帧。标记出现在 JSON 字符串中、长度上限及跨次读取状态,都需要按服务端实现另行验证,单帧往返测试不证明存在或不存在额外解析漏洞。
动作八把路径交给 NSTask
SocketListener::parseMessage: 解析 JSON 后读取 AgentAction,转为整数并分发。动作 8 取出 Command 和 CommandArgumentsString,将参数文本按字面空格拆成数组,随后进入 runTask:arguments:。
[NSTask launchedTaskWithLaunchPath:command arguments:arguments];助手随后等待子进程退出。这里的关键是客户端控制了启动路径;NSTask 接收路径和 argv,自身并不把参数作为 shell 命令解释。历史演示显式选择 /bin/sh,shell 语义来自被启动的程序,而不是分号在 NSTask 中自动生效。API 的路径与参数约定见 Apple 文档。
componentsSeparatedByString:@" " 不是 shell 词法分析器。下列 Python 字面分隔模型展示相同的分隔原则:连续或首尾空格留下空字段,引号仍是普通字符。Foundation 的规则见 NSString 分隔接口。
'' => ['']
'a b' => ['a', '', 'b']
' a ' => ['', 'a', '']
'"a b"' => ['"a', 'b"']因此,分析时要记录最终 argv,而不是只抄下界面中的参数文本。带空格的路径、引号或转义字符,均可能让实际参数与人的直觉不同。
root 输出证明了哪一条链
$ id
uid=0(root) gid=0(wheel) ...以上是历史终端中 id 结果的节选,省略其余组列表和无关的主机信息。它对应普通用户发起请求、助手启动子进程、子进程报告 uid=0 的一次运行,不是离线模型的输出。
sequenceDiagram accTitle: 从请求到观察到的身份 accDescr: 助手解析请求、启动子进程并等待结束;历史子进程输出显示 uid 为零。 participant C as 本地客户端 participant H as Root 助手 participant P as 子进程 C->>H: 封装 JSON,AgentAction 8 H->>H: 读取 Command 并切分参数 H->>P: NSTask 启动 Note over P: 历史 id 输出:uid=0 P-->>H: 退出 H->>H: 等待结束
演示用两阶段脚本和本地连接带回输出;这个交互通道不是缺陷成立的必要条件。权限边界已经在助手接受不受业务约束的启动路径时跨过。对修复的验证应关注请求为何获准,而不是复现整套交互包装。
installCCleanerLibFromPath: 与 installCCleanerToolFromPath: 也值得检查,但现有证据没有闭合这两条路线。方法名与安装职责只能支持进一步检查路径、所有权及签名,尚未证明任意文件替换。
把通用启动接口收回业务边界
- 1
确定实际身份
核对启动配置、运行进程和套接字权限,分开记录安装授权、服务身份与客户端可达性。
- 2
跟踪每次授权
从接收请求追到敏感操作,找到对端身份与操作权限的实际判断,不以安装弹窗或固定帧头替代证据。
- 3
缩小可委托动作
优先暴露固定业务操作,约束路径、参数和对象所有权;避免由客户端选择任意可执行程序。
- 4
验证拒绝与成功两侧
用未授权客户端、获准客户端和无效参数分别检查结果,同时观察是否产生子进程及其有效身份。
这个样本的有效结论是:可达入口、动作 8、受控路径、NSTask 和 root 结果彼此对应。更换 IPC 形式不自动解决授权问题;审核新实现时,应重新核对每项能力在何处、依据什么身份被授予。