Skip to content

ensureClassInitialized() no longer initializes on ART since 7983e815 — hooks on uninitialized classes install successfully but never dispatch #408

Description

@Amark19

Summary

Since 7983e815 (first released in v7.0.11), ensureClassInitialized() no longer initializes the class on ART. Hooks installed on a class that has not yet been initialized are accepted without error and then never dispatch — no exception, no warning, nothing in the console.

The change

 export function ensureClassInitialized (env, classRef) {
   const api = getApi();
   if (api.flavor !== 'art') {
     return;
   }

-  env.getFieldId(classRef, 'x', 'Z');
-  env.exceptionClear();
+  env.getClassName(classRef);
 }

android: Make ensureClassInitialized() less noisy
By performing an operation that never throws.

The intent was clearly to remove exception noise. But the noisy call was the only thing doing the work: GetFieldID for a field that does not exist reaches ClassLinker::EnsureInitialized, which is what actually initialized the class. GetClassName never does. The function still has the same name and is still called from class-factory.js immediately before ClassModel.build() on every Java.use() — it just no longer initializes anything.

Why it matters

entry_point_from_quick_compiled_code_ on an uninitialized class holds an ART trampoline rather than final code. The ArtMethod patch is applied over that placeholder with a raw pointer write and no verification, so it always reports success. The class is then initialized later, on first use, and the hook never fires.

The failure is completely silent: Java.use() succeeds, .implementation = succeeds, and the hook simply does nothing.

Affected versions

version contains 7983e815 hooks on uninitialized classes
v7.0.10 no work
v7.0.11, v7.0.12, v7.0.13, master (b38a5b64) yes silently dead

Only three code commits landed between v7.0.10 and v7.0.11 — 4a4970bc, 8487d3d5, 7983e815.

How we isolated it

Four builds differing by a single commit each, with frida-gadget held byte-identical at 17.17.0 across all of them. Only reverting 7983e815 flips the behaviour, and two control hooks in the same build and same process keep working either way:

class hooked method dispatches?
android.hardware.biometrics.BiometricPrompt authenticate no
android.hardware.camera2.CaptureRequest$Builder addTarget, build no
android.hardware.camera2.impl.CameraCaptureSessionImpl setRepeatingRequest no
android.hardware.biometrics.BiometricManager canAuthenticate yes
android.hardware.camera2.impl.CameraDeviceImpl createCaptureSession yes

The split matches class initialization state. oatdump on a failing device shows the dead class at SuperclassValidated and a working one at VisiblyInitialized in the same process. The working classes are ones the app touches early (so they are already initialized when we hook); the dead ones are first used later.

It is therefore device-dependent, since which framework classes are pre-initialized varies by vendor image. On Galaxy S21 the same app and same build passes on Android 11 and fails on Android 12.

A partially applied hook set can crash the app

Where several hooks cooperate, losing some of them is worse than losing all of them. We substitute camera surfaces across createCaptureSession + addTarget + build. On an affected device only createCaptureSession dispatches, so the session is configured with substituted surfaces while the request keeps the originals:

java.lang.IllegalArgumentException: CaptureRequest contains unconfigured Input/Output Surface!
  at android.hardware.camera2.CaptureRequest.convertSurfaceToStreamId
  at android.hardware.camera2.impl.CameraDeviceImpl.setRepeatingRequest

Minimal reproduction

The behaviour change should be observable without hooking anything, since ensureClassInitialized() runs on every Java.use(). Noting honestly that this standalone case is derived from the code path rather than executed — our own observations come from an embedded-gadget setup, so the smallest form we can vouch for is the dispatch failure below it:

public class Uninit {
    static { android.util.Log.i("REPRO", "CLINIT RAN"); }
    public static String foo() { return "real"; }
}

Uninit is never referenced during startup. Then:

Java.perform(() => { Java.use('com.example.repro.Uninit'); });
  • v7.0.10 — CLINIT RAN appears in logcat
  • v7.0.11+ — nothing

Adding .foo.implementation = ... on top shows the consequence: the hook installs and never fires on v7.0.11+, and fires again if the class is initialized first via Class.forName(name, true, loader).

What we have not verified

We have not confirmed how the patch stops taking effect. Two possibilities are consistent with everything we measured:

  1. initialization rewrites the entry point, discarding the patch; or
  2. the patch is never read, because dispatch on an uninitialized class resolves through a path that does not consult the patched ArtMethod.

Both are fixed by initializing before patching, but they imply different fixes upstream. Happy to instrument and report back if useful.

Suggested fix

Restore initialization without the exception noise, or handle the uninitialized case in the patching path so it fails loudly rather than silently. Either way, a hook that cannot take effect should not report success.

Happy to test any patch against the affected devices.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions