阶段四: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:
commit
2e7ab7c33c
113 files changed
+25327
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user