Android Xposed 模块入门

前言

老早以前就想搞 Xposed 开发了,一直没有机会学习用上,再加上 iPhone 是主要设备,这几年一直没有关注安卓社区,等手中有个安卓备用机后发现现在玩机社区不如过去了,生态越来越封闭,不过这不影响我继续学习 Xposed 模块开发,目前主流 Xposed 框架环境是 LSPosed Framework ,支持 A8.1 ~ A14 ,不过开发者好像放弃维护了,不知道后续怎么样就不清楚了。Xposed 很强大,强大到在安卓几乎什么都能干,系统美化、软件破解、去广告等诸多操作,这些操作都不会影响原来应用下进行运行,所以说以前玩机最大的快乐来自 Xposed

我想写 Xposed 模块缘由是女朋友一直用醒图,网上对最新醒图破解资源很少,再加上老早以前就想学逆向开发,借此机会就入门了,不过写此教程是记录我学习下来的笔记,不记录醒图逆向开发过程。

准备

想要开发 Xposed 模块需要做到:

  • 一台安装好 Xposed 框架环境的手机(EdXposed, LSPosed 等)
  • 装有 AS 编辑器(Android Studio)且有 JDK 环境的电脑
  • 查看 apk 的反编译工具,例如:jd-gui, JADX 等诸多
  • 查看 apk 的布局应用:MT 管理器, 开发助手专业版
  • 熟悉的且掌握 Java 反射

本篇不研究怎么破解醒图会员过程,记录在我手机下拉全局搜索,在输入框在联网的情况下会有热点广告词轮播,我的目的将把这个热点删除且还原原来的搜索占位词:

创建项目

打开 AS 编辑器,选择没有 No Activity , 语言我选择 kotlin , SDK 选择 API 26

创建好项目后引入 Xposed 库,需在在 Gradle Script 下的 settings.gradle.kts 添加:

dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories {
        google()
        mavenCentral()
        maven { url=uri("https://api.xposed.info") }
    }
}

之后在 app 目录下的 build.gradle.kts 添加 Xposed 声明:

dependencies {
    implementation(libs.androidx.core.ktx)
    implementation(libs.androidx.appcompat)
    implementation(libs.material)
    testImplementation(libs.junit)
    androidTestImplementation(libs.androidx.junit)
    androidTestImplementation(libs.androidx.espresso.core)
    compileOnly(libs.api)
}

如果编译的文件很大的话可以删除里面的主题文件即可:

  • dependencies保留 只保留:compileOnly(libs.api)
  • 移除 src/res/values/themes.xml 里面的主题,注意还有个夜间模式,在 values-night 文件夹下
  • 移除 AndroidManifest.xml 文件里 <application ... /> 中的 android:theme="xxx" 的一行

之后还需要在 src/res/values/ 文件夹新建 arrays.xml 文件,来声明 LSPosed 作用域:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string-array name="xposedscope" >
        <!-- 这里填写模块的作用域应用的包名,可以填多个。 -->
        <item>com.xt.app1</item>
        <item>com.xt.app2</item>
    </string-array>
</resources>

然后在编辑器运行按钮编辑一下启动配置,勾选 Always install with package manager 并且将 Launch Options 改成 Nothing 即可。

声明模块

创建好项目后需要声明它是一个 Xposed 模块,好让框架发现它,这样才能激活模块 到 AndroidManifest.xml 文件里声明如下:

<application ... > 
        <!-- 是否为Xposed模块 -->
        <meta-data
            android:name="xposedmodule"
            android:value="true"/>
        <!-- 模块的简介(在框架中显示) -->
        <meta-data
            android:name="xposeddescription"
            android:value="我是Xposed模块简介" />
        <!-- 模块最低支持的Api版本 一般填54即可 -->
        <meta-data 
            android:name="xposedminversion"     
            android:value="54"/>
        <!-- 模块作用域 -->
        <meta-data
            android:name="xposedscope"
            android:resource="@array/xposedscope"/>
</appication>

然后在 src/main 目录下创建一个文件夹名叫 assets,并且创建一个文件叫 xposed_init,内容写模块的入口类名,例如像我的:

com.iiong.xtcracked.MainHook

编写模块

现在在搜索要去广告,得先知道软件包名:

根据 IXposedHookLoadPackage 接口操作如下:

package com.iiong.xtcracked

import de.robv.android.xposed.IXposedHookLoadPackage
import de.robv.android.xposed.XposedBridge
import de.robv.android.xposed.callbacks.XC_LoadPackage


class MainHook : IXposedHookLoadPackage {
    override fun handleLoadPackage(lpparam: XC_LoadPackage.LoadPackageParam) {
        // ColorOS 全局搜索
        if (lpparam.packageName == "com.heytap.quicksearchbox") {
            XposedBridge.log("找到应用:" + lpparam.packageName)
            // hook it
            qsbHook(lpparam)
        }
    }

    private fun qsbHook(lpparam: XC_LoadPackage.LoadPackageParam) {
      // 具体业务
    }
}

然后在作用域声明下包名,打包后调试在手机上可以激活了。

反编译

如果想写具体的业务需要对 apk 进行反编译去读代码,找到关键点去修改即可。将搜索从手机导出 apk 后在电脑端使用 JADX 打开 apk 反编译。

每次打开搜索的时候,发现它的默认占位词是:搜索本机和全网内容

所以利用这个作为突破口,在 JADA 搜索这个关键词后发现确实有眉目:

看了一下其中的 dark_word 是用于桌面组件的,寻思我也用不到桌面小组件就不管它了,就从第三个 dock_dark_word_default_all 进入:

入眼就是所谓的判断了,所以切入点直接走这里,直接将 if 判断 给 true 就不会出现热点轮播:

XposedHelpers.findAndHookMethod(
    "com.heytap.quicksearchbox.common.helper.EnterSourceManager",
    lpparam.classLoader,
    "c",
    object : XC_MethodHook() {
        override fun beforeHookedMethod(param: MethodHookParam) {}

        override fun afterHookedMethod(param: MethodHookParam) {
        XposedBridge.log("TestHookLog 1b:$param")
        param.result = true
    }
})

然后再打包更新确实有效果了,后续又找到一个入口方向,大概是这样的:

直觉给我就是给占位框输入广告词,就用日志输出:

val darkWordSetClass = lpparam.classLoader.loadClass("com.heytap.quicksearchbox.core.db.entity.DarkWordSet")
XposedHelpers.findAndHookMethod(
    "com.heytap.quicksearchbox.ui.widget.searchbar.BaseSearchBar",
    lpparam.classLoader,
    "F0",
    darkWordSetClass, String::class.java, String::class.java, Boolean::class.java, Boolean::class.java, String::class.java, Boolean::class.java, object : XC_MethodHook() {
    override fun beforeHookedMethod(param: MethodHookParam) {
        super.beforeHookedMethod(param)
        XposedBridge.log("TestHookLog 1b for args1:${param.args[0]}")
        XposedBridge.log("TestHookLog 1b for args2:${param.args[1]}")
        XposedBridge.log("TestHookLog 1b for args3:${param.args[2]}")
        XposedBridge.log("TestHookLog 1b for args4:${param.args[3]}")
        XposedBridge.log("TestHookLog 1b for args5:${param.args[4]}")
        XposedBridge.log("TestHookLog 1b for args6:${param.args[5]}")
        XposedBridge.log("TestHookLog 1b for args7:${param.args[6]}")
    }

    override fun afterHookedMethod(param: MethodHookParam) {
        super.afterHookedMethod(param)
        // XposedBridge.log("TestHookLog 1a for:${param.result}")
    }
})

从上面的 args1args2 打印知道传入广告占位的广告词,下面就简单的:

val darkWordSetClass = lpparam.classLoader.loadClass("com.heytap.quicksearchbox.core.db.entity.DarkWordSet")
XposedHelpers.findAndHookMethod(
    "com.heytap.quicksearchbox.ui.widget.searchbar.BaseSearchBar",
    lpparam.classLoader,
    "F0",
    darkWordSetClass, String::class.java, String::class.java, Boolean::class.java, Boolean::class.java, String::class.java, Boolean::class.java, object : XC_MethodHook() {
    override fun beforeHookedMethod(param: MethodHookParam) {
        super.beforeHookedMethod(param)
        param.args[0] = null
        param.args[1] = "请输入本机内容和全网内容"
    }

    override fun afterHookedMethod(param: MethodHookParam) {
        super.afterHookedMethod(param)
        // XposedBridge.log("TestHookLog 1a for:${param.result}")
    }
})

打包后确实生效了。

Hook 函数的复杂参数

例如会遇到要 hook 的函数:

public static boolean fun1(String[][] strAry, Map mp1, Map<String,String> mp2, Map<Integer, String> mp3, ArrayList<String> al1, ArrayList<Integer> al2, ArgClass ac)

可以利用 getDeclaredMethods 方法打印出这个函数的参数列表输出如下:

([[Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Ljava/util/Map;Ljava/util/ArrayList;Ljava/util/ArrayList;Laqcxbom/xposedhooktarget/ArgClass;)

梳理下如下:

String[][] ==> [[Ljava/lang/String;
Map 数组不论何种形式 ==> Ljava/util/Map;
ArrayList 无论何种形式 ==> Ljava/util/ArrayList;
ArgClass 自定义类给个全路径的事==> Laqcxbom/xposedhooktarget/ArgClass;

Xposed 项目下可以尝试这么写:

package com.iiong.xtcracked

import android.util.Log
import de.robv.android.xposed.IXposedHookLoadPackage
import de.robv.android.xposed.XC_MethodHook
import de.robv.android.xposed.XposedHelpers
import de.robv.android.xposed.callbacks.XC_LoadPackage.LoadPackageParam

class XposedMain : IXposedHookLoadPackage {
    var TAG: String = "AqCxBoM"
    private val mStrPackageName = "aqcxbom.xposedhooktarget" //HOOK APP目标的包名
    private val mStrClassPath = "aqcxbom.xposedhooktarget.MyClass" //HOOK 目标类全路径
    private val mStrMethodName = "fun1" //HOOK 目标函数名
    private fun LOGI(ct: String) {
        Log.d(TAG, ct)
    }
    
    override fun handleLoadPackage(loadPackageParam: LoadPackageParam) {
        //判断包名是否一致
        if (loadPackageParam.packageName == mStrPackageName) {
            LOGI("found target: " + loadPackageParam.packageName)
            val ArgClass = XposedHelpers.findClass(
                "aqcxbom.xposedhooktarget.ArgClass",
                loadPackageParam.classLoader
            )
            val ArrayList =
                XposedHelpers.findClass("java.util.ArrayList", loadPackageParam.classLoader)
            val Map = XposedHelpers.findClass("java.util.Map", loadPackageParam.classLoader)
            //包名一致时查找是否有匹配参数的类及函数
            XposedHelpers.findAndHookMethod(mStrClassPath,  //类路径
                loadPackageParam.classLoader,  //ClassLoader
                mStrMethodName,  //目标函数名
                "[[Ljava.lang.String;",  //参数1
                Map,  //参数2
                Map,  //参数3
                Map,  //参数4
                ArrayList,  //参数5
                ArrayList,  //参数6
                ArgClass,  //参数7
                object : XC_MethodHook() {
                    override fun beforeHookedMethod(param: MethodHookParam) {
                        super.beforeHookedMethod(param) //这个函数会在被hook的函数执行前执行
                        LOGI("beforeHook")
                    }
                    
                    override fun afterHookedMethod(param: MethodHookParam) {
                        super.afterHookedMethod(param) //这个函数会在被hook的函数执行后执行
                        LOGI("afterHooke param: ")
                    }
                })
        }
    }
}

去广告

现在安卓广告大多数都是国内几大广告商:腾讯、穿山甲、快手等几家,不过后面我懒得自己去定位查找,直接百度搜索发现相关的资料,不过大多数都是 Smail 语法。

比如想去掉穿山甲广告可以这么做:

XposedHelpers.findAndHookMethod("com.bytedance.pangle.Zeus", lpparam.classLoader, "hasInit", object : XC_MethodHook() {
    override fun beforeHookedMethod(param: MethodHookParam) {
        super.beforeHookedMethod(param)
    }

    override fun afterHookedMethod(param: MethodHookParam) {
        super.afterHookedMethod(param)
        param.result = false
    }
})

根据上面的定位可以知道 com.bytedance.pangle.Zeus.hasInit 是用于 SDK 是否初始化成功,如果成功后则加载广告业务,这里直接返回 false 来达到去广告效果,所以其他厂商广告大同小异,故不再详写了。

总结

在后面几天又摸索了几个逆向虽然还是容易绕弯子,不过现在有了入手点和思路无非就是多花点时间,到后续熟悉了话很快找到其中的关键点。