plthook技术在native的hook代码上起到关键的作用,native的函数调用分为内部调用和外部调用。内部调用指的是so内部的方法调用,当so被打包好后,so内部的方法都会被分配一个偏移地址,所以如果是内部函数的调用,那么直接通过编译期间给函数分配偏移地址就能去调用内部的其它方法。 如果是外部调用,也就是调用其它so的方法,就只能通过绝对地址来调用了,也就是该外部so的首地址+函数的偏移地址。我们通过一个例子来说明外部调用的过程:
|
|
这里我在jni的方法MallocLeak方法中调用了malloc方法,它是lic.so库中的方法,所以我们认为它是一个外部调用的方法。
jni方法解释:extern “C”,告诉c++编译器,该方法是一个jni的方法,你不能混淆该方法,要不然java层找不到该方法的签名。下面的__android_log_print是调用了android库的log方法,用该方法的时候需要导入android/log.h
,JNIEXPORT void JNICALL是一个跨平台的两个宏定义。一般在windows平台上如何没有该宏会报错。在jni方法参数上有两个,一个jni环境的方法,它是一个指针变量,第二个参数是java层谁调用的对象,比如我示例中是通过MainActivity调用的,那么此处的jobject就是MainActivity对象。
在java层就应该有该native方法的定义。
|
|
在kotlin代码中没有native关键字,用external关键字定义native方法。下面我们再来看下CMakeLists.txt文件,它是用来定义会构建哪几个so库,以及每一个so库是由那些c层代码构建的。以及每一个so库它所需要依赖的其它三方库有哪些:
|
|
上面的cmake构建文件中,我定义了要构建两个so,其中一个so叫nativelib.so,另外一个叫anr_monitor.so。在定义之前我定义了一个find_package方法,用来引入一个外部库bytehook。add_library方法表示要构建的so由哪些c/c++类。target_link_libraries方法表示该so库需要哪些三方库的支持,比如android log bytehook::bytehook是我们要在c++代码中使用的三方库。如果要生成更多的so,依次类推。 在上面的nativelib.so的构建过程中,参与编译的文件是native-lib.cpp,前面我们已经介绍过jni方法Java_com_example_nativelib_MainActivity_MallocLeak。在它里面调用了外部so的方法malloc来申请内存,malloc方法是lic.so外部库的方法,我们的目的是通过plthook技术来实现malloc方法的监听,从而做自己的事情。前面已经讲过plthook技术其实是通过拦截外部so的方法调用,具体它是通过so内部的plt表跳转到外部的函数对应的got表的代码段,而在got表中记录了外部函数的地址。在程序运行时,动态链接器会根据函数的符号信息,将函数的真实地址回写到got表中,从而实现函数的动态调用。而plthook技术其实就是修改got表中记录的真实地址,改为我们的自定义方法的地址。而在自定义方法中,通过调用原函数的地址来实现原函数的调用。 下面我们通过bhook框架来实现外部函数调用的拦截:
|
|
这里我定义了一个jni的方法,它的调用需要在上面jni方法Java_com_example_nativelib_MainActivity_MallocLeak之前。因为只有先监听,后面调用的时候才能监听到。bytehook_hook_single方法是bhook框架中的方法,它是bhook框架中的方法,用于hook进程中的单个调用者动态库的某个方法。第一个参数指定作用于在哪个so上,此处的malloc方法拦截是在libnativelib.so库中的Java_com_example_nativelib_MainActivity_MallocLeak方法调用的。第二个参数是被调用so的名字,此处我传的是nullptr,其实它是lic.so的方法,此处如果有多个库中出现了malloc方法,该参数需要制定。第三个参数是方法名。第四个参数是被hook时候的方法指针,此处要求类型是void *,在c++/c中表示指向未知类型的指针,也叫通用指针。而reinterpret_cast是c++/c中的强转。 下面来了解下c++中的几种强转:
- static_cast
(expr) - 做类型之间的转换,作用于编译期检查,运行时不检查。比如基础类型之间转换(int->double)
- 有继承关系的指针或引用之间的转换(向上转型安全,向下转型需自己保证安全)
- void* ↔ 具体类型
- 举例说明:
- 基本类型转换
1 2 3
int i = 10; double d = static_cast<double>(i);//int->double类型 cout <<"数值是:"<< d << endl;
- void*转成具体类型
那什么是编译期检查的类型呢?我们通过下面一个例子来说明:1 2 3 4
int i = 10; void *p = &i;//将i的地址给到指针p int *pi = static_cast<int*>(p);//将p的指针强转成int类型的指针 cout << "pi指针存储的值是 = " << *pi << endl;//取值操作
这里我定义了一个父类和一个子类,然后通过static_cast能将子类类型指向父类类型:1 2
class Base{}; class Derived : public Base{};
这里直接能将子类指针指向父类指针。下面举个不能被指向的例子:1 2
Derived* derived = new Derived(); Base* base = static_cast<Base*>(derived);
1 2 3 4
struct A {}; struct B {}; A* a = new A(); B* b = static_cast<B*>(a); // ❌ 报错:没有 A* -> B* 的转换规则
- dynamic_cast
(expr) - 运行时类型安全转换,主要用于多态类(含有 虚函数表 的类)。向下转换时,会在运行时检查,失败则返回nullptr(指针情况),或抛出std::bad_cast(引用情况)。
上面可以通过dynamic_cast转换将子类类型的指针转换成父类类型的指针。上面如果Base中没有定义虚函数,则在dynamic_cast编译期就会提示错误。在上面例子中derived不会为空,什么叫运行时安全转换呢?下面通过一个不是继承关系来说明问题:1 2 3 4 5 6 7 8
class Base{virtual void fun(){}}; class Derived : public Base{}; Base* base = new Derived(); Derived* derived = dynamic_cast<Derived*>(base); std::cout <<"转换后的结果:"<< derived <<std::endl; if (derived) { std::cout << "转换成功"<<std::endl; }
在上面代码中,Derived不是继承自Base,并且在用dynamic_cast时候,编译期不会报错,如果用上面的static_cast就会报错了。但是在编译期得到的结果就是nullptr。1 2 3 4 5 6 7 8
class Base{virtual void fun(){}}; class Derived {}; Base* base = new Base(); Derived* derived = dynamic_cast<Derived*>(base); std::cout <<"转换后的结果:"<< derived <<std::endl; if (derived == nullptr) { std::cout << "转换后为空"<<std::endl; }
- const_cast
(expr) - 去除或添加 const、volatile 修饰符
- 唯一能去掉 const 的 cast
- 不能用于不同类型之间的转换
- 如果原对象本身是 const 的,去掉 const 后修改会导致 未定义行为。
此处定义了const a等于10,在后面虽然把p指针指向a,改变p的值后,对a没有影响。1 2 3 4 5
const int a = 10; int* p = const_cast<int*>(&a); *p = 20; std::cout << "a="<< a <<std::endl;//a=10 std::cout << "p="<< *p <<std::endl;//p=20
- reinterpret_cast
(expr) - 底层二进制级别的重新解释。
- 不安全,不检查类型。
- 常用于指针之间的的转换、指针和整数之间的转换。
- 只是简单地“解释”比特位,没有语义上的转换。
- 举个指针转化成long类型的例子:
p2指向了变量i的地址,接着使用reinterpret_cast将p2这个指针转化成long类型。 再来举一个指针变成另外一个指针的例子:1 2 3 4 5 6
int i =10; int* p2 = &i; std::cout << "p2="<< *p2 <<std::endl; std::cout << "p2的地址值="<< p2 <<std::endl; long s = reinterpret_cast<long>(p2); std::cout << "s的值是:" << s <<std::endl;
上面使用reinterpret_cast能直接将base2的指针直接强转到derived2上,从结果来看,base2和derived2的指针值都是一样的,在上面讲的const_cast它是得到一个默认指针,也就是nullptr。1 2 3 4
Base* base2 = new Base(); std::cout << "base2:"<<base2<<std::endl; Derived* derived2 = reinterpret_cast<Derived*>(base2); std::cout << "derived2:"<< derived2 <<std::endl;
回到上面jni的拦截方法里面,它是将malloc_hook_by_plt这个函数通过取址符(&)来获取到函数的地址,接着通过reinterpret_cast强转操作符转化成void*类型的指针,它是c/c++中的任何指针类型。我们来看下malloc_hook_by_plt函数:
|
|
首先该函数的返回值类型是size_t,它表示的是无符号的int值,在32位机器上,它是4字节,64位机器上,它是8字节,一般用来表示地址值。接着函数的参数也是一个size_t值,它是和我们的malloc函数的参数对应,表示申请的内存大小。接着我们输出android的log,如果申请的内存大于20M的时候,也输出android的log,最后返回原来函数的地址值。我们看下hacker_orig_strlen函数,它是一个函数指针类型:
|
|
此处使用typedef是给当前函数指针起一个别名,名字叫hacker_strlen_t,函数的返回值是size_t,第二行定义了一个hacker_strlen_t类型的函数。此处为什么要这样定义函数指针呢?我们回到上面的调用就知道了,malloc_hook_by_plt方法需要一个size_t用来表示new_func的地址,参数和malloc方法的参数一致。这个在hock的时候需要知道申请的内存大小,hacker_orig_strlen函数指针是什么时候初始化的呢?它是在hacker_bytehook_strlen_hooked方法中被赋值的:
|
|
它是bhook中bytehook_hook_single方法的hooked参数被调用的,它表示plthook过程中被hooked时候调用的方法,下面来分析下该方法:
- 该函数的定义是用于hook成功后保存原始函数指针
- static表示该函数只能在当前源文件中可见(局部链接)
- task_stub:hook操作的句柄,bhook用来标识一次hook任务
- status_code:状态码,表示hook是否成功,在bytehook.h中定义了BYTEHOOK_STATUS_CODE_OK,表示hook成功
- caller_path_name:这是一个指向常量字符的指针,在c和c++中,字符串不是一个单独的类型,而是由字符数组实现的,而指针可以用来指向一串字符的指针,所以次数用char *,前面加const表示该函数中不能修改该字符串,调用者模块的路径,是谁调用了被hook的函数
- sym_name:被hook的符号名
- new_func:新的函数地址,此处使用void *,表示的是泛型指针,表示任意类型的指针,即一个没有具体类型的内存地址,hook框架不知道你hook函数到底是什么签名,所以只返回一个地址,让你自行转换为对应函数指针类型
- prev_func:被替换掉的原函数地址(可通过它调用原函数)
- arg:你 hook 时传入的自定义参数,可传 hook 时的上下文等
在函数里面将prev_func的void*类型指针直接强转成hacker_strlen_t函数指针,其实这种小括号的强转不够规范,应该用上面的reinterpret_cast来进行指针类型的强转。最后输出了android log,整个过程就结束了。