"Solar": a Minecraft server backdoor, fully deobfuscated

Analysis of a Zelix KlassMaster (ZKM) obfuscated Paper/Spigot backdoor that calls itself Solar, how it is delivered, what it does, and a start-to-finish account of how its obfuscation was removed: every obfuscated call and every encrypted string in the payload is resolved, and the result is checked five independent ways.

Scope notes:


1. Summary

A legitimate Paper plugin was modified to carry a small dropper. When the plugin loads, the dropper contacts a command-and-control server (s0lar[.]net), downloads an encrypted second-stage jar, decrypts it in memory and loads it as a plugin. Two different second stages were recovered:

Decoy / stub Solar (the real backdoor)
Masquerades as bStats-Release-3 Mojang DataFixerUpper 1.0-SNAPSHOT
Entry class p.c (NUL-prefixed) com.mojang.datafixers.util.impl.NbtRebuilder
SHA-256 f3e4ccd9eaa42a8ff90791fa0a62101bf1aaac5c69f5e314fc91f59f236c068f 57e795322bad23ce356b1e0f78d764ad135d69e31b4a5f1d5c0e8d00ecfa7934
Behaviour Destructive code that is never reached Full remote-access backdoor, ~60 operator commands

Solar gives its operators remote code execution, DDoS, jar infection (plugins and the server jar), credential theft on Windows hosts, full player manipulation and data exfiltration, while hiding itself from the plugin list and the server log.

Everything Solar encrypts (C2 traffic, on-disk state, the copy of itself it leaves in logs/) is protected by one constant baked into the jar, so anyone holding a sample can decrypt all of it.

Deobfuscation result

Total Resolved
Obfuscated method/field call sites 8,397 8,397 (100%)
Encrypted-string sites (invokedynamic) 1,255 1,255 (100%)
Encrypted-string sites (direct accessor calls) 1,586 1,586 (100%)
Strings read from a decrypt buffer in static initialisers 83 83
Obfuscator call sites left in the rewritten classes 0

2. Samples and how they were obtained

Both jars are obfuscated with ZKM: encrypted strings, calls and field accesses routed through invokedynamic, opaque predicates and junk control flow, and meaningless or misleading names. Solar goes further and renames its own classes to look like Mojang data-fixer classes (NbtRebuilder, ChunkDeserializationFixer, LevelSpawnFixer, ...).


3. Lab


4. Delivery chain

  1. The dropper runs in the host plugin's onEnable. It builds the C2 URL and a request header from encrypted strings, POSTs to https://s0lar[.]net/routes/0/d.php, receives base64(AES-CBC), decrypts it in memory, writes a temporary jar and loads it through Bukkit's plugin loader.
  2. In the captured host sample that branch sits behind a constant empty-string guard and is dead at runtime. The copy found in a victim's logs/ folder shows a live fetch did happen there, so other builds of the dropper exist.
  3. Solar enables, deletes the temporary jar, starts beaconing and registers its event listeners.

5. The decoy payload

This payload was resolved earlier with a different technique (section 6.1) and is not the subject of the rest of this report.


6. Deobfuscating Solar

6.1 What the obfuscation looks like

Almost every method call and field access in Solar is replaced by an invokedynamic instruction. The real arguments are pushed, followed by two long values:

aload obj
ldc2_w  5334055709001702337     // C: a constant unique to this call site
lload   5                       // l: a key that depends on the method
invokedynamic  "È" (Object, long, long) Foo

Each class has its own bootstrap, an encrypted table of the classes, fields and methods it uses, and five small resolver methods. On first execution the bootstrap combines C and l, picks a table entry, decrypts it, looks the member up reflectively and binds the call site to it.

Strings work the same way: a per-class accessor String a(int, long) is called either through invokedynamic or directly.

The decoy payload uses an older variant where the key depends on the calling method's name. It was solved by loading the classes without initialising them, replacing each bootstrap with a recorder, and invoking every site from a generated method named like the real caller: 12,497 of 12,497 sites. That trick does not apply to Solar.

6.2 The arithmetic (recovered from the resolver bytecode)

For a site with constant C in a method whose key is l:

v     = C ^ ((l << 48) | l)
index = v >>> 46                         // which table entry
sub   = SUBST[(v >>> 42) & 63]           // per-class 64-entry substitution table
key[i]= (((v >>> 7*(5-i)) & 127) - sub) mod 128      for i = 0..5
plain = cipher XOR key (repeating, a zero key byte ends decryption)

Table entries are text. A class entry is a dotted class name. A member entry is owner \b name \b type for a field and owner \b name \b param ... \b return \b for a method, where every type is a base-36 number that is itself the full v of another (class) entry. So one correct key decrypts an entry and everything it refers to, with no ambiguity. A wrong key produces text that fails to parse.

The whole problem therefore reduces to: what is l in each method?

6.3 Where the keys come from

In each case the caller computes it as callerKey ^ constant. Keys form a chain down the call graph, rooted at the class seeds.

6.4 First attempt: static guessing (abandoned)

The first tool (ZTab) attacked the tables as a classical cipher: match class entries against a dictionary of ~157,000 class names, brute-force the 6-byte key of member entries and keep candidates that name real members, then per method search for an index mask that puts every site on a compatible member.

It resolved about 77% of sites. Later comparison with exact results showed about 8% of those answers were wrong: a mask can land on a different member with a compatible signature. It is kept only as the extractor of the raw encrypted tables.

6.5 Final method: exact key propagation (KeyProp)

KeyProp is an abstract interpreter over the bytecode (ASM's Analyzer with a custom value domain) run interprocedurally to a fixpoint.

Two obfuscator tricks needed specific handling:

  1. Decoy jumps. The junk control flow pushes arguments and then jumps into the middle of another call sequence, so a site appears reachable with two different constants. Both can decrypt to real members. The interpreter keeps the candidate set, and a candidate is accepted only if the member it decrypts to matches the site's full signature (arity, every parameter type, return type). That left exactly one candidate at every such site (13 sites). In 11 of them the surviving constant could be compared with the constant pushed in straight-line code immediately before the site, and it was the same in all 11. A compiler never jumps into the middle of an expression, so the straight-line constant is the original; that rule was then used for 10 string sites where the decoy affects the string index and both candidates are valid strings.
  2. Keys the tracker loses. For 7 helper inputs (28 sites) no call passed the key in a trackable form. For those, every concrete key-like value computed by a known caller was tried, and a value was accepted only if it was the single one under which every dependent site of the helper decrypted to a member matching its site. Six of the seven are confirmed by 2 to 8 sites each; one (NbtNormalizeFixer.L) rests on a single site.

6.6 Getting the seeds (and the mistakes made on the way)

Seeds are read from the running payload by a Java agent (StatAgent).

6.7 Strings

  1. An early pass hooked javax.crypto.Cipher and logged every plaintext produced by payload code. Driving the server with the lab client surfaced 521 strings, enough to map the C2 and the command set, but only for code that ran.
  2. The final pass uses the keys: every string site has a known (index, key) pair, so the agent calls each class's own accessor for each site and the rewriter replaces the call with a literal. This covers the 1,255 invokedynamic sites and the 1,586 direct accessor calls (which the first version of the tool missed entirely).
  3. Enum constants and a few other values are read in static initialisers straight from the local array the decryption loop fills (buf[0], buf[1], ...). The agent instruments those reads and the rewriter substitutes the logged value: 83 reads.

The remaining 100 accessor calls are inside the 100 per-class bootstrap stubs themselves, where the arguments are the runtime values of whatever site is being linked. They are plumbing, not sites.

Total: 2,924 string reads replaced by literals, 2,278 distinct (class, string) pairs.

6.8 Rewrite and decompile

SubstB rewrites each resolved site back into plain bytecode:

Names are made filesystem-safe, then the classes are decompiled with Procyon. Classes containing a method Procyon cannot render are also decompiled with CFR and provided as a second tree.

Stage Time
Boot payload, initialise classes, dump seeds and tables 55 s
Key propagation (8,997 methods, 1,883 re-analyses, 8 rounds) 19 s
Boot again, fetch 2,841 strings 52 s
Bytecode rewrite 2 s
Procyon, 1,472 classes 174 s
CFR on the 253 classes Procyon struggled with 47 s
Total about 6 minutes

Rewritten: 6,843 method calls, 1,554 field accesses, 2,924 string reads. Output: 1,472 classes, about 513,000 lines of Java.

6.9 Bugs found and fixed in the deobfuscator

Bug Exact dispatch sites after the fix
Method entries misparsed (the last field is the return type, not a parameter) 1,696 -> 4,466
Bulk class initialisation poisoned the seed factory 4,466 -> 5,854
Calls matched on the exact owner only (missed interface and virtual dispatch) 5,854 -> 7,727
Arrays re-created on each interpreter pass were lost at merges 7,727 -> 8,059
Keys passed as long parameters or split into three Integers; no constant folding 8,059 -> 8,326
Seeds that live only in an initialiser local 8,326 -> 8,347
Decoy jumps merging two constants at one site 8,347 -> 8,359
Helper inputs the tracker lost (validated caller-constant trial) 8,359 -> 8,396
Last site: constant unknown, key ambiguous (straight-line constant + signature check) 8,396 -> 8,397

Other fixes: direct string-accessor calls were not being inlined at all (1,586 sites); the rewriter chose static versus instance by looking the member up in its exact owner, which is wrong for inherited members (now derived from the site's arity); the heuristic fallback, about 8% wrong, was removed.

6.10 Verification

Check Result
Decryption validity. A wrong key gives text that fails the entry grammar. 0 failures in 8,397 sites
Signature check. Every resolved member compared with its site: arity, each parameter type, return type (a site type may be erased to Object). 0 mismatches in 8,397 sites
JVM ground truth. In a separate bot-driven run the payload resolved 2,502 of these table entries itself; the resulting Field/Method objects were dumped and compared. 2,502 of 2,502 agree (11 differ only in that the JVM reports the declaring supertype)
Bytecode verification. ASM's verifier over every method of the original jar and of the rewritten classes. 15,962 methods, 0 failures before, 0 after, 0 regressions
Residual obfuscation. Scan of the rewritten classes. 0 dispatcher invokedynamic; the 371 left are ordinary Java lambdas. 0 accessor calls outside the bootstrap stubs
Strings against an independent capture. The 521 plaintexts logged by the Cipher hook compared with the inlined literals. 516 present. The other 5 are decrypted eagerly by single-string classes whose only reader is an accessor nothing calls

6.11 Limits


7. What Solar does

7.1 Command and control

7.2 Cryptography

7.3 Who can control it

7.4 Capabilities

7.5 Jar infection in detail

7.6 Hiding


8. Indicators of compromise


9. Cleaning an infected server

  1. Stop the server. Treat every jar in the server root and in plugins/ as suspect: Solar can infect any of them, including the server jar. Replace them from trusted sources rather than trying to clean them.
  2. Delete the five fake .dat files from every world's data/ folder and the disguised file in logs/. A surviving copy can be loaded again.
  3. Ban both operator UUIDs and reset the bStats server UUID.
  4. If the host runs Windows, treat browser-saved and other DPAPI-protected credentials of the account running the server as stolen. Rotate any Discord tokens present on the host.
  5. Assume arbitrary commands were run as the server's OS user. Check for other persistence, rotate database and panel credentials, and review other servers on the same machine.
  6. Run backends in online-mode or behind a proxy with authenticated forwarding (Velocity modern forwarding). Never expose a legacy-BungeeCord backend directly.

10. Tools written for this analysis

Tool Purpose
KeyProp Interprocedural key propagation and exact table decryption for Solar.
StatAgent (+ SL) Java agent: safe-order class initialisation, seed and table dump, seed-factory hook, per-site string fetch, initialiser array-read hook.
SubstB Rewrites resolved dispatch and string sites back to plain bytecode.
Verify Bytecode verification before/after and residual-obfuscation scan.
deob.sh, merge.py One-command pipeline.
ZTab Static extractor of the encrypted tables.
Resolve2 Genuine-JVM resolver for the decoy payload's older dispatch scheme.
CryptoAgent javax.crypto.Cipher hook logging keys, IVs and plaintext.
mcbot.py, mitm.py Lab Minecraft client and fake internet.

The source bundle that accompanies this report contains the Procyon tree (src/), the CFR tree for the classes Procyon could not finish (src-cfr/), the full call-site map (callsites.tsv), every recovered string (strings_all.tsv) and the tools above.

Source bundle access

The deobfuscated source, call-site map, string list and tools are not publicly downloadable. They are shared with malware researchers and affected server operators on request.