Potential Stealer: Purrglar in Progress
Unlike traditional viruses or ransomware, stealers are designed with a singular purpose: to quietly infiltrate systems and exfiltrate sensitive data—often without the victim even realizing it. These malicious programs are highly focused on gathering personal information, usually to be sold or used for further criminal activity.
Kandji's Threat Research team discovered another potential stealer named kitty that was uploaded to VirusTotal on 1/10/2025. This stealer, which we're calling Purrglar, focuses primarily on capturing Chrome and Exodus wallet-related files. What is most interesting is the use of the Security Framework APIs to query the macOS Keychain.
It is unclear if this application is currently in a development phase since localhost is used as the destination for the captured and uploaded files. That said, it does appear to likely be in a development stage, which means the intention of this potential stealer is unknown as of now. With that in mind, our team's findings are leaning towards this being malware to have on your radar.
In this blog post we will dive into the interesting parts of this potential stealer including how it attempts to access the Keychain for a Chrome key, explore how the Chrome and Exodus files are captured, and how the file uploading via Curl APIs works. Whether you're a seasoned cybersecurity professional or someone looking to stay informed about the latest threats, this discovery is one you’ll want to keep an eye on.
File Analysis
UID() - NSTask
We will begin our analysis at the main()
function. The first function we branch to is called uid()
. This function captures the Serial Number of the device to be used later. Let’s explore how it accomplishes this.
This application is written in Objective-C so we will see the use of objc_msgSend()
for method passing throughout. First an NSTask object is allocated and saved on the stack. This object is then loaded into X0 as the object to pass the selector setLaunchPath:
alongwith the argument of /usr/sbin/system_profiler
. Let’s cover how the objc_msgSend()
call works since it will help with the understanding of the rest of the instructions we cover.
The branch to _objc_msgSend$setLaunchPath:
results in the selector being moved to X1 and the address of objc_msgSend()
is loaded into X16. The br instruction will then branch to the address of objc_msgSend()
that was loaded into X16 and complete the method passing. Every branch to _objc_msgSend$<selectorName>
in this application will work this way. With this, the launchPath of the NSTask has been set to /usr/sbin/system_profiler
. Next we have the call to setArguments:
, which is used to pass an argument to the file set as the launchPath.
We can see the NSTask object is moved from the stack to the X0 register and the argument in X2 is loaded with a pointer to an NSConstantArray that will be used as the argument to the setArguments:
method. Let’s see what is stored at the NSConstantArray.
We can see a count and a pointer to an object. If we click on unnamed_array_storage
, we can see the one member of this array.
__objc_arraydata (REGULAR) section started {0x100004440-0x100004448}
100004440 void* __unnamed_array_storage = cfstr_SPHardwareDataType |
The SPHardwareDataType
CFString is passed to the setArguments:
method. This results in the execution of the command /usr/sbin/system_profiler SPHardwareDataType
which is commonly seen by malware to obtain information about the system. Moving forward, we will skip the remaining NSTask setup methods and focus on what is captured from the output which is accomplished by using the NSScanner object.
The data that was read from the NSTask is used as an argument to the scannerWithString:
method that is passed to the NSScanner object. The result of this is used to search within output for a value in between specific CFStrings.
The "Serial Number (system):“
CFString is used as a search for the value shown after this string in the output before the next space. This results in the serial number value being found. The system serial number is then returned by the uid()
function and saved on the stack to be used later. Let’s continue to the next function called getTimestamp()
.
getTimestamp()
This function’s sole purpose is to query the current timestamp since 1970 using the NSDate object. This timestamp is then returned by the function and saved on the stack to be used later. Let’s go back to the main()
function to see how this timestamp and the serial number are used.
URL setup
Back at the main()
function, the two values captured above are used to help create a URL that is later used with CURL. What is most interesting about this URL is the use of localhost:8000/api
which may indicate that this potential stealer is still in development or a research project. Let’s walk through how this URL is set up.
Starting from the second red bracket above, a pointer to the NSString
object is loaded into X0 preceded by the timestamp and serial number being moved to registers X8 and X10. The address of the stack pointer is then moved into X9. X9 is then used as the destination address of the serial number value. The next value of the timestamp is stored at the offset +0x8 from the address of SP. This indicates that these two values are on the stack next to each other.
The reason for this is we will see that these two values are passed on the stack to a variadic method called stringWithFormat:
which replaces the format strings in the URL with these two values. We can see the CFString for the URL is loaded into X2 and passed as an argument. This results in the URL used later and would look like this:
http://localhost:8000/api/{SerialNumber}/{timeSince1970}
The use of Security framework API’s are what we will focus on next in a function called getEncryptionKey()
.
getEncryptionKey()
This function results in prompting the user for their consent to obtain the Chrome key from the Keychain that would be used to access sensitive Chrome-related files. Apple describes how best to complete keychain searches in this article, which matches what is used here. Let’s walk through how the Keychain query works. First let’s start with a look at how this function looks from a decompilation perspective.
Within this function, we can see the use of several kSec*
symbols that we can use to further our understanding. At the start of this function, we see the use of the CFStrings “Chrome Safe Storage”
and “Chrome”
. These are the values that are used to query the keychain. “Chrome Safe Storage”
will be used as the kSecAttrService
value and “Chrome”
will be used as kSecAttrAccount
. The kSecClass
is passed the string kSecClassGenericPassword
. On installation, Chrome adds a kSecClassGenericPassword
key to the keychain for “Chrome Safe Storage”
. These symbols are used to create a query as a dictionary. Below is an example of creating a query using Swift:
let passwordQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, //”Chrome” kSecAttrAccount as String: account, //”Chrome Safe Storage” kSecReturnData as String: true ] |
This query is then executed by the SecItemCopyMatching()
function which accepts the query as an argument; this leads to a prompt for the user to allow access to the Keychain like the one shown below.
If the user inputs their password, the stealer activity will continue, however, if the user clicks the “Deny” button, another prompt will be shown to the user specifying that they should enter their password as shown below.
This function returns a 0 if not successful otherwise it returns the key. Let’s dig into how the Error prompt is set up if the user denies the keychain access prompt.
Error NSAlert
Back in the main()
function, there is a check the return value of the getEncryptionKey()
function. If the return value is not 0, then the stealer activity continues, which we will cover next. If the capturing of the key was unsuccessful, then an NSAlert
is created. The function called alert()
handles this prompt creation. Let’s dig into it.
Before the branch to the alert()
function, we can see the arguments loaded into X0 (“Error”) and X1 (“Please enter password”) to be passed to this function.
After the NSAlert object reference was stored on the stack, we can see a call to setMessageText:
which accepts the value that was passed in arg1 as the argument, which in this case was the CFString “Error”.
Next we have the NSAlert object being passed the setInformativeText:
method which accepts the value passed in as arg2, which was the CFString “Please enter password”.
Lastly, the button for “OK” is passed as an argument to the addButtonWithTitle:
method which sets up the last part of the NSAlert. This NSAlert is then executed with the runModal()
method. This matches the screenshot above showing the NSAlert that this function generates. The consent prompt to enter a password for access to the keychain is part of macOS and it appears that the authors of this application created this NSAlert prompt to attempt to encourage the user to enter a password. Now that we have covered this “Error” prompt, let’s go back to the main()
function and continue the analysis of the stealer related components.
Stealer Activity after Keychain Access Granted
Continuing after successful completion of the getEncryptionkey()
function, we begin at the address 0x100002ed8
.
These instructions set up an NSString “1” that is used to append to the local host URL we covered earlier.
The local host URL object is passed the stringByAppendingFormat:
method using /%@ as the argument and the value of “1” that was passed on the stack as the format string. This results in a local host URL like this:
http://localhost:8000/api/{SerialNumber}/{timeSince1970}/1
Next, there is a call to a function called sendGet()
that accepts the previously created URL path as an argument. This function sets up the Curl related part of this potential stealer. Let’s cover this function before continuing with the remainder of the main()
function.
sendGet()
The decompilation of this function is easy to read so we will use that for this section.
First, the local host URL is saved using the obj_storeString()
function. Next we see the curl_easy_init()
function which returns a handle that is used for the remaining CURL functions. The call to curl_easy_setopt()
function sets the CURLOPT_URL
to the local host URL and curl_easy_perform()
function initiates the CURL session. Next, there is a check for the CURL session which would print that this function failed if it was unsuccessful, otherwise it sets the return value to 1. Let’s continue with the main()
function to see what is uploaded to this local host URL.
Continuing the Stealer Activity
There are multiple calls to a function called sendFile()
, which accepts two arguments: the destination path and the object being sent. Let’s walk through how this works.
We see the localHostURL being moved to the X0 register as the object used to pass the stringByAppendingFormat:
method to. The argument passed to this method is the CFString “/chrome_cookies/%@”
with the Chrome keychain item used as the format string that was passed on the stack. This will return a path that looks like this:
http://localhost:8000/api/{SerialNumber}/{timeSince1970}/1/chrome_cookies/{Chrome key}
This URL path is then used for the next set of instructions we cover.
The URL path including the Chrome cookies is saved on the stack to be passed to the sendFile()
function. Before the branch to the sendFile()
function, we see a CFString “~/Library/Application Support/Google/Chrome/Default/Cookies”
is used with a call to stringByExpandingTildeInPath
to return the path to the Chrome cookies for the user. This path on the system and the URL path including Chrome cookies are both then passed to the sendFile()
function. Before we cover how the sendFile()
function works, let’s take a look at the other files and URL paths that are passed to the sendFile()
function.
The next URL path that is appended to the local host URL is “/chrome_passwords/%@”
, which also passes the Chrome key captured from the keychain. The path to the Chrome Login_Data file for the user is then set up and passed to the sendFile()
function along with the URL path. Let’s continue to the next file and URL path that are passed to sendFile()
.
We see the passphrase.json CFString passed to the stringByAppendingFormat:”/exodus/%@”
call.
~/Library/Application Support/Exodus/exodus.wallet/passphrase.json
This would be used to save the Exodus passphrase file captured from the system and sent to the URL path. These two values are then passed to the sendFile()
function. Let’s continue with the next set up.
Similar to what we have seen above, a URL path for exodus/seed.seco is set up and passed to the sendFile()
function with the seed file for the Ecodus wallet:
~/Library/Application Support/Exodus/exodus.wallet/seed.seco
The last set up to be sent to the sendFile()
function is for the Exodus storage.seco file. The file path used is “~/Library/Application Support/Exodus/exodus.wallet/storage.seco”
.
Now that we have covered all the files and the corresponding URL paths that are passed to the sendFile()
function, we will focus on the function next.
sendFile()
Let’s conclude this blog post with how the uploading of the captured files works. The decompilation of this function will assist in our understanding of how the uploading works.
We see the arguments passed to this function are saved to be used as arguments to other function calls including Curl APIs. There is a call to [NSFileManager defaultManager]
to work with the file system and locate the file path that was passed to this function. What we don’t see in the decompilation is that the fileExistsAtPath:
is actually passed the file path (like storage.seco) as the argument. If the file exists, a curl_mime
struct is created to upload the file as a mime object. Using a mime object like this allows you to send multipart/form-data (used for file uploads or sending form data with different content types) via HTTP. The curl_mime_filedata()
function accepts the pathToFile for upload. Next, we see the curl_easy_setop()
functions passed the destination URL (localhost with the path appended) and the mimepost set up. This Curl session is then executed with the curl_easy_perform()
function. These instructions are executed for each of the file paths we covered above to upload the files to the corresponding URLs. If the uploading is successful, another NSAlert is created that includes the string “Success”, which we won’t cover.
Conclusion
As was mentioned at the beginning of this blog post, we are not sure of the intentions of this application as of yet. It is also unclear if this may be a potential stealer that is currently being developed; however, there are some interesting behaviors within this application that we felt would be helpful to cover if this evolves into something more or if another stealer in the future leverages similar instructions.
IOCs
SHA256:
33f0387ea327203ce9c38289d14cf26c14fe24862440b525a9de320111c7a0c3
Curl Destination:
“http://localhost:8000/api/%@/%ld"
File Paths Queried:
“~/Library/Application Support/Google/Chrome/Default/Cookies”
“~/Library/Application Support/Google/Chrome/Default/Login Data”
“~/Library/Application Support/Exodus/exodus.wallet/passphrase.json”
“~/Library/Application Support/Exodus/exodus.wallet/seed.seco”
“~/Library/Application Support/Exodus/exodus.wallet/storage.seco”
See Kandji in Action
Experience Apple device management and security that actually gives you back your time.
See Kandji in Action
Experience Apple device management and security that actually gives you back your time.