Featured image of post 从零开始编写Xposed模块

从零开始编写Xposed模块

记录第一次编写Xposed模块的步骤

之前写过一篇关于Magisk模块的编写的文章,不过Magisk修改的都是比较偏系统的,一般情况下用的并不是很多。于是我便把目光转向Xposed,毕竟会Hook真的太酷辣😎

# Xposed的基本概念

提到Xposed,爱玩机的用户都应该听过他的大名,作为一款可以在不修改APK的情况下影响程序运行的框架,可以基于它可以制作出许多功能强大的模块,且在功能不冲突的情况下同时运作。微信防撤回、步数修改、去广告、美化…….他能实现许许多多的功能,可以说没有它玩机的世界将会少很多的乐趣。

它的原理就是通过替换系统原本的app_process,加载一个额外的jar包,从而实现对zygote进程及其创建的Dalvik/ART虚拟机的劫持。

没听懂?没关系,我也不懂。目前我们只要关注如何使用即可了,至于原理可以先放一放。

# 环境准备

在开始之前,我们需要:

  • 一台可以安装Xposed框架的手机(推荐LSPosed、Android 10+)
  • 一台可以编写代码并且装有jdk的电脑
  • 一个名叫Android Studio的软件(我主打一个叛逆,用IDEA同样可以)
  • 一个反编译软件,如:JADX
  • 一个可以查看布局的App,如:开发者助手

这次我打算从一个实例出发:小明手机上装了一些恶意软件,我们需要通过Xposed进行Hook,不让小明启动这些软件。

# 准备工作

# 创建项目&引入依赖

首先在IDEA选择新建项目,可以看到生成器下有Android这项,我们选择它。

第一次选择时可能会要求你下载安卓的SDK,按照指示一步一步进行就好。

# Java

我们选择创建一个No Activity,语言选择Java,SDK选默认的API 24就可。

然后,我们需要引入Xposed的库,不过它并没有上传到MavenCentral上,所以我们需要在settings.gradle里修改一下(gradle 7.0+)

打开settings.gradle,添加一行代码

1
2
3
4
5
6
7
8
dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        google()
        mavenCentral()
        maven { url 'https://api.xposed.info/' }  // 添加这一行即可
    }
}

之后,进入我们app目录下的build.gradle,引入xposed的依赖

1
2
3
4
dependencies {
    compileOnly 'de.robv.android.xposed:api:82' //添加我
    // compileOnly 'de.robv.android.xposed:api:82:sources' // 不要导入源码,这会导致idea无法索引文件,从而让语法提示失效
}

我们还要在./app/src/main/res/values目录下创建arrays.xml,填入下面的内容:

1
2
3
4
5
6
7
8
<resources>
    <string-array name="xposedscope" >
        <!-- 这里填写模块的作用域应用的包名,可以填多个。 -->
        <item>ceui.lisa.pixiv</item>
        <item>com.xjs.ehviewer</item>
        <item>com.picacomic.fregata</item>
    </string-array>
</resources>

这一步主要是指定模块的作用域包名,效果就是在Lsposed中勾选作用域时会在应用下提示推荐应用。

推荐应用效果

最后,我们在Run那里编辑一下启动配置,勾选Always install with package manager并且将Launch Options改成Nothing

# Kotlin

创建项目的部分和Java的设置没有什么区别,只不过是需要将语言切换一下。新版本的Android Studio将原来的打包语言换成了Kotlin,所以我们的设置有一些不一样的地方。2024.09.15更新

原来的settings.gradle变为settings.gradle.kts

1
2
3
4
5
6
7
8
dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        google()
        mavenCentral()
        maven { url =  uri("https://api.xposed.info/") } //添加这一行
    }
}

原来的build.gradle变为build.gradle.kts

1
2
3
dependencies {
    compileOnly("de.robv.android.xposed:api:82") //添加我
}

# 声明模块

接下来就是要声明我们是一个Xposed模块,方便框架发现。

./app/src/main/AndroidManifest.xml里,我们将<application ... />改成以下形式(注意,是改成!就是把结尾的/>换成> </application>

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
<application ... > 
		<!-- 是否是xposed模块,xposed根据这个来判断是否是模块 -->
        <meta-data
                android:name="xposedmodule"
                android:value="true" />
        <!-- 模块描述,显示在xposed模块列表那里第二行 -->
        <meta-data
                android:name="xposeddescription"
                android:value="不可以涩涩" />
        <!-- 最低xposed版本号(lib文件名可知,一般填54即可) -->
        <meta-data
                android:name="xposedminversion"
                android:value="54" />
        <!-- 模块作用域 -->
        <meta-data
                android:name="xposedscope"
                android:resource="@array/xposedscope"/>
    </application>

然后在src/main目录下创建一个文件夹名叫assets,并且创建一个文件叫xposed_init注意,它没有后缀名!!

接着我们需要创建一个入口类,名叫MainHook(或者随便你想取什么名字都行),创建好后回到我们的xposed_init里并用文本文件的方式打开它,输入我们刚刚创建的类的完整路径。如:top.lbqaq.nosese.MainHook,同时注意大小写

完成以上步骤后,我们就可以正式开始编写Xposed模块了。

# 模块编写

# MainHook

MainHook里,我们需要实现Xposed的IXposedHookLoadPackage接口,以便执行Hook操作。将以下内容替换原来的类

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import de.robv.android.xposed.IXposedHookLoadPackage;
import de.robv.android.xposed.callbacks.XC_LoadPackage;

public class MainHook implements IXposedHookLoadPackage {
    @Override
    public void handleLoadPackage(XC_LoadPackage.LoadPackageParam lpparam) throws Throwable {
        // 过滤不必要的应用
        if (!lpparam.packageName.equals("ceui.lisa.pixiv")) return;
        // 执行Hook
        hook(lpparam);
    }

    private void hook(XC_LoadPackage.LoadPackageParam lpparam) {
        // 具体流程
    }
}

对于Kotlin,代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import de.robv.android.xposed.IXposedHookLoadPackage
import de.robv.android.xposed.callbacks.XC_LoadPackage

class MainHook : IXposedHookLoadPackage {
    override fun handleLoadPackage(lpparam: XC_LoadPackage.LoadPackageParam) {
        // 过滤不必要的应用
        if (lpparam.packageName != "ceui.lisa.pixiv") return
        // 执行Hook
        hook(lpparam)
    }

    private fun hook(lpparam: XC_LoadPackage.LoadPackageParam) {
        // 具体流程
    }
}

到这里,我们的准备工作已经完成,安装模块并在框架中激活它!

# 阻止应用启动

接下来,我们需要反编译程序来找到需要Hook的点。根据实例的要求,我们需要阻止小明启动某些应用。那么,我们只需要Hook启动函数,让其无法运行即可。

在此之前,我们要先了解Android的四大组件之一——“Activity(活动)”

在应用中的一个Activity可以用来表示一个界面,意思可以理解为“活动”,即一个活动开始,代表 Activity组件启动,活动结束,代表一个Activity的生命周期结束。一个Android应用必须通过Activity来运行和启动,Activity的生命周期交给系统统一管理。

函数名称 描述
onCreate() 一个Activity启动后第一个被调用的函数,常用来在此方法中进行Activity的一些初始化操作。例如创建View,绑定数据,注册监听,加载参数等。
onStart() 当Activity显示在屏幕上时,此方法被调用但此时还无法进行与用户的交互操作。
onResume() 这个方法在onStart()之后调用,也就是在Activity准备好与用户进行交互的时候调用,此时的Activity一定位于Activity栈顶,处于运行状态。
onPause() 这个方法是在系统准备去启动或者恢复另外一个Activity的时候调用,通常在这个方法中执行一些释放资源的方法,以及保存一些关键数据。
onStop() 这个方法是在Activity完全不可见的时候调用的。
onDestroy() 这个方法在Activity销毁之前调用,之后Activity的状态为销毁状态。
onRestart() 当Activity从停止stop状态恢进入start状态时调用状态。

当 Activity 进入新状态时,系统会调用其中每个回调。

Activity生命周期

那么,我们的思路就很明确了:只要找到这些程序的起始Activity,并Hook它的onCreate()函数,在其启动时就将其杀死,这样能实现无法启动该程序。

我们使用开发者助手,选择布局查看,然后打开目标应用。可以看到,它显示了当前的包名和当前Activity。

布局查看

接下来,我们使用jadx-gui,反编译该应用,找到该Activity(ceui.lisa.activities.MainActivity),并寻找是否有onCreate()函数,如果存在直接Hook即可。

jadx-gui反编译

然而,这个程序居然没有😥,说明它没有重写该方法,那该如何做呢?这时就可以用第二种方法了。

# 遍历所有类下的所有方法

从标题就能看出来,我直接把你所有能Hook的方法全部读出来,那不就随便我挑了嘛。

我们知道,Java程序都运行在jvm虚拟机中,jvm虚拟机通过ClassLoader动态装载所需的class。那么,我们直接Hook ClassLoader ,不就知道你有哪些方法被加载进来了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public void hook(XC_LoadPackage.LoadPackageParam lpparam) {
    XposedHelpers.findAndHookMethod(ClassLoader.class, "loadClass", String.class, new XC_MethodHook() {
        @Override
        protected void afterHookedMethod(MethodHookParam param) throws Throwable {
            super.afterHookedMethod(param);
            Class clazz = (Class) param.getResult();
            String clazzName = clazz.getName();
            //排除非包名的类
            if(clazzName.contains("ceui.lisa")){
                Method[] mds = clazz.getDeclaredMethods();
                for(int i =0;i<mds.length;i++){
                    final Method md = mds[i];
                    int mod = mds[i].getModifiers();
                    //去除抽象、native、接口方法
                    if(!Modifier.isAbstract(mod)
                            && !Modifier.isNative(mod)
                            &&!Modifier.isAbstract(mod)){
                        XposedBridge.hookMethod(mds[i], new XC_MethodHook() {
                            @Override
                            protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
                                super.beforeHookedMethod(param);
                                Log.d("lbqaq",md.toString());
                            }
                        });
                    }
                }
            }
        }
    });
}

(PS:这个程序很奇怪,包名有pixiv,但类名没有,所以用ceui.lisa.pixiv会报空指针异常)

将上面这段代码复制到之前写好的MainHook中去,编译推送到手机。

在终端执行adb logcat "lbqaq:D *:S"开启logcat并进行过滤。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
❯ adb logcat "lbqaq:D *:S"
--------- beginning of main
07-21 10:49:48.185  5050  5050 D lbqaq   : public void ceui.lisa.activities.Shaft.onCreate()
07-21 10:49:48.194  5050  5050 D lbqaq   : private void ceui.lisa.activities.Shaft.updateTheme()
07-21 10:49:48.216  5050  5050 D lbqaq   : public static android.content.Context ceui.lisa.activities.Shaft.getContext()
07-21 10:49:48.238  5050  5050 D lbqaq   : protected int ceui.lisa.activities.MainActivity.initLayout()
07-21 10:49:48.238  5050  5050 D lbqaq   : public boolean ceui.lisa.activities.MainActivity.hideStatusBar()
07-21 10:49:48.276  5050  5050 D lbqaq   : protected void ceui.lisa.activities.MainActivity.initView()
07-21 10:49:48.276  5050  5050 D lbqaq   : public static com.tencent.mmkv.MMKV ceui.lisa.activities.Shaft.getMMKV()
07-21 10:49:48.276  5050  5050 D lbqaq   : private void ceui.lisa.activities.MainActivity.initDrawerHeader()
07-21 10:49:48.278  5050  5050 D lbqaq   : public androidx.drawerlayout.widget.DrawerLayout ceui.lisa.activities.MainActivity.getDrawer()
07-21 10:49:48.279  5050  5050 D lbqaq   : protected void ceui.lisa.activities.MainActivity.initData()
07-21 10:49:48.279  5050  5050 D lbqaq   : private void ceui.lisa.activities.MainActivity.initFragment()

可以看到该程序的调用方法。这里,我们选择靠后的ceui.lisa.activities.MainActivity.initFragment()作为我们的Hook目标。

为什么要选择靠后的方法作为我们的Hook目标呢?

这是由于如果选择较前的方法,有些变量还没初始化完成,这时调用finish()可能会报错。

# Hook activity

我们使用最基础的hook方式,即Xposed自带的XposedHelpers.findAndHookMethod,使用方法如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
private void hook(XC_LoadPackage.LoadPackageParam lpparam) {
    // 它有两个重载,区别是一个是填Class,一个是填ClassName以及ClassLoader
    // 第一种 填ClassName
    XC_MethodHook.Unhook unhook = XposedHelpers.findAndHookMethod("me.kyuubiran.xposedapp.MainActivity",    // className
            lpparam.classLoader,    // classLoader 使用lpparam.classLoader
            "onCreate",             // 要hook的方法
            Bundle.class,           // 要hook的方法的参数表,如果有多个就用逗号隔开 
            new XC_MethodHook() {   // 最后一个填hook的回调
                @Override
                protected void beforeHookedMethod(MethodHookParam param) {} // Hook方法执行前  
                @Override
                protected void afterHookedMethod(MethodHookParam param) {} // Hook方法执行后
            });
    // 它返回一个unhook 在你不需要继续hook的时候可以调用它来取消Hook
    unhook.unhook();    // 取消空的Hook 

    // 第二种方式 填Class
    // 首先你得加载它的类 我们使用XposedHelpers.findClass即可 参数有两个 一个是类名 一个是类加载器
    Class<?> clazz = XposedHelpers.findClass("me.kyuubiran.xposedapp.MainActivity", lpparam.classLoader);
    XposedHelpers.findAndHookMethod(clazz, "onCreate", Bundle.class, new XC_MethodHook() {
        @Override
        protected void afterHookedMethod(MethodHookParam param){
            // 由于我们需要在Activity创建之后再弹出Toast,所以我们Hook方法执行之后
            Toast.makeText((Activity) param.thisObject, "模块加载成功!", Toast.LENGTH_SHORT).show();
        }
    });
}

根据上面的示例,我们就可以写出对应的方法了。那么,我们该如何结束这个程序呢?在前面我们介绍了Activity的生存周期,通过查询可知Activity存在一个关闭的方法finish()。所以我们只要手动调用即可,具体的代码如下。

1
2
3
4
5
6
7
8
9
if (lpparam.packageName.equals("ceui.lisa.pixiv")){
    XposedHelpers.findAndHookMethod("ceui.lisa.activities.MainActivity", lpparam.classLoader, "initFragment", new XC_MethodHook() {
        @Override
        protected void afterHookedMethod(MethodHookParam param) throws Throwable {
            Toast.makeText((Activity) param.thisObject, "不许涩涩!", Toast.LENGTH_SHORT).show();
            ((Activity) param.thisObject).finish();
        }
    });
}

同样,另外两个应用也可以通过这种方式实现

1
2
3
4
5
6
7
8
9
if(lpparam.packageName.equals("com.xjs.ehviewer")){
    XposedHelpers.findAndHookMethod("com.hippo.ehviewer.ui.MainActivity", lpparam.classLoader, "initUserImage", new XC_MethodHook() {
        @Override
        protected void afterHookedMethod(MethodHookParam param) throws Throwable {
            Toast.makeText((Activity) param.thisObject, "不许涩涩!", Toast.LENGTH_SHORT).show();
            ((Activity) param.thisObject).finish();
        }
    });
}
1
2
3
4
5
6
7
8
9
if(lpparam.packageName.equals("com.picacomic.fregata")){
    XposedHelpers.findAndHookMethod("com.picacomic.fregata.activities.SplashActivity", lpparam.classLoader, "onCreate", Bundle.class ,new XC_MethodHook() {
        @Override
        protected void afterHookedMethod(MethodHookParam param) throws Throwable {
            Toast.makeText((Activity) param.thisObject, "不许涩涩!", Toast.LENGTH_SHORT).show();
            ((Activity) param.thisObject).finish();
        }
    });
}

这里要注意,使用findAndHookMethod时要hook的方法的参数表一定要填对。如果没有填对就会报java.lang.NoSuchMethodError错误。我之前被这个问题折磨了很久😭(仔细想想也是这样,Java里有重载机制,参数不同就不是同一个方法了)

kotlin所对应的代码如下:

1
2
3
4
5
6
7
8
9
if (lpparam.packageName == "com.xjs.ehviewer"){
    XposedHelpers.findAndHookMethod("com.hippo.ehviewer.ui.MainActivity", lpparam.classLoader, "initUserImage",object : XC_MethodHook() {
        override fun afterHookedMethod(param: MethodHookParam){
            Toast.makeText(param.thisObject as Activity,"不许涩涩!",Toast.LENGTH_SHORT).show()
            (param.thisObject as Activity).finish()
        }
    })
}
          

# 修改内部参数

虽然一股脑关闭程序非常简单,但小明不乐意了。对于ceui.lisa.pixiv来说,只有长按头像才会触发,直接一刀切实在是太粗暴了。没问题,直接安排上。

首先我们要定位到这个切换的方法究竟在哪?仔细观察,每次切换都会弹出一个颜文字的Toast,我们就从此处切入。这两个颜文字分别为ԅ(♡﹃♡ԅ)X﹏X

我们使用jadx-gui反编译程序,全局搜索,直接就找到了所在位置。

全局搜索

双击查看此处的代码,坏起来了,这是一个匿名函数,根据上面的方法,我们没有这个函数名,自然也就无法对其进行Hook。

具体代码

难道就要卡在这里了吗?如果真是这样我就不会写这篇文章了

仔细分析这段代码,这里对this.userHead设置了一个OnLongClickListener。一个控件只能绑定一个侦听器,所以我们可以进行一个替换,不就实现对其的Hook了吗😎

直接Hook initView这个方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
if (lpparam.packageName.equals("ceui.lisa.pixiv")){
    XposedHelpers.findAndHookMethod("ceui.lisa.activities.MainActivity", lpparam.classLoader, "initView", new XC_MethodHook() {
        @Override
        protected void afterHookedMethod(MethodHookParam param) throws Throwable {
            Field f = param.thisObject.getClass().getDeclaredField("userHead");
            f.setAccessible(true);
            ImageView v = (ImageView) f.get(param.thisObject);
//          v.setLongClickable(false);    //设置其无法响应
            v.setOnLongClickListener(v1 -> {
                Toast.makeText((Activity) param.thisObject, "不许涩涩!", Toast.LENGTH_SHORT).show();
                return true;
            });
        }
    });
}

我们这里使用了Java的反射机制,拿到了它内部的变量userHead,然后通过setAccessible将其设置为可访问。这样我们就能对其私有变量进行修改了。

我们可以简单的将其可点击关闭,但是这样一点也不酷。我直接使用自己的函数将其进行替换,这样每次点击都会出现一个Toast

kotlin的代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
if (lpparam.packageName == "ceui.lisa.pixiv"){
    XposedHelpers.findAndHookMethod("ceui.lisa.activities.MainActivity", lpparam.classLoader, "initView",object : XC_MethodHook() {
        override fun afterHookedMethod(param: MethodHookParam){
            val f = param.thisObject.javaClass.getDeclaredField("userHead")
            f.isAccessible = true
            val v = f[param.thisObject] as ImageView
            v.setOnLongClickListener{
                Toast.makeText(param.thisObject as Activity,"不许涩涩!",Toast.LENGTH_SHORT).show()
                true
            }
    }
    })
}

至此,一个简单的Xposed模块就写好了。

# 配置文件读取

现在的模块往往还会搭配一个UI来实现配置文件的设置,在安卓开发中,常用SharedPreferences来实现配置的保存。随着google的更新,现在的配置文件无法设置MODE_WORLD_READABLE的权限,即只允许本应用读取。虽然Xposed本身提供了XSharedPreferences来读取配置文件,然而模块是依附于宿主程序所执行的,它的访问权限和宿主一致,所以无法访问到我们的配置文件。2024.09.15更新

好在目前常用的框架LSPosed提供了New XSharedPreferences,我们只需要指定xposedAPI的最低版本≥93就可以了。

AndroidManifest.xml里将xposedminversion的值改为93

对于模块的Activity来说,我们只需要指定权限为Context.MODE_WORLD_READABLE即可

1
val sharedPreferences : SharedPreferences = getSharedPreferences("config", Context.MODE_WORLD_READABLE)

对于hook的应用来说,我们使用原来的XSharedPreferences即可

1
val xsp = XSharedPreferences("top.lbqaq.nosese","config")

# 结语

完整的项目代码可以在Github仓库中查看,通过这次的编写,我发现Xposed实际上还是一个工具,想要实现去广告等功能,都是通过反编译来找到突破口,有了Hook的目标后才需要Xposed。

所以,之后的学习还是要以安卓逆向为主,只有打好基本功,才能写出优秀的代码。

# 后续更新

# 2024.09.15

将项目使用了目前流行的Kotlin重构了一波,并添加了UI和模块的配置读取的内容

# 参考文献