
Запускает программы macOS через частный интерфейс XPC launchd, не выполняя их через exec, из-за чего EDR фиксирует launchd как родительский процесс. Поддерживает одноразовые задания, задания KeepAlive и задания на основе plist.
xspawn запускает программу в macOS через launchd и сам никогда не выполняет exec этой программы. Цель в том, чтобы EDR фиксировал в качестве родительского процесса launchd, а не этот инструмент или вызвавшую его оболочку.
автор: cenobyte [email protected], 2026
https://github.com/cenobyte-vincit/xspawn
xspawn открывает xpc_pipe_create_from_port(bootstrap_port) и выполняет bootstrap запуска программы через _xpc_pipe_interface_routine — тот же приватный XPC-канал, который использует launchctl, — и сам никогда не выполняет exec этой программы.
/bin/launchctl.gui/<uid>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 для 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 для 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 проверяют метку в gui и user (дескриптор 708) перед записью временного plist. При занятой метке происходит выход с сообщением 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). Каталог удаляется при любом завершении. При занятой метке этот файл не создаётся.ASEPFilePath) в событии ProcessRollup2. Родительским процессом остаётся launchd.xspawn виден как этот клиент: в истории оболочки и в событии EDR для этого бинарного файла. CrowdStrike Falcon для macOS записывает полный CommandLine, который включает путь к программе и её аргументы. Встраивайте клиент в другие инструменты, если такой образ и argv могут быть заметны. Встраивание не убирает ASEPFilePath и строку bootstrap в launchd.log (см. ARCHITECTURE.md, Parentage).launchd — это bootstrap-сервер Mach. Этот клиент не использует публичный XPC (xpc_connection_create). Он открывает приватный канал libxpc на унаследованном bootstrap_port с помощью xpc_pipe_create_from_port(bootstrap_port, 4) и затем отправляет _xpc_pipe_interface_routine. Эти символы находятся в libxpc и отсутствуют в заголовках SDK.
Идентификатор процедуры — это аргумент-дескриптор, а не ключ в словаре запроса. В macOS 26.6.1 build 25G76 load — это дескриптор 800, а bootout — 801. Флаги интерфейса равны 6. Требуется сессия gui/<uid>: унаследованный порт является gui-доменом launchd только внутри сеанса входа Aqua, и этот клиент отправляет только type 8 с handle = uid.
Load (800) — это XPC-словарь. Определение задания находится не в теле сообщения.
handle uid (uint64)
type 8 (gui)
paths [absolute .plist]
by-cli true
launchd выполняет stat для пути, разбирает plist и затем запускает xpcproxy через posix_spawn. xpcproxy выполняет exec программы в том же PID. Успех — это возврат канала 0, отсутствие xpc-fault, error 0, bootstrap-error 0.
Bootout (801) выполняется по метке: handle, type 8, name, no-einprogress, wait. Без plist.
Канал уже был описан ранее. Джонатан Левин (launjctl, 2015; Mac OS X and iOS Internals, том 1) показал, что launchctl общается с launchd через приватный XPC-канал, и задокументировал xpc_pipe_create_from_port / xpc_pipe_routine с ключами словаря type, handle, subsystem, routine и name. Патрик Уордл (The Art of Mac Malware, том 2) описал _xpc_pipe_interface_routine как более позднюю точку отправки. Чаба Фитцл и Брэндон Далтон (OBTS) описали то же семейство словарей и коды типов доменов (gui — это 8). В публичных сниппетах уже использовался xpc_pipe_create_from_port(bootstrap_port, 4).
Эти публикации описывают класс протокола. Они не содержат актуальных констант load для 25G76. В перехвате Левина 2015 года subsystem и routine находились внутри словаря, и использовался xpc_pipe_routine. На 25G76 этих ключей нет. launchctl bootstrap вызывает _xpc_pipe_interface_routine с идентификатором процедуры в качестве аргумента-дескриптора. Статический анализ arm64e для launchctl по-прежнему похож на старый путь со словарём и предполагает 703 в качестве идентификатора load. Живой lldb на x86_64 и запуск arm64-клиента используют 800 / 801 без этих ключей. Этот клиент поставляется с этой единственной формой в обоих слайсах.
Дампы регистров, рецепт перепривязки через lldb и заметки о родительском процессе находятся в ARCHITECTURE.md.
| Подкоманда | Жизненный цикл |
|---|
oneshot | Одноразовый (RunAtLoad + LaunchOnlyOnce) |
submit | KeepAlive |
load | plist вызывающей стороны как есть |
remove | Выгрузка по метке |
/private/var/log/com.apple.xpc.launchd/launchd.log (см. ARCHITECTURE.md, Parentage).XPCService в написанный вручную plist: тогда xpcproxy создаст форк, и CrowdStrike Falcon для macOS запишет родителем xpcproxy.