Reversing SeroXen
SeroXen is a Windows RAT built from a modified Quasar client and a customized copy of bytecode77's r77 user-mode rootkit. This post follows one sample from the obfuscated batch loader down to the rootkit DLL it injects into other processes.
The sample is sero.bat, a 13,039,164-byte batch file from MalwareBazaar, SHA-256 4c6e90e178396d000b5dd5c5bb2b9ae5bbbca5986f26ffad2a6bd0845b6b2c83, first seen there on 3 October 2023. The Quasar payload, the SeroXen pipe names, the $sxr artifacts and the native components all match the SeroXen build Alien Labs described earlier.
I used ILSpy CLI for the .NET assemblies and IDA Home with Hex-Rays for the native code, plus Python for batch expansion and resource decoding (dnfile for metadata, dncil for CIL, Cryptodome for the crypto). Everything here is static. I never ran the sample or anything extracted from it, so "the client does X" below always means "the code does X". The C# excerpt is shortened and uses my own variable names. Disassembly keeps the original addresses.
Unpacking
Most of the batch file is variable assignments and string substitutions that build a command at runtime. Instead of letting cmd.exe run it, I wrote a Python parser that tracks the assignments, expands known variables and resolves the substring and replace operations as text. That turned up a PowerShell loader on line 12,140.
The loader reads line 12,154 of the same file: the marker SEROXEN followed by two encoded blobs separated by a backslash. Each blob is Base64, then AES-CBC with PKCS#7 padding, then GZip. In Python:
ciphertext = base64.b64decode(blob, validate=True)
compressed = unpad(AES.new(key, AES.MODE_CBC, iv).decrypt(ciphertext), 16)
assembly = gzip.decompress(compressed)
Both blobs decode to .NET PE files, a wrapper around the installer and an auxiliary assembly. The loader would reflection-load them and call their entry points. I wrote them to disk and opened them in ILSpy instead.
The wrappers add another layer. Their encrypted settings hold the parameters for an embedded payload.exe resource, and the settings themselves are AES-ECB with a 32-byte key built from an MD5 digest. The key construction is odd. The digest is copied into a zeroed 32-byte array at offset 0 and again at offset 15, so you end up with the first 15 digest bytes, then the full digest, then one zero byte. Build the key any other way and the settings won't decrypt. The decoded settings give the AES-CBC parameters for the payload resource, which is also GZip-compressed.
Unwrapping the first blob gave me the installer, CSStub2. Its embedded payload is a second wrapper using the same scheme, and that one decodes to Quasar.Client, an 8,143,360-byte assembly. I hashed every decoded output so I could match it against resources and registry payloads later.
ILSpy decompiles the client, but the output needs cleanup. Many constants are built one character at a time, and control flow is split into small methods called through function-pointer tables. I wrote a decoder for the character arrays that only accepts complete, sequential literal assignments and swaps them for the readable string. For the more fragmented code I kept track of methods by MethodDef token and RVA and read the CIL with dncil.
Most of the decompiled code isn't SeroXen's anyway. A Costura resolver loads Deflate-compressed copies of protobuf-net, Quasar.Common, the Task Scheduler library, MouseKeyHook, Ionic.Zip, SharpDX and BouncyCastle. I followed references from the client into those libraries to see which features it actually calls.
The wrappers have VM checks over WMI and debugger checks through Debugger.IsAttached, CheckRemoteDebuggerPresent and IsDebuggerPresent. The decoded anti_vm setting is False in this sample.
Config and C2 protocol
I found the client settings by working back from the values passed to the connection manager and the X509Certificate2 object. They come from a class initialized by MethodDef 0x06000B90. Most of its strings are encrypted, but they all go through one decoder, so I could decrypt the whole config without touching the rest of the client.
This decoder is different from the wrappers'. Rfc2898DeriveBytes gets a master-key string, a fixed 32-byte salt and 50,000 iterations, and with that constructor it's PBKDF2-HMAC-SHA1. Two reads in a row give the AES and HMAC keys:
using var derivation = new Rfc2898DeriveBytes(masterKey, salt, 50000);
byte[] aesKey = derivation.GetBytes(32);
byte[] macKey = derivation.GetBytes(64);
The second GetBytes continues the same stream. Create a fresh PBKDF2 instance for the HMAC key and you get the start of the stream again, which is the wrong key. Each stored value is a 32-byte HMAC-SHA256 tag, a 16-byte IV and AES-256-CBC ciphertext. The HMAC covers the IV and ciphertext and is checked before decryption and PKCS#7 unpadding.
All ten settings passed the HMAC check in my decoder:
- Connection string:
igboat[.]com:1167;(defanged) - Identification string:
v2.2.2 | NuGeta - Log directory name:
$sxr-Logs - Mutex:
Local\6a5dd02b-c4c3-4c95-8718-d851c4c1b042, which the single-instance helper creates and checks was newly created - Also
1.0.0.0,.exeand two empty installation/path fields
The two big settings are an RSA signature and an X.509 certificate with a 4096-bit RSA public key. The master-key string is the uppercase SHA-1 fingerprint of that certificate, and I verified the signature offline over the SHA-256 hash of the master-key string. The HMACs prove I got the layout and key derivation right. The fingerprint and signature tie the certificate to the client's startup checks.
That certificate secures the C2 connection. MethodDef 0x0600043C (RVA 0x3B8C4) opens a TCP socket, turns on keepalive and wraps it in a NetworkStream and an SslStream. The TLS arguments are right there in the CIL:
IL_0057: 02 ldarg.0
IL_0058: 7b5e010004 ldfld 0x0400015e // field token
IL_005D: 03 ldarg.1
IL_005E: 6f9000000a callvirt System.Object::ToString
IL_0063: 14 ldnull
IL_0064: 20000c0000 ldc.i4 3072
IL_0069: 16 ldc.i4.0
IL_006A: 6fe001000a callvirt System.Net.Security.SslStream::AuthenticateAsClient
After the stream object come the target host string, a null client-certificate collection, 3072 (TLS 1.2) and zero (no revocation check). The validation callback compares the server's certificate with the embedded one using Equals, so the client only accepts a server presenting that exact pinned certificate.
The client does its own framing on top of TLS. Each message starts with a four-byte length read with BitConverter.ToInt32. The receive worker keeps state between reads, so it can hold a partial header or payload and pick up a second message that arrived in the same read. Lengths of zero or less, or above 104857600, are rejected. If you write a parser for this traffic, don't treat a TLS read as a message boundary.
Messages implement IMessage and are registered with protobuf-net through TypeRegistry.AddTypesToSerializer. On connect, the client sends ClientIdentification with the version, tag, OS description, account type, country information, hardware ID, username, computer name, an encryption-key field and the embedded signature. The version and tag come from splitting v2.2.2 | NuGeta on the separator.
Nothing reaches MessageHandler.Process until the server answers with a ClientIdentificationResult that marks the client as identified. After that, packets go to processors that check the type and call the matching handler. When the connection fails, the client retries after a configured 3,000 ms plus 250 to 749 ms of jitter from Random.Next(250, 750).
Remote commands
GetPasswords shows how a request from the operator turns into local data access. The handler at MethodDef 0x060004DF (RVA 0x43C50) builds ten recovery providers and loops over them. This is the collection step, with the metadata tokens left in for the obfuscated provider type and the generic list method:
IL_0063: 07 ldloc.1
IL_0064: 08 ldloc.2
IL_0065: 9a ldelem.ref
IL_0066: 0d stloc.3
IL_0067: 06 ldloc.0
IL_0068: 09 ldloc.3
IL_0069: 6f68030006 callvirt 0x06000368 // ReadAccounts
IL_006E: 6f3a02000a callvirt 0x0A00023A // List<T>.AddRange
ldloc.0 puts the result list on the stack and the provider goes on top. ReadAccounts returns whatever that provider found, and AddRange appends it to the list. Each provider call has its own exception handler, so one broken store doesn't stop the rest. The final list goes into GetPasswordsResponse.RecoveredAccounts and back to the server.
The providers are Brave, Chrome, Edge, Firefox, Internet Explorer, Opera GX, Opera, Yandex, FileZilla and WinSCP. The Chromium ones share code but use different profile paths. They read origin_url, username_value and password_value from the logins table in Login Data, then take encrypted_key from Local State, Base64-decode it, strip the five-byte prefix and unwrap it with ProtectedData.Unprotect under DataProtectionScope.CurrentUser. Passwords starting with v10 are decrypted with that key using AES-GCM. Anything else goes straight to DPAPI, also as CurrentUser, so recovery depends on running as the user who owns the profile. These are the formats this sample handles. I didn't check it against newer browser protections.
Firefox recovery looks for signons.sqlite and logins.json and resolves NSS functions including NSS_Init and PK11SDR_Decrypt. FileZilla recovery pulls Host, Port, User and Pass from recentservers.xml and sitemanager.xml and Base64-decodes the password. WinSCP recovery reads sessions under the current user's Martin Prikryl\WinSCP 2\Sessions registry key and runs its own decoding routine on them.
The other commands go through the same dispatch. DoShellExecute creates or reuses a shell object, passes it message.Command and disposes it on exit. The file manager lists drives and directories and handles transfers in chunks, plus cancel, delete and rename. Process and registry handlers list and modify what you'd expect. The reverse proxy opens sockets to the requested destinations and tags relayed data with connection IDs, so several proxied connections can be kept apart from each other and from the client's own control connection.
The desktop handler takes capture, monitor, mouse and keyboard messages. Separate code creates a VirtualDesktop with CreateDesktopW and captures bitmaps from it. Keyboard-hook code and keylog-directory retrieval are in there too, though I didn't work out which features are switched on at startup. Some packet names are misleading leftovers. DoWebcamStop shows up in unrelated dispatch paths, so I didn't count it as evidence of webcam capture. DoAskElevate is clearer: it uses the runas verb and reports "User refused the elevation request" if that fails, so elevation goes through a normal UAC prompt.
Persistence
After installation SeroXen doesn't need the batch file anymore. To find what replaces it, I followed the registry writes and task registration in CSStub2. One string, $sxr-ArapbJcqiFeDiSwKsZvE, ties together the payload value, the environment variables and the scheduled task.
ConstructorReturnMessageEmitCall (MethodDef 0x06000303, RVA 0x81EAC) calls Registry.LocalMachine.OpenSubKey("SOFTWARE", writable: true) and writes a REG_SZ value with that name. The value sits directly under SOFTWARE. If you go looking for a subkey you'll miss it. The data is 7,750,189 ASCII characters: two encrypted Base64 blobs separated by a backslash. They decode to the RAT-loader wrapper and the same auxiliary assembly I pulled out of the batch file, byte for byte, which is how I linked the installed launch path to the original one.
The installer also copies three Windows binaries into the Windows directory as $sxr-mshta.exe, $sxr-cmd.exe and $sxr-powershell.exe, and writes three machine-scoped environment variables named with the same stem plus a suffix. Machine scope means they outlive the installer process.
4321holds the path of the copied mshta.4312holds the obfuscated launch command that the task uses.1234is a working copy used while the launcher is prepared.StartDTDSubsetGetTypeFromProgIDdecrypts the command, inserts the same generated 20-character string at every zero-length match of the pattern.{0}and saves that copy to the machine environment.CdfIdentityDictionaryKeyCollectionreads it back and strips the inserted string and the PowerShell executable text.
When the task fires, its executable field expands 4321, which starts the copied mshta. The mshta arguments contain script that calls WScript.Shell.Run to start the copied cmd. Cmd expands 4312, strips the inserted &#<? marker and starts the copied PowerShell. PowerShell reads the registry value, splits the two blobs, decrypts and decompresses them and calls their entry points:
scheduled task
-> $sxr-mshta.exe -> $sxr-cmd.exe -> $sxr-powershell.exe
-> HKLM\SOFTWARE payload value
-> auxiliary assembly + RAT loader -> Quasar.Client
Deleting the original batch file does nothing here. The code is in the registry, the launch command is in the environment and the hosts are in the Windows directory. The RAT gets rebuilt locally every time, without downloading anything from C2.
The task is built by MethodDef 0x06000251 (RVA 0x719F0). It has a LogonTrigger with no user filter and a TimeTrigger starting on 1999-01-01 that repeats every five minutes. Run level is Highest, with no execution time limit and no idle or network condition, and it runs on battery. MultipleInstances is IgnoreNew and Hidden is false. The decoded PowerShell command also checks for an already running $sxr-powershell process.
Another helper runs the task, sets the principal group to BUILTIN\Users and registers it again. That's what brings the RAT back after logon. There's no BootTrigger, and the task isn't assigned to SYSTEM. The install itself needs enough privilege to write to the Windows directory and HKLM, and Highest run level won't give admin rights to an account that doesn't have them.
The registry-plus-PowerShell setup comes from r77's fileless install. The upstream installer stores a stager as a REG_BINARY value under HKLM\SOFTWARE and creates a task that loads it through PowerShell reflection. SeroXen stores encrypted text in a REG_SZ instead, and its task reaches PowerShell through the copied mshta and cmd and the environment variables.
The r77 layer
The native side is mostly r77. The process-hollowing host, PID registration, DLL unhooking, critical-process check and enumeration hooks all match. The hiding prefix changed from $77 to $sxr, so $77config became $sxrconfig. SeroXen's own work is the packaging and the reset interception. I compared against r77's September 2023 native host and hooks, but I haven't pinned this binary to an exact upstream version.
Besides the network dispatcher, the client has a local named pipe. Its name is the $sxr prefix, then -SeroXenClientNamedPipe_, then a fixed suffix (the full name is in the IOC table). Matching the installer's and client's pipe operations leads to a handler that loads Quasar.Client.InstallStager.exe. That assembly carries 32-bit and 64-bit native payloads and the code that puts them inside dllhost.exe, taken from System32 or SysWOW64 depending on architecture.
On x64, InstallStager starts the host with CreateProcessInternal, reads the thread context, unmaps the original image with NtUnmapViewOfSection and allocates room for the replacement with VirtualAllocEx. It writes the PE headers and sections with WriteProcessMemory, updates the remote image-base pointer, puts the new entry point in RCX (EAX on x86) with SetThreadContext and resumes the thread. It's textbook process hollowing. The process is dllhost.exe on disk, but the image in its memory is the native host.
The 64-bit host's main routine at 0x140001B30 tries to enable SeDebugPrivilege, loads the DLL embedded as resource type DLL, ID 101, and starts its injection threads. It writes its PID as svc64 under HKLM\SOFTWARE\$sxrconfig\pid. The name comes from r77, which reserves svc32 and svc64 for its host PIDs. No SCM service is involved. Registering the PID lets the rootkit hide the host even though dllhost.exe doesn't carry the $sxr prefix.
Before its main loop, sub_140001908 maps ntdll.dll and kernel32.dll from C:\Windows\System32, finds their .text sections, makes the loaded copies writable with VirtualProtect, copies the clean code over them and restores the old protection. That undoes any patches in the loaded copies, including hooks a security product may have placed there. The injection routine at 0x140001000 opens eligible processes, skips critical ones by checking ProcessBreakOnTermination, allocates executable memory in the target, writes the embedded DLL and calls a helper to start it. r77's injection code has the same critical-process check.
The injected DLL hooks the APIs other programs use to list things. The registration routine at 0x180001EB0 hooks NtQuerySystemInformation, the directory-query APIs, registry and service enumeration, NtResumeThread and NtDeviceIoControlFile. For system-information class 5 (the process list), sub_18000221C calls the original function and then walks the records it got back. The prefix check looks like this:
00000001800022B2 41b804000000 mov r8d, 4; nChar
00000001800022B8 488d1581890100 lea rdx, aSxr; "$sxr"
00000001800022BF ff153b100100 call cs:StrCmpNIW; SHLWAPI
00000001800022C5 4533db xor r11d, r11d
00000001800022C8 85c0 test eax, eax
00000001800022CA 742c jz short loc_1800022F8
RCX already holds the process name. RDX gets $sxr and R8D gets 4, so this is a case-insensitive compare of the first four characters. A match returns zero and jumps to the exclusion path at 0x1800022F8. Elsewhere the function also checks PID and name filters.
Hiding a record means rewriting the links in the returned buffer:
0000000180002304 418b06 mov eax, [r14]
0000000180002307 4d85ff test r15, r15
000000018000230A 740e jz short loc_18000231A
000000018000230C 85c0 test eax, eax
000000018000230E 7405 jz short loc_180002315
0000000180002310 410107 add [r15], eax
0000000180002313 eb11 jmp short loc_180002326
0000000180002315 45891f mov [r15], r11d
R14 is the current record and R15 is the last record that was kept. The first DWORD of each record is NextEntryOffset, the byte distance to the next one. At 0x180002310 the current record's offset gets added to the previous record's. Say the previous offset was 0x100 and the hidden record's was 0x180: the previous one becomes 0x280, and anything walking the list jumps straight over the hidden record. If the hidden record was the last one, the previous offset is set to zero.
The process keeps running. Only the list handed to the caller changes. The function also adds up the accounting fields of every hidden entry and adds the totals to the record with PID 0, so the idle process absorbs the hidden processes' numbers.
The registry filters at 0x180002A08 and 0x180002AF4 do the same for registry enumeration and depend on internal synchronization state. A registry value can exist and still be missing from what a hooked tool shows you. That's the point of all the $sxr names in the installer.
The NtResumeThread hook at 0x180002444 spreads the injection to new processes. It gets the target PID with GetProcessIdOfThread, skips its own process and checks the target's bitness. Then it opens the 32-bit or 64-bit $sxrchildproc pipe, writes the PID as four bytes and waits for a one-byte reply. On the other end, the host's listener at 0x140001F94 runs its injection callback and replies with byte 77. Only then does the hook call the real NtResumeThread, so the injection is attempted while the child's first thread is still suspended. The reply just means the callback finished. It doesn't confirm the injection worked, and if the pipe can't be opened the hook resumes the thread as normal.
The NtDeviceIoControlFile hook at 0x180002D78 handles network listings. After calling the original, it looks for a successful 0x12001B request with a 112-byte output structure on a handle whose object name is \Device\Nsi. For TCP and UDP tables it checks the port filters and process-hiding rules, shifts the following connection, status and process entries over each hidden row and decrements the count. netstat and similar tools that go through this path won't show the connection. The socket stays open, and the traffic still shows up in a network capture.
Next to the r77 hooks, this build has one that targets reset. It loads spopk.dll and replaces Sysprep_Clean_Validate_Opk with:
0000000180002218 33c0 xor eax, eax
000000018000221A c3 retn
It returns zero immediately, so the real validator never runs. I didn't test whether the hook actually lands in a given Sysprep process. In Remove mode (below), ResetPatcher runs the real Sysprep after its cleanup.
Reset persistence
SeroXen is advertised as surviving a Windows reset. In this sample the reset never actually runs. The client kills it as it starts and puts up its own reset screen instead, with no command from the operator needed. It sets up a ManagementEventWatcher on SELECT * FROM Win32_ProcessStartTrace. For each new process it gets the PID, resolves the process object and compares MainModule.FileName with Windows\System32\SystemSettingsAdminFlows.exe on the system drive. If that matches, it checks for the operation argument FeaturedResetPC.
Those two checks catch Settings starting a reset. The client calls proc.Kill() on it and loads the embedded Quasar.Client.ResetPatcher.bin, passing FeaturedResetPC, the sample identifier and an internal authentication argument to its entry point. The resource is AES-ECB with an MD5-derived key, PKCS#7 padding and raw Deflate, and decodes to a 1,769,472-byte WinForms assembly.
The assembly is a WinForms copy of the reset dialog. It's titled "Reset this PC" and has resources for both "Keep my files" and "Remove everything". Once the user continues, the form hides its options, goes borderless, maximized and topmost, and loads the embedded SystemSettingsAdminFlows.ResetHTML.html into a WebBrowser control. Input blocking and a hidden cursor keep the user from doing anything while it's up.
The progress on screen isn't connected to anything. A setInterval(timer, 14830) in the page moves the percentage, and the managed worker separately waits on Task.Delay(1400000), which is 23 minutes 20 seconds. Neither one reads anything from a real recovery process, so the number keeps climbing while deletions fail, files stay locked or account operations get skipped.
Cleanup in both modes
The cleanup worker lists the immediate subdirectories of the shared Start-menu Programs folder, Program Files, Program Files (x86) and ProgramData. It lowercases each name, checks it against an exception list and recursively deletes everything that isn't on it. No uninstallers run, so the apps' registry entries and services can stay behind. Loose files sitting directly in those folders survive too, since only directories are listed.
The exception lists (in full at the end) keep a lot of Windows and Microsoft folders. The Start-menu one has a bug. It includes names like Microsoft Edge.lnk, but the loop calls Directory.GetDirectories, so a normal shortcut file can never match. Both modes also go after the current user's $sxr-Logs directory and the files under the Public Desktop.
Account removal starts with a WinNT user enumeration. Administrator, DefaultAccount, Guest and WDAGUtilityAccount are exempt by exact name, and deletion commands for every other account are started asynchronously. After that the two modes split.
Keep my files
The current Environment.UserName is also exempt, and the all-profile deletion pass is skipped. The code tries to remove the current user's roaming AppData and the Explorer Taskband key, then sets up more cleanup under HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce with the value name $sxr-System_Reset_Task.
That command uses $sxr-cmd.exe to start the real FirstLogonAnim.exe with /RunFirstLogonAnim /explorer, the animation Windows shows on a first logon. After roughly five pings of delay it tries to delete the Taskband\Favorites registry path, removes the original profile's whole AppData directory and kills and restarts Explorer. Roughly forty pings later it kills the animation. The profile path is captured when the command is built, but the HKCU operations apply to whoever runs RunOnce, and that may be a different user.
The Taskband command treats Favorites as a key path, not a value selected with /v. The file cleanup also doesn't explicitly touch the current profile's Documents, Pictures, Downloads or Desktop. Other non-exempt accounts are still targeted. Keep mode ends with a forced reboot and never calls Sysprep.
Remove everything
The shared account pass also targets the current user, unless the name matches one of the built-in exemptions. Terminal Services sessions other than the reset process's own are logged off. The profile pass lists %SystemDrive%\Users, skipping Default, Public, All Users and Default User. It also skips any folder whose lowercased name contains the original profile name, which can spare more folders than an exact match would.
For each remaining folder it looks up the folder name with UserPrincipal.FindByIdentity. Only if that returns an account does it call UserPrincipal.Delete and recursively delete the folder. That races with the asynchronous account deletions from earlier. If the account is already gone, the lookup fails and the profile folder is skipped. So even before file locks and swallowed exceptions, it can't be counted on to wipe every profile.
Remove mode also tries to delete the executing user's profile and writes its own version of $sxr-System_Reset_Task. Its delayed targets are Taskband\Favorites, C:\Users\defaultuser0 and the captured original profile, without the FirstLogonAnim and Explorer steps. Both RunOnce entries only clean up. The repeating scheduled task is still what relaunches the RAT.
When the cleanup workers finish and the fixed delay runs out, Remove mode force-kills any running Sysprep through a hidden command shell, waits up to ten seconds for that and starts the real Sysprep with exactly:
%SystemRoot%\System32\sysprep\sysprep.exe /oobe /reboot
It sets UseShellExecute = true and WindowStyle = Hidden, then immediately suspends the new process with a helper that opens each thread with SUSPEND_RESUME access and calls SuspendThread. Ten seconds later it tries to resume Winlogon and then Sysprep. Separately, the UI/watch path suspends a Winlogon process it finds with NtSuspendProcess, and a matching helper calls NtResumeProcess. Winlogon is suspended and resumed, and its file on disk is left alone.
According to Microsoft's Sysprep reference, /oobe boots into first-run setup and /reboot restarts the machine. There's no /generalize and no /unattend answer file, and nothing asks for the image to be replaced or the disk formatted. The user can get account creation and the other first-run screens, on the same Windows install the malware just finished cleaning up.
Two more resources, ULLH_KeepFiles and ULLH_RemoveAllFiles, hold related cleanup and reboot helpers. Their launchers can wait for a given PID to exit before loading the chosen assembly in memory. They handle input blocking, display suppression, Winlogon resumption and log removal, and finish with a forced reboot or Sysprep. I decoded both, but I'm not assuming they run on every reset attempt.
What the feature amounts to
None of the persistence is in the cleanup paths above: not the task, the machine environment variables, the copied binaries in the Windows directory or the HKLM\SOFTWARE payload. The next time someone logs on, the launcher has everything it needs to rebuild the RAT. So the advertised reset persistence works only because the real reset never happens, and only when it's started from Settings while the watcher is running, sees the process and manages to kill it. I didn't find anything in this sample that would carry it through a reset that actually completes, whether that's started from Windows RE, done by reinstalling from external media or by wiping the partition.
Detection and removal
The best disk indicators are the linked install artifacts: the $sxr-ArapbJcqiFeDiSwKsZvE task and HKLM\SOFTWARE value, the three environment variables with the same stem and the renamed Windows binaries. Check the binaries together with the task action and the stored payload. On their own they're real Windows executables and may carry valid Microsoft signatures. In memory, look for the replacement image inside dllhost.exe and injected copies of the rootkit. Network-side logs will show traffic the local connection table hides.
Don't trust live listings while the rootkit is active. Its process, directory and registry hooks can hide all of this from tools running in injected processes, including task files and registry data read through those APIs. The task has Hidden=false, and I didn't confirm whether every Task Scheduler view misses it. Starting a fresh tool doesn't guarantee a clean view either, because the child-process hook asks for injection before the new process's first thread runs.
Cleaning a machine means dealing with both the saved launcher and the DLLs already in memory:
- Take the machine off the network and preserve disk and memory evidence before deleting anything.
- Inspect and clean from trusted external recovery media, or from an incident-response environment that can see the install without the injected user-mode hooks. Check both registry views, because the installer's
HKLM\SOFTWAREaccess doesn't pick an explicit view. - Disable or remove the task with trusted remediation tooling. Remove the exact registry payload value, the three matching machine environment variables, the
$sxrconfigkey, the$sxr-System_Reset_TaskRunOnce value if it's there and the three$sxr-binaries. Keep copies for analysis and remove only the artifacts you've identified. - Reboot once the launch chain is gone, then check the artifacts and processes again with trusted tools after logon and across the five-minute task interval. Deleting the payload on a running system doesn't unload DLLs already injected into other processes.
If you need a known-clean system, save the data you need and reinstall Windows from trusted external media. The Settings reset won't do it, since this client can intercept it while running. Reset passwords and revoke sessions from a separate clean device. Removing the RAT doesn't undo credential theft that may have already happened.
IOCs
These were pulled from this sample and show its configuration when it was built. They say nothing about who owns or runs the domain today.
| Type | Value |
|---|---|
| Configured endpoint | igboat[.]com:1167 |
| Client version / tag | v2.2.2 / NuGeta |
| Certificate SHA-256 | 8ce86540ca5f8992c923341c10fd9d87089e74574ac3f6089d558c725fa78b8c |
| Task | \$sxr-ArapbJcqiFeDiSwKsZvE |
| Registry payload | Value $sxr-ArapbJcqiFeDiSwKsZvE directly under HKLM\SOFTWARE |
| Machine environment variables | The same stem with suffixes 4321, 1234, 4312 |
| Launcher copies | %SystemRoot%\$sxr-mshta.exe, $sxr-cmd.exe, $sxr-powershell.exe |
| Native host PID | HKLM\SOFTWARE\$sxrconfig\pid, DWORD svc64 |
| Reset cleanup | HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce, value $sxr-System_Reset_Task |
| Log directory | %AppData%\$sxr-Logs |
| Managed client pipe | \\.\pipe\$sxr-SeroXenClientNamedPipe_DfDSWJQXBSBVdJXIBeMKrhdiNahnVenOKDfDSWJQXBSBVdJXIBeMKyWKhobLMDFTgSLOHXDfDSWJQXBSBVdJXIBeMK |
| Child-injection pipe, 32-bit target | \\.\pipe\$sxrchildproc34226543a32 |
| Child-injection pipe, 64-bit target | \\.\pipe\$sxrchildproc38764243a64 |
Method references, component hashes and the cleanup exception lists are below if you want to check my work. Native addresses use each module's preferred image base. I compared the printed instruction bytes against the original PE files, and checked the config decoding with all ten HMACs, the certificate fingerprint and an offline RSA signature check.
Methods and addresses
| Component | Method or address | Purpose |
|---|---|---|
| Installer | ConstructorReturnMessageEmitCall, 0x06000303, RVA 0x81EAC |
Registry payload write |
| Installer | getAccessControlTypePercentGroupSeparator, 0x06000251, RVA 0x719F0 |
Scheduled-task construction |
| Installer | InheritedObjectAceTypePresentIndexOfValue, 0x060000C0, RVA 0x5AB70 |
Run task; update principal group |
| Quasar client | ThumbprintDoCallBack, 0x06000239, RVA 0x2A8D8 |
Settings executable-path check |
| Quasar client | setPersistHandlerOffset, 0x06000238, RVA 0x2A6F0 |
FeaturedResetPC argument check |
| Quasar client | DisablePrivateReflectionAttributeComponent, 0x06000245, RVA 0x2C190 |
Kill intercepted process |
| Quasar client | TaskExecuteSchemaVersion, 0x0600023C, RVA 0x2AFC8 |
Recover and load ResetPatcher |
| Quasar client | writePosgetConsumptionScope, 0x06000254, RVA 0x2CE6C |
Invoke ResetPatcher entry point |
| ResetPatcher | SuspendedObjectCloneHelper, 0x060000C4, RVA 0xA458 |
Prepare HTML reset screen |
| ResetPatcher | ResourceLocatorsetPMDesignator, 0x06000205, RVA 0x2415C |
Build branch-specific RunOnce cleanup |
| ResetPatcher | UnsafeAddrOfPinnedArrayElementFindTokenByIndex, 0x06000208, RVA 0x2652C |
Write RunOnce value |
| ResetPatcher | ISOCurrencySymbolResourceTableMappingEntry, 0x0600024D, RVA 0x2DDE8 |
Start and suspend Sysprep |
| Native 64-bit component | 0x140001B30 |
PID registration and injection setup |
| Native rootkit DLL | 0x180001EB0 |
Install API hooks |
| Native rootkit DLL | 0x18000221C |
Filter $sxr process entries |
| Native rootkit DLL | 0x180002444 |
Request child-process injection before thread resume |
| Native rootkit DLL | 0x180002D78 |
Filter NSI TCP/UDP enumeration |
| Native 64-bit component | 0x140001F94 |
Receive child PID; call injector; acknowledge |
SHA-256 hashes
| Recovered component | SHA-256 |
|---|---|
| Initial wrapper | ac562299cd216585d58cab4c435c1578f3e451820a4c0feb2d902d0662645446 |
| Auxiliary assembly | 0664673cd5981106172b8df9f730afa55247bf6943e5ca4f7eb7d8be2e0a15ee |
| Unwrapped installer | 37d456e053173d1940995ab8da929b3409d782648e78fef43389a9616d24dc84 |
Unwrapped Quasar.Client |
8e73056a2c43dc86ba78a7858bb06c417dc8c59e8cc624e8226ebc7d9168fded |
| Component | SHA-256 |
|---|---|
InstallStager |
fef344668a48c83d7a46beb4eafb35d553c2ef91dcf976b108b2dd89fb8633c4 |
service64 |
2981a03b5180831d79d5927053365b89ee538d7259909159f9fb5c219d347207 |
rootkit64 |
2ee5bacb1efaf063896ce48066a50c1636760ef6c74510b4b2c855444242b1cd |
ResetPatcher |
eb7990f280afccc1f81e2a7f6abd8e2546a741ad9bc5fef2e1ee5b6c61cf9c94 |
ULLH_KeepFiles |
3c13be6f009763aea31e55cd36b137c7fcc386a155f5a6a5dbfb0fe8cf9564c8 |
ULLH_RemoveAllFiles |
6ab4b7ba75e0d9a23ce0c38f0df8ff07d6dba88cc08f6944af4fbc9d5b2f77f3 |
ResetHTML resource: 854f3cda916a2ecd56ffb32ecce92ef62552d3f0e4b3aca8dfc320cdf073a52b.
Program Files and Program Files (x86): 42 names
Windows Defender
Windows Mail
Windows Media Player
Windows NT
Windows Photo Viewer
Windows Portable Devices
Windows Sidebar
WindowsApps
WindowsPowerShell
Windows Accessories
Windows Administrative Tools
Windows Alarms & Clock
Windows Calculator
Windows Camera
Windows Console
Windows Feedback Hub
Windows File Explorer
Windows Hello Face Software
Windows Maps
Windows Multimedia Platform
Windows Narrator
Windows PowerShell Modules
Windows Quick Assist
Windows Security
Windows Subsystem for Linux
Windows System
Windows Terminal
Windows Update
Windows Voice Recorder
Windows Weather
Windows WIA Scan
Windows Defender Advanced Threat Protection
Windows Live
Windows Live Mail
Windows Live Messenger
Windows Live Writer
Windows Multimedia Platform x64
Windows Multimedia Platform x86
Common Files
Internet Explorer
ModifiableWindowsApps
Microsoft
ProgramData: 16 names
Microsoft
Windows
Windows Defender
Windows NT
Windows Photo Viewer
Windows Portable Devices
Windows Sidebar
Windows Defender Advanced Threat Protection
Microsoft OneDrive
Packages
regid.1991-06.com.microsoft
SoftwareDistribution
ssh
USOPrivate
USOShared
WindowsHolographicDevices
Shared Start-menu Programs: 16 names
Microsoft Edge.lnk
Microsoft Edge Beta.lnk
Microsoft Edge Dev.lnk
Microsoft Office
Windows Accessories
Accessories
Administrative Tools
Windows Administrative Tools
Windows PowerShell ISE.lnk
Windows System
Windows Terminal.lnk
Accessibility
Windows PowerShell
Windows Tools
System Tools
Maintenance
Sources: sample record, earlier SeroXen research, bytecode77's r77 source, Sysprep command-line documentation.