Building a macOS Implant: What Works, What Doesn't, and What Apple Breaks on Purpose
Porting a C2 agent to macOS sounds like a straightforward project. The kernel is Unix-based, the network stack supports POSIX sockets, and most of the BOF logic is plain C. In practice, Apple’s security architecture — SIP, TCC, Gatekeeper, hardened runtime, notarization — creates constraints that don’t exist on Windows or Linux. Some of these constraints can be worked around. Others fundamentally change what a macOS implant can and can’t do.
This post covers the design decisions we made building Krait’s macOS agent, the techniques that work reliably, and the tradeoffs we accepted.
TLS Without OpenSSL
On Linux, Krait links against OpenSSL for TLS. On macOS, we use Apple’s SecureTransport framework instead. The reason isn’t preference — it’s operational.
An OpenSSL dependency on macOS means either statically linking a 2MB library (which bloats the binary and includes recognizable symbol strings) or depending on a system installation that may not exist. macOS hasn’t shipped OpenSSL since High Sierra. Homebrew installs it, but an implant that requires brew install openssl isn’t an implant — it’s a deployment problem.
SecureTransport is Apple’s native TLS implementation. It’s present on every macOS version, requires no external dependencies, and the API calls look like legitimate application behavior because they are — every macOS application that makes HTTPS connections uses the same framework. An EDR that monitors TLS library loading sees the same framework being used by Safari, Mail, and every other native app.
The downside is API complexity. SecureTransport’s session management is more involved than OpenSSL’s — certificate verification callbacks, manual read/write functions wired to the socket, and Apple-specific error codes. But the result is a macOS binary with zero external dependencies that makes TLS connections through the same path as every native application on the system.
Keeping WiFi Alive: IOKit Power Assertions
macOS aggressively power-manages network interfaces. When the display sleeps, the OS can disable WiFi to save power. For a C2 agent that needs to beacon during idle periods — which is most of its lifetime — losing network connectivity during display sleep is a critical problem.
The solution is IOKit power assertions. macOS applications can declare power assertions that tell the system “I need this resource to stay active.” Krait creates two assertions: one that prevents the system from going to idle sleep, and one that specifically keeps the network interface active during display sleep.
The assertion name is important. macOS exposes active power assertions through system utilities and the Activity Monitor. An assertion named krait-keep-alive would be immediately suspicious. Krait names its assertion to match a system daemon that legitimately holds similar assertions — the kind of name that an administrator would glance at in Activity Monitor and not question.
Power assertions are the correct macOS mechanism for this. The alternative — periodically waking the system with a timer — is more visible, less reliable, and doesn’t prevent the WiFi interface from being powered down during the sleep interval.
Process Spawning: DYLD_INSERT_LIBRARIES
On Windows, process injection typically involves CreateRemoteThread or thread hijacking in a remote process. On Linux, Krait uses ptrace with memfd_create to inject into a running process. macOS has its own injection primitive: DYLD_INSERT_LIBRARIES.
This environment variable tells the dynamic linker to load a specified dylib into a new process before the application’s own code runs. The Krait agent is compiled as a dylib, and spawning a new agent means launching a legitimate application with this variable set.
The critical constraint is SIP. System Integrity Protection strips DYLD_INSERT_LIBRARIES from any process that loads a SIP-protected binary. This means the technique only works with user-installed applications — browsers, editors, Electron apps, anything in /Applications that isn’t an Apple system binary. The loader dylib needs an ad-hoc code signature to be loaded by the linker at all.
In practice, this is a workable constraint. Most target machines have user-installed applications, and the technique is reliable as long as the operator picks an appropriate host process. The Safari browser is off-limits (it has hardened runtime entitlements that strip DYLD variables), but most third-party applications work.
For environments where SIP is disabled — which is uncommon in production but not rare in development environments — Krait falls back to task_for_pid injection, which provides traditional process injection into any running process via Mach port manipulation.
Persistence: Cron and Shell Profiles
macOS offers several persistence mechanisms. LaunchAgents and LaunchDaemons are the most documented, but they’re also the most monitored — every macOS EDR and MDM solution watches ~/Library/LaunchAgents/ and /Library/LaunchDaemons/ for new plist files.
Krait uses two lower-profile mechanisms: cron jobs and shell profile injection.
Cron is the classic Unix scheduler, and it works on macOS. The persist-cron BOF installs a crontab entry that executes the agent on a schedule. One macOS-specific quirk: the crontab command prints “no crontab for
Shell profile persistence writes a launch command to .zshrc or .zprofile. Since macOS Catalina, the default shell is zsh, not bash. Krait targets the zsh profiles and creates them if they don’t exist. The agent launches whenever the user opens a terminal session. This is stealthier than a LaunchAgent because shell profile files aren’t typically monitored by endpoint security products, but the tradeoff is that persistence only triggers when the user opens a terminal — it won’t survive a reboot unless the user opens a shell.
The two mechanisms complement each other. Cron provides reboot persistence with a predictable schedule. Shell profiles provide session-based persistence that flies under the monitoring radar. An operator can deploy both for redundancy.
BOF Parity: What Crosses Over and What Doesn’t
Krait’s macOS agent ships with 91 BOFs — the subset of the Linux BOF library that works on macOS plus macOS-specific overrides. The Linux agent has 107.
The 16 Linux-only BOFs are filtered because they depend on Linux-specific interfaces: cgroup manipulation, container escape techniques, systemd unit installation, /proc filesystem parsing for credential extraction, and kernel module enumeration. These interfaces don’t exist on macOS, and the underlying concepts don’t have macOS equivalents.
Everything that uses POSIX APIs — file operations, network enumeration, SSH operations, process listing, environment inspection — crosses over unchanged. The shared bof_exec.h header abstracts the platform differences (output formatting, memory allocation, string handling) so that a BOF written in standard C compiles for both platforms without #ifdef blocks.
Two BOFs have macOS-specific overrides. persist-cron handles the “no crontab” edge case described above. persist-bashrc targets .zshrc and .zprofile instead of .bashrc and .profile, and creates the files if they don’t exist — a condition that’s common on fresh macOS installations where the user has never customized their shell environment.
The TCC Problem
Transparency, Consent, and Control is macOS’s permission framework for sensitive resources: camera, microphone, contacts, location, full disk access, and — critically for C2 operations — the local network.
When an application makes its first connection to a local network address, macOS displays a system prompt asking the user to allow or deny local network access. This prompt fires for LAN connections — addresses that resolve to private RFC 1918 ranges. It does not fire for connections to public internet addresses.
The operational impact is straightforward: a macOS agent pointed at a LAN C2 server (a common lab or internal assessment setup) will trigger a visible popup on first connection. An agent pointed at an internet-facing redirector — CloudFront, Azure Front Door, or any public domain — connects silently.
This is one more reason to use CDN redirectors for macOS targets. The redirector isn’t just hiding the C2 server’s IP — it’s preventing a system prompt from appearing on the target’s screen.
Universal Binary
Krait’s macOS agent ships as a universal binary — a single Mach-O file containing both arm64 (Apple Silicon) and x86_64 (Intel) code. The system automatically selects the correct architecture at load time. This means one agent binary works on every Mac currently in deployment, from 2012 Intel MacBooks to the latest M-series machines.
The alternative — shipping separate binaries per architecture and guessing which one the target needs — adds operational friction and doubles the chance of deploying the wrong binary. A universal binary eliminates this class of error entirely.