added support for hooking class initializers - #403
Open
mnalmahmud wants to merge 1 commit into
Open
Conversation
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.
Author
|
@oleavr kindly have a look at this if you get time. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Added support for hooking class initializers
Expose
<clinit>as$cliniton class wrappers, alongside$initand$new, so aclass' static initialization can be intercepted, inspected or skipped:
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 throughFindMethodJNI(), which begins by callingEnsureInitialized(), and would therefore run the very initializer we are about tohook. Java reflection is no help either, as
<clinit>is exposed by neithergetDeclaredMethods()norgetDeclaredConstructors(), which is how$initand$newresolve their method IDs. The ID is therefore taken from
ClassModel, which enumeratesmethods without going through method resolution.
Unlike
$init,$clinitis a plainMethodrather than aMethodDispatcher, as aclass has at most one class initializer and its signature is always
()V, leavingnothing to dispatch on.
Class model
model_new()'s three branches disagreed on constructors. The JVMTI branch added everymethod
GetClassMethods()reports, so<init>and<clinit>became members undertheir raw names, reachable as
wrapper['<init>']; the ART branch dropped everythingcarrying
kAccConstructor; the reflection fallback usesgetDeclaredMethods(), whichnever reports constructors at all. All three now agree that constructors are not
members. Instance ones are dropped, as
$newand$initresolve them independentlyand need the signatures only reflection provides.
The class initializer is kept in
Model::class_initializerrather than in the membershash table, and read back through the new
model_find_class_initializer(). Putting itin the table would surface it in
model_list(), which backs the wrapper Proxy'sownKeys()trap, and asgetOwnPropertyDescriptor()reports every member asenumerable,
Object.entries()on a wrapper would resolve it throughmakeMethodFromSpec(). That callsToReflectedMethod(), which yields aConstructorfor
<clinit>, and would then invokejava.lang.reflect.Methodmethods on it.The ART branch identifies
<clinit>asIsConstructor() && IsStatic(), matchingArtMethod::IsClassInitializer(), and names it without consulting ART for the samereason.
Java.enumerateMethods()now reports$clinitinstead 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 isfirst 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(), andArtMethodMangler.replace()snapshotsquickCodewhen the hook is installed, so hooking a static method of anot-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_METHODis exempt, as forcing initialization is precisely whathooking 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 replacedits dummy field lookup with
getClassName()to avoid theNoSuchFieldErrorit had toclear afterwards. The exception was noise, but the lookup was the mechanism:
FindFieldJNI()begins by callingEnsureInitialized(), andgetClassName()reachesnothing of the sort. Resolve
Object.hashCode()instead, which every class inheritsand
FindClassMethod()finds by walking the superclass chain. Interfaces are the oneexception, as
FindInterfaceMethodWithSignature()searches only their declared andsuperinterface 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 throughArtMethod::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 replacementmethod, 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 initializerre-entered through
this.$clinit()recursed until the stack ran out. Our own JNIreplacement 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 areplacement 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
$clinitwas enumerable on the JVM but never hookable:jni_FindClass()passesinit=truedown tofind_class_from_class_loader(), soJava.use()ran theinitializer before the caller could install anything.
Resolve through
Class.forName(name, false, loader)instead. That loads withoutinitializing, but stops short of linking too, and JVMTI will not enumerate the members
of a class that has yet to be prepared:
GetClassMethods()fails withJVMTI_ERROR_CLASS_NOT_PREPARED, andmodel_new()ignores the return value, leavingit 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 betold the same or classes only a custom loader can see would stop resolving from inside
a hook.
getCallerClassLoader()works it out withGetStackTrace(),GetMethodDeclaringClass()andGetClassLoader(), none of which need a capability.jvm.js'sensureClassInitialized()was an empty function, which no longer holds nowthat
Java.use()leaves the class alone: every static field would read back itsdefault.
jni_GetMethodID()callsklass->initialize()before the lookup, so the trickART 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
ClassInitializerTestcovers hooking a class initializer, replacing it, invoking theoriginal 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_PLATFORMis clamped to what theNDK actually ships. The test matrix extends to Android 16, dropping x86 in favour of
x86_64 throughout.