Skip to content

Fix Java.perform crash on Android 17 when attaching to a live VM#400

Open
Wes765 wants to merge 1 commit into
frida:mainfrom
Wes765:fix/android-17-jvmti-live-attach-deadlock
Open

Fix Java.perform crash on Android 17 when attaching to a live VM#400
Wes765 wants to merge 1 commit into
frida:mainfrom
Wes765:fix/android-17-jvmti-live-attach-deadlock

Conversation

@Wes765

@Wes765 Wes765 commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

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 reproduces on attach; a -f spawn is unaffected.

Root cause

tryGetEnvJvmti() loads the JVMTI plugin via
Runtime::EnsurePluginLoaded("libopenjdkjvmti.so"):

EnsurePluginLoaded -> Plugin::Load -> dlopen -> ArtPlugin_Initialize -> DeoptManager::FinishSetup()

On a live VM, DeoptManager::FinishSetup() runs the heavy debuggable
transition (WaitForBackgroundVerificationTasksToFinish, ScopedJitSuspend, a
full ThreadList::SuspendAll, InvalidateAllCompiledCode,
UpdateEntrypointsForDebuggable). None of those blocks; the branch runs to the
end and then aborts inside Instrumentation::UpdateEntrypointsForDebuggable().
That walks every loaded class installing debug stubs, and for each method
resolves AOT code:

UpdateEntrypointsForDebuggable -> InstallStubsForClass -> GetOptimizedCodeFor
  -> ArtMethod::GetOatMethodQuickCode -> FindOatMethodFor
  -> OatFile::OatClass::GetOatMethodOffsets  // CHECK_LT(method_index, num_methods_)

For a class whose runtime method layout diverges from its AOT odex, the computed
method index runs past the class's OatClass and the CHECK aborts the process.
This is common on pairip-protected apps, where the shipped base.odex is a
stub and the real classes are decrypted and loaded at runtime, so a class ART
sees with 17+ methods may have only 5 recorded in the odex. ART's abort holds the
mutator lock exclusively and skips its all-thread dump, so from the client side
it looks like a hang right before the tombstone.

A -f spawn dodges all of this because the VM is still in JVMTI_PHASE_ONLOAD
when the plugin loads, where FinishSetup() takes a cheap early-return branch.
That ONLOAD vs LIVE split is the only spawn-vs-attach divergence in the load
path. On an ordinary app the LIVE transition completes on its own (about 30 ms
here) and the process is fine; the failure only surfaces on apps with the
odex/runtime divergence.

Fix

The bridge only uses the jvmtiEnv for GetLoadedClasses and object tagging;
neither needs the debuggable transition (method hooking uses the bridge's own
ArtMethodMangler, not JVMTI deopt). So on API >= 37 we no-op
DeoptManager::FinishSetup for the duration of the plugin load, then restore it:

  • Hook dlsym/__loader_dlsym. When ART resolves ArtPlugin_Initialize, its
    return value locates the just-mapped libopenjdkjvmti.so; resolve
    DeoptManager::FinishSetup by exported symbol name and Interceptor.replace
    it with a no-op before init() runs. The cheap DeoptManager::Setup()
    still runs.
  • Once EnsurePluginLoaded returns, undo() reverts the replacement and
    detaches the dlsym hooks, so nothing is left installed.

Resolved by symbol name, no hardcoded offsets. Gated to API >= 37; older ART is
unchanged. On apps where the transition would have completed anyway this is a
harmless no-op (the transition is not needed by the bridge); on apps where it
would have aborted, it keeps the target alive.

Verification

Pixel 10 Pro XL, Android 17 (SDK 37, arm64):

  • Attach to a pairip-protected app across repeated fresh launches: without the
    workaround the target aborts in UpdateEntrypointsForDebuggable
    (CHECK failed: method_index < num_methods_); with it, Java.perform
    completes, GetLoadedClasses enumerates ~31k classes via JVMTI,
    .implementation hooks install and dispatch, and the target stays alive.
  • Attach to an ordinary app (Settings): the unpatched transition completes on
    its own; behavior is unchanged with the workaround.
  • -f spawn (Settings and the same apps): unchanged.

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.
@Wes765 Wes765 force-pushed the fix/android-17-jvmti-live-attach-deadlock branch from 6785206 to 4174688 Compare July 2, 2026 23:23
@Wes765 Wes765 changed the title Fix Java.perform hang on Android 17 when attaching to a live VM Fix Java.perform crash on Android 17 when attaching to a live VM Jul 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant