macOS.ZuRu Resurfaces | Modified Khepri C2 Hides Inside Doctored Termius App

First noted by a Chinese blogger in July 2021, macOS.ZuRu is a backdoor that was initially delivered through poisoned web results on Baidu. Users searching for the popular Terminal emulator iTerm2 were redirected to a malicious site hosting a trojanized version of the app. Subsequent ZuRu variants used the same model, poisoning Baidu for other popular macOS utilities including SecureCRT, Navicat and Microsoft’s Remote Desktop for Mac. The selection of trojanized apps suggested the malware authors were targeting users of backend tools for SSH and other remote connections utilities.

In January 2024, researchers at JAMF discovered pirated macOS apps using similar technical indicators, but now leveraging the open-source Khepri C2 framework for post-infection operations. In late May 2025, a new sample trojanizing the cross‑platform SSH client and server‑management tool Termius came to light on social media.

This recent sample uses a new method to trojanize legitimate applications as well as a modified Khepri beacon. In this post, we provide a technical analysis of this latest version of the malware along with new technical indicators to aid detection engineers and threat hunters.

Trojanized Application Bundle

The malware is delivered via a .dmg disk image and contains a hacked version of the genuine Termius.app. The legitimate version of Termius comes on a disk image of around 225MB, whereas the trojanized version is somewhat larger at 248MB due to the malicious binaries that have been added.

The legitimate Termius (top) and the trojan (bottom) with two extra binaries
The legitimate Termius (top) and the trojan (bottom) with two extra binaries

Since the application bundle inside the disk image has been modified, the attackers have replaced the developer’s code signature with their own ad hoc signature in order to pass macOS code signing rules.

Aside from the modified signature, the trojan adds two executables to the embedded Termius Helper.app. The legitimate Termius Helper binary of around 248KB is replaced and renamed .Termius Helper1. On execution, the implanted Termius Helper binary – a massive 25MB Mach-O – launches both the malware loader, .localized, and .Termius Helper1, to ensure the parent application behaves as expected for the user. In the background, .localized downloads a further binary from download.termius[.]info and writes it to /tmp/.fseventsd. This download is a Khepri C2 beacon.

While the use of Khepri was seen in earlier versions of ZuRu, this means of trojanizing a legitimate application varies from the threat actor’s previous technique. In older versions of ZuRu, the malware authors modified the main bundle’s executable by adding an additional load command referencing an external .dylib, with the dynamic library functioning as the loader for the Khepri backdoor and persistence modules.

Persistence via LaunchDaemon

On execution, the .localized binary reaches out to download.termius[.]info and requests elevated privileges from the user. If privileges are granted, it writes a persistence plist with the label com.apple.xssooxxagent to the domain level folder /Library/LaunchDaemons/.

The persistence module is hard-coded into the .localized binary and is designed to execute a copy of .localized, located at /Users/Shared/com.apple.xssooxxagent, every hour.

The malicious LaunchDaemon dropped by macOS ZuRu
The malicious LaunchDaemon dropped by macOS.ZuRu

To manage the malicious daemon after it has been written to disk, .localized uses the function _writePlistAndStartDaemon() to execute the following commands.

launchctl bootout system/com.apple.xssooxxagent; 
launchctl bootstrap system/com.apple.xssooxxagent; 
sleep 1

The malware also uses the Security framework’s deprecated API AuthorizationExecuteWithPrivileges to request privileges for the persistence module.

Pseudocode for requesting elevated privileges uses a deprecated API
Pseudocode for requesting elevated privileges uses a deprecated API

The function _copySelfToShare() retrieves the current executable’s path using [NSBundle mainBundle].executablePath and checks whether this contains the string /Users/Shared/. If it does not, it copies itself to /Users/Shared/com.apple.xssooxxagent, deleting any file that may already be at the path first.

The .localized’s _copySelfToShare() function is installed as the target of the LaunchDaemon
.localized’s _copySelfToShare() function

The .localized Loader and md5 Updater Mechanism

Aside from installing persistence, the loader performs some checking and installation tasks.

The .localized loader’s primary tasks are setup and installation of the malware components
.localized’s primary tasks are setup and installation of the malware components

The _LockManager() function ensures that only one instance of the malware is running by writing a lock file to /tmp/apple-local-ipc.sock.lock. Despite the name, the lock file is not related either to IPC or Sockets. _LockManager() uses flock to obtain an exclusive lock on the file, exiting if another process already holds the lock. If the file lock is successful, the malware writes its own PID to the file.

The _LockManager function ensures only one instance of the malware is running
The _LockManager function ensures only one instance of the malware is running

The _checkFileAndDownloadIfNeeded() function downloads, verifies and executes the second stage payload. It first checks to see if the payload already exists at /tmp/.fseventsd. If not, it retrieves the payload from the C2 at http[:]//download.termius[.]info/bn.log.enc, decrypting it using the hardcoded key my_secret_key. We note that this URL pattern follows closely that seen in earlier versions of ZuRu, for example hxxp://download[.]finalshell[.]cc/bd.log.

If the file does exist, the function calculates its md5 hash and checks the value against one received remotely from a call to http[:]//download.termius[.]info/bn.log.md5. If the values fail to match, a new version of the file is downloaded from the C2. This is likely implemented as an update mechanism, allowing the malware to check if a newer or different version of the payload is available from the C2, but it could also function to ensure the payload hasn’t been corrupted or tampered with.

Interestingly, we see some artifacts in this binary potentially indicating that the malware source code could have been reused from earlier campaigns or at least had earlier development. The _startBackgroundProcess() function, for example, is not called in the code and has almost identical functionality to the _runfile(_maziFilePath) function. The latter, which is called by _main, takes a file path argument, /tmp/.fseventsd, then forks and executes the payload. The _startBackgroundProcess() function does much the same thing, except it takes no argument and instead has the filepath /tmp/check_id hardcoded into an otherwise identical function body. This filepath was not used when executing the malware and appears to have no cross references to it other than from the redundant _startBackgroundProcess() function.

Similarly, the code contains an earlier version of the _writePlistAndStartDaemon() function called _startLaunchDaemon. This latter function is never called and uses the kickstart argument rather than the bootout/bootstrap arguments with launchctl, as we noted in the previous section.

Modified ZuRu Payload Decryption Routine

We note that earlier versions of ZuRu used a custom XOR routine. JAMF researchers described a function which:

“takes an encoded byte, applies a combination of XOR and subtraction operations with the XOR key 0x7a, and produces a decrypted byte. The use of both XOR and subtraction makes the encryption slightly more complex than a simple XOR cipher. The masking with 0xff ensures that all operations stay within the valid range for a byte, accounting for any underflow during subtraction.”

In the newer version we analyzed, the decryption still uses XOR and combines it with both addition and subtraction. However, the single byte XOR key 0x7a has been replaced with the key string, my_secret_key. The decryption function _decryptData() iterates over each byte of the input data and applies four operations in sequence.

The _decryptData() function in .localized
The _decryptData() function in .localized

First, it adds a byte to the input by taking a byte from the key, cycling through the 13 bytes of my_secret_key in turn, and performing a modulo 3 operation. This ensures that the byte to be added will effectively be 0, 1 or 2. Next, to ensure that the result stays within the valid range of 0-255 (i.e., can be stored as a single byte), it adds 0x100 (256) then takes the remainder after dividing again by 0x100. The third step subtracts one of 0, 1 or 2 (modulo 3 again) depending on the byte’s position in the data. Finally, it XORs the result with the original key byte. We can represent the logic more simply in pseudocode as follows.

for (i = 0; i < inputLength; i++) {
    byte = input[i];
    // Step 1: Add a pseudo-random byte from the key
    byte += key[i % keyLength] % 3;
    // Step 2: Normalize with modulo 256
    byte = (byte + 0x100) % 0x100;
    // Step 3: Subtract an offset based on i % 3
    byte -= (i % 3);
    // Step 4: XOR again with the same key byte
    byte ^= key[i % keyLength];
    output[i] = byte;
}

The symmetrical nature of the XOR routine ensures that the same code can be used both to encode and decode the data being transmitted. However, roll-your-own routines like this do not provide true encryption and serve primarily to obfuscate data from automated analysis and detection engines.

Modified Khepri Command & Control Implant

The payload obtained from the C2 is a universal Mach-O around 174KB carrying an ad hoc signature with the identifier beacon_arm64_x86_64. The executable requires Sonoma 14.1 (released in October 2023) or later, indicating that the attackers do not expect their intended targets to be running older versions of macOS.

The payload is a modified Khepri C2 beacon first seen on VirusTotal on 05 December 2024. The embedded strings suggest that the original open source version of Khepri hosted on GitHub has been customized for use specifically with the Termius trojan.

Khepri is a full-featured C2 implant with capabilities for:

  • File transfer
  • System reconnaissance
  • Process execution and control
  • Command execution with output capture
Khepri C2 task list
Khepri C2 task list

The beacon can be launched with either -s or -bd as a flag, or neither. If -s is not used, the code tries to acquire a lock on a file at /tmp/my_unique_process.lock. If the lock fails, the beacon exits; otherwise, it looks for the -bd flag. If -bd is supplied on launch, the beacon calls the runInBackground() function to daemonize the process. If -s is used, the beacon skips checking for the lock file and continues to check for -bd.

The Khepri beacon can run in “skip” mode or “background daemon” mode, or both
The Khepri beacon can run in “skip” mode or “background daemon” mode, or both

The beacon sets a constant heartbeat interval of 5 seconds, somewhat more rapid than the 10 second default in the open source version, and sets the C2 to port 53, a common port used for DNS. It uses the legitimate domain www.baidu[.]com as a decoy while in fact reaching out to ctl01.termius[.]fun. The C2, which resolves to the Alibaba Cloud IP 47[.]238.28[.]21, follows a pattern seen in earlier ZuRu malware artifacts, specifically ctl01.macnavicat[.]com, resolving to 47[.]242.144[.]113 and reported in 2024 by previous researchers.

SentinelOne Singularity Detects macOS.ZuRu

SentinelOne Singularity detects and protects against macOS.ZuRu. When the Protect policy is enabled, all malicious components are prevented from executing on the device. If the policy is set to Detect, SentinelOne Singularity detects macOS.ZuRu’s attempt to install persistence and immediately kills all related processes and quarantines its malicious components.

Conclusion

The latest variant of macOS.ZuRu continues the threat actor’s pattern of trojanizing legitimate macOS applications used by developers and IT professionals. The shift in technique from Dylib injection to trojanizing an embedded helper application is likely an attempt to circumvent certain kinds of detection logic. Even so, the actor’s continued use of certain TTPs - from choice of target applications and domain name patterns to the reuse of file names, persistence and beaconing methods - suggest these are offering continued success in environments lacking sufficient endpoint protection.

SentinelOne customers are protected from macOS.ZuRu; organizations without that protection are advised to review the indicators provided below and the technical details presented above.

Indicators of Compromise

File Paths
/Library/LaunchDaemons/com.apple.xssooxxagent.plist
/Users/Shared/com.apple.xssooxxagent
/private/tmp/Termius
/tmp/.fseventsd
/tmp/apple-local-ipc.sock.lock

Files

SHA-1 Name Description
a7a9b0f8cc1c89f5c195af74ce3add74733b15c0 .fseventsd Khepri C2 Beacon
ace81626924c34dfbcd9a485437cbb604e184426 Termius Helper Trojan Mach-O
de8aca685871ade8a75e4614ada219025e2d6fd7 Termius9.5.0.dmg Trojan Disk Image
fa9b89d4eb4d47d34f0f366750d55603813097c1 .localized
Termius
com.apple.xssooxxagent
Malware Loader

Network Communications
http[:]//download.termius[.]info/bn.log.enc
http[:]//download.termius[.]info/bn.log.md5
ctl01.termius[.]fun
47[.]238.28[.]21