From efceeae34681c5f493a19e3b92da533d17637f8d Mon Sep 17 00:00:00 2001 From: JustRed23 Date: Sun, 30 Jun 2024 11:57:31 +0200 Subject: [PATCH] Implement stacktrace deobfuscation --- build.gradle | 8 + .../ketting/common/deobf/ClassMapping.java | 9 + .../ketting/common/deobf/Mapping.java | 12 ++ .../ketting/common/deobf/MappingLoader.java | 96 +++++++++ .../common/deobf/StacktraceDeobfuscator.java | 182 ++++++++++++++++++ 5 files changed, 307 insertions(+) create mode 100644 src/main/java/org/kettingpowered/ketting/common/deobf/ClassMapping.java create mode 100644 src/main/java/org/kettingpowered/ketting/common/deobf/Mapping.java create mode 100644 src/main/java/org/kettingpowered/ketting/common/deobf/MappingLoader.java create mode 100644 src/main/java/org/kettingpowered/ketting/common/deobf/StacktraceDeobfuscator.java diff --git a/build.gradle b/build.gradle index f64dc7b..d1d0e2f 100644 --- a/build.gradle +++ b/build.gradle @@ -22,6 +22,14 @@ dependencies { api 'org.slf4j:slf4j-api:1.8.0-beta4' implementation 'org.spongepowered:mixin:0.8.5' implementation 'org.jetbrains:annotations:24.0.0' + implementation 'it.unimi.dsi:fastutil:8.5.12' + + //ASM + implementation 'org.ow2.asm:asm:9.7' + implementation 'org.ow2.asm:asm-tree:9.7' + implementation 'org.ow2.asm:asm-util:9.7' + implementation 'org.ow2.asm:asm-commons:9.7' + implementation 'org.ow2.asm:asm-analysis:9.7' } jar { diff --git a/src/main/java/org/kettingpowered/ketting/common/deobf/ClassMapping.java b/src/main/java/org/kettingpowered/ketting/common/deobf/ClassMapping.java new file mode 100644 index 0000000..3ffcbe3 --- /dev/null +++ b/src/main/java/org/kettingpowered/ketting/common/deobf/ClassMapping.java @@ -0,0 +1,9 @@ +package org.kettingpowered.ketting.common.deobf; + +import java.util.Map; + +public record ClassMapping( + String obfName, + String deobfName, + Map methods +) {} diff --git a/src/main/java/org/kettingpowered/ketting/common/deobf/Mapping.java b/src/main/java/org/kettingpowered/ketting/common/deobf/Mapping.java new file mode 100644 index 0000000..865de23 --- /dev/null +++ b/src/main/java/org/kettingpowered/ketting/common/deobf/Mapping.java @@ -0,0 +1,12 @@ +package org.kettingpowered.ketting.common.deobf; + +public record Mapping(String obfMethod, String obfDesc, String mojang) { + + static Mapping from(String obfMethod, String obfDesc, String mojang) { + return new Mapping(obfMethod, obfDesc, mojang); + } + + public String obf() { + return obfMethod + obfDesc; + } +} diff --git a/src/main/java/org/kettingpowered/ketting/common/deobf/MappingLoader.java b/src/main/java/org/kettingpowered/ketting/common/deobf/MappingLoader.java new file mode 100644 index 0000000..1b2bbe4 --- /dev/null +++ b/src/main/java/org/kettingpowered/ketting/common/deobf/MappingLoader.java @@ -0,0 +1,96 @@ +package org.kettingpowered.ketting.common.deobf; + +import net.fabricmc.mappingio.MappingReader; +import net.fabricmc.mappingio.format.MappingFormat; +import net.fabricmc.mappingio.tree.MappingTree; +import net.fabricmc.mappingio.tree.MemoryMappingTree; +import org.jetbrains.annotations.Nullable; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + +public class MappingLoader { + + public final Map mappings; + public final Map> bukkitMappings = new HashMap<>(); + + MappingLoader(File mojmap, Map bukkitMethods) throws IOException { + mappings = loadMappingsIfPresent(mojmap); + bukkitMethods.forEach(this::parseBukkitMappings); + } + + private void parseBukkitMappings(String obf, String mojang) { + String raw = obf.substring(0, obf.indexOf('(') - 1); + int lastSlash = raw.lastIndexOf('/'); + String classPath = raw.substring(0, lastSlash); + String methodName = raw.substring(lastSlash + 1); + String desc = obf.substring(obf.indexOf(methodName) + methodName.length() + 1).trim(); + bukkitMappings.computeIfAbsent(classPath.replace('/', '.'), k -> new ArrayList<>()).add(Mapping.from(methodName, desc, mojang)); + } + + private @Nullable Map loadMappingsIfPresent(File mojmap) throws IOException { + try (final @Nullable InputStream mappingsInputStream = new FileInputStream(mojmap)) { + final MemoryMappingTree tree = new MemoryMappingTree(); + MappingReader.read(new InputStreamReader(mappingsInputStream, StandardCharsets.UTF_8), MappingFormat.PROGUARD_FILE, tree); + + String LEFT = "named"; + String RIGHT = "official"; + + // Since the mapping works like 'named -> official', the source is technically the right side + tree.setSrcNamespace(RIGHT); + tree.setDstNamespaces(Collections.singletonList(LEFT)); + + final Set classes = new HashSet<>(); + + final StringPool pool = new StringPool(); + for (final MappingTree.ClassMapping cls : tree.getClasses()) { + final Map methods = new HashMap<>(); + + for (final MappingTree.MethodMapping methodMapping : cls.getMethods()) { + methods.put( + pool.string(methodKey( + methodMapping.getName(LEFT), + methodMapping.getDesc(LEFT) + )), + pool.string(methodMapping.getName(RIGHT)) + ); + } + + final ClassMapping map = new ClassMapping( + cls.getName(LEFT).replace('/', '.'), + cls.getName(RIGHT).replace('/', '.'), + Map.copyOf(methods) + ); + classes.add(map); + } + + return Set.copyOf(classes).stream().collect(Collectors.toUnmodifiableMap(ClassMapping::deobfName, map -> map)); + } + } + + public String methodKey(final String obfName, final String obfDescriptor) { + return obfName + obfDescriptor; + } + + public String getFromMojang(String className, String mojangName, String desc) { + if (mappings == null || mappings.isEmpty() || mojangName == null || mojangName.isBlank()) + return mojangName; + + ClassMapping mapping = mappings.get(className); + if (mapping == null) + return mojangName; + + return mapping.methods().getOrDefault(methodKey(mojangName, desc), mojangName); + } + + private static final class StringPool { + private final Map pool = new HashMap<>(); + + public String string(final String string) { + return this.pool.computeIfAbsent(string, Function.identity()); + } + } +} diff --git a/src/main/java/org/kettingpowered/ketting/common/deobf/StacktraceDeobfuscator.java b/src/main/java/org/kettingpowered/ketting/common/deobf/StacktraceDeobfuscator.java new file mode 100644 index 0000000..f289d7e --- /dev/null +++ b/src/main/java/org/kettingpowered/ketting/common/deobf/StacktraceDeobfuscator.java @@ -0,0 +1,182 @@ +package org.kettingpowered.ketting.common.deobf; + +import it.unimi.dsi.fastutil.ints.IntArrayList; +import it.unimi.dsi.fastutil.ints.IntList; +import org.jetbrains.annotations.Nullable; +import org.objectweb.asm.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.*; +import java.util.*; + +public class StacktraceDeobfuscator { + + private static final Logger LOGGER = LoggerFactory.getLogger(StacktraceDeobfuscator.class); + private static MappingLoader mappingLoader; + + private static boolean enabled; + + private static final Map, Map> lineMapCache = Collections.synchronizedMap(new LinkedHashMap<>(128, 0.75f, true) { + @Override + protected boolean removeEldestEntry(final Map.Entry, Map> eldest) { + return this.size() > 127; + } + }); + + public static void init(File mojmap, Map bukkitMethods) { + if (mappingLoader != null) return; + + try { + mappingLoader = new MappingLoader(mojmap, bukkitMethods); + } catch (IOException e) { + LOGGER.error("Failed to load mappings", e); + return; + } + + int mojmapSize = mappingLoader.mappings.size(); + int bukkitSize = mappingLoader.bukkitMappings.values().stream().mapToInt(List::size).sum(); + LOGGER.info("Loaded {} mappings [{} mojmap, {} bukkit]", mojmapSize + bukkitSize, mojmapSize, bukkitSize); + enabled = true; + } + + public static void setEnabled(boolean enabled) { + StacktraceDeobfuscator.enabled = enabled; + } + + public static void deobf(Throwable throwable) { + if (!enabled || mappingLoader == null) return; + + throwable.setStackTrace(deobf(throwable.getStackTrace())); + + final Throwable cause = throwable.getCause(); + if (cause != null) deobf(cause); + + Arrays.stream(throwable.getSuppressed()).forEach(StacktraceDeobfuscator::deobf); + } + + public static StackTraceElement[] deobf(StackTraceElement[] stacktrace) { + if (!enabled || mappingLoader == null || stacktrace.length == 0) return stacktrace; + + final StackTraceElement[] deobf = new StackTraceElement[stacktrace.length]; + for (int i = 0; i < stacktrace.length; i++) { + final StackTraceElement element = stacktrace[i]; + final String className = element.getClassName(); + + final List classMappings = mappingLoader.bukkitMappings.get(className); + if (classMappings == null) { + deobf[i] = element; + continue; + } + + final Class clazz; + try { + clazz = Class.forName(className); + } catch (ClassNotFoundException e) { + LOGGER.error("Failed to find class for {}", className, e); + deobf[i] = element; + continue; + } + + final String methodKey = determineMethodForLine(clazz, element.getLineNumber()); + + if (methodKey == null) { + deobf[i] = element; + continue; + } + + final Mapping map = classMappings.stream().filter(m -> m.obf().equals(methodKey)).findFirst().orElse(null); + + if (map == null) { + deobf[i] = element; + continue; + } + + final String methodName = mappingLoader.getFromMojang(className, map.mojang(), map.obfDesc()); + + deobf[i] = new StackTraceElement( + element.getClassLoaderName(), + element.getModuleName(), + element.getModuleVersion(), + className, + Objects.equals(methodName, map.mojang()) ? element.getMethodName() : methodName, + sourceFileName(className), + element.getLineNumber() + ); + } + return deobf; + } + + private static @Nullable String determineMethodForLine(final Class clazz, final int lineNumber) { + final Map lineMap = lineMapCache.computeIfAbsent(clazz, StacktraceDeobfuscator::buildLineMap); + for (final var entry : lineMap.entrySet()) { + final String methodKey = entry.getKey(); + final IntList lines = entry.getValue(); + for (int i = 0, linesSize = lines.size(); i < linesSize; i++) { + final int num = lines.getInt(i); + if (num == lineNumber) { + return methodKey; + } + } + } + return null; + } + + private static String sourceFileName(final String fullClassName) { + final int dot = fullClassName.lastIndexOf('.'); + final String className = dot == -1 + ? fullClassName + : fullClassName.substring(dot + 1); + final String rootClassName = className.split("\\$")[0]; + return rootClassName + ".java"; + } + + private static Map buildLineMap(final Class key) { + final Map lineMap = new HashMap<>(); + final class LineCollectingMethodVisitor extends MethodVisitor { + private final IntList lines = new IntArrayList(); + private final String name; + private final String descriptor; + + LineCollectingMethodVisitor(String name, String descriptor) { + super(Opcodes.ASM9); + this.name = name; + this.descriptor = descriptor; + } + + @Override + public void visitLineNumber(int line, Label start) { + super.visitLineNumber(line, start); + this.lines.add(line); + } + + @Override + public void visitEnd() { + super.visitEnd(); + lineMap.put(mappingLoader.methodKey(this.name, this.descriptor), this.lines); + } + } + final ClassVisitor classVisitor = new ClassVisitor(Opcodes.ASM9) { + @Override + public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) { + return new LineCollectingMethodVisitor(name, descriptor); + } + }; + try { + final @Nullable InputStream inputStream = StacktraceDeobfuscator.class.getClassLoader() + .getResourceAsStream(key.getName().replace('.', '/') + ".class"); + if (inputStream == null) { + throw new IllegalStateException("Could not find class file: " + key.getName()); + } + final byte[] classData; + try (inputStream) { + classData = inputStream.readAllBytes(); + } + final ClassReader reader = new ClassReader(classData); + reader.accept(classVisitor, 0); + } catch (final IOException ex) { + throw new RuntimeException(ex); + } + return lineMap; + } +}