Introduction
In 2023, our Positive Technologies Computer Security Incident Response Team (PT CSIRT) discovered that a certain power company was compromised by the Decoy Dog trojan. According to the PT CSIRT investigation, Decoy Dog has been actively used in cyberattacks on Russian companies and government organizations since at least September 2022. This trojan was previously discussed by NCIRCC, Infoblox, CyberSquatting, and Solar 4RAYS.
However, the sample we found on the victim’s host was a new modification of the trojan, which the adversaries altered in such a way as to make it harder to detect and analyze.
As far as we can tell, the APT group Hellhounds that uses Decoy Dog only targets organizations located in Russia. Remarkably, the attackers were using the command-and-control (C2) server maxpatrol[.]net to impersonate Positive Technologies MaxPatrol products. Positive Technologies products contain all indicators of compromise mentioned in this article in their databases.
First Stage (Decoy Dog Loader)
When investigating the incident, we found a 9 KB executable on path /usr/bin/dcrond. It was protected by a modified version of the UPX packer, with the signature UPX! replaced with 37 13 03 00. At the moment of our investigation, only one antivirus engine could detect the packer, while some malware samples were not detectable by any engine. The modified UPX can be detected by a public YARA rule from the JPCERT/CC research.
Unlike the standard UPX tool, which unpacks the executable, this modification unpacks a shellcode that is written in the assembly language and uses only Linux system calls. The modified UPX header is followed by an encrypted configuration that contains the path to the encrypted file with the main payload, and the configuration is followed by the compressed shellcode:
The loader operates in the system and disguises itself as the legitimate cron service. We also discovered samples masquerading as the legitimate irqbalance service and lib7.so library.
[Unit] Description=Daemon to execute scheduled commands Documentation=man:dcrond(8) [Service] Type=forking ExecStart=/usr/bin/dcrond Restart=always
In attacks in 2022, the original malware samples were disguised as the atd service and systemd-readahead-stop.service. The samples were located in the /usr/bin/atd directory or the /usr/bin/container directory:
[Unit] Description=Deferred execution scheduler Documentation=man:atd(8) [Service] Type=forking ExecStart=/usr/bin/atd Restart=always [Install] WantedBy=multi-user.target
[Unit] Description= systemd-redhead is a service that collects disk usage patterns at boot time. systemd-readahead-stop.service is a service that replays this access data collected at the subsequent boot. [Service] Type=forking ExecStart=/usr/bin/container Restart=always [Install] WantedBy=multi-user.target
The loader first checks whether it is being debugged. For this, it reads /proc/self/status and checks that the value of TracerPid is 0. If the TracerPid value is different from 0, the loader replaces itself with /bin/sh using the execve system call.
After ensuring that it is not being debugged, the loader attempts to read each of the following files containing the compromised host’s identifiers and calculates an MD5 hash of the first file existing in the file system:
- /etc/machine-id
- /var/lib/dbus/machine-id
- /var/db/dbus/machine-id
- /usr/local/etc/machine-id
- /sys/class/dmi/id/product_uuid
- /sys/class/dmi/id/board_serial
- /etc/hostid
- /proc/self/cgroup
The loader uses the obtained MD5 hash as a key to decrypt the configuration and then the main payload, which are encrypted using the 128-bit CLEFIA algorithm.
At this stage of our research, it became clear that this malware sample was designed to target a specific host and that the adversaries had previously accessed that host to get the identifier and add it to the configuration.
Second Stage (Decoy Dog)
The main payload of the analyzed malware sample is stored in the file system at /usr/share/misc/pcie.cache. The decrypted payload is a modified version of Pupy RAT known as Decoy Dog.
Pupy RAT is a cross-platform multifunctional backdoor and an open-source post-exploitation tool, mostly written in Python. Pupy supports Windows and Linux and partially supports Android and macOS. It features an all-in-memory execution guideline and leaves a minimal footprint. Pupy RAT can maintain a connection to the C2 server using multiple transports, migrate into processes by leveraging the reflective injection technique, and remotely load Python (.py, .pyc) packets and compiled Python C (.pyd, .so extensions) from memory.
While the development of Pupy RAT stopped two years ago, Decoy Dog is actively being developed. The key improvements in Decoy Dog as compared to Pupy RAT are:
- The client was upgraded from Python 2.7 to Python 3.8, which means all code was rewritten under Python 3.8. This explains why the number of modules was reduced, leaving only those modules that are actually used.
- New features for injecting code into Java virtual machines were added.
- The following new transports were added:
- — BOSH (Bidirectional-streams Over Synchronous HTTP), with combination with ECPV and RC4—instead of HTTP transport
- — lc4 (combination of ECPV and RC4 used for a local client or server over TCP)
- — lws4 (combination of ECPV and RC4 used for a local client or server over WebSockets)
- — ws4 (the same as the original ws, but the RSA and AES combination is replaced by ECPV and RC4)
- — dfws4 (the same as the original dfws, but the RSA and AES combination is replaced by ECPV and RC4)
- A new feature was added to enable encrypted dynamic configuration files to be downloaded and saved to the disk.
- A new launcher called "special" was added (it establishes a local connection using the IP address and port or file socket).
- Fault tolerance was increased by means of backup C2 servers with specific domains defined and the use of DGA.
The analyzed sample used the C2 server z-uid.lez2yae2.dynamic-dns[.]net, which was specified in the configuration included in the executable. Here is a fragment of the configuration:
The trojan also gets the dynamic (current) configuration from the /var/lib/misc/mpci.bin file. The file is encrypted with the 128-bit AES algorithm in Counter (CTR) mode (the 128-bit key is also encrypted using the elliptic curve brainpoolP384r1) and contains new C2 servers:
- m-srv.daily-share.ns3[.]name;
- f-share.duckdns[.]org.
The public key used to decrypt the AES key is stored in the configuration inside the executable.
The configuration of the analyzed sample also contains a scriptlet called "telemetry" which is started each time the backdoor is launched. This scriptlet is used to send telemetry data (information about the infected system) to mindly.social (social media powered by the open-source engine Mastodon) via the service API. Here are the contents of the telemetry data:
{ 'cid': <backdoor ID from the configuration>, 'user': <username>, 'hostname': <host name>, 'node': <MAC address as a 48-bit number>, 'platform': <platform>, 'node': <MAC address as a 48-bit number>, 'pid': <backdoor process ID>, 'ppid': <backdoor parent process ID>, 'cwd': <work directory>, 'proc_arch': <architecture of the running backdoor process>, 'exec_path': <path to the running backdoor process>, 'uac_lvl': <UAC protection level>, 'intgty_lvl': <backdoor process integrity level>, 'machine_key': <MD5 hash of the system ID>, 'proxy': <default proxy server connection string>, 'external_ip': <external IP address as a 32-bit number>, 'internal_ip': <internal IP address as a 32-bit number>, 'boottime': <system boot date and time (Unix time)> }
The transmitted data is encrypted in the same way as the dynamic configuration file and with the same public key. This means that, even if the data is intercepted, it is impossible to decrypt it without knowing the private key.
The data is transmitted using an API key stored in the code in cleartext. However, the adversaries restricted access to the API key by making it read-only. In other words, obtaining the API key will not allow you to read any data.
Nonetheless, we managed to find out that the telemetry data of the infected hosts is sent to the account with the username @lahat, which is where our research got its name.
Apart from being the primary C2 channel, the analyzed sample also functioned as a server using an additional local channel to read data from the file socket /var/run/ctl.socket.
Decoy Dog supports a domain generation algorithm (DGA) that generates domain names (DGA domains) when the connection over the primary C2 channel is lost.
If the bootstrap-domains option is enabled in the configuration, one of the main domains is used for name generation. Otherwise, the malware generates either a subdomain for one of the top-level domains specified in the configuration or a domain under one of the specified zones (the top-level domain dynamic-dns.net is used by default). In the configuration of the analyzed sample, the duckdns.org and dynamic-dns.net domains are selected.
A backup domain is generated as the first half of the hexadecimal representation of the MD5 hash calculated from the string with the current date in
Then, an MD5 hash is calculated from the generated domain (or one of the main domains if the bootstrap-domains option is enabled), after which two characters from the first half of the hexadecimal representation are appended to the left of the domain name. This results in a set of nine domains to which the malware attempts to connect. For example, for the domain m-srv.daily-share.ns3[.]name, the following eight domains will be generated:
- 6cm-srv.daily-share.ns3[.]name
- 78m-srv.daily-share.ns3[.]name
- 7fm-srv.daily-share.ns3[.]name
- b1m-srv.daily-share.ns3[.]name
- 98m-srv.daily-share.ns3[.]name
- d5m-srv.daily-share.ns3[.]name
- 2fm-srv.daily-share.ns3[.]name
- 08m-srv.daily-share.ns3[.]name
This is the code that generates domains:
import datetime, hashlib WELL_KNOWN_ZONES = ('dynamic-dns.net', ) def make_emergency_related_domains(domain): domain_bytes = domain if isinstance(domain_bytes, bytes): domain = domain.decode() else: domain_bytes = domain.encode() prefix_hash = hashlib.md5(domain_bytes).hexdigest()[:16] for x in range(len(prefix_hash) // 2): yield prefix_hash[x * 2:x * 2 + 2] + domain class EmergencyDomains(object): __slots__ = ('key', 'zones', 'beacon_domains', '_zone_id', '_emergency_loop') def __init__(self, key, beacon_domains=None, zones=None): self.key = key self.zones = zones or WELL_KNOWN_ZONES if not isinstance(self.zones, (list, tuple, set)): self.zones = tuple((self.zones,)) self.beacon_domains = beacon_domains self._zone_id = 0 self._emergency_loop = self._emergency_loop_generator() def _emergency_loop_generator(self): if self.beacon_domains: for domain in self.beacon_domains: yield domain yield self._domain_of_the_day() def iterate(self): try: while True: yield next(self._emergency_loop) except StopIteration: self._emergency_loop = self._emergency_loop_generator() def _domain_of_the_day(self): now = datetime.datetime.utcnow() ts_formatted = now.strftime('%Y%m%d') if not isinstance(ts_formatted, bytes): ts_formatted = ts_formatted.encode() formatted_key = self.key if not isinstance(formatted_key, bytes): formatted_key = formatted_key.encode() domain_hash = hashlib.md5() domain_hash.update(ts_formatted) domain_hash.update(formatted_key) domain_part = domain_hash.hexdigest()[:16] zone = self.zones[self._zone_id] self._zone_id = (self._zone_id + 1) % len(self.zones) return domain_part + '.' + zone
Here is a detailed chart showing how Decoy Dog works: