xposed入门: FDex2源码分析与实战登山赛车

准备

lsposed

xposed仅支持到android 7.0版本, 在更高的版本有许多替代, 最常用的便是lsposed

特性 Xposed LSPosed
Hook 方式 直接 Hook Zygote 进程 通过 Magisk / Riru 注入 Zygote
兼容性 旧版 Android(7.0-10.0) 适用于 Android 8.0+
模块管理 需要 Xposed Installer 需要 LSPosed Manager
性能影响 修改系统 Zygote,易损坏 仅 Hook 选定进程,更稳定
安全性 易被检测(如 SafetyNet) 更隐蔽,适用于新版本 Android

不过二者模块是几乎完全兼容的, 语法什么的都差不多

magisk

lsposed需要magisk配合才能使用

android studio

android开发必备

模块init

  1. android studio创建一个新项目

  2. 在main目录下新增一个libs目录, 将XposedBridge.jar放入, 放入后右击选择Add As Library(注: 该依赖也可以通过在线导入)

  3. 到build.gradle中将上一步在dependencies对应产生的引入依赖语句中的implementation 改为 compileOnly

  4. 在main目录下新增assets目录, 其中新建文件xposed_init, 添加一行内容包名.xposed入口类名

  5. 在main目录下新增/res/values目录, 创建文件arrays.xml, 用于管理模块作用域

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

  6. 修改AndroidManifest.xml在\标签中新增内容

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    <application>
    <!-- other information here -->
    <!-- 表明这是一个xposed模块, xposed通过这个标识识别模块, 不得更改 -->
    <meta-data
    android:name="xposedmodule"
    android:value="true" />
    <!-- 模块描述 -->
    <meta-data
    android:name="xposeddescription"
    android:value="Easy example which makes the status bar clock red and adds a smiley" />
    <!-- 最低要求xposed版本, 根据导入的xposed版本填 -->
    <meta-data
    android:name="xposedminversion"
    android:value="82" />
    <!-- 模块作用域 -->
    <meta-data
    android:name="xposedscope"
    android:resource="@array/xposedscope"/>
    </application>

文档速览

https://api.xposed.info/

快速过一遍文档中常用部分

XC_MethodHook

钩子回调类, 进行hook时一般需要创建该类的匿名子类

MethodHookParam

封装关于方法调用的信息, 也就是回hook的参数

XposedBridge

提供与 Xposed 框架交互的核心方法

XposedHelper

提供一些实用方法,用于快速hook, 反射和动态操作类、方法、字段等

入口

Xposed/LSPosed 模块的入口类通常需要实现 Xposed 框架的接口,用于接收 Hook 事件并执行 Hook 逻辑, 对于一个模块至少应该实现一个该类接口

主要有四类接口

IXposedHookLoadPackage

用于监听应用的加载过程并hook

重写方法: handleLoadPackage

参数:XC_LoadPackage.LoadPackageParam

示例

注意:当app内存在多个包时, 使用参数lpparam.packageName过滤也只能使用应用包名

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import de.robv.android.xposed.IXposedHookLoadPackage;
import de.robv.android.xposed.XC_LoadPackage;
import de.robv.android.xposed.XposedBridge;
import de.robv.android.xposed.XposedHelpers;

public class MainHook implements IXposedHookLoadPackage {
@Override
public void handleLoadPackage(XC_LoadPackage.LoadPackageParam lpparam) throws Throwable {
if (!lpparam.packageName.equals("com.target.app")) return; // 仅 Hook 目标应用
XposedBridge.log("Hooking: " + lpparam.packageName);

// 示例:Hook getPackageName() 方法
XposedHelpers.findAndHookMethod(
"android.app.Application", lpparam.classLoader, "getPackageName",
new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
param.setResult("com.fake.package");
}
}
);
}
}

IXposedHookInitPackageResources

监听资源加载并hook, 用于修改目标应用的 UI 资源,例如替换图片、修改文本等

重写方法: handleInitPackageResources

参数:XC_InitPackageResources.InitPackageResourcesParam

示例:

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

public class ResourceHook implements IXposedHookInitPackageResources {
@Override
public void handleInitPackageResources(XC_InitPackageResources.InitPackageResourcesParam resparam) throws Throwable {
if (!resparam.packageName.equals("com.target.app")) return;

// 替换字符串资源(如修改 App 标题)
resparam.res.setReplacement("com.target.app", "string", "app_name", "Hooked App!");

// 替换图片资源
resparam.res.setReplacement("com.target.app", "drawable", "app_icon", XposedHelpers.findSystemClass("com.myhook.module.R.drawable.new_icon"));
}
}

IXposedHookZygoteInit

系统启动时 进行 Hook,影响所有应用

重写方法:initZygote

参数:StartupParam

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import de.robv.android.xposed.IXposedHookZygoteInit;
import de.robv.android.xposed.XposedBridge;
import de.robv.android.xposed.XposedHelpers;

public class ZygoteHook implements IXposedHookZygoteInit {
@Override
public void initZygote(StartupParam startupParam) throws Throwable {
XposedBridge.log("Zygote Hook Initialized!");

// Hook 所有应用的 getDeviceId()
XposedHelpers.findAndHookMethod(
"android.telephony.TelephonyManager", null, "getDeviceId",
new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
param.setResult("000000000000000");
}
}
);
}
}

接口组合

在一个 Xposed 模块中,可以同时实现多个 Hook 接口,例如:

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
import de.robv.android.xposed.*;
import de.robv.android.xposed.callbacks.*;

public class MainHook implements IXposedHookLoadPackage, IXposedHookInitPackageResources {
@Override
public void handleLoadPackage(XC_LoadPackage.LoadPackageParam lpparam) throws Throwable {
if (!lpparam.packageName.equals("com.target.app")) return;
XposedBridge.log("Hooking: " + lpparam.packageName);

// Hook getPackageName()
XposedHelpers.findAndHookMethod("android.app.Application", lpparam.classLoader, "getPackageName",
new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
param.setResult("com.fake.package");
}
});
}

@Override
public void handleInitPackageResources(XC_InitPackageResources.InitPackageResourcesParam resparam) throws Throwable {
if (!resparam.packageName.equals("com.target.app")) return;

// 修改界面
resparam.res.setReplacement("com.target.app", "string", "app_name", "Hooked App!");
}
}

de.robv.android.xposed.callbacks

包含了 Xposed 框架中的回调接口和类,用于处理模块与框架之间的交互

de.robv.android.xposed.services

提供了与系统服务交互的工具类,通常用于读取文件、获取系统信息等操作

主要就是BaseService

案例分析

FDex2源码分析

FDex2是早期针对整体加固的脱壳xposed模块, 其源码并不复杂

其依赖了android7.0以前的两个特殊方法getDexgetBytes(这两个方法在android8开始废弃)

android中java.lang.Class类有一个名为getDex的方法, 可以获得某一个类的Dex对象

1
2
3
4
5
6
public Dex getDex() {
if (dexCache == null) {
return null;
}
return dexCache.getDex();
}

Dex又有一个方法名为getBytes, 可以获取Dex对象的内存数据

1
2
3
4
5
6
7
public byte[] getBytes() {
ByteBuffer data = this.data.duplicate(); // positioned ByteBuffers aren't thread safe
byte[] result = new byte[data.capacity()];
data.position(0);
data.get(result);
return result;
}

FDex2首先就是通过反射获取这两个类的引用

1
2
3
4
5
6
7
8
9
10
11
12
13
public void initRefect(){
try {
// public byte[] getBytes()
Dex = Class.forName("com.android.dex.Dex");
Dex_getBytes = Dex.getDeclaredMethod("getBytes",null);
// public Dex getDex()
getDex = Class.forName("java.lang.Class").getDeclaredMethod("getDex",null);
} catch (ClassNotFoundException e) {
e.printStackTrace();
} catch (NoSuchMethodException e) {
e.printStackTrace();
}
}

接着使用xposed对java.lang.ClassLoaderloadClass方法进行hook

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@Override
public void handleLoadPackage(XC_LoadPackage.LoadPackageParam lpparam) throws Throwable {
initRefect();
XposedBridge.log("目标包名:"+ lpparam.packageName);
String str = "java.lang.ClassLoader";
String str2 = "loadClass";
final String packagename = "com.jiongji.andriod.card";

// protected Class<?> loadClass(String className, boolean resolve) throws ClassNotFoundException
if(lpparam.packageName.equals(packagename)){
// public static Unhook findAndHookMethod(String var0, ClassLoader var1, String var2, Object... var3)
XposedHelpers.findAndHookMethod(str, lpparam.classLoader, str2, String.class, Boolean.TYPE, new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) throws Throwable {
super.afterHookedMethod(param);
//获取hook函数返回类
Class cls = (Class) param.getResult();
if(cls == null){
return;
}
String name = cls.getName();
XposedBridge.log("当前类名:"+name);
byte[] bArr = (byte[]) Dex_getBytes.invoke(getDex.invoke(cls,null)); //Class.getDex().getBytes()
if(bArr == null) {
XposedBridge.log("数据为空,返回");
return;

}
XposedBridge.log("开始写数据");
String dex_path = "/data/data/"+packagename+"/"+packagename+"_"+bArr.length+".dex";
XposedBridge.log(dex_path);
File file = new File(dex_path);
if(file.exists()){
return;
}
writeByte(bArr,file.getAbsolutePath());
}

@Override
protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
super.beforeHookedMethod(param);
}
});
}

}

其入口是IXposedHookLoadPackage, 在应用加载时触发回调

之后使用方法findAndHookMethod进行hook, 该方法有2个重载

这里选择的是

1
2
3
4
5
6
public static Unhook findAndHookMethod(
String className, // 目标类的全限定名
ClassLoader classLoader, // 目标类的类加载器
String methodName, // 目标方法名
Object... parameterTypesAndCallback // 方法参数类型和回调
)

第四个参数是可变参数, 用于指定目标方法的参数类型和回调逻辑, 且参数类型的顺序必须与目标方法的签名一致(避免有重载的情况)

源码中就是hook java.lang.ClassLoaderloadClass方法

待其执行完毕后从返回值获取到加载得到的类, 并调用getDex方法和getBytes方法从而获取到对应的内存字节流并将其保存

1
2
3
4
5
6
7
8
9
10
public void writeByte(byte[] bArr,String str){
try {
OutputStream outputStream = new FileOutputStream(str);
outputStream.write(bArr);
outputStream.close();
} catch (IOException e) {
e.printStackTrace();
XposedBridge.log("文件写出失败");
}
}

这个脚本完全也可以通过frida来实现

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
31
32
33
34
35
36
37
38
39
40
41
42
43
function savedex(dexbytes, dexpath) {
Java.perform(function () {
var File = Java.use("java.io.File");
var FileOutputStream = Java.use("java.io.FileOutputStream");
var fileobj = File.$new(dexpath);
var fileOutputStreamobj = FileOutputStream.$new(fileobj);
fileOutputStreamobj.write(dexbytes);
fileOutputStreamobj.close();
console.warn("[dumpdex] Dex saved to: " + dexpath);
});
}

function fdex2(classname) {
Java.perform(function () {
Java.enumerateClassLoadersSync().forEach(function (loader) {
try {
var ThisClass = loader.loadClass(classname);
if (ThisClass) {
console.log("[dumpdex] Found class: " + classname + " in loader: " + loader);

var dexobj = ThisClass.getDex();
if (dexobj) {
var dexbytearray = dexobj.getBytes();
if (dexbytearray) {
var savedexpath = "/sdcard/" + classname.replace(/\./g, "_") + ".dex";
savedex(dexbytearray, savedexpath);
} else {
console.error("[dumpdex] Failed to get Dex bytes for class: " + classname);
}
} else {
console.error("[dumpdex] Failed to get Dex object for class: " + classname);
}
}
} catch (e) {
console.error("[dumpdex] Error while processing loader: " + loader + ", error: " + e.message);
}
});
});
}

Java.perform(function () {
fdex2("com.example.target.TargetClass");
});

实战登山赛车

找款小游戏试试手, 登山赛车版本1.48.18, 没有加壳

游戏打开竟然要求实名

且该窗口无法关闭, 所以首先得想办法pass掉实名验证

jadx打开, 通过activity记录能够知道, 由MyStartActivity启动了IDCheckDialog进行验证

一开始的想法是hook点击确定后的doRidCheck函数

但发现这个函数怎么都hook不到, 但他的同级函数却可以被hook, 暂时搞不明白

发现只有当actAsIdVerifyActivity为true时才会进入验证过程, 其又依赖于loginActivityExists的返回值

private boolean actAsIdVerifyActivity = !loginActivityExists();

所以我们直接hook loginActivityExists的返回值为true

先使用frida尝试

1
2
3
4
5
6
7
8
9
Java.perform(function () {
let MyStartActivity = Java.use("com.mygamez.common.MyStartActivity");
MyStartActivity["loginActivityExists"].implementation = function () {
console.log("MyStartActivity.loginActivityExists is called");
let result = this["loginActivityExists"]();
console.log("MyStartActivity.loginActivityExists result=" + result);
return true;
};
});

成功加载但显然后续还有其他检测, 通过关键字可以知道游戏检测到了我们是游客模式

在防沉迷类下找到这三个函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Java.perform(function () {
let MyStartActivity = Java.use("com.mygamez.common.MyStartActivity");
MyStartActivity["loginActivityExists"].implementation = function () {
console.log("MyStartActivity.loginActivityExists is called");
let result = this["loginActivityExists"]();
console.log("MyStartActivity.loginActivityExists result=" + result);
return true;
};

let AntiAddictionManagerInstance = Java.use("com.mygamez.common.antiaddiction.AntiAddictionManager$AntiAddictionManagerInstance");
AntiAddictionManagerInstance["isGuestMode"].implementation = function () {
console.log("AntiAddictionManagerInstance.isGuestMode is called");
return false;
};

AntiAddictionManagerInstance["getUserAge"].implementation = function () {
console.log("AntiAddictionManagerInstance.getUserAge is called");
let result = this["getUserAge"]();
console.log("AntiAddictionManagerInstance.getUserAge result=" + result);
return 20;
};
});

之后就可以成功进入游戏, 尝试内购

提示需要安装微信, 通过字符串找到检测处

然后其调用处判断返回值是否是空串, 如果是则说明没有错误, 所以hook其返回值始终返回空

接着继续搜索购买失败, 找到对应关键处

可以知道只有getResultCode返回1时才是购买成功, hook其返回值

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
31
32
33
34
35
36
37
Java.perform(function () {
let MyStartActivity = Java.use("com.mygamez.common.MyStartActivity");
MyStartActivity["loginActivityExists"].implementation = function () {
console.log("MyStartActivity.loginActivityExists is called");
let result = this["loginActivityExists"]();
console.log("MyStartActivity.loginActivityExists result=" + result);
return true;
};

let AntiAddictionManagerInstance = Java.use("com.mygamez.common.antiaddiction.AntiAddictionManager$AntiAddictionManagerInstance");
AntiAddictionManagerInstance["isGuestMode"].implementation = function () {
console.log("AntiAddictionManagerInstance.isGuestMode is called");
return false;
};

AntiAddictionManagerInstance["getUserAge"].implementation = function () {
console.log("AntiAddictionManagerInstance.getUserAge is called");
let result = this["getUserAge"]();
console.log("AntiAddictionManagerInstance.getUserAge result=" + result);
return 20;
};
let BillingWrapperWeChat = Java.use("com.mygamez.billing.BillingWrapperWeChat");
BillingWrapperWeChat["checkIfOkToContinuePaymentPaymentSpecificRequirements"].implementation = function () {
console.log("BillingWrapperWeChat.checkIfOkToContinuePaymentPaymentSpecificRequirements is called");
let result = this["checkIfOkToContinuePaymentPaymentSpecificRequirements"]();
console.log("BillingWrapperWeChat.checkIfOkToContinuePaymentPaymentSpecificRequirements result=" + result);
return "";
};

let BillingResult = Java.use("com.mygamez.billing.BillingResult");
BillingResult["getResultCode"].implementation = function () {
console.log("BillingResult.getResultCode is called");
let result = this["getResultCode"]();
console.log("BillingResult.getResultCode result=" + result);
return 1;
};
});

此时已经破解完成, 接下来将frida代码转为xposed

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
package com.example.xposed;

import de.robv.android.xposed.IXposedHookLoadPackage;
import de.robv.android.xposed.XC_MethodHook;
import de.robv.android.xposed.XposedBridge;
import de.robv.android.xposed.XposedHelpers;
import de.robv.android.xposed.callbacks.XC_LoadPackage;

public class main implements IXposedHookLoadPackage {
@Override
public void handleLoadPackage(final XC_LoadPackage.LoadPackageParam lpparam) throws Throwable {
if (!lpparam.packageName.equals("com.fingersoft.hillclimb.noncmcc")) {
return;
}

// Hook MyStartActivity.loginActivityExists
XposedHelpers.findAndHookMethod("com.mygamez.common.MyStartActivity", lpparam.classLoader, "loginActivityExists", new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
XposedBridge.log("loginActivityExists is called");
}

@Override
protected void afterHookedMethod(MethodHookParam param) throws Throwable {
XposedBridge.log("loginActivityExists result=" + param.getResult());
param.setResult(true);
}
});

// Hook AntiAddictionManager$AntiAddictionManagerInstance.isGuestMode
XposedHelpers.findAndHookMethod("com.mygamez.common.antiaddiction.AntiAddictionManager$AntiAddictionManagerInstance", lpparam.classLoader, "isGuestMode", new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
XposedBridge.log("isGuestMode is called");
}

@Override
protected void afterHookedMethod(MethodHookParam param) throws Throwable {
param.setResult(false);
}
});

// Hook AntiAddictionManager$AntiAddictionManagerInstance.getUserAge
XposedHelpers.findAndHookMethod("com.mygamez.common.antiaddiction.AntiAddictionManager$AntiAddictionManagerInstance", lpparam.classLoader, "getUserAge", new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
XposedBridge.log("getUserAge is called");
}

@Override
protected void afterHookedMethod(MethodHookParam param) throws Throwable {
XposedBridge.log("getUserAge result=" + param.getResult());
param.setResult(20);
}
});

// Hook BillingWrapperWeChat.checkIfOkToContinuePaymentPaymentSpecificRequirements
XposedHelpers.findAndHookMethod("com.mygamez.billing.BillingWrapperWeChat", lpparam.classLoader, "checkIfOkToContinuePaymentPaymentSpecificRequirements", new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
XposedBridge.log("checkIfOkToContinuePaymentPaymentSpecificRequirements is called");
}

@Override
protected void afterHookedMethod(MethodHookParam param) throws Throwable {
XposedBridge.log("checkIfOkToContinuePaymentPaymentSpecificRequirements result=" + param.getResult());
param.setResult("");
}
});

// Hook BillingResult.getResultCode
XposedHelpers.findAndHookMethod("com.mygamez.billing.BillingResult", lpparam.classLoader, "getResultCode", new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
XposedBridge.log("getResultCode is called");
}

@Override
protected void afterHookedMethod(MethodHookParam param) throws Throwable {
XposedBridge.log("getResultCode result=" + param.getResult());
param.setResult(1);
}
});
}
}

安装并启用模块