Dissecting the macOS 'AppleProcessHub' Stealer: Technical Analysis of a Multi-Stage Attack

On May 15, 2025, the security research team MalwareHunterTeam (@malwrhunterteam) identified a suspicious file named libsystd.dylib
with low detection—only 2 at the time of posting— which appeared to be an infostealer.
On macOS, infostealers collect private information such as keychain passwords and cryptocurrency wallets, which are then uploaded to an attacker-controlled server.
This particular infostealer is designed to exfiltrate user files including bash history, zsh history, GitHub configurations, SSH information, and the Keychain. A Threat Actor may leverage this information to find IP addresses, hostnames, domain names, or paths to internal resources. This could evolve the threat from an exposure of the individual, to an infiltration of their organization.
MalwareHunterTeam also shared the second-stage bash script that is executed by the initial Mach-O. This second post includes the command and control server, appleprocesshub[.]com,
which is used to download the stealer script, execute it, and then upload the collected data to the command and control server.
Although this results in a straightforward stealer, we noticed some interesting code in the initial Mach-O and felt it would be a good use case to cover.
Four days after the initial discovery, Moonlock Lab posted an in-depth analysis of both the Mach-O binary and its second-stage bash script. Building on their excellent work and MalwareHunterTeam's initial findings, this article provides a comprehensive technical breakdown of the malware's functionality, with particular focus on the initial dropper's operation and how it ultimately executes the data theft script.
In this analysis, we'll cover how the initial downloader works which results in the execution of the bash script covered in the posts above.
Initial Triage
Mach-O SHA256 Hash:
3f86c4cc956a6df5ddfad5d03334ece07e78351dec3ca62390f203f82675e00f
Bash Script SHA256 Hash:
639e824e329c429a53d0e64f3a4f254131443a669da93a59a755fb7171d49745
Starting with the initial Mach-O, although it is named with the .dylib
file extension libsystd.dylib
, analysis reveals that it is not a dynamic library and is compiled for X86_64
.
Taking a closer look at the imported libraries, we can see that this Mach-O is written in Objective-C.
We will start our reverse engineering at the entrypoint function named _start().
_start()
The start()
function simply calls the [Task ccsys]
method and returns 0 when complete.
Let’s continue from the call to the [Task ccsys]
method.
[Task ccsys]
At the start of this function, we have a call to [Task serln]
, which returns the serial number of the endpoint. This is then used for logging by passing to a function called [Task clog:]
which does not show up in the decompilation. A block is then passed to a call to dispatch_after()
which leverages Grand Central Dispatch to queue the block for execution at a specific interval.
The block that was passed to the dispatch_after()
call will execute the function that was renamed to CallToRequest_10000281a()
, which we will cover soon. First, let’s cover how the device serial number is queried via the call to [Tasl serln]
.
[Task serln]
Using Binary Ninja, we can see that there are two calls to this method so we can expect to see the serial number used in more than one place.
This function executes_IOServiceGetMatchingService()
by passing in the return value of _IOServiceMatching("IOPlatformExpertDevice")
, which is used as an argument to _IORegistryEntryCreateCFProperty()
to query the serial number. The method then returns the serial number as an NSString
object. Let’s continue with the call to the block execution by Grand Central Dispatch to cover more of this file’s capabilities.
CallToRequest_10000281a() Block Execution
We renamed this function to CallToRequest_10000281a()
because it loads the pointer that was passed as the only argument adding a +0x20 offset and uses it as self for the call to objc_msgSend()
. This offset is for the Task class and the selector “request” is passed to it. This will indirectly call [Task request]
and will not show up in the cross references section in Binary Ninja.
This can cause some confusion while statically analyzing the file. Searching for “request” and highlighting the [Task request]
symbol, we will see that Binary Ninja does not show any cross references. The symbols that we renamed including the string “request” is a bit of foreshadowing and will be explained later.
We continue our analysis with the call to [Task request]
.
[Task request]
At the start of this call, we can see user defaults are queried for the key “identifier” by leveraging the NSUserDefaults
class.
If this is not found, a new default is created using the string “identifier” as the key and the serial number from the endpoint as the value.
The endpoint serial number is captured by calling the [Task serln]
method we covered previously. This serial number is then passed as the value for the identifier key. This new default configuration is then applied by calling the synchronize method. Here is what this new defaults configuration would look like from a debugging session using a virtual machine:
Following the activity related to user defaults, we can see what appears to be base64 looking core foundation strings being passed to a call to [Task aesd:]
. The screenshot below gives the result away since we changed the variable names but this is one of the more interesting parts of this dropper.
To understand how these core foundation strings are used, we continue our analysis of the call to [Task aesd]
(the selector name does provide a hint about what this is).
[Task aesd]
This method leverages AES for decrypting the strings to build the command and control server used to download the second stage.
There are 3 base64 strings passed to this method for decryption:
umm8pChcGqXHmKhPKLz7AQ==
WnD1BYMsv1hA87nbaMRsyA==
fg94nzBafSnFOdSgX+4Lz0Mqgem4m+Hlji0fIoVRuDI=
These base64 strings are passed to the [Task aesd:]
which are then passed to the [4294983976 (Encry) des12Decry:]
method along with the string “CMKD378491212qwe”
, which will be used as a key for the AES decryption.
This function prepares the encoded strings and the key to be passed to the CCCrypt()
function. The arguments to the CCCrypt()
function indicate that this will be decrypting using AES-128
and since the IV value was set to Null, it would be decrypted in ECB mode. Having these values makes it trivial to decode the strings ourselves with a simple python script that we used on Binary Ninja.
Decoding Script:
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
import base64
b64_ciphertext = ["umm8pChcGqXHmKhPKLz7AQ==","WnD1BYMsv1hA87nbaMRsyA==","fg94nzBafSnFOdSgX+4Lz0Mqgem4m+Hlji0fIoVRuDI="]
ascii_key = "CMKD378491212qwe"
for enc in b64_ciphertext:
try:
key = ascii_key.encode("utf-8")
ciphertext = base64.b64decode(enc)
cipher = AES.new(key, AES.MODE_ECB)
decrypted = unpad(cipher.decrypt(ciphertext), AES.block_size)
print(decrypted.decode("utf-8", errors="replace"))
except Exception as e:
print(f"Decryption failed: {e}")
Running this script, results in these 3 strings being encrypted:
“https://”
“www.”
“appleprocesshub[.]com/v1/resource”
Let’s continue with the rest of the [Task request]
call to see how these unencrypted strings are used.
Once these strings for the URL have been decrypted, they are passed to a call to the stringWithFormat:
method to set up the network communications. We can see the result of this call in the debugging session which also includes the serial number of the virtual machine captured earlier.
Another call to [Task clog:]
is completed and these three strings that make up the domain are passed to multiple stringWithFormat:
method calls to log the strings with the prefix “str.”
Once the call to [Task clog:]
is completed, an NSMutableURLRequest
is created using an NSURL object that was created using the completed command and control server domain.
Another block is set up as a completion handler for this network communication. This block includes a function that we will focus on next which results in the execution of the bytes returned by this GET request.
Let’s continue with the handler next at the address 0x100001bd0
.
C2NetHandler_100001bd0()
If we look closer at the handler of this network connection, we can see the block which includes the function that is invoked at the address 0x100001bd0
.
Below is what we would see as a return when the domain is no longer up which was the case at the time of analysis.
After the network communication, we can see how the function handles the response if there is no response data at the start. Using the [Task clog:]
call, a failure message is initialized and saved. Following this call, we see another block set up to once again indirectly call [Task request]
which is passed to another call to dispatch_after()
.
Let’s look at how the response data is used. The response data is passed as an argument to the method [NSString initWithData:encoding:]
, which returns the data as an NSString object.
The use of [Task clog:]
is also used here, which is again not shown in the decompilation to write this log of the result. Here is the call in the disassembly.
After the check from the response, there is another check for the size using a call to the length method.
If the length is less than or equal to 9 bytes, another block is set up to once again call [Task request]
which then passes this block to another dispatch_after()
call.
Let’s see what happens if the bytes are greater than 9 bytes.
The returnData is passed to the [Task aesd:]
function to decrypt the bytes using AES as we covered previously. These decrypted bytes are then passed to [Task dictionaryWithJsonString:]
method. This function returns a dictionary from the JSON data. The dictionary object is then parsed for 3 keys: time, enable, and sign. The integer value of the time key is captured, the bool value of the enable key is also captured. There is then a check to see if the enable key is set to False which will then set up another block to execute [Task request]
using dispatch_after()
. If enable is set to True, then we see the execution of a method called [Task rsc:completeBlock:]
, which will set up an NSTask
. We’ll cover this call next.
[Task rsc:completeBlock:]
The goal of this function is to set up another block and execute the response from the network connection via an NSTask
.
The response is passed to the call to NSTask
. Let’s look at this next.
The NSTask
is set up, setting the path to “/bin/sh”
and the first argument to “-c”
and what we don’t see is the next part of the argument which would be executed. For that we need to look at the disassembly.
At the start of this function, we can see the rdi
register moved to r14 and later used to move the offset of +0x20
to the register rcx
which is passed as the second object to the method call [NSArray arrayWithObjects:]
to initialize the NSArray
which is then passed to the call to [NSTask setArguments:]
. This tells us that this Mach-O can handle the execution of any script that is hosted on the command and control server. In this specific case, there is only one script related to this specific command and control server: fSidEOWW.sh
. This script is readable and has been covered already but to summarize, it targets specific files including bash_history
, zsh_history
, gitconfig
, /etc/hosts
, .ssh
, Login.keychain-db
, zips the results, and then uploads to the command and control server.
Conclusion
This is an example of a Mach-O written in Objective-C which communicates with a command and control server to execute scripts. What was most interesting was the use of Grand Central Dispatch throughout the sample, the indirect calls to [Task request],
which affects any cross references when statically analyzing this sample, and simple decryption logic using AES. While the command and control server was offline at the start of this analysis, the malware still could execute other scripts.
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.