Xposed的大名相信很多同学都不陌生,它提供了一种能力,可以在不修改原apk的情况下,以插件的方式改变目标App的某些行为。
但随着Android系统版本的迭代,原来的Xposed已经不适合在高版本的系统上运行了,原Xposed作者也在3年前就停止了更新,取而代之的是Magisk + Riru + EdXposed这一套组合。不过,基于此类框架开发Hook插件,是需要掌握一定的逆向知识的,比如你在进行Hook之前,首先要知道方法签名以及其执行时机,在没有源代码的情况下,这些信息只能从反编译的目标apk的smali(或转jar)代码中获取,如果目标apk加了壳的话,还要先脱壳……
而本篇文章将结合一个实践案例,给大家介绍无需反编译,对无逆向基础的同学非常友好的一个库:Hookworm(钩虫)。
前段时间在WanAndroid每日一问里有个问题:“应用进程中那4个Binder线程分别是跟谁通讯?”
一番简单分析无果后,就想着写个Xposed插件来hook Thread对象的创建,但是看到有同学提醒:“Xposed插件的handleLoadPackage
方法是在handleBindApplication
时才回调的!”。 没办法,只能找其他的方案了。
忽然想到了Magisk,可是我又不会做Magisk插件……
第二天看了下自己一直在用的那个【微信指纹支付】的Magisk模块源码(其实之前也看过好多遍了,一直没看懂,一头雾水),发现核心代码其实就是libs下面的那个apk的dex!
反编译看了下,大概摸清了思路,但是想到用这种方式(先手动打包成apk放在插件项目libs下)开发起来太繁琐了,而且维护起来成本又高,还没有一个规范的模板,这样就很难抽出来为大家所用。
于是心有不甘的我又继续在github上面搜Riru相关的模块,看到一个叫【QQ Simplify】的项目,是用来阉割QQ一些 “花里胡哨” 的功能的,看了下代码,它是在进程Fork之后,用反射把ServiceFetcher里面的LayoutInflater对象换成自己的代理类,这样就可以在布局inflate时,选择性地把一些View的宽高set为0,达到隐藏的效果。
结合这两个项目的部分代码以及思路,我封装出来一个入侵程度非常低的Module,开发新的插件的话,只需要添加这个Module的依赖,然后在module.properties中配置一下模块属性就行了,非常简单!
那么接下来就跟大家一起编写一个寄生插件版的Hello World,以便大家对Hookworm有个大致的了解。
编写和安装寄生插件,有几个前提:
目标手机系统版本至少是6.0(API 23);
目标手机已正确安装 Magisk (无从下手的同学可以搜索引擎搜一下: “【目标手机型号】Magisk 安装” 等字眼,相关资料挺多);
目标手机已刷入 Riru 模块(这个简单,只要成功安装Magisk之后,在Magisk APP里面就能直接搜索和刷入);
Android Studio已升级到4.1或以上(因为Hookworm用到了Gradle Plugin4.1.1的一些新功能,需要更高版本的Android Studio才支持);
最好会一点Kotlin语法(Hookworm的核心代码基本都是用Kotlin编写的,会Kotlin的话能帮助你更好地编写寄生插件);
好,那我们开始吧。
首先创建一个新项目:
新建项目的时候,注意Language要选Kotlin,还有Minimum SDK选API 23,因为等下依赖的Module,都是以这个为基准的。
项目初始化完成后,打开 File -> Project Structure :
检查下当前的Gradle Plugin和Gradle版本是不是 >=4.1.1 和 >=6.5,如果不是请手动更正。
接着来创建一个入口类,名字随便,只要有:
public static void main(String processName);
这个方法就行。(注意main
方法的参数processName
是String而不是String数组哦)
看下用Kotlin是怎么写的:
object HelloHookworm {
@JvmStatic
fun main(processName: String) {
}
}
好,现在把Hookworm Clone下来,Github地址在这里: https://github.com/wuyr/HookwormForAndroid 。
然后导入到项目中,并在app模块的build.gradle
中添加Hookworm的依赖:
dependencies {
......
implementation project(path: ':HookwormForAndroid')
}
同步一下,会发现报错了:
Please copy "module.properties.sample" and rename to "module.properties" and fill in the module information!
这是插件信息还没有完善的原因。
切换到Project视图,把HookwormForAndroid/module.properties.sample
复制一份,并改名为module.properties
,然后编辑插件信息:
# 模块唯一标识,只能使用字母 + 下划线组合,如:my_module_id
moduleId=hello_hookworm
# 模块名称
moduleName=Hello Hookworm
# 模块作者
moduleAuthor=Demo
# 模块描述
moduleDescription=Hello World for Hookworm
# 版本名
moduleVersion=v1.0.0
# 版本号,只能填数字
moduleVersionCode=1
上面这几个属性都很好理解,可以随便填写,只要保证moduleId
不跟其他已安装的模块有冲突就行了。
还有几个比较重要的属性:
# 主入口类名,例:com.demo.ModuleMain
moduleMainClass=com.demo.hellohookworm.HelloHookworm
# 目标进程名/包名,即要寄生的目标。
targetProcessName=com.tencent.mm
# 自动安装模块
automaticInstallation=1
# 免重启安装
debug=1
第一个moduleMainClass
是入口类的类名,在宿主进程启动时,这里填写的类里面的public static void main(String processName)
方法就会被调用。我们就把刚刚创建的那个HelloHookworm的完整类名填上去;
targetProcessName
就是要寄生的目标,emmmm。。。。就选择微信吧,好记一点;
automaticInstallation
,编译后自动安装,肯定开着更好啦;
最后一个debug
属性,开启后会提供一种最小化安装的能力,也就是免重启安装,可以用来快速测试模块(注意,在正式发布时需要关闭)。
嗯,配置好属性之后,回到刚刚创建的HelloHookworm,随便写点代码:
object HelloHookworm {
private const val TAG = "HelloHookworm"
@JvmStatic
fun main(processName: String) {
Log.i(TAG, processName)
//监听宿主应用初始化
Hookworm.onApplicationInitializedListener = {
application ->
Log.i(TAG, "Application initialized!")
Toast.makeText(application, "Hello Hookworm!", Toast.LENGTH_SHORT).show()
}
}
}
可以看到这里第一时间打印了宿主的进程名,然后监听宿主初始化,在初始化完成时打印log并且show了一个 “Hello Hookworm” 的Toast。
好啦,现在属性配置好了,代码也写好了,来看下效果吧。
不过,因为寄生插件的特殊性,它并不是通过常规方式去安装的,所以在打包的时候也会跟平时有点不同:
就像上图一样,寄生插件它是通过Project的assemble
这个Task来打包的,只需要双击一下就ok啦。
在打包之前,可以先连上测试手机的adb,等打包完成后就会自动安装并重启了~
。。。。。。。。。。。。。。。。。。。。。。
如果过程顺利的话,手机自动重启后,点开微信:
哈哈哈,看到刚刚我们添加的Toast了没!!!
再看下log:
2021-02-01 09:52:26.956 21929-21929/? I/HelloHookworm: com.tencent.mm
2021-02-01 09:52:27.802 21929-21929/? I/HelloHookworm: Application initialized!
成功了!!!
接下来开始hook实战。
为避免侵犯他人利益,我们决定选一个非商用的开源项目来作为这次的Hook目标:WanAndroid客户端 (选第二个)。
安装打开看下:
首页的结构大致就是Banner + 文章列表,底部有4个导航按钮,用来切换Fragment。
emmmm。。。。这个Banner图片就有点不好看,先把它换成好看的。
想一想,如何在不修改apk代码的前提下,替换掉图片呢?
要知道,寄生插件的代码是运行在宿主进程中的,也就是只要拿到对应的Banner对象,就能给它重新指定图片Url,或者换成自己的Adapter。
那怎么才能拿到这个Banner对象?
刚刚做Hello World的Demo时,不是可以监听到Application的初始化嘛?
既然有Application对象,那就能通过registerActivityLifecycleCallbacks
方法,监听到所有Activity的生命周期,从而拿到目标Activity对象!然后就可以通过findViewById
获取到想要的Banner对象啦!
监听Activity生命周期这一步,Hookworm也已经做了封装:
// Activity完整类名
val mainActivity = "com.demo.MainActivity"
// 监听目标Activity onCreate
Hookworm.registerOnActivityCreated(mainActivity) {
activity, savedInstanceState ->
Log.i(TAG, "Activity ${
activity.javaClass.simpleName} created")
}
// 监听目标Activity onResume
Hookworm.registerOnActivityResumed(mainActivity) {
activity ->
Log.i(TAG, "Activity ${
activity.javaClass.simpleName} resumed")
}
// 监听目标Activity onDestroy
Hookworm.registerOnActivityDestroyed(mainActivity) {
activity ->
Log.i(TAG, "Activity ${
activity.javaClass.simpleName} destroyed")
}
不过,【监听Activity生命周期 + findViewById
】这个方法,是明显存在问题的,因为我们不能确定目标View的attach时机,如果目标View在findViewById
之后才加载,这个方法就失效了。
那有没有办法监听到目标View加载呢? 要是可以的话,问题就解决了。
有,可以用反射把Activity对应的LayoutInflater替换成自己做过手脚的类。
这一步Hookworm也已经帮我们实现了:
// Activity完整类名
val mainActivity = "com.demo.MainActivity"
//劫持Activity布局加载
Hookworm.registerPostInflateListener(mainActivity) {
resourceId, resourceName, rootView ->
// do something…
}
Hookworm的registerPostInflateListener
方法会在目标Activity每次加载布局资源时回调后面的lambda。
lambda的三个参数分别是:
resourceId: 目标Activity正在加载的布局资源id;
resourceName: 目标Activity正在加载的布局资源名称;
rootView: inflate完成后的View对象;
我们可以通过这个方法来监听首页Activity的布局加载,在它每次inflate布局之后去查找Banner的实例。
不过在此之前,需要先知道对应Activity完整类名和Banner的id值或id名称。
获取Activity类名有很多种方式,我就比较喜欢用shell命令:
adb shell
dumpsys activity activities top | grep "Hist #" | awk 'NR==1{print $6}'&&exit
打开WanAndroid应用首页,然后在终端里执行上面的命令:
类名就出来了:per.goweii.wanandroid/.module.main.activity.MainActivity
(注意这里多了个/
符号,等下用到的时候要删掉)。
至于Banner的id值,是需要反编译才能看到的,既然文章标题说了不用反编译,那就换一种方式——借助Android SDK提供的***UIAutomatorViewer***来获取它的id资源名称。
uiautomatorviewer工具位于sdk/tools/bin
目录下(Windows系统是bat文件),把它拖到终端里enter就能运行了:
左上角四个功能按钮分别是:打开、获取屏幕当前页面布局信息、获取精简(去掉多余嵌套)后的页面布局信息、保存。
它获取布局信息其实是借助***
uiautomator
***命令来完成的,这个uiautomator
内部会通过AccessibilityService把视图层级信息dump出来。
手机再次打开WanAndroid应用首页,然后点一下获取屏幕当前页面布局信息按钮:
通过右边的布局信息可以知道,这个Banner原来是个ViewPager,id名称是bannerViewPager
,还可以看到它的Item也只是ImageView而已。
有了id名称,就能通过Hookworm提供的扩展函数findViewByIDName
来获取到这个ViewPager对象,ViewPager里刚好有个监听Adapter变更的方法:addOnAdapterChangeListener
,我们可以借助这个方法,在ViewPager的Adapter变更时将目标Adapter替换掉,这样就能确保Banner总是能显示修改后的图片了。
像前面创建Hello Hookworm那样,先创建一个Hookworm For WanAndroid项目,并配置插件信息(目标应用包名记得要改成WanAndroid应用的包名)。
然后编写代码:
object Main {
private fun Any?.logD() = Log.d("Main", toString())
@JvmStatic
fun main(processName: String) {
// 首页Activity类名
val mainActivity = "per.goweii.wanandroid.module.main.activity.MainActivity"
// 拦截mainActivity的布局加载
Hookworm.registerPostInflateListener(mainActivity) {
_, resourceName, rootView ->
rootView?.apply {
hookBanner(resourceName)
}
}
}
private fun View.hookBanner(resourceName: String) {
// 根据id名称: "bannerViewPager" 查找ViewPager
findViewByIDName<ViewPager>("bannerViewPager")?.let {
viewPager ->
// 打印布局名称和查找到的ViewPager对象实例
"bannerViewPager所在布局:$resourceName".logD()
viewPager.logD()
}
}
}
在找到ViewPager之后打印所在布局名称和ViewPager的实例,运行看下效果:
java.lang.ClassCastException: com.youth.banner.view.BannerViewPager cannot be cast to androidx.viewpager.widget.ViewPager
at com.wuyr.hookwormforwanandroid.Main$main$1.invoke(Main.kt:24)
at com.wuyr.hookwormforwanandroid.Main$main$1.invoke(Main.kt:13)
at com.wuyr.hookworm.extensions.PhoneLayoutInflater.inflate(PhoneLayoutInflater.kt:66)
at android.view.LayoutInflater.inflate(LayoutInflater.java:532)
at com.wuyr.hookworm.extensions.PhoneLayoutInflater.inflate(PhoneLayoutInflater.kt:57)
at com.youth.banner.Banner.initView(Banner.java:102)
at com.youth.banner.Banner.(Banner.java:96)
at com.youth.banner.Banner.(Banner.java:84)
at com.youth.banner.Banner.(Banner.java:80)
at per.goweii.wanandroid.module.home.fragment.HomeFragment.createHeaderBanner(HomeFragment.java:395)
at per.goweii.wanandroid.module.home.fragment.HomeFragment.initView(HomeFragment.java:300)
......
咦?为什么会报强转失败呢?这个Banner不就是ViewPager嘛?!
其实是因为寄生插件的DexClassLoader和宿主的PathClassLoader都分别加载了ViewPager这个Class造成的,就像这样:
通俗地说,寄生插件的DexClassLoader跟宿主的PathClassLoader是属于叔侄关系,并不是直系亲属,不能进行Parent-Delegation,所以才会各自加载一次ViewPager类。
要解决这个问题也很简单,把寄生插件的DexClassLoader转接到宿主的PathClassLoader下面(强制户口迁移),让它们变成直系亲属:
这样寄生插件在使用到ViewPager时就会优先让宿主的PathClassLoader去加载了。
具体要怎么做呢:
在调用registerPostInflateListener
之前,加上这句代码:
Hookworm.transferClassLoader = true
再修改一下build.gradle,把几个相关的依赖库从implementation改成compileOnly(只编译,不打包):
dependencies {
compileOnly 'androidx.core:core-ktx:1.3.2'
compileOnly 'androidx.appcompat:appcompat:1.2.0'
compileOnly 'com.google.android.material:material:1.2.1'
}
就行了,编译运行看下log(如果编译时报错的话,直接删掉对应文件即可,比如:AAPT: error: style attribute 'attr/colorPrimary xxx' not found.
之类的错误,可以把themes.xml
删掉,还有Manifest里面的android:theme
也去掉):
D/Main: bannerViewPager所在布局:banner
D/Main: com.youth.banner.view.BannerViewPager{3b21ab4 VFED..... ......I. 0,0-0,0 #7f080071 app:id/bannerViewPager}
成功打印了!
接下来开始替换图片。
就按照之前说的那样做:借助addOnAdapterChangeListener
方法在ViewPager的Adapter变更时将目标Adapter替换掉,这样就能确保Banner总是能显示修改后的图片了。
看看代码要怎么写:
object Main {
......
private fun View.hookBanner(resourceName: String) {
// 根据id名称: "bannerViewPager" 查找ViewPager
findViewByIDName<ViewPager>("bannerViewPager")?.let {
viewPager ->
// 打印布局名称和查找到的ViewPager对象实例
"bannerViewPager所在布局:$resourceName".logD()
viewPager.logD()
// 监听Adapter变更,在每次Adapter变更时替换掉目标Adapter
viewPager.addOnAdapterChangeListener(object : ViewPager.OnAdapterChangeListener {
// 自己的Adapter
private val adapter = ImageAdapter(context)
override fun onAdapterChanged(
viewPager: ViewPager, oldAdapter: PagerAdapter?, newAdapter: PagerAdapter?
) {
// 先移除监听避免递归调用
viewPager.removeOnAdapterChangeListener(this)
viewPager.adapter = adapter
viewPager.addOnAdapterChangeListener(this)
}
})
}
}
}
好,现在就差一个加载自己的图片的Adapter了。
不过我们这次并不打算直接依赖图片加载框架,而是先看宿主依赖了哪个,我们直接拿来用。。。
刚刚通过Hookworm.transferClassLoader = true
把插件ClassLoader转接到了宿主ClassLoader下面,这样就已经能直接使用宿主里面的资源了。
比如加载图片的类库,我们可以先测试下宿主有没有使用一些常见的图片加载框架:
object Main {
private fun Any?.logD() = Log.d("Main", toString())
@JvmStatic
fun main(processName: String) {
Hookworm.transferClassLoader = true
......
Hookworm.onApplicationInitializedListener = {
fun String.classExists() = runCatching {
Class.forName(this) }.isSuccess
when {
"com.facebook.drawee.view.SimpleDraweeView".classExists() -> "正在使用Fresco".logD()
"com.squareup.picasso3.Picasso".classExists() -> "正在使用Picasso".logD()
"com.bumptech.glide.Glide".classExists() -> "正在使用Glide".logD()
else -> "没有使用常见的图片加载框架".logD()
}
}
}
......
}
只要调用Class.forName
后不报NoClassDefFoundError就说明宿主添加了对应类库的依赖。
编译运行,会看到打印的是"正在使用Glide"
这句log,那现在可以直接在插件里使用Glide了。
先给build.gradle加上glide的依赖(注意使用的是compileOnly而非implementation):
compileOnly 'com.github.bumptech.glide:glide:4.11.0'
准备几张图片:
https://c-ssl.duitang.com/uploads/item/201708/13/20170813095305_FSQhj.thumb.700_0.jpeg
https://c-ssl.duitang.com/uploads/item/201512/05/20151205212633_nFx3d.thumb.700_0.jpeg
https://c-ssl.duitang.com/uploads/item/201606/12/20160612235102_z3dja.thumb.700_0.jpeg
https://c-ssl.duitang.com/uploads/item/201707/27/20170727121828_Z5TRA.thumb.700_0.png
https://c-ssl.duitang.com/uploads/item/201707/27/20170727122213_3HBaN.thumb.700_0.png
https://c-ssl.duitang.com/uploads/item/201512/04/20151204202153_nEUMt.thumb.700_0.jpeg
扩展一个PagerAdapter:
class ImageAdapter(context: Context) : PagerAdapter() {
private val imageUrls = arrayOf(
"https://c-ssl.duitang.com/uploads/item/201708/13/20170813095305_FSQhj.thumb.700_0.jpeg",
"https://c-ssl.duitang.com/uploads/item/201512/05/20151205212633_nFx3d.thumb.700_0.jpeg",
"https://c-ssl.duitang.com/uploads/item/201606/12/20160612235102_z3dja.thumb.700_0.jpeg",
"https://c-ssl.duitang.com/uploads/item/201707/27/20170727121828_Z5TRA.thumb.700_0.png",
"https://c-ssl.duitang.com/uploads/item/201707/27/20170727122213_3HBaN.thumb.700_0.png",
"https://c-ssl.duitang.com/uploads/item/201512/04/20151204202153_nEUMt.thumb.700_0.jpeg"
)
private val imageViews = ArrayList<ImageView>().apply {
imageUrls.forEach {
url ->
add(ImageView(context).apply {
scaleType = ImageView.ScaleType.CENTER_CROP
Glide.with(context).load(url).into(this)
})
}
}
override fun instantiateItem(container: ViewGroup, position: Int) =
imageViews[position].also {
container.addView(it) }
override fun destroyItem(container: ViewGroup, position: Int, `object`: Any) =
container.removeView(imageViews[position])
override fun getCount() = imageUrls.size
override fun isViewFromObject(view: View, `object`: Any) = view == `object`
}
编译运行,看下效果:
哈哈哈哈,成功替换了!
那接下来试着拦截首页文章列表的Item点击吧。
有同学可能会想:不就是给Item重新设置一个OnClickListener嘛,这有什么的。
emmmm,如果只是粗暴地直接给Item重新setOnClickListener,那就不能保留宿主原来的点击逻辑了,这确实没什么可说的,不过我们要的是可以随心所欲地控制每一次的点击事件。
比如只把文章标题含有 “每日一问” 字眼的交给宿主去处理,其余的Item在点击时都弹出一个 “禁止点击” 的Dialog。
这就需要先拿到Item原来的OnClickListener,但是View的OnClickListener都是不公开的,只能用反射来获取,再加上一个列表那么多Item,难道还要用List装起来?这也太麻烦了叭!
还好Hookworm替我们做了这个事情,有个叫setOnClickProxy
的扩展方法,它会在回调时把旧的(原来的)OnClickListener实例也传回来,像这样:
view.setOnClickProxy {
targetView, oldListener ->
if (xxx) {
// Do something...
} else {
// 交给宿主原有listener去处理
oldListener?.onClick(targetView)
}
}
好,那现在来看看首页的文章列表是个什么View,打开UIAutomatorViewer,dump一下视图:
是RecyclerView,id名就叫rv
。
在开始hook之前,有一个问题需要解决:
因为RecyclerView的Item都是会复用的,每个Item复用时,都会经过一次onBindViewHolder
方法,通常Item的点击事件都会在这里去设置。如果插件的点击代理是在onBindViewHolder
调用前设置的,那就不起作用了(Listener会被覆盖),要是在View显示出来之后才设置,也有可能Item会先被点击,那时候走的还是原来的点击逻辑。
所以必须找到一个时机:在onBindViewHolder
执行之后,在Item显示出来之前。
有没有想到呢?
RecyclerView有个addOnChildAttachStateChangeListener
方法,可以监听到每个Item的Attached和Detached!我们可以在Item Attached时给它设置点击代理!
看看代码怎么写:
object Main {
@JvmStatic
fun main(processName: String) {
......
Hookworm.registerPostInflateListener(mainActivity) {
_, resourceName, rootView ->
rootView?.apply {
if (resourceName == "banner") {
hookBanner(resourceName)
}
hookArticleItem(resourceName)
}
}
}
private fun View.hookArticleItem(resourceName: String) {
// 根据id名“rv” 找到首页文章列表RecyclerView实例
findViewByIDName<RecyclerView>("rv")?.let {
recyclerView ->
// 监听Item的attach状态
recyclerView.addOnChildAttachStateChangeListener(object :
RecyclerView.OnChildAttachStateChangeListener {
private val dialog = AlertDialog.Builder(context).setMessage("禁止点击!").create()
private val onClickProxy: (view: View, oldListener: View.OnClickListener?) -> Unit =
{
view, oldListener ->
// 查找id名为“tv_title”的TextView
view.findViewByIDName<TextView>("tv_title")?.let {
titleView ->
// 检查是否包含 “每日一问” 字眼
if (titleView.text.toString().contains("每日一问")) {
// 有则交给宿主处理
oldListener?.onClick(view)
} else {
// 没有就弹出dialog
dialog.show()
}
} ?: oldListener?.onClick(view) // 没找到,交给宿主去处理
}
override fun onChildViewAttachedToWindow(child: View) {
// 在Item每次attach之后重新设置点击代理
child.setOnClickProxy(onClickProxy)
}
override fun onChildViewDetachedFromWindow(child: View) {
}
})
}
}
......
}
看看效果怎么样:
可以了,现在只有点击 “每日一问” 的Item才会跳转Web页面,点击其他的Item都会弹出 “禁止点击” Dialog,跟预期的一样。
前面几个小节都只是直接调用目标对象原有的api来实现UI的修改,看上去好像有点过于简单了,那现在就试着结合反射来把4个导航页改成2个。
先把底部的第3,第4个Tab移除掉吧,打开UIAutomatorViewer:
可以看到底部的导航栏是一个LinearLayout,4个子View都是RelativeLayout。分别点一下这4个子View,会发现它们的id名称都是相同的,都叫ll_ltab
,这说明了什么?说明它们很有可能都是来自同一个独立的xml布局,这样我们要在它inflate时移除掉后面两个的话,就必须加一个变量去记录当前inflate的数量,有点麻烦,干脆换一个方式吧:
我们可以监听它父容器的子View添加,在它的子View数量大于2的时候,移除掉后面的子View:
object Main {
@JvmStatic
fun main(processName: String) {
......
Hookworm.registerPostInflateListener(mainActivity) {
_, resourceName, rootView ->
rootView?.apply {
......
removeTabs(resourceName)
}
}
}
private fun View.removeTabs(resourceName: String) {
// 查找ll_bb,监听其子View的添加
findViewByIDName<ViewGroup>("ll_bb")?.setOnHierarchyChangeListener(
object : ViewGroup.OnHierarchyChangeListener {
override fun onChildViewAdded(parent: View, child: View) {
// 转成ViewGroup
(parent as ViewGroup).run {
// 当子View数量大于2时移除最后一个
if (childCount > 2) {
removeViewAt(2)
}
}
}
override fun onChildViewRemoved(parent: View?, child: View?) {
}
})
}
}
看看效果:
嗯,现在底部的Tab是移除了,但实际的页面还没移除,向右滑动还是能看到。
回到UIAutomatorViewer窗口,翻一下右边视图层级信息,会发现这个切换页面的View其实也是ViewPager,id名称是vp_tab
,那移除它的页面,我们可以从Adapter下手。
先看一下它设置的Adapter里面都有些什么:
private fun View.removeTabs(resourceName: String) {
......
// 查找id名为“vp_tab”的ViewPager
findViewByIDName<ViewPager>("vp_tab")?.let {
viewPager ->
// 监听Adapter变更
viewPager.addOnAdapterChangeListener {
_, _, newAdapter ->
// 遍历Adapter所有变量
newAdapter?.javaClass?.declaredFields?.forEach {
f ->
f.isAccessible = true
// 分别打印变量修饰符、变量类型、变量名、变量值
("${
Modifier.toString(f.modifiers)} ${
f.type.simpleName} ${
f.name} = ${
f.get(newAdapter)};").logD()
}
}
}
}
编译运行,看下log:
D/Main: private Page[] mPages = null;
D/Main: private final LinearLayout mTabContainer = android.widget.LinearLayout{5cfd786 V.E...... ......I. 0,0-0,0 #7f080172 app:id/ll_bb};
D/Main: private final int mTabItemRes = 2131427509;
D/Main: private final ViewPager mViewPager = androidx.viewpager.widget.ViewPager{c8e1874 VFED..... ......I. 0,0-0,0 #7f0802b4 app:id/vp_tab};
第一个数组mPages
,估计就是页面Item了,不过现在是null可能是打印的时候数据还没有准备好,我们可以套一个post,等它显示出来的时候再打印:
private fun View.removeTabs(resourceName: String) {
......
// 查找id名为“vp_tab”的ViewPager
findViewByIDName<ViewPager>("vp_tab")?.let {
viewPager ->
// 监听Adapter变更
viewPager.addOnAdapterChangeListener {
_, _, newAdapter ->
viewPager.post {
// 遍历Adapter所有变量
newAdapter?.javaClass?.declaredFields?.forEach {
f ->
f.isAccessible = true
// 分别打印变量修饰符、变量类型、变量名、变量值
("${
Modifier.toString(f.modifiers)} ${
f.type.simpleName} ${
f.name} = ${
// 如果变量类型是数组,则直接打印数组内容
if (f.type.isArray) (f.get(newAdapter) as Array<*>).contentToString()
else f.get(newAdapter)
};").logD()
}
}
}
}
}
编译运行,看下log:
D/Main: private Page[] mPages = [per.goweii.basic.core.adapter.TabFragmentPagerAdapter$Page@efc18e5, per.goweii.basic.core.adapter.TabFragmentPagerAdapter$Page@70a8fba, per.goweii.basic.core.adapter.TabFragmentPagerAdapter$Page@b260e6b, per.goweii.basic.core.adapter.TabFragmentPagerAdapter$Page@95aedc8];
D/Main: private final LinearLayout mTabContainer = android.widget.LinearLayout{da5e61 V.E...... ........ 0,1749-1080,1878 #7f080172 app:id/ll_bb};
D/Main: private final int mTabItemRes = 2131427509;
D/Main: private final ViewPager mViewPager = androidx.viewpager.widget.ViewPager{5c1ed86 VFED..... .......D 0,0-1080,1878 #7f0802b4 app:id/vp_tab};
可以看到mPages
里有四个元素,刚好对应了四个导航页,可以断定它储存的就是导航页的Item实例了,那现在试试用反射把最后2个元素移除掉:
private fun View.removeTabs(resourceName: String) {
......
// 查找id名为“vp_tab”的ViewPager
findViewByIDName<ViewPager>("vp_tab")?.let {
viewPager ->
// 监听Adapter变更
viewPager.addOnAdapterChangeListener {
_, _, newAdapter ->
viewPager.post {
newAdapter?.let {
adapter ->
// 取出Adapter变量mPages
adapter::class.get<Array<*>>(adapter, "mPages")?.let {
pages ->
// 只保留前2个元素
val newPages = pages.filterIndexed {
index, _ -> index < 2 }.toTypedArray()
// 重新赋值
adapter::class.set(adapter, "mPages", newPages)
}
// 通知Adapter数据变更
adapter.notifyDataSetChanged()
}
}
}
}
}
运行看看:
java.lang.IllegalArgumentException: field per.goweii.basic.core.adapter.TabFragmentPagerAdapter.mPages has type per.goweii.basic.core.adapter.TabFragmentPagerAdapter$Page[], got java.lang.Object[]
at java.lang.reflect.Field.set(Native Method)
at com.wuyr.hookworm.utils.ReflectUtilKt.set(ReflectUtil.kt:35)
at com.wuyr.hookworm.utils.ReflectUtilKt.set(ReflectUtil.kt:207)
at com.wuyr.hookwormforwanandroid.Main$removeTabs$2$1$1.run(Main.kt:72)
at android.os.Handler.handleCallback(Handler.java:938)
at android.os.Handler.dispatchMessage(Handler.java:99)
at android.os.Looper.loop(Looper.java:223)
at per.goweii.ponyo.crash.Crash$Companion$initialize$1.run(Crash.kt:23)
at android.os.Handler.handleCallback(Handler.java:938)
at android.os.Handler.dispatchMessage(Handler.java:99)
at android.os.Looper.loop(Looper.java:223)
at android.app.ActivityThread.main(ActivityThread.java:7656)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:592)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:947)
额,报错了,我们给的是Object数组,它要的是per.goweii.basic.core.adapter.TabFragmentPagerAdapter$Page
数组。
这个也不难解决,直接用反射创建对应类型的数组就行了:
private fun View.removeTabs(resourceName: String) {
......
// 查找id名为“vp_tab”的ViewPager
findViewByIDName<ViewPager>("vp_tab")?.let {
viewPager ->
// 监听Adapter变更
viewPager.addOnAdapterChangeListener {
_, _, newAdapter ->
viewPager.post {
newAdapter?.let {
adapter ->
// 取出Adapter变量mPages
adapter::class.get<Array<*>>(adapter, "mPages")?.let {
pages ->
// 通过反射创建长度为2的数组
val newPages = java.lang.reflect.Array.newInstance(
Class.forName("per.goweii.basic.core.adapter.TabFragmentPagerAdapter\$Page"),
2
) as Array<Any?>
// 只取前面2个元素
newPages[0] = pages[0]
newPages[1] = pages[1]
// 重新赋值
adapter::class.set(adapter, "mPages", newPages)
}
// 通知Adapter数据变更
adapter.notifyDataSetChanged()
}
}
}
}
}
里面的class.get
、class.set
是Hookworm的ReflectUtil里面的扩展函数,借助它们可以很方便地使用反射操作。
好了,运行看下效果:
怎么滑都滑不到第3页,证明后2页的Item实例已经成功被移除。
那么,本次的Hook实战也就告一段落了,回顾一下:
我们替换了目标应用的Banner图片、拦截了首页文章列表的Item点击、移除了多余的导航页,一共只用了100多行的代码哦~
整篇文章看下来,貌似Hookworm最多也只能通过反射和动态代理之类的方式来进行一些浅层次的Hook操作,没办法像Xposed那样可以随意拦截任何方法。
Hookworm的能力还可以再强大一点吗?
当然可以!Hookworm内部已经对寄生插件带有so的依赖库做了处理,也就是说,你现在可以直接在插件里依赖一些诸如epic 、 SandHook 、 YAHFA等ART Hook框架,让你的寄生插件马上拥有像Xposed一样的能力!,如果你会JS,还可以在插件中直接使用frida!
免责声明: 文章所介绍知识点仅用于学习研究,利用本文知识进行非法行为造成的一切后果自负!