Frida Internal - Part 3: Java Bridge 与 ART hook
The following article is from 有价值炮灰 Author evilpan
前面的文章中介绍了 frida 的基础组件 frida-core
,用于实现进程注入、通信和管理等功能。加上 frida-gum 和 gum-js 的核心能力,我们已经可以很方便地使用 JavaScript 脚本来进行代码劫持、动态跟踪等进程分析操作。
不过 frida 并不满足于此,而是又实现了针对高级语言的支持,比如 Java
、Objective-C
、Swift
等。这些额外支持实际上是在 gum-js
的基础上针对对应高级语言的 Runtime 进行 hack 而实现的,统一称为对应语言的 bridge。例如,针对 Java 语言的封装称为 frida-java-bridge
,不仅实现了 Oracle JVM 的封装、还支持 Android 中的 Dalvik 虚拟机和 ART 虚拟机。本文就以 ART 为例来看看 frida 中的具体实现。
前情提要:
Java Bridge
frida-java-bridge[1] 就是我们在编写 frida js 脚本时使用的 Java.use
等接口实现。Java 接口是 Android Runtime 的封装,实现在 index.js
的 class Runtime
中。比如 Java.perform
就对应了 Runtime.perform
函数。在 Runtime 的构造函数中分别初始化了 api、vm 和 classFactory 属性等属性:
_tryInitialize () {
// ...
this.api = getApi();
this.vm = new VM(api);
ClassFactory._initialize(vm, api);
this.classFactory = new ClassFactory();
}
下面分别对其进行介绍。
Native API
API 是从对应 Java 虚拟机的动态库中所抽象出来的一套统一接口,用以实现对运行时、垃圾回收、堆栈管理等底层操作,是实现上层方法劫持和替换的基础设施。
在 lib/api.js
中指定了 API 的类型,如下所示:
let { getApi, getAndroidVersion } = require('./android');
try {
getAndroidVersion();
} catch (e) {
getApi = require('./jvm').getApi;
}
module.exports = getApi;
通过尝试调用 libc 的 __system_property_get
函数获取属性,如果成功表示当前进程为 Android 环境,即 Java 实现为 Dalvik 或者 ART 虚拟机;如果失败则认为是 Oracle 的 JVM 虚拟机实现。java-bridge 的实现是基于 inline-hook (gum-js) 对目标 Java 虚拟机中的符号进行解析、调用、替换从而实现 Java Hook 的功能,其支持的三种虚拟机及其对应动态库分别是:
• Dalvik: libdvm.so
• ART: libart.so
• JVM: jvm.(dll|dylib|so)
由于三种实现大同小异,因此我们只需要关注其中一种。因为笔者对于 Android 较为感兴趣,故而重点关注 ART 下的实现。在 lib/android.js
中 _getApi
同时处理了 Dalvik 和 ART 虚拟机的情况,区分这二者的方法是判断当前进程中加载的模块是 libdvm.so 还是 libart.so。
查找符号
在两种情况下都需要先获取对应动态库中的一些符号地址,对于 ART 而言,主要需要下面函数:
• JNI_GetCreatedJavaVMs
• art::JavaVMExt::AddGlobalRef
• art::IndirectReferenceTable::Add
• art::JavaVMExt::DecodeGlobal
• art::ThreadList::SuspendAll
• art::ThreadList::ResumeAll
• art::ClassLinker::VisitClasses
• art::ClassLinker::VisitClassLoaders
• art::gc::Heap::VisitObjects
• art::gc::Heap::GetInstances
• art::StackVisitor::StackVisitor
• art::StackVisitor::WalkStack
• art::StackVisitor::GetMethod
• art::StackVisitor::DescribeLocation
• art::StackVisitor::GetCurrentQuickFrameInfo
• art::Thread::GetLongJumpContext
• art::mirror::Class::GetDescriptor
• art::ArtMethod::PrettyMethod
• art::ArtMethod::PrettyMethodNullSafe
• art::Thread::CurrentFromGdb
• art::mirror::Object::Clone
• art::Dbg::SetJdwpAllowed
• art::Dbg::ConfigureJdwp
• art::InternalDebuggerControlCallback::StartDebugger
• art::Dbg::StartJdwp
• art::Dbg::GoActive
• art::Dbg::RequestDeoptimization
• art::Dbg::ManageDeoptimization
• art::Instrumentation::EnableDeoptimization
• art::Instrumentation::DeoptimizeEverything
• art::Runtime::DeoptimizeBootImage
• art::Instrumentation::Deoptimize
• art::jni::JniIdManager::DecodeMethodId
• art::interpreter::GetNterpEntryPoint
• art::Monitor::TranslateLocation
以及两个用于判断调试器状态的变量:
• art::Dbg::gRegistry - 判断 JDWP 是否启动
• art::Dbg::gDebuggerActive - 判断是否正在调试
值得一提的是,这两个变量也可以用在安全 SDK 中作为一种隐秘的反调试手法。
上面的函数通过在模块中的符号中查找到对应地址后 (如 Module.enumerateExports("libart.so")
),便将其转换为 NativeFunction
类然后保存到 temporaryApi 字典中留作备用。
查找偏移
在找到上述的函数和变量地址后,就可以通过这些 Native 函数获取需要的信息,首先第一步是获取当前进程中所有创建的 Java 虚拟机。这里使用的是 JavaScript 代码去调用,乍看起来有点别扭,但这是做 Native 调用的常规操作:
const vms = Memory.alloc(pointerSize);
const vmCount = Memory.alloc(jsizeSize);
temporaryApi.JNI_GetCreatedJavaVMs(vms, 1, vmCount);
if (vmCount.readInt() === 0)
return null;
temporaryApi.vm = vms.readPointer();
该函数原型为:
jint JNI_GetCreatedJavaVMs(JavaVM **vmBuf, jsize bufLen, jsize *nVMs);
虽然参数说可以返回多个 VM,但在单个进程中是不支持创建多个 Java 虚拟机的,因此这里也只用获取一个 VM 即可。获取到的 Java 虚拟机,(即 JavaVM *
地址) 保存在 temporaryApi.vm 变量中,这是我们一系列后续操作的基础。
通过获得的 JavaVM 指针,我们可以获取一系列重要数据结构的地址,比如:
• art::Runtime
• art::Instrumentation
• art::Heap
• art::ThreadList
• art::ClassLinker
• 各个 trampoline 的地址
• ...
其中的一些是通过结构体的固定偏移来获得,比如 art::Runtime 就在 JavaVM 结构的第二个位置:
struct _JavaVM {
const struct JNIInvokeInterface* functions;
}
class JavaVMExt : public JavaVM {
public:
// 一些函数 ...
Runtime* const runtime_;
}
用 gdb 也可以进行验证:
(gdb) ptype /o JavaVM
type = struct _JavaVM {
/* 0 | 8 */ const struct JNIInvokeInterface *functions;
/* total size (bytes): 8 */
}
(gdb) ptype /o art::JavaVMExt
/* offset | size */ type = class art::JavaVMExt : public JavaVM {
private:
/* 8 | 8 */ class art::Runtime * const runtime_;
...
但是对于一些复杂的数据结构就没有那么简单了,比如在 art::Runtime
结构体中查找 heap、threadList、classLinker 等属性的时候,就需要先搜索到特定的属性位置,然后通过不同版本的相对偏移去定位其他属性:
class Runtime {
// ...
gc::Heap* heap_; // <-- we need to find this
std::unique_ptr<ArenaPool> jit_arena_pool_; // <----- API level >= 24
std::unique_ptr<ArenaPool> arena_pool_; // __
std::unique_ptr<ArenaPool> low_4gb_arena_pool_;// <--|__ API level >= 23
std::unique_ptr<LinearAlloc> linear_alloc_; // \_
size_t max_spins_before_thin_lock_inflation_;
MonitorList* monitor_list_;
MonitorPool* monitor_pool_;
ThreadList* thread_list_; // <--- and these
InternTable* intern_table_; // <--/
ClassLinker* class_linker_; // <-/
SignalCatcher* signal_catcher_;
std::unique_ptr<jni::JniIdManager> jni_id_manager_; // <- API level >= 30 or Android R Developer Preview
bool use_tombstoned_traces_; // <-------------------- API level 27/28
std::string stack_trace_file_; // <-------------------- API level <= 28
JavaVMExt* java_vm_; // <-- so we find this then calculate our way backwards
// ...
}
由于 JavaVM 的地址我们已知, 故而可以在 Runtime 的起始地址处开始一直往前搜索,发现匹配后就可以确定 java_vm_
属性的位置,进而可以根据相对偏移往前找到 InternTable、ClassLinker、ThreadList、gc::Heap 等属性地址。之所以这样查找是因为 art::Runtime 这个数据结构相当复杂,总大小有 2000 字节以上,搜索 java_vm_
的时候也做了一些优化,从第 50 个指针大小位置处开始搜索,以加快搜索速度。
上面这些偏移都还算好找,但是在寻找 Instrumentation 偏移的时候就需要一些额外的技巧,当前 frida 使用的方法是先找到 libart.so 的 MterpHandleException
函数地址,由于该函数中会使用到 Runtime->instrumentation
的数据,因此逐条解析汇编指令即可定位到对应偏移。
该函数定义在 mterp.cc 中,代码如下:
extern "C" size_t MterpHandleException(Thread* self, ShadowFrame* shadow_frame)
REQUIRES_SHARED(Locks::mutator_lock_) {
DCHECK(self->IsExceptionPending());
const instrumentation::Instrumentation* const instrumentation =
Runtime::Current()->GetInstrumentation();
return MoveToExceptionHandler(self, *shadow_frame, instrumentation);
}
虽然道理很简单,但实际操作时候需要区分不同架构的指令解析方法,frida 中支持以下几种架构:
const instrumentationOffsetParsers = {
ia32: parsex86InstrumentationOffset,
x64: parsex86InstrumentationOffset,
arm: parseArmInstrumentationOffset,
arm64: parseArm64InstrumentationOffset
};
以 arm64 为例,观察该函数,可以通过寻找 add
指令来定位偏移,因为 GetInstrumentation
等函数实际上都被 inline 优化成了直接指令,如下所示,实际偏移即为 0x2b8
:
(gdb) disassemble MterpHandleException
Dump of assembler code for function MterpHandleException(art::Thread*, art::ShadowFrame*):
0x00000000007551ac <+0>: stp x29, x30, [sp, #-16]!
0x00000000007551b0 <+4>: mov x29, sp
0x00000000007551b4 <+8>: adrp x8, 0xa17000 <_ZN3art2gc9allocator8RosAlloc27dedicated_full_run_storage_E+3464>
0x00000000007551b8 <+12>: ldr x8, [x8, #3280]
0x00000000007551bc <+16>: add x2, x8, #0x2b8 // <-- 这里即为所求偏移!
0x00000000007551c0 <+20>: bl 0x3e1170 <art::interpreter::MoveToExceptionHandler(art::Thread*, art::ShadowFrame&, art::instrumentation::Instrumentation const*)>
0x00000000007551c4 <+24>: and x0, x0, #0x1
0x00000000007551c8 <+28>: ldp x29, x30, [sp], #16
0x00000000007551cc <+32>: ret
End of assembler dump.
frida 的代码实现为:
function parseArm64InstrumentationOffset (insn) {
if (insn.mnemonic !== 'add') {
return null;
}
const ops = insn.operands;
if (ops.length !== 3) {
return null;
}
const op2 = ops[2];
if (op2.type !== 'imm') {
return null;
}
return op2.value;
}
其中 insn
是 Instruction
类型,是 frida 中用以解析汇编指令的关键类,这里不再赘述。另外一个属性 jni_ids_indirection_
的查找也是类似套路,有点类似漏洞利用过程中寻找 gadget 的过程。
Trampoline
根据上面获取到的各个重要字段在 art::Runtime 中的偏移后,就可以计算出这些属性在目标内存中的实际地址了。另外一个重要的数据结构是 art::ClassLinker,其中包含了 ART hook 所需要的几个重要的共享跳板代码地址,即 trampoline,如下所示:
class ClassLinker {
...
InternTable* intern_table_; // <-- We find this then calculate our way forwards
const void* quick_resolution_trampoline_;
const void* quick_imt_conflict_trampoline_;
const void* quick_generic_jni_trampoline_; // <-- ...to this
const void* quick_to_interpreter_bridge_trampoline_;
...
}
intern_table_
的地址我们已经在 art::Runtime 中获得了,因此可以用前面类似的搜索方法依次找出这几个 trampoline 的地址。需要注意的是在 Android 5.x 和 6.x 之后 ClassLinker 的数据结构有所不同,frida 针对这两种情况都分别进行了处理。
quick trampoline 一般是指被编译成 native 代码后的字节码在运行过程中所使用到的跳转地址表,比如 quick_resolution_trampoline_
所指向的 stub 作用就是给(native 代码)第一次调用某个方法时候解析指定方法,同理 generic_jni_trampoline
就是用来跳转到 JNI 方法(代码) 的 stub,quick_to_interpreter_bridge_trampoline_
则是用来从 native 代码跳转到解释器执行的 stub。
其他
在获取完各个地址后,android.js
中初始化了一个全局的变量 artController,其中主要包含一个 CModule,用以保存、搜索、当前进程的 hook 方法信息,方法的值是 ArtMethod 指针,数据保存在全局的线程安全哈希表中。C 代码的部分关键实现如下:
void init (void) {
g_mutex_init (&lock);
methods = g_hash_table_new_full (NULL, NULL, NULL, NULL);
replacements = g_hash_table_new_full (NULL, NULL, NULL, NULL);
}
void set_replacement_method (gpointer original_method, gpointer replacement_method) {
g_mutex_lock (&lock);
g_hash_table_insert (methods, original_method, replacement_method);
g_hash_table_insert (replacements, replacement_method, original_method);
g_mutex_unlock (&lock);
}
同时在 CModule 中还定义若干个 native 方法用以实现一些高频调用的函数 hook,比如:
• on_interpreter_do_call
• on_art_method_get_oat_quick_method_header
• on_art_method_pretty_method
• on_leave_gc_concurrent_copying_copying_phase
最后获取了 C++ 的 new 和 delete 函数地址,以及根据运行时确定 MethodMangler
所属类,完成整个 API 的初始化。MethodMangler 类是对一个指定 Java 方法的封装,实现了调用、劫持、还原等操作,在后文还会继续进行介绍。
VM
VM 顾名思义,是对 Java 虚拟机的封装,更准确地说是实现了对 JavaVM *
的封装,包括下面的接口:
• attachCurrentThread
• detachCurrentThread
• getEnv
• ...
值得一提的是初始化的地方:
function initialize () {
const vtable = handle.readPointer();
const options = {
exceptions: 'propagate'
};
attachCurrentThread = new NativeFunction(vtable.add(4 * pointerSize).readPointer(), 'int32', ['pointer', 'pointer', 'pointer'], options);
detachCurrentThread = new NativeFunction(vtable.add(5 * pointerSize).readPointer(), 'int32', ['pointer'], options);
getEnv = new NativeFunction(vtable.add(6 * pointerSize).readPointer(), 'int32', ['pointer', 'pointer', 'int32'], options);
}
handle
是构造函数传入的 api.vm
,即前文说到的 JNI_GetCreatedJavaVMs
调用的结果,其指针类型是 JavaVM *
,内存布局如下所示:
#ifdef __cplusplus
typedef JavaVM_ JavaVM;
#else
typedef const struct JNIInvokeInterface_ *JavaVM;
#endif
struct JavaVM_ {
const struct JNIInvokeInterface_ *functions;
#ifdef __cplusplus
jint DestroyJavaVM() {
return functions->DestroyJavaVM(this);
}
jint AttachCurrentThread(void **penv, void *args) {
return functions->AttachCurrentThread(this, penv, args);
}
jint DetachCurrentThread() {
return functions->DetachCurrentThread(this);
}
jint GetEnv(void **penv, jint version) {
return functions->GetEnv(this, penv, version);
}
jint AttachCurrentThreadAsDaemon(void **penv, void *args) {
return functions->AttachCurrentThreadAsDaemon(this, penv, args);
}
#endif
};
这是兼容 C 和 C++ 的写法,由于 ART 虚拟机是用 C++ 写的,因此要想找到这几个函数的地址,首先需要获得 JNIInvokeInterface_
的地址,其正好是 JavaVM
的第一个元素。然后再根据下面的偏移即可获取到 AttachCurrentThread
等函数的实际地址。
struct JNIInvokeInterface_ {
void *reserved0;
void *reserved1;
void *reserved2;
jint (JNICALL *DestroyJavaVM)(JavaVM *vm);
jint (JNICALL *AttachCurrentThread)(JavaVM *vm, void **penv, void *args);
jint (JNICALL *DetachCurrentThread)(JavaVM *vm);
jint (JNICALL *GetEnv)(JavaVM *vm, void **penv, jint version);
jint (JNICALL *AttachCurrentThreadAsDaemon)(JavaVM *vm, void **penv, void *args);
};
ClassFactory
在 Runtime 中最后一个初始化的属性是 ClassFactory
,虽然它其貌不扬,但却是很多核心操作的实施者,比如通过类名获取目标类的接口 Java.use
,又或者是获取某个类在内存中实例的接口 Java.choose
,等等。
在平时对一些 APP 进行测试的时候,我们会发现 frida 对于一些加壳的 APP 也同样能够进行注入和劫持,即便是类似 libjiagu 这种动态修改 ClassLoader 的壳,那么 frida 是如何查找类的呢?或者说,是如何找到被加固的类所属的 ClassLoader 的呢?还有 Java.choose
为什么可以找到类的示例,原理是什么?……
类查找
了解 frida 的人可能都知道,我们一般使用 Java.use
来获取目标类的 Wrapper,其实现为:
use (className, options = {}) {
const allowCached = options.cache !== 'skip';
let C = allowCached ? this._getUsedClass(className) : undefined;
if (C === undefined) {
try {
const env = vm.getEnv();
const { _loader: loader } = this;
const getClassHandle = (loader !== null)
? makeLoaderClassHandleGetter(className, loader, env)
: makeBasicClassHandleGetter(className);
C = this._make(className, getClassHandle, env);
} finally {
if (allowCached) {
this._setUsedClass(className, C);
}
}
}
return C;
}
可能很多人都不知道 use 还可以携带第二个 options 参数,可用于指定是否缓存查找的结果。除去缓存的逻辑,如果没有特殊指定 loader,那么将会使用 makeBasicClassHandleGetter
来获取对应类的查找方法:
function makeBasicClassHandleGetter (className) {
const canonicalClassName = className.replace(/\./g, '/');
return function (env) {
const tid = getCurrentThreadId();
ignore(tid);
try {
return env.findClass(canonicalClassName);
} finally {
unignore(tid);
}
};
}
这里需要注意的是使用了 env.findClass
来获取类 handle,从名称看也能猜出这是 env->FindClass
方法,所以 handle 本身是一个 jclass
封装后的对象。_make
方法简化如下:
_make (name, getClassHandle, env) {
const C = makeClassWrapperConstructor();
const proto = Object.create(Wrapper.prototype, { ... });
C.prototype = proto;
const classWrapper = new C(null);
proto.$w = classWrapper;
const h = classWrapper.$borrowClassHandle(env);
const classHandle = h.value;
ensureClassInitialized(env, classHandle);
proto.$l = ClassModel.build(classHandle, env);
return classWrapper;
}
上面省略了一些初始化和异常处理的代码,重要的是最后两个语句,ensureClassInitialized 确保对应类被 Java 虚拟机成功加载:
function ensureClassInitialized (env, classRef) {
const api = getApi();
if (api.flavor !== 'art') {
return;
}
env.getFieldId(classRef, 'x', 'Z');
env.exceptionClear();
}
实现上就是通过 JNI 函数 GetFieldID
来试着取一下目标类的属性,即便该属性不存在,也会触发该类的初始化,当然还需要清理一下 NoSuchFieldException
异常。
类模型
env->FindClass
仅能找到一个 jclass,frida 在此基础上封装了一个类模型,使用 ClassModel.build
进行创建,其定义在 lib/class-model.js
中。在 Model 里定义了一个 CModule,与 Native 交互比较多或者调用频繁的代码直接使用 C 语言实现(这里主要原因是前者),包含的函数有:
• model_new/has/find/list - 用于创建、查找、列举自定义的
Model *
信息;• enumerate_methods_art/jvm - 用于获取指定类的所有方法;
• dalloc - 使用
g_free
释放堆内存;
看过 ART 源码的同学应该比较熟悉,jclass
本质上是对应 art::mirror::Class
在内存中的地址,在 AOSP 中一般通过下述方式进行转换和使用:
static void SomeFunc(JNIEnv *env, jclass javaClass) {
ScopedObjectAccess soa(env);
ObjPtr<mirror::Class> c = soa.Decode<mirror::Class>(javaClass);
}
有了 mirror::Class
之后,就可以通过以下的字段去进一步获取类相关信息:
// C++ mirror of java.lang.Class
class MANAGED Class final : public Object {
// ...
uint64_t ifields_;
uint64_t methods_;
uint64_t sfields_;
uint32_t access_flags_;
// ...
uint16_t copied_methods_offset_;
}
frida 中根据不同版本的 ART 写死了 ifields_
的偏移,并以此往后计算其他字段,但是 copied_methods_offset_
除外,因为其位置并不固定在上述字段具体位置,因此也需要通过不同的 ART 版本进行确定。
• ifields_ 表示该类的实例属性(instance fields),不包含父类所定义的属性。该值为一个指针,指向一个带有长度前缀的
ArtFields
数组;• methods_ 指针同样是带长度前缀的数组,元素类型为
ArtMethod
,其中包括所有该类定义的方法。•
virtual_methods_offset_
之前的是直接方法;•
virtual_methods_offset_
与copied_methods_offset_
之间的是抽象方法;•
copied_methods_offset_
之后的是从接口复制过来的默认方法;• sfields_ 和 ifields 类似,区别是表示静态属性;
frida 中对类模型的封装是如下:
class Model {
static build (handle, env) {
ensureInitialized(env);
return unwrap(handle, env, object => {
return new Model(cm.new(handle, object, env));
});
}
}
cm.new
对应 CModule 的 C 函数:
Model * model_new (jclass class_handle, gpointer class_object, JNIEnv * env) {
model = g_new (Model, 1);
members = g_hash_table_new_full (g_str_hash, g_str_equal, g_free, g_free);
model->members = members;
elements = read_art_array (class_object, art_api.class_offset_methods, sizeof (gsize), NULL);
n = *(guint16 *) (class_object + art_api.class_offset_copied_methods_offset);
// 读取方法
for (i = 0; i != n; i++) {
jmethodID id;
guint32 access_flags;
jboolean is_static;
jobject method, name;
const char * name_str;
jint modifiers;
id = elements + (i * art_api.method_size);
access_flags = *(guint32 *) (id + art_api.method_offset_access_flags);
if ((access_flags & kAccConstructor) != 0)
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);
}
// 读取属性
for (field_array_cursor = 0; field_array_cursor != G_N_ELEMENTS (field_arrays); field_array_cursor++) {
// ...
model_add_field (model, name_str, id, modifiers);
}
return model;
}
可见,对应 Class 的方法和属性都会保存在 Model 中的哈希表中,在我们使用对应 ClassWrapper 访问属性时候就可以通过接口进行快速地查找。注意这里哈希表的 key 是方法名称,value 是方法类型、jmethodId
以及重载信息,后续需要调用或者 hook 对应方法的时候才进行实际的方法模型构建操作。
ART Hook
前面介绍完了 Java.use
的具体实现,即类的查找和封装操作,下一步自然是针对方法的实现分析。前面分析 API 初始化的末尾处我们看到指定了 MethodMangler 为 ArtMethodMangler
作为方法的封装,本节介绍其具体实现。
MethodMangler
MethodMangler 同样是 API 定义的一个工具类,主要功能是用于实现 Java 方法封装和 Hook 操作。
在 Android 中分为 ART 和 Dalvik 两种实现,二者都提供了相同的接口:
• constructor (methodId)
• replace (impl, isInstanceMethod, argTypes, vm, api)
• revert (vm)
• resolveTarget (wrapper, isInstanceMethod, env, api)
每次 hook 指定方法的时候都会使用对应方法的 methodId 构造一个 MethodMangler,然后使用 replace 函数来替换目标方法实现;revert 则用来恢复原来的方法。
回忆使用 frida 进行 Android hook 的典型操作:
const Activity = Java.use("com.evilpan.MainActivity");
Activity.onCreate.implementation = function(bundle) {
console.log("We're in!");
return this.onCreate(bundle);
}
类初始化的部分前面已经介绍过了,第二行通过指定方法的 implementation 属性赋值来实现函数替换的操作本质上就是通过 MethodMangler 完成的,代码在 lib/class-factory.js
,关键部分如下:
methodPrototype = Object.create(Function.prototype, {
methodName: {
enumerable: true,
get () {
return this._p[0];
}
},
// holder: {},
// type: {},
// handle: {},
implementation: {
enumerable: true,
get () {
const replacement = this._r;
return (replacement !== undefined) ? replacement : null;
},
set (fn) {
const params = this._p;
const holder = params[1];
const type = params[2];
if (type === CONSTRUCTOR_METHOD) {
throw new Error('Reimplementing $new is not possible; replace implementation of $init instead');
}
const existingReplacement = this._r;
if (existingReplacement !== undefined) {
holder.$f._patchedMethods.delete(this);
const mangler = existingReplacement._m;
mangler.revert(vm);
this._r = undefined;
}
if (fn !== null) {
const [methodName, classWrapper, type, methodId, retType, argTypes] = params;
const replacement = implement(methodName, classWrapper, type, retType, argTypes, fn, this);
const mangler = makeMethodMangler(methodId);
replacement._m = mangler;
this._r = replacement;
mangler.replace(replacement, type === INSTANCE_METHOD, argTypes, vm, api);
holder.$f._patchedMethods.add(this);
}
}
},
// returnType: {},
// argumentTypes: {},
// canInvokeWith: {},
// clone: {},
// invoke: {}
});
当对目标方法赋值时,调用了 set 方法,首先会判断是否之前有替换过,如果有的话直接将原 hook 函数替换,从这里可以看出 frida 在 针对 Java 目前还没有实现多重 hook;对于未 hook 情况,会创建对应的 MethodMangler 并保存到 Method._r._m
中。
值得注意的是 Method._p
属性,这是一个数组,依次包含对应方法的信息:
idx | meaning |
0 | methodName |
1 | classWrapper |
2 | type |
3 | methodId |
4 | retType |
5 | argTypes |
6 | jniCall |
7 | ... |
replacement
将用户提供的 hook JavaScript 方法封装后转换成一个 NativeCallbacks
进行后续处理,而实际替换则使用 mangler.replace
进行实现。
方法封装
上面传给 MehtodMangler 的 methodId 对应 JNI 的 jmethodId,那么问题来了,jmethodId 如何转换成 ArtMethod
指针呢?直觉认为应该和 jclass
差不多,直接强制转换就行了,很可惜这个直觉只对了一半,对于旧版本的 ART 这个猜测是正确的,但是在新版本中进行了部分重构。frida 中使用的是之前保存的一个 ART 函数 art::jni::JniIdManager::DecodeMethodId
来实现。
通过查看 Android 源码可以发现,该方法在一部分情况下是可以直接转换的,但是当使用摇色子模式且 jmethodId 是奇数时,会使用一个静态的哈希表来获取对应的原始指针,如下所示:
template <typename ArtType> ArtType* JniIdManager::DecodeGenericId(uintptr_t t) {
if (Runtime::Current()->GetJniIdType() == JniIdType::kIndices && (t % 2) == 1) {
ReaderMutexLock mu(Thread::Current(), *Locks::jni_id_lock_);
size_t index = IdToIndex(t);
DCHECK_GT(GetGenericMap<ArtType>().size(), index);
return GetGenericMap<ArtType>().at(index);
} else {
DCHECK_EQ((t % 2), 0u) << "id: " << t;
return reinterpret_cast<ArtType*>(t);
}
}
ArtMethod* JniIdManager::DecodeMethodId(jmethodID method) {
return DecodeGenericId<ArtMethod>(reinterpret_cast<uintptr_t>(method));
}
由于转换方式相对繁琐,因此 frida 没有自己计算,而是通过 JniIdManager 的成员方法来辅助获取。
方法替换
回顾上文介绍的 implementaion.set
,其中 Java 方法替换主要就是通过 MethodMangler
的 replace
的方法实现的,这其中是 frida 进行 ART hook 的主要逻辑,需要我们重点关注。
其实在之前的文章 “ART 在 Android 安全攻防中的应用” 中已经简要介绍过了 ART Hook 的大致流程。这里再深入介绍一次,当然主要原因是我自己忘得差不多了,学而时习之,不亦悦乎?
在 ART 虚拟机中,对于方法的调用,大部分会调用到 ArtMethod::Invoke
,因此我们可以以此为起点理解实际调用的过程。注意是大部分而不是所有,后面会解释为什么。
Invoke 的核心逻辑如下:
void ArtMethod::Invoke(Thread* self, uint32_t* args, uint32_t args_size, JValue* result, const char* shorty) {
if (UNLIKELY(!runtime->IsStarted() || (self->IsForceInterpreter() && !IsNative() && !IsProxyMethod() && IsInvokable()))) {
if (IsStatic()) {
art::interpreter::EnterInterpreterFromInvoke(
self, this, nullptr, args, result, /*stay_in_interpreter=*/ true);
} else {
mirror::Object* receiver = reinterpret_cast<StackReference<mirror::Object>*>(&args[0])->AsMirrorPtr();
art::interpreter::EnterInterpreterFromInvoke(self, this, receiver, args + 1, result, /*stay_in_interpreter=*/ true);
}
} else {
if (!IsStatic()) {
(*art_quick_invoke_stub)(this, args, args_size, self, result, shorty);
} else {
(*art_quick_invoke_static_stub)(this, args, args_size, self, result, shorty);
}
}
}
主要分为两种情况,一种是 ART 未初始化完成或者系统配置强制以解释模式运行,此时则进入解释器;另一种情况是有 native 代码时,比如 JNI 代码、OAT 提前编译过的代码或者 JIT 运行时编译过的代码以及代理方法等,此时则直接跳转到 invoke_stub 去执行。
对于解释执行的情况,也细分为两种情况,一种是真正的解释执行,不断循环解析 CodeItem 中的每条指令并进行解析;另外一种是在当前解释执行遇到 native 方法时,这种情况一般是遇到了 JNI 函数,这时则通过 method->GetEntryPointFromJni()
获取对应地址进行跳转,所跳转的地址为:
class ArtMethod final {
// ...
struct PtrSizedFields {
// Depending on the method type, the data is
// - native method: pointer to the JNI function registered to this method
// or a function to resolve the JNI function,
// - resolution method: pointer to a function to resolve the method and
// the JNI function for @CriticalNative.
// - conflict method: ImtConflictTable,
// - abstract/interface method: the single-implementation if any,
// - proxy method: the original interface method or constructor,
// - other methods: during AOT the code item offset, at runtime a pointer
// to the code item.
void* data_;
// Method dispatch from quick compiled code invokes this pointer which may cause bridging into
// the interpreter.
void* entry_point_from_quick_compiled_code_;
} ptr_sized_fields_;
// ...
};
而对于快速执行的模式是跳转到 stub 代码,以非静态方法为例,该 stub 定义在 art/runtime/arch/arm64/quick_entrypoints_arm64.S 文件中,大致作用是将参数保存在对应寄存器中,然后跳转到实际的地址执行:
.macro INVOKE_STUB_CALL_AND_RETURN
REFRESH_MARKING_REGISTER
REFRESH_SUSPEND_CHECK_REGISTER
// load method-> METHOD_QUICK_CODE_OFFSET
ldr x9, [x0, #ART_METHOD_QUICK_CODE_OFFSET_64]
// Branch to method.
blr x9
// Pop the ArtMethod* (null), arguments and alignment padding from the stack.
mov sp, xFP
// ...
.endm
x0
的值保存着 ArtMethod 的地址,实际跳转的是对于 ArtMethod 的某个偏移,这个偏移定义在 art/tools/cpp-define-generator/art_method.def:
ASM_DEFINE(ART_METHOD_QUICK_CODE_OFFSET_64,
art::ArtMethod::EntryPointFromQuickCompiledCodeOffset(art::PointerSize::k64).Int32Value())
对应的正好是 entry_point_from_quick_compiled_code_
属性的偏移:
static constexpr MemberOffset EntryPointFromQuickCompiledCodeOffset(PointerSize pointer_size) {
return MemberOffset(PtrSizedFieldsOffset(pointer_size) + OFFSETOF_MEMBER(
PtrSizedFields, entry_point_from_quick_compiled_code_) / sizeof(void*)
* static_cast<size_t>(pointer_size));
}
因此,不管是解释模式还是其他模式,只要目标方法有 native 代码,那么该方法的代码地址都是会保存在 entry_point_from_quick_compiled_code_
字段,只不过这个字段的含义在不同的场景中略有不同。
所以我们若想要实现 ART Hook,理论上只要找到对应方法在内存中的 ArtMethod 地址,然后替换其 entrypoint 的值即可。但是前面说过,并不是所有方法都会走到 ArtMethod::Invoke
。比如对于系统函数的调用,OAT 优化时会直接将对应系统函数方法的调用替换为汇编跳转,跳转的目的就是就是对应方法的 entrypoint,因为 boot.oat
由 zygote 加载,对于所有应用而言内存地址都是固定的,因此 ART 可以在优化过程中省略方法的查找过程从而直接跳转。
在这样的前提下,再让我们回到 frida 的代码,看它如何能实现针对包括系统函数在内所有方法的 hook。
首先 MethodMangler 接受的是 JNI 层的 jmethodId
,通过前面介绍过的转换方法获取到了原始的 ArtMethod
指针,并保存到 methodId
属性中:
class ArtMethodMangler {
constructor (opaqueMethodId) {
const methodId = unwrapMethodId(opaqueMethodId);
this.methodId = methodId;
this.originalMethod = null;
this.hookedMethodId = methodId;
this.replacementMethodId = null;
this.interceptor = null;
}
当然 ArtMethod 结构体在不同版本中也是不同的,因此 frida 使用了一种特别的搜索方法来确定 jniCode(即data_
)、quickCode、interpreterCode、accessFlag 的偏移,以及 ArtMethod 结构的大小。这些信息保存在 originalMethod
字段。
在执行方法替换的时候,会将原方法拷贝一份,地址保存在 replacementMethodId
中,然后对拷贝的方法进行 patch,主要修改以下几个字段:
patchArtMethod(replacementMethodId, {
jniCode: impl,
accessFlags: ((originalFlags & ~(kAccCriticalNative | kAccFastNative | kAccNterpEntryPointFastPathFlag)) | kAccNative) >>> 0,
quickCode: api.artClassLinker.quickGenericJniTrampoline,
interpreterCode: api.artInterpreterToCompiledCodeBridge
}, vm);
jniCode 替换为用户指定的 js 函数封装而成的 NativeFunction,并将 accessFlags 设置为 kAccNative
,即 JNI 方法。quickCode 和 interpreterCode 分别是 Quick 模式和解释器模式的入口,替换为了上文中查找保存的 trampoline,令 Quick 模式跳转到 JNI 入口,解释器模式跳转到 Quick 代码,这样就实现了该方法的拦截,每次执行都会当做 JNI 函数执行到 jniCode 即我们替换的代码中。
这样就完成了吗?基本功能已经实现了,但是还要处理一些特殊的情况。比如防止解释器模式进入 fast_path
:
let hookedMethodRemovedFlags = kAccFastInterpreterToInterpreterInvoke | kAccSingleImplementation | kAccNterpEntryPointFastPathFlag;
if ((originalFlags & kAccNative) === 0) {
hookedMethodRemovedFlags |= kAccSkipAccessChecks;
}
patchArtMethod(hookedMethodId, {
accessFlags: (originalFlags & ~(hookedMethodRemovedFlags)) >>> 0
}, vm);
另外,在 Android 8.0 后的 ART 中,使用了新一代的解释器 Nterp(Next-Generation Interpreter)[2],直接使用汇编实现以提高执行性能和内存使用。
如果待 hook 方法是解释执行,且使用了 nterp,那么 frida 会将其入口替换为 art_quick_to_interpreter_bridge
来跳出解释器,并进入到实际的 quickCode 去执行,从而让我们的方法替换功能保持完整。
const { artNterpEntryPoint } = api;
if (artNterpEntryPoint !== undefined && quickCode.equals(artNterpEntryPoint)) {
patchArtMethod(hookedMethodId, {
quickCode: api.artQuickToInterpreterBridge
}, vm);
}
虽然此时我们已经将目标 ArtMethod 改成了 Native 方法,且 JNI 的入口指向我们的 hook 函数,但如果该方法已经被 OAT 或者 JIT 优化成了二进制代码,此时在字节码层调用 invoke-xxx
时会通过方法的 entry_point_from_quick_compiled_code_
直接跳转到 native 代码执行,而不是 quick_xxx_trampoline
。
因此对于这种情况,我们可以将 entrypoint 的地址重新指向 trampoline,但如前文所说,对于系统函数而言,其地址已知,因此调用方被优化后很可能直接就调转到了对应的 native 地址,而不会通过 entrypoint 去查找。因此 frida 采用的方法是直接修改目标方法的 quickCode 内容,将其替换为一段跳板代码,然后再间接跳转到我们的劫持实现中。
以 ARM64 为例,trampoline 的部分代码如下所示:
Memory.patchCode(trampoline, 256, code => {
const writer = new Arm64Writer(code, { pc: trampoline });
const relocator = new Arm64Relocator(target, writer);
// Save FPRs.
writer.putPushRegReg('d0', 'd1');
writer.putPushRegReg('d2', 'd3');
writer.putPushRegReg('d4', 'd5');
writer.putPushRegReg('d6', 'd7');
// Save core args, callee-saves & LR.
writer.putPushRegReg('x1', 'x2');
writer.putPushRegReg('x3', 'x4');
writer.putPushRegReg('x5', 'x6');
writer.putPushRegReg('x7', 'x20');
writer.putPushRegReg('x21', 'x22');
writer.putPushRegReg('x23', 'x24');
writer.putPushRegReg('x25', 'x26');
writer.putPushRegReg('x27', 'x28');
writer.putPushRegReg('x29', 'lr');
// Save ArtMethod* + alignment padding.
writer.putSubRegRegImm('sp', 'sp', 16);
writer.putStrRegRegOffset('x0', 'sp', 0);
writer.putCallAddressWithArguments(artController.replacedMethods.findReplacementFromQuickCode, ['x0', 'x19']);
// 保存调用结果,恢复寄存器 ...
}
quickCode 的替换使用使用 ArtQuickCodeInterceptor
完成,姑且理解为跳板管理器,保存在 MethodMangler 的 interceptor 属性中,这样在程序退出或者用户调用 method.implementation = null
时还会调用 interceptor.deactivate
恢复方法,防止残留 hook 代码导致的运行时崩溃。
总结
本文介绍了 frida 针对 ART 运行时的实现,其基本思路是通过运行时中的私有函数来与对应的 Java 虚拟机进行交互,其中大部分操作都基于 gum-js 接口进行实现,包括 Java 类和方法的封装、ART hook 以及 trampoline 汇编的编写等。除了 ART 还实现了 Dalvik 和 JVM 这两个虚拟机的接口,流程大同小异。甚至对于其他语言来说也是类似的,这也得益于 frida 开源社区的活跃性,相信未来会出现对更多高级语言的支持。
参考链接
• frida-java-bridge[3]
• frida JavaScript API[4]
• SandHook - Docs[5]
• 在Android N上对Java方法做hook遇到的坑[6]
• 我为Dexposed续一秒——论ART上运行时 Method AOP实现[7]
引用链接
[1]
frida-java-bridge: https://github.com/frida/frida-java-bridge[2]
Nterp(Next-Generation Interpreter): https://source.android.com/devices/tech/dalvik/improvements#interpreter-performance[3]
frida-java-bridge: https://github.com/frida/frida-java-bridge[4]
frida JavaScript API: https://frida.re/docs/javascript-api/[5]
SandHook - Docs: https://github.com/asLody/SandHook/blob/master/doc/doc.md[6]
在Android N上对Java方法做hook遇到的坑: http://rk700.github.io/2017/06/30/hook-on-android-n/[7]
我为Dexposed续一秒——论ART上运行时 Method AOP实现: https://weishu.me/2017/11/23/dexposed-on-art/