Files
Spine_lab/tools/decrypt/DecAll.java
T
tianmo 2e7ab7c33c 阶段四: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 副本、从载荷派生的批量产物。
2026-10-16 13:34:18 +08:00

371 lines
15 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}
}