xspawn 通过 launchd 在 macOS 上启动程序,并且自身从不 exec 该程序。目标是让 EDR 将 launchd 记录为父进程,而不是此工具或调用它的 shell。
作者:cenobyte [email protected] 2026
https://github.com/cenobyte-vincit/xspawn
xspawn 打开 xpc_pipe_create_from_port(bootstrap_port),并通过 _xpc_pipe_interface_routine 引导程序的执行——这与 launchctl 使用的私有 XPC 管道相同——并且自身从不 exec 该程序。
/bin/launchctl。gui/<uid> 会话的 macOS (Darwin)cc)makebrew install cppcheck)make
xspawn oneshot -l <label> [-o <stdout>] [-e <stderr>] [--] <program> [args...]
xspawn submit -l <label> [-o <stdout>] [-e <stderr>] [--] <program> [args...]
xspawn remove -l <label>
xspawn load -p <plist>
一次性(RunAtLoad + LaunchOnlyOnce;0 表示不睡眠):
./xspawn oneshot -l com.example.once -- /tmp/helloworld 0
-- 之后的参数即 ProgramArguments,包括内联代码(python3 -c、perl -e)。CrowdStrike Falcon for macOS 会记录完整的 CommandLine,因此请谨慎使用带解释器的内联代码。
./xspawn oneshot -l com.example.py -o /tmp/py.out -- \
/usr/bin/python3 -c "print('hello world')"
KeepAlive 作业,与 launchctl submit 生命周期相同。睡眠 60 秒,以便 CrowdStrike Falcon for macOS 和 launchctl print 仍能看到该进程:
./xspawn submit -l com.example.svc \
-o /tmp/out.log -e /tmp/err.log -- /tmp/helloworld 60
使用 launchctl print 检查(仅作参照;本客户端不会调用它):
launchctl print gui/$(id -u)/com.example.svc
成功时显示 type = LaunchAgent(而非 Submitted)、program 为绝对路径,以及 state = running 或短暂的 xpcproxy。出现 Submitted 表示该作业未走 bootstrap 路径。
清理测试作业:
./xspawn remove -l com.example.svc
加载调用方拥有的 plist(回复后不删除):
./xspawn load -p /tmp/job.plist
<program> 必须是绝对路径。launchd 不会搜索 $PATH。
load -p 要求绝对路径以 .plist 结尾。
-o / -e 可以是相对路径。在写入 plist 之前,会相对于当前工作目录进行解析。省略 -o 和 -e 时默认为 /dev/null。
oneshot 和 submit 在写入临时 plist 之前,会先在 gui 和 user(描述符 708)中探测该标签。标签已被占用时,程序退出并提示 label already loaded,且不输出到 stdout。这一检查的存在是为了让注定失败的 800 不会写入 $TMPDIR/XXXXXX/XXXXXX.plist(一种 DFIR 痕迹;CrowdStrike Falcon 会将该路径保存在 ASEPFilePath 中),也不会打印作业字典的 XML 副本。标签可用时会先打印临时路径,然后打印该 XML,最后发送 800。load -p 对文件的 Label 执行相同的占用检查,然后打印调用方路径和 XML。临时目录在每次退出时都会被删除。remove 按标签操作。
| 代码 | 含义 |
|---|---|
| 0 | Bootstrap 或 bootout XPC 成功 |
| 1 | 用法错误、标签无效、以 root 运行,或 launchd/XPC 拒绝 |
构建主机(make 和测试树;通常与 gui 会话位于同一处)。这些检查并非干净运行时的证明:
make
make test
make test-unit
make test-functional
./xspawn oneshot -l com.example.once -- /tmp/helloworld 0
gui/<uid>。拒绝以 root 运行。不支持其他 UID 目标。sw_vers -buildVersion 发生变化,需重新固定(参见 ARCHITECTURE.md)。$TMPDIR/XXXXXX/XXXXXX.plist($TMPDIR 必须是绝对路径,否则使用 /tmp)。每次退出都会删除该目录。标签已被占用时绝不会创建该文件。ProcessRollup2 事件的自启动扩展点字段(ASEPFilePath)中记录临时 plist 路径。进程父进程仍为 launchd。xspawn 会以本客户端的形式可见:shell 历史记录,以及针对该二进制的 EDR 进程事件。CrowdStrike Falcon for macOS 会记录完整的 CommandLine,其中包含程序路径及其参数。当该镜像和 argv 具有辨识度时,请将客户端编译进其他工具中。嵌入并不会消除 ASEPFilePath 或 launchd.log 中的 bootstrap 行(参见 ARCHITECTURE.md 的 Parentage 部分)。/private/var/log/com.apple.xpc.launchd/launchd.log 中记录该 spawn(参见 ARCHITECTURE.md 的 Parentage 部分)。launchd 是一个 Mach bootstrap 服务器。本客户端不使用公开的 XPC(xpc_connection_create)。它通过 xpc_pipe_create_from_port(bootstrap_port, 4) 在继承的 bootstrap_port 上打开一条私有 libxpc 管道,然后发送 _xpc_pipe_interface_routine。这些符号位于 libxpc 中,不在 SDK 头文件里。
例程 ID 是描述符参数,而不是请求字典中的键。在 macOS 26.6.1 build 25G76 上,load 是描述符 800,bootout 是 801。接口标志为 6。需要 gui/<uid> 会话:只有在 Aqua 登录会话内,继承的端口才是 gui launchd 域,且本客户端仅发送 type 8 且 handle = uid。
Load(800)是一个 XPC 字典。作业定义不在消息主体中。
handle uid (uint64)
type 8 (gui)
paths [absolute .plist]
by-cli true
launchd 对路径执行 stat,解析 plist,然后通过 posix_spawn 启动 xpcproxy。xpcproxy 在同一 PID 内 exec 该程序。成功的标志是管道返回 0、无 xpc-fault、error 为 0、bootstrap-error 为 0。
Bootout(801)按标签进行:handle、type 8、name、no-einprogress、wait。不涉及 plist。
该通道已有先例。Jonathan Levin(launjctl,2015 年;Mac OS X and iOS Internals 第 1 卷)表明 launchctl 通过私有 XPC 管道与 launchd 通信,并记录了带有字典键 type、handle、subsystem、routine 和 name 的 xpc_pipe_create_from_port / xpc_pipe_routine。Patrick Wardle(The Art of Mac Malware 第 2 卷)记录了作为后来发送入口的 _xpc_pipe_interface_routine。Csaba Fitzl 和 Brandon Dalton(OBTS)梳理了相同的字典族和域类型码(gui 为 8)。公开代码片段已经使用了 xpc_pipe_create_from_port(bootstrap_port, 4)。
这些文章描述的是该协议的大类,并未提供 25G76 上实时的 load 常量。Levin 2015 年的抓包将 subsystem 和 routine 放在字典内并使用 xpc_pipe_routine。在 25G76 上这些键不存在。launchctl bootstrap 调用 _xpc_pipe_interface_routine,例程 ID 作为描述符参数。对 launchctl 的 arm64e 静态分析看起来仍是旧的字典路径,并暗示 703 为 load ID。实时的 x86_64 lldb 和一次 arm64 客户端运行均使用 800 / 801,且这些键不存在。本客户端在两个架构切片上都使用这一种形态。
寄存器转储、lldb 重新固定步骤以及父进程字段说明均位于 ARCHITECTURE.md。
| 子命令 |
|---|
| 生命周期 |
|---|
oneshot | 一次性(RunAtLoad + LaunchOnlyOnce) |
submit | KeepAlive |
load | 按原样使用调用方拥有的 plist |
remove | 按标签卸载 |
XPCService 键:否则 xpcproxy 会 fork,且 CrowdStrike Falcon for macOS 会将父进程记录为 xpcproxy。