From 41746885ee52a92ffaf00ec468147a85680570c4 Mon Sep 17 00:00:00 2001 From: Wes Smith Date: Thu, 2 Jul 2026 16:01:35 -0600 Subject: [PATCH] Fix Java.perform crash on Android 17 when attaching to a live VM On Android 17 (API 37), the first Java.use() after attaching to an already-running ("live") VM can make Java.perform() appear to hang and then abort the target process. It only happens on attach; a `-f` spawn is unaffected. EnsurePluginLoaded -> Plugin::Load -> dlopen -> ArtPlugin_Initialize -> DeoptManager::FinishSetup(). On a live VM FinishSetup runs the heavy debuggable transition, ending in Instrumentation::UpdateEntrypointsForDebuggable(). That walks every loaded class installing debug stubs, and for each method resolves AOT code via ArtMethod::GetOatMethodQuickCode -> FindOatMethodFor -> OatFile::OatClass::GetOatMethodOffsets, which asserts CHECK_LT(method_index, num_methods_). For a class whose runtime method layout diverges from its AOT odex (for example pairip-protected apps, where the shipped base.odex is a stub and the real classes are loaded at runtime), the computed index runs past the stub OatClass and ART aborts. The abort holds the mutator lock exclusively and skips its all-thread dump, so it looks like a hang before the tombstone lands. A `-f` spawn dodges it because the VM is still in JVMTI_PHASE_ONLOAD, where FinishSetup takes a cheap early-return branch. The bridge only needs the jvmtiEnv for GetLoadedClasses and object tagging, neither of which needs the debuggable transition (method hooking uses the bridge's own ArtMethodMangler). So on API >= 37 we no-op DeoptManager::FinishSetup for the duration of the plugin load: hook dlsym/__loader_dlsym, and when ART resolves ArtPlugin_Initialize, locate the just-mapped module and Interceptor.replace its FinishSetup export with a no-op before init() runs. The cheap DeoptManager::Setup() still runs. Resolved by symbol name with no hardcoded offsets, and undone once EnsurePluginLoaded returns. Verified on a Pixel 10 Pro XL (Android 17, SDK 37, arm64): JVMTI loads on attach, Java.perform completes across repeated fresh launches, GetLoadedClasses enumerates via JVMTI, and .implementation hooks dispatch. On a normal app the transition completes on its own in about 30 ms, so the workaround is a no-op there; on a pairip-protected app it aborts without the workaround and succeeds with it. Spawn path unchanged. --- lib/android.js | 116 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 115 insertions(+), 1 deletion(-) diff --git a/lib/android.js b/lib/android.js index d023166..5c28f99 100644 --- a/lib/android.js +++ b/lib/android.js @@ -556,7 +556,35 @@ function tryGetEnvJvmti (vm, runtime) { 'bool', ['pointer', 'pointer', 'pointer']); const errorPtr = Memory.alloc(pointerSize); - const success = ensurePluginLoaded(runtime, Memory.allocUtf8String('libopenjdkjvmti.so'), errorPtr); + // Android 17 (API >= 37): loading libopenjdkjvmti.so into an already-running + // ("live") VM makes the first Java.use() appear to hang and then aborts the + // target. EnsurePluginLoaded -> Plugin::Load -> dlopen -> ArtPlugin_Initialize + // -> DeoptManager::FinishSetup(); on a LIVE VM that runs the heavy debuggable + // transition, ending in Instrumentation::UpdateEntrypointsForDebuggable(). Its + // class walk (InstallStubsForClass -> GetOptimizedCodeFor -> + // ArtMethod::GetOatMethodQuickCode -> FindOatMethodFor -> + // OatFile::OatClass::GetOatMethodOffsets) hits a fatal + // CHECK_LT(method_index, num_methods_) on any class whose runtime method layout + // diverges from its AOT odex (e.g. pairip-protected apps, where the shipped + // base.odex is a stub and the real classes are loaded at runtime). ART's abort + // holds the mutator lock exclusively, so it presents as a hang before the + // process dies. A -f spawn dodges it: the VM is still in JVMTI_PHASE_ONLOAD, + // where FinishSetup takes a cheap early-return branch. The bridge only needs the + // jvmtiEnv for GetLoadedClasses + object tagging, neither of which needs the + // debuggable transition (method hooking uses the bridge's own ArtMethodMangler), + // so on 37+ we no-op FinishSetup for the duration of the load, then undo it. See + // installJvmtiFinishSetupWorkaround below. + const undoJvmtiLoadWorkaround = (getAndroidApiLevel() >= 37) + ? installJvmtiFinishSetupWorkaround() + : null; + let success; + try { + success = ensurePluginLoaded(runtime, Memory.allocUtf8String('libopenjdkjvmti.so'), errorPtr); + } finally { + if (undoJvmtiLoadWorkaround !== null) { + undoJvmtiLoadWorkaround(); + } + } if (!success) { // FIXME: Avoid leaking error return; @@ -580,6 +608,92 @@ function tryGetEnvJvmti (vm, runtime) { return env; } +// Installs the FinishSetup workaround and returns an undo() that removes it. Scoped +// to the plugin load: libopenjdkjvmti.so is loaded by art::Plugin::Load via +// dlopen -> dlsym("ArtPlugin_Initialize") -> init(). We can't hook +// ArtPlugin_Initialize before it exists, so we hook dlsym: when ART looks up +// ArtPlugin_Initialize the module is mapped, and dlsym's return value is that +// export's address. From it we locate the module and replace +// DeoptManager::FinishSetup() with a no-op before init() runs, so +// ArtPlugin_Initialize's cheap DeoptManager::Setup() still runs but the aborting +// FinishSetup() transition is skipped. Once the load has returned, undo() detaches +// the dlsym hooks and reverts FinishSetup, leaving nothing installed. +function installJvmtiFinishSetupWorkaround () { + const ARTPLUGIN_INIT = 'ArtPlugin_Initialize'; + const FINISH_SETUP = '_ZN12openjdkjvmti12DeoptManager11FinishSetupEv'; + + // Kept referenced for the lifetime of the replacement (until undo reverts it). + const noop = new NativeCallback(() => {}, 'void', ['pointer']); + const listeners = []; + let finishSetupAddr = null; + + const neuterFinishSetup = (pluginInitAddr) => { + if (finishSetupAddr !== null) { + return; + } + let mod = null; + try { mod = Process.findModuleByAddress(pluginInitAddr); } catch (e) {} + if (mod === null) { + try { mod = Process.getModuleByName('libopenjdkjvmti.so'); } catch (e) {} + } + if (mod === null) { + return; + } + let addr = null; + try { addr = mod.findExportByName(FINISH_SETUP); } catch (e) {} + if (addr === null || addr === undefined) { + return; + } + Interceptor.replace(addr, noop); + Interceptor.flush(); + finishSetupAddr = addr; + }; + + const hookDlsym = (name) => { + let addr = null; + for (const lib of ['libdl.so', 'libc.so']) { + try { addr = Process.getModuleByName(lib).findExportByName(name); } catch (e) {} + if (addr !== null && addr !== undefined) { + break; + } + } + if (addr === null || addr === undefined) { + try { addr = Module.getGlobalExportByName(name); } catch (e) {} + } + if (addr === null || addr === undefined) { + return; + } + listeners.push(Interceptor.attach(addr, { + onEnter (args) { + this.wanted = false; + try { + this.wanted = args[1].readCString() === ARTPLUGIN_INIT; + } catch (e) {} + }, + onLeave (retval) { + if (this.wanted && !retval.isNull()) { + neuterFinishSetup(retval); + } + } + })); + }; + + hookDlsym('dlsym'); + hookDlsym('__loader_dlsym'); + Interceptor.flush(); + + return function undo () { + for (const listener of listeners) { + listener.detach(); + } + if (finishSetupAddr !== null) { + Interceptor.revert(finishSetupAddr); + finishSetupAddr = null; + } + Interceptor.flush(); + }; +} + export function ensureClassInitialized (env, classRef) { const api = getApi(); if (api.flavor !== 'art') {