At the beginning of the year, we investigated a cluster of Ivanti Connect Secure gateways that attackers had breached via CVE-2025-0282. If you missed the story, Mandiant’s write-up laid out a polished, multi-stage operation that combined code redirection, web-shell deployment, and meticulous clean-up. Last week, Florian Roth pointed us to a follow-up from JPCERT/CC that zeroes in on an ELF-based remote-access Trojan, dubbed DslogdRAT (1dd64c00f061425d484dd67b359ad99df533aa430632c55fa7e7617b55dab6a8), which surfaced in several of those same Ivanti intrusions. Because the sample was published, we set out to reproduce (and extend) JPCERT/CC’s findings.
What follows is a hands-on tour, built in IDA Pro and radare2, that walks through initial triage, full configuration decoding—both static and live—and a peek at the RAT’s networking logic. All kudos to JPCERT/CC for the solid groundwork and the elegant behaviour diagram that guided our dive.
Initial RAT Triage
The sample under the microscope is a 970-KiB, 64-bit ELF: statically linked, unstripped, and compiled for GNU/Linux 2.6.18. A quick file check confirms the basics:
PS C:\Users\HxForensics > file .\dslogdrat.bin
.\dslogdrat.bin: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, for GNU/Linux 2.6.18, BuildID[sha1]=82a0dff36f1adfc48ef1977f5cae1b17291500c6, not stripped
High entropy, a generous scatter of printable strings, and the absence of tell-tale “PE” artefacts all suggest the binary is shipped unpacked. Header markers point to a GCC toolchain on a Red Hat–flavored system.

VirusTotal first saw the file back in February and has already assigned it several community YARA matches; those signatures will later serve as handy way-points when we pivot through the disassembly. One rule in particular—Mandiant’s M_APT_VIRTUALPITA_1
—tags a socket-initialization opcode cluster we will revisit.

For now, though, we take the traditional route: step in at _start
, follow the hand-off to __libc_start_main
, and let the trail lead naturally into main()
. We aim to reproduce JPCERT/CC’s analysis, add a few layers of technical color, and use the uncovered RAT internals as a springboard for wider hunting.
Basic Static Analysis
Execution begins at _start
(.text:00000000004003C0
), which invokes __libc_start_main()
and hands control to main()
.

The very first libc call inside main is fork()
. Its return value is interpreted in strict POSIX fashion: a value of -1 causes an immediate abort, 0 identifies the newly spawned child, and any positive value indicates the parent, whose return value is the PID of the created child. DslogdRAT’s parent follows the latter path and terminates with exit(0)
, leaving the child process to continue alone. From that point, the child executes init_config()
, responsible for the RAT configuration setup, and then calls child()
. This control flow—parent self-termination followed by configuration setup and a delegated worker routine—matches the layout documented by JPCERT/CC.

RAT Configuration Extraction
When the parent process exits, the child enters init_config()
. This routine decrypts a fixed-length buffer that begins at .data:0x00000000006D4600
and holds the RAT’s entire configuration. Decryption relies on a single-byte XOR: register EAX
is initialized to 0 and used as the loop index; for each pass, the byte at g_myconfig + EAX
is loaded, XOR-ed with the constant 0x63
, written back to the same address, and EAX
is incremented. The terminating comparison cmp eax, 0x833
, followed by a jbe
back-edge, limits the loop to 0x834
iterations, so exactly 2100 bytes are transformed.

In IDA’s graph view, the decryption logic is immediately recognizable. The basic block that applies the XOR branches back to its own entry, forming a clear back-edge that flags the construct as a counted loop. The comparison against 0x833
sits at the loop head; the single add that increments the counter is visible at the tail, and the counter itself lives in EAX
.

Parts of the region at the g_myconfig
label are filled with the value 0x63
—encrypted zerobytes, very similar to the 0x2e
sleds embedded in Cobalt Strike beacons.

Because the loop’s termination variable is hard-coded, we know the exact size of the blob: 0x834
bytes, or 2100 decimal. Converting that slice into a standalone array using the known array size makes the memory region easier to read.

Lifting exactly that span into a separate file and applying a simple XOR with the value 0x63
reveals the plaintext configuration in one step. A hex editor, a single-line Python snippet (bytes(b ^ 0x63 for b in open("blob", "rb").read())
), or a quick pass through CyberChef all yield the same result: the C2 endpoint, proxy details, hour gates, shell path, and every other field, exactly as JPCERT/CC listed.

Analyzing the child() Function
child()
(at .text:0000000000403709
) forks in an endless loop. A fork()
return value of -1 breaks the loop and ends the routine. A return of 0 marks the new grandchild, who jumps into the test()
. Any positive value is the supervising parent: it waits for that grandchild to finish, sleeps for the configured interval, then forks again. All RAT work—beaconing, network setup, worker threads—runs inside the grandchild, while the child re-spawns each instance to keep the malware alive.

Analyzing the test() Function
The test()
routine starts by calling init_env()
, establishing a process-wide mutex, and verifying the local host name. It then selects one of two network modes by reading the single configuration byte at 0x6D404
(the listen-mode flag in JPCERT’s nomenclature). A value not equal 1—the setting in our specimen—routes execution to connectx()
, whereas a value of 1 would branch to listenx()
.

Until now, our inspection has been purely static, but a finer understanding requires single-stepping the code. We used radare2 under WSL, launching the binary in debugging mode with r2 –d dslogdrat.bin
, seeking to main with s main
, and setting an initial breakpoint on its prologue, hitting F2
. Direct tracing quickly collides with the implant’s forking strategy: stepping over the first fork()
produces an immediate segmentation fault.

Several workarounds are possible—attaching to the child after a timed sleep()
, or forcing the instruction pointer past the fork()
call—but we opted for a more straightforward, destructive approach: NOP
-patching every fork()
and non-required functions we encountered.
After breaking in main
, we single-stepped to the first fork()
, dumped the subsequent 64 bytes with p8 64
, and used the output to locate that sequence in HxD. Overwriting the call and its setup with 0x90
bytes neutralized the fork. We repeated the procedure for the matching path in the child so that both execution branches were fork-free.

With the forks removed, we could run straight into test()
without crashes, and because init_config()
remained untouched, the configuration was already resident in clear text. For convenience, we set secondary breakpoints on sym.child()
and sym.test()
; at the latter address, we verified that the listen-mode flag at 0x6D4604
was indeed zero and that the in-memory C2 host, port, and sleep-interval matched the values extracted during static analysis.

Alternatively, we can extract the decrypted configuration directly by reading from the configuration memory region immediately after init_config()
. This yields the familiar fields—command shell, shell path, proxy IP, username, password, and C2 endpoint. We used pf z @ <addr>
to read the memory and format it as a null-terminated string.

connectx() and listenx() Functions
Next, we turn to the two network entry-points, connectx()
and listenx()
, that test()
selects according to the listen-mode flag. The call in our sample reads connectx(&MEMORY[0x6D4608], (unsigned)MEMORY[0x6D4708], (unsigned)MEMORY[0x6D4710]);
so the routine receives the C2 host 3.112.192[.]119
, port 443
, and the 30-second timeout pulled from the decrypted configuration blob.

Once inside the function, the implant checks whether the current local hour falls between two byte-encoded limits stored at 0x6D4E18
and 0x6D4E1C
: if ( *(_DWORD *)(v13 + 8) > MEMORY[0x6D4E18] && *(_DWORD *)(v13 + 8) < MEMORY[0x6D4E1C])
. In memory, those words contain the values 0x8
and 0x14
, giving an active window of 08:00 – 20:00.
Inside connectx()
, proxy handshake logic resides in connect_via_proxy()
at .text:0000000000402228
. The function first opens a TCP socket, applies send- and receive-timeouts with two setsockopt()
calls, and connects. It then builds an HTTP CONNECT
request in a 4-KiB stack buffer. If proxy credentials were supplied in the RAT configuration, the code concatenates the user name and password, Base64-encodes the pair, and inserts a Proxy-Authorization: Basic
header; otherwise, it emits a plain CONNECT
that carries only the target host and port. The request is transmitted with send()
, after which the routine reads up to 4095 bytes from the proxy into the same buffer, NUL-terminates the string, and performs a single check: it searches for the literal substring 200 Connection established
.

Stepping into connectx()
using radare2, the proxy configuration can also be seen in the debugged sample.

The beacon’s lifecycle is tracked with the global flag m_bIsRunning
, which is set to 1 once a C2 socket is live and reset to 0 by destroy_socket()
. Whenever connectx()
or listenx()
exits, test()
calls this cleanup routine to shut down and close the socket, dismantle any active shell, and release the associated mutexes and condition variable, leaving the process ready for the next connection attempt.

listenx()
mirrors connectx()
almost line-for-line. The sole functional change is direction: instead of dialling out, the routine binds on 0.0.0.0:443
, waits for the C2 host to connect, and, once the socket is live, spins up a new pthread that executes workthread()
just as the outbound path does.

Immediately before any worker thread is launched, both functions listenx()
and connectx()
invoke the function getbaseinfo()
. This helper collects the current local time, the numeric UID, the passwd pointer returned by getpwuid()
, the kernel version from uname()
, and the system’s hostname.

The resulting system information structure and the primary C2 socket pointer are then handed to handler_online()
, which passes both to SendPkg()
.

SendPkg()
handles every outbound message. The routine first locks the RATS mutex initialized in init_env()
, ensuring that only one thread writes to the C2 socket at a time. It then calls enc_buffer()
, which XOR-encrypts the payload with the byte sequence 0x1
through 0x7
, cycling for the length of the buffer. Once the data is transformed, SendPkg()
pushes it over the already-established socket and releases the lock. The same enc_buffer()
logic, invoked in reverse, is later used to decrypt inbound traffic.

Analysis of the Worker Thread
At this stage, we have traced the RAT through its two-level fork sequence, configuration decryption, and the repeated invocation of test()
in either outbound or inbound network mode. Now we turn to the code that actually processes commands from the C2: the detached worker thread. In both connectx()
and listenx()
, once the socket is established and the host profile sent by handler_online()
, execution reaches the creation of a new thread, which executes the routine workthread()
, which gets passed the primary C2 socket as its only parameter. The test()
call graph confirms that the work thread is created from both paths and marks the RAT’s primary command dispatcher.

The new thread detaches, then enters a blocking recv()
loop. Each message received from the C2 consists of an opcode byte followed by an XOR-encoded payload, which is then passed to OnRead()
.

The OnRead()
function passes this encrypted payload to enc_buffer()
, which decrypts the data using the same function previously used for the encrypted outbound data. Based on the opcode supplied to OnRead()
, the RAT executes the corresponding operation: file download or upload, command execution via a shell, sleeping, or updating proxy settings.

The decrypted payload is XOR-ed with a predefined key array whose contents range from 0x1
to 0x7
.

After the payload is decrypted and handed off to one of the final-stage functions, we reach the end of the RAT’s implemented functionality. For example, the handler_shell()
function creates a new PTY, forks and attaches to the PTY to create a new process operating in a pseudoterminal via forkpty()
, spawns a fully functional shell with execve()
, adds a PATH
environment variable, uses the shell binary specified in the RAT’s configuration, and ensures no command history is written. Finally, the shell’s output is encrypted and returned to the C2 server via SendPkg()
.

Alternative Analysis Approach
As mentioned in the beginning, we present an alternative, signature-driven shortcut for investigators who would rather leap directly into the RAT’s functionalities than step through the entire entry-point and fork/decrypt sequence. VirusTotal’s community tab already flags this sample with several YARA rules.

One in particular—Mandiant’s YARA rule M_APT_VIRTUALPITA_1—targets an opcode sequence used for socket or networking setup.
rule M_APT_VIRTUALPITA_1
{
meta:
author = "Mandiant"
md5 = "fe34b7c071d96dac498b72a4a07cb246"
description = "Finds opcodes to set a port to bind on 2233, encompassing the setsockopt(), htons(), and bind() from 40973d to 409791 in fe34b7c071d96dac498b72a4a07cb246"
strings:
$x = {8b ?? ?? 4? b8 04 00 00 00 [0 - 4] ba 02 00 00 00 be 01 00 00 00 [0 - 2] e8 ?? ?? ?? ?? 89 4? ?? 83 7? ?? 00 79 [0 - 50] ba 10 00 00 00 [0 - 10] e8}
condition:
uint32(0) == 0x464c457f and all of them
}
Dropping the core byte pattern BA 02 00 00 00 BE 01 00 00 00
into IDA immediately locates the prologue of listenx()
. From there, a single cross-reference leads to test()
, another to child()
, and finally back to main()
.
This concludes our deep dive into DslogdRAT. While we’ve covered its key mechanisms—from configuration decryption and dual-mode networking to the XOR-based command processor—many details remain for further study, including the full packet framing and a detailed analysis of the primary RAT functions provided by OnRead()
. We encourage other analysts to build on this work, share any new samples, and help map out the broader threat landscape. To date, our threat-intelligence efforts have not uncovered further variants of this RAT. Finally, our thanks go to JPCERT/CC and Florian Roth for their pioneering research and for bringing this sample to our attention.