前言

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 Hookworm

那么接下来就跟大家一起编写一个寄生插件版的Hello World,以便大家对Hookworm有个大致的了解。
编写和安装寄生插件,有几个前提:

  1. 目标手机系统版本至少是6.0(API 23);

  2. 目标手机已正确安装 Magisk (无从下手的同学可以搜索引擎搜一下: “【目标手机型号】Magisk 安装” 等字眼,相关资料挺多);

  3. 目标手机已刷入 Riru 模块(这个简单,只要成功安装Magisk之后,在Magisk APP里面就能直接搜索和刷入);

  4. Android Studio已升级到4.1或以上(因为Hookworm用到了Gradle Plugin4.1.1的一些新功能,需要更高版本的Android Studio才支持);

  5. 最好会一点Kotlin语法(Hookworm的核心代码基本都是用Kotlin编写的,会Kotlin的话能帮助你更好地编写寄生插件);

好,那我们开始吧。
首先创建一个新项目:

新建项目的时候,注意Language要选Kotlin,还有Minimum SDKAPI 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 {@JvmStaticfun 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"@JvmStaticfun 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实战

为避免侵犯他人利益,我们决定选一个非商用的开源项目来作为这次的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总是能显示修改后的图片了。

编写Hook代码

像前面创建Hello Hookworm那样,先创建一个Hookworm For WanAndroid项目,并配置插件信息(目标应用包名记得要改成WanAndroid应用的包名)。
然后编写代码:

object Main {private fun Any?.logD() = Log.d("Main", toString())@JvmStaticfun 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" 查找ViewPagerfindViewByIDName<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.ViewPagerat 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.<init>(Banner.java:96)at com.youth.banner.Banner.<init>(Banner.java:84)at com.youth.banner.Banner.<init>(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}

成功打印了!
接下来开始替换图片。

替换Banner图片

就按照之前说的那样做:借助addOnAdapterChangeListener方法在ViewPager的Adapter变更时将目标Adapter替换掉,这样就能确保Banner总是能显示修改后的图片了。
看看代码要怎么写:

object Main {......private fun View.hookBanner(resourceName: String) {// 根据id名称: "bannerViewPager" 查找ViewPagerfindViewByIDName<ViewPager>("bannerViewPager")?.let { viewPager ->// 打印布局名称和查找到的ViewPager对象实例"bannerViewPager所在布局:$resourceName".logD()viewPager.logD()// 监听Adapter变更,在每次Adapter变更时替换掉目标AdapterviewPager.addOnAdapterChangeListener(object : ViewPager.OnAdapterChangeListener {// 自己的Adapterprivate val adapter = ImageAdapter(context)override fun onAdapterChanged(viewPager: ViewPager, oldAdapter: PagerAdapter?, newAdapter: PagerAdapter?) {// 先移除监听避免递归调用viewPager.removeOnAdapterChangeListener(this)viewPager.adapter = adapterviewPager.addOnAdapterChangeListener(this)}})}}
}

好,现在就差一个加载自己的图片的Adapter了。
不过我们这次并不打算直接依赖图片加载框架,而是先看宿主依赖了哪个,我们直接拿来用。。。

借用宿主类库

刚刚通过Hookworm.transferClassLoader = true把插件ClassLoader转接到了宿主ClassLoader下面,这样就已经能直接使用宿主里面的资源了。
比如加载图片的类库,我们可以先测试下宿主有没有使用一些常见的图片加载框架:

object Main {private fun Any?.logD() = Log.d("Main", toString())@JvmStaticfun main(processName: String) {Hookworm.transferClassLoader = true......Hookworm.onApplicationInitializedListener = {fun String.classExists() = runCatching { Class.forName(this) }.isSuccesswhen {"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_CROPGlide.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.sizeoverride 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的AttachedDetached!我们可以在Item Attached时给它设置点击代理!
看看代码怎么写:

object Main {@JvmStaticfun 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”的TextViewview.findViewByIDName<TextView>("tv_title")?.let { titleView ->// 检查是否包含 “每日一问” 字眼if (titleView.text.toString().contains("每日一问")) {// 有则交给宿主处理oldListener?.onClick(view)} else {// 没有就弹出dialogdialog.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 {@JvmStaticfun 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”的ViewPagerfindViewByIDName<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”的ViewPagerfindViewByIDName<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”的ViewPagerfindViewByIDName<ViewPager>("vp_tab")?.let { viewPager ->// 监听Adapter变更viewPager.addOnAdapterChangeListener { _, _, newAdapter ->viewPager.post {newAdapter?.let { adapter ->// 取出Adapter变量mPagesadapter::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”的ViewPagerfindViewByIDName<ViewPager>("vp_tab")?.let { viewPager ->// 监听Adapter变更viewPager.addOnAdapterChangeListener { _, _, newAdapter ->viewPager.post {newAdapter?.let { adapter ->// 取出Adapter变量mPagesadapter::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的依赖库做了处理,也就是说,你现在可以直接在插件里依赖一些诸如epicSandHookYAHFA等ART Hook框架,让你的寄生插件马上拥有像Xposed一样的能力!,如果你会JS,还可以在插件中直接使用frida

好啦,文章到此结束,有错误的地方请指出,谢谢大家!

免责声明: 文章所介绍知识点仅用于学习研究,利用本文知识进行非法行为造成的一切后果自负!

Github地址:https://github.com/wuyr/HookwormForAndroid 欢迎Star

不反编译、无逆向基础也能轻松编写Android App Hook插件? Xposed的远房表弟,Hookworm来也!相关推荐

  1. -反编译 APKTool 逆向助手

    最佳实践--Android逆向助手 1.点击"反编译apk,完成后res下的所有资源就都可以正常使用了,相当于apktool的功能------目前已失效,但是直接用rar解压是可以的! 2. ...

  2. 反编译 APKTool 逆向助手

    最佳实践--Android逆向助手 1.点击"反编译apk,完成后res下的所有资源就都可以正常使用了,相当于apktool的功能------目前已失效,但是直接用rar解压是可以的! 2. ...

  3. 【app反编译和逆向打包】

    一:反编译 1:反编译代码 JADX(推荐) 具体的安装和使用,推荐看这篇文章吧点这里 dex2jar 和 jd-gui 关键命令: d2j-dex2jar classes.dex ps:将获取到的c ...

  4. vue打包代码反编译_Android逆向反编译代码注入APK过程思路分析

    一.名称解释 逆向 - 是一种产品设计技术再现过程,从可运行的程序系统出发,运用解 密.反汇编.系统分析等多种计算机技术,对软件的结构.流程.算法. 代码等进行逆向拆解和分析,推导出软件产品的源代码. ...

  5. exe反编译NET逆向

    下载一个DotNetCrackMe1.exe 使用ILSPY打开程序,使用C#反编译 关键代码如下: private void button1_Click(object sender, EventAr ...

  6. android 360加固 反编译,[原创]逆向360加固等dex被隐藏的APK

    如果遇到apk中的lib文件夹中是这样的 基本没有dex文件可以反编译,这中的dex文件一般都是加密混淆压缩后放在so中啦. 但是软件要想运行就需要解出dex字节码然后加载到手机内存中,这样就可以在软 ...

  7. APK反编译之一:基础知识

    作者:lpohvbe | http://blog.csdn.net/lpohvbe/article/details/7981386 这部分涉及的内容比较多,我会尽量从最基础开始说起,但需要读者一定的a ...

  8. 反编译 轻松调频 Android APP 下载“飞鱼秀”录音

    经常听"飞鱼秀",但是由于时间的原因,只能听回放,但是轻松调频的APP做的有点儿... 听回放的时候经常会中断,还不能拖动进度条,就决定把录音下载下来听. 1.反编译apk(And ...

  9. 反编译008神器,修改手机型号与android版本号信息

    一个可以修改手机信息的xposed插件 008神器依赖xposed可修改其他应用获取到的系统参数值,见下图 但是我们直接从网上下载的008神器生成的手机型号和安卓版本号都是好几年前的.下面就来分析下怎 ...

最新文章

  1. Matlab参考函数
  2. 苹果cms的php.ini,苹果cms安装及配置详细教程
  3. 软件工程--项目开发计划
  4. Android之部分手机(oppo r9s)安装app出现崩溃问题解决办法
  5. jQuery comet
  6. vba 判断是否为数字
  7. 7.13 Python基础语法
  8. 白板机器学习笔记 P28-P35 支持向量机
  9. centos6.5搭建lnmp过程
  10. 只考虑用户估计的计算机时间,操作系统第四章进程调度和死锁习题及答案
  11. 安全防范趋势、信息安全管理、隐私保护
  12. 济南 章丘 科目三 资料 收集
  13. excel随机数_办公软件操作技巧052:如何在excel中填充随机数
  14. 小丁带你走进git世界五-远程仓库
  15. 电路图符号超强科普,轻松看懂电路图!(推荐收藏)
  16. Java求时间差(日期差)
  17. 任意分布的随机数的产生方法
  18. 表白代码制作(附源码)
  19. 【Spire.PDF】Spire.PDF导出报告之一获取与破解
  20. 笔试题(十五):身高体重排序

热门文章

  1. Django学习笔记2-使用QuerySet删除和查询单表
  2. android平板camera,CameraMator:为单反相机和平板电脑搭桥
  3. Oracle EBS 资产重分类API fa_reclass_pub.do_reclass报错
  4. MS SQL 监控磁盘空间告警
  5. 棋盘覆盖问题——分治法——代码清晰易懂
  6. 《王者荣耀》成为王者之路
  7. 航天军工产品测试-军工装备检测上市公司-第三方检测
  8. 浅谈【Stable-Diffusion WEBUI】(AI绘图)的基础和使用
  9. 人脸对齐:DCNN的人脸关键点检测
  10. 2021年中国燃气产量与燃气生产及供应业经营分析[图]