Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/linting.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ jobs:
eslint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 18
node-version: 22
- run: npm install
- run: npm run lint
16 changes: 6 additions & 10 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,22 @@ jobs:
tests:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
api: [26, 27, 28, 29, 30, 31]
arch: [x86, x86_64]
exclude:
- api: 30
arch: x86
- api: 31
arch: x86
api: [26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36]
arch: [x86_64]
env:
ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: 200 # Default is 20
steps:
- name: Check out repo
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: AVD cache
uses: actions/cache@v4
uses: actions/cache@v6
id: avd-cache
with:
path: |
Expand All @@ -50,7 +46,7 @@ jobs:
disable-animations: true
script: >-
sdkmanager "platforms;android-${{ matrix.api }}"
&& PATH="$ANDROID_SDK_ROOT/build-tools/34.0.0:$PATH"
&& PATH="$ANDROID_SDK_ROOT/build-tools/37.0.0:$PATH"
ANDROID_ARCH=${{ matrix.arch }}
ANDROID_ABI=${{ matrix.arch }}
ANDROID_API_LEVEL=${{ matrix.api }}
Expand Down
14 changes: 14 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,20 @@ declare module "frida-java-bridge" {
*/
$init: MethodDispatcher<T>;

/**
* The class initializer, i.e. `<clinit>`, which the runtime invokes once to
* initialize the class' static state.
*
* Replace the `implementation` property to hook it. As the class is only ever
* initialized once, this must happen before anything touches it -- accessing a
* static member, or calling a static method, is enough to trigger it. Note that
* `Java.use()` itself deliberately does not. Throws if the class has no class
* initializer.
*
* Not supported on Dalvik, i.e. Android < 5.0.
*/
$clinit: Method<T, [], void>;

/**
* Eagerly deletes the underlying JNI global reference without having to
* wait for the object to become unreachable and the JavaScript
Expand Down
83 changes: 82 additions & 1 deletion lib/android.js
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ let thunkPage = null;
let thunkOffset = 0;
let taughtArtAboutReplacementMethods = false;
let taughtArtAboutMethodInstrumentation = false;
let taughtArtAboutRuntimeMethodInvocation = false;
let backtraceModule = null;
const jdwpSessions = [];
let socketpair = null;
Expand Down Expand Up @@ -586,7 +587,16 @@ export function ensureClassInitialized (env, classRef) {
return;
}

env.getClassName(classRef);
// FindMethodJNI() begins by calling EnsureInitialized(), so resolving any method initializes
// the class. Every class inherits Object.hashCode() and FindClassMethod() walks the superclass
// chain, so the lookup succeeds and nothing is thrown.
const method = env.getMethodId(classRef, 'hashCode', '()I');
if (method.isNull()) {
// Interfaces are the one exception, as FindInterfaceMethodWithSignature() searches only
// their declared and superinterface methods, never Object's. They are initialized all the
// same, before the lookup fails.
env.exceptionClear();
}
}

function getArtVMSpec (api) {
Expand Down Expand Up @@ -1977,6 +1987,50 @@ on_interpreter_do_call (GumInvocationContext * ic)
gum_invocation_context_replace_nth_argument (ic, 0, replacement_method);
}

gpointer
find_replacement_method_from_runtime_invoke (gpointer method,
gpointer thread)
{
gpointer replacement_method;
gpointer managed_stack;
gpointer * top_quick_frame;

replacement_method = get_replacement_method (method);
if (replacement_method == NULL)
return NULL;

/*
* Stack check, as above, but for callers that have yet to push a fragment of
* their own -- ArtMethod::Invoke() does so only once we have returned, leaving
* the frame on top still belonging to whoever called it.
*
* Our own JNI replacement stub is therefore on top precisely when this is the
* hook invoking the original, which is the one case where the original is what
* must run. The check is per-method, so one replacement invoking a different
* hooked method still reaches that method's hook.
*/
managed_stack = thread + ${threadOffsets.managedStack};
top_quick_frame = GSIZE_TO_POINTER (*((gsize *) (managed_stack + ${managedStackOffsets.topQuickFrame})) & ~((gsize) 1));
if (top_quick_frame != NULL && *top_quick_frame == replacement_method)
return NULL;

return replacement_method;
}

void
on_art_method_invoke (GumInvocationContext * ic)
{
gpointer method, thread;
gpointer replacement_method;

method = gum_invocation_context_get_nth_argument (ic, 0);
thread = gum_invocation_context_get_nth_argument (ic, 1);

replacement_method = find_replacement_method_from_runtime_invoke (method, thread);
if (replacement_method != NULL)
gum_invocation_context_replace_nth_argument (ic, 0, replacement_method);
}

gpointer
on_art_method_get_oat_quick_method_header (gpointer method,
gpointer pc)
Expand Down Expand Up @@ -2059,6 +2113,7 @@ on_leave_gc_concurrent_copying_copying_phase (GumInvocationContext * ic)
doCall: cm.on_interpreter_do_call
},
ArtMethod: {
invoke: cm.on_art_method_invoke,
getOatQuickMethodHeader: cm.on_art_method_get_oat_quick_method_header,
prettyMethod: cm.on_art_method_pretty_method
},
Expand Down Expand Up @@ -2181,6 +2236,28 @@ function instrumentArtFixupStaticTrampolines () {
}
}

export function ensureArtKnowsHowToHandleRuntimeMethodInvocation () {
if (taughtArtAboutRuntimeMethodInvocation) {
return;
}
taughtArtAboutRuntimeMethodInvocation = true;

/*
* ArtMethod::Invoke() hands over to the interpreter instead of the quick entrypoints -- and
* thus bypasses our instrumentation of them -- whenever the calling thread is forced to use
* the interpreter. Swapping in our replacement, which is native and therefore not eligible for
* interpretation, puts the call back onto the quick path.
*
* https://android.googlesource.com/platform/art/+/refs/heads/main/runtime/art_method.cc
*/
const invoke = getApi().find('_ZN3art9ArtMethod6InvokeEPNS_6ThreadEPjjPNS_6JValueEPKc');
if (invoke === null) {
return;
}

Interceptor.attach(invoke, artController.hooks.ArtMethod.invoke);
}

function ensureArtKnowsHowToHandleReplacementMethods (vm) {
if (taughtArtAboutReplacementMethods) {
return;
Expand Down Expand Up @@ -3990,6 +4067,8 @@ export function deoptimizeBootImage (vm, env) {
throw new Error('This API is only available on Android >= 8.0');
}

ensureArtKnowsHowToHandleRuntimeMethodInvocation();

withRunnableArtThread(vm, env, thread => {
api['art::Runtime::DeoptimizeBootImage'](api.artRuntime);
});
Expand All @@ -4002,6 +4081,8 @@ function requestDeoptimization (vm, env, kind, method) {
throw new Error('This API is only available on Android >= 7.0');
}

ensureArtKnowsHowToHandleRuntimeMethodInvocation();

withRunnableArtThread(vm, env, thread => {
if (getAndroidApiLevel() < 30) {
if (!api.isJdwpStarted()) {
Expand Down
97 changes: 93 additions & 4 deletions lib/class-factory.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import Env from './env.js';
import * as android from './android.js';
import { ensureClassInitialized as jvmEnsureClassInitialized, makeMethodMangler as jvmMakeMethodMangler } from './jvm.js';
import {
ensureClassInitialized as jvmEnsureClassInitialized,
findClassWithoutInitializing as jvmFindClassWithoutInitializing,
makeMethodMangler as jvmMakeMethodMangler
} from './jvm.js';
import ClassModel from './class-model.js';
import LRU from './lru.js';
import mkdex from './mkdex.js';
Expand All @@ -21,6 +25,7 @@ const kAccStatic = 0x0008;
const CONSTRUCTOR_METHOD = 1;
const STATIC_METHOD = 2;
const INSTANCE_METHOD = 3;
const CLASS_INITIALIZER_METHOD = 4;

const STATIC_FIELD = 1;
const INSTANCE_FIELD = 2;
Expand Down Expand Up @@ -235,6 +240,14 @@ export default class ClassFactory {
return this[Symbol.for('c')];
}
},
[Symbol.for('ci')]: {
value: [null]
},
$ci: {
get () {
return this[Symbol.for('ci')];
}
},
[Symbol.for('m')]: {
value: new Map()
},
Expand Down Expand Up @@ -282,8 +295,6 @@ export default class ClassFactory {
try {
const classHandle = h.value;

ensureClassInitialized(env, classHandle);

proto.$l = ClassModel.build(classHandle, env);
} finally {
h.unref(env);
Expand Down Expand Up @@ -914,6 +925,18 @@ Object.defineProperties(Wrapper.prototype, {
return this[Symbol.for('init')];
}
},
[Symbol.for('clinit')]: {
enumerable: false,
get () {
return this.$getClassInitializer();
}
},
$clinit: {
enumerable: true,
get () {
return this[Symbol.for('clinit')];
}
},
[Symbol.for('dispose')]: {
enumerable: false,
value () {
Expand Down Expand Up @@ -1092,6 +1115,39 @@ Object.defineProperties(Wrapper.prototype, {
return this[Symbol.for('getCtor')](type);
}
},
[Symbol.for('getClassInitializer')]: {
enumerable: false,
value () {
const slot = this.$ci;

let clinit = slot[0];
if (clinit === null) {
const classWrapper = this.$w;
const { $n: className, $f: factory } = classWrapper;

/*
* Taken from our own model instead of GetStaticMethodID(), which resolves through
* FindMethodJNI() -- and that calls EnsureInitialized(), running the very initializer we
* are about to hook. Superclasses are not consulted, as <clinit> is never inherited.
*/
const methodId = this.$l.findClassInitializer();
if (methodId === null) {
throw new Error(`${className} has no class initializer`);
}

clinit = makeMethod('$clinit', classWrapper, CLASS_INITIALIZER_METHOD, methodId,
factory._getType('void', false), [], vm.getEnv());
slot[0] = clinit;
}

return clinit;
}
},
$getClassInitializer: {
value () {
return this[Symbol.for('getClassInitializer')]();
}
},
[Symbol.for('borrowClassHandle')]: {
enumerable: false,
value (env) {
Expand Down Expand Up @@ -1279,6 +1335,9 @@ function makeBasicClassHandleGetter (className) {
const tid = getCurrentThreadId();
ignore(tid);
try {
if (!isArtVm) {
return jvmFindClassWithoutInitializing(env, className);
}
return env.findClass(canonicalClassName);
} finally {
unignore(tid);
Expand Down Expand Up @@ -1644,7 +1703,7 @@ function makeMethod (methodName, classWrapper, type, methodId, retType, argTypes
if (type === INSTANCE_METHOD) {
callVirtually = env.vaMethod(rawRetType, rawArgTypes, invocationOptions);
callDirectly = env.nonvirtualVaMethod(rawRetType, rawArgTypes, invocationOptions);
} else if (type === STATIC_METHOD) {
} else if (type === STATIC_METHOD || type === CLASS_INITIALIZER_METHOD) {
callVirtually = env.staticVaMethod(rawRetType, rawArgTypes, invocationOptions);
callDirectly = callVirtually;
} else {
Expand Down Expand Up @@ -1722,6 +1781,26 @@ methodPrototype = Object.create(Function.prototype, {
if (fn !== null) {
const [methodName, classWrapper, type, methodId, retType, argTypes] = params;

if (type !== CLASS_INITIALIZER_METHOD) {
// ART rewrites every static method's quick entrypoint once the class is initialized,
// which would leave the mangler holding a stale one.
const env = vm.getEnv();
const h = classWrapper.$borrowClassHandle(env);
try {
ensureClassInitialized(env, h.value);
} finally {
h.unref(env);
}
} else if (isArtVm) {
/*
* Initializing is exactly what hooking <clinit> is meant to intercept, so it is exempt
* from the above and needs something else: ClassLinker reaches <clinit> through
* ArtMethod::Invoke(), which hands over to the interpreter -- bypassing the entrypoint
* the mangler patched -- whenever the calling thread is forced to interpret.
*/
android.ensureArtKnowsHowToHandleRuntimeMethodInvocation();
}

const replacement = implement(methodName, classWrapper, type, retType, argTypes, fn, this);
const mangler = makeMethodMangler(methodId);
replacement._m = mangler;
Expand Down Expand Up @@ -2047,6 +2126,16 @@ function makeFieldFromSpec (name, spec, classHandle, classWrapper, env) {
const id = ptr(spec.substr(3));
const { $f: factory } = classWrapper;

/*
* GetStaticField() makes no initialization check of its own, and the field ID comes from
* ClassModel rather than GetStaticFieldID(), so nothing else on this path would run the
* initializer. Members are resolved lazily, so this happens on first access rather than in
* Java.use(), leaving $clinit hookable.
*/
if (type === STATIC_FIELD) {
ensureClassInitialized(env, classHandle);
}

let fieldType;
const field = env.toReflectedField(classHandle, id, (type === STATIC_FIELD) ? 1 : 0);
try {
Expand Down
Loading