目标:在 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 副本、从载荷派生的批量产物。
189 lines
9.2 KiB
Java
189 lines
9.2 KiB
Java
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();
|
||
}
|
||
}
|