"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:
- All execution happened in an isolated lab VM with no route to the internet.
- The plugin that was originally trojaned is private third-party code and is deliberately left out. Only the malware is described.
- Network indicators are defanged (
[.]). A few names chosen by the author are vulgar; they are masked here and absent from the shared source bundle.
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
- The decoy is what the C2 returned to a plain request for the stage-2 route.
- Solar was recovered from an infected server: the malware leaves an encrypted copy of itself in
the server's
logs/folder, named like a rotated log (YYYY-MM-DD-N.log.gzfollowed by zero-width characters). It decrypts with the same scheme as the C2 traffic.
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
- Proxmox host, one isolated Debian 12 VM (Temurin 21, Paper 1.21.11) on a host-only bridge with all forwarding dropped.
- A fake internet inside the lab: DNS that answers every name with a lab address, and a TLS-terminating listener whose CA is added to the JVM trust store, so the payload's HTTPS requests complete and are logged in clear.
- VM snapshots before every run. The decoy contains a routine that deletes the working directory, so rollback mattered.
- A minimal Minecraft protocol client (written against the server's own packet registry) that logs in as an arbitrary UUID through legacy BungeeCord forwarding and sends chat and commands.
4. Delivery chain
- The dropper runs in the host plugin's
onEnable. It builds the C2 URL and a request header from encrypted strings, POSTs tohttps://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. - 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. - Solar enables, deletes the temporary jar, starts beaconing and registers its event listeners.
5. The decoy payload
onEnablereads four strings and returns. It registers no listeners and no commands.- The jar does contain a thread bomb, a loop that recursively deletes
./, an external-IP lookup and a Discord webhook post, all inside a private method that nothing calls. On a real server it does nothing, which matches what infected operators saw (no wipe). - It stores a URL to a second jar on another domain (
a***-pro[.]xyz, vulgar path, masked). The field holding it is never read. - It carries a 205 MB resource that is one short phrase repeated about nine million times (538 KB compressed). It is not referenced by any code. It slows down tooling that unpacks jars naively.
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
- Each class computes a 48-bit seed in its static initialiser:
seed = Factory.a(K1, K2, thisClass).a(K3), usually stored in astatic final long. - An ordinary method starts with
l = seed ^ constant. - The obfuscator turns many methods into
static X name(Object[] args)helpers. Their key arrives from the caller, in one of several shapes: - a boxed
Longat some index of the array (not always index 0); - a plain
longparameter (mostly constructors); - three
Integerarguments that the callee shifts and ORs back into a 48-bit value.
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.
- Values tracked: known long, known int, "input N of this method XOR constant", boxed value, array with per-index contents, and a small set of alternative constants (see decoys below).
- Constant folding for the long/int operations the obfuscator uses (xor, shifts, or, and,
narrowing, widening) and for
Long.valueOf/Integer.valueOfand their unboxing. - Arrays get one identity per allocation instruction, so a
new Object[]{...}built inside a loop is still recognised when the interpreter revisits it. - Calls are recorded for direct invocations, for dispatch sites as soon as they resolve, and for lambdas / method references (captured values become leading parameters). A call naming a class reaches the inherited declaration and every override or interface implementation.
- Re-analysis: whenever a method gains a concrete input it is interpreted again with it, so a helper that received a split key can fold it and pass keys on to its own callees.
- Ordinary arguments are not keys: an input that receives different values from different callers (722 of them) is blacklisted.
Two obfuscator tricks needed specific handling:
- 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.
- 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).
- Solar hides from the plugin manager. After enabling it removes itself from the list returned
by
getPlugins(). A helper plugin could not find it; the agent usesInstrumentation.getAllLoadedClasses()instead. - Initialising classes in arbitrary order corrupts seeds. The seed factory is stateful. Forcing
all 343 classes to initialise in hash order gave about 70 of them a wrong seed: some threw
BadPaddingExceptionin their own string decryption, others initialised "successfully" with a wrong value. Reading the field withUnsafeafterwards only returned the same wrong value. Initialising in sorted order, payload packages first, gives 342 of 343 a correct seed. The one failure is the WindowsCrypt32binding on a Linux lab host. - Some seeds never reach a field. A few classes keep the seed in a local variable of the static
initialiser. The agent therefore also instruments the seed interface's
long a(long)and logs every (argument, result) pair;KeyPropsubstitutes the logged result where the argument is a known constant. 318 distinct arguments were logged; 31 produced more than one result (the stateful factory again) and are ignored.
6.7 Strings
- An early pass hooked
javax.crypto.Cipherand 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. - 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,255invokedynamicsites and the 1,586 direct accessor calls (which the first version of the tool missed entirely). - 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:
- dispatch site:
POP2; POP2(drop the two longs), then the realget/putfield,get/putstaticorinvoke*. Static versus instance is taken from the site's own arity, not from a class lookup; - string site:
POP2; POP; LDC "literal"; - initialiser array read:
POP; LDC "literal"after the read.
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
- Names of methods, fields and most classes are still the obfuscator's. Call targets, types and strings are real, so the code is readable but not pretty.
- Procyon fails on some method in 253 of the 1,472 classes because of the junk control flow; CFR recovers most of them (the CFR tree still contains about 254 failure markers). No single decompiler handles all methods. The rewritten bytecode itself is complete and verifies.
- Ten string sites rely on the straight-line rule and one helper input rests on a single validating site (section 6.5). Everything else is confirmed by decryption plus signature.
- The rewritten classes are for reading. They are not meant to be run: the original reaches private members through reflection, which direct calls cannot.
7. What Solar does
7.1 Command and control
- Primary host
s0lar[.]net. URL template%s://%s/routes/0/%s.php%s. Routes seen:a.php(check-in),d.php(stage fetch),download.php. - A host list is tried in order. Besides the C2 it contains
google.com,cloudflare.com,github.comandbstats.org, which are only contacted to test connectivity and blend in. - The check-in is JSON, AES-CBC encrypted and base64 encoded: server port, OS, MOTD, an install id, a per-server token, hostname, a bot UUID, world name, player and operator lists.
- The domain can be changed at runtime (
setdomain,forcesetdomain). On a change Solar also re-patches the jars it has infected so they call the new domain. - It queries
https://sessionserver.mojang[.]com/session/minecraft/profile/%s(legitimate Mojang API) to resolve players, andhttps://discord[.]com/api/v10/users/@me(legitimate Discord API), which validates a Discord token.
7.2 Cryptography
- AES key = SHA-256 of a 50-character constant embedded in the jar
=
925f48542018b88d7aaf7b4400636e64b6b884cc48e4112852cebeb5a1808ca2. It decrypts C2 replies, the on-disk state files and thelogs/copy, for every victim of this build. - The outbound check-in uses a second fixed key.
- A separate operator "key" (
setkey) is an optional runtime password, null by default. Jar injection refuses to run while it is null.
7.3 Who can control it
- Two hardcoded account UUIDs (version 4, i.e. real Mojang accounts):
e19212b9-53d8-44cd-8eac-41c85f8b0e28andd7b77c65-23fe-4159-b73c-50fc3d2c293d. - On an online-mode server Mojang authentication makes those UUIDs unforgeable. On an offline-mode backend behind a proxy using legacy BungeeCord forwarding, the UUID is whatever the connecting client claims, which is how the lab client logged in as an operator.
- Commands must arrive over a real network connection: Solar inspects the player's Netty channel and ignores events injected by other plugins.
7.4 Capabilities
- Code execution: OS shell (
exec,run,runtime), server console (cmd,console), and loading a compiled class from a URL (eval,loadclass,runclass). - Jar infection (
inject,infect,reinject): see 7.5. - Plugin loading: from a URL (
load) or from a jar already on disk (loadplugin,enable); self-update (update). - DDoS (
attack,ddos,dos): UDP flood and TCP connection flood with host, port, duration and thread parameters. - Files: download to a path, replace, delete.
- Credential theft: calls Windows DPAPI
CryptUnprotectDatathrough bundled JNA, the API that protects browser-saved secrets. Only functional on Windows hosts. - Players: op/deop, gamemode, inventory and ender chest viewing, teleport, give, heal, invisibility, whitelist, and transferring players to another server. It collects every player's IP, location and permissions.
- Exfiltration: upload logs, errors and the command list.
- Other backdoors:
disablerdetects and disables competing backdoors. - Evasion: changes the bStats server UUID (
requestnewserveruuid), can toggle online-mode.
7.5 Jar infection in detail
inject [-f] <path>is described by the malware itself as "Inject a plugin/server jar with Solar". The patcher bundles a renamed copy of the ASM bytecode library and handles two jar types:- plugin jars: reads
plugin.yml/paper-plugin.yml, finds themain:class and hooks itsonEnable; - any other jar: reads
META-INF/MANIFEST.MF, findsMain-Classand hooksmain. That is how a server jar is infected. - It deletes signature files (
META-INF/*.SF,.RSA,.DSA) andchecksums.md5so the modified jar still loads. reinjectfinds jars it infected earlier and re-infects them with current code.- The automatic re-patch on a domain change covers jars directly in
./pluginsand jars directly in the server root (Files.walk("./", 1), depth 1). - The patcher lives in Solar itself (the stage-2 jar), not in the dropper. The dropper found in
an infected plugin is the patcher's output: a small stub added to that plugin's
onEnable(or a server jar'smain). An infected jar therefore re-downloads Solar on every start, and a running Solar can infect further jars. - No string or code path names
cache/,versions/orlibraries/. Those are not targeted automatically; they can only be hit if an operator passes such a path toinject.
7.6 Hiding
- Removes itself from the plugin manager's plugin list after enabling.
- Installs a log4j filter so its own loading and enabling lines never reach the console or log.
- Unregisters other plugins' listeners where it needs to.
- Keeps state in the world folder under names that look like game data:
world/data/profile_cache.dat,session_cache.dat,skin_cache.dat,particles_store.dat,entity_registry.dat. Each is a fake zip header followed by base64(AES-CBC). - Leaves an encrypted copy of its jar in
logs/disguised as a rotated log.
8. Indicators of compromise
- Domains:
s0lar[.]net(C2).a***-pro[.]xyz(decoy payload only, never contacted). - URLs:
https://s0lar[.]net/routes/0/a.php,.../d.php,.../download.php. - Plugin names:
DataFixerUpperwith main classcom.mojang.datafixers.util.impl.NbtRebuilder;bStats-Release-3. Neither is a real plugin. Solar will not appear in/plugins. - Operator UUIDs:
e19212b9-53d8-44cd-8eac-41c85f8b0e28,d7b77c65-23fe-4159-b73c-50fc3d2c293d. - Files: the five
.datnames above inside any world'sdata/folder (vanilla does not create them); alogs/*.log.gzwhose name ends in invisible characters and that is not valid gzip; plugin or server jars whose signature files orchecksums.md5disappeared. - Hashes: section 1.
- AES key: section 7.2.
9. Cleaning an infected server
- 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. - Delete the five fake
.datfiles from every world'sdata/folder and the disguised file inlogs/. A surviving copy can be loaded again. - Ban both operator UUIDs and reset the bStats server UUID.
- 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.
- 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.
- 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.