From cc64ef31ea566e32a53cedeedff63a9c3224a169 Mon Sep 17 00:00:00 2001 From: FxRayHughes Date: Sat, 22 Aug 2026 12:05:31 +0800 Subject: [PATCH] fix(incision): repair target scanning and shared JVMTI Route loaded-class scans and native operations through the canonical owner, then chain every plugin transformer through the bootstrap bridge. --- .../izzel/incision/bridge/IncisionBridge.java | 74 +++++++++++++ .../module/incision/IncisionBootstrap.kt | 1 + .../module/incision/loader/JvmtiBackend.kt | 102 +++++++++++++++--- .../incision/runtime/CanonicalBridge.kt | 29 +++++ .../bridge/IncisionBridgeNativeRoutingTest.kt | 37 +++++++ .../loader/BackendInstallLifecycleTest.kt | 52 +++++++++ 6 files changed, 279 insertions(+), 16 deletions(-) create mode 100644 module/incision/src/test/kotlin/taboolib/module/incision/bridge/IncisionBridgeNativeRoutingTest.kt create mode 100644 module/incision/src/test/kotlin/taboolib/module/incision/loader/BackendInstallLifecycleTest.kt diff --git a/module/incision/src/main/java/io/izzel/incision/bridge/IncisionBridge.java b/module/incision/src/main/java/io/izzel/incision/bridge/IncisionBridge.java index c2e6eac0b..d244735a8 100644 --- a/module/incision/src/main/java/io/izzel/incision/bridge/IncisionBridge.java +++ b/module/incision/src/main/java/io/izzel/incision/bridge/IncisionBridge.java @@ -60,6 +60,14 @@ private IncisionBridge() {} private static final ConcurrentHashMap routeConflictWarnings = new ConcurrentHashMap(); + /** + * JVM 进程只能由一个隔离 ClassLoader 直接拥有已加载的 JVMTI DLL。 + * Bridge 保留该 owner,并把 native 回调广播给所有插件后端,避免后加载插件再次 System.load。 + */ + private static volatile Class nativeOwner; + private static final CopyOnWriteArrayList> nativeDelegates = new CopyOnWriteArrayList>(); + private static final ConcurrentHashMap, Method> nativeTransformCache = new ConcurrentHashMap, Method>(); + /** * Side-car body 的字段解析缓存。 * @@ -226,6 +234,72 @@ public static int localLeaseCount() { return localCache.size(); } + /** 注册插件后端;返回 JVM 当前是否已有可用 native owner。 */ + public static synchronized boolean registerNativeBackend(Class backendClass, boolean ownsNative) { + if (backendClass == null) return nativeOwner != null; + if (ownsNative && nativeOwner == null) nativeOwner = backendClass; + if (!nativeDelegates.contains(backendClass)) nativeDelegates.add(backendClass); + return nativeOwner != null; + } + + /** + * native ClassFileLoadHook 的 JVM 级聚合入口。每个 delegate 接收前一个插件产生的字节码, + * 因而两个插件对同一方法的织入会形成确定的先后链,而不是互相覆盖。 + */ + public static byte[] transformNative(ClassLoader loader, String name, byte[] bytes) { + byte[] current = bytes; + boolean changed = false; + for (Class backend : nativeDelegates) { + try { + Method method = nativeTransformCache.get(backend); + if (method == null) { + method = backend.getMethod("onSharedClassFileLoad", ClassLoader.class, String.class, byte[].class); + nativeTransformCache.put(backend, method); + } + byte[] output = (byte[]) method.invoke(null, loader, name, current); + if (output != null) { + current = output; + changed = true; + } + } catch (Throwable t) { + System.err.println("[Incision][Bridge] native transformer delegate failed: " + backend.getName() + " — " + t); + } + } + return changed ? current : null; + } + + /** 非 owner 插件通过这一入口复用唯一 native image。 */ + public static Object invokeNative(String operation, Object[] args) { + Class owner = nativeOwner; + if (owner == null) throw new IllegalStateException("Incision native owner unavailable"); + try { + Method method = owner.getMethod("sharedNativeInvoke", String.class, Object[].class); + return method.invoke(null, operation, args); + } catch (Throwable t) { + throw new IllegalStateException("Incision shared native invocation failed: " + operation, t); + } + } + + /** + * 插件卸载只移除自己的 delegate。最后一个 lease 才关闭 JVMTI;若 owner 先卸载, + * 其 Class 对象必须暂留到最后一个 lease 结束,否则其他插件无法继续调用 native image。 + */ + public static synchronized void unregisterNativeBackend(Class backendClass) { + if (backendClass == null) return; + nativeDelegates.remove(backendClass); + nativeTransformCache.remove(backendClass); + if (!nativeDelegates.isEmpty()) return; + Class owner = nativeOwner; + nativeOwner = null; + if (owner == null) return; + try { + owner.getMethod("sharedNativeInvoke", String.class, Object[].class) + .invoke(null, "dispose", new Object[0]); + } catch (Throwable t) { + System.err.println("[Incision][Bridge] native dispose failed: " + t); + } + } + // ----------------------------------------------------------------- private static Object findSystemHost() { diff --git a/module/incision/src/main/kotlin/taboolib/module/incision/IncisionBootstrap.kt b/module/incision/src/main/kotlin/taboolib/module/incision/IncisionBootstrap.kt index d991c064e..58815273d 100644 --- a/module/incision/src/main/kotlin/taboolib/module/incision/IncisionBootstrap.kt +++ b/module/incision/src/main/kotlin/taboolib/module/incision/IncisionBootstrap.kt @@ -215,6 +215,7 @@ object IncisionBootstrap { try { // 后续 target 注册、host 绑定与卸载必须复用同一个类句柄,不能再直接链接插件内同名 Bridge。 CanonicalBridge.bind(bridgeClass) + JvmtiBackend.bindCanonicalBridge() CanonicalBridge.registerDispatcher(TheatreDispatcher::class.java) Forensics.info("IncisionBridge dispatcher 已注册 (CL=${bridgeClass.classLoader ?: "bootstrap"})") } catch (t: Throwable) { diff --git a/module/incision/src/main/kotlin/taboolib/module/incision/loader/JvmtiBackend.kt b/module/incision/src/main/kotlin/taboolib/module/incision/loader/JvmtiBackend.kt index 82650a302..3010318c1 100644 --- a/module/incision/src/main/kotlin/taboolib/module/incision/loader/JvmtiBackend.kt +++ b/module/incision/src/main/kotlin/taboolib/module/incision/loader/JvmtiBackend.kt @@ -1,6 +1,7 @@ package taboolib.module.incision.loader import taboolib.module.incision.diagnostic.Forensics +import taboolib.module.incision.runtime.CanonicalBridge import java.io.File import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CopyOnWriteArrayList @@ -27,32 +28,50 @@ object JvmtiBackend : Backend { @Volatile private var loaded = false @Volatile private var available = false + @Volatile private var localNativeOwner = false + @Volatile private var sharedBound = false override val name: String = "JVMTI" override fun available(): Boolean { + if (sharedBound) return available if (!loaded) tryLoad() return available } + /** Bridge 注入完成后加入 JVM 级 native lease;后加载插件不得再次调用 System.load。 */ + fun bindCanonicalBridge() { + sharedBound = true + available = CanonicalBridge.registerNativeBackend(JvmtiBackend::class.java, localNativeOwner) + loaded = true + if (available && !localNativeOwner) { + Forensics.info("JvmtiBackend: 复用 JVM canonical native owner") + } + } + override fun addTransformer(className: String, transformer: (ByteArray) -> ByteArray?): Backend.BackendToken { val key = className.replace('.', '/') transformers.computeIfAbsent(key) { CopyOnWriteArrayList() }.add(transformer) return object : Backend.BackendToken { - override fun remove() { transformers[key]?.remove(transformer) } + override fun remove() { + transformers[key]?.remove(transformer) + if (transformers[key].isNullOrEmpty()) { + transformers.remove(key) + } + } } } override fun retransform(className: String): Boolean { if (!available()) return false val internalName = className.replace('.', '/') - val loadedCount = runCatching { nLoadedClassCount(internalName) }.getOrDefault(0) + val loadedCount = runCatching { nativeLoadedClassCount(internalName) }.getOrDefault(0) if (loadedCount <= 0) { Forensics.debug("JvmtiBackend.retransform: $className not loaded yet, will transform on load") return false } return try { - val transformed = nRetransformByName(internalName) + val transformed = nativeRetransformByName(internalName) val ok = transformed == loadedCount Forensics.debug("JvmtiBackend.retransform $className → $ok (loaded=$loadedCount transformed=$transformed)") ok @@ -65,6 +84,13 @@ object JvmtiBackend : Backend { /** Called from native ClassFileLoadHook for every (re)loaded class. */ @JvmStatic fun onClassFileLoad(loader: ClassLoader?, name: String, bytes: ByteArray): ByteArray? { + if (sharedBound) return CanonicalBridge.transformNative(loader, name, bytes) + return onSharedClassFileLoad(loader, name, bytes) + } + + /** 由 bootstrap Bridge 广播到当前插件的本地 transformer 链。 */ + @JvmStatic + fun onSharedClassFileLoad(loader: ClassLoader?, name: String, bytes: ByteArray): ByteArray? { // 防重入:如果当前线程正在 weave 中(触发了新类加载),跳过 transformer 避免无限递归 if (reentrantGuard.get()) return null val list = transformers[name] @@ -114,10 +140,43 @@ object JvmtiBackend : Backend { @JvmStatic external fun nStaticFieldSet(ownerClass: Class<*>, fieldName: String, fieldDesc: String, value: Any?) @JvmStatic external fun nInvokeMethod(obj: Any?, ownerClass: Class<*>, methodName: String, methodDesc: String, args: Array?): Any? + private fun nativeLoadedClassCount(internalName: String): Int = + (nativeInvoke("loadedClassCount", internalName) as? Number)?.toInt() ?: 0 + + private fun nativeRetransformByName(internalName: String): Int = + (nativeInvoke("retransformByName", internalName) as? Number)?.toInt() ?: -1 + + @Suppress("UNCHECKED_CAST") + private fun nativeInvoke(operation: String, vararg args: Any?): Any? = + if (sharedBound) CanonicalBridge.invokeNative(operation, *args) + else sharedNativeInvoke(operation, args.copyOf() as Array) + + /** + * canonical Bridge 通过这个固定反射入口调用唯一 native owner。 + * switch 明确限制可调用协议,避免把任意反射能力暴露给其他插件 ClassLoader。 + */ + @JvmStatic + fun sharedNativeInvoke(operation: String, args: Array): Any? = when (operation) { + "loadedClassCount" -> nLoadedClassCount(args[0] as String) + "retransformByName" -> nRetransformByName(args[0] as String) + "defineClass" -> nDefineClass(args[0] as ClassLoader?, args[1] as String, args[2] as ByteArray) + "cacheOriginal" -> nCacheOriginal(args[0] as String, args[1] as ByteArray) + "getCachedOriginal" -> nGetCachedOriginal(args[0] as String) + "extractClassBytes" -> nExtractClassBytes(args[0] as Class<*>) + "purgeCache" -> nPurgeCache(args[0] as String) + "fieldGet" -> nFieldGet(args[0]!!, args[1] as Class<*>, args[2] as String, args[3] as String) + "fieldSet" -> nFieldSet(args[0]!!, args[1] as Class<*>, args[2] as String, args[3] as String, args[4]) + "staticFieldGet" -> nStaticFieldGet(args[0] as Class<*>, args[1] as String, args[2] as String) + "staticFieldSet" -> nStaticFieldSet(args[0] as Class<*>, args[1] as String, args[2] as String, args[3]) + "invokeMethod" -> nInvokeMethod(args[0], args[1] as Class<*>, args[2] as String, args[3] as String, args[4] as Array?) + "dispose" -> nDispose() + else -> throw IllegalArgumentException("Unsupported shared native operation: $operation") + } + fun defineClassInClassLoader(loader: ClassLoader?, name: String, bytes: ByteArray): Class<*>? { if (!available()) return null return try { - val cls = nDefineClass(loader, name.replace('.', '/'), bytes) + val cls = nativeInvoke("defineClass", loader, name.replace('.', '/'), bytes) as? Class<*> if (cls != null) Forensics.debug("JvmtiBackend.defineClass: $name in ${loader ?: "bootstrap"}") cls } catch (t: Throwable) { @@ -126,22 +185,32 @@ object JvmtiBackend : Backend { } } - override fun isClassLoaded(className: String): Boolean = - available() && runCatching { nLoadedClassCount(className.replace('.', '/')) > 0 }.getOrDefault(false) + override fun isClassLoaded(className: String): Boolean { + if (!available()) return false + val internalName = className.replace('.', '/') + val count = runCatching { nativeLoadedClassCount(internalName) }.getOrDefault(0) + Forensics.debug("JvmtiBackend.isClassLoaded: $internalName count=$count") + return count > 0 + } /** 当前插件 lease 释放时停止 native 回调并清空所有插件类引用,避免 reload 持有已关闭 loader。 */ @Synchronized fun dispose() { transformers.clear() if (!available) return - runCatching { nDispose() }.onFailure { Forensics.warn("JvmtiBackend.dispose 失败: ${it.message}") } + if (sharedBound) { + CanonicalBridge.unregisterNativeBackend(JvmtiBackend::class.java) + } else { + runCatching { nDispose() }.onFailure { Forensics.warn("JvmtiBackend.dispose 失败: ${it.message}") } + } available = false + sharedBound = false } fun cacheOriginal(owner: String, bytes: ByteArray): Boolean { if (!available()) return false return try { - nCacheOriginal(cacheKey(owner), bytes) + nativeInvoke("cacheOriginal", cacheKey(owner), bytes) as? Boolean ?: false } catch (t: Throwable) { Forensics.warn("nCacheOriginal 失败: ${t.message}") false @@ -151,7 +220,7 @@ object JvmtiBackend : Backend { fun getCachedOriginal(owner: String): ByteArray? { if (!available()) return null return try { - nGetCachedOriginal(cacheKey(owner)) + nativeInvoke("getCachedOriginal", cacheKey(owner)) as? ByteArray } catch (t: Throwable) { Forensics.warn("nGetCachedOriginal 失败: ${t.message}") null @@ -161,7 +230,7 @@ object JvmtiBackend : Backend { fun extractClassBytes(target: Class<*>): ByteArray? { if (!available()) return null return try { - nExtractClassBytes(target) + nativeInvoke("extractClassBytes", target) as? ByteArray } catch (t: Throwable) { Forensics.warn("nExtractClassBytes 失败: ${t.message}") null @@ -171,7 +240,7 @@ object JvmtiBackend : Backend { fun purgeCache(owner: String) { if (!available()) return try { - nPurgeCache(cacheKey(owner)) + nativeInvoke("purgeCache", cacheKey(owner)) } catch (t: Throwable) { Forensics.warn("nPurgeCache 失败: ${t.message}") } @@ -190,7 +259,7 @@ object JvmtiBackend : Backend { fun fieldGet(obj: Any, ownerClass: Class<*>, fieldName: String, fieldDesc: String): Any? { if (!available()) return null return try { - nFieldGet(obj, ownerClass, fieldName, fieldDesc) + nativeInvoke("fieldGet", obj, ownerClass, fieldName, fieldDesc) } catch (t: Throwable) { Forensics.warn("nFieldGet 失败: ${ownerClass.name}.$fieldName — ${t.message}") null @@ -200,7 +269,7 @@ object JvmtiBackend : Backend { fun fieldSet(obj: Any, ownerClass: Class<*>, fieldName: String, fieldDesc: String, value: Any?) { if (!available()) return try { - nFieldSet(obj, ownerClass, fieldName, fieldDesc, value) + nativeInvoke("fieldSet", obj, ownerClass, fieldName, fieldDesc, value) } catch (t: Throwable) { Forensics.warn("nFieldSet 失败: ${ownerClass.name}.$fieldName — ${t.message}") } @@ -209,7 +278,7 @@ object JvmtiBackend : Backend { fun staticFieldGet(ownerClass: Class<*>, fieldName: String, fieldDesc: String): Any? { if (!available()) return null return try { - nStaticFieldGet(ownerClass, fieldName, fieldDesc) + nativeInvoke("staticFieldGet", ownerClass, fieldName, fieldDesc) } catch (t: Throwable) { Forensics.warn("nStaticFieldGet 失败: ${ownerClass.name}.$fieldName — ${t.message}") null @@ -219,7 +288,7 @@ object JvmtiBackend : Backend { fun staticFieldSet(ownerClass: Class<*>, fieldName: String, fieldDesc: String, value: Any?) { if (!available()) return try { - nStaticFieldSet(ownerClass, fieldName, fieldDesc, value) + nativeInvoke("staticFieldSet", ownerClass, fieldName, fieldDesc, value) } catch (t: Throwable) { Forensics.warn("nStaticFieldSet 失败: ${ownerClass.name}.$fieldName — ${t.message}") } @@ -228,7 +297,7 @@ object JvmtiBackend : Backend { fun invokeMethod(obj: Any?, ownerClass: Class<*>, methodName: String, methodDesc: String, args: Array?): Any? { if (!available()) return null return try { - nInvokeMethod(obj, ownerClass, methodName, methodDesc, args) + nativeInvoke("invokeMethod", obj, ownerClass, methodName, methodDesc, args) } catch (t: Throwable) { Forensics.warn("nInvokeMethod 失败: ${ownerClass.name}.$methodName — ${t.message}") null @@ -250,6 +319,7 @@ object JvmtiBackend : Backend { System.setProperty("incision.jvmti.class", JvmtiBackend::class.java.name) System.load(lib.absolutePath) available = nInit(JvmtiBackend::class.java) + localNativeOwner = available if (available) Forensics.info("JvmtiBackend: JVMTI native loaded from $lib") else Forensics.warn("JvmtiBackend: nInit returned false") available diff --git a/module/incision/src/main/kotlin/taboolib/module/incision/runtime/CanonicalBridge.kt b/module/incision/src/main/kotlin/taboolib/module/incision/runtime/CanonicalBridge.kt index 91f7cbc8e..d7c9259f0 100644 --- a/module/incision/src/main/kotlin/taboolib/module/incision/runtime/CanonicalBridge.kt +++ b/module/incision/src/main/kotlin/taboolib/module/incision/runtime/CanonicalBridge.kt @@ -26,6 +26,35 @@ internal object CanonicalBridge { invoke("registerLocalDispatcher", arrayOf>(Class::class.java), dispatcherClass) } + fun registerNativeBackend(backendClass: Class<*>, ownsNative: Boolean): Boolean = + invokeResult( + "registerNativeBackend", + arrayOf>(Class::class.java, Boolean::class.javaPrimitiveType!!), + backendClass, + ownsNative, + ) as? Boolean ?: false + + fun transformNative(loader: ClassLoader?, name: String, bytes: ByteArray): ByteArray? = + invokeResult( + "transformNative", + arrayOf>(ClassLoader::class.java, String::class.java, ByteArray::class.java), + loader, + name, + bytes, + ) as? ByteArray + + fun invokeNative(operation: String, vararg args: Any?): Any? = + invokeResult( + "invokeNative", + arrayOf>(String::class.java, Array::class.java), + operation, + args, + ) + + fun unregisterNativeBackend(backendClass: Class<*>) { + invoke("unregisterNativeBackend", arrayOf>(Class::class.java), backendClass) + } + fun unregisterDispatcher(classLoader: ClassLoader) { invoke("unregisterLocalDispatcher", arrayOf>(ClassLoader::class.java), classLoader) } diff --git a/module/incision/src/test/kotlin/taboolib/module/incision/bridge/IncisionBridgeNativeRoutingTest.kt b/module/incision/src/test/kotlin/taboolib/module/incision/bridge/IncisionBridgeNativeRoutingTest.kt new file mode 100644 index 000000000..86142ba97 --- /dev/null +++ b/module/incision/src/test/kotlin/taboolib/module/incision/bridge/IncisionBridgeNativeRoutingTest.kt @@ -0,0 +1,37 @@ +package taboolib.module.incision.bridge + +import io.izzel.incision.bridge.IncisionBridge +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +/** + * 验证多个隔离后端共享唯一 native owner 时,字节码必须按 delegate 注册顺序串联处理。 + * 后一个 transformer 接收前一个的输出,任何插件都不能覆盖或跳过另一个插件的织入。 + */ +class IncisionBridgeNativeRoutingTest { + + @Test + fun nativeDelegatesTransformSequentially() { + IncisionBridge.registerNativeBackend(FirstBackend::class.java, true) + IncisionBridge.registerNativeBackend(SecondBackend::class.java, false) + try { + val transformed = IncisionBridge.transformNative(null, "example/Target", byteArrayOf(1)) + assertArrayEquals(byteArrayOf(1, 2, 3), transformed) + assertEquals("loadedClassCount:example/Target", IncisionBridge.invokeNative("loadedClassCount", arrayOf("example/Target"))) + } finally { + IncisionBridge.unregisterNativeBackend(FirstBackend::class.java) + IncisionBridge.unregisterNativeBackend(SecondBackend::class.java) + } + } + + object FirstBackend { + @JvmStatic fun onSharedClassFileLoad(loader: ClassLoader?, name: String, bytes: ByteArray) = bytes + 2 + @JvmStatic fun sharedNativeInvoke(operation: String, args: Array): Any? = + if (operation == "dispose") null else "$operation:${args[0]}" + } + + object SecondBackend { + @JvmStatic fun onSharedClassFileLoad(loader: ClassLoader?, name: String, bytes: ByteArray) = bytes + 3 + } +} diff --git a/module/incision/src/test/kotlin/taboolib/module/incision/loader/BackendInstallLifecycleTest.kt b/module/incision/src/test/kotlin/taboolib/module/incision/loader/BackendInstallLifecycleTest.kt new file mode 100644 index 000000000..5a3af8ee2 --- /dev/null +++ b/module/incision/src/test/kotlin/taboolib/module/incision/loader/BackendInstallLifecycleTest.kt @@ -0,0 +1,52 @@ +package taboolib.module.incision.loader + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Test + +/** + * 固定 API v2 后端安装协议:先注册 transformer,再判断是否需要同步重转换。 + * 注册先于 loaded-check 可封闭竞态:类若在检查前后首次定义,ClassFileLoadHook 必然能看到 transformer。 + */ +class BackendInstallLifecycleTest { + + @Test + fun pendingLoadNotifiesOnlyAfterLoadedCheck() { + val backend = FakeBackend(loaded = false) + val installation = backend.install("example/Test") { bytes -> bytes.copyOf() } + + assertEquals(Backend.InstallStatus.PENDING_LOAD, installation.status) + assertEquals(0, backend.retransformCount) + assertNotNull(installation.token) + } + + @Test + fun loadedClassRetransformsSynchronouslyWithoutPendingNotification() { + val backend = FakeBackend(loaded = true) + val installation = backend.install("example/Test") { bytes -> bytes.copyOf() } + + assertEquals(Backend.InstallStatus.INSTALLED, installation.status) + assertEquals(1, backend.retransformCount) + } + + private class FakeBackend( + private val loaded: Boolean, + ) : Backend { + override val name = "fake" + var retransformCount = 0 + private var transformer: ((ByteArray) -> ByteArray?)? = null + + override fun available() = true + override fun addTransformer(className: String, transformer: (ByteArray) -> ByteArray?): Backend.BackendToken = + object : Backend.BackendToken { + init { this@FakeBackend.transformer = transformer } + override fun remove() { this@FakeBackend.transformer = null } + } + override fun isClassLoaded(className: String) = loaded + override fun retransform(className: String): Boolean { + retransformCount++ + transformer?.invoke(byteArrayOf(1)) + return true + } + } +}