阶段四: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

+39
View File
@@ -0,0 +1,39 @@
# ============================================================
# 只管理本项目自己的东西:源码、脚本、文档、小资源。
# 下面这些要么是第三方工具链/厂商 App,要么是从载荷派生的批量文件。
# ============================================================
# ---- 第三方工具链(体积巨大,可重新下载安装)----
tools/*.zip
tools/*.tar.gz
tools/ghidra_12.1.4_PUBLIC/
tools/jdk-11.0.32.1+1/
tools/jdk-21.0.12.1+1/
tools/jdk8u504-b01/
tools/dl/
# 下载中途留下的隐藏分片(.ghidra.part*、.p4_* 之类,几百 MB)
tools/.*
# ---- 厂商 App 本体与实验室副本(不是本项目源码,且体积大)----
Spine.app/
spine-lab/
# ---- 上游 rea 仓库:它是独立的 git 仓库,单独管理,不并入本项目 ----
rea/
# ---- 构建产物 ----
viewpanel/build/
viewpanel/compat-classes/
*.class
# ---- 从载荷派生的批量产物(本机可重新生成)----
viewpanel/analysis/spine-398/
viewpanel/analysis/398/classes/
viewpanel/analysis/398/3.8.99.readable.zip
# ---- macOS ----
.DS_Store
# ---- Python 缓存 ----
__pycache__/
*.pyc
+1
View File
@@ -0,0 +1 @@
{"evidence_id":"ev_9b04f23052e3407e648ebc0041e3974dcb27b0143b60b2383d5421ca6e9f0ee9","subject":{"name":"Spine.app","digest":{"sha256":"3ae6f7d89dd215bc2af41c342e6f0da18e4879a367a792864af123cc932eddd6"},"local_path":"/Users/tianmokeji/Desktop/临时/spine加功能/Spine.app","format":"directory","architecture":null},"provider":{"id":"rea-artifact-graph","name":"REA safe artifact graph provider","version":"1"},"analysis_profile":null,"predicate_type":"rea.analysis","operation":"inspect_artifact","parameters":{"integrity_policy":"fail"},"raw_result":null,"normalized_result":{"inspection_id":"ai_b1669da28fcdcff85de83bb85c6f2cc17303c03200353fc1c778550a8f3a92f7","subject":{"manifest_id":"agm_82b508d4d559c723e0f2dd27c51e61eaf519031bb2c465ca4524b9c7ba692c74","root_artifact_id":"art_ae3243db850c220d5ab80b5cb8079ae70b9b833bb3b0254420a699b137b75a0e","root_sha256":"3ae6f7d89dd215bc2af41c342e6f0da18e4879a367a792864af123cc932eddd6","root_format":"directory"},"substeps":[{"substep_id":"ais_31c0e3c3ea51a057c0602b45c0f647d753fa1df1209b269fb746848c5cfbb89e","operation":"inventory_artifact","status":"completed","evidence_id":"ev_1f18fe9264db0c8d46d98d51641d533fae5bcb7c49ba7c92b06e9df4a44aced6","evidence":{"evidence_id":"ev_1f18fe9264db0c8d46d98d51641d533fae5bcb7c49ba7c92b06e9df4a44aced6","subject":{"name":"Spine.app","digest":{"sha256":"3ae6f7d89dd215bc2af41c342e6f0da18e4879a367a792864af123cc932eddd6"},"local_path":"/Users/tianmokeji/Desktop/临时/spine加功能/Spine.app","format":"directory","architecture":null},"provider":{"id":"rea-artifact-graph","name":"REA safe artifact graph provider","version":"1"},"analysis_profile":null,"predicate_type":"rea.analysis","operation":"inventory_artifact","parameters":{"integrity_policy":"fail"},"raw_result":null,"normalized_result":{"manifest":{"manifest_id":"agm_82b508d4d559c723e0f2dd27c51e61eaf519031bb2c465ca4524b9c7ba692c74","root_artifact_id":"art_ae3243db850c220d5ab80b5cb8079ae70b9b833bb3b0254420a699b137b75a0e","root_sha256":"3ae6f7d89dd215bc2af41c342e6f0da18e4879a367a792864af123cc932eddd6","root_format":"directory","graph_sha256":"d18481e958456ac2dc71cdc43211671100d49ca366b20f474c6ad7e328af11c0","node_count":185,"occurrence_count":239,"edge_count":238},"nodes":[{"artifact_id":"art_00afd2822ca673596e66205dadeffc761c4bd49d25be1e2fa7c41498ecde1300","format":"file","sha256":"5e4e6623a21a63f9bc16ea54af4133b8038e490c0d499a74676f9e5a61b9c5b2","size":2384,"media_type":null,"architecture":null,"content_state":"embedded","limitations":[]},{"artifact_id":"art_00b813901f721c0c8df140a505792c62c4579857d96aa50cdbb9b236a57a9144","format":"file","sha256":"299c2360b6155eb28990ec49cd21753f97e43442fe8fab03e04f3e213df43a66","size":1210,"media_type":null,"architecture":null,"content_state":"embedded","limitations":[]},{"artifact_id":"art_01025a6e14341a82c76634d2700f78272e5a93c717fa2d6f3bf82b5a23d9b84e","format":"file","sha256":"dcaf0e0e52b7cc073ded86c66117ea0fb6b91ae33eb4d3584a98902d586b1387","size":2594,"media_type":null,"architecture":null,"content_state":"embedded","limitations":[]},{"artifact_id":"art_029ea9766596fc6bcfc8e2eb5d51e7e3ee65c696183548d15bd202449e227d5f","format":"directory","sha256":"8bba921eb7f84133b3856d4d2352d0b7eb9d07774ce75fedb461a1bd51c8b059","size":0,"media_type":null,"architecture":null,"content_state":"virtual","limitations":[]},{"artifact_id":"art_0561f8eedecb675bf831c7b400e75dfbb3a1d7e6b7e02a91e76ba48035564434","format":"directory","sha256":"83a810c05768a4df008af38946a514628211e02684974b2c177d431d10be2269","size":0,"media_type":null,"architecture":null,"content_state":"virtual","limitations":[]},{"artifact_id":"art_061c6700f9c7043f1b12cee1dd964184128e56df771f58db5c580be2be667152","format":"file","sha256":"c8b99f33890887d27ad56fba9edd8ebbc668cfe0689168505a95613d1d4b32f8","size":1288,"media_type":null,"architecture":null,"content_state":"embedded","limitations":[]},{"artifact_id":"art_0915d610333f36edf46090c94528b94230afdb3707865849b48836062609d691","format":"file","sha256":"41f4d63e670d75f5936a895aae919e226f5d50c54b5b09db3ba4d7052a2c1554","size":1178,"media_type":null,"architecture":null,"content_state":"embedded","limitations":[]},{"artifact_id":"art_09c78588acba2970adea1787e5d4c9eec8296df85fdf105bd147033f01598ce8","format":"file","sha256":"e93dc8e2b930cdc39c0c618fe1b23e74e73002765ffea18440d8e9c4311ebdf1","size":11392,"media_type":null,"architecture":null,"content_state":"embedded","limitations":[]},{"artifact_id":"art_0cb4629ab6fe26684cfd515b50a275d46686056f4b06a02254389d914acb83c1","format":"mach-o","sha256":"7468546df6669959e25bf6d58c8a76b9969557457a08feb47c3794ef55ca1f82","size":123888,"media_type":null,"architecture":null,"content_state":"embedded","limitations":[]},{"artifact_id":"art_0ce386ea941a60972488e16a6f57e655cc84557d8cfa265a93cf84f6e6e54d0d","format":"mach-o","sha256":"a86a356ba1cecd35fb34044432d37790e1dd29a7dc18f7499b82a3a2119fade9","size":373088,"media_type":null,"architecture":null,"content_state":"embedded","limitations":[]},{"artifact_id":"art_0ce5a0009Line truncated
View File
Whitespace-only changes.
+1
View File
@@ -0,0 +1 @@
{"evidence_id":"ev_de24dbaf976675fccca527eb5fc3ce078310ce52f606a3a6cceb816ef651649a","subject":{"name":"Spine","digest":{"sha256":"fea6e69af72dc5a3f38195f293b8af3d61b3bf4b3845b1069b6c3603384340f2"},"local_path":"/Users/tianmokeji/Desktop/临时/spine加功能/Spine.app/Contents/MacOS/Spine","format":"mach-o","architecture":"arm64"},"provider":{"id":"native-macos","name":"macOS native inspection utilities","version":null},"analysis_profile":null,"predicate_type":"rea.analysis","operation":"inspect_macho","parameters":{},"raw_result":null,"normalized_result":{"format":"mach-o","selected_architecture":{"name":"arm64","container_file_offset":0},"endian":"little","word_size":64,"file_type":"2","flags":["0x00200085"],"uuid":"0764A539-8C54-317A-B9B1-0BA2CA4874AC","entrypoints":{"items":[{"file_offset":16332}],"total":1,"exhaustive":true,"limitations":[]},"architectures":{"items":[{"name":"arm64","cpu_type":null,"cpu_subtype":null,"file_offset":null,"size":null,"alignment":null}],"total":1,"exhaustive":true,"limitations":[]},"build_metadata":{"items":[{"platform":"MACOS","minimum_os":"11.0","sdk":"14.5","tools":[{"name":"LD","version":"1053.12"}]}],"total":1,"exhaustive":true,"limitations":[]},"load_commands":{"items":[{"index":0,"kind":"LC_SEGMENT_64","fields":{"cmd":"LC_SEGMENT_64","cmdsize":72,"segname":"__PAGEZERO","vmaddr":0,"vmsize":4294967296,"fileoff":0,"filesize":0,"maxprot":0,"initprot":0,"nsects":0,"flags":0}},{"index":1,"kind":"LC_SEGMENT_64","fields":{"cmd":"LC_SEGMENT_64","cmdsize":872,"segname":"__TEXT","vmaddr":4294967296,"vmsize":32768,"fileoff":0,"filesize":32768,"maxprot":5,"initprot":5,"nsects":10,"flags":0}},{"index":2,"kind":"LC_SEGMENT_64","fields":{"cmd":"LC_SEGMENT_64","cmdsize":552,"segname":"__DATA_CONST","vmaddr":4295000064,"vmsize":16384,"fileoff":32768,"filesize":16384,"maxprot":3,"initprot":3,"nsects":6,"flags":16}},{"index":3,"kind":"LC_SEGMENT_64","fields":{"cmd":"LC_SEGMENT_64","cmdsize":712,"segname":"__DATA","vmaddr":4295016448,"vmsize":16384,"fileoff":49152,"filesize":16384,"maxprot":3,"initprot":3,"nsects":8,"flags":0}},{"index":4,"kind":"LC_SEGMENT_64","fields":{"cmd":"LC_SEGMENT_64","cmdsize":72,"segname":"__LINKEDIT","vmaddr":4295032832,"vmsize":32768,"fileoff":65536,"filesize":22000,"maxprot":1,"initprot":1,"nsects":0,"flags":0}},{"index":5,"kind":"LC_DYLD_INFO_ONLY","fields":{"cmd":"LC_DYLD_INFO_ONLY","cmdsize":48,"rebase_off":65536,"rebase_size":192,"bind_off":65728,"bind_size":536,"weak_bind_off":0,"weak_bind_size":0,"lazy_bind_off":66264,"lazy_bind_size":424,"export_off":66688,"export_size":32}},{"index":6,"kind":"LC_SYMTAB","fields":{"cmd":"LC_SYMTAB","cmdsize":24,"symoff":66840,"nsyms":39,"stroff":67640,"strsize":744}},{"index":7,"kind":"LC_DYSYMTAB","fields":{"cmd":"LC_DYSYMTAB","cmdsize":80,"ilocalsym":0,"nlocalsym":1,"iextdefsym":1,"nextdefsym":1,"iundefsym":2,"nundefsym":37,"tocoff":0,"ntoc":0,"modtaboff":0,"nmodtab":0,"extrefsymoff":0,"nextrefsyms":0,"indirectsymoff":67464,"nindirectsyms":44,"extreloff":0,"nextrel":0,"locreloff":0,"nlocrel":0}},{"index":8,"kind":"LC_LOAD_DYLINKER","fields":{"cmd":"LC_LOAD_DYLINKER","cmdsize":32,"name":"/usr/lib/dyld (offset 12)"}},{"index":9,"kind":"LC_UUID","fields":{"cmd":"LC_UUID","cmdsize":24,"uuid":"0764A539-8C54-317A-B9B1-0BA2CA4874AC"}},{"index":10,"kind":"LC_BUILD_VERSION","fields":{"cmd":"LC_BUILD_VERSION","cmdsize":32,"platform":1,"minos":"11.0","sdk":"14.5","ntools":1,"tool":3,"version":"1053.12"}},{"index":11,"kind":"LC_SOURCE_VERSION","fields":{"cmd":"LC_SOURCE_VERSION","cmdsize":16,"version":"0.0"}},{"index":12,"kind":"LC_MAIN","fields":{"cmd":"LC_MAIN","cmdsize":24,"entryoff":16332,"stacksize":0}},{"index":13,"kind":"LC_LOAD_DYLIB","fields":{"cmd":"LC_LOAD_DYLIB","cmdsize":96,"name":"/System/Library/Frameworks/Foundation.framework/Versions/C/Foundation (offset 24)","time stamp":"2 Thu Jan 1 08:00:02 1970","current version":"2503.1.0","compatibility version":"300.0.0"}},{"index":14,"kind":"LC_LOAD_DYLIB","fields":{"cmd":"LC_LOAD_DYLIB","cmdsize":88,"name":"/System/Library/Frameworks/AppKit.framework/Versions/C/AppKit (offset 24)","time stamp":"2 Thu Jan 1 08:00:02 1970","current version":"2487.60.105","compatibility version":"45.0.0"}},{"index":15,"kind":"LC_LOAD_DYLIB","fields":{"cmd":"LC_LOAD_DYLIB","cmdsize":88,"name":"/System/Library/Frameworks/Cocoa.framework/Versions/A/Cocoa (offset 24)","time stamp":"2 Thu Jan 1 08:00:02 1970","current version":"24.0.0","compatibility version":"1.0.0"}},{"index":16,"kind":"LC_LOAD_DYLIB","fields":{"cmd":"LC_LOAD_DYLIB","cmdsize":56,"name":"/usr/lib/libobjc.A.dylib (offset 24)","time stamp":"2 Thu Jan 1 08:00:02 1970","current version":"228.0.0","compatibility version":"1.0.0"}},{"index":17,"kind":"LC_LOAD_DYLIB","fields":{"cmd":"LC_LOAD_DYLIB","cmdsize":56,"name":"/usr/lib/libSystem.B.dylib (offset 24)","time stamp":"2 Thu Jan 1 08:00:02 1970","current version":"1345.120.2","compatibility version":"1.0.0"}},{"index":18,"kind":"LC_LOAD_DYLIB","fields":{"cmd":"LC_LOAD_DYLLine truncated
+1
View File
@@ -0,0 +1 @@
{"evidence_id":"ev_8db26c98ef5a365354f5fc68ff26d5c8d0ab4d3ab273b09aba70753a958a5087","subject":{"name":"Spine","digest":{"sha256":"fea6e69af72dc5a3f38195f293b8af3d61b3bf4b3845b1069b6c3603384340f2"},"local_path":"/Users/tianmokeji/Desktop/临时/spine加功能/Spine.app/Contents/MacOS/Spine","format":"mach-o","architecture":"arm64"},"provider":{"id":"native-macos","name":"macOS native inspection utilities","version":null},"analysis_profile":null,"predicate_type":"rea.analysis","operation":"inspect_signature","parameters":{},"raw_result":null,"normalized_result":{"signed":true,"identifier":"Spine","team_identifier":null,"format":"Mach-O thin (arm64)","cdhashes":["e47d518d37f960a2e45a1b738056b57b00f885c7"],"hash_algorithms":["sha256"],"authorities":[],"designated_requirement":"cdhash H\"e47d518d37f960a2e45a1b738056b57b00f885c7\"","entitlements":{},"timestamp":null,"hardened_runtime":false,"provenance":[{"tool":"codesign","command":["/usr/bin/codesign","-d","--verbose=4","$ARTIFACT"],"tool_version":null,"version_reason":"Tool exposes no uniform stable version flag; executable SHA-256 is a prelaunch file sample and does not prove atomic OS image binding.","executable_sha256":"6f92f630759f1a7f3faa0bebe1b27b3565a44d5d44c15cc4ddead6b3af373f40","exit":{"code":0,"signal":null},"stdout_bytes":0,"stderr_bytes":832},{"tool":"codesign","command":["/usr/bin/codesign","-d","-r-","$ARTIFACT"],"tool_version":null,"version_reason":"Tool exposes no uniform stable version flag; executable SHA-256 is a prelaunch file sample and does not prove atomic OS image binding.","executable_sha256":"6f92f630759f1a7f3faa0bebe1b27b3565a44d5d44c15cc4ddead6b3af373f40","exit":{"code":0,"signal":null},"stdout_bytes":67,"stderr_bytes":95},{"tool":"codesign","command":["/usr/bin/codesign","-d","--entitlements",":-","$ARTIFACT"],"tool_version":null,"version_reason":"Tool exposes no uniform stable version flag; executable SHA-256 is a prelaunch file sample and does not prove atomic OS image binding.","executable_sha256":"6f92f630759f1a7f3faa0bebe1b27b3565a44d5d44c15cc4ddead6b3af373f40","exit":{"code":0,"signal":null},"stdout_bytes":184,"stderr_bytes":183}],"limitations":["Entitlements and designated requirements require separate bounded commands.","Inspected a digest-verified Mach-O byte snapshot; filesystem-bound signing context and trust validation are not examined."]},"confidence":"observed","authority":"shipped-artifact","environment":null,"limitations":["Entitlements and designated requirements require separate bounded commands.","Inspected a digest-verified Mach-O byte snapshot; filesystem-bound signing context and trust validation are not examined."],"relationship_coverage":null,"locations":[],"evidence_links":[]}
+1
View File
@@ -0,0 +1 @@
{"evidence_id":"ev_b21baaed28ef8ff188a0ef20956c5ff5258f22377164cbe1969e3866a758804b","subject":{"name":"Spine","digest":{"sha256":"fea6e69af72dc5a3f38195f293b8af3d61b3bf4b3845b1069b6c3603384340f2"},"local_path":"/Users/tianmokeji/Desktop/临时/spine加功能/Spine.app/Contents/MacOS/Spine","format":"mach-o","architecture":"arm64"},"provider":{"id":"rea-artifact-graph","name":"REA safe artifact graph provider","version":"1"},"analysis_profile":null,"predicate_type":"rea.analysis","operation":"trace_dylib_resolution","parameters":{},"raw_result":null,"normalized_result":{"root_path":"/Users/tianmokeji/Desktop/临时/spine加功能/Spine.app","target_sha256":"fea6e69af72dc5a3f38195f293b8af3d61b3bf4b3845b1069b6c3603384340f2","roots":[{"image":"Contents/MacOS/Spine","architecture":"arm64"},{"image":"Contents/MacOS/Spine_x86","architecture":"x86_64"},{"image":"Contents/MacOS/launcher/1/lib/jspawnhelper","architecture":"x86_64"},{"image":"Contents/MacOS/launcher/2/bin/ffmpeg-macos","architecture":"arm64"},{"image":"Contents/MacOS/launcher/2/bin/ffmpeg-macos-arm","architecture":"arm64"},{"image":"Contents/MacOS/launcher/2/bin/oxipng-macos","architecture":"arm64"},{"image":"Contents/MacOS/launcher/2/bin/oxipng-macos-arm","architecture":"arm64"},{"image":"Contents/MacOS/launcher/2/lib/jspawnhelper","architecture":"arm64"}],"images":[{"path":"Contents/MacOS/Spine","sha256":"fea6e69af72dc5a3f38195f293b8af3d61b3bf4b3845b1069b6c3603384340f2","parse_status":"parsed","reason":null,"slices":[{"architecture":"arm64","slice_offset":0,"slice_size":87536,"cpu_type":16777228,"cpu_subtype":0,"fat_cpu_type":null,"fat_cpu_subtype":null,"fat_alignment_exponent":null,"file_type":"execute","file_type_code":2,"platform":1,"platforms":[1],"install_name":null,"rpaths":["@executable_path/../Frameworks"],"dyld_environment":[],"code_signature_present":true}]},{"path":"Contents/MacOS/Spine_x86","sha256":"f7ca9cb634a1a81150c29e078edc7aacc99ec06181895f6d421847444ad387fd","parse_status":"parsed","reason":null,"slices":[{"architecture":"x86_64","slice_offset":0,"slice_size":54528,"cpu_type":16777223,"cpu_subtype":3,"fat_cpu_type":null,"fat_cpu_subtype":null,"fat_alignment_exponent":null,"file_type":"execute","file_type_code":2,"platform":1,"platforms":[1],"install_name":null,"rpaths":["@executable_path/../Frameworks"],"dyld_environment":[],"code_signature_present":true}]},{"path":"Contents/MacOS/launcher/1/lib/jspawnhelper","sha256":"35eea96a9f708da1c3b7b736f4a2a6d06478de769b8a02fc9da9911478992dac","parse_status":"parsed","reason":null,"slices":[{"architecture":"x86_64","slice_offset":0,"slice_size":15144,"cpu_type":16777223,"cpu_subtype":2147483651,"fat_cpu_type":null,"fat_cpu_subtype":null,"fat_alignment_exponent":null,"file_type":"execute","file_type_code":2,"platform":1,"platforms":[1],"install_name":null,"rpaths":[],"dyld_environment":[],"code_signature_present":false}]},{"path":"Contents/MacOS/launcher/2/bin/ffmpeg-macos","sha256":"7dc6231d735fabcec85e2892e68b620435aa2c9aa5ea8d33ee0a1ad1b1f330e8","parse_status":"parsed","reason":null,"slices":[{"architecture":"arm64","slice_offset":0,"slice_size":7727432,"cpu_type":16777228,"cpu_subtype":0,"fat_cpu_type":null,"fat_cpu_subtype":null,"fat_alignment_exponent":null,"file_type":"execute","file_type_code":2,"platform":1,"platforms":[1],"install_name":null,"rpaths":[],"dyld_environment":[],"code_signature_present":true}]},{"path":"Contents/MacOS/launcher/2/bin/ffmpeg-macos-arm","sha256":"7dc6231d735fabcec85e2892e68b620435aa2c9aa5ea8d33ee0a1ad1b1f330e8","parse_status":"parsed","reason":null,"slices":[{"architecture":"arm64","slice_offset":0,"slice_size":7727432,"cpu_type":16777228,"cpu_subtype":0,"fat_cpu_type":null,"fat_cpu_subtype":null,"fat_alignment_exponent":null,"file_type":"execute","file_type_code":2,"platform":1,"platforms":[1],"install_name":null,"rpaths":[],"dyld_environment":[],"code_signature_present":true}]},{"path":"Contents/MacOS/launcher/2/bin/oxipng-macos","sha256":"201b21e95b488e111857063bc71929704b892796badfd9b61dd9f05a699db1b7","parse_status":"parsed","reason":null,"slices":[{"architecture":"arm64","slice_offset":0,"slice_size":766608,"cpu_type":16777228,"cpu_subtype":0,"fat_cpu_type":null,"fat_cpu_subtype":null,"fat_alignment_exponent":null,"file_type":"execute","file_type_code":2,"platform":1,"platforms":[1],"install_name":null,"rpaths":[],"dyld_environment":[],"code_signature_present":true}]},{"path":"Contents/MacOS/launcher/2/bin/oxipng-macos-arm","sha256":"201b21e95b488e111857063bc71929704b892796badfd9b61dd9f05a699db1b7","parse_status":"parsed","reason":null,"slices":[{"architecture":"arm64","slice_offset":0,"slice_size":766608,"cpu_type":16777228,"cpu_subtype":0,"fat_cpu_type":null,"fat_cpu_subtype":null,"fat_alignment_exponent":null,"file_type":"execute","file_type_code":2,"platform":1,"platforms":[1],"install_name":null,"rpaths":[],"dyld_environment":[],"code_signature_present":true}]},{"path":"Contents/MacOS/launcher/2/lib/jspawnhelper","sha256":"35a5c7c56b0c7efc8e4Line truncated
+1
View File
@@ -0,0 +1 @@
{"evidence_id":"ev_58eeb4faed0e160239c43d5b217df18e7021acb5377b554f6b19547d2cf650ba","subject":{"name":"libjvm.dylib","digest":{"sha256":"6c1bd905b388177efd31128e19e6ea5e0e29ab88082d4a355deaf79874ff50b5"},"local_path":"/Users/tianmokeji/Desktop/临时/spine加功能/Spine.app/Contents/MacOS/launcher/2/lib/server/libjvm.dylib","format":"mach-o","architecture":"arm64"},"provider":{"id":"native-macos","name":"macOS native inspection utilities","version":null},"analysis_profile":null,"predicate_type":"rea.analysis","operation":"inspect_macho","parameters":{},"raw_result":null,"normalized_result":{"format":"mach-o","selected_architecture":{"name":"arm64","container_file_offset":0},"endian":"little","word_size":64,"file_type":"6","flags":["0x00900085"],"uuid":"71648639-9507-35C6-A994-683E656CFEA1","entrypoints":{"items":[],"total":0,"exhaustive":true,"limitations":[]},"architectures":{"items":[{"name":"arm64","cpu_type":null,"cpu_subtype":null,"file_offset":null,"size":null,"alignment":null}],"total":1,"exhaustive":true,"limitations":[]},"build_metadata":{"items":[{"platform":"MACOS","minimum_os":"11.0","sdk":"14.5","tools":[{"name":"LD","version":"1053.12"}]}],"total":1,"exhaustive":true,"limitations":[]},"load_commands":{"items":[{"index":0,"kind":"LC_SEGMENT_64","fields":{"cmd":"LC_SEGMENT_64","cmdsize":632,"segname":"__TEXT","vmaddr":0,"vmsize":8142848,"fileoff":0,"filesize":8142848,"maxprot":5,"initprot":5,"nsects":7,"flags":0}},{"index":1,"kind":"LC_SEGMENT_64","fields":{"cmd":"LC_SEGMENT_64","cmdsize":392,"segname":"__DATA_CONST","vmaddr":8142848,"vmsize":786432,"fileoff":8142848,"filesize":786432,"maxprot":3,"initprot":3,"nsects":4,"flags":16}},{"index":2,"kind":"LC_SEGMENT_64","fields":{"cmd":"LC_SEGMENT_64","cmdsize":552,"segname":"__DATA","vmaddr":8929280,"vmsize":49315840,"fileoff":8929280,"filesize":48906240,"maxprot":3,"initprot":3,"nsects":6,"flags":0}},{"index":3,"kind":"LC_SEGMENT_64","fields":{"cmd":"LC_SEGMENT_64","cmdsize":72,"segname":"__LINKEDIT","vmaddr":58245120,"vmsize":573440,"fileoff":57835520,"filesize":559488,"maxprot":1,"initprot":1,"nsects":0,"flags":0}},{"index":4,"kind":"LC_ID_DYLIB","fields":{"cmd":"LC_ID_DYLIB","cmdsize":48,"name":"@rpath/libjvm.dylib (offset 24)","time stamp":"1 Thu Jan 1 08:00:01 1970","current version":"1.0.0","compatibility version":"1.0.0"}},{"index":5,"kind":"LC_DYLD_INFO_ONLY","fields":{"cmd":"LC_DYLD_INFO_ONLY","cmdsize":48,"rebase_off":57835520,"rebase_size":12496,"bind_off":57848016,"bind_size":792,"weak_bind_off":0,"weak_bind_size":0,"lazy_bind_off":57848808,"lazy_bind_size":4608,"export_off":57853416,"export_size":4744}},{"index":6,"kind":"LC_SYMTAB","fields":{"cmd":"LC_SYMTAB","cmdsize":24,"symoff":57907176,"nsyms":450,"stroff":57916328,"strsize":7880}},{"index":7,"kind":"LC_DYSYMTAB","fields":{"cmd":"LC_DYSYMTAB","cmdsize":80,"ilocalsym":0,"nlocalsym":1,"iextdefsym":1,"nextdefsym":197,"iundefsym":198,"nundefsym":252,"tocoff":0,"ntoc":0,"modtaboff":0,"nmodtab":0,"extrefsymoff":0,"nextrefsyms":0,"indirectsymoff":57914376,"nindirectsyms":488,"extreloff":0,"nextrel":0,"locreloff":0,"nlocrel":0}},{"index":8,"kind":"LC_UUID","fields":{"cmd":"LC_UUID","cmdsize":24,"uuid":"71648639-9507-35C6-A994-683E656CFEA1"}},{"index":9,"kind":"LC_BUILD_VERSION","fields":{"cmd":"LC_BUILD_VERSION","cmdsize":32,"platform":1,"minos":"11.0","sdk":"14.5","ntools":1,"tool":3,"version":"1053.12"}},{"index":10,"kind":"LC_SOURCE_VERSION","fields":{"cmd":"LC_SOURCE_VERSION","cmdsize":16,"version":"0.0"}},{"index":11,"kind":"LC_LOAD_DYLIB","fields":{"cmd":"LC_LOAD_DYLIB","cmdsize":56,"name":"/usr/lib/libSystem.B.dylib (offset 24)","time stamp":"2 Thu Jan 1 08:00:02 1970","current version":"1345.120.2","compatibility version":"1.0.0"}},{"index":12,"kind":"LC_LOAD_DYLIB","fields":{"cmd":"LC_LOAD_DYLIB","cmdsize":104,"name":"/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation (offset 24)","time stamp":"2 Thu Jan 1 08:00:02 1970","current version":"2503.1.0","compatibility version":"150.0.0"}},{"index":13,"kind":"LC_LOAD_DYLIB","fields":{"cmd":"LC_LOAD_DYLIB","cmdsize":48,"name":"/usr/lib/libc++.1.dylib (offset 24)","time stamp":"2 Thu Jan 1 08:00:02 1970","current version":"1700.255.5","compatibility version":"1.0.0"}},{"index":14,"kind":"LC_RPATH","fields":{"cmd":"LC_RPATH","cmdsize":32,"path":"@loader_path/. (offset 12)"}},{"index":15,"kind":"LC_RPATH","fields":{"cmd":"LC_RPATH","cmdsize":32,"path":"@loader_path/.. (offset 12)"}},{"index":16,"kind":"LC_FUNCTION_STARTS","fields":{"cmd":"LC_FUNCTION_STARTS","cmdsize":16,"dataoff":57858160,"datasize":49016}},{"index":17,"kind":"LC_DATA_IN_CODE","fields":{"cmd":"LC_DATA_IN_CODE","cmdsize":16,"dataoff":57907176,"datasize":0}},{"index":18,"kind":"LC_CODE_SIGNATURE","fields":{"cmd":"LC_CODE_SIGNATURE","cmdsize":16,"dataoff":57924208,"datasize":470800}}],"total":19,"exhaustive":true,"limitations":[]},"dependencies":{"items":[{"path":"@rpath/libjvm.dylib","kind":"LC_ID_DYLIB","current_version":"1.0.0","compatiLine truncated
+1
View File
@@ -0,0 +1 @@
{"evidence_id":"ev_1c87c2e11e2adc7afddcccbaf00635f15aa114d28d87b240cfee1687cdff6ca9","subject":{"name":"libjvm.dylib","digest":{"sha256":"c8b42b75d6a5536e9d633141289db72b508a3d804a879d74ccb6773fa03b8a9b"},"local_path":"/Users/tianmokeji/Desktop/临时/spine加功能/Spine.app/Contents/MacOS/launcher/1/lib/server/libjvm.dylib","format":"mach-o","architecture":"x86_64"},"provider":{"id":"native-macos","name":"macOS native inspection utilities","version":null},"analysis_profile":null,"predicate_type":"rea.analysis","operation":"inspect_macho","parameters":{},"raw_result":null,"normalized_result":{"format":"mach-o","selected_architecture":{"name":"x86_64","container_file_offset":0},"endian":"little","word_size":64,"file_type":"6","flags":["0x00910085"],"uuid":"B301D2B0-E23F-381E-AAD0-FECE002E9172","entrypoints":{"items":[],"total":0,"exhaustive":true,"limitations":[]},"architectures":{"items":[{"name":"x86_64","cpu_type":null,"cpu_subtype":null,"file_offset":null,"size":null,"alignment":null}],"total":1,"exhaustive":true,"limitations":[]},"build_metadata":{"items":[{"platform":"MACOSX","minimum_os":"10.10","sdk":"10.9","tools":[]}],"total":1,"exhaustive":true,"limitations":[]},"load_commands":{"items":[{"index":0,"kind":"LC_SEGMENT_64","fields":{"cmd":"LC_SEGMENT_64","cmdsize":952,"segname":"__TEXT","vmaddr":0,"vmsize":8540160,"fileoff":0,"filesize":8540160,"maxprot":7,"initprot":5,"nsects":11,"flags":0}},{"index":1,"kind":"LC_SEGMENT_64","fields":{"cmd":"LC_SEGMENT_64","cmdsize":872,"segname":"__DATA","vmaddr":8540160,"vmsize":1359872,"fileoff":8540160,"filesize":1085440,"maxprot":7,"initprot":3,"nsects":10,"flags":0}},{"index":2,"kind":"LC_SEGMENT_64","fields":{"cmd":"LC_SEGMENT_64","cmdsize":72,"segname":"__LINKEDIT","vmaddr":9900032,"vmsize":3640508,"fileoff":9629696,"filesize":3640508,"maxprot":7,"initprot":1,"nsects":0,"flags":0}},{"index":3,"kind":"LC_ID_DYLIB","fields":{"cmd":"LC_ID_DYLIB","cmdsize":48,"name":"@rpath/libjvm.dylib (offset 24)","time stamp":"1 Thu Jan 1 08:00:01 1970","current version":"1.0.0","compatibility version":"1.0.0"}},{"index":4,"kind":"LC_DYLD_INFO_ONLY","fields":{"cmd":"LC_DYLD_INFO_ONLY","cmdsize":48,"rebase_off":9629696,"rebase_size":15176,"bind_off":9644872,"bind_size":968,"weak_bind_off":9645840,"weak_bind_size":16,"lazy_bind_off":9645856,"lazy_bind_size":4096,"export_off":9649952,"export_size":4192}},{"index":5,"kind":"LC_SYMTAB","fields":{"cmd":"LC_SYMTAB","cmdsize":24,"symoff":9716560,"nsyms":57502,"stroff":10638700,"strsize":2631504}},{"index":6,"kind":"LC_DYSYMTAB","fields":{"cmd":"LC_DYSYMTAB","cmdsize":80,"ilocalsym":0,"nlocalsym":57100,"iextdefsym":57100,"nextdefsym":177,"iundefsym":57277,"nundefsym":225,"tocoff":0,"ntoc":0,"modtaboff":0,"nmodtab":0,"extrefsymoff":0,"nextrefsyms":0,"indirectsymoff":10636592,"nindirectsyms":527,"extreloff":0,"nextrel":0,"locreloff":0,"nlocrel":0}},{"index":7,"kind":"LC_UUID","fields":{"cmd":"LC_UUID","cmdsize":24,"uuid":"B301D2B0-E23F-381E-AAD0-FECE002E9172"}},{"index":8,"kind":"LC_VERSION_MIN_MACOSX","fields":{"cmd":"LC_VERSION_MIN_MACOSX","cmdsize":16,"version":"10.10","sdk":"10.9"}},{"index":9,"kind":"LC_SOURCE_VERSION","fields":{"cmd":"LC_SOURCE_VERSION","cmdsize":16,"version":"0.0"}},{"index":10,"kind":"LC_LOAD_DYLIB","fields":{"cmd":"LC_LOAD_DYLIB","cmdsize":56,"name":"/usr/lib/libSystem.B.dylib (offset 24)","time stamp":"2 Thu Jan 1 08:00:02 1970","current version":"1197.1.1","compatibility version":"1.0.0"}},{"index":11,"kind":"LC_LOAD_DYLIB","fields":{"cmd":"LC_LOAD_DYLIB","cmdsize":56,"name":"/usr/lib/libstdc++.6.dylib (offset 24)","time stamp":"2 Thu Jan 1 08:00:02 1970","current version":"60.0.0","compatibility version":"7.0.0"}},{"index":12,"kind":"LC_RPATH","fields":{"cmd":"LC_RPATH","cmdsize":32,"path":"@loader_path/. (offset 12)"}},{"index":13,"kind":"LC_RPATH","fields":{"cmd":"LC_RPATH","cmdsize":32,"path":"@loader_path/.. (offset 12)"}},{"index":14,"kind":"LC_FUNCTION_STARTS","fields":{"cmd":"LC_FUNCTION_STARTS","cmdsize":16,"dataoff":9654144,"datasize":62376}},{"index":15,"kind":"LC_DATA_IN_CODE","fields":{"cmd":"LC_DATA_IN_CODE","cmdsize":16,"dataoff":9716520,"datasize":0}},{"index":16,"kind":"LC_DYLIB_CODE_SIGN_DRS","fields":{"cmd":"LC_DYLIB_CODE_SIGN_DRS","cmdsize":16,"dataoff":9716520,"datasize":40}}],"total":17,"exhaustive":true,"limitations":[]},"dependencies":{"items":[{"path":"@rpath/libjvm.dylib","kind":"LC_ID_DYLIB","current_version":"1.0.0","compatibility_version":"1.0.0"},{"path":"/usr/lib/libSystem.B.dylib","kind":"LC_LOAD_DYLIB","current_version":"1197.1.1","compatibility_version":"1.0.0"},{"path":"/usr/lib/libstdc++.6.dylib","kind":"LC_LOAD_DYLIB","current_version":"60.0.0","compatibility_version":"7.0.0"}],"total":3,"exhaustive":true,"limitations":[]},"imports":{"items":[{"name":"__DefaultRuneLocale","address":null,"weak":null,"reexport":null,"source":"libSystem"},{"name":"__NSGetEnviron","address":null,"weak":null,"reexport":null,"source":"libSystem"},{"name":"__ZSt7nothrow","address":null,"weaLine truncated
+1
View File
@@ -0,0 +1 @@
{"error":"Analysis failed","code":"capability_unavailable","category":"unsupported_provider","message":"This analysis is unavailable for the current target. Choose another analysis or target.","retryable":false,"remediation":{"action":"This analysis is unavailable for the current target. Choose another analysis or target."},"details":{"provider_id":"native-macos","operation":"observe_native_ui","reason":"process-identity-unavailable: Selected application does not expose a launch time; stable PID reuse checks are unavailable"}}
+1
View File
@@ -0,0 +1 @@
{"evidence_id":"ev_2bf6740d94a700c99b536f2ff28ac6fe4c13b72a9f4ce3405571cbfc65c0abd0","subject":{"name":"libspine-editorarm64.dylib","digest":{"sha256":"2549d0b07be0926995628e0e887e72cf0812c80438a7cfb9d1935c88cad28c57"},"local_path":"/private/var/folders/jl/m50cxy0d7zzfd0448wzhj2bc0000gn/T/.spine/tianmokeji/4.3.26/libspine-editorarm64.dylib","format":"mach-o","architecture":"arm64"},"provider":{"id":"native-macos","name":"macOS native inspection utilities","version":null},"analysis_profile":null,"predicate_type":"rea.analysis","operation":"inspect_macho","parameters":{},"raw_result":null,"normalized_result":{"format":"mach-o","selected_architecture":{"name":"arm64","container_file_offset":0},"endian":"little","word_size":64,"file_type":"6","flags":["0x00118085"],"uuid":"C6696447-82AC-3DFF-837E-AA32C12F4C53","entrypoints":{"items":[],"total":0,"exhaustive":true,"limitations":[]},"architectures":{"items":[{"name":"arm64","cpu_type":null,"cpu_subtype":null,"file_offset":null,"size":null,"alignment":null}],"total":1,"exhaustive":true,"limitations":[]},"build_metadata":{"items":[{"platform":"MACOS","minimum_os":"10.7","sdk":"13.0","tools":[{"name":"LD","version":"820.1"}]}],"total":1,"exhaustive":true,"limitations":[]},"load_commands":{"items":[{"index":0,"kind":"LC_SEGMENT_64","fields":{"cmd":"LC_SEGMENT_64","cmdsize":872,"segname":"__TEXT","vmaddr":0,"vmsize":1146880,"fileoff":0,"filesize":1146880,"maxprot":5,"initprot":5,"nsects":10,"flags":0}},{"index":1,"kind":"LC_SEGMENT_64","fields":{"cmd":"LC_SEGMENT_64","cmdsize":712,"segname":"__DATA","vmaddr":1146880,"vmsize":49152,"fileoff":1146880,"filesize":49152,"maxprot":3,"initprot":3,"nsects":8,"flags":0}},{"index":2,"kind":"LC_SEGMENT_64","fields":{"cmd":"LC_SEGMENT_64","cmdsize":72,"segname":"__LINKEDIT","vmaddr":1196032,"vmsize":1867776,"fileoff":1196032,"filesize":1851408,"maxprot":1,"initprot":1,"nsects":0,"flags":0}},{"index":3,"kind":"LC_ID_DYLIB","fields":{"cmd":"LC_ID_DYLIB","cmdsize":64,"name":"target/libspine-editorarm64.dylib (offset 24)","time stamp":"1 Thu Jan 1 08:00:01 1970","current version":"0.0.0","compatibility version":"0.0.0"}},{"index":4,"kind":"LC_DYLD_INFO_ONLY","fields":{"cmd":"LC_DYLD_INFO_ONLY","cmdsize":48,"rebase_off":1196032,"rebase_size":40,"bind_off":1196072,"bind_size":1512,"weak_bind_off":1197584,"weak_bind_size":676544,"lazy_bind_off":1874128,"lazy_bind_size":2656,"export_off":1876784,"export_size":343640}},{"index":5,"kind":"LC_SYMTAB","fields":{"cmd":"LC_SYMTAB","cmdsize":24,"symoff":2229264,"nsyms":4525,"stroff":2337560,"strsize":671400}},{"index":6,"kind":"LC_DYSYMTAB","fields":{"cmd":"LC_DYSYMTAB","cmdsize":80,"ilocalsym":0,"nlocalsym":1,"iextdefsym":1,"nextdefsym":4423,"iundefsym":4424,"nundefsym":101,"tocoff":0,"ntoc":0,"modtaboff":0,"nmodtab":0,"extrefsymoff":0,"nextrefsyms":0,"indirectsymoff":2301664,"nindirectsyms":8973,"extreloff":0,"nextrel":0,"locreloff":0,"nlocrel":0}},{"index":7,"kind":"LC_UUID","fields":{"cmd":"LC_UUID","cmdsize":24,"uuid":"C6696447-82AC-3DFF-837E-AA32C12F4C53"}},{"index":8,"kind":"LC_BUILD_VERSION","fields":{"cmd":"LC_BUILD_VERSION","cmdsize":32,"platform":1,"minos":"10.7","sdk":"13.0","ntools":1,"tool":3,"version":"820.1"}},{"index":9,"kind":"LC_LOAD_DYLIB","fields":{"cmd":"LC_LOAD_DYLIB","cmdsize":56,"name":"/usr/lib/libSystem.B.dylib (offset 24)","time stamp":"2 Thu Jan 1 08:00:02 1970","current version":"1319.0.0","compatibility version":"1.0.0"}},{"index":10,"kind":"LC_LOAD_DYLIB","fields":{"cmd":"LC_LOAD_DYLIB","cmdsize":88,"name":"/System/Library/Frameworks/Cocoa.framework/Versions/A/Cocoa (offset 24)","time stamp":"2 Thu Jan 1 08:00:02 1970","current version":"23.0.0","compatibility version":"1.0.0"}},{"index":11,"kind":"LC_LOAD_DYLIB","fields":{"cmd":"LC_LOAD_DYLIB","cmdsize":48,"name":"/usr/lib/libc++.1.dylib (offset 24)","time stamp":"2 Thu Jan 1 08:00:02 1970","current version":"1300.32.0","compatibility version":"1.0.0"}},{"index":12,"kind":"LC_LOAD_DYLIB","fields":{"cmd":"LC_LOAD_DYLIB","cmdsize":88,"name":"/System/Library/Frameworks/AppKit.framework/Versions/C/AppKit (offset 24)","time stamp":"2 Thu Jan 1 08:00:02 1970","current version":"2297.0.0","compatibility version":"45.0.0"}},{"index":13,"kind":"LC_LOAD_DYLIB","fields":{"cmd":"LC_LOAD_DYLIB","cmdsize":56,"name":"/usr/lib/libobjc.A.dylib (offset 24)","time stamp":"2 Thu Jan 1 08:00:02 1970","current version":"228.0.0","compatibility version":"1.0.0"}},{"index":14,"kind":"LC_FUNCTION_STARTS","fields":{"cmd":"LC_FUNCTION_STARTS","cmdsize":16,"dataoff":2220424,"datasize":8840}},{"index":15,"kind":"LC_DATA_IN_CODE","fields":{"cmd":"LC_DATA_IN_CODE","cmdsize":16,"dataoff":2229264,"datasize":0}},{"index":16,"kind":"LC_CODE_SIGNATURE","fields":{"cmd":"LC_CODE_SIGNATURE","cmdsize":16,"dataoff":3008960,"datasize":38480}}],"total":17,"exhaustive":true,"limitations":[]},"dependencies":{"items":[{"path":"target/libspine-editorarm64.dylib","kind":"LC_ID_DYLIB","current_version":"0.0.0","compatibility_version":"0.0.0"},{"path":"/Line truncated
+161
View File
@@ -0,0 +1,161 @@
# 用 REA 分析 Spine.app — 首轮结果
日期:2026-10-15
工具:REA (rea-agents 6.5.0),仓库来源 `https://github.com/morluto/rea.git`
目标:`/Users/tianmokeji/Desktop/临时/spine加功能/Spine.app`(Spine 4.3.06 启动器 / 4.3.26 Professional 编辑器)
## 0. 怎么跑起来的
| 步骤 | 结果 |
| --- | --- |
| `git clone https://github.com/morluto/rea.git` | 直连 **卡死在 72KB**;换 `ghfast.top` 镜像也卡在 4.1MB |
| `git -c http.version=HTTP/1.1 clone` | **成功**,68MB / 约 4 秒。沙箱代理下 HTTP/2 大包传输会中断,强制 HTTP/1.1 可绕过 |
| `npm install -g rea-agents` | 安装 rea 6.5.0,CLI 可用 |
| `rea doctor --client codex` | MCP 未注册;Hopper/Ghidra/IDA 均缺失(深度反汇编不可用) |
REA 的 MCP 注册需要重启客户端才生效,本轮全部走 **CLI**(REA 技能文档认可的同源路径)。
**踩坑**:REA 的 `inspect_plist` / `inspect_signature` / `observe-native-ui` 会现场用 `xcrun` 编译辅助进程,沙箱内会失败,必须放沙箱外执行。
## 1. 包结构(REA `inspect-artifact`)
- 图规模:185 节点 / 239 次出现 / 238 条边,`coverage.status = complete`
- Info.plist:`CFBundleShortVersionString 4.3.06`、`LSMinimumSystemVersion 10.13`、文档类型 `.spine`
- 签名(`inspect-signature`):**ad-hoc 签名**,`authorities: []`、`team_identifier: null`、`hardened_runtime: false`、无 entitlements
`/Applications/Spine.app` 与工作区副本的 `Contents/MacOS/Spine` SHA-256 完全一致(`fea6e69a…4340f2`),两个 libjvm 大小也一致 → 工作区副本即真实安装的等价副本。
## 2. 启动链路(REA `inspect-macho` + `trace-dylib-resolution`)
`Contents/MacOS/Spine` 只有 88KB,是纯原生启动器:
- 链接:Foundation / AppKit / Cocoa / libobjc / libSystem(**不含 libjvm**,JVM 是运行期 `dlopen` 进来的)
- 内置字符串给出完整启动参数:
```
-Djava.class.path=%@/Contents/MacOS/launcher/1/launcher.jar
%@/Contents/MacOS/launcher/1/lib/jli/libjli.dylib → 失败则 %@/Contents/MacOS/launcher/2/lib/libjli.dylib
%@/Contents/MacOS/launcher/1/lib/server/libjvm.dylib → 失败则 %@/Contents/MacOS/launcher/2/lib/server/libjvm.dylib
JNI_CreateJavaVM
主类候选:com/esotericsoftware/spine/editor/launcher/Launcher、com/esotericsoftware/spine/launcher/Launcher
```
`launcher.jar` 只有 72 个条目 / 2MB(`s/*.class` 混淆包 + 一个 374KB 的 `Launcher.class`)——**它只是引导壳,不是编辑器本体**。
## 3. 真正的加固点在 launcher/2 的 libjvm(已实测确认)
REA 对两个 libjvm 的段对比:
| | launcher/1/libjvm.dylib | launcher/2/libjvm.dylib |
| --- | --- | --- |
| 文件大小 | 13.3 MB | **58.4 MB** |
| `__TEXT` | 8.5 MB | 8.1 MB |
| `__DATA` | 313 KB | **48,896,800 B(≈48.9MB,文件偏移 8,933,376)** |
| HotSpot 调试段 `__dof_hotspot*` | 有 | **无** |
| `lib/modules`(jimage) | **46,549,519 B 存在** | **不存在** |
`launcher/2/libjvm` 的 `__DATA` 前 1MiB 熵 **7.59 bit/byte**,且扫描不到 `PK\x03\x04` / `PK\x05\x06` / jimage magic → 是**加密载荷**,不是明文 jar。
配合缺 `modules` 仍能正常启动这一事实,结论是:
> launcher/2 把 Java 模块镜像(jimage)连同编辑器类一起搬进了 libjvm 的 `__DATA` 并加密,
> 由打过补丁的 JVM 在加载时解密 —— 这就是「类不在 jar 里」的真正原因。
### 运行期到底加载了哪个?(零干扰实测)
对正在运行的 Spine(PID 91687)做 `lsof` / `vmmap` 只读采样:
```
…/launcher/2/lib/libjli.dylib
…/launcher/2/lib/server/libjvm.dylib ← 实际使用 launcher/2,不是 launcher/1
```
即:**`launcher/1` 是引导尝试路径,真正的运行时是 `launcher/2`**,与第 3 节吻合。
## 4. 新发现:编辑器原生核心在临时目录里
同一份 `lsof` 显示编辑器真正的原生库并不是从 app 包里加载的,而是运行期解包到临时目录:
```
$TMPDIR/.spine/tianmokeji/4.2.43/{libspine-editorarm64.dylib, libgdxarm64.dylib,
libgdx-freetypearm64.dylib, libjglfwarm64.dylib,
liblwjgl.dylib, openal.dylib}
$TMPDIR/.spine/tianmokeji/L4.3.06/libjglfwarm64.dylib
$TMPDIR/.spine/tianmokeji/4.3.26/libspine-editorarm64.dylib
```
REA `inspect-macho` 对 `libspine-editorarm64.dylib`(3,047,440 B)的结果:
- install name `target/libspine-editorarm64.dylib`,依赖仅 Cocoa/AppKit/libSystem/libobjc/libc++
- 导出符号(REA 导出列表)里最有价值的几条:
```
_Java_com_esotericsoftware_spine_editor_Editor_licCheck
_Java_com_esotericsoftware_spine_editor_Editor_register
_Java_com_esotericsoftware_spine_editor_Editor_expire
_Java_com_esotericsoftware_spine_editor_audio_Audio_pauseDeviceJni / resumeDeviceJni
```
另外导出大量 **Eigen** 符号(`Eigen::Matrix`、`squared_edge_lengths`),说明网格/几何解算是 Eigen 在跑。
**这条信息对「在编辑器内部加粒子面板」很关键**:编辑器原生核心是**磁盘上、未加密、可直接分析的 dylib**,
比拿 libjvm 里那 49MB 加密数据段要现实得多。
日志侧证据:`~/Library/Application Support/Spine/spine.log` →
`Spine Launcher 4.3.06 (macOS Apple Silicon)` / `Starting: Spine 4.3.26 Professional`。
## 5. REA 运行时 UI 观测:被一个具体阻塞点挡住
REA 有 `observe-native-ui` / `capture-native-ui-scenario`(读取窗口的无障碍树 + 截图),
本来最适合你现在的面板研究。实测报错:
```
process-identity-unavailable: Selected application does not expose a launch time
```
定位(读源码 + 实测):`rea/bridge/native/ReaNativeUI.swift` 用
`NSRunningApplication.launchDate` 做 PID 复用校验,而当前实例由命令行直接 exec 启动,
LaunchServices 没有登记它:
```
pid=91687 bundleIdentifier=nil bundleURL=…/spine-lab/Spine.app launchDate=nil
```
**解决办法**:让 Spine 由 LaunchServices 启动(`open -na <app> --args …` 或访达双击),
新进程才有 `launchDate`,REA 的 UI 树观测即可通过。
## 6. 本轮结论清单
| 问题 | 状态 | 依据 |
| --- | --- | --- |
| 编辑器类为什么不在 jar 里 | 已答 | launcher.jar 仅 72 条目;launcher/2 无 `lib/modules`,libjvm `__DATA` 48.9MB 高熵 |
| 实际运行哪个 libjvm | 已答(实测) | `lsof`/`vmmap`:launcher/2 |
| 编辑器原生代码在哪 | 已答 | `$TMPDIR/.spine/<user>/<ver>/libspine-editorarm64.dylib` |
| 编辑器原生库的 JNI 面 | 已答 | REA 导出:`Editor_licCheck/register/expire`、`Audio_*` |
| 包内清单 / 签名 / plist | 已答 | REA inventory / signature / plist |
| 深度反汇编(伪代码、函数、xrefs) | **未答** | 缺 Hopper/Ghidra/IDA 提供商 |
| 编辑器窗口内面板运行时结构 | **未答** | 需要先解决 launchDate(见第 5 节) |
## 7. 建议的下一步
1. **装 Ghidra(免费)**:`GHIDRA_INSTALL_DIR` 指过去,REA 立刻能对
`libspine-editorarm64.dylib`、`launcher/2/libjvm.dylib`、`libgdxarm64.dylib` 出伪代码与 xrefs。
2. **用 LaunchServices 启动 Spine**,然后跑
`rea capture-native-ui-scenario`,把编辑器面板/工具栏的无障碍树和可点击路径抓出来(可直接验证「窗口内面板是否可见、能否点」)。
3. 面板注入目标从 libjvm 改为 **`libspine-editorarm64.dylib`**:磁盘未加密、有完整 JNI 导出、可被 LLDB/Frida 附着。
4. 若要 REA 的 MCP 工具而非 CLI:`rea setup --client codex --dry-run --json` 看变更清单,
确认后 `--yes` 并重启 Codex(注册写入 `~/.codex/config.toml`)。
## 8. 证据文件
| 文件 | 内容 |
| --- | --- |
| `rea-analysis/01-inspect-artifact.json` | 应用清单图(185 节点,含 SHA-256) |
| `rea-analysis/02-macho-Spine.json` | 原生启动器 Mach-O(依赖、段、入口) |
| `rea-analysis/03-signature.json` | ad-hoc 签名详情 |
| `rea-analysis/04-dylib-resolution.json` | dyld 解析(8 个可执行根,含 ffmpeg/oxipng) |
| `rea-analysis/05-macho-libjvm2.json` / `06-macho-libjvm1.json` | 两个 libjvm 段对比 |
| `rea-analysis/07-native-ui.json` | 运行时 UI 观测尝试(记录阻塞原因) |
| `rea-analysis/08-macho-spine-editor.json` | 编辑器原生库(约 1MB 导出/导入明细) |
| `rea-analysis/tools/list-windows.swift` | 列出窗口 ID(配合 REA) |
| `rea-analysis/tools/check-app-identity.swift` | 检查启动时间/LaunchServices 身份(定位第 5 节问题) |
@@ -0,0 +1,21 @@
import AppKit
import Foundation
// Reports the LaunchServices identity REA relies on for one PID.
// Usage: swift check-app-identity.swift <pid>
guard CommandLine.arguments.count > 1, let pid = Int32(CommandLine.arguments[1]) else {
FileHandle.standardError.write(Data("usage: check-app-identity.swift <pid>\n".utf8))
exit(2)
}
guard let app = NSRunningApplication(processIdentifier: pid) else {
print("pid=\(pid) NSRunningApplication=nil (process not registered with LaunchServices)")
exit(0)
}
print("pid=\(pid) bundleIdentifier=\(app.bundleIdentifier ?? "nil")")
print("localizedName=\(app.localizedName ?? "nil")")
print("bundleURL=\(app.bundleURL?.path ?? "nil")")
print("launchDate=\(app.launchDate.map { String($0.timeIntervalSince1970) } ?? "nil")")
print("activationPolicy=\(app.activationPolicy.rawValue)")
+32
View File
@@ -0,0 +1,32 @@
import CoreGraphics
import Foundation
// Posts one synthetic left click at global screen coordinates.
// Usage: swift click.swift <x> <y>
guard CommandLine.arguments.count > 2,
let x = Double(CommandLine.arguments[1]),
let y = Double(CommandLine.arguments[2])
else {
FileHandle.standardError.write(Data("usage: click.swift <x> <y>\n".utf8))
exit(2)
}
let point = CGPoint(x: x, y: y)
let source = CGEventSource(stateID: .hidSystemState)
guard
let move = CGEvent(mouseEventSource: source, mouseType: .mouseMoved, mouseCursorPosition: point, mouseButton: .left),
let down = CGEvent(mouseEventSource: source, mouseType: .leftMouseDown, mouseCursorPosition: point, mouseButton: .left),
let up = CGEvent(mouseEventSource: source, mouseType: .leftMouseUp, mouseCursorPosition: point, mouseButton: .left)
else {
FileHandle.standardError.write(Data("cannot create events\n".utf8))
exit(1)
}
move.post(tap: .cghidEventTap)
usleep(120_000)
down.post(tap: .cghidEventTap)
usleep(80_000)
up.post(tap: .cghidEventTap)
print("clicked at \(Int(x)),\(Int(y))")
+26
View File
@@ -0,0 +1,26 @@
import CoreGraphics
import Foundation
// Lists on-screen windows, optionally filtered by owning PID.
// Usage: swift list-windows.swift [pid]
let filterPid: Int? = CommandLine.arguments.count > 1 ? Int(CommandLine.arguments[1]) : nil
guard
let windows = CGWindowListCopyWindowInfo(
[.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]]
else {
FileHandle.standardError.write(Data("window list unavailable\n".utf8))
exit(1)
}
for window in windows {
guard let pid = window[kCGWindowOwnerPID as String] as? Int else { continue }
if let filterPid, pid != filterPid { continue }
let id = window[kCGWindowNumber as String] as? Int ?? -1
let owner = window[kCGWindowOwnerName as String] as? String ?? "?"
let name = window[kCGWindowName as String] as? String ?? ""
let layer = window[kCGWindowLayer as String] as? Int ?? -1
let bounds = window[kCGWindowBounds as String] as? [String: Any] ?? [:]
print("windowId=\(id) pid=\(pid) layer=\(layer) owner=\(owner) name=\(name) bounds=\(bounds)")
}
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+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"
File diff suppressed because it is too large. Load diff
+139
View File
@@ -0,0 +1,139 @@
// 在类定义入口抓字节:判断编辑器的类是在哪个环节被解密的。
//
// 用法(attach 到正在跑的编辑器,或 spawn):
// rea instrument-with-frida attach <pid> --script-file frida-dump-classes.js --duration-ms 60000
//
// 输出:
// /tmp/spine-classes/plain/*.class —— 在入口就已经是 cafebabe 的类(直接可用)
// /tmp/spine-classes/meta.txt —— 每次调用的名字 + 前 8 字节 + 长度
const OUT = "/tmp/spine-classes";
function ensureDirs() {
try { new File(OUT, "r"); } catch (e) { new File(OUT, "w").close(); }
const plain = OUT + "/plain";
try { new File(plain, "r"); } catch (e) { new File(plain, "w").close(); }
}
let metaFile = null;
function meta(line) {
try {
if (metaFile === null) metaFile = new File(OUT + "/meta.txt", "a");
metaFile.write(line + "\n");
metaFile.flush();
} catch (e) { }
}
let dumped = 0;
let seen = 0;
function hex(bytes, count) {
let s = "";
for (let i = 0; i < count && i < bytes.length; i++) {
s += ("0" + bytes[i].toString(16)).slice(-2);
}
return s;
}
function handle(namePtr, bufPtr, len, where) {
seen++;
if (bufPtr.isNull() || len <= 8 || len > 8 * 1024 * 1024) return;
let name = "";
try { name = namePtr.isNull() ? "(null)" : namePtr.readUtf8String(); } catch (e) { name = "(?)"; }
let head;
try { head = Memory.readByteArray(bufPtr, Math.min(len, 8)); } catch (e) { return; }
const bytes = new Uint8Array(head);
const tag = hex(bytes, 4);
if (seen < 40) send({ where: where, name: name, len: len, head: hex(bytes, 8) });
if (tag === "cafebabe") {
dumped++;
try {
const data = Memory.readByteArray(bufPtr, len);
const file = new File(OUT + "/plain/" + (name ? name.replace(/[\\/]/g, "_") : ("anon" + dumped)) + ".class", "wb");
file.write(data);
file.close();
} catch (e) { }
} else if (seen < 40) {
meta(where + " " + name + " len=" + len + " head=" + hex(bytes, 8));
}
}
// Frida 17 去掉了 Module.findExportByName,这里把新旧几套 API 都试一遍。
function exportOf(module, name) {
try {
if (typeof module.findExportByName === "function") return module.findExportByName(name);
} catch (e) { }
try {
if (typeof module.getExportByName === "function") return module.getExportByName(name);
} catch (e) { }
try {
if (typeof Module.findExportByName === "function") return Module.findExportByName(module.name, name);
} catch (e) { }
try {
if (typeof Module.getExportByName === "function") return Module.getExportByName(module.name, name);
} catch (e) { }
return null;
}
function hookModule() {
const libjvm = Process.findModuleByName("libjvm.dylib");
if (!libjvm) {
send({ error: "libjvm.dylib 未加载" });
return false;
}
send({ module: libjvm.name, base: libjvm.base.toString(), size: libjvm.size });
const targets = [
"JVM_DefineClassWithSource",
"JVM_DefineClass",
"JVM_LookupDefineClass",
"_JVM_DefineClassWithSource",
"_JVM_DefineClass",
"_JVM_LookupDefineClass",
];
let hooked = 0;
for (const name of targets) {
const addr = exportOf(libjvm, name);
if (!addr) continue;
hooked++;
send({ hooked: name, addr: addr.toString() });
Interceptor.attach(addr, {
onEnter(args) {
// (JNIEnv*, const char* name, jobject loader, const jbyte* buf, jsize len, jobject pd[, const char* source])
try {
handle(args[1], args[3], args[4].toInt32(), name);
} catch (e) { }
},
});
}
send({ hookedCount: hooked });
return hooked > 0;
}
ensureDirs();
if (!hookModule()) {
// libjvm 还没加载:用模块加载通知(拿不到就退回到高频轮询),
// 因为类定义紧接着 libjvm 加载就开始了,晚了就什么都抓不到。
let done = false;
try {
Process.attachModuleObserver({
onAdded(module) {
if (done || module.name !== "libjvm.dylib") return;
done = true;
hookModule();
},
});
} catch (e) { }
const waiter = setInterval(function () {
if (done) { clearInterval(waiter); return; }
if (Process.findModuleByName("libjvm.dylib")) {
done = true;
clearInterval(waiter);
hookModule();
}
}, 20);
}
setInterval(function () {
send({ seen: seen, dumped: dumped });
}, 10000);
Whitespace-only changes.
Whitespace-only changes.
+1
View File
@@ -0,0 +1 @@
{"A": {"name": "A", "super": "java.lang.Enum", "interfaces": [], "flags": 16433, "fields": [{"name": "D", "type": "A", "flags": 16409}, {"name": "B", "type": "A", "flags": 16409}, {"name": "L", "type": "A", "flags": 16394}, {"name": "I", "type": "A", "flags": 16394}, {"name": "S", "type": "A", "flags": 16394}, {"name": "C", "type": "A", "flags": 16394}, {"name": "Z", "type": "A", "flags": 16394}, {"name": "D", "type": "int", "flags": 17}, {"name": "D", "type": "A[]", "flags": 4122}], "methods": [{"name": "values", "params": [], "returns": "A[]", "flags": 9}, {"name": "valueOf", "params": ["java.lang.String"], "returns": "A", "flags": 9}, {"name": "<init>", "params": ["java.lang.String", "int", "int"], "returns": "void", "flags": 2}, {"name": "<clinit>", "params": [], "returns": "void", "flags": 8}], "path": "/Volumes/Spine398/A.class"}, "AA": {"name": "AA", "super": "java.lang.Enum", "interfaces": [], "flags": 16433, "fields": [{"name": "D", "type": "AA", "flags": 16409}, {"name": "B", "type": "AA", "flags": 16409}, {"name": "L", "type": "AA", "flags": 16409}, {"name": "I", "type": "AA", "flags": 16409}, {"name": "S", "type": "AA", "flags": 16409}, {"name": "D", "type": "AA[]", "flags": 4122}], "methods": [{"name": "values", "params": [], "returns": "AA[]", "flags": 9}, {"name": "valueOf", "params": ["java.lang.String"], "returns": "AA", "flags": 9}, {"name": "<init>", "params": ["java.lang.String", "int"], "returns": "void", "flags": 2}, {"name": "<clinit>", "params": [], "returns": "void", "flags": 8}], "path": "/Volumes/Spine398/AA.class"}, "AB": {"name": "AB", "super": "et", "interfaces": [], "flags": 1057, "fields": [{"name": "D", "type": "Ah", "flags": 17}, {"name": "D", "type": "JS", "flags": 1}, {"name": "B", "type": "boolean", "flags": 0}], "methods": [{"name": "<init>", "params": ["Ah", "JS"], "returns": "void", "flags": 1}, {"name": "L", "params": ["java.lang.String"], "returns": "void", "flags": 20}, {"name": "D", "params": ["AB"], "returns": "void", "flags": 1}, {"name": "B", "params": ["AB"], "returns": "void", "flags": 1}, {"name": "D", "params": [], "returns": "java.lang.Exception", "flags": 1025}, {"name": "D", "params": ["com.esotericsoftware.spine.editor.export.ExportSettings"], "returns": "void", "flags": 1025}, {"name": "D", "params": [], "returns": "com.esotericsoftware.spine.editor.export.ExportSettings", "flags": 1025}, {"name": "D", "params": ["java.lang.String"], "returns": "boolean", "flags": 17}, {"name": "D", "params": ["java.lang.Class", "java.lang.String"], "returns": "java.io.InputStream", "flags": 8}, {"name": "Nb", "params": ["java.lang.Object", "java.lang.Object", "java.lang.Object", "java.lang.Object"], "returns": "java.lang.Object", "flags": 10}], "path": "/Volumes/Spine398/AB.class"}, "AC": {"name": "AC", "super": "vv", "interfaces": [], "flags": 48, "fields": [{"name": "D", "type": "AB", "flags": 4098}], "methods": [{"name": "<init>", "params": ["AB", "java.lang.String"], "returns": "void", "flags": 0}, {"name": "L", "params": ["java.lang.Object"], "returns": "void", "flags": 17}, {"name": "xp", "params": ["java.lang.Object", "java.lang.Object", "java.lang.Object", "java.lang.Object"], "returns": "java.lang.Object", "flags": 10}], "path": "/Volumes/Spine398/AC.class"}, "AD": {"name": "AD", "super": "et", "interfaces": ["java.lang.Runnable"], "flags": 49, "fields": [{"name": "D", "type": "Ah", "flags": 16}, {"name": "D", "type": "s", "flags": 1}, {"name": "D", "type": "JX", "flags": 1}, {"name": "D", "type": "dl", "flags": 1}, {"name": "D", "type": "df", "flags": 16}, {"name": "B", "type": "df", "flags": 2}, {"name": "D", "type": "float[]", "flags": 16}, {"name": "B", "type": "int", "flags": 0}, {"name": "Code", "type": "float", "flags": 0}, {"name": "SourceFile", "type": "float", "flags": 0}, {"name": "StackMapTable", "type": "float", "flags": 0}, {"name": "init", "type": "float", "flags": 0}, {"name": "LineNumberTable", "type": "float", "flags": 0}, {"name": "String", "type": "float", "flags": 0}, {"name": "Class", "type": "float", "flags": 0}, {"name": "Ljava", "type": "float", "flags": 0}, {"name": "java", "type": "float", "flags": 0}, {"name": "lang", "type": "float", "flags": 0}, {"name": "Object", "type": "float", "flags": 0}, {"name": "invoke", "type": "float", "flags": 0}, {"name": "MethodHandle", "type": "float", "flags": 0}, {"name": "Lcom", "type": "float", "flags": 0}, {"name": "CallSite", "type": "float", "flags": 0}, {"name": "ConstantCallSite", "type": "float", "flags": 0}, {"name": "Exceptions", "type": "float", "flags": 0}, {"name": "util", "type": "float", "flags": 0}, {"name": "B", "type": "boolean", "flags": 0}, {"name": "L", "type": "boolean", "flags": 0}, {"name": "D", "type": "de", "flags": 2}, {"name": "D", "type": "long", "flags": 2}, {"name": "D", "type": "java.util.concurrent.ThreadPoolExecutor", "flags": 16}, {"name": "D", "type": "z", "flags": 0}, {"name": "I", "type": "boolean", "flags": 0}, {"name": "S", "type": "boolean", "flags": 0}, {Line truncated
@@ -0,0 +1 @@
{"evidence_id":"ev_e367b51c6019f1891f6453ad38ede6785bbe937bb983126847ece3b99ae05bd3","subject":{"name":"libspine-editorarm64.dylib","digest":{"sha256":"2549d0b07be0926995628e0e887e72cf0812c80438a7cfb9d1935c88cad28c57"},"local_path":"/private/var/folders/jl/m50cxy0d7zzfd0448wzhj2bc0000gn/T/.spine/tianmokeji/4.3.26/libspine-editorarm64.dylib","format":"mach-o","architecture":"arm64"},"provider":{"id":"rea-workflow","name":"REA composed investigation workflow","version":"1"},"analysis_profile":{"provider":{"id":"rea-workflow","name":"REA composed investigation workflow","version":"1"},"parameters":{"upstream_analysis_profile":{"provider":{"id":"ghidra","name":"Ghidra","version":"12.1.4"},"parameters":{"target_kind":"executable","target_format":"mach-o","architecture":"arm64","available_architectures":["arm64"],"import_mode":"ephemeral-source-immutable","annotation_policy":"atomic-function-entry-metadata-v1","load_image_observations":"source-mappings-entry-context-v2","function_body_evidence":"complete-inclusive-ranges-v1","function_references":"complete-body-and-entry-reference-manager-v2","call_relationship_coverage":"function-body-call-coverage-v5","function_dossier_coverage":"observed-facets-and-call-relationships-v1","string_inventory_evidence":"defined-data-coverage-v1","location_resolution":"explicit-address-exact-entry-symbol-first-v3","procedure_file_offset_resolution":"address-first-unique-target-source-mapping-v2","instruction_flow_evidence":"decoded-return-pcode-v1","instruction_references":"exact-address-incoming-v1","function_boundary_observations":"ghidra-function-boundary-limitations-v3","no_return_repair":"returning-imports-decoded-return-v2","process_launch":"inspected-jvm-launch-support-v1","jump_table_evidence":"typed-case-default-blocks-v1","decompiler_jump_loads":true,"loader":"auto-from-header","language_id":"auto-from-header","compiler_spec_id":"auto-default","analyzer_preset":"ghidra-default"},"digest":"966e6016724421ad282356f583a9d5bf24577db4ac77a75c7099d236b88bcc28"},"workflow_revision":3},"digest":"36e03fb12d9b7fb24826a311c442f39020ecdc72441154c33bf99b49256605a8"},"predicate_type":"rea.analysis","operation":"binary_overview","parameters":{},"raw_result":null,"normalized_result":{"document":"target-2549d0b07be0.dylib","image_base":"0x0","segments":[{"name":"__TEXT","start":"0x0","end":"0x1d70","length":7536,"image_base":"0x0","address_space":"ram"},{"name":"__text","start":"0x1d70","end":"0xb6f74","length":741892,"image_base":"0x0","address_space":"ram"},{"name":"__stubs","start":"0xb6f74","end":"0xc40f0","length":53628,"image_base":"0x0","address_space":"ram"},{"name":"__stub_helper","start":"0xc40f0","end":"0xc4420","length":816,"image_base":"0x0","address_space":"ram"},{"name":"__objc_stubs","start":"0xc4420","end":"0xc45a0","length":384,"image_base":"0x0","address_space":"ram"},{"name":"__gcc_except_tab","start":"0xc45a0","end":"0xc6ca0","length":9984,"image_base":"0x0","address_space":"ram"},{"name":"__const","start":"0xc6ca0","end":"0xc6dd8","length":312,"image_base":"0x0","address_space":"ram"},{"name":"__cstring","start":"0xc6dd8","end":"0xc739a","length":1474,"image_base":"0x0","address_space":"ram"},{"name":"__objc_methname","start":"0xc739a","end":"0xc7458","length":190,"image_base":"0x0","address_space":"ram"},{"name":"__unwind_info","start":"0xc7458","end":"0xcc1e0","length":19848,"image_base":"0x0","address_space":"ram"},{"name":"__eh_frame","start":"0xcc1e0","end":"0x118000","length":310816,"image_base":"0x0","address_space":"ram"},{"name":"__got","start":"0x118000","end":"0x118118","length":280,"image_base":"0x0","address_space":"ram"},{"name":"__la_symbol_ptr","start":"0x118118","end":"0x120cc0","length":35752,"image_base":"0x0","address_space":"ram"},{"name":"__const","start":"0x120cc0","end":"0x120e30","length":368,"image_base":"0x0","address_space":"ram"},{"name":"__objc_imageinfo","start":"0x120e30","end":"0x120e38","length":8,"image_base":"0x0","address_space":"ram"},{"name":"__objc_selrefs","start":"0x120e38","end":"0x120e98","length":96,"image_base":"0x0","address_space":"ram"},{"name":"__objc_classrefs","start":"0x120e98","end":"0x120ea0","length":8,"image_base":"0x0","address_space":"ram"},{"name":"__data","start":"0x120ea0","end":"0x120f18","length":120,"image_base":"0x0","address_space":"ram"},{"name":"__bss","start":"0x120f18","end":"0x120f1c","length":4,"image_base":"0x0","address_space":"ram"},{"name":"__DATA","start":"0x120f1c","end":"0x124000","length":12516,"image_base":"0x0","address_space":"ram"},{"name":"__LINKEDIT","start":"0x124000","end":"0x2e8010","length":1851408,"image_base":"0x0","address_space":"ram"},{"name":"__LINKEDIT","start":"0x2e8010","end":"0x2ec000","length":16368,"image_base":"0x0","address_space":"ram"},{"name":"EXTERNAL","start":"0x2ec000","end":"0x2edf00","length":7936,"image_base":"0x0","address_space":"ram"}],"segment_count":23,"procedure_count":17006,"string_count":16920},"confidence":"derived","authority":"shipped-artifact"Line truncated
View File
Whitespace-only changes.
@@ -0,0 +1 @@
{"binding":{"analysis_profile":{"digest":"966e6016724421ad282356f583a9d5bf24577db4ac77a75c7099d236b88bcc28","parameters":{"analyzer_preset":"ghidra-default","annotation_policy":"atomic-function-entry-metadata-v1","architecture":"arm64","available_architectures":["arm64"],"call_relationship_coverage":"function-body-call-coverage-v5","compiler_spec_id":"auto-default","decompiler_jump_loads":true,"function_body_evidence":"complete-inclusive-ranges-v1","function_boundary_observations":"ghidra-function-boundary-limitations-v3","function_dossier_coverage":"observed-facets-and-call-relationships-v1","function_references":"complete-body-and-entry-reference-manager-v2","import_mode":"ephemeral-source-immutable","instruction_flow_evidence":"decoded-return-pcode-v1","instruction_references":"exact-address-incoming-v1","jump_table_evidence":"typed-case-default-blocks-v1","language_id":"auto-from-header","load_image_observations":"source-mappings-entry-context-v2","loader":"auto-from-header","location_resolution":"explicit-address-exact-entry-symbol-first-v3","no_return_repair":"returning-imports-decoded-return-v2","procedure_file_offset_resolution":"address-first-unique-target-source-mapping-v2","process_launch":"inspected-jvm-launch-support-v1","string_inventory_evidence":"defined-data-coverage-v1","target_format":"mach-o","target_kind":"executable"},"provider":{"id":"ghidra","name":"Ghidra","version":"12.1.4"}},"provider":{"id":"ghidra","name":"Ghidra","version":"12.1.4"}},"entries":[],"evidence_bundle":{"artifacts":[{"architecture":"arm64","digest":{"sha256":"2549d0b07be0926995628e0e887e72cf0812c80438a7cfb9d1935c88cad28c57"},"format":"mach-o"}],"captures":[],"environments":[],"providers":[{"id":"rea-workflow","name":"REA composed investigation workflow","version":"1"}],"records":[{"analysis_profile":{"digest":"36e03fb12d9b7fb24826a311c442f39020ecdc72441154c33bf99b49256605a8","parameters":{"upstream_analysis_profile":{"digest":"966e6016724421ad282356f583a9d5bf24577db4ac77a75c7099d236b88bcc28","parameters":{"analyzer_preset":"ghidra-default","annotation_policy":"atomic-function-entry-metadata-v1","architecture":"arm64","available_architectures":["arm64"],"call_relationship_coverage":"function-body-call-coverage-v5","compiler_spec_id":"auto-default","decompiler_jump_loads":true,"function_body_evidence":"complete-inclusive-ranges-v1","function_boundary_observations":"ghidra-function-boundary-limitations-v3","function_dossier_coverage":"observed-facets-and-call-relationships-v1","function_references":"complete-body-and-entry-reference-manager-v2","import_mode":"ephemeral-source-immutable","instruction_flow_evidence":"decoded-return-pcode-v1","instruction_references":"exact-address-incoming-v1","jump_table_evidence":"typed-case-default-blocks-v1","language_id":"auto-from-header","load_image_observations":"source-mappings-entry-context-v2","loader":"auto-from-header","location_resolution":"explicit-address-exact-entry-symbol-first-v3","no_return_repair":"returning-imports-decoded-return-v2","procedure_file_offset_resolution":"address-first-unique-target-source-mapping-v2","process_launch":"inspected-jvm-launch-support-v1","string_inventory_evidence":"defined-data-coverage-v1","target_format":"mach-o","target_kind":"executable"},"provider":{"id":"ghidra","name":"Ghidra","version":"12.1.4"}},"workflow_revision":3},"provider":{"id":"rea-workflow","name":"REA composed investigation workflow","version":"1"}},"authority":"shipped-artifact","confidence":"derived","environment":null,"evidence_id":"ev_e367b51c6019f1891f6453ad38ede6785bbe937bb983126847ece3b99ae05bd3","evidence_links":[],"limitations":["Derived by an REA composed workflow.","A headless Ghidra session contains exactly one imported Program, unlike Hopper's multi-document GUI session.","Cancelling an active Ghidra request terminates its ephemeral database and discards session annotations. The selected target remains open; the next provider query imports the original artifact again.","Charset is reported, while a non-missing terminator cannot distinguish a present terminator from a fixed or Pascal layout.","Default-space addresses use lowercase 0x-prefixed hexadecimal; other address spaces use <percent-encoded-space>:0x<hex>.","External functions and local thunks are distinct; procedure metadata identifies both and preserves a thunk target when Ghidra resolves one.","Memory-block end addresses are exclusive; permissions come from Ghidra MemoryBlock flags rather than inference from section names.","Only Ghidra-defined string Data is observed; packed or undefined string bytes may be absent. Completeness applies to that inventory, and an empty search does not establish that text is absent from the artifact. If an address is known, use read_bytes and xrefs to inspect bytes and references.","The imported Program and temporary project are ephemeral and deleted on close. Annotation operations edit session database metadata; original executable bytes are never written.","The seLine truncated
+46
View File
@@ -0,0 +1,46 @@
#!/bin/zsh
# 编译 ViewPanel:注入器(C,无 UI 依赖)+ 面板(纯 Java / Scene2D 反射)
set -e
HERE="$(cd "$(dirname "$0")" && pwd)"
cd "$HERE"
# 找一个 JDK(编辑器运行时是 OpenJDK;本机现成的是 /tmp 下的 Temurin 17)
if [[ -z "$JAVA_HOME" ]]; then
for candidate in /tmp/jdk-17.0.20.1+1/Contents/Home /tmp/jdk*/Contents/Home; do
[[ -x "$candidate/bin/javac" ]] && JAVA_HOME="$candidate" && break
done
fi
if [[ -z "$JAVA_HOME" || ! -x "$JAVA_HOME/bin/javac" ]]; then
echo "找不到 JDK:请设置 JAVA_HOME" >&2
exit 1
fi
mkdir -p build/classes
echo "== 编译 Java 侧(javac $("$JAVA_HOME/bin/javac" -version 2>&1))"
find src -name '*.class' -delete 2>/dev/null || true
"$JAVA_HOME/bin/javac" --release 8 -encoding UTF-8 -d build/classes $(find src -name '*.java')
# 保名类清单:这套运行时把 libGDX 基类混淆了,但 ui/* 和 editor/* 保名。
# 旧版本的明文载荷里能直接列出这些类名,用它们做静态字段扫描的候选。
UPDATES="$HOME/Library/Application Support/Spine/updates"
if [[ -f "$UPDATES/3.8.99" ]]; then
unzip -l "$UPDATES/3.8.99" 2>/dev/null \
| awk '{print $4}' | grep -E '\.class$' | grep -E '^(com|org)/' \
| sed 's#/#.#g; s#\.class$##' | sort -u > build/kept-classes.txt
echo "保名类清单: $(wc -l < build/kept-classes.txt) 个"
fi
echo "== 编译注入器"
# 编译成通用二进制:3.8.99 的编辑器是 x86_64 的 Spine_x86(Rosetta),
# 只带 arm64 的 dylib 被继承到那个进程会直接 dyld 报错。
clang -dynamiclib -O2 -Wall -Wextra -Wno-unused-parameter \
-arch arm64 -arch x86_64 \
-I"$JAVA_HOME/include" -I"$JAVA_HOME/include/darwin" \
-o build/inject.dylib \
native/inject.c
echo "== 产物"
ls -la build/inject.dylib
find build/classes -name '*.class' | sed 's/^/ /'
@@ -0,0 +1,41 @@
package com.esotericsoftware.spine.editor.launcher;
/**
* 3.8.99 编辑器会反射去读启动器类 com.esotericsoftware.spine.editor.launcher.Launcher
* 上的若干静态成员(clsid / clr / dat / info / dll)。
*
* 官方那个类在 4.3.06 的 launcher.jar 里,而且是 Java 10 编译的(class 54),
* JDK8 加载直接 UnsupportedClassVersionError,会把 Prefs/sZ 的初始化打断、编辑器起不来。
*
* 所以这里放一个 Java 8 的替身:字段和同名静态方法都提供,反射用哪种都能读到。
*/
public final class Launcher {
public static String clsid = "pro";
public static String clr = "false";
public static String dll = "spine$12345";
public static String dat = "";
public static Object info = null;
private Launcher() {
}
public static String clsid() {
return clsid;
}
public static String clr() {
return clr;
}
public static String dll() {
return dll;
}
public static String dat() {
return dat;
}
public static Object info() {
return info;
}
}
+481
View File
@@ -0,0 +1,481 @@
/* ViewPanel —— 最小注入器
*
* 依据(REA 对 Contents/MacOS/Spine 的实测):
* · 该二进制导入 _dlopen/_dlsym/_dlerror,__la_symbol_ptr 里有 _dlsym 槽位,
* 且没有直接链接 _JNI_CreateJavaVM —— JNI 入口是运行期 dlopen+dlsym 拿的,
* 所以重绑定 _dlsym 是有效钩子点。
* · ad-hoc 签名、无 hardened runtime、无 entitlements —— DYLD_INSERT_LIBRARIES 可用。
*
* 本文件只做三件事:
* 1) 重绑定 _dlsym(含后加载的 libjvm)
* 2) 拦 JNI_CreateJavaVM
* 3) JVM 起来后,把我们的类 DefineClass 进编辑器自己的类加载器,再调用它的 main
*
* 这里没有任何 UI 代码:面板是纯 Java / Scene2D 实现的。
*/
#include <dirent.h>
#include <dlfcn.h>
#include <jni.h>
#include <mach-o/dyld.h>
#include <mach-o/loader.h>
#include <mach-o/nlist.h>
#include <pthread.h>
#include <stdarg.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>
#define VP_LOG "/tmp/spine-viewpanel.log"
#define VP_DEFAULT_CLASSES "/tmp/spine-viewpanel/classes"
#define VP_DEFAULT_MAIN "com/spineview/SceneScan"
static void vp_log(const char *format, ...) {
FILE *file = fopen(VP_LOG, "a");
if (!file) return;
fprintf(file, "[pid %d] ", (int)getpid());
va_list arguments;
va_start(arguments, format);
vfprintf(file, format, arguments);
va_end(arguments);
fputc('\n', file);
fclose(file);
}
typedef void *(*dlsym_fn)(void *, const char *);
typedef jint (*create_vm_fn)(JavaVM **, void **, void *);
static dlsym_fn real_dlsym = NULL;
static create_vm_fn real_create_vm = NULL;
static JavaVM *g_vm = NULL;
static jobject g_editor_loader = NULL; /* 编辑器自己的 ClassLoader 实例 */
static jclass g_panel_class = NULL; /* com.spineview.SceneScan 的全局引用 */
static jmethodID g_on_render = NULL; /* SceneScan.onRenderThread() */
static char g_classes_dir[4096];
static char g_main_class[512];
static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER;
typedef void (*swap_fn)(JNIEnv *, jclass, jlong);
static swap_fn real_swap = NULL;
static int g_swap_calls = 0;
/* 编辑器用它自己的类加载器加载了这些保名的 libGDX 控件类(REA 运行时证据),
* 拿它们的 Class 对象就能反推出编辑器类加载器。 */
static const char *kEditorAnchorClasses[] = {
"com/badlogic/gdx/scenes/scene2d/ui/TextButton",
"com/badlogic/gdx/scenes/scene2d/ui/Label",
"com/badlogic/gdx/scenes/scene2d/ui/ImageTextButton",
"com/badlogic/gdx/scenes/scene2d/ui/Window",
"com/badlogic/jglfw/Glfw",
};
/* ---------- 读取我们的 .class ---------- */
static jclass vp_define_file(JNIEnv *env, jobject loader, const char *path) {
FILE *file = fopen(path, "rb");
if (!file) return NULL;
fseek(file, 0, SEEK_END);
long size = ftell(file);
fseek(file, 0, SEEK_SET);
if (size <= 0) { fclose(file); return NULL; }
unsigned char *buffer = malloc((size_t)size);
if (!buffer) { fclose(file); return NULL; }
size_t read = fread(buffer, 1, (size_t)size, file);
fclose(file);
if (read != (size_t)size) { free(buffer); return NULL; }
jclass defined = (*env)->DefineClass(env, NULL, loader, (const jbyte *)buffer, (jsize)size);
free(buffer);
if ((*env)->ExceptionCheck(env)) {
vp_log("DefineClass 抛出异常: %s", path);
(*env)->ExceptionDescribe(env);
(*env)->ExceptionClear(env);
return NULL;
}
return defined;
}
static jclass vp_define_directory(JNIEnv *env, jobject loader, const char *root,
const char *relative, const char *main_internal) {
jclass found = NULL;
char path[4096];
if (relative[0]) snprintf(path, sizeof(path), "%s/%s", root, relative);
else snprintf(path, sizeof(path), "%s", root);
DIR *directory = opendir(path);
if (!directory) return NULL;
struct dirent *entry;
while ((entry = readdir(directory)) != NULL) {
if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) continue;
char child_relative[4096];
if (relative[0]) snprintf(child_relative, sizeof(child_relative), "%s/%s", relative, entry->d_name);
else snprintf(child_relative, sizeof(child_relative), "%s", entry->d_name);
char child_path[4096];
snprintf(child_path, sizeof(child_path), "%s/%s", root, child_relative);
struct stat info;
if (stat(child_path, &info) != 0) continue;
if (S_ISDIR(info.st_mode)) {
jclass nested = vp_define_directory(env, loader, root, child_relative, main_internal);
if (nested) found = nested;
continue;
}
size_t length = strlen(child_relative);
if (length <= 6 || strcmp(child_relative + length - 6, ".class") != 0) continue;
char internal_name[4096];
snprintf(internal_name, sizeof(internal_name), "%s", child_relative);
internal_name[length - 6] = '\0';
jclass defined = vp_define_file(env, loader, child_path);
vp_log("define %s -> %s", internal_name, defined ? "ok" : "failed");
if (defined && main_internal && strcmp(internal_name, main_internal) == 0) found = defined;
}
closedir(directory);
return found;
}
/* ---------- 在编辑器自己的类加载器里加载并执行我们的类 ---------- */
/* 把我们的类定义进调用方传入的加载器(也就是编辑器自己的那个),返回入口类。
* 目的是让我们的代码和编辑器处在同一个类命名空间里,能直接按名字找它的类。 */
static jclass VPC_defineIntoLoader(JNIEnv *env, jclass clazz, jobject loader) {
(void)clazz;
if (!env || !loader) return NULL;
jclass defined = vp_define_directory(env, loader, g_classes_dir, "", g_main_class);
vp_log("defineIntoLoader: %s", defined ? "已定义" : "失败");
return defined;
}
static void *vp_run_thread(void *unused) {
(void)unused;
/* 等编辑器把保名的控件类加载进来(启动后几百毫秒到几秒)。 */
jobject loader = NULL;
for (int attempt = 0; attempt < 120 && !loader; attempt++) {
pthread_mutex_lock(&g_lock);
loader = g_editor_loader;
pthread_mutex_unlock(&g_lock);
if (!loader) usleep(200000);
}
JNIEnv *env = NULL;
if (!g_vm || (*g_vm)->AttachCurrentThread(g_vm, (void **)&env, NULL) != JNI_OK || !env) {
vp_log("run: AttachCurrentThread 失败");
return NULL;
}
if (!loader) {
/* 有些进程不触发 dlsym("Java_..."),抓不到编辑器加载器。
* 退一步用系统类加载器:我们的类定义进去照样能跑,只是看不到编辑器的私有类。 */
jclass class_loader_class = (*env)->FindClass(env, "java/lang/ClassLoader");
jmethodID get_system = class_loader_class
? (*env)->GetStaticMethodID(env, class_loader_class, "getSystemClassLoader",
"()Ljava/lang/ClassLoader;") : NULL;
if (get_system) loader = (*env)->CallStaticObjectMethod(env, class_loader_class, get_system);
if ((*env)->ExceptionCheck(env)) { (*env)->ExceptionDescribe(env); (*env)->ExceptionClear(env); loader = NULL; }
vp_log("run: 没抓到编辑器加载器,改用系统类加载器 -> %p", loader);
}
if (!loader) { vp_log("run: 拿不到任何类加载器,放弃"); return NULL; }
{
jclass object_class = (*env)->FindClass(env, "java/lang/Object");
jmethodID to_string = object_class
? (*env)->GetMethodID(env, object_class, "toString", "()Ljava/lang/String;") : NULL;
jstring text = to_string ? (jstring)(*env)->CallObjectMethod(env, loader, to_string) : NULL;
const char *chars = text ? (*env)->GetStringUTFChars(env, text, NULL) : NULL;
vp_log("run: 编辑器类加载器 = %s", chars ? chars : "(未知)");
if (chars) (*env)->ReleaseStringUTFChars(env, text, chars);
}
jclass main_class = vp_define_directory(env, loader, g_classes_dir, "", g_main_class);
if (!main_class) { vp_log("run: 没有定义到入口类 %s", g_main_class); return NULL; }
{
JNINativeMethod methods[] = {
{ "defineIntoLoader", "(Ljava/lang/ClassLoader;)Ljava/lang/Class;", (void *)VPC_defineIntoLoader },
};
if ((*env)->RegisterNatives(env, main_class, methods, 1) != JNI_OK) {
vp_log("run: 注册 defineIntoLoader 失败");
if ((*env)->ExceptionCheck(env)) { (*env)->ExceptionDescribe(env); (*env)->ExceptionClear(env); }
} else {
vp_log("run: 已注册 defineIntoLoader");
}
}
g_panel_class = (jclass)(*env)->NewGlobalRef(env, main_class);
g_on_render = (*env)->GetStaticMethodID(env, main_class, "onRenderThread", "()V");
if (!g_on_render) {
vp_log("run: 找不到 onRenderThread");
if ((*env)->ExceptionCheck(env)) { (*env)->ExceptionDescribe(env); (*env)->ExceptionClear(env); }
}
/* 把进程号写进去:日志里要能区分启动器进程和编辑器进程。 */
{
jfieldID pid_field = (*env)->GetStaticFieldID(env, main_class, "pid", "J");
if (pid_field) {
(*env)->SetStaticLongField(env, main_class, pid_field, (jlong)getpid());
vp_log("run: 已把 pid=%d 写进 Java", (int)getpid());
} else {
if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env);
vp_log("run: 没找到 pid 静态字段");
}
}
jclass string_class = (*env)->FindClass(env, "java/lang/String");
jobjectArray arguments = (*env)->NewObjectArray(env, 0, string_class, NULL);
jmethodID main_method = (*env)->GetStaticMethodID(env, main_class, "main", "([Ljava/lang/String;)V");
if (!main_method) {
vp_log("run: 找不到 main([Ljava/lang/String;)V");
if ((*env)->ExceptionCheck(env)) { (*env)->ExceptionDescribe(env); (*env)->ExceptionClear(env); }
return NULL;
}
vp_log("run: 调用 %s.main", g_main_class);
(*env)->CallStaticVoidMethod(env, main_class, main_method, arguments);
if ((*env)->ExceptionCheck(env)) {
vp_log("run: main 抛出异常");
(*env)->ExceptionDescribe(env);
(*env)->ExceptionClear(env);
}
return NULL;
}
static void vp_start_thread(void) {
pthread_t thread;
if (pthread_create(&thread, NULL, vp_run_thread, NULL) == 0) {
pthread_detach(thread);
vp_log("已启动注入线程");
} else {
vp_log("线程创建失败");
}
}
typedef jint (*get_created_vms_fn)(JavaVM **, jsize, jsize *);
/* 有些进程(例如 3.8.99 的 x86 编辑器)是从启动器进程继承 JVM 的,
* 根本不会调用 JNI_CreateJavaVM —— 钩子自然不会触发。
* 这里改用 JNI_GetCreatedJavaVMs 去取已经存在的 VM。 */
static void *vp_find_existing_vm_thread(void *unused) {
(void)unused;
for (int attempt = 0; attempt < 1200; attempt++) {
if (g_vm) return NULL;
get_created_vms_fn get_vms = real_dlsym
? (get_created_vms_fn)real_dlsym(RTLD_DEFAULT, "JNI_GetCreatedJavaVMs") : NULL;
if (get_vms) {
JavaVM *vm = NULL;
jsize count = 0;
if (get_vms(&vm, 1, &count) == JNI_OK && count > 0 && vm) {
g_vm = vm;
vp_log("用 JNI_GetCreatedJavaVMs 拿到已存在的 JVM,开始注入");
vp_start_thread();
return NULL;
}
}
usleep(500000);
}
return NULL;
}
static void vp_start_vm_watch(void) {
pthread_t thread;
if (pthread_create(&thread, NULL, vp_find_existing_vm_thread, NULL) == 0) pthread_detach(thread);
}
/* ---------- 抓编辑器类加载器 ---------- */
/* 编辑器在给自己绑定原生方法时会 dlsym("Java_...");此刻调用 FindClass,
* 正好落在编辑器自己的类加载上下文里,用它反推编辑器类加载器。只观察,不改返回值。 */
static void vp_try_capture(const char *symbol) {
pthread_mutex_lock(&g_lock);
int need = (g_editor_loader == NULL);
pthread_mutex_unlock(&g_lock);
if (!need || !g_vm) return;
JNIEnv *env = NULL;
if ((*g_vm)->GetEnv(g_vm, (void **)&env, JNI_VERSION_1_6) != JNI_OK || !env) return;
jclass class_class = (*env)->FindClass(env, "java/lang/Class");
jmethodID get_class_loader = class_class
? (*env)->GetMethodID(env, class_class, "getClassLoader", "()Ljava/lang/ClassLoader;") : NULL;
for (size_t index = 0; index < sizeof(kEditorAnchorClasses) / sizeof(kEditorAnchorClasses[0]); index++) {
jclass found = (*env)->FindClass(env, kEditorAnchorClasses[index]);
if (!found) { (*env)->ExceptionClear(env); continue; }
jobject loader = get_class_loader ? (*env)->CallObjectMethod(env, found, get_class_loader) : NULL;
if (!loader || (*env)->ExceptionCheck(env)) {
(*env)->ExceptionClear(env);
/* FindClass 落在 bootstrap/系统加载器上时 getClassLoader() 会是 null,
* 这时改用当前 Java 线程的上下文加载器(编辑器线程的上下文就是编辑器加载器)。 */
jclass thread_class = (*env)->FindClass(env, "java/lang/Thread");
jmethodID current = thread_class
? (*env)->GetStaticMethodID(env, thread_class, "currentThread", "()Ljava/lang/Thread;") : NULL;
jmethodID context = thread_class
? (*env)->GetMethodID(env, thread_class, "getContextClassLoader", "()Ljava/lang/ClassLoader;") : NULL;
if (current && context) {
jobject thread = (*env)->CallStaticObjectMethod(env, thread_class, current);
if (thread) loader = (*env)->CallObjectMethod(env, thread, context);
if ((*env)->ExceptionCheck(env)) { (*env)->ExceptionClear(env); loader = NULL; }
}
}
if (!loader) {
vp_log("锚点 %s 的加载器为空,继续试下一个", kEditorAnchorClasses[index]);
continue;
}
pthread_mutex_lock(&g_lock);
if (!g_editor_loader) {
g_editor_loader = (*env)->NewGlobalRef(env, loader);
char message[512];
snprintf(message, sizeof(message), "捕获编辑器类加载器(锚点 %s,触发符号 %s)",
kEditorAnchorClasses[index], symbol ? symbol : "(null)");
pthread_mutex_unlock(&g_lock);
vp_log("%s", message);
return;
}
pthread_mutex_unlock(&g_lock);
return;
}
}
/* ---------- JVM 创建拦截 ---------- */
static jint vp_create_vm(JavaVM **pvm, void **penv, void *args) {
vp_log("拦截到 JNI_CreateJavaVM");
jint result = real_create_vm ? real_create_vm(pvm, penv, args) : JNI_ERR;
if (result != JNI_OK || !pvm || !*pvm) {
vp_log("JNI_CreateJavaVM 失败: %d", (int)result);
return result;
}
g_vm = *pvm;
vp_log("JVM 已创建,等编辑器类加载器");
vp_start_thread();
return result;
}
/* ---------- _dlsym 重绑定 ---------- */
/* 渲染线程上的钩子:编辑器每帧调 glfwSwapBuffers 时,先问一下 Java 侧要不要装配面板。
* 只在前若干帧回调,之后彻底安静。 */
static void vp_swap(JNIEnv *env, jclass clazz, jlong window) {
const char *no_call = getenv("SPINE_VIEWPANEL_NO_JCALL");
if (g_panel_class && g_on_render && env && g_swap_calls < 20000
&& !(no_call && no_call[0] == '1')) {
g_swap_calls++;
(*env)->CallStaticVoidMethod(env, g_panel_class, g_on_render);
if ((*env)->ExceptionCheck(env)) {
(*env)->ExceptionDescribe(env);
(*env)->ExceptionClear(env);
}
}
if (real_swap) real_swap(env, clazz, window);
}
static void *vp_dlsym(void *handle, const char *name) {
if (name && strcmp(name, "JNI_CreateJavaVM") == 0) {
if (!real_create_vm && real_dlsym) real_create_vm = (create_vm_fn)real_dlsym(handle, name);
vp_log("dlsym: 返回注入版 JNI_CreateJavaVM");
return (void *)vp_create_vm;
}
/* 只截 JNI 方法,不碰 C 函数 glfwSwapBuffers。
* libjglfw 里两个符号都有:C 函数的签名是 (GLFWwindow*),JNI 方法是 (JNIEnv*,jclass,jlong)。
* 截错那个会让包装函数用错签名,直接把渲染搞坏(实测窗口全黑)。 */
if (name && strncmp(name, "Java_", 5) == 0 && strstr(name, "glfwSwapBuffers") != NULL) {
void *real = real_dlsym ? real_dlsym(handle, name) : NULL;
if (!real) {
vp_log("dlsym: %s 拿不到真实实现,不挂钩(否则会没有交换缓冲 → 黑屏)", name);
return NULL;
}
/* 实测:替换这个 JNI 方法会让编辑器整窗黑屏(启动器没事),所以默认不挂。
* 需要时用 SPINE_VIEWPANEL_SWAP=1 显式打开。 */
const char *enable = getenv("SPINE_VIEWPANEL_SWAP");
if (!enable || enable[0] != '1') return real;
if (getenv("SPINE_VIEWPANEL_NO_SWAP")) return real;
if (!real_swap) {
real_swap = (swap_fn)real;
void *via_default = real_dlsym(RTLD_DEFAULT, name);
void *via_next = real_dlsym(RTLD_NEXT, name);
vp_log("dlsym: 截住 %s 真实=%p RTLD_DEFAULT=%p RTLD_NEXT=%p",
name, real, via_default, via_next);
}
return (void *)vp_swap;
}
if (name && strncmp(name, "Java_", 5) == 0) vp_try_capture(name);
return real_dlsym ? real_dlsym(handle, name) : NULL;
}
static void vp_make_writable(void *address, size_t size) {
uintptr_t page = (uintptr_t)address & ~((uintptr_t)getpagesize() - 1);
uintptr_t end = ((uintptr_t)address + size + getpagesize() - 1) & ~((uintptr_t)getpagesize() - 1);
mprotect((void *)page, end - page, PROT_READ | PROT_WRITE);
}
static void vp_rebind(const struct mach_header *header, intptr_t slide) {
if (!header || header->magic != MH_MAGIC_64) return;
const uint8_t *image = (const uint8_t *)header;
const struct load_command *cmd = (const struct load_command *)(image + sizeof(struct mach_header_64));
const struct symtab_command *symtab = NULL;
const struct dysymtab_command *dysymtab = NULL;
const struct segment_command_64 *linkedit = NULL;
for (uint32_t index = 0; index < header->ncmds; index++) {
if (cmd->cmd == LC_SYMTAB) symtab = (const struct symtab_command *)cmd;
else if (cmd->cmd == LC_DYSYMTAB) dysymtab = (const struct dysymtab_command *)cmd;
else if (cmd->cmd == LC_SEGMENT_64) {
const struct segment_command_64 *segment = (const struct segment_command_64 *)cmd;
if (strcmp(segment->segname, "__LINKEDIT") == 0) linkedit = segment;
}
cmd = (const struct load_command *)((const uint8_t *)cmd + cmd->cmdsize);
}
if (!symtab || !dysymtab || !linkedit) return;
const uintptr_t linkedit_base = (uintptr_t)(linkedit->vmaddr - linkedit->fileoff + slide);
const struct nlist_64 *symbols = (const struct nlist_64 *)(linkedit_base + symtab->symoff);
const char *strings = (const char *)(linkedit_base + symtab->stroff);
const uint32_t *indirect = (const uint32_t *)(linkedit_base + dysymtab->indirectsymoff);
cmd = (const struct load_command *)(image + sizeof(struct mach_header_64));
for (uint32_t index = 0; index < header->ncmds; index++) {
if (cmd->cmd != LC_SEGMENT_64) {
cmd = (const struct load_command *)((const uint8_t *)cmd + cmd->cmdsize);
continue;
}
const struct segment_command_64 *segment = (const struct segment_command_64 *)cmd;
const struct section_64 *sections =
(const struct section_64 *)((const uint8_t *)segment + sizeof(struct segment_command_64));
for (uint32_t section_index = 0; section_index < segment->nsects; section_index++) {
const struct section_64 *section = &sections[section_index];
if (strcmp(section->sectname, "__la_symbol_ptr") != 0 &&
strcmp(section->sectname, "__got") != 0) continue;
if (section->reserved1 == 0) continue;
const size_t count = section->size / sizeof(void *);
for (size_t entry = 0; entry < count; entry++) {
uint32_t symbol_index = indirect[section->reserved1 + entry];
if (symbol_index >= symtab->nsyms) continue;
const char *name = strings + symbols[symbol_index].n_un.n_strx;
if (name && strcmp(name, "_dlsym") == 0) {
void **slot = (void **)((uintptr_t)section->addr + slide + entry * sizeof(void *));
vp_make_writable(slot, sizeof(void *));
*slot = vp_dlsym;
}
}
}
cmd = (const struct load_command *)((const uint8_t *)cmd + cmd->cmdsize);
}
}
static void vp_image_added(const struct mach_header *header, intptr_t slide) {
vp_rebind(header, slide);
}
__attribute__((constructor))
static void vp_install(void) {
real_dlsym = (dlsym_fn)dlsym(RTLD_DEFAULT, "dlsym");
const char *dir = getenv("SPINE_VIEWPANEL_CLASSES");
snprintf(g_classes_dir, sizeof(g_classes_dir), "%s",
(dir && dir[0]) ? dir : VP_DEFAULT_CLASSES);
const char *main_class = getenv("SPINE_VIEWPANEL_MAIN");
snprintf(g_main_class, sizeof(g_main_class), "%s",
(main_class && main_class[0]) ? main_class : VP_DEFAULT_MAIN);
vp_log("=== inject.dylib 已载入 pid=%d classes=%s main=%s ===",
(int)getpid(), g_classes_dir, g_main_class);
uint32_t count = _dyld_image_count();
for (uint32_t index = 0; index < count; index++) {
vp_rebind(_dyld_get_image_header(index), _dyld_get_image_vmaddr_slide(index));
}
_dyld_register_func_for_add_image(vp_image_added);
/* 万一这个进程不创建 JVM(继承来的),也要能注入。 */
vp_start_vm_watch();
}
+133
View File
@@ -0,0 +1,133 @@
/* 内存扫描:找进程里已经解密成明文的 class 字节。
*
* 为什么走这条路:实测 Frida 的内联挂钩(Interceptor.attach 到 JVM_DefineClass*)
* 会让这套运行时直接 SIGSEGV,attach 又太晚(类早加载完了)。
* 扫描内存不改任何代码,风险最低。
*/
#include <mach/mach.h>
#include <mach/vm_region.h>
#include <pthread.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#define SCAN_LOG "/tmp/spine-viewpanel.log"
#define SCAN_DIR "/tmp/spine-classes/scan"
static void scan_log(const char *message) {
FILE *file = fopen(SCAN_LOG, "a");
if (file) { fprintf(file, "%s\n", message); fclose(file); }
}
/* 用常量池长度算出一个 class 文件到底多少字节;不是合法 class 就返回 -1。 */
static int class_file_length(const unsigned char *p, size_t avail) {
if (avail < 10) return -1;
if (!(p[0] == 0xCA && p[1] == 0xFE && p[2] == 0xBA && p[3] == 0xBE)) return -1;
uint16_t cp_count = (uint16_t)((p[8] << 8) | p[9]);
size_t i = 10;
for (uint16_t index = 1; index < cp_count; index++) {
if (i >= avail) return -1;
uint8_t tag = p[i++];
switch (tag) {
case 1: {
if (i + 2 > avail) return -1;
uint16_t len = (uint16_t)((p[i] << 8) | p[i + 1]);
i += 2 + len;
break;
}
case 3: case 4: i += 4; break;
case 5: case 6: i += 8; index++; break;
case 7: case 8: case 16: case 19: case 20: i += 2; break;
case 9: case 10: case 11: case 12: case 17: case 18: i += 4; break;
case 15: i += 3; break;
default: return -1;
}
if (i > avail) return -1;
}
if (i + 8 > avail) return -1;
uint16_t interfaces = (uint16_t)((p[i + 6] << 8) | p[i + 7]);
i += 8 + (size_t)interfaces * 2;
for (int section = 0; section < 3; section++) {
if (i + 2 > avail) return -1;
uint16_t count = (uint16_t)((p[i] << 8) | p[i + 1]);
i += 2;
for (uint16_t k = 0; k < count; k++) {
if (i + 8 > avail) return -1;
uint16_t attributes = (uint16_t)((p[i + 6] << 8) | p[i + 7]);
i += 8;
for (uint16_t a = 0; a < attributes; a++) {
if (i + 6 > avail) return -1;
uint32_t len = ((uint32_t)p[i + 2] << 24) | ((uint32_t)p[i + 3] << 16)
| ((uint32_t)p[i + 4] << 8) | p[i + 5];
i += 6 + len;
if (i > avail) return -1;
}
}
}
return (int)i;
}
static void scan_memory(void) {
mkdir("/tmp/spine-classes", 0755);
mkdir(SCAN_DIR, 0755);
int dumped = 0;
uint64_t scanned = 0;
vm_address_t address = 0;
while (dumped < 6000) {
vm_size_t size = 0;
vm_region_basic_info_data_64_t info;
mach_msg_type_number_t count = VM_REGION_BASIC_INFO_COUNT_64;
mach_port_t object = MACH_PORT_NULL;
kern_return_t kr = vm_region_64(mach_task_self(), &address, &size, VM_REGION_BASIC_INFO_64,
(vm_region_info_t)&info, &count, &object);
if (kr != KERN_SUCCESS) break;
int readable = (info.protection & VM_PROT_READ) != 0;
/* 只扫进程自己分配的区域,跳过文件映射和共享缓存,减少误报与耗时。 */
int is_shared = info.shared;
if (readable && !is_shared && size >= 4096 && size <= (vm_size_t)256 * 1024 * 1024) {
const unsigned char *base = (const unsigned char *)address;
const unsigned char *end = base + size;
scanned += size;
for (const unsigned char *p = base; p + 4 < end; p++) {
if (p[0] != 0xCA || p[1] != 0xFE || p[2] != 0xBA || p[3] != 0xBE) continue;
int length = class_file_length(p, (size_t)(end - p));
if (length < 100 || length > 4 * 1024 * 1024) continue;
char path[256];
snprintf(path, sizeof(path), SCAN_DIR "/mem_%05d.class", dumped);
FILE *file = fopen(path, "wb");
if (file) {
fwrite(p, 1, (size_t)length, file);
fclose(file);
dumped++;
}
if (dumped >= 6000) break;
}
}
address += size;
}
char message[256];
snprintf(message, sizeof(message), "内存扫描:扫了 %.0f MB,dump 出 %d 个 class",
(double)scanned / 1048576.0, dumped);
scan_log(message);
}
static void *scan_thread(void *unused) {
(void)unused;
sleep(20);
scan_memory();
return NULL;
}
__attribute__((constructor))
static void scan_install(void) {
const char *enable = getenv("SPINE_VIEWPANEL_DUMP");
if (!enable || enable[0] != '1') return;
scan_log("=== memscan.dylib 已载入,20 秒后开始扫描 ===");
pthread_t thread;
if (pthread_create(&thread, NULL, scan_thread, NULL) == 0) pthread_detach(thread);
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 136 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 623 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

+30
View File
@@ -0,0 +1,30 @@
-----BEGIN RSA PRIVATE KEY-----
Proc-Type: 4,ENCRYPTED
DEK-Info: AES-128-CBC,800E9517EC57E30629C255E77732F416
UYC1bkuQiSC8cAajmmgwowyRzkUf39oZ2/EfVG1eHQkHxGwz4TY9m2PrioSNeILD
z7fj6uDdo3YHgUfGw4mxoLV4vdUlDyTg8thorPP7K0gr4xCzCxb8gkcyd3Wtyqon
jbDUqM6/fcG6OJn+44ZPFM46y6rnIzM8aGbAsS+pXNsb351I1d61Ky02ReIZQxNc
L1rx083W91mzybDDn32FN9r1YP+jIKq15XT1MA1+P1ayMiPY0963FLU6STs3gyiM
mqWq+PQ3aqjUXEFbNZVNNWIPD7fizuxSfWNdGY+9oz9zvDlrapYMLXNZEkB7r5LG
si08C8OVpo96lkX4NfTQP02B0vzDtbiEAxdbKv0Y9kOkIN3yJDF8yTQO+XdYkqlC
PEd5bqix2t+kOVscAOdGM5eo6BKR8EZ744HIPOqToQGFDrLzZiUNE6CH1O8okKBa
ormcuKE4S7SrYpSi5SSt5/o6VVULDVNbRZ96H0lCErCLbzQXVF8CQTreUNpCT3oD
+ZBV/tMB3mfYERLDXCHibMoosCfEmAWgk35PSoEfFlxE9f26yt4dnUwobjEC5x81
rMQvzDApcZ2hP5pow6crDjz1Xf5/6Vi5N2ajU9MYmmINIts6va2JYwFbKABa4NP5
Hzo0cvBfg9rvok/bnlppOIoY7phMX6PSSjKozkrCe/6ds/KgTZPjImgxMsVfOja+
MRe9/iG1GjplEjmu1yXgjabnKmKTc0szEsaOyR+ibykn1/pkAMXWXCD+cDAKh8ZX
Xz4wbi1RHHbXg+zRY6v/TalJv2mSj0Sixx2FoXW1qSIX5ztRfiNa5qKGVYaibZmK
MiXhj6P4l+/vrDkzV61cwfJ/9VKhB/FwIHvsbhjkH9bshhr5h9U4DiE/X82VUbT5
Fnot1RFeRYyJBzATxQKg00uoj8JuJlLCpT3TUO4HAlfMquz37lURCoAhRW4YXZ96
xxJADt+ud+kXqGroTOMff5mFCEMS2J+FOHws+6LCVUA0XLQl71L/1BIxq2dPWrl4
835fsxeXu5fgDy8VRYEXbQQABmCSVBJbcBLZdyd1NuJ33XtjZNuU8O1KA8118HA0
fWNyOGXLTw2+jLe+cz9a4cPSH8m9pt9dS1KF9JNTgHOLnFW8HcuCUfklDJx5S1Q3
xa43kEBI2tbzeHV575hc5SRMyU8bZ8KFBwJauHLVUhdh1f+25nHndATzRdAAMtJN
OLuPSWbvx+NOU80N030qohKF9q8cIQPjayRDeA3old2JM3mSG4Bh6akYwjyICmqW
YB7sZUiinH/WPftQ4hGaNf+je0+BHQtKezRmP0EQaSbkUPH5UUVmKVLbG2QFF0Wz
Bf9mhjc/Pw+dD57fV3kfpslJq7ynECpdKjHfIfW0MqfiW3YO7I7LmfafxgqFGQ9n
no/Mvk6UIs3J2h9vxuakTbr6d/YRkmQri4zcVdLsWdTHEDXi3ngGSoVZGud6acYa
cDkEI6TAa2vvJNb1VK+i/RMHjhE5od55nxGo8FYgMoZ1BRPj2Z22MAGhDvyAw/Ap
28UdENl1xUVePd5bT1aAkMLr+Zi2dXCX9Nh1Gz5G486d0JpSMmfb63qm8nnaHYUN
-----END RSA PRIVATE KEY-----
Binary file not shown.

After

Width:  |  Height:  |  Size: 127 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 B

Loaded 100 of 113 files, more files were not shown because too many files have changed in this diff. Show more