阶段四:Spine 3.8.99 编辑器里挂上自建视图面板

目标:在 Spine 编辑器窗口内部加一个自己的视图面板(按钮 + 文字显示),
点击读取场景对象,并像原生面板一样出现在 Views 菜单、可停靠可折叠。

本轮打通:
1. 编辑器在我们自己的进程里完整启动
   - 类加载器必须是 URLClassLoader 本身(自检要求)
   - Editor.main 必须在 AWT EDT 上调用
   - jvm.cfg 第 21 行要以两个空格结尾 + -DSpineJava
   - Editor.D:I 版本号、fonts/、/tzax 资源、Java8 版 launcher.Launcher 替身
2. 3.8.99 全量解密 → 可读源码
   - 字符串 17251/17461(伪造同名调用帧对齐 key)
   - 混淆动态派发还原 53871 处(调引导方法取 MemberName)
3. 自建视图
   - agent 在内存里给 us 枚举加 spineView 常量(不改磁盘,避开完整性自检)
   - 面板 Actor 由外部反射填进 us.spineView.D
   - 复用已有图标做别名(不新建图片资源)
   最终 "Spine View" 出现在 Views 菜单,可与 Tree 并排停靠、折叠展开

另外:
- ViewPatcher/ViewAgent:视图枚举与面板挂载的字节码插桩
- DecAll/ObMap/rewrite_source:字符串解密、派发还原、可读源码生成
- 阶段四-视图面板挂载.md:完整记录做法与踩过的坑

不纳入版本管理:rea/(独立仓库)、tools 下的 JDK/Ghidra 工具链、
Spine.app 与 spine-lab 副本、从载荷派生的批量产物。
This commit is contained in:
tianmo committed 2026-10-16 13:34:18 +08:00
commit 2e7ab7c33c
113 files changed
+25327

No files matched your search

+90
View File
@@ -0,0 +1,90 @@
import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.Instrumentation;
import java.security.ProtectionDomain;
import java.util.ListIterator;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.Opcodes;
import org.objectweb.asm.tree.AbstractInsnNode;
import org.objectweb.asm.tree.ClassNode;
import org.objectweb.asm.tree.InsnList;
import org.objectweb.asm.tree.InsnNode;
import org.objectweb.asm.tree.InvokeDynamicInsnNode;
import org.objectweb.asm.tree.JumpInsnNode;
import org.objectweb.asm.tree.MethodNode;
import org.objectweb.asm.tree.VarInsnNode;
/**
* 3.8.99 载荷跑在自备 JDK8 上时,Editor.main 里有一道
* "请使用 Spine 自带的 Java 运行时" 的校验,不通过就 Runtime.halt(1)。
*
* 字节码里的形状是:
* iload 2
* ifne <成功分支>
* goto <报错 + halt>
*
* 这个 agent 把那段 iload 前面塞上 iconst_1 / istore 2,
* 让分支永远走成功那一条。只作用于内存中的类,不动磁盘文件。
*/
public final class ForceAgent {
private static final String TARGET = "com/esotericsoftware/spine/editor/Editor";
private static boolean patched;
private static boolean skipLicencePatch;
public static void premain(String arguments, Instrumentation instrumentation) {
skipLicencePatch = arguments != null && arguments.contains("wyridonly");
System.err.println("[force-agent] premain 已加载" + (skipLicencePatch ? "(只改 WYRiDdid)" : ""));
instrumentation.addTransformer(new ClassFileTransformer() {
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> beingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer) {
if (!TARGET.equals(className)) return null;
try {
return patch(classfileBuffer);
} catch (Throwable error) {
System.err.println("[force-agent] 打补丁失败: " + error);
return null;
}
}
});
}
private static byte[] patch(byte[] data) {
ClassNode node = new ClassNode();
new ClassReader(data).accept(node, 0);
for (MethodNode method : node.methods) {
if (!"main".equals(method.name) || !"([Ljava/lang/String;)V".equals(method.desc)) continue;
InsnList list = method.instructions;
for (ListIterator<AbstractInsnNode> it = list.iterator(); it.hasNext();) {
AbstractInsnNode insn = it.next();
// 第二道门:invokedynamic WYRiDdid()Z 为 false 就直接 return。
// 直接把这次调用的结果换成常量 true —— 一对一替换,栈效果完全一样,
// 所以栈帧表和 max_stack 都不用动(改成跳转就会破坏栈帧)。
if (insn instanceof InvokeDynamicInsnNode
&& "WYRiDdid".equals(((InvokeDynamicInsnNode) insn).name)) {
list.set(insn, new InsnNode(Opcodes.ICONST_1));
System.err.println("[force-agent] 已把 WYRiDdid() 守卫改成永远通过");
}
if (insn.getOpcode() != Opcodes.ILOAD || ((VarInsnNode) insn).var != 2) continue;
if (skipLicencePatch) continue;
AbstractInsnNode next = insn.getNext();
if (next == null || next.getOpcode() != Opcodes.IFNE) continue;
AbstractInsnNode after = next.getNext();
if (after == null || after.getOpcode() != Opcodes.GOTO) continue;
InsnList force = new InsnList();
force.add(new InsnNode(Opcodes.ICONST_1));
force.add(new VarInsnNode(Opcodes.ISTORE, 2));
list.insertBefore(insn, force);
patched = true;
System.err.println("[force-agent] 已把 Editor.main 的运行时校验改成永远通过");
}
}
if (!patched) return null;
// 只插两条不入栈的指令,帧信息不变,用 COMPUTE_MAXS 就够了。
// 用 COMPUTE_FRAMES 的话 ASM 会在定义类的过程中反射加载同一个类,
// 触发 "attempted duplicate class definition"。
ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_MAXS);
node.accept(writer);
return writer.toByteArray();
}
}
+60
View File
@@ -0,0 +1,60 @@
import java.lang.instrument.Instrumentation;
import java.security.Permission;
/**
* 极小 javaagent:拦 System.exit,把调用栈打出来。
*
* 目的:3.8.99 编辑器在"必须用捆绑运行时"那道检查后会退出,
* 拿到 checkExit 的调用栈就知道检查写在哪、校验的是什么,不用再猜。
*
* 用法:-javaagent:rea-agent.jar
*/
public final class ReaAgent {
public static void premain(String arguments, Instrumentation instrumentation) {
System.err.println("[rea-agent] premain 已加载");
// JVM 退出时把主线程等的位置打出来。进程是"静默退出"还是"崩在别处",
// 看一次栈就够,不用再猜。
Runtime.getRuntime().addShutdownHook(new Thread() {
@Override
public void run() {
System.err.println("[rea-agent] JVM 正在退出,当前线程栈:");
for (java.util.Map.Entry<Thread, StackTraceElement[]> entry
: Thread.getAllStackTraces().entrySet()) {
Thread thread = entry.getKey();
if (!thread.isAlive()) continue;
System.err.println("[rea-agent] 线程 " + thread.getName()
+ (thread.isDaemon() ? " (daemon)" : ""));
for (StackTraceElement frame : entry.getValue()) {
System.err.println("[rea-agent] " + frame);
}
}
}
});
Thread.setDefaultUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() {
@Override
public void uncaughtException(Thread thread, Throwable error) {
System.err.println("[rea-agent] 未捕获异常,线程 " + thread.getName());
error.printStackTrace(System.err);
}
});
System.setSecurityManager(new SecurityManager() {
@Override
public void checkPermission(Permission permission) {
// 全部放行,只观察。
}
@Override
public void checkExit(int status) {
Throwable trace = new Throwable("checkExit(" + status + ")");
StringBuilder builder = new StringBuilder("[rea-agent] System.exit(" + status + ") 调用栈:");
for (StackTraceElement frame : trace.getStackTrace()) {
builder.append("\n ").append(frame);
}
System.err.println(builder.toString());
// 不抛异常 = 允许退出,这样行为和原来一致。
}
});
}
}
+69
View File
@@ -0,0 +1,69 @@
import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.Instrumentation;
import java.security.ProtectionDomain;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.Opcodes;
import org.objectweb.asm.tree.AbstractInsnNode;
import org.objectweb.asm.tree.ClassNode;
import org.objectweb.asm.tree.InsnList;
import org.objectweb.asm.tree.MethodInsnNode;
import org.objectweb.asm.tree.MethodNode;
import org.objectweb.asm.tree.VarInsnNode;
/**
* 在 uk(编辑器 Stage 的实现类,继承 dt)的构造函数末尾插一条:
* com.spineview.StageHook.onStage(this);
*
* 这样 Stage 一建出来我们就能拿到它,把自定义面板挂进场景树。
* 之所以必须插桩:Stage 只是 tD 里的局部变量,载荷里没有任何静态字段引用它。
*/
public final class StageAgent {
private static final String TARGET = "uk";
public static void premain(String arguments, Instrumentation instrumentation) {
System.err.println("[stage-agent] premain 已加载");
instrumentation.addTransformer(new ClassFileTransformer() {
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> beingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer) {
if (className != null && ("uk".equals(className) || "dt".equals(className)
|| "tD".equals(className))) {
System.err.println("[stage-agent] 看到类 " + className + " loader=" + loader);
}
if (!TARGET.equals(className)) return null;
try {
return patch(classfileBuffer);
} catch (Throwable error) {
System.err.println("[stage-agent] 插桩失败: " + error);
return null;
}
}
});
}
private static byte[] patch(byte[] data) {
ClassNode node = new ClassNode();
new ClassReader(data).accept(node, 0);
int patched = 0;
for (MethodNode method : node.methods) {
if (!"<init>".equals(method.name)) continue;
AbstractInsnNode last = method.instructions.getLast();
while (last != null && last.getOpcode() != Opcodes.RETURN) {
last = last.getPrevious();
}
if (last == null) continue;
InsnList hook = new InsnList();
hook.add(new VarInsnNode(Opcodes.ALOAD, 0));
hook.add(new MethodInsnNode(Opcodes.INVOKESTATIC, "com/spineview/StageHook", "onStage",
"(Ljava/lang/Object;)V", false));
method.instructions.insertBefore(last, hook);
patched++;
}
if (patched == 0) return null;
ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_MAXS);
node.accept(writer);
System.err.println("[stage-agent] 已给 uk 的 " + patched + " 个构造器插上 StageHook");
return writer.toByteArray();
}
}
+188
View File
@@ -0,0 +1,188 @@
import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.Instrumentation;
import java.security.ProtectionDomain;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.Opcodes;
import org.objectweb.asm.tree.AbstractInsnNode;
import org.objectweb.asm.tree.ClassNode;
import org.objectweb.asm.tree.FieldInsnNode;
import org.objectweb.asm.tree.FieldNode;
import org.objectweb.asm.tree.InsnList;
import org.objectweb.asm.tree.InsnNode;
import org.objectweb.asm.tree.IntInsnNode;
import org.objectweb.asm.tree.LdcInsnNode;
import org.objectweb.asm.tree.MethodInsnNode;
import org.objectweb.asm.tree.MethodNode;
import org.objectweb.asm.tree.TypeInsnNode;
import org.objectweb.asm.tree.VarInsnNode;
/**
* 在**内存里**给编辑器加一个正式视图("Spine View"),磁盘上的载荷一个字节都不改。
*
* 为什么必须走内存:编辑器启动时会自检——把它自己 jar 里所有类的 SHA1 和签名里的清单比对,
* 一旦有出入就报 "Spine's integrity check failed."。所以任何磁盘改动都不行。
*
* 两处插桩(与 tools/patch_view/ViewPatcher 完全同源):
* us.<clinit> :造一个新枚举常量 spineView,并在 $VALUES 组装完之后追加进数组
* (必须在分组列表生成之前,否则 Views 菜单里看不到它)
* tD.<init> :最后一个 `putfield us.D:Ldl;`(即给视图塞面板 Actor)之后,
* 插一条 com.spineview.ViewInstaller.install()
*/
public final class ViewAgent {
private static final String VIEW_ENUM = "us";
private static final String UI_CLASS = "tD";
private static final String VIEW_NAME = "spineView";
private static final String VIEW_TITLE = "Spine View";
private static final String VIEW_FIELD = "SPINEVIEW";
private static boolean enumDone;
private static boolean uiDone;
public static void premain(String arguments, Instrumentation instrumentation) {
System.err.println("[view-agent] premain 已加载");
instrumentation.addTransformer(new ClassFileTransformer() {
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> beingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer) {
try {
if (VIEW_ENUM.equals(className) && !enumDone
&& !"0".equals(System.getProperty("viewagent.enum"))) {
enumDone = true;
return patchEnum(classfileBuffer);
}
// 默认不再动 tD:这个混淆过的构造函数对插入指令极其敏感,
// 插一条 NOP 没事,插任何带操作数的指令都会把它的初始化流程弄坏。
// 视图面板改由 ViewInstaller 在编辑器起来之后从外部填进 us.spineView.D。
if (UI_CLASS.equals(className) && !uiDone
&& "1".equals(System.getProperty("viewagent.ui"))) {
uiDone = true;
return patchUi(classfileBuffer);
}
} catch (Throwable error) {
System.err.println("[view-agent] 插桩 " + className + " 失败: " + error);
}
return null;
}
});
}
private static byte[] patchEnum(byte[] data) {
ClassNode node = new ClassNode();
new ClassReader(data).accept(node, 0);
boolean hasField = false;
for (FieldNode field : node.fields) {
if (VIEW_FIELD.equals(field.name)) hasField = true;
}
if (!hasField) {
node.fields.add(new FieldNode(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, VIEW_FIELD,
"L" + VIEW_ENUM + ";", null, null));
}
for (MethodNode method : node.methods) {
if (!"<clinit>".equals(method.name)) continue;
InsnList head = new InsnList();
head.add(new TypeInsnNode(Opcodes.NEW, VIEW_ENUM));
head.add(new InsnNode(Opcodes.DUP));
head.add(new LdcInsnNode(VIEW_NAME));
head.add(new IntInsnNode(Opcodes.BIPUSH, 16));
head.add(new LdcInsnNode(VIEW_TITLE));
head.add(new FieldInsnNode(Opcodes.GETSTATIC, "MV", "L", "I"));
head.add(new FieldInsnNode(Opcodes.GETSTATIC, "ut", "B", "Lut;"));
head.add(new MethodInsnNode(Opcodes.INVOKESPECIAL, VIEW_ENUM, "<init>",
"(Ljava/lang/String;ILjava/lang/String;ILut;)V", false));
head.add(new FieldInsnNode(Opcodes.PUTSTATIC, VIEW_ENUM, VIEW_FIELD,
"L" + VIEW_ENUM + ";"));
method.instructions.insert(head);
AbstractInsnNode anchor = null;
for (AbstractInsnNode insn = method.instructions.getFirst(); insn != null;
insn = insn.getNext()) {
if (insn instanceof FieldInsnNode) {
FieldInsnNode field = (FieldInsnNode) insn;
if (field.getOpcode() == Opcodes.PUTSTATIC && VIEW_ENUM.equals(field.owner)
&& "B".equals(field.name) && ("[L" + VIEW_ENUM + ";").equals(field.desc)) {
anchor = insn;
}
}
}
if (anchor == null) throw new IllegalStateException("找不到 $VALUES 组装点");
InsnList extend = new InsnList();
extend.add(new FieldInsnNode(Opcodes.GETSTATIC, VIEW_ENUM, "B", "[L" + VIEW_ENUM + ";"));
extend.add(new VarInsnNode(Opcodes.ASTORE, 40));
extend.add(new VarInsnNode(Opcodes.ALOAD, 40));
extend.add(new InsnNode(Opcodes.ARRAYLENGTH));
extend.add(new InsnNode(Opcodes.ICONST_1));
extend.add(new InsnNode(Opcodes.IADD));
extend.add(new TypeInsnNode(Opcodes.ANEWARRAY, VIEW_ENUM));
extend.add(new VarInsnNode(Opcodes.ASTORE, 41));
extend.add(new VarInsnNode(Opcodes.ALOAD, 40));
extend.add(new InsnNode(Opcodes.ICONST_0));
extend.add(new VarInsnNode(Opcodes.ALOAD, 41));
extend.add(new InsnNode(Opcodes.ICONST_0));
extend.add(new VarInsnNode(Opcodes.ALOAD, 40));
extend.add(new InsnNode(Opcodes.ARRAYLENGTH));
extend.add(new MethodInsnNode(Opcodes.INVOKESTATIC, "java/lang/System", "arraycopy",
"(Ljava/lang/Object;ILjava/lang/Object;II)V", false));
extend.add(new VarInsnNode(Opcodes.ALOAD, 41));
extend.add(new VarInsnNode(Opcodes.ALOAD, 40));
extend.add(new InsnNode(Opcodes.ARRAYLENGTH));
extend.add(new FieldInsnNode(Opcodes.GETSTATIC, VIEW_ENUM, VIEW_FIELD,
"L" + VIEW_ENUM + ";"));
extend.add(new InsnNode(Opcodes.AASTORE));
extend.add(new VarInsnNode(Opcodes.ALOAD, 41));
extend.add(new FieldInsnNode(Opcodes.PUTSTATIC, VIEW_ENUM, "B", "[L" + VIEW_ENUM + ";"));
method.instructions.insert(anchor, extend);
System.err.println("[view-agent] us.<clinit> 已插入视图常量 " + VIEW_NAME);
}
ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_MAXS);
node.accept(writer);
return writer.toByteArray();
}
private static byte[] patchUi(byte[] data) {
ClassNode node = new ClassNode();
ClassReader reader = new ClassReader(data);
reader.accept(node, 0);
for (MethodNode method : node.methods) {
if (!"<init>".equals(method.name)) continue;
// 插在构造函数最末尾(最后一条 return 之前):那时皮肤、贴图都读完了,
// 期间去加载别的类不会干扰编辑器自己的资源流。
AbstractInsnNode end = method.instructions.getLast();
while (end != null && end.getOpcode() != Opcodes.RETURN) end = end.getPrevious();
if (end == null) continue;
InsnList hook = new InsnList();
if ("1".equals(System.getProperty("viewagent.mark"))) {
// 只碰 JDK 类,不加载我们自己的任何类(判定实验)
hook.add(new LdcInsnNode("spineview.tD"));
hook.add(new LdcInsnNode("1"));
hook.add(new MethodInsnNode(Opcodes.INVOKESTATIC, "java/lang/System", "setProperty",
"(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;", false));
hook.add(new InsnNode(Opcodes.POP));
} else if ("1".equals(System.getProperty("viewagent.ping"))) {
hook.add(new MethodInsnNode(Opcodes.INVOKESTATIC, "com/spineview/ViewInstaller",
"ping", "()V", false));
} else if (!"1".equals(System.getProperty("viewagent.nop"))) {
hook.add(new MethodInsnNode(Opcodes.INVOKESTATIC, "com/spineview/ViewInstaller",
"install", "()V", false));
} else {
hook.add(new InsnNode(Opcodes.NOP));
System.err.println("[view-agent] 只插 NOP(判定实验)");
}
method.instructions.insertBefore(end, hook);
// 用 ClassWriter(reader, 0) 时不重算 max_stack,必须自己抬高,
// 否则插入的代码会把操作栈写爆(表现为编辑器初始化随机的 IOException)。
// 多给几格,反正只影响这一帧的栈分配。
method.maxStack += 8;
System.err.println("[view-agent] " + method.name + method.desc
+ " 已插入 ViewInstaller.install()");
}
// 关键:用 ClassWriter(reader, 0),未改动的部分原样复制原始字节码。
// 之前用 COMPUTE_MAXS 全量重写这个 14 万字节、混淆得很厉害的类,
// 会把它的初始化流程弄坏(表现为构造器里资源流出 IOException)。
// 我们只插了一条净栈效果为 0 的 invokestatic,max_stack / 栈帧都不用动。
ClassWriter writer = new ClassWriter(reader, 0);
node.accept(writer);
return writer.toByteArray();
}
}
+3
View File
@@ -0,0 +1,3 @@
Premain-Class: ReaAgent
Can-Redefine-Classes: true
Can-Retransform-Classes: true
+1
View File
@@ -0,0 +1 @@
Premain-Class: ForceAgent
+1
View File
@@ -0,0 +1 @@
Premain-Class: StageAgent
+1
View File
@@ -0,0 +1 @@
Premain-Class: ViewAgent
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+214
View File
@@ -0,0 +1,214 @@
#!/usr/bin/env python3
"""Minimal Java .class parser.
Reads every .class under a directory and prints a JSON model with the pieces we
need to identify obfuscated classes by *structure* rather than by name:
this class, super class, declared fields (name + type) and declared methods
(name + parameter/return types).
Usage:
python3 tools/classdump.py <classes-dir> [--out model.json] [--only TextButton]
"""
import argparse
import json
import os
import struct
import sys
TYPE_CHARS = {
"B": "byte", "C": "char", "D": "double", "F": "float", "I": "int",
"J": "long", "S": "short", "Z": "boolean", "V": "void",
}
def parse_descriptor(desc, at=0):
"""Turn one JVM type descriptor into (readable name, next index)."""
arrays = 0
while at < len(desc) and desc[at] == "[":
arrays += 1
at += 1
if at >= len(desc):
return "?", at
ch = desc[at]
if ch == "L":
end = desc.find(";", at)
if end < 0:
return "?", len(desc)
name = desc[at + 1:end].replace("/", ".")
at = end + 1
else:
name = TYPE_CHARS.get(ch, ch)
at += 1
return name + "[]" * arrays, at
def parse_method_descriptor(desc):
params = []
at = 0
assert desc[at] == "("
at += 1
while desc[at] != ")":
name, at = parse_descriptor(desc, at)
params.append(name)
at += 1
result, _ = parse_descriptor(desc, at)
return params, result
class Reader:
def __init__(self, data):
self.data = data
self.pos = 0
def u1(self):
value = self.data[self.pos]
self.pos += 1
return value
def u2(self):
value = struct.unpack_from(">H", self.data, self.pos)[0]
self.pos += 2
return value
def u4(self):
value = struct.unpack_from(">I", self.data, self.pos)[0]
self.pos += 4
return value
def skip(self, count):
self.pos += count
def read_constant_pool(reader):
count = reader.u2()
pool = [None] * count
index = 1
while index < count:
tag = reader.u1()
if tag == 1: # Utf8
length = reader.u2()
raw = reader.data[reader.pos:reader.pos + length]
reader.skip(length)
pool[index] = raw.decode("utf-8", "replace")
elif tag in (3, 4): # Integer, Float
reader.skip(4)
elif tag in (5, 6): # Long, Double
reader.skip(8)
index += 1
elif tag == 7: # Class -> index of the Utf8 holding the internal name
pool[index] = ("class", reader.u2())
elif tag in (8, 16, 19, 20): # String, MethodType, Module, Package
reader.skip(2)
elif tag in (9, 10, 11, 12, 17, 18): # Field/Method/Interface refs, NameAndType, Dynamic
reader.skip(4)
elif tag == 15: # MethodHandle
reader.skip(3)
else:
raise ValueError("unknown constant pool tag %d" % tag)
index += 1
return pool
def parse_class(path):
with open(path, "rb") as handle:
data = handle.read()
if len(data) < 10 or data[:4] != b"\xca\xfe\xba\xbe":
return None
reader = Reader(data)
reader.skip(4)
reader.u2() # minor
reader.u2() # major
pool = read_constant_pool(reader)
def utf8(index):
value = pool[index] if 0 < index < len(pool) else None
return value if isinstance(value, str) else None
def class_name(index):
entry = pool[index] if 0 < index < len(pool) else None
if isinstance(entry, tuple) and entry[0] == "class":
entry = utf8(entry[1])
if isinstance(entry, str):
return entry.replace("/", ".")
return None
access_flags = reader.u2()
this_class = class_name(reader.u2())
super_class = class_name(reader.u2())
interfaces = [class_name(reader.u2()) for _ in range(reader.u2())]
fields = []
for _ in range(reader.u2()):
flags = reader.u2()
name = utf8(reader.u2())
desc = utf8(reader.u2())
for _ in range(reader.u2()):
reader.u2()
reader.skip(reader.u4())
fields.append({
"name": name,
"type": parse_descriptor(desc)[0] if desc else "?",
"flags": flags,
})
methods = []
for _ in range(reader.u2()):
flags = reader.u2()
name = utf8(reader.u2())
desc = utf8(reader.u2())
for _ in range(reader.u2()):
reader.u2()
reader.skip(reader.u4())
if desc and desc.startswith("("):
params, result = parse_method_descriptor(desc)
else:
params, result = [], "?"
methods.append({"name": name, "params": params, "returns": result, "flags": flags})
return {
"name": this_class,
"super": super_class,
"interfaces": interfaces,
"flags": access_flags,
"fields": fields,
"methods": methods,
}
def main():
parser = argparse.ArgumentParser()
parser.add_argument("directory")
parser.add_argument("--out")
parser.add_argument("--only", help="only keep classes whose name contains this")
args = parser.parse_args()
model = {}
failed = 0
for dirpath, _, filenames in os.walk(args.directory):
for filename in filenames:
if not filename.endswith(".class"):
continue
path = os.path.join(dirpath, filename)
try:
entry = parse_class(path)
except Exception:
failed += 1
continue
if not entry or not entry["name"]:
failed += 1
continue
if args.only and args.only not in entry["name"]:
continue
entry["path"] = path
model[entry["name"]] = entry
print("解析成功 %d 个类,失败 %d" % (len(model), failed), file=sys.stderr)
if args.out:
with open(args.out, "w") as handle:
json.dump(model, handle)
print("已写入 %s" % args.out, file=sys.stderr)
if __name__ == "__main__":
main()
+370
View File
@@ -0,0 +1,370 @@
package com.spineview.dec;
import java.io.BufferedWriter;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.zip.ZipOutputStream;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;
import org.objectweb.asm.tree.AbstractInsnNode;
import org.objectweb.asm.tree.ClassNode;
import org.objectweb.asm.tree.InsnNode;
import org.objectweb.asm.tree.LdcInsnNode;
import org.objectweb.asm.tree.MethodInsnNode;
import org.objectweb.asm.tree.MethodNode;
/**
* 3.8.99 字符串批量解密。
*
* 原理全部来自字节码,不是猜的:
* 所有解密函数(PrefsPanes$Prefs$ToolbarPosition.f、aaQ$Wm.x 以及若干内联副本)
* 第一步都算同一个 key:
* key = (cpSize + 调用类名 + 调用方法名 + cpSize).hashCode()
* cpSize 取的是**调用解密函数的那个类**的常量池大小(两次都用 st[2])。
*
* 所以只要造一个合成类,让它
* - 类名 == 原始调用方类名
* - 方法名 == 原始调用方法名
* 由它去调解密函数,key 就自然对上;cpSize 由解密函数内部 Class.forName 从真实类读到。
*
* 用法:
* java -cp "asm.jar:本工具:已全 public 化的载荷.zip" com.spineview.dec.DecAll
* <载荷> <映射.tsv> [可读副本.zip]
*/
public final class DecAll {
private static final String DECRYPT_DESC = "(Ljava/lang/Object;)Ljava/lang/String;";
private static final String HOOK_OWNER = "sun/misc/SharedSecrets";
private static final String HOOK_NAME = "getJavaLangAccess";
public static void main(String[] args) throws Exception {
Path payload = Paths.get(args[0]);
Path mapOut = Paths.get(args[1]);
Path readableOut = args.length > 2 ? Paths.get(args[2]) : null;
// 0) 整个载荷读进内存(约 15MB,无压力)
Map<String, byte[]> entries = new LinkedHashMap<String, byte[]>();
List<String> order = new ArrayList<String>();
try (ZipFile zip = new ZipFile(payload.toFile())) {
Enumeration<? extends ZipEntry> it = zip.entries();
while (it.hasMoreElements()) {
ZipEntry entry = it.nextElement();
if (entry.isDirectory()) continue;
entries.put(entry.getName(), readAll(zip.getInputStream(entry)));
order.add(entry.getName());
}
}
System.err.println("载荷条目 " + entries.size());
// 1) 找出所有解密函数:方法体里出现 SharedSecrets.getJavaLangAccess 的 (Object)String 方法
Set<String> decryptors = new HashSet<String>();
Map<String, ClassNode> parsed = new LinkedHashMap<String, ClassNode>();
for (Map.Entry<String, byte[]> entry : entries.entrySet()) {
if (!entry.getKey().endsWith(".class")) continue;
ClassNode node;
try {
node = parse(entry.getValue());
} catch (Throwable error) {
System.err.println(" 跳过 " + entry.getKey() + " : " + error);
continue;
}
parsed.put(entry.getKey(), node);
for (MethodNode method : node.methods) {
if (!DECRYPT_DESC.equals(method.desc)) continue;
if (callsCryptHook(method)) decryptors.add(node.name + "." + method.name);
}
}
System.err.println("找到解密函数 " + decryptors.size() + " 个");
for (String id : decryptors) System.err.println(" " + id);
// 2) 找所有 "ldc 密文; invokestatic 解密函数" 位置,按 (调用类, 调用方法, 解密函数) 分组
Map<String, Group> groups = new LinkedHashMap<String, Group>();
Map<String, List<Site>> sitesByClass = new LinkedHashMap<String, List<Site>>();
for (Map.Entry<String, ClassNode> entry : parsed.entrySet()) {
ClassNode node = entry.getValue();
for (MethodNode method : node.methods) {
for (AbstractInsnNode insn = method.instructions.getFirst(); insn != null;
insn = insn.getNext()) {
if (!(insn instanceof LdcInsnNode)
|| !(((LdcInsnNode) insn).cst instanceof String)) continue;
AbstractInsnNode after = insn.getNext();
while (after != null && (after.getOpcode() == Opcodes.NOP
|| after.getOpcode() == Opcodes.CHECKCAST)) {
after = after.getNext();
}
if (!(after instanceof MethodInsnNode)) continue;
MethodInsnNode call = (MethodInsnNode) after;
if (call.getOpcode() != Opcodes.INVOKESTATIC) continue;
String id = call.owner + "." + call.name;
if (!decryptors.contains(id)) continue;
String key = node.name + '\u0000' + method.name + '\u0000' + id;
Group group = groups.get(key);
if (group == null) {
group = new Group(node.name.replace('/', '.'), method.name, call.owner, call.name);
groups.put(key, group);
}
group.ciphers.add((String) ((LdcInsnNode) insn).cst);
List<Site> list = sitesByClass.get(entry.getKey());
if (list == null) {
list = new ArrayList<Site>();
sitesByClass.put(entry.getKey(), list);
}
list.add(new Site((LdcInsnNode) insn, (MethodInsnNode) after, method, group,
group.ciphers.size() - 1));
}
}
}
int total = 0;
for (Group group : groups.values()) total += group.ciphers.size();
System.err.println("(调用类,调用方法,解密函数) 组合 " + groups.size()
+ ",待解密字符串 " + total + " 个");
// 3) 逐组解密
int done = 0, ok = 0, failed = 0;
try (BufferedWriter writer = Files.newBufferedWriter(mapOut, StandardCharsets.UTF_8)) {
for (Group group : groups.values()) {
String[] plain = group.decrypt();
group.plain = plain;
for (int i = 0; i < group.ciphers.size(); i++) {
String value = plain == null ? null : plain[i];
if (value == null) failed++; else ok++;
writer.write(group.owner);
writer.write('\t');
writer.write(group.method);
writer.write('\t');
writer.write(escape(value == null ? "<失败>" : value));
writer.write('\n');
}
if (++done % 300 == 0) System.err.println(" 进度 " + done + "/" + groups.size());
}
}
System.err.println("解密完成:成功 " + ok + ",失败 " + failed + " -> " + mapOut);
// 4) 产出"字符串已换成明文"的可读副本:ldc 换成明文,解密调用换成 NOP。
// 解密调用是 (Object)->String,净栈效果为 0,换成 NOP 后栈完全平衡,
// 栈帧表和 max_stack 都不用重算。
if (readableOut != null) {
int rewritten = 0;
try (ZipOutputStream out = new ZipOutputStream(Files.newOutputStream(readableOut))) {
for (String name : order) {
byte[] raw = entries.get(name);
List<Site> sites = sitesByClass.get(name);
if (sites != null && !sites.isEmpty()) {
ClassNode node = parse(raw);
int replaced = 0;
// 和第二步同样的遍历顺序,所以用计数器就能把密文和实际调用点对上
Map<Group, Integer> counter = new LinkedHashMap<Group, Integer>();
for (MethodNode method : node.methods) {
for (AbstractInsnNode insn = method.instructions.getFirst(); insn != null;
insn = insn.getNext()) {
if (!(insn instanceof LdcInsnNode)
|| !(((LdcInsnNode) insn).cst instanceof String)) continue;
AbstractInsnNode after = insn.getNext();
while (after != null && (after.getOpcode() == Opcodes.NOP
|| after.getOpcode() == Opcodes.CHECKCAST)) {
after = after.getNext();
}
if (!(after instanceof MethodInsnNode)) continue;
MethodInsnNode call = (MethodInsnNode) after;
if (call.getOpcode() != Opcodes.INVOKESTATIC) continue;
String key = node.name + '\u0000' + method.name + '\u0000'
+ call.owner + "." + call.name;
Group group = groups.get(key);
if (group == null || group.plain == null) continue;
Integer seen = counter.get(group);
int index = seen == null ? 0 : seen.intValue();
if (index >= group.ciphers.size()) continue;
counter.put(group, Integer.valueOf(index + 1));
((LdcInsnNode) insn).cst = group.plain[index];
method.instructions.set(call, new InsnNode(Opcodes.NOP));
replaced++;
}
}
rewritten += replaced;
ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_MAXS);
node.accept(writer);
raw = writer.toByteArray();
}
out.putNextEntry(new ZipEntry(name));
out.write(raw);
out.closeEntry();
}
}
System.err.println("可读副本已写出(替换 " + rewritten + " 处) -> " + readableOut);
}
}
private static boolean callsCryptHook(MethodNode method) {
for (AbstractInsnNode insn = method.instructions.getFirst(); insn != null;
insn = insn.getNext()) {
if (insn instanceof MethodInsnNode) {
MethodInsnNode call = (MethodInsnNode) insn;
if (HOOK_OWNER.equals(call.owner) && HOOK_NAME.equals(call.name)) return true;
}
}
return false;
}
private static ClassNode parse(byte[] raw) {
ClassNode node = new ClassNode();
new ClassReader(raw).accept(node, ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES);
return node;
}
private static String escape(String text) {
// 解出来的可能是任意 char(有些"字符串"其实是二进制数据),一律转成纯 ASCII 转义,
// 免得写 UTF-8 时因为落单代理对炸掉。
StringBuilder builder = new StringBuilder(text.length() + 8);
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
if (c == '\t') builder.append("\\t");
else if (c == '\n') builder.append("\\n");
else if (c == '\r') builder.append("\\r");
else if (c == '\\') builder.append("\\\\");
else if (c >= 0x20 && c <= 0x7E) builder.append(c);
else builder.append(String.format("\\u%04x", Integer.valueOf(c)));
}
return builder.toString();
}
private static byte[] readAll(InputStream in) throws java.io.IOException {
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
byte[] chunk = new byte[65536];
int n;
while ((n = in.read(chunk)) > 0) buffer.write(chunk, 0, n);
return buffer.toByteArray();
}
private static final class Site {
final LdcInsnNode ldc;
final MethodInsnNode call;
final MethodNode method;
final Group group;
final int index;
Site(LdcInsnNode ldc, MethodInsnNode call, MethodNode method, Group group, int index) {
this.ldc = ldc;
this.call = call;
this.method = method;
this.group = group;
this.index = index;
}
}
private static final class Group {
final String owner;
final String method;
final String decryptorOwner;
final String decryptorName;
final List<String> ciphers = new ArrayList<String>();
String[] plain;
Group(String owner, String method, String decryptorOwner, String decryptorName) {
this.owner = owner;
this.method = method;
this.decryptorOwner = decryptorOwner;
this.decryptorName = decryptorName;
}
String[] decrypt() {
try {
byte[] bytes = build(owner, method, decryptorOwner, decryptorName, ciphers);
ShadowLoader loader = new ShadowLoader(owner, bytes, DecAll.class.getClassLoader());
Class<?> cls = Class.forName(owner, false, loader);
if ("<clinit>".equals(method)) {
Class.forName(owner, true, loader);
} else if ("<init>".equals(method)) {
cls.getDeclaredConstructor().newInstance();
} else {
for (Method candidate : cls.getDeclaredMethods()) {
if (candidate.getName().equals(method)) {
candidate.setAccessible(true);
candidate.invoke(null);
break;
}
}
}
return (String[]) cls.getField("R").get(null);
} catch (Throwable error) {
Throwable cause = error;
while (cause.getCause() != null && cause.getCause() != cause) cause = cause.getCause();
System.err.println(" [失败] " + owner + "." + method + " via " + decryptorOwner + "."
+ decryptorName + " : " + error + (cause == error ? "" : " <- " + cause));
return null;
}
}
}
private static byte[] build(String owner, String method, String decryptorOwner,
String decryptorName, List<String> ciphers) {
String internal = owner.replace('.', '/');
ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_MAXS);
writer.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC | Opcodes.ACC_SUPER, internal, null,
"java/lang/Object", null);
writer.visitField(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "R", "[Ljava/lang/String;",
null, null).visitEnd();
int access = Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC;
if ("<clinit>".equals(method)) access = Opcodes.ACC_STATIC;
else if ("<init>".equals(method)) access = Opcodes.ACC_PUBLIC;
MethodVisitor code = writer.visitMethod(access, method, "()V", null, null);
code.visitCode();
if ("<init>".equals(method)) {
code.visitVarInsn(Opcodes.ALOAD, 0);
code.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "<init>", "()V", false);
}
code.visitLdcInsn(Integer.valueOf(ciphers.size()));
code.visitTypeInsn(Opcodes.ANEWARRAY, "java/lang/String");
code.visitFieldInsn(Opcodes.PUTSTATIC, internal, "R", "[Ljava/lang/String;");
for (int i = 0; i < ciphers.size(); i++) {
code.visitFieldInsn(Opcodes.GETSTATIC, internal, "R", "[Ljava/lang/String;");
code.visitLdcInsn(Integer.valueOf(i));
code.visitLdcInsn(ciphers.get(i));
code.visitMethodInsn(Opcodes.INVOKESTATIC, decryptorOwner, decryptorName, DECRYPT_DESC, false);
code.visitInsn(Opcodes.AASTORE);
}
code.visitInsn(Opcodes.RETURN);
code.visitMaxs(0, 0);
code.visitEnd();
writer.visitEnd();
return writer.toByteArray();
}
private static final class ShadowLoader extends ClassLoader {
private final String shadowed;
private final byte[] bytes;
ShadowLoader(String shadowed, byte[] bytes, ClassLoader parent) {
super(parent);
this.shadowed = shadowed;
this.bytes = bytes;
}
@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
if (name.equals(shadowed)) {
Class<?> found = findLoadedClass(name);
if (found == null) found = defineClass(name, bytes, 0, bytes.length);
if (resolve) resolveClass(found);
return found;
}
return super.loadClass(name, resolve);
}
}
}
+231
View File
@@ -0,0 +1,231 @@
package com.spineview.dec;
import java.io.BufferedWriter;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.lang.invoke.CallSite;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.Handle;
import org.objectweb.asm.tree.AbstractInsnNode;
import org.objectweb.asm.tree.ClassNode;
import org.objectweb.asm.tree.InvokeDynamicInsnNode;
import org.objectweb.asm.tree.MethodNode;
/**
* 把 3.8.99 的"混淆化动态派发"整层解出来。
*
* 编辑器里几乎每个方法调用都被编译成 invokedynamic,每个类都有自己的一份引导方法
* (Editor.Ob / AB.Nb / AD.HW ...,共 1421 个),签名统一是
* (Lookup, String 名字, MethodType, 密文描述) -> Object
* 按 JVM 的方式把它调一遍拿回 CallSite,再顺着绑定句柄里的 MemberName,
* 就能问出这次调用真正指向哪个方法。
*
* 用法:
* java -cp "asm.jar:本工具:已全 public 化的载荷.zip" com.spineview.dec.ObMap <载荷> <映射.tsv>
*/
public final class ObMap {
private static final String BSM_DESC =
"(Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;";
public static void main(String[] args) throws Exception {
Path payload = Paths.get(args[0]);
Path out = Paths.get(args[1]);
// 1) 收集所有 invokedynamic 站点
List<Site> sites = new ArrayList<Site>();
Set<String> distinctNames = new HashSet<String>();
try (ZipFile zip = new ZipFile(payload.toFile())) {
Enumeration<? extends ZipEntry> entries = zip.entries();
while (entries.hasMoreElements()) {
ZipEntry entry = entries.nextElement();
if (!entry.getName().endsWith(".class")) continue;
ClassNode node;
try {
node = parse(readAll(zip.getInputStream(entry)));
} catch (Throwable error) {
continue;
}
for (MethodNode method : node.methods) {
for (AbstractInsnNode insn = method.instructions.getFirst(); insn != null;
insn = insn.getNext()) {
if (!(insn instanceof InvokeDynamicInsnNode)) continue;
InvokeDynamicInsnNode indy = (InvokeDynamicInsnNode) insn;
Handle bsm = indy.bsm;
if (!BSM_DESC.equals(bsm.getDesc())) continue;
sites.add(new Site(node.name, method.name, bsm.getOwner(), bsm.getName(),
indy.name, indy.desc, indy.bsmArgs.length > 0 ? indy.bsmArgs[0] : null));
distinctNames.add(indy.name);
}
}
}
}
System.err.println("invokedynamic 站点 " + sites.size() + ",不同名字 " + distinctNames.size());
// 2) 每个 (引导类,引导方法,名字) 只需解析一次
Map<String, String> resolved = new LinkedHashMap<String, String>();
Map<String, Method> bootstrapCache = new HashMap<String, Method>();
MethodHandles.Lookup[] lookupCache = new MethodHandles.Lookup[1];
int done = 0, failed = 0;
for (Site site : sites) {
String triple = site.bsmOwner + "." + site.bsmName + "#" + site.indyName;
if (resolved.containsKey(triple)) continue;
String target;
try {
target = resolve(site, bootstrapCache, lookupCache);
} catch (Throwable error) {
target = "<失败> " + error;
failed++;
}
resolved.put(triple, target);
if (++done % 2000 == 0) System.err.println(" 已解析 " + done);
}
System.err.println("解析组合 " + resolved.size() + " 个,失败 " + failed);
try (BufferedWriter writer = Files.newBufferedWriter(out, StandardCharsets.UTF_8)) {
for (Site site : sites) {
String triple = site.bsmOwner + "." + site.bsmName + "#" + site.indyName;
writer.write(site.bsmOwner);
writer.write('\t');
writer.write(site.bsmName);
writer.write('\t');
writer.write(site.indyName);
writer.write('\t');
writer.write(resolved.get(triple));
writer.write('\n');
}
}
System.err.println("映射已写出 -> " + out);
}
private static String resolve(Site site, Map<String, Method> bootstrapCache,
MethodHandles.Lookup[] lookupCache) throws Throwable {
Class<?> owner = Class.forName(site.bsmOwner.replace('/', '.'));
String key = site.bsmOwner + "." + site.bsmName;
Method bootstrap = bootstrapCache.get(key);
if (bootstrap == null) {
for (Method candidate : owner.getDeclaredMethods()) {
if (candidate.getName().equals(site.bsmName)
&& candidate.getParameterTypes().length == 4) {
bootstrap = candidate;
break;
}
}
if (bootstrap == null) throw new NoSuchMethodException(site.bsmName);
bootstrap.setAccessible(true);
bootstrapCache.put(key, bootstrap);
}
if (lookupCache[0] == null) lookupCache[0] = trustedLookup();
MethodType type = MethodType.fromMethodDescriptorString(site.desc,
ObMap.class.getClassLoader());
Object callSite = bootstrap.invoke(null, lookupCache[0], site.indyName, type, site.arg);
if (!(callSite instanceof CallSite)) return String.valueOf(callSite);
return describe(((CallSite) callSite).getTarget());
}
/** CallSite 的目标是"真实方法 + 若干个已绑定的参数",往里挖一层就能拿到方法本体。 */
private static String describe(MethodHandle target) {
String direct = memberOf(target);
if (direct != null) return direct;
for (Class<?> type = target.getClass(); type != null; type = type.getSuperclass()) {
for (java.lang.reflect.Field field : type.getDeclaredFields()) {
if (!field.getName().startsWith("argL") && !field.getName().startsWith("argJ")
&& !field.getName().startsWith("argI")) continue;
try {
field.setAccessible(true);
Object value = field.get(target);
if (!(value instanceof MethodHandle)) continue;
String nested = memberOf((MethodHandle) value);
if (nested != null) return nested;
} catch (Throwable ignored) {
// 继续找
}
}
}
return "?" + target.type();
}
/** 直接句柄能拿到 MemberName,也就能知道 类名.方法名 以及是不是静态方法。 */
private static String memberOf(MethodHandle handle) {
try {
Method internal = MethodHandle.class.getDeclaredMethod("internalMemberName");
internal.setAccessible(true);
Object member = internal.invoke(handle);
if (member == null) return null;
Class<?> memberClass = member.getClass();
Method getDeclaring = memberClass.getMethod("getDeclaringClass");
Method getName = memberClass.getMethod("getName");
Method isStatic = memberClass.getMethod("isStatic");
getDeclaring.setAccessible(true);
getName.setAccessible(true);
isStatic.setAccessible(true);
Object declaring = getDeclaring.invoke(member);
String owner = String.valueOf(declaring).replace("class ", "").replace("interface ", "");
return owner + "." + getName.invoke(member)
+ (Boolean.TRUE.equals(isStatic.invoke(member)) ? " (static)" : "");
} catch (Throwable error) {
return null;
}
}
/** Java 8 上的经典手法:造一个权限全开的 Lookup。 */
private static MethodHandles.Lookup trustedLookup() throws Exception {
Constructor<MethodHandles.Lookup> constructor =
MethodHandles.Lookup.class.getDeclaredConstructor(Class.class, int.class);
constructor.setAccessible(true);
return constructor.newInstance(Object.class, Integer.valueOf(-1));
}
private static final class Site {
final String owner;
final String method;
final String bsmOwner;
final String bsmName;
final String indyName;
final String desc;
final Object arg;
Site(String owner, String method, String bsmOwner, String bsmName, String indyName,
String desc, Object arg) {
this.owner = owner;
this.method = method;
this.bsmOwner = bsmOwner;
this.bsmName = bsmName;
this.indyName = indyName;
this.desc = desc;
this.arg = arg;
}
}
private static ClassNode parse(byte[] raw) {
ClassNode node = new ClassNode();
new ClassReader(raw).accept(node, ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES);
return node;
}
private static byte[] readAll(InputStream in) throws java.io.IOException {
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
byte[] chunk = new byte[65536];
int n;
while ((n = in.read(chunk)) > 0) buffer.write(chunk, 0, n);
return buffer.toByteArray();
}
}
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""把 3.8.99 载荷里所有方法的访问标志加上 public。
为什么敢改访问标志:解密函数算 key 用的是 **(调用方类)** 的常量池大小
key = (cpSize + 调用类名 + 调用方法名 + cpSize).hashCode()
其中 cpSize 每次都取 st[2](调用解密函数的那个类),跟解密函数所在类无关。
而 access_flags 是方法头里的两个字节,改它不会动常量池,密文一个字节都不受影响。
改 public 的目的:我们可以造一个合成类,用**原始调用方的类名和方法名**去调解密函数,
从而让 key 自然对上,不需要自己实现 AES —— 载荷里有好几个内联副本,全开 public 最省事。
"""
import sys
import zipfile
ACC_PUBLIC = 0x0001
ACC_PRIVATE = 0x0002
ACC_PROTECTED = 0x0004
class Reader:
def __init__(self, data):
self.d = data
self.p = 0
def u1(self):
v = self.d[self.p]
self.p += 1
return v
def u2(self):
v = (self.d[self.p] << 8) | self.d[self.p + 1]
self.p += 2
return v
def u4(self):
v = int.from_bytes(self.d[self.p:self.p + 4], "big")
self.p += 4
return v
def skip(self, n):
self.p += n
def parse_constant_pool(r):
"""返回 (utf8 列表, 索引->绝对偏移) 以及常量池结束位置。"""
count = r.u2()
utf8 = {}
offsets = {}
i = 1
while i < count:
offsets[i] = r.p
tag = r.u1()
if tag == 1:
length = r.u2()
raw = r.d[r.p:r.p + length]
r.skip(length)
utf8[i] = raw
elif tag in (3, 4):
r.skip(4)
elif tag in (5, 6):
r.skip(8)
i += 1
elif tag in (7, 8, 16, 19, 20):
r.skip(2)
elif tag in (9, 10, 11, 12, 17, 18):
r.skip(4)
elif tag == 15:
r.skip(3)
else:
raise ValueError("未知常量池 tag %d @%d" % (tag, r.p - 1))
i += 1
return utf8, offsets, count
def skip_attributes(r):
n = r.u2()
for _ in range(n):
r.skip(2)
r.skip(r.u4())
def patch(data):
"""把类里所有方法的访问标志加上 ACC_PUBLIC,就地修改 data(bytearray)。"""
r = Reader(data)
if r.u4() != 0xCAFEBABE:
raise ValueError("不是 class 文件")
r.u2()
r.u2()
utf8, offsets, cp_count = parse_constant_pool(r)
r.skip(2) # access_flags(类的)
r.skip(2) # this_class
r.skip(2) # super_class
r.skip(2 * r.u2()) # interfaces
for _ in range(r.u2()): # fields
r.skip(6)
skip_attributes(r)
changed = 0
for _ in range(r.u2()): # methods
access_pos = r.p
access = r.u2()
r.skip(4) # name_index / descriptor_index
skip_attributes(r)
# 不能直接 OR:private static final 是 0x1A,OR 上 0x01 会变成 0x1B(public+private,非法)
new_access = (access & ~(ACC_PRIVATE | ACC_PROTECTED)) | ACC_PUBLIC
if new_access != access:
data[access_pos] = (new_access >> 8) & 0xFF
data[access_pos + 1] = new_access & 0xFF
changed += 1
return changed, cp_count
def main():
source, target = sys.argv[1], sys.argv[2]
patched_classes = 0
patched_methods = 0
with zipfile.ZipFile(source) as zin:
with zipfile.ZipFile(target, "w", zipfile.ZIP_DEFLATED) as zout:
for item in zin.infolist():
raw = zin.read(item.filename)
if item.filename.endswith(".class"):
raw = bytearray(raw)
try:
changed, cp_count = patch(raw)
patched_classes += 1
patched_methods += changed
except Exception as error:
# 载荷里有几个不以 CAFEBABE 开头的 .class(比如 wvv.class 是签名数据),跳过
print("跳过 %s : %s" % (item.filename, error))
zout.writestr(item, raw)
print("已把 %d 个类里的 %d 个方法改成 public" % (patched_classes, patched_methods))
print("已写出 " + target)
if __name__ == "__main__":
main()
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""把反编译源码里的混淆动态派发还原成真实方法名。
编辑器里几乎每个调用都被编译成 invokedynamic,反编译出来长这样:
yf_2.OB("lKRXTTGf", "<密文>", "Find")
其中 "lKRXTTGf" 是混淆名,Ob/OB/Nb/HW… 是每个类各自的引导方法。
obmap.tsv 给出了每个混淆名真正指向的方法,于是:
yf_2.OB("lKRXTTGf", "<密文>", "Find") -> java.lang.String.contains("Find")
实例方法统一写成 类名.方法(接收者, 参数...) 的静态形式 —— 不做参数搬移,
这样永远不会解析错参数边界,读起来照样一目了然。
用法:rewrite_source.py <反编译目录> <obmap.tsv> <输出目录>
"""
import os
import re
import sys
# X.bsm("混淆名", "<密文>", <- 只吃掉这一段
CALL = re.compile(
r'([A-Za-z_$][\w$]*)\.([A-Za-z_$][\w$]*)\(\s*"([A-Za-z0-9_$]+)"\s*,\s*"(?:[^"\\]|\\.)*"\s*(?:,\s*)?'
)
def load_map(path):
mapping = {}
with open(path, encoding="utf-8") as handle:
for line in handle:
parts = line.rstrip("\n").split("\t")
if len(parts) < 4:
continue
name, target = parts[2], parts[3]
if target.startswith("<失败>") or target.startswith("?"):
continue
target = target.replace(" (static)", "")
mapping.setdefault(name, target)
return mapping
def main():
source_dir, map_path, out_dir = sys.argv[1], sys.argv[2], sys.argv[3]
mapping = load_map(map_path)
print("映射条目 %d" % len(mapping))
files = replaced_total = files_touched = 0
missed = set()
for root, _, names in os.walk(source_dir):
for name in names:
if not name.endswith(".java"):
continue
files += 1
path = os.path.join(root, name)
with open(path, encoding="utf-8", errors="replace") as handle:
text = handle.read()
count = [0]
def swap(match):
target = mapping.get(match.group(3))
if target is None:
missed.add(match.group(3))
return match.group(0)
count[0] += 1
return target + "("
new_text = CALL.sub(swap, text)
if count[0]:
files_touched += 1
replaced_total += count[0]
relative = os.path.relpath(path, source_dir)
destination = os.path.join(out_dir, relative)
os.makedirs(os.path.dirname(destination), exist_ok=True)
with open(destination, "w", encoding="utf-8") as handle:
handle.write(new_text)
print("扫描 %d 个文件,改写 %d 个文件,替换 %d 处调用" % (files, files_touched, replaced_total))
print("没映射上的混淆名 %d 个" % len(missed))
if __name__ == "__main__":
main()
+34
View File
@@ -0,0 +1,34 @@
#!/bin/zsh
# 分块并行下载 Ghidra(GitHub release 单连接只有 ~150KB/s,多连接能快很多)。
set -e
URL="https://github.com/NationalSecurityAgency/ghidra/releases/download/Ghidra_12.1.4_build/ghidra_12.1.4_PUBLIC_20260921.zip"
TOTAL=569649598
PARTS=8
HERE="$(cd "$(dirname "$0")" && pwd)"
OUT="$HERE/ghidra.zip"
CHUNK=$(( (TOTAL + PARTS - 1) / PARTS ))
for index in $(seq 0 $((PARTS - 1))); do
start=$(( index * CHUNK ))
end=$(( start + CHUNK - 1 ))
(( end >= TOTAL )) && end=$(( TOTAL - 1 ))
part="$HERE/.ghidra.part$index"
have=0
[[ -f "$part" ]] && have=$(stat -f%z "$part")
want=$(( end - start + 1 ))
if (( have >= want )); then
echo "part$index 已完成 ($have)"
continue
fi
echo "part$index -> $(( have )) / $want"
curl -sL --max-time 1800 -r $(( start + have ))-$end -o "$part" "$URL" &
done
wait
echo "拼接"
: > "$OUT"
for index in $(seq 0 $((PARTS - 1))); do
cat "$HERE/.ghidra.part$index" >> "$OUT"
done
ls -la "$OUT"
+114
View File
@@ -0,0 +1,114 @@
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.jar.JarEntry;
import java.util.jar.JarInputStream;
import java.util.jar.JarOutputStream;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;
/**
* 在 Spine 3.8.99 的 launcher.jar 里插一条调用:
* com.esotericsoftware.spine.editor.launcher.Launcher.main(String[])
* 的第一行改成先调 com.spineview.SceneScan.main(String[]),
* 并把我们编译好的类一起打进 jar。
*
* 因为 3.8.99 是明文的,这么做之后我们的代码就和编辑器处在同一个类加载器里,
* 不再有"跨加载器看不到 TextButton"的问题。
*/
public final class Patcher {
/** 目标类 / 方法 / 描述符,从命令行传(args[3..5])。 */
private static String targetClass;
private static String targetEntry;
private static String hookMethod;
private static String hookDesc;
private static final String HOOK_OWNER = "com/spineview/SceneScan";
public static void main(String[] args) throws Exception {
Path source = Paths.get(args[0]);
Path target = Paths.get(args[1]);
Path classesDir = Paths.get(args[2]);
targetClass = args.length > 3 ? args[3] : "com/esotericsoftware/spine/editor/launcher/Launcher";
String methodName = args.length > 4 ? args[4] : "main";
hookMethod = args.length > 5 ? args[5] : "main";
hookDesc = args.length > 6 ? args[6] : "([Ljava/lang/String;)V";
targetEntry = targetClass + ".class";
Map<String, byte[]> entries = new LinkedHashMap<String, byte[]>();
try (JarInputStream in = new JarInputStream(Files.newInputStream(source))) {
JarEntry entry;
while ((entry = in.getNextJarEntry()) != null) {
if (entry.isDirectory()) continue;
entries.put(entry.getName(), readAll(in));
}
}
System.out.println("原 jar 条目数 = " + entries.size());
byte[] original = entries.get(targetEntry);
if (original == null) throw new IllegalStateException("找不到 " + targetEntry);
entries.put(targetEntry, patch(original, methodName));
System.out.println("已给 " + targetClass + "." + methodName + " 插入了 " + HOOK_OWNER + "." + hookMethod);
int added = 0;
try (java.util.stream.Stream<Path> stream = Files.walk(classesDir)) {
for (Path path : (Iterable<Path>) stream::iterator) {
if (!Files.isRegularFile(path)) continue;
String relative = classesDir.relativize(path).toString();
if (!relative.endsWith(".class")) continue;
entries.put(relative.replace('\\', '/'), Files.readAllBytes(path));
added++;
}
}
System.out.println("加进 jar 的类 = " + added);
try (JarOutputStream out = new JarOutputStream(Files.newOutputStream(target))) {
for (Map.Entry<String, byte[]> entry : entries.entrySet()) {
JarEntry jarEntry = new JarEntry(entry.getKey());
jarEntry.setTime(0L);
out.putNextEntry(jarEntry);
out.write(entry.getValue());
out.closeEntry();
}
}
System.out.println("已写出 " + target);
}
private static byte[] patch(byte[] original, final String methodName) {
ClassReader reader = new ClassReader(original);
ClassWriter writer = new ClassWriter(0);
ClassVisitor visitor = new ClassVisitor(Opcodes.ASM9, writer) {
@Override
public MethodVisitor visitMethod(int access, String name, String descriptor,
String signature, String[] exceptions) {
MethodVisitor next = super.visitMethod(access, name, descriptor, signature, exceptions);
if (!methodName.equals(name) || !hookDesc.equals(descriptor)) return next;
return new MethodVisitor(Opcodes.ASM9, next) {
@Override
public void visitCode() {
super.visitCode();
// 我们的钩子是 ()V,不需要压任何参数;栈平衡保持不变。
visitMethodInsn(Opcodes.INVOKESTATIC, HOOK_OWNER, hookMethod, hookDesc, false);
}
};
}
};
reader.accept(visitor, 0);
return writer.toByteArray();
}
private static byte[] readAll(InputStream in) throws Exception {
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
byte[] chunk = new byte[16384];
int read;
while ((read = in.read(chunk)) > 0) buffer.write(chunk, 0, read);
return buffer.toByteArray();
}
}
+196
View File
@@ -0,0 +1,196 @@
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.jar.JarEntry;
import java.util.jar.JarInputStream;
import java.util.jar.JarOutputStream;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.Opcodes;
import org.objectweb.asm.tree.AbstractInsnNode;
import org.objectweb.asm.tree.ClassNode;
import org.objectweb.asm.tree.FieldInsnNode;
import org.objectweb.asm.tree.FieldNode;
import org.objectweb.asm.tree.InsnList;
import org.objectweb.asm.tree.InsnNode;
import org.objectweb.asm.tree.IntInsnNode;
import org.objectweb.asm.tree.LdcInsnNode;
import org.objectweb.asm.tree.MethodInsnNode;
import org.objectweb.asm.tree.MethodNode;
import org.objectweb.asm.tree.TypeInsnNode;
import org.objectweb.asm.tree.VarInsnNode;
/**
* 给 Spine 3.8.99 的编辑器载荷加一个"正式视图"。
*
* 视图系统的情况(全部从字节码读出来的):
* - us 是视图枚举:us("meshTools", 4, "Mesh Tools", MV.L, ut.B)
* <clinit> 里建好全部常量、组装 $VALUES(字段 B:[Lus;),再按 MV/ut 分成左右两组列表。
* - tD.<init> 给每个视图 new 一个 Actor 并挂到 us.X.D。
*
* 所以两处补丁:
* 1) us:<clinit> 开头造一个新常量 "spineView",并在 $VALUES 组装完之后把它追加进数组
* (必须在分组列表生成之前追加,否则 Views 菜单里看不到)。
* 2) tD:<init> 里最后一个 us.X.D 赋值之后,插一条调用
* com.spineview.ViewInstaller.install(),由我们把面板 Actor 塞进 us.SPINEVIEW.D。
*
* 用法:java -cp asm.jar:... ViewPatcher <源载荷> <目标载荷>
*/
public final class ViewPatcher {
private static final String VIEW_ENUM = "us";
private static final String UI_CLASS = "tD";
private static final String VIEW_NAME = "spineView";
private static final String VIEW_TITLE = "Spine View";
private static final String VIEW_FIELD = "SPINEVIEW";
public static void main(String[] args) throws Exception {
Path source = Paths.get(args[0]);
Path target = Paths.get(args[1]);
Map<String, byte[]> entries = new LinkedHashMap<String, byte[]>();
try (JarInputStream in = new JarInputStream(Files.newInputStream(source))) {
JarEntry entry;
while ((entry = in.getNextJarEntry()) != null) {
if (entry.isDirectory()) continue;
entries.put(entry.getName(), readAll(in));
}
}
System.out.println("载荷条目 " + entries.size());
entries.put(VIEW_ENUM + ".class", patchEnum(entries.get(VIEW_ENUM + ".class")));
entries.put(UI_CLASS + ".class", patchUi(entries.get(UI_CLASS + ".class")));
try (JarOutputStream out = new JarOutputStream(Files.newOutputStream(target))) {
for (Map.Entry<String, byte[]> entry : entries.entrySet()) {
out.putNextEntry(new JarEntry(entry.getKey()));
out.write(entry.getValue());
out.closeEntry();
}
}
System.out.println("已写出 " + target);
}
/** 给 us 加一个新枚举常量。 */
private static byte[] patchEnum(byte[] data) {
ClassNode node = new ClassNode();
new ClassReader(data).accept(node, 0);
boolean hasField = false;
for (FieldNode field : node.fields) {
if (VIEW_FIELD.equals(field.name)) hasField = true;
}
if (!hasField) {
node.fields.add(new FieldNode(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, VIEW_FIELD,
"L" + VIEW_ENUM + ";", null, null));
}
for (MethodNode method : node.methods) {
if (!"<clinit>".equals(method.name)) continue;
InsnList head = new InsnList();
head.add(new TypeInsnNode(Opcodes.NEW, VIEW_ENUM));
head.add(new InsnNode(Opcodes.DUP));
head.add(new LdcInsnNode(VIEW_NAME));
head.add(new IntInsnNode(Opcodes.BIPUSH, 16));
head.add(new LdcInsnNode(VIEW_TITLE));
head.add(new FieldInsnNode(Opcodes.GETSTATIC, "MV", "L", "I"));
head.add(new FieldInsnNode(Opcodes.GETSTATIC, "ut", "B", "Lut;"));
head.add(new MethodInsnNode(Opcodes.INVOKESPECIAL, VIEW_ENUM, "<init>",
"(Ljava/lang/String;ILjava/lang/String;ILut;)V", false));
head.add(new FieldInsnNode(Opcodes.PUTSTATIC, VIEW_ENUM, VIEW_FIELD,
"L" + VIEW_ENUM + ";"));
method.instructions.insert(head);
// 找到 $VALUES 的组装点(PUTSTATIC us.B:[Lus;),在它后面把新常量追加进数组
AbstractInsnNode anchor = null;
for (AbstractInsnNode insn = method.instructions.getFirst(); insn != null;
insn = insn.getNext()) {
if (insn instanceof FieldInsnNode) {
FieldInsnNode field = (FieldInsnNode) insn;
if (field.getOpcode() == Opcodes.PUTSTATIC && VIEW_ENUM.equals(field.owner)
&& "B".equals(field.name) && ("[L" + VIEW_ENUM + ";").equals(field.desc)) {
anchor = insn;
}
}
}
if (anchor == null) throw new IllegalStateException("找不到 $VALUES 的组装点");
InsnList extend = new InsnList();
extend.add(new FieldInsnNode(Opcodes.GETSTATIC, VIEW_ENUM, "B", "[L" + VIEW_ENUM + ";"));
extend.add(new VarInsnNode(Opcodes.ASTORE, 40));
extend.add(new VarInsnNode(Opcodes.ALOAD, 40));
extend.add(new InsnNode(Opcodes.ARRAYLENGTH));
extend.add(new InsnNode(Opcodes.ICONST_1));
extend.add(new InsnNode(Opcodes.IADD));
extend.add(new TypeInsnNode(Opcodes.ANEWARRAY, VIEW_ENUM));
extend.add(new VarInsnNode(Opcodes.ASTORE, 41));
extend.add(new VarInsnNode(Opcodes.ALOAD, 40));
extend.add(new InsnNode(Opcodes.ICONST_0));
extend.add(new VarInsnNode(Opcodes.ALOAD, 41));
extend.add(new InsnNode(Opcodes.ICONST_0));
extend.add(new VarInsnNode(Opcodes.ALOAD, 40));
extend.add(new InsnNode(Opcodes.ARRAYLENGTH));
extend.add(new MethodInsnNode(Opcodes.INVOKESTATIC, "java/lang/System", "arraycopy",
"(Ljava/lang/Object;ILjava/lang/Object;II)V", false));
extend.add(new VarInsnNode(Opcodes.ALOAD, 41));
extend.add(new VarInsnNode(Opcodes.ALOAD, 40));
extend.add(new InsnNode(Opcodes.ARRAYLENGTH));
extend.add(new FieldInsnNode(Opcodes.GETSTATIC, VIEW_ENUM, VIEW_FIELD,
"L" + VIEW_ENUM + ";"));
extend.add(new InsnNode(Opcodes.AASTORE));
extend.add(new VarInsnNode(Opcodes.ALOAD, 41));
extend.add(new FieldInsnNode(Opcodes.PUTSTATIC, VIEW_ENUM, "B", "[L" + VIEW_ENUM + ";"));
method.instructions.insert(anchor, extend);
System.out.println("us.<clinit> 已插入新视图常量 " + VIEW_NAME);
}
ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_MAXS);
node.accept(writer);
return writer.toByteArray();
}
/** 在 tD.<init> 里最后一个 us.X.D 赋值之后,插一条调用去建我们视图的面板。 */
private static byte[] patchUi(byte[] data) {
ClassNode node = new ClassNode();
new ClassReader(data).accept(node, 0);
int inserted = 0;
for (MethodNode method : node.methods) {
AbstractInsnNode last = null;
for (AbstractInsnNode insn = method.instructions.getFirst(); insn != null;
insn = insn.getNext()) {
if (insn instanceof FieldInsnNode) {
FieldInsnNode field = (FieldInsnNode) insn;
// 真正的写法是:getstatic us.StackMapTable:Lus; ... putfield us.D:Ldl;
// 也就是"先取视图常量,再把面板 Actor 写进它的 D 字段"。
if (field.getOpcode() == Opcodes.PUTFIELD && VIEW_ENUM.equals(field.owner)
&& "Ldl;".equals(field.desc)) {
last = insn;
}
}
}
if (last == null) continue;
InsnList hook = new InsnList();
hook.add(new MethodInsnNode(Opcodes.INVOKESTATIC, "com/spineview/ViewInstaller",
"install", "()V", false));
method.instructions.insert(last, hook);
inserted++;
System.out.println("在 " + method.name + method.desc + " 里插入了调用");
}
if (inserted == 0) throw new IllegalStateException("tD 里没找到挂视图的地方");
System.out.println("tD.<init> 已插入 ViewInstaller.install()");
ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_MAXS);
node.accept(writer);
return writer.toByteArray();
}
private static byte[] readAll(InputStream in) throws java.io.IOException {
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
byte[] chunk = new byte[65536];
int n;
while ((n = in.read(chunk)) > 0) buffer.write(chunk, 0, n);
return buffer.toByteArray();
}
}
+14
View File
@@ -0,0 +1,14 @@
#!/bin/zsh
# 用 source 加载:把 REA 需要的 Ghidra / JDK 环境变量设好。
# source tools/rea-env.sh
#
# 注意:Ghidra 12.1.4 官方发布包里只带了 linux/win 的原生反编译器,
# macOS arm64 的那两个是通过源码现编的,产物放在:
# tools/ghidra_12.1.4_PUBLIC/Ghidra/Features/Decompiler/os/mac_arm_64/{decompile,sleigh}
REA_TOOLS_DIR="$(cd "$(dirname "${(%):-%x}")" && pwd)"
export GHIDRA_INSTALL_DIR="$REA_TOOLS_DIR/ghidra_12.1.4_PUBLIC"
export JAVA_HOME="$REA_TOOLS_DIR/jdk-21.0.12.1+1/Contents/Home"
echo "GHIDRA_INSTALL_DIR=$GHIDRA_INSTALL_DIR"
echo "JAVA_HOME=$JAVA_HOME"