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