Skip to content

added support for hooking class initializers - #403

Open
mnalmahmud wants to merge 1 commit into
frida:mainfrom
mnalmahmud:clinit-hooking
Open

added support for hooking class initializers#403
mnalmahmud wants to merge 1 commit into
frida:mainfrom
mnalmahmud:clinit-hooking

Conversation

@mnalmahmud

@mnalmahmud mnalmahmud commented Aug 7, 2026

Copy link
Copy Markdown

Added support for hooking class initializers

Expose <clinit> as $clinit on class wrappers, alongside $init and $new, so a
class' static initialization can be intercepted, inspected or skipped:

const Foo = Java.use('com.example.Foo');
Foo.$clinit.implementation = function () {
    this.$clinit();
};

As a class is only ever initialized once, the hook must be installed before anything
touches the class. Resolving <clinit> through JNI would defeat that:
GetStaticMethodID() goes through FindMethodJNI(), which begins by calling
EnsureInitialized(), and would therefore run the very initializer we are about to
hook. Java reflection is no help either, as <clinit> is exposed by neither
getDeclaredMethods() nor getDeclaredConstructors(), which is how $init and $new
resolve their method IDs. The ID is therefore taken from ClassModel, which enumerates
methods without going through method resolution.

Unlike $init, $clinit is a plain Method rather than a MethodDispatcher, as a
class has at most one class initializer and its signature is always ()V, leaving
nothing to dispatch on.

Class model

model_new()'s three branches disagreed on constructors. The JVMTI branch added every
method GetClassMethods() reports, so <init> and <clinit> became members under
their raw names, reachable as wrapper['<init>']; the ART branch dropped everything
carrying kAccConstructor; the reflection fallback uses getDeclaredMethods(), which
never reports constructors at all. All three now agree that constructors are not
members. Instance ones are dropped, as $new and $init resolve them independently
and need the signatures only reflection provides.

The class initializer is kept in Model::class_initializer rather than in the members
hash table, and read back through the new model_find_class_initializer(). Putting it
in the table would surface it in model_list(), which backs the wrapper Proxy's
ownKeys() trap, and as getOwnPropertyDescriptor() reports every member as
enumerable, Object.entries() on a wrapper would resolve it through
makeMethodFromSpec(). That calls ToReflectedMethod(), which yields a Constructor
for <clinit>, and would then invoke java.lang.reflect.Method methods on it.

The ART branch identifies <clinit> as IsConstructor() && IsStatic(), matching
ArtMethod::IsClassInitializer(), and names it without consulting ART for the same
reason. Java.enumerateMethods() now reports $clinit instead of skipping it,
mirroring how <init> is reported as $init.

https://android.googlesource.com/platform/art/+/refs/heads/main/runtime/art_method.h

Class initialization

Java.use() no longer initializes the class; that now happens when a static field is
first read or an implementation is installed. ART rewrites the quick entrypoint of
every static method of a class as that class becomes initialized, via
ClassLinker::FixupStaticTrampolines(), and ArtMethodMangler.replace() snapshots
quickCode when the hook is installed, so hooking a static method of a
not-yet-initialized class would leave the mangler holding an entrypoint ART replaces
moments later, silently bypassing the hook. Initializing at hook time preserves that
ordering while leaving classes that are merely wrapped alone.
CLASS_INITIALIZER_METHOD is exempt, as forcing initialization is precisely what
hooking it is meant to intercept.

https://android.googlesource.com/platform/art/+/refs/heads/main/runtime/class_linker.cc

ensureClassInitialized() had not initialized anything since 7983e81, which replaced
its dummy field lookup with getClassName() to avoid the NoSuchFieldError it had to
clear afterwards. The exception was noise, but the lookup was the mechanism:
FindFieldJNI() begins by calling EnsureInitialized(), and getClassName() reaches
nothing of the sort. Resolve Object.hashCode() instead, which every class inherits
and FindClassMethod() finds by walking the superclass chain. Interfaces are the one
exception, as FindInterfaceMethodWithSignature() searches only their declared and
superinterface methods; they are initialized all the same, before the lookup fails.

https://android.googlesource.com/platform/art/+/refs/heads/main/runtime/jni/jni_internal.cc

Invocation

ClassLinker::InitializeClass() runs the initializer through ArtMethod::Invoke()
rather than from a call site. That function hands over to the interpreter — running the
DEX bytecode straight from the code item, never consulting the quick entrypoint the
mangler patched — whenever the calling thread is forced to interpret, and dispatches
through the entrypoint otherwise. Hooking a class initializer therefore took effect
only when ART happened to pick the latter branch, which varies with the API level and
the compiler filter. Every other method has bytecode or quick call sites that stay
instrumented; <clinit> has only this one.

ArtMethod::Invoke() is therefore instrumented as well, swapping in the replacement
method, which is native and thus not eligible for interpretation, putting the call back
onto the quick path. It needs a stack check written for the stack it actually sees: the
fragment is pushed after the hook has run, so the one in
find_replacement_method_from_quick_code() does not hold, and a class initializer
re-entered through this.$clinit() recursed until the stack ran out. Our own JNI
replacement stub is on top precisely when the hook is invoking the original, so compare
against it directly, masking the tag bit off the top of the stack the way
ManagedStack::GetTopQuickFrame() does. The comparison is per-method, leaving a
replacement free to invoke some other hooked method and still reach that method's hook.
The deoptimization APIs are the other caller of this hook and were exposed to the same
recursion, so they get the fix too.

https://android.googlesource.com/platform/art/+/refs/heads/main/runtime/art_method.cc

HotSpot

$clinit was enumerable on the JVM but never hookable: jni_FindClass() passes
init=true down to find_class_from_class_loader(), so Java.use() ran the
initializer before the caller could install anything.

Resolve through Class.forName(name, false, loader) instead. That loads without
initializing, but stops short of linking too, and JVMTI will not enumerate the members
of a class that has yet to be prepared: GetClassMethods() fails with
JVMTI_ERROR_CLASS_NOT_PREPARED, and model_new() ignores the return value, leaving
it to walk and free a count and array it never wrote. Ask for the declared methods
first, as JVM_GetClassDeclaredMethods() links the class without initializing it.

jni_FindClass() takes its loader from the topmost Java frame on the calling thread,
falling back to the system one when the thread has none, so Class.forName() has to be
told the same or classes only a custom loader can see would stop resolving from inside
a hook. getCallerClassLoader() works it out with GetStackTrace(),
GetMethodDeclaringClass() and GetClassLoader(), none of which need a capability.

jvm.js's ensureClassInitialized() was an empty function, which no longer holds now
that Java.use() leaves the class alone: every static field would read back its
default. jni_GetMethodID() calls klass->initialize() before the lookup, so the trick
ART uses ports over unchanged, down to interfaces needing the pending exception cleared
afterwards.

https://github.com/openjdk/jdk/blob/master/src/hotspot/share/prims/jni.cpp

Tests

ClassInitializerTest covers hooking a class initializer, replacing it, invoking the
original from the replacement, enumeration, and the class that has none. The harness
needed repairs to run at all on newer API levels: the runner gives ART back its signal
handlers instead of stubbing out libsigchain, script code is spliced into the bundle
rather than appended to it, a script that never replies fails with its own error and a
logcat dump instead of timing out silently, and APP_PLATFORM is clamped to what the
NDK actually ships. The test matrix extends to Android 16, dropping x86 in favour of
x86_64 throughout.

Expose <clinit> as $clinit on class wrappers, alongside $init and $new,
so a class' static initialization can be intercepted, inspected or
skipped:

    const Foo = Java.use('com.example.Foo');
    Foo.$clinit.implementation = function () {
        this.$clinit();
    };

As a class is only ever initialized once, the hook must be installed
before anything touches the class. Resolving <clinit> through JNI would
defeat that: GetStaticMethodID() goes through FindMethodJNI(), which
begins by calling EnsureInitialized(), and would therefore run the very
initializer we are about to hook. Java reflection is no help either, as
<clinit> is exposed by neither getDeclaredMethods() nor
getDeclaredConstructors(), which is how $init and $new resolve their
method IDs. The ID is therefore taken from ClassModel, which enumerates
methods without going through method resolution.

Unlike $init, $clinit is a plain Method rather than a MethodDispatcher,
as a class has at most one class initializer and its signature is
always ()V, leaving nothing to dispatch on.

Class model

model_new()'s three branches disagreed on constructors. The JVMTI branch
added every method GetClassMethods() reports, so <init> and <clinit>
became members under their raw names, reachable as wrapper['<init>'];
the ART branch dropped everything carrying kAccConstructor; the
reflection fallback uses getDeclaredMethods(), which never reports
constructors at all. All three now agree that constructors are not
members. Instance ones are dropped, as $new and $init resolve them
independently and need the signatures only reflection provides.

The class initializer is kept in Model::class_initializer rather than in
the members hash table, and read back through the new
model_find_class_initializer(). Putting it in the table would surface it
in model_list(), which backs the wrapper Proxy's ownKeys() trap, and as
getOwnPropertyDescriptor() reports every member as enumerable,
Object.entries() on a wrapper would resolve it through
makeMethodFromSpec(). That calls ToReflectedMethod(), which yields a
Constructor for <clinit>, and would then invoke java.lang.reflect.Method
methods on it.

The ART branch identifies <clinit> as IsConstructor() && IsStatic(),
matching ArtMethod::IsClassInitializer(), and names it without
consulting ART for the same reason. Java.enumerateMethods() now reports
$clinit instead of skipping it, mirroring how <init> is reported as
$init.

    https://android.googlesource.com/platform/art/+/refs/heads/main/runtime/art_method.h

Class initialization

Java.use() no longer initializes the class; that now happens when a
static field is first read or an implementation is installed. ART
rewrites the quick entrypoint of every static method of a class as that
class becomes initialized, via ClassLinker::FixupStaticTrampolines(),
and ArtMethodMangler.replace() snapshots quickCode when the hook is
installed, so hooking a static method of a not-yet-initialized class
would leave the mangler holding an entrypoint ART replaces moments
later, silently bypassing the hook. Initializing at hook time preserves
that ordering while leaving classes that are merely wrapped alone.
CLASS_INITIALIZER_METHOD is exempt, as forcing initialization is
precisely what hooking it is meant to intercept.

    https://android.googlesource.com/platform/art/+/refs/heads/main/runtime/class_linker.cc

ensureClassInitialized() had not initialized anything since 7983e81,
which replaced its dummy field lookup with getClassName() to avoid the
NoSuchFieldError it had to clear afterwards. The exception was noise,
but the lookup was the mechanism: FindFieldJNI() begins by calling
EnsureInitialized(), and getClassName() reaches nothing of the sort.
Resolve Object.hashCode() instead, which every class inherits and
FindClassMethod() finds by walking the superclass chain. Interfaces are
the one exception, as FindInterfaceMethodWithSignature() searches only
their declared and superinterface methods; they are initialized all the
same, before the lookup fails.

    https://android.googlesource.com/platform/art/+/refs/heads/main/runtime/jni/jni_internal.cc

Invocation

ClassLinker::InitializeClass() runs the initializer through
ArtMethod::Invoke() rather than from a call site. That function hands
over to the interpreter -- running the DEX bytecode straight from the
code item, never consulting the quick entrypoint the mangler patched --
whenever the calling thread is forced to interpret, and dispatches
through the entrypoint otherwise. Hooking a class initializer therefore
took effect only when ART happened to pick the latter branch, which
varies with the API level and the compiler filter. Every other method
has bytecode or quick call sites that stay instrumented; <clinit> has
only this one.

ArtMethod::Invoke() is therefore instrumented as well, swapping in the
replacement method, which is native and thus not eligible for
interpretation, putting the call back onto the quick path. It needs a
stack check written for the stack it actually sees: the fragment is
pushed after the hook has run, so the one in
find_replacement_method_from_quick_code() does not hold, and a class
initializer re-entered through this.$clinit() recursed until the stack
ran out. Our own JNI replacement stub is on top precisely when the hook
is invoking the original, so compare against it directly, masking the
tag bit off the top of the stack the way
ManagedStack::GetTopQuickFrame() does. The comparison is per-method,
leaving a replacement free to invoke some other hooked method and still
reach that method's hook. The
deoptimization APIs are the other caller of this hook and were exposed
to the same recursion, so they get the fix too.

    https://android.googlesource.com/platform/art/+/refs/heads/main/runtime/art_method.cc

HotSpot

$clinit was enumerable on the JVM but never hookable: jni_FindClass()
passes init=true down to find_class_from_class_loader(), so Java.use()
ran the initializer before the caller could install anything.

Resolve through Class.forName(name, false, loader) instead. That loads
without initializing, but stops short of linking too, and JVMTI will not
enumerate the members of a class that has yet to be prepared:
GetClassMethods() fails with JVMTI_ERROR_CLASS_NOT_PREPARED, and
model_new() ignores the return value, leaving it to walk and free a
count and array it never wrote. Ask for the declared methods first, as
JVM_GetClassDeclaredMethods() links the class without initializing it.

jni_FindClass() takes its loader from the topmost Java frame on the
calling thread, falling back to the system one when the thread has none,
so Class.forName() has to be told the same or classes only a custom
loader can see would stop resolving from inside a hook.
getCallerClassLoader() works it out with GetStackTrace(),
GetMethodDeclaringClass() and GetClassLoader(), none of which need a
capability.

jvm.js's ensureClassInitialized() was an empty function, which no longer
holds now that Java.use() leaves the class alone: every static field
would read back its default. jni_GetMethodID() calls klass->initialize()
before the lookup, so the trick ART uses ports over unchanged, down to
interfaces needing the pending exception cleared afterwards.

    https://github.com/openjdk/jdk/blob/master/src/hotspot/share/prims/jni.cpp

Tests

ClassInitializerTest covers hooking a class initializer, replacing it,
invoking the original from the replacement, enumeration, and the class
that has none. The harness needed repairs to run at all on newer API
levels: the runner gives ART back its signal handlers instead of
stubbing out libsigchain, script code is spliced into the bundle rather
than appended to it, a script that never replies fails with its own
error and a logcat dump instead of timing out silently, and
APP_PLATFORM is clamped to what the NDK actually ships. The test matrix
extends to Android 16, dropping x86 in favour of x86_64 throughout.
@mnalmahmud mnalmahmud changed the title Add support for hooking class initializers added support for hooking class initializers Aug 7, 2026
@mnalmahmud

Copy link
Copy Markdown
Author

@oleavr kindly have a look at this if you get time.

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