Skip to content

Commit 69189d0

Browse files
authored
Implement jar scanner to check files for Fractureiser malware (#49)
1 parent b9ff03a commit 69189d0

File tree

3 files changed

+195
-1
lines changed

3 files changed

+195
-1
lines changed

build.gradle

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ plugins {
22
id 'com.gradle.plugin-publish' version '1.1.0'
33
}
44

5-
version = '2.7.5'
5+
version = '2.8.0'
66
group = 'com.modrinth.minotaur'
77
archivesBaseName = 'Minotaur'
88
description = 'Modrinth plugin for publishing builds to the website!'
@@ -21,6 +21,8 @@ dependencies {
2121
compileOnly gradleApi()
2222
compileOnly group: 'org.jetbrains', name: 'annotations', version: '+'
2323
api group: 'dev.masecla', name: 'Modrinth4J', version: '2.1.0'
24+
api group: 'org.ow2.asm', name: 'asm', version: '9.5'
25+
api group: 'org.ow2.asm', name: 'asm-tree', version: '9.5'
2426
compileOnly group: 'io.papermc.paperweight', name: 'paperweight-userdev', version: '1.5.2'
2527
}
2628

src/main/java/com/modrinth/minotaur/TaskModrinthUpload.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import com.google.gson.GsonBuilder;
55
import com.modrinth.minotaur.dependencies.Dependency;
66
import com.modrinth.minotaur.responses.ResponseUpload;
7+
import com.modrinth.minotaur.scanner.JarInfectionScanner;
78
import io.papermc.paperweight.userdev.PaperweightUserExtension;
89
import masecla.modrinth4j.endpoints.version.CreateVersion.CreateVersionRequest;
910
import masecla.modrinth4j.main.ModrinthAPI;
@@ -22,7 +23,10 @@
2223

2324
import javax.annotation.Nullable;
2425
import java.io.File;
26+
import java.io.IOException;
2527
import java.util.*;
28+
import java.util.zip.ZipException;
29+
import java.util.zip.ZipFile;
2630

2731
import static com.modrinth.minotaur.Util.*;
2832

@@ -195,6 +199,17 @@ && getProject().getExtensions().findByName("loom") != null) {
195199
files.add(resolvedFile);
196200
});
197201

202+
// Scan detected files for presence of the Fractureiser malware
203+
files.forEach(file -> {
204+
try (ZipFile zipFile = new ZipFile(file)) {
205+
JarInfectionScanner.scan(getLogger(), zipFile);
206+
} catch (ZipException e) {
207+
getLogger().warn("Failed to scan {}. Not a valid zip or jar file", file.getName(), e);
208+
} catch (IOException e) {
209+
throw new GradleException(String.format("Failed to scan %s", file.getName()), e);
210+
}
211+
});
212+
198213
// Start construction of the actual request!
199214
CreateVersionRequest data = CreateVersionRequest.builder()
200215
.projectId(id)
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
package com.modrinth.minotaur.scanner;
2+
3+
import org.gradle.api.GradleException;
4+
import org.gradle.api.logging.Logger;
5+
import org.objectweb.asm.ClassReader;
6+
import org.objectweb.asm.tree.*;
7+
8+
import java.io.ByteArrayOutputStream;
9+
import java.io.IOException;
10+
import java.io.InputStream;
11+
import java.util.zip.ZipFile;
12+
13+
import static org.objectweb.asm.Opcodes.*;
14+
15+
/**
16+
* Jar Scanner to scan for the Fractureiser malware
17+
* Contains code copied from <a href="https://github.com/MCRcortex/nekodetector/blob/master/src/main/java/me/cortex/jarscanner/Detector.java">...</a>
18+
* with permission
19+
*/
20+
public class JarInfectionScanner {
21+
22+
public static void scan(Logger logger, ZipFile file) {
23+
try {
24+
boolean matches = file.stream()
25+
.filter(entry -> entry.getName().endsWith(".class"))
26+
.anyMatch(entry -> {
27+
try {
28+
return scanClass(readAllBytes(file.getInputStream(entry)));
29+
} catch (IOException e) {
30+
throw new RuntimeException(e);
31+
}
32+
});
33+
try {
34+
file.close();
35+
} catch (IOException e) {
36+
e.printStackTrace();
37+
}
38+
if (!matches)
39+
return;
40+
throw new GradleException(String.format("!!!! %s is infected with Fractureiser", file.getName()));
41+
} catch (Exception e) {
42+
logger.error("Failed to scan {}", file.getName(), e);
43+
}
44+
45+
logger.info("Fractureiser not detected in {}", file.getName());
46+
}
47+
48+
private static final AbstractInsnNode[] SIG1 = new AbstractInsnNode[] {
49+
new TypeInsnNode(NEW, "java/lang/String"),
50+
new MethodInsnNode(INVOKESPECIAL, "java/lang/String", "<init>", "([B)V"),
51+
new TypeInsnNode(NEW, "java/lang/String"),
52+
new MethodInsnNode(INVOKESPECIAL, "java/lang/String", "<init>", "([B)V"),
53+
new MethodInsnNode(INVOKESTATIC, "java/lang/Class", "forName", "(Ljava/lang/String;)Ljava/lang/Class;"),
54+
new MethodInsnNode(INVOKEVIRTUAL, "java/lang/Class", "getConstructor", "([Ljava/lang/Class;)Ljava/lang/reflect/Constructor;"),
55+
new MethodInsnNode(INVOKESPECIAL, "java/lang/String", "<init>", "([B)V"),
56+
new MethodInsnNode(INVOKESPECIAL, "java/lang/String", "<init>", "([B)V"),
57+
new MethodInsnNode(INVOKESPECIAL, "java/lang/String", "<init>", "([B)V"),
58+
new MethodInsnNode(INVOKESPECIAL, "java/net/URL", "<init>", "(Ljava/lang/String;Ljava/lang/String;ILjava/lang/String;)V"),
59+
new MethodInsnNode(INVOKEVIRTUAL, "java/lang/reflect/Constructor", "newInstance", "([Ljava/lang/Object;)Ljava/lang/Object;"),
60+
new MethodInsnNode(INVOKESTATIC, "java/lang/Class", "forName", "(Ljava/lang/String;ZLjava/lang/ClassLoader;)Ljava/lang/Class;"),
61+
new MethodInsnNode(INVOKESPECIAL, "java/lang/String", "<init>", "([B)V"),
62+
new MethodInsnNode(INVOKEVIRTUAL, "java/lang/Class", "getMethod", "(Ljava/lang/String;[Ljava/lang/Class;)Ljava/lang/reflect/Method;"),
63+
new MethodInsnNode(INVOKEVIRTUAL, "java/lang/reflect/Method", "invoke", "(Ljava/lang/Object;[Ljava/lang/Object;)Ljava/lang/Object;"),
64+
};
65+
66+
private static final AbstractInsnNode[] SIG2 = new AbstractInsnNode[] {
67+
new MethodInsnNode(INVOKESTATIC, "java/lang/Runtime", "getRuntime", "()Ljava/lang/Runtime;"),
68+
new MethodInsnNode(INVOKESTATIC, "java/util/Base64", "getDecoder", "()Ljava/util/Base64$Decoder;"),
69+
new MethodInsnNode(INVOKEVIRTUAL, "java/lang/String", "INVOKEVIRTUAL", "(Ljava/lang/String;)Ljava/lang/String;"),//TODO:FIXME: this might not be in all of them
70+
new MethodInsnNode(INVOKEVIRTUAL, "java/util/Base64$Decoder", "decode", "(Ljava/lang/String;)[B"),
71+
new MethodInsnNode(INVOKESPECIAL, "java/lang/String", "<init>", "([B)V"),
72+
new MethodInsnNode(INVOKEVIRTUAL, "java/io/File", "getPath", "()Ljava/lang/String;"),
73+
new MethodInsnNode(INVOKEVIRTUAL, "java/lang/Runtime", "exec", "([Ljava/lang/String;)Ljava/lang/Process;"),
74+
};
75+
76+
private static boolean same(AbstractInsnNode a, AbstractInsnNode b) {
77+
if (a instanceof TypeInsnNode) {
78+
return ((TypeInsnNode)a).desc.equals(((TypeInsnNode)b).desc);
79+
}
80+
if (a instanceof MethodInsnNode) {
81+
return ((MethodInsnNode)a).owner.equals(((MethodInsnNode)b).owner) && ((MethodInsnNode)a).desc.equals(((MethodInsnNode)b).desc);
82+
}
83+
if (a instanceof InsnNode) {
84+
return true;
85+
}
86+
throw new IllegalArgumentException("TYPE NOT ADDED");
87+
}
88+
89+
private static boolean scanClass(byte[] clazz) {
90+
ClassReader reader = new ClassReader(clazz);
91+
ClassNode node = new ClassNode();
92+
try {
93+
reader.accept(node, 0);
94+
} catch (Exception e) {
95+
return false;//Yes this is very hacky but should never happen with valid clasees
96+
}
97+
for (MethodNode method : node.methods) {
98+
{
99+
//Method 1, this is a hard detect, if it matches this it is 100% chance infected
100+
boolean match = true;
101+
int j = 0;
102+
for (int i = 0; i < method.instructions.size() && j < SIG1.length; i++) {
103+
if (method.instructions.get(i).getOpcode() == -1) {
104+
continue;
105+
}
106+
if (method.instructions.get(i).getOpcode() == SIG1[j].getOpcode()) {
107+
if (!same(method.instructions.get(i), SIG1[j++])) {
108+
match = false;
109+
break;
110+
}
111+
}
112+
}
113+
if (j != SIG1.length) {
114+
match = false;
115+
}
116+
if (match) {
117+
return true;
118+
}
119+
}
120+
121+
{
122+
//Method 2, this is a near hard detect, if it matches this it is 95% chance infected
123+
boolean match = false;
124+
outer:
125+
for (int q = 0; q < method.instructions.size(); q++) {
126+
int j = 0;
127+
for (int i = q; i < method.instructions.size() && j < SIG2.length; i++) {
128+
if (method.instructions.get(i).getOpcode() != SIG2[j].getOpcode()) {
129+
continue;
130+
}
131+
132+
if (method.instructions.get(i).getOpcode() == SIG2[j].getOpcode()) {
133+
if (!same(method.instructions.get(i), SIG2[j++])) {
134+
continue outer;
135+
}
136+
}
137+
}
138+
if (j == SIG2.length) {
139+
match = true;
140+
break;
141+
}
142+
}
143+
if (match) {
144+
return true;
145+
}
146+
}
147+
}
148+
return false;
149+
}
150+
151+
// Java 8 equivalent of InputStream.readAllBytes()
152+
private static byte[] readAllBytes(InputStream inputStream) throws IOException {
153+
final int bufLen = 1024;
154+
byte[] buf = new byte[bufLen];
155+
int readLen;
156+
IOException exception = null;
157+
158+
try {
159+
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
160+
161+
while ((readLen = inputStream.read(buf, 0, bufLen)) != -1)
162+
outputStream.write(buf, 0, readLen);
163+
164+
return outputStream.toByteArray();
165+
} catch (IOException e) {
166+
exception = e;
167+
throw e;
168+
} finally {
169+
if (exception == null) inputStream.close();
170+
else try {
171+
inputStream.close();
172+
} catch (IOException e) {
173+
exception.addSuppressed(e);
174+
}
175+
}
176+
}
177+
}

0 commit comments

Comments
 (0)