If you’ve been working primarily on Linux and you suddenly land on a Mac during an engagement, the prompt looks familiar enough that you’ll do half an hour of work before you realize you’re in a different world. macOS is Darwin underneath — XNU kernel (Mach plus BSD), a BSD userland, and several Apple-only security layers on top — and almost every Linux assumption about what root can do is wrong here. This is the list of differences that tripped me up early and that I now check first.
Darwin and BSD userland#
The first trap is that coreutils aren’t coreutils here. macOS ships BSD versions of most tools, and BSD flags differ from GNU flags in small, infuriating ways.
sed -i 's/foo/bar/g' file— fails. BSDsedrequires an explicit backup-extension argument:sed -i '' 's/foo/bar/g' file.ps auxworks, but the BSD-styleps -ax -o pid,user,commis the more idiomatic invocation.netstat -pfor process-to-port is broken on macOS —netstatexists but doesn’t show owning processes. Uselsof -iTCP -sTCP:LISTEN -n -P.grep -P(Perl regex) is not in BSD grep. Use-Efor extended, or install GNU grep via Homebrew if you really need PCRE.readlink -fdoesn’t exist on stock macOS. Userealpath(which itself behaves differently) or installcoreutils(greadlink,grealpath, etc.).
The annoying part is that GNU-isms tend to produce wrong output silently on BSD tools rather than failing with a clear error. A sed -i script that runs on macOS will create a backup file named after your sed expression and you’ll never notice until something downstream breaks.
SIP and TCC#
On macOS, root is not god. Apple has two separate guardrail systems layered on top of POSIX, and you need to understand both before you do anything privileged.
SIP (System Integrity Protection)#
SIP locks down /System, /bin, /sbin, /usr (excluding /usr/local), and several other paths so that even root can’t modify them. It also restricts kernel extensions and the task_for_pid() Mach call against Apple-signed processes, unless your binary carries the right entitlements (which only Apple can grant).
- Check status:
csrutil status. - Practical implication: you can’t drop a kext or rootkit into
/System/Library/Extensions. Your writable spots for persistence are/Library,~/Library, and/usr/local.
TCC (Transparency, Consent, and Control)#
TCC is the prompt-driven permission system for Documents, Desktop, Downloads, Camera, Mic, Full Disk Access, and Accessibility (which is roughly equivalent to keylogging access). Even with a root shell, accessing a TCC-protected resource will either fail with Operation not permitted or — worse for OPSEC — pop a GUI consent prompt on the user’s desktop.
Where TCC stores its state:
- System-wide:
/Library/Application Support/com.apple.TCC/TCC.db(SIP-protected). - Per-user:
~/Library/Application Support/com.apple.TCC/TCC.db.
Both are SQLite. You can read them from outside the sandbox sometimes; you generally cannot edit them without first getting Full Disk Access — which requires either an FDA-granted process (like a properly configured Terminal.app) or disabling SIP.
The practical strategy is to inherit, not to bypass. If Terminal.app, iTerm2, or another GUI app has TCC entitlements the user already approved, commands launched from inside that app inherit those rights. Compromising the app’s parent process tree is usually easier than fighting TCC head-on.
The Keychain#
macOS stores user passwords, SSH keys, certificates, and tokens in the Keychain. If you can read it, you’ve usually won that account.
# Pull a generic password by service name
security find-generic-password -ga "TargetName"security command triggers a GUI prompt asking the user to allow access. That prompt is the moment you get caught. Confirm the Keychain is already unlocked, or work from inside a process that’s already trusted.The keychain files themselves live in ~/Library/Keychains/. With the master key (or the login password on a logged-in machine), tools like Chainbreaker can parse them offline. The system keychain at /Library/Keychains/System.keychain needs root.
Persistence via launchd#
cron still exists on macOS but is deprecated. launchd is what modern persistence uses. Two flavors:
- LaunchDaemons at
/Library/LaunchDaemons— run as root, at boot, regardless of user. System-wide. - LaunchAgents at
~/Library/LaunchAgents— run as the user, at login. Per-user.
There are also /Library/LaunchAgents (run for every user at login) and /System/Library/LaunchDaemons (system-shipped, SIP-protected). You don’t write to the System versions.
A minimal agent plist:
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.apple.update.helper</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/python3</string>
<string>/Users/Shared/payload.py</string>
</array>
<key>RunAtLoad</key>
<true />
</dict>
</plist>launchctl load -w ~/Library/LaunchAgents/com.apple.update.helper.plistOne caveat on Ventura (13) and later: when a LaunchAgent is installed, the user gets a “background item added” notification from System Settings. The notification stays in the Login Items panel until acknowledged. Worth knowing before you install one on a user’s box thinking they won’t notice.
Living off the land: JXA and AppleScript#
macOS has Open Scripting Architecture (OSA), an automation layer that lets you script most applications. The two front-end languages are AppleScript and JavaScript for Automation (JXA). JXA is the more useful one for offensive work because it can bridge into the Objective-C runtime.
JXA via the ObjC bridge#
This runs as a script and never drops a binary:
// osascript -l JavaScript payload.js
ObjC.import("Foundation");
var fm = $.NSFileManager.defaultManager;
var data = fm.contentsAtPath("/etc/passwd");
var str = $.NSString.alloc.initWithDataEncoding(data, $.NSUTF8StringEncoding);
console.log(ObjC.unwrap(str));You’re calling Cocoa APIs from JavaScript. Practically anything Objective-C can do is reachable.
AppleScript phishing#
Native-looking credential prompts are a one-liner:
osascript -e 'display dialog "Software Update requires your password to continue:" default answer "" with icon file "System:Library:CoreServices:Software Update.app:Contents:Resources:SoftwareUpdate.icns" with hidden answer'The dialog renders pixel-identically to a system credential prompt. The phishing success rate is then a question about your social engineering, not your tooling.
The quarantine xattr (Gatekeeper)#
Files downloaded by Safari, Mail, Messages, AirDrop, and other Apple apps get the com.apple.quarantine extended attribute. On execution, that triggers Gatekeeper and Notarization checks, which means a “this file was downloaded from the internet, are you sure” prompt at best and a hard block at worst.
curl and wget don’t add the xattr. So a payload pulled via curl http://... runs without the quarantine prompt. Things downloaded as a .zip and extracted via Finder usually inherit it, though, so the source of every file in your operation matters.
# Show xattrs (the "@" after permissions indicates extended attrs)
ls -la@ payload
# Strip quarantine
xattr -d com.apple.quarantine payloadIf you find yourself executing a binary you brought in via a Mac GUI app, strip the xattr first.
Post-exploitation toolkit#
A short list of tools and one-liners worth knowing on a freshly compromised Mac:
- Mythic (with Poseidon or Apfell agents) — current generation Mac C2. Poseidon is Go, Apfell is JXA.
- SwiftBelt — host enumeration written in Swift, designed to avoid command-line logging that catches
bash/zshrecon. - Basic recon one-liners:
system_profiler SPSoftwareDataType— OS version, build, kernel.sw_vers— quicker OS version.dscl . list /Users | grep -v '^_'— local users without service accounts.scutil --proxy— proxy config (sometimes leaks credentials in PAC URLs).defaults read /Library/Preferences/com.apple.loginwindow— autologin settings.
Closing#
The hard part about coming to macOS from Linux isn’t the syntax. POSIX is still POSIX. The hard part is that Apple has layered a separate trust model on top — entitlements, TCC consent state, Mach-port restrictions — and a lot of operations that would just work as root on Linux fail silently here because some entitlement check you can’t see refused. The first thing I do on a new Mac engagement is map what’s already trusted: which TCC permissions the user has granted to which apps, which LaunchAgents are already running, which processes have entitlements I can inherit. That mapping is usually the whole engagement.