diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index adc979e6..a4e4cfb9 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -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 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d77a2577..59806e64 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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: | @@ -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 }} diff --git a/index.d.ts b/index.d.ts index 8237d839..ddf4fb09 100644 --- a/index.d.ts +++ b/index.d.ts @@ -378,6 +378,20 @@ declare module "frida-java-bridge" { */ $init: MethodDispatcher; + /** + * The class initializer, i.e. ``, 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; + /** * Eagerly deletes the underlying JNI global reference without having to * wait for the object to become unreachable and the JavaScript diff --git a/lib/android.js b/lib/android.js index d023166d..7d28dd37 100644 --- a/lib/android.js +++ b/lib/android.js @@ -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; @@ -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) { @@ -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) @@ -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 }, @@ -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; @@ -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); }); @@ -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()) { diff --git a/lib/class-factory.js b/lib/class-factory.js index 68a1a3b4..97e2084c 100644 --- a/lib/class-factory.js +++ b/lib/class-factory.js @@ -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'; @@ -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; @@ -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() }, @@ -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); @@ -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 () { @@ -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 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) { @@ -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); @@ -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 { @@ -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 is meant to intercept, so it is exempt + * from the above and needs something else: ClassLinker reaches 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; @@ -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 { diff --git a/lib/class-model.js b/lib/class-model.js index 28b68b07..f90bbfae 100644 --- a/lib/class-model.js +++ b/lib/class-model.js @@ -54,6 +54,7 @@ typedef void (* ArtPrettyMethodFunc) (StdString * result, ArtMethod * method, jb struct _Model { GHashTable * members; + jmethodID class_initializer; }; struct _EnumerateMethodsContext @@ -256,6 +257,7 @@ model_new (jclass class_handle, members = g_hash_table_new_full (g_str_hash, g_str_equal, g_free, g_free); model->members = members; + model->class_initializer = NULL; if (jvmti != NULL) { @@ -282,7 +284,11 @@ model_new (jclass class_handle, get_method_name (jvmti, method, &name, NULL, NULL); get_method_modifiers (jvmti, method, &modifiers); - model_add_method (model, name, method, modifiers); + /* Not a member: it is reached through $clinit. */ + if (strcmp (name, "") == 0) + model->class_initializer = method; + else + model_add_method (model, name, method, modifiers); deallocate (jvmti, name); } @@ -327,13 +333,20 @@ model_new (jclass class_handle, id = elements + (i * art_api.method_size); access_flags = *(guint32 *) (id + art_api.method_offset_access_flags); + is_static = (access_flags & kAccStatic) != 0; + modifiers = access_flags & 0xffff; + if ((access_flags & kAccConstructor) != 0) + { + /* ArtMethod::IsClassInitializer() is IsConstructor() && IsStatic(). */ + if (is_static) + model->class_initializer = id; continue; - is_static = (access_flags & kAccStatic) != 0; + } + method = to_reflected_method (env, class_handle, id, is_static); name = call_object_method (env, method, java_api.method.get_name); name_str = get_string_utf_chars (env, name, NULL); - modifiers = access_flags & 0xffff; model_add_method (model, name_str, id, modifiers); @@ -508,6 +521,12 @@ model_find (Model * self, return g_hash_table_lookup (self->members, member); } +jmethodID +model_find_class_initializer (Model * self) +{ + return self->class_initializer; +} + gchar * model_list (Model * self) { @@ -619,7 +638,7 @@ collect_matching_class_methods (ArtClassVisitor * self, { ArtMethod * method; guint32 access_flags; - jboolean is_constructor; + jboolean is_constructor, is_static; StdString method_name = { 0, }; const gchar * bare_method_name; gchar * bare_method_name_copy = NULL; @@ -630,6 +649,7 @@ collect_matching_class_methods (ArtClassVisitor * self, access_flags = *(guint32 *) ((gpointer) method + art_api.method_offset_access_flags); is_constructor = (access_flags & kAccConstructor) != 0; + is_static = (access_flags & kAccStatic) != 0; art_api.pretty_method (&method_name, method, ctx->include_signature); bare_method_name = std_string_c_str (&method_name); @@ -640,14 +660,12 @@ collect_matching_class_methods (ArtClassVisitor * self, return_type_end = strchr (bare_method_name, ' '); name_begin = return_type_end + 1 + class_name_length + 1; - if (is_constructor && g_str_has_prefix (name_begin, "")) - goto skip_method; name = g_string_sized_new (64); if (is_constructor) { - g_string_append (name, "$init"); + g_string_append (name, is_static ? "$clinit" : "$init"); g_string_append (name, strchr (name_begin, '>') + 1); } else @@ -662,14 +680,8 @@ collect_matching_class_methods (ArtClassVisitor * self, } else { - const gchar * name_begin; - - name_begin = bare_method_name + class_name_length + 1; - if (is_constructor && strcmp (name_begin, "") == 0) - goto skip_method; - if (is_constructor) - bare_method_name = "$init"; + bare_method_name = is_static ? "$clinit" : "$init"; else bare_method_name += class_name_length + 1; } @@ -844,7 +856,7 @@ enumerate_methods_jvm (const gchar * class_query, if (strcmp (method_name, "") == 0) method_name = "$init"; else if (strcmp (method_name, "") == 0) - goto skip_method; + method_name = "$clinit"; } if (include_signature) @@ -1303,6 +1315,11 @@ export default class Model { return cm.find(this.handle, Memory.allocUtf8String(member)).readUtf8String(); } + findClassInitializer () { + const id = cm.findClassInitializer(this.handle); + return !id.isNull() ? id : null; + } + list () { const str = cm.list(this.handle); try { @@ -1409,6 +1426,7 @@ function compileModule (env) { new: new NativeFunction(cm.model_new, 'pointer', ['pointer', 'pointer', 'pointer'], reentrantOptions), has: new NativeFunction(cm.model_has, 'bool', ['pointer', 'pointer'], fastOptions), find: new NativeFunction(cm.model_find, 'pointer', ['pointer', 'pointer'], fastOptions), + findClassInitializer: new NativeFunction(cm.model_find_class_initializer, 'pointer', ['pointer'], fastOptions), list: new NativeFunction(cm.model_list, 'pointer', ['pointer'], fastOptions), enumerateMethodsArt: new NativeFunction(cm.enumerate_methods_art, 'pointer', ['pointer', 'pointer', 'bool', 'bool', 'bool'], reentrantOptions), diff --git a/lib/env.js b/lib/env.js index b8a5a7d1..d2d37037 100644 --- a/lib/env.js +++ b/lib/env.js @@ -214,6 +214,33 @@ Env.prototype.findClass = proxy(6, 'pointer', ['pointer', 'pointer'], function ( return result; }); +/* + * Same, but for the JVM, whose FindClass() also initializes -- running the very class initializer + * that hooking $clinit exists to intercept. Class.forName() stops short of that, but stops short + * of linking too, and JVMTI will not enumerate the members of an unprepared class, so ask for the + * declared methods to link it. + * + * Takes the name in dotted form, as Class.forName() does, and the loader to resolve it against -- + * FindClass() picks one implicitly, so it is up to the caller to pick the same. + */ +Env.prototype.findClassWithoutInitializing = function (name, loader) { + const Class = this.javaLangClass(); + const nameValue = this.newStringUtf(name); + try { + const handle = this.staticVaMethod('pointer', ['pointer', 'int', 'pointer'])( + this.handle, Class.handle, Class.forName, nameValue, 0, loader); + this.throwIfExceptionPending(); + + const methods = this.vaMethod('pointer', [])(this.handle, handle, Class.getDeclaredMethods); + this.throwIfExceptionPending(); + this.deleteLocalRef(methods); + + return handle; + } finally { + this.deleteLocalRef(nameValue); + } +}; + Env.prototype.throwIfExceptionPending = function () { const throwable = this.exceptionOccurred(); if (throwable.isNull()) { @@ -659,6 +686,8 @@ Env.prototype.javaLangClass = function () { const get = this.getMethodId.bind(this, handle); javaLangClass = { handle: register(this.newGlobalRef(handle)), + forName: this.getStaticMethodId(handle, 'forName', + '(Ljava/lang/String;ZLjava/lang/ClassLoader;)Ljava/lang/Class;'), getName: get('getName', '()Ljava/lang/String;'), getSimpleName: get('getSimpleName', '()Ljava/lang/String;'), getGenericSuperclass: get('getGenericSuperclass', '()Ljava/lang/reflect/Type;'), @@ -677,6 +706,24 @@ Env.prototype.javaLangClass = function () { return javaLangClass; }; +let systemClassLoader = null; +Env.prototype.systemClassLoader = function () { + if (systemClassLoader === null) { + const handle = this.findClass('java/lang/ClassLoader'); + try { + const getSystemClassLoader = this.getStaticMethodId(handle, 'getSystemClassLoader', + '()Ljava/lang/ClassLoader;'); + const loader = this.staticVaMethod('pointer', [])(this.handle, handle, getSystemClassLoader); + this.throwIfExceptionPending(); + systemClassLoader = register(this.newGlobalRef(loader)); + this.deleteLocalRef(loader); + } finally { + this.deleteLocalRef(handle); + } + } + return systemClassLoader; +}; + let javaLangObject = null; Env.prototype.javaLangObject = function () { if (javaLangObject === null) { diff --git a/lib/jvm.js b/lib/jvm.js index 0319cdba..fda58e92 100644 --- a/lib/jvm.js +++ b/lib/jvm.js @@ -10,6 +10,7 @@ import VM from './vm.js'; const jsizeSize = 4; const { pointerSize } = Process; +const jvmtiFrameInfoSize = 16; // jmethodID plus jlocation, the latter a jlong on every ABI const JVM_ACC_NATIVE = 0x0100; const JVM_ACC_IS_OLD = 0x00010000; @@ -427,6 +428,54 @@ function parseX64ThreadOffset (insn) { } export function ensureClassInitialized (env, classRef) { + // get_method_id() initializes the class before looking anything up, and every class inherits + // Object.hashCode(), so the lookup succeeds and nothing is thrown. + const method = env.getMethodId(classRef, 'hashCode', '()I'); + if (method.isNull()) { + // Interfaces are the one exception, as lookup_method() and its walk of the superinterfaces + // never reach Object's. They are initialized all the same, before the lookup fails. + env.exceptionClear(); + } +} + +export function findClassWithoutInitializing (env, name) { + const loader = getCallerClassLoader(env); + try { + return env.findClassWithoutInitializing(name, (loader !== null) ? loader : env.systemClassLoader()); + } finally { + if (loader !== null) { + env.deleteLocalRef(loader); + } + } +} + +/* + * jni_FindClass() resolves against the loader of the topmost Java frame on the calling thread, + * falling back to the system one when there is none -- as there is not whenever we are called + * from outside a hook. Class.forName() insists on being told, so work out the same loader. + * + * Returns a local reference, NULL meaning the bootstrap loader, or null when there is no frame + * to take one from. + */ +function getCallerClassLoader (env) { + const { jvmti } = getApi(); + + const frames = Memory.alloc(jvmtiFrameInfoSize); + const numFrames = Memory.alloc(jsizeSize); + jvmti.getStackTrace(NULL, 0, 1, frames, numFrames); + if (numFrames.readS32() === 0) { + return null; + } + + const result = Memory.alloc(pointerSize); + jvmti.getMethodDeclaringClass(frames.readPointer(), result); + const klass = result.readPointer(); + try { + jvmti.getClassLoader(klass, result); + return result.readPointer(); + } finally { + env.deleteLocalRef(klass); + } } class JvmMethodMangler { diff --git a/lib/jvmti.js b/lib/jvmti.js index bcae1a80..d96b7cc7 100644 --- a/lib/jvmti.js +++ b/lib/jvmti.js @@ -24,11 +24,26 @@ EnvJvmti.prototype.deallocate = proxy(47, 'int32', ['pointer', 'pointer'], funct return impl(this.handle, mem); }); +EnvJvmti.prototype.getClassLoader = proxy(57, 'int32', ['pointer', 'pointer', 'pointer'], function (impl, klass, classLoaderPtr) { + const result = impl(this.handle, klass, classLoaderPtr); + checkJniResult('EnvJvmti::getClassLoader', result); +}); + +EnvJvmti.prototype.getMethodDeclaringClass = proxy(65, 'int32', ['pointer', 'pointer', 'pointer'], function (impl, method, declaringClassPtr) { + const result = impl(this.handle, method, declaringClassPtr); + checkJniResult('EnvJvmti::getMethodDeclaringClass', result); +}); + EnvJvmti.prototype.getLoadedClasses = proxy(78, 'int32', ['pointer', 'pointer', 'pointer'], function (impl, classCountPtr, classesPtr) { const result = impl(this.handle, classCountPtr, classesPtr); checkJniResult('EnvJvmti::getLoadedClasses', result); }); +EnvJvmti.prototype.getStackTrace = proxy(104, 'int32', ['pointer', 'pointer', 'int', 'int', 'pointer', 'pointer'], function (impl, thread, startDepth, maxFrameCount, frameBuffer, countPtr) { + const result = impl(this.handle, thread, startDepth, maxFrameCount, frameBuffer, countPtr); + checkJniResult('EnvJvmti::getStackTrace', result); +}); + EnvJvmti.prototype.iterateOverInstancesOfClass = proxy(112, 'int32', ['pointer', 'pointer', 'int', 'pointer', 'pointer'], function (impl, klass, objectFilter, heapObjectCallback, userData) { const result = impl(this.handle, klass, objectFilter, heapObjectCallback, userData); checkJniResult('EnvJvmti::iterateOverInstancesOfClass', result); diff --git a/test/Application.mk b/test/Application.mk index 45ad11f9..d5d4b963 100644 --- a/test/Application.mk +++ b/test/Application.mk @@ -1,6 +1,11 @@ include config.mk +# The NDK aborts when APP_PLATFORM names an API level newer than the headers and stubs +# it ships, so clamp to the newest it knows. Harmless, as APP_PLATFORM is a minimum +# rather than a target: a runner built against an older platform runs on a newer one. +ndk_max_api := $(shell sed -n 's/^[[:space:]]*"max":[[:space:]]*\([0-9]*\).*/\1/p' $(ANDROID_NDK_ROOT)/meta/platforms.json 2>/dev/null) + APP_ABI := $(ANDROID_ABI) -APP_PLATFORM := android-$(ANDROID_API_LEVEL) +APP_PLATFORM := android-$(firstword $(sort $(ANDROID_API_LEVEL) $(ndk_max_api))) APP_STL := c++_static APP_BUILD_SCRIPT := Android.mk diff --git a/test/Makefile b/test/Makefile index ea3bada8..caf19860 100644 --- a/test/Makefile +++ b/test/Makefile @@ -9,7 +9,7 @@ deploy_prefix := /data/local/tmp deploy_data_dir := $(deploy_prefix)/frida-java-bridge-tests deploy_cache_dir := $(deploy_data_dir)/dalvik-cache -frida_version := 16.0.1 +frida_version := 17.17.0 test_sources := $(wildcard re/frida/*.java) test_classes := $(patsubst %.java,%.class,$(test_sources)) @@ -27,7 +27,10 @@ deploy: build/$(ANDROID_ABI)/runner build/tests.dex build/frida-java-bridge.js adb push $^ build/$(ANDROID_ABI)/libartpalette.so $(deploy_data_dir) run: - adb shell "LD_PRELOAD=$(ANDROID_VM) LD_LIBRARY_PATH='$(APEX_LIBDIRS):$(deploy_data_dir)' $(deploy_data_dir)/runner $(RUNNER_ARGS)" + @adb logcat -G 16M >/dev/null 2>&1 || true + @adb logcat -c >/dev/null 2>&1 || true + adb shell "LD_PRELOAD=$(ANDROID_VM) LD_LIBRARY_PATH='$(APEX_LIBDIRS):$(deploy_data_dir)' $(deploy_data_dir)/runner $(RUNNER_ARGS)" \ + || { status=$$?; adb logcat -d -b all; exit $$status; } watch: npm run watch & @@ -101,7 +104,7 @@ build/tests.jar: $(test_sources) build/junit.jar build/hamcrest.jar build/junit.jar: @mkdir -p $(@D) - curl -Ls https://github.com/junit-team/junit4/releases/download/r4.12/junit-4.12.jar > $@ + curl -Ls https://repo1.maven.org/maven2/junit/junit/4.13.2/junit-4.13.2.jar > $@ build/hamcrest.jar: @mkdir -p $(@D) diff --git a/test/bundle.js b/test/bundle.js index 22646687..f1adff7a 100644 --- a/test/bundle.js +++ b/test/bundle.js @@ -1,3 +1,3 @@ import LocalJava from 'frida-java-bridge'; -global.LocalJava = LocalJava; +globalThis.LocalJava = LocalJava; diff --git a/test/re/frida/ClassCreationTest.java b/test/re/frida/ClassCreationTest.java index 4022b6de..146f73c0 100644 --- a/test/re/frida/ClassCreationTest.java +++ b/test/re/frida/ClassCreationTest.java @@ -409,8 +409,8 @@ public void classWithUserConstructorsCanBeImplemented() throws ClassNotFoundExce private Script script = null; private void loadScript(String code) { - Script script = new Script(TestRunner.fridaJavaBundle + - ";\n(function (Java) {" + + Script script = new Script(TestRunner.buildScript( + "(function (Java) {" + "Java.perform(function () {" + "Java.classFactory.cacheDir = '" + TestRunner.getCacheDir() + "';" + @@ -421,7 +421,7 @@ private void loadScript(String code) { "').readPointer(), Java.use('java.lang.ClassLoader'));" + code + "});" + - "})(LocalJava);"); + "})(LocalJava);")); this.script = script; } diff --git a/test/re/frida/ClassInitializerTest.java b/test/re/frida/ClassInitializerTest.java new file mode 100644 index 00000000..2c7ae29d --- /dev/null +++ b/test/re/frida/ClassInitializerTest.java @@ -0,0 +1,105 @@ +package re.frida; + +import static org.junit.Assert.assertEquals; + +import org.junit.After; +import org.junit.Test; + +import java.io.IOException; + +public class ClassInitializerTest { + @Test + public void classInitializerCanBeHooked() { + loadScript("var Lazy = Java.use('re.frida.LazilyInitialized');" + + "Lazy.$clinit.implementation = function () {" + + "send('before');" + + "this.$clinit();" + + "send('after');" + + "};"); + + assertEquals("initialized", LazilyInitialized.greeting); + assertEquals("before", script.getNextMessage()); + assertEquals("after", script.getNextMessage()); + } + + @Test + public void classInitializerCanBeReplaced() { + loadScript("var Lazy = Java.use('re.frida.ReplaceablyInitialized');" + + "Lazy.$clinit.implementation = function () {" + + "send('skipped');" + + "};"); + + assertEquals(null, ReplaceablyInitialized.greeting); + assertEquals("skipped", script.getNextMessage()); + } + + @Test + public void classInitializerIsEnumerable() { + loadScript("Java.use('re.frida.EnumerablyInitialized');" + + "var names = [];" + + "Java.enumerateMethods('re.frida.EnumerablyInitialized!*').forEach(function (group) {" + + "group.classes.forEach(function (klass) {" + + "names = names.concat(klass.methods);" + + "});" + + "});" + + "send(names.indexOf('$clinit') !== -1 ? 'found' : names.join(','));"); + + assertEquals("found", script.getNextMessage()); + } + + @Test + public void classWithoutClassInitializerThrows() { + loadScript("var Bare = Java.use('re.frida.NeverInitialized');" + + "try {" + + "Bare.$clinit;" + + "send('no error');" + + "} catch (e) {" + + "send(e.message);" + + "}"); + + assertEquals("re.frida.NeverInitialized has no class initializer", + script.getNextMessage()); + } + + private Script script = null; + + private void loadScript(String code) { + Script script = new Script(TestRunner.buildScript( + "(function (Java) {" + + "Java.perform(function () {" + + code + + "});" + + "})(LocalJava);")); + this.script = script; + } + + private void unloadScript() throws IOException { + if (script != null) { + script.close(); + script = null; + } + } + + @After + public void tearDown() throws IOException { + unloadScript(); + } +} + +// A non-final static field with an initializer is enough to give a class a , +// and reading it is enough to trigger one. Each test needs its own, as a class is only +// ever initialized once. +class LazilyInitialized { + public static String greeting = "initialized"; +} + +class ReplaceablyInitialized { + public static String greeting = "initialized"; +} + +class EnumerablyInitialized { + public static String greeting = "initialized"; +} + +class NeverInitialized { +} diff --git a/test/re/frida/ClassRegistryTest.java b/test/re/frida/ClassRegistryTest.java index fa402e8a..bd47edec 100644 --- a/test/re/frida/ClassRegistryTest.java +++ b/test/re/frida/ClassRegistryTest.java @@ -158,12 +158,12 @@ public void classWrapperShouldSupportExplicitDispose() { private Script script = null; private void loadScript(String code) { - Script script = new Script(TestRunner.fridaJavaBundle + - ";\n(function (Java) {" + + Script script = new Script(TestRunner.buildScript( + "(function (Java) {" + "Java.perform(function () {" + code + "});" + - "})(LocalJava);"); + "})(LocalJava);")); this.script = script; } diff --git a/test/re/frida/MethodTest.java b/test/re/frida/MethodTest.java index 0ec16525..9d4fe9b8 100644 --- a/test/re/frida/MethodTest.java +++ b/test/re/frida/MethodTest.java @@ -582,12 +582,12 @@ private void loadScript(String code) { } private void loadScript(String code, String performMethodName) { - Script script = new Script(TestRunner.fridaJavaBundle + - ";\n(function (Java) {" + + Script script = new Script(TestRunner.buildScript( + "(function (Java) {" + "Java." + performMethodName + "(function () {" + code + "});" + - "})(LocalJava);"); + "})(LocalJava);")); this.script = script; } diff --git a/test/re/frida/Script.java b/test/re/frida/Script.java index 9fefdbaf..1e3ead38 100644 --- a/test/re/frida/Script.java +++ b/test/re/frida/Script.java @@ -35,7 +35,12 @@ public void close() throws IOException { public String getNextMessage() { try { - return pending.poll(5, TimeUnit.SECONDS); + String message = pending.poll(5, TimeUnit.SECONDS); + if (message == null) { + throw new IllegalStateException("timed out waiting for a message from the" + + " script; see stderr and logcat for errors raised by it"); + } + return message; } catch (InterruptedException e) { return getNextMessage(); } @@ -51,7 +56,8 @@ private void onMessage(String rawMessage) { } else if (type.equals("log")) { System.out.println(message.getString("payload")); } else if (type.equals("error")) { - System.err.println(message.getString("stack")); + // Not every error carries a stack, and getString() would throw right past it. + System.err.println(message.optString("stack", rawMessage)); } else { System.err.println(rawMessage); } diff --git a/test/re/frida/TestRunner.java b/test/re/frida/TestRunner.java index 6866dda6..e608e3ea 100644 --- a/test/re/frida/TestRunner.java +++ b/test/re/frida/TestRunner.java @@ -1,9 +1,12 @@ package re.frida; +import java.io.ByteArrayOutputStream; import java.io.DataInputStream; import java.io.File; import java.io.FileInputStream; +import static java.nio.charset.StandardCharsets.UTF_8; + import org.junit.runner.JUnitCore; public class TestRunner { @@ -26,10 +29,86 @@ public static void main(String[] args, String dataDir, String cacheDir, JUnitCore.main( "re.frida.ClassRegistryTest", "re.frida.MethodTest", - "re.frida.ClassCreationTest" + "re.frida.ClassCreationTest", + "re.frida.ClassInitializerTest" ); } + // Archive markers, written as escapes rather than literally, as javac is given + // no -encoding and would otherwise read this file in the platform default charset. + private static final String MAGIC = "\uD83D\uDCE6"; // PACKAGE + private static final String END_OF_HEADER = "\u2704"; // BLACK SCISSORS + private static final String ALIAS_PREFIX = "\u21BB"; // CLOCKWISE OPEN CIRCLE ARROW + private static final String BODY_DELIMITER = "\n" + END_OF_HEADER + "\n"; + private static final byte[] DELIMITER = BODY_DELIMITER.getBytes(UTF_8); + + /** + * Wraps the bundle around a snippet of test code, yielding a loadable script. + * + * frida-compile does not emit a plain script but an archive: a header naming + * each module and the exact number of bytes it occupies, followed by the module + * bodies. Code appended to such a file lands past the last body's declared + * extent, where nothing will ever parse it. It has to go inside the entrypoint + * module instead, with the length the header declares for that module fixed up + * to match. A bundle that is a plain script is handled as one. + */ + public static String buildScript(String code) { + String archive = fridaJavaBundle; + + if (!archive.startsWith(MAGIC)) { + return archive + ";\n" + code; + } + + // The first delimiter ends the header; the rest separate one body from the next. + int headerEnd = archive.indexOf(BODY_DELIMITER); + String[] lines = archive.substring(MAGIC.length() + 1, headerEnd).split("\n"); + byte[] region = archive.substring(headerEnd + BODY_DELIMITER.length()).getBytes(UTF_8); + byte[] addition = ("\n" + code).getBytes(UTF_8); + + // Walk the header as far as the entrypoint -- the first module that is not a source + // map -- summing what the modules up to and including it occupy, which is where its + // body ends. Alias lines belong to the module above them and take up no body. + int entrypoint = -1; + int bodyEnd = 0; + int modules = 0; + for (int i = 0; i != lines.length && entrypoint == -1; i++) { + String line = lines[i]; + if (line.startsWith(ALIAS_PREFIX)) { + continue; + } + + if (modules++ != 0) { + bodyEnd += DELIMITER.length; + } + + int space = line.indexOf(' '); + int length = Integer.parseInt(line.substring(0, space)); + bodyEnd += length; + + if (!line.endsWith(".map")) { + entrypoint = i; + lines[i] = (length + addition.length) + line.substring(space); + } + } + + StringBuilder header = new StringBuilder(MAGIC).append('\n'); + for (String line : lines) { + header.append(line).append('\n'); + } + header.append(END_OF_HEADER).append('\n'); + + byte[] preamble = header.toString().getBytes(UTF_8); + + ByteArrayOutputStream out = + new ByteArrayOutputStream(preamble.length + region.length + addition.length); + out.write(preamble, 0, preamble.length); + out.write(region, 0, bodyEnd); + out.write(addition, 0, addition.length); + out.write(region, bodyEnd, region.length - bodyEnd); + + return new String(out.toByteArray(), UTF_8); + } + public static String getCacheDir() { return dataDir; } diff --git a/test/runner.c b/test/runner.c index 9c41e29f..d7e92987 100644 --- a/test/runner.c +++ b/test/runner.c @@ -2,11 +2,14 @@ #include #include #include +#include #include +#include #include typedef struct _CreateScriptOperation CreateScriptOperation; typedef struct _DestroyScriptOperation DestroyScriptOperation; +typedef struct _SigchainAction SigchainAction; struct _CreateScriptOperation { @@ -31,6 +34,14 @@ struct _DestroyScriptOperation GCond cond; }; +/* Must match art::SigchainAction, as ART hands us these by pointer. */ +struct _SigchainAction +{ + bool (* sc_sigaction) (int, siginfo_t *, void *); + sigset_t sc_mask; + guint64 sc_flags; +}; + static void frida_java_init_vm (JavaVM ** vm, JNIEnv ** env, gboolean enable_optimizations); static void frida_java_register_test_runner_api (JNIEnv * env); static void frida_java_register_script_api (JNIEnv * env); @@ -54,6 +65,8 @@ static void destroy_weak_ref (jweak ref); static guint get_system_api_level (void); +static void on_special_signal (int signo, siginfo_t * info, void * context); + static const JNINativeMethod re_frida_test_runner_methods[] = { { "registerClassLoader", "(Ljava/lang/ClassLoader;)V", re_frida_test_runner_register_class_loader }, @@ -78,6 +91,9 @@ static jobject re_frida_test_runner_class_loader; static jmethodID re_frida_script_on_message_method; +static SigchainAction special_handler[NSIG]; +static struct sigaction previous_action[NSIG]; + int main (int argc, char * argv[]) { @@ -544,14 +560,70 @@ SetSpecialSignalHandlerFn (int signal, gpointer fn) /* g_print ("SetSpecialSignalHandlerFn(signal=%d)\n", signal); */ } +/* + * These two are the only part of the chain ART actually uses, and unlike the rest + * they cannot be stubbed out: our definitions shadow the real libsigchain that + * libart.so pulls in, so a handler we drop is a handler ART never installs. + * + * That was survivable while the only casualty was the SIGSEGV handler behind + * implicit null checks, which the tests never trip. It stopped being survivable + * with the userfaultfd-based GC that became the default in API level 35: ART + * registers the heap with UFFD_FEATURE_SIGBUS and depends on servicing the + * resulting SIGBUS itself, so losing that handler kills the process on the first + * compaction, wherever the mutator happens to be. + * + * The handler goes in through plain sigaction(), which Gum's exceptor intercepts + * and chains to, exactly as it would for any other process hosting a VM. + */ + void -AddSpecialSignalHandlerFn (int signal, gpointer sa) +AddSpecialSignalHandlerFn (int signal, SigchainAction * sa) { - /* g_print ("AddSpecialSignalHandlerFn(signal=%d)\n", signal); */ + struct sigaction action; + + if (signal <= 0 || signal >= NSIG || special_handler[signal].sc_sigaction != NULL) + return; + + special_handler[signal] = *sa; + + memset (&action, 0, sizeof (action)); + action.sa_sigaction = on_special_signal; + action.sa_flags = SA_SIGINFO | SA_ONSTACK; + /* + * The kernel installs this mask around the handler and takes it away again on return, + * which is what libsigchain otherwise does by hand. It also blocks the signal being + * delivered, so a handler that faults on its own account cannot arrive back here + * forever: the kernel forces the default action instead, which is the tombstone we + * want rather than a hang. + */ + action.sa_mask = sa->sc_mask; + + sigaction (signal, &action, &previous_action[signal]); } void RemoveSpecialSignalHandlerFn (int signal, bool (* fn) (int, siginfo_t *, void *)) { - /* g_print ("RemoveSpecialSignalHandlerFn(signal=%d)\n", signal); */ + if (signal <= 0 || signal >= NSIG || special_handler[signal].sc_sigaction != fn) + return; + + special_handler[signal].sc_sigaction = NULL; + + sigaction (signal, &previous_action[signal], NULL); +} + +static void +on_special_signal (int signo, siginfo_t * info, void * context) +{ + bool (* handler) (int, siginfo_t *, void *) = special_handler[signo].sc_sigaction; + + if (handler != NULL && handler (signo, info, context)) + return; + + /* + * Nobody claimed it. Put back whatever was installed before us and return, so + * that the faulting instruction runs again and crashes for real, leaving the + * tombstone we would otherwise have swallowed. + */ + sigaction (signo, &previous_action[signo], NULL); }