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:
- initialization rewrites the entry point, discarding the patch; or
- 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.
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); }The intent was clearly to remove exception noise. But the noisy call was the only thing doing the work:
GetFieldIDfor a field that does not exist reachesClassLinker::EnsureInitialized, which is what actually initialized the class.GetClassNamenever does. The function still has the same name and is still called fromclass-factory.jsimmediately beforeClassModel.build()on everyJava.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. TheArtMethodpatch 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
7983e815b38a5b64)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-gadgetheld byte-identical at 17.17.0 across all of them. Only reverting7983e815flips the behaviour, and two control hooks in the same build and same process keep working either way:android.hardware.biometrics.BiometricPromptauthenticateandroid.hardware.camera2.CaptureRequest$BuilderaddTarget,buildandroid.hardware.camera2.impl.CameraCaptureSessionImplsetRepeatingRequestandroid.hardware.biometrics.BiometricManagercanAuthenticateandroid.hardware.camera2.impl.CameraDeviceImplcreateCaptureSessionThe split matches class initialization state.
oatdumpon a failing device shows the dead class atSuperclassValidatedand a working one atVisiblyInitializedin 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 onlycreateCaptureSessiondispatches, so the session is configured with substituted surfaces while the request keeps the originals:Minimal reproduction
The behaviour change should be observable without hooking anything, since
ensureClassInitialized()runs on everyJava.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:Uninitis never referenced during startup. Then:CLINIT RANappears in logcatAdding
.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 viaClass.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:
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.