Frida逆向与协议分析-2

 frida逆向与协议分析第二部分。

0x03 Frida逆向之违法App协议分析与取证实战

 之前介绍了frida定位关键类的两种方式:基于trace(objection、Zentracer)、基于内存(Java.choose寻找实例)。本章以两个违法样本为例,对app关键协议进行分析,从而巩固之前的知识。

加固app协议分析

抓包

 抓包往往可以快速定位关键接口函数的位置

 抓包原理:在手机上设置代理,将手机流量数据转发到计算机的代理软件后再完成上网,这样就可以再计算机上监听手机上的流量数据。由于中间人抓包无法应对app使用Https等加密协议进行通信的情况,因此需要将代理软件的证书导入手机系统并添加到证书信任列表中。如果app不信任用户添加到系统中的证书,那么需要将证书从用户信任去移动到系统信任列表中

 代理方式有两种:

(1)wifi代理(应用层),如下所示:

image-20231009213402929

 这种方式有两个弊端:(a)无法处理非http通信,例如websocket。(b)容易被app检测到,相关代码为:

1
System.getProperty("http.proxyHost")

(2)vpn代理(更加推荐),相当于虚拟新网卡并修改手机路由表,工具为postern(注意要匹配所有地址)。

image-20231009230301580

 linux中间人中安装charles,并设置代理:

image-20231009231938941

 movetv.apk的登陆界面抓包如下:

image-20231009234020487

image-20231009234104412

注册/登录协议分析

 通过抓包分析,name与pass分别代表用户名与密码,而login始终为”login”。而key、rightkey、memi1等字段暂时还不确定。

 要对这些字段进行分析,需要找到字段形成的地方。除了静态工具分析外,推荐使用前面介绍的快速定位关键类的方式,即基于内存枚举的关键类定位方案,以确定字段形成的位置。

 在此,基于用户登录一定要单击登录按钮的特性,而按钮button属于view的继承类,因此可以通过hook view类的onClick函数快速得到当前控件的onClick函数所在的类,如下hookEvent.js所示:

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
var jclazz = null
var jobj = null

function getObjClassName(obj){
if(!jclazz){
var jclazz = Java.use("java.lang.Class")
}
if(!jobj){
var jobj = Java.use("java.lang.Object")
}
return jclazz.getName.call(jobj.getClass.call(obj))
}

function watch(obj, mtdName){
var listener_name = getObjClassName(obj)
var target = Java.use(listener_name)
// target should have onClick method
if(!target || !mtdName in target){
return
}
target[mtdName].overloads.forEach(function(overload){
overload.implementation = function(){
console.log("[WatchEvent] " + mtdName + ": " + getObjClassName(this))
return this[mtdName].apply(this, arguments)
}
})
}

function OnClickListener(){
Java.perform(function(){
// start in spawn mode
Java.use("android.view.View").setOnClickListener.implementation = function(listener){
if(listener != null){
watch(listener, 'onClick')
}
return this.setOnClickListener(listener)
}
// start in attach mode
Java.choose("android.view.View$ListenerInfo", {
onMatch: function(instance){
instance = instance.mOnClickListener.value
if(instance){
console.log("mOnClickListener : " + getObjClassName(instance))
watch(instance, 'onClick')
}
},
onComplete: function(){}
})
})
}
setImmediate(OnClickListener)
1
2
// 注入到前台 app 中
frida -U -F -l hookEvent.js

 结果为:

image-20231010110906554

 因此,找到字段形成的地方为com.cz.babySister.activity.LoginActivity

 之后,要得到此类具体的代码,可以通过静态反编译工具进行。发现进行加壳处理

image-20231010112211340

 可以利用脱壳工具进行脱壳,例如一代frida_dexdump,二代抽取脱壳frida_fart|FART等。在此使用frida_dexdump,流程见链接。脱壳出5个dex,在dex3中找到:

image-20231010115547318

 其中,猜测b()函数应该是提交用户名与密码到服务器的函数。使用objection对b()函数进行注入:

1
2
objection -g com.cz.babySister explore
android hooking watch class_method com.cz.babySister.activity.LoginActivity.b --dump-args --dump-return --dump-backtrace

 结果如下所示:

image-20231010120458729

 因此确定b()传递用户名与密码。在用静态分析跟踪实现,最终确认到了q()函数,但是跟进后发现全为nop,如下所示:

image-20231010125607830

 经过查询资料,发现是二代抽取加固,使用frida_fart,按照链接进行脱壳(注意到对movetv.apk设置读取sd卡的权限)。经历千辛万苦,终于找到了正常的逻辑:

image-20231010152911816

 依次跟进v5(key)、v6(rightkey)、v4(memi1)的实现逻辑,如下所示:

image-20231010153119863

image-20231010153237044

image-20231010153542277

 审计上述代码,发现:v5(key)实际上是app的签名,v6(rightkey)是app所安装的包的证书部分,v4(memi1)是android_id。这些值都是固定值,因此,无需借助app,只需要输入正确的用户名与密码,再传入固定的key、rightkey、memi1即可。

 app注册的逻辑与登陆类似。最终可以用python实现脱机注册与登录,如下所示:

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
# invoke.py
import requests
requests.packages.urllib3.disable_warnings()

class tv:
def __init__(self):
self.root = "http://39.108.64.125/WebRoot/superMaster/Server"
self.memi1 = "34463b7d13ee6ad9"
self.rightkey = "376035775"
self.key = "308202d5308201bda00302010202041669d9bf300d06092a864886f70d01010b0500301b310b3009060355040613023836310c300a06035504031303776569301e170d3136303731383038313935395a170d3431303731323038313935395a301b310b3009060355040613023836310c300a0603550403130377656930820122300d06092a864886f70d01010105000382010f003082010a028201010095f85892400aae03ca4ed9dcd838d162290ae8dd51939aac6ecfde8282f207c4cd9e507929a279e0a36f1e4847330cb53908c92915b2c6a93d7064be452d073a472093f7ca14f4ab68f827582fe0988e9e4bc8a6ea3b56001cbbbb760f9eec571b0bbc97392e65aaf08c686f0e2ba353896d48a37c36716239977bd0e4dd878025cab497d8164537aec9f6599eefb98577dce972a1b794e211226520e23497beec3fd8548bb5b4d263120d40115cca28116bac32378df5033f536a0d7367fef78c587fefed28c5c9b35ba684ed6e46d9369c40950cf7ad7236d10b7a51dfd2a8f218db72323bbd19f46947410b1191f263012ad4ba8f749223e37591254ee7f50203010001a321301f301d0603551d0e041604143d43284bd5e4b0d322c9962a5b70aad4dcbc3634300d06092a864886f70d01010b050003820101000f04c51ff763311aa011777ba2842b441b15c316373d1e1ed4116cf86e29d55c6ed3fa4c475251b1fb4fac57195dbca0166ebe565d9834552a3758b97c4528bab1f7ab82bb3a9faa932f5bc10943f3daf52e0fe5889ffb58a6be67ea1c9a2fb37dc8aa6f3af476039a467336991a4e52dccd520195cd473eb5b984e702ed9ff638a14c3abb575a7a80ae4062084d1138a06a20e173be9df32df631311b07352898706198ddebaaa011f0da8e5f288f7cfb77505bc943f6476d6cc1feef56b68137aad91f23c4bb772169539d05653a6f0d75f7192164e822b934322f3a975df677903b1667f5dc1e9ddb185da3281d31bfb8f67a84bd23bbcb398f8bb637dd72"

def post(self, data=None):
if data is None:
data = {}
return requests.post(url = self.root, data = data)

def register(self, name, pw):
ret = self.post({
"name":name,
"pass":pw,
"memi1":self.memi1,
"key":self.key,
"rightkey":self.rightkey,
"register":"register"
})
print("register: ", ret.content.decode('utf-8'))

def login(self, name, pw):
ret = self.post({
"name":name,
"pass":pw,
"memi1":self.memi1,
"key":self.key,
"rightkey":self.rightkey,
"login":"login"
})
print("login: ", ret.content.decode('utf-8'))

if __name__ == "__main__":
tv = tv()
# register
print(tv.register("wd2711", "1111"))

# login
print(tv.login("wd2711", "1111"))

 结果如下所示:

image-20231010155627948

违法应用取证分析与vip破解

 安装fulao2.apk。

1
adb install fulao2.apk

vip清晰度破解

 手机app对应的服务器好像寄了,如下所示:

image-20231010163343163

 ok,那抓不了包了,日了。那直接定位关键类,由于书中写了,清晰度切换是一个按钮控件,这需要vip。因此,我们只需要使用之前抓取按钮的脚本hookEvent.js即可。但是由于我们进不去页面呀(hookEvent.js,此时清晰度控件应该还未加载),只能抓取到:

image-20231010164845820

 正常的话,应该抓取到com.ilulutv.fulao2.film.l$t

 且验证此apk未加壳(PKID不准,movetv.apk放进去也显示这个):

image-20231010165814405

 定位到com.ilulutv.fulao2.film.l$t这个类,如下所示,可以看到是一个判断语句。

image-20231010170758627

 之后分析各个判断语句之后执行的操作:

(1)this.d.i:生成一个对话框,其中要求升级vip。

image-20231010172852843

(2)…

 那么,如果将l.d(this.d)的值设置为true即可。由于此app已无法正常访问,因此以书中为准。使用frida脚本在内存中修改l.d(this.d)的值。l.d的实现如下,其中arg0为l类型:

image-20231010173538290

 脚本如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// hookq0.js
function hookq0(){
Java.perform(function(){
Java.choose("com.ilulutv.fulao2.film.l", {
onMatch:function(instance){
console.log("found instance =>", instance)
instance.q0.value = true
},
onComplete:function(){
console.log("completed")
}
})
})
}

 最终调用成功了,可惜我这无法验证,呜呜。

图片取证分析

 之后分析app协议内容。协议分析的第一步:针对样本流量的抓取与关键字段的定位

 本次抓包不使用之前的中间人抓包(charles|postern),而是使用hook抓包,其优势在于:(1)抓取的流量更加专一,不会受到手机上其他app流量的影响;(2)可以避免app本身对抗抓包的姿势,例如服务器校验客户端,SSL Pinning等。但是,如果app有对抗frida等工具的手段,那么就需要bypass。(3)hook可以打印调用栈,因此可确定函数之前经过的所有函数。

 hook抓包的效果取决于找到的hook点。(1)android有很多封装网络通信的库,例如okhttp3、retrofit。因此,可能需要针对不同的库设置hook点。(2)对于通信协议而言,只抓取http的hook点也不够全面。之后装逼时刻,r0ysue开发了r0capture,hook系统中在socket层发送和接收数据包的关键函数,基本上能抓所有流量

 虽然跑不了,但是分析一波r0capture,此书中利用r0capture抓取了图片(挖个坑)。抓取后的图片是加密的。那之后,就要找到图片解密的点。图片要加载的时候,此时图片就被解密

 android中加载图片的过程:使用BitmapFactory类中的函数加载bitmap,最终通过ImageView加载Bitmap类型的图片。

 为了验证这个过程,使用objection插件wallbreaker在内存中搜索Bitmap对象,手动触发图片的加载后再次搜索Bitmap对象,前后数量不一致,因此可以确定此app使用Bitmap对象来保存图片。

 android开发中,BitmapFactory中有4个静态方法:decodeFile、decodeResource、decodeStream、decodeByteArray,分别用于从文件系统、资源、输入流、字节数组中加载出Bitmap对象。更暴力一点,对BitmapFactory中所有函数进行hook,如下所示:

1
android hooking watch class android.graphics.BitmapFactory 

 最终发现是使用decodeByteArray函数(static),此函数的第1个参数就是原始图片的字节信息。为了确认此图片信息为明文,使用frida脚本获取参数并保存。saveBitmap.js代码如下所示:

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
// generate random string as image name
function guid(){
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function(c){
// generate r in [0:1:15]
var r = Math.random() * 16 | 0
var v = c == "x" ? r : (r&0x3|0x8)
return v.toString(16)
})
}

// hook decodeByteArray
// decodeByteArray(byte[] data, int offset, int length, Options opts)
function saveBitmap_1(){
Java.perform(function(){
var BitmapFactory = Java.use("android.graphics.BitmapFactory")
var decodeByteArray_func = decodeByteArray.overload("[B", "int", "int", "android.graphics.BitmapFactory$Options")
decodeByteArray_func.implementation = function(data, offset, length, opts){
var result = this.decodeByteArray(data, offset, length, opts)
var path = "/sdcard/Download/tmp" + guid() + ".jpg"
console.log("path =>", path)
var f = Java.use("java.io.File").$new(path)
var fos = Java.use("java.io.FileOutputStream").$new(f)
fos.write(data)
fos.close()
return result
}
})
}
setImmediate(saveBitmap_1)

 之后,对上述脚本中的saveBitmap_1进行修改,以适应最后的rpc操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// hookBitmap.js
function saveBitmap_1(){
Java.perform(function(){
var BitmapFactory = Java.use("android.graphics.BitmapFactory")
var decodeByteArray_func = BitmapFactory.decodeByteArray.overload("[B", "int", "int", "android.graphics.BitmapFactory$Options")
decodeByteArray_func.implementation = function(data, offset, length, opts){
var result = this.decodeByteArray(data, offset, length, opts)
/*
var path = "/sdcard/Download/tmp" + guid() + ".jpg"
console.log("path =>", path)
var f = Java.use("java.io.File").$new(path)
var fos = Java.use("java.io.FileOutputStream").$new(f)
fos.write(data)
fos.close()
*/
send(data)
return result
}
})
}
setImmediate(saveBitmap_1)

 相应python脚本为:

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
# saveBitmap.py
import frida
import json
import time
import uuid

def my_message_handler(message, payload):
if message["type"] == "send":
image = message["payload"]

intArr = []
for m in image:
ival = int(m)
if ival < 0:
ival += 256
intArr.append(ival)
bs = bytes(intArr)

# 根据 uuid 生成文件名
fileName = str(uuid.uuid1()) + ".jpg"
print("fileName:", fileName)
f = open(filename, "wb")
# 写入字节流
f.write(bs)
f.close()

device = frida.get_usb_device()
# 获得最上方的应用
target = device.get_frontmost_application()
session = device.attach(target.pid)
with open("hookBitmap.js") as f:
script = session.create_script(f.read())
# 错误处理
script.on("message", my_message_handler)
script.load()

 运行saveBitmap.py后,再对图片进行刷新,就可以下载图片啦。但是!我们目的不是为了下载图片,而是为了分析协议,也就是对如何对图片进行的解密

 我们使用objection hook decodeByteArray并打印调用栈:

1
android hooking watch class_method android.graphics.BitmapFactory.decodeByteArray "[B, int, int, android.graphics.BitmapFactory$Options" --dump-backtrace 

 发现业务层代码com.ilulutv.fulao2.other.helper.glide.b.a。使用jeb找到此函数,如下所示:

image-20231010201958270

 跟进解密函数,其中arg3为密钥Key,arg4为向量IV,arg5是要解密的数据。

image-20231010202233421

 因此,可以使用主动调用的方式对key与IV进行获取,如下所示:

1
2
3
4
5
6
7
8
9
// getKey.js
function getKey(){
Java.perform(function(){
var CipherClient = Java.use("net.idik.lib.cipher.so.CipherClient")
var key = CipherClient.decodeImgKey()
var iv = CipherClient.decodeImgIv()
console.log(key, iv)
})
}

 获取到key与IV之后,采用rpc的方式来模拟,从而实现最终的脱机抓取图片数据。经过分析,前面抓包得到的图片数据其实是com.ilulutv.fulao2.other.i.b.a函数的返回值,因此可以写脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
// final.js
function get_image_ciphertext(){
Java.perform(function(){
var b = Java.use("com.ilulutv.fulao2.other.i.b")
var a_func = b.a.overload("java.nio.ByteBuffer")
a_func.implementation = function(obj){
var result = this.a(obj)
send(result)
return result
}
})
}

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
# final.py
import base64
from Crypto.Cipher import AES

def IMGdecrypt(bytearray):
key = "xxxxx"
iv = "xxxx"
imgkey = base64.decodebytes(bytes(key), encoding="utf-8")
imgiv = base64.decodebytes(bytes(iv), encoding="utf-8")
cipher = AES.new(imgkey, AES.MODE_CBC, imgiv)
msg = cipher.decrypt(bytearray)
return msg

def my_message_handler(message, payload):
if message["type"] == "send":
image = message["payload"]
intArr = []
for m in image:
ival = int(m)
if ival < 0:
ival += 256
intArr.append(ival)
bs = bytes(intArr)
bs = IMGdecrypt(bs)
# 根据 uuid 生成文件名
fileName = str(uuid.uuid1()) + ".jpg"
print("fileName:", fileName)
f = open(filename, "wb")
# 写入字节流
f.write(bs)
f.close()

device = frida.get_usb_device()
# 获得最上方的应用
target = device.get_frontmost_application()
session = device.attach(target.pid)
with open("final.js") as f:
script = session.create_script(f.read())
# 错误处理
script.on("message", my_message_handler)
script.load()

0x04 Xposed Hook及主动调用与RPC实现

 Xposed 是 Frida 的前辈,其作为系统框架类型的 hook 思想对安卓安全研究有很大的影响。EdXposed 是 Xposed 的后续产品。本章主要介绍 Xposed 基本使用,并与 frida 做对比。

Xposed 应用 hook

Xposed 安装&Xposed插件(模块)

 需要在 Root 环境下通过 XposedInstaller App 安装对应系统的 xposed 框架,安装成功后,接着安装相应的 hook 框架并重启,从而完成对目标进程的 hook。xposed 本质上是替换安卓系统中的 zygote 与libart.so 库,来将 XposedBridge.jar 注入到应用中,从而实现进程的 hook。安装 xposed 需要满足:安卓版本小于等于7.1,系统 root。

 经过一番周折,终于将 Xposed 安装上了。

 Xposed插件以App的形式安装到系统中。举一个xposed插件(app)的例子,以说明xposed插件的开发流程。

(1)修改AndroidManifest.xml,在application节点下增加meta-data属性:

image-20231019184544598

(2)在app目录下新建assets目录,并新建xposed_init文件,文件中填入Xposed模块入口类的完整类名,在此为:com.roysue.xposed1.HookTest

(3)MainActivity.java中填入:

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
package com.roysue.xposed1;

import androidx.appcompat.app.AppCompatActivity;

import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.Toast;

// AppCompatActivity 用于构建兼容的 Android 应用程序
public class MainActivity extends AppCompatActivity {
private Button button;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// 设置 activity_main.xml 为当前布局
setContentView(R.layout.activity_main);
// 在当前布局中寻找 button
button = (Button) findViewById(R.id.button);
button.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
// 在屏幕底部提示消息
Toast.makeText(MainActivity.this, toastMessage("我未被劫持"), Toast.LENGTH_SHORT).show();
}
});
}
public String toastMessage(String message) {
return message;
}
}

 若没有hook代码,那么打开此插件后,会提示我未被劫持。下面,我们hook的目标为:打印toastMessage函数的参数,并且修改返回值为"你已被劫持"。那么该如何做咧?

(1)之前,在xposed_init中写入了com.roysue.xposed1.HookTest,从而指定了hook相关的类。我们要在此类中实现IXposedHookLoadPackage接口。之后,每个由Zygote孵化出的进程启动时都会调用接口中的handleLoadPackage函数。如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
package com.roysue.xposed1;

// 其他的 import 在此先省略

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

public class HookTest implements IXposedHookLoadPackage {
public void handleLoadPackage(XC_LoadPackage.LoadPackageParam loadPackageParam) throws Throwable {
// 函数逻辑 logic-1 在此先省略
}
// 其他函数在此先省略
}

 如果想要hook指定进程,就需要对上述代码中的loadPackageParam进行过滤。loadPackageParam中包括一些成员变量,如下所示:

成员变量类型 成员变量名 含义
String packageName 包名
String processName 进程名
ClassLoader classLoader 进程的类加载器
ApplicationInfo appInfo 其他信息

 通常使用packageName进行过滤。

(2)锁定目标进程后,接下来对目标进程的函数进行hook,其中需要使用xposed相关的类:XposedHelpers。此类中提供了java类、类成员的接口函数。在此例中,需要使用此类中的findAndHookMethod函数。此函数参数为:要hook函数所在类的handle、函数名、函数参数列表、hook回调类XC_MethodHook。在将XC_MethodHook作为参数传给findAndHookMethod之前,需要实现抽象回调函数:beforeHookedMethodafterHookedMethod。顾名思义,不必多说。

 因此,可以完善 logic-1,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 打印 hook 日志
import de.robv.android.xposed.XposedBridge;
// log 日志
import android.util.Log;

// 使用 packageName 进行过滤
if(loadPackageParam.packageName.equals("com.roysue.xposed1")){
XposedBridge.log("has hooked!");
XposedBridge.log("inner" + loadPackageParam.processName);
Class clazz = loadPackageParam.classLoader.loadClass("com.roysue.xposed1.MainActivity");
// 使用 findAndHookMethod 来 hook 函数
XposedHelpers.findAndHookMethod(clazz, "toastMessage", String.class, new XC_MethodHook(){
protected void beforeHookedMethod(MethodHookParam param) throws Throwable{
String oldText = (String) param.args[0];
Log.d("oldText", oldText);
param.args[0] = "您已被劫持";
}
protected void afterHookedMethod(MethodHookParam param) throws Throwable{
}
});
}

 在Xposed Installer中激活此插件(模块),并重启系统。最终可以得到:

image-20231019193235633

Hook API 详解

 本节介绍了被大量使用的Xposed插件(模块),即GravityBox。通过此插件,对Xposed的hook相关API作进一步介绍。GravityBox可以修改状态栏、锁屏、电源等,源码在下载。

 GravityBox的分析路径如下:

(1)打开xposed_init,发现其中为:com.ceco.r.gravitybox.GravityBox。找到此类,此类实现了IXposedHookZygoteInit与IXposedHookLoadPackage的接口,如下所示:

1
2
3
public class GravityBox implements IXposedHookZygoteInit, IXposedHookLoadPackage {
// 其中代码 logic-2 省略
}

 在此类中,有函数initZygote,用于在Zygote进程启动时执行(每次开机执行一次),正常使用中,initZygote用于初始化工具类。在GravityBox类中,重写initZygote函数,用于初始化配置文件(initZygote重写后调用XSharedPreferences)、打印关键信息,如下所示(logic-2):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 其它函数
@Override
public void initZygote(StartupParam startupParam) {
// 其它逻辑
MODULE_PATH = startupParam.modulePath;
if (XposedBridge.getXposedVersion() < 93) {
// 初始化配置文件
prefs = new XSharedPreferences(prefsFileProt);
// 其它逻辑
} else {
// 其它逻辑
}
// 其它逻辑
if (startupParam.startsSystemServer) {
// 打印关键信息
XposedBridge.log("GB:Hardware: " + Build.HARDWARE);
// 其它逻辑
}
// 其它逻辑
}
// 其他函数

 在logic-2中,还有一个重要的函数,即handleLoadPackage,此函数是进程启动时被调用的函数,作用为完成java函数的hook工作,其调用时机早于Application.onCreate函数。此函数中有很多if语句,以区分启动的app,从而在一个xposed模块(插件)中hook多个应用。代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
public void handleLoadPackage(LoadPackageParam lpparam) {
// 其余逻辑
if (lpparam.packageName.equals(SystemPropertyProvider.PACKAGE_NAME)) {
SystemPropertyProvider.init(prefs, qhPrefs, tunerPrefs, lpparam.classLoader);
}
if (lpparam.packageName.equals(ModLowBatteryWarning.PACKAGE_NAME)) {
ModLowBatteryWarning.init(prefs, qhPrefs, lpparam.classLoader);
}
// 其余逻辑
if (lpparam.packageName.equals(ModStatusbarColor.PACKAGE_NAME)) {
ModStatusbarColor.init(lpparam.classLoader);
}
// 其余逻辑
}

 以控制系统状态栏颜色为例,跟踪ModStatusbarColor.init函数,发现其对函数的hook都是使用XposedHelpers类进行处理(使用类中的findAndHookMethod函数)。分析ModStatusbarColor.init函数代码,可以得到以下结论(分析过程略):

  • 在afterHookedMethod或beforeHookedMethod函数中,使用param.thisObject即可拿到被hook函数的实例对象。

  • frida中获取实例对象的成员值,为实例对象.成员名称.value,而在xposed中为XposedHelpers.get<type>Field(实例对象,成员名称),也有对应的set<type>Field方法。

  • frida中需要使用java.cast来完成类型的转换,而xposed本身就是java的,所以强制转换即可。

 再来看GravityBox的ModAudio.java部分代码:

1
2
3
4
5
6
7
8
9
10
11
12
// 其余逻辑
XposedHelpers.findAndHookConstructor("android.media.AudioManager", classLoader, Context.class, new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
Object objService = XposedHelpers.callMethod(param.thisObject, "getService");
Context mApplicationContext = (Context) XposedHelpers.getObjectField(param.thisObject, "mApplicationContext");
if (objService != null && mApplicationContext != null) {
XposedHelpers.callMethod(param.thisObject, "disableSafeMediaVolume");
}
}
});
// 其余逻辑

 可以看出,xposed还可以钩取类的构造函数,此时findAndHookConstructor无需传递函数名称。

 因此,可以说:针对app中的类、函数、变量的处理都是通过XposedHelpers类中提供的函数实现的。那么XposedHelpers是如何实现这些功能的呢?答案是用java的反射实现的。以XposedHelpers的getBooleanField为例:

1
2
3
4
5
6
7
8
9
10
11
12
public static boolean getBooleanField(Object obj, final String fieldName) {
return findField(obj.getClass(), fieldName).getBoolean(obj);
}
public static Field findField(final Class<?> clazz, final String fieldName) {
final Field field = findFieldRecursiveImpl(clazz, fieldName);
field.setAccessible(true);
fieldCache.put(fullFieldName, field);
return field;
}
private static Field findFieldRecursiveImpl(Class<?> clazz, final String fieldName) throws NoSuchFieldException {
return clazz.getDeclaredField(fieldName);
}

 Frida与Xposed对比:Xposed可以在一个函数中完成针对所有进程的hook,在zygote重新启动后生效。而frida是单进程级别的hook。

Xposed Hook加固应用

 对加固应用进行hook时,如果直接hook应用中的函数,则会提示ClassNotFoundException,同样,使用frida在spawn模式下对加固应用进行hook时,也会提示相同错误(但是frida使用attach进行hook时为什么不提示咧?)。

 答案是:时机不对,即类加载器ClassLoader在加固应用启动时切换,从而导致上述情况。具体而言,app中的类都是对应的ClassLoader加载到ART虚拟机中的,如果ClassLoader不正确,那么就无法找到对应的类。当加固应用启动时,app的当前ClassLoader会发生切换,故而出现上述情况

 那么,如何让xposed在面对加固应用时也可以使用attach注入进程呢?

 我们来分析这个问题,首先,xposed注入进程的时机不可更改,即zygote启动时,此时app的Application类并未加载,也就导致用于加载app的业务相关的类的ClassLoader未出现。但是,我们可以手动切换ClassLoader

 举个例子,当我们静态分析加固app时发现,壳程序总是通过在应用进程中最先获得执行权限的application类中的attachBaseContext与onCreate函数完成对dex的释放与ClassLoader的切换,那么,我们可以hook 这两个函数来获得真实app的上下文。相关代码逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
XposedHelpers.findAndHookMethod("com.xxx.StubApp", loadPackageParam.classloader, "attachBaseContext", Context.class, new XC_MethodHook() {
@Override
protected void afterHookMethod(MethodHookParam param) throws Throwable {
// 此时 param 指的应该是返回值
super.afterHookedMethod(param);
Context context = (Context) param.args[0];
// 获取真实业务代码的 classLoader
ClassLoader finalClassLoader = context.getClassLoader();
// 之后 hook 真实 classLoader 的 method 即可
XposedHelpers.findAndHookMethod(clzz, finalClassLoader, "method", ..., new XC_MethodHook(){
...
});
}
});

 上述方法有一个弊端,即一旦加固厂商改变相应继承Application类的类名,在此理解为方法名,即不一定为attachBaseContext函数,那么上述hook就失效了。那如何解决?

 再来补充知识:app被zygote孵化后,会通过ActivityThread.main启动相应app,在此函数中创建ActivityThread的实例,并进行初始化操作。ActivityThread类至关重要,它根据ActivityManager发送的请求对activities、broadcast Receviers等操作进行调度执行。ActivityThread中的performLaunchActivity函数用于响应与activity相关的操作,ActivityThread中的mInitialApplication存放着当前的ClassLoader

 以movetv的MainActivity为例,实现hook的代码如下所示(代码放在函数handleLoadPackage中):

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
import android.app.Application;

if(loadPackageParam.packageName.equals("com.cz.babySister")){
XposedBridge.log("hooked " + loadPackageParam.processName);
// hook ActivityThread 类
Class ActivityThread = XposedHelpers.findClass("android.app.ActivityThread", loadPackageParam.classLoader);
// performLaunchActivity 用于响应与 activity 相关的操作
XposedBridge.hookAllMethods(ActivityThread, "performLaunchActivity", new XC_MethodHook(){
@Override
protected void afterHookedMethod(MethodHookParam param) throws Throwable{
super.afterHookedMethod(param);
// 获取 ActivityThread 类中的 mInitialApplication 成员
Application mInitialApplication = (Application) XposedHelpers.getObjectField(param.thisObject, "mInitialApplication");
ClassLoader finalLoader = mInitialApplication.getClassLoader();
XposedBridge.log("found classloader is => " + finalLoader.toString());

Class BabyLogin = finalLoader.loadClass("com.cz.babySister.activity.LoginActivity");
XposedBridge.log("Debug -> " + BabyLogin.toString());
XposedBridge.hookAllMethods(BabyLogin, "onCreate", new XC_MethodHook(){
@Override
protected void beforeHookedMethod(MethodHookParam param) throws Throwable{
// 当启动 MainActivity 时进行 Log
super.beforeHookedMethod(param);
XposedBridge.log("LoginActivity onCreate called");
}
});
}
});
}

 结果如下所示:

image-20231020201257929

Frida 探 Xposed Hook

 本节利用frida,来学习Xposed是如何进行hook的。以Xposed 安装&Xposed插件(模块)一节中的示例插件为例,使用objection注入目标进程,搜索com.roysue.xposed1.HookTest,命令行如下所示:

1
2
objection -g com.roysue.xposed1 explore
android hooking search classes HookTest

 结果如下:

image-20231020202510881

 但是,如果想要执行以下两条命令的任何一条时,就会报错,显示ClassNotFoundException:

1
2
3
4
5
6
// 列出类中的函数
android hooking list class_methods com.roysue.xposed1.HookTest
android hooking list class_methods com.roysue.xposed1.HookTest$1
// 钩取类中的所有函数
android hooking watch class com.roysue.xposed1.HookTest
android hooking watch class com.roysue.xposed1.HookTest$1

 这里之所以报错,是因为:ClassLoader不对,objection中并未结合frida中切换ClassLoader的功能,因此这部分需要自己写脚本完成。如下traceXposed.js所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// traceXposed.js
function hook(){
Java.perform(function(){
// enumerateClassLoaders 是 frida 的 api
Java.enumerateClassLoaders({
onMatch: function(loader){
try{
if(loader.findClass("com.roysue.xposed1.HookTest")){
console.log("found!")
console.log(loader)
// 切换 classLoader
Java.classFactory.loader = loader
}
} catch(error){}
}, onComplete: function(){
console.log("end")
}
})
})
}
setImmediate(hook)
1
frida -U -f com.roysue.xposed1 -l traceXposed.js --no-pause

 输出此时含有com.roysue.xposed1.HookTest的ClassLoader,如下所示:

image-20231020215345260

 完成切换后,对目标类进行hook发现不再报错,在下面的代码中,针对HookTest类中的PrintStack进行Hook,脚本如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// traceXposed.js
function hook(){
Java.perform(function(){
// enumerateClassLoaders 是 frida 的 api
Java.enumerateClassLoaders({
...
})
// hook PrintStack 函数
Java.use("com.roysue.xposed1.HookTest").PrintStack.implementation = function(){
console.log("hacked by wd")
return true
}
})
}
setImmediate(hook)

 注入之后,点击应用的button,会调用PrintStack,此时会显示”hacked by wd”。作者发现,切换完ClassLoader之后,虽然能够获取com.roysue.xposed1.HookTest的类,但是对于Xposed提供的api,例如XposedBridge.log等,钩取时也会出现ClassNotFoundException错误,这说明:使用Xposed实现的插件与Xposed自身的api不在一个ClassLoader所能加载的范围内

 我们先查看XposedBridge对应的ClassLoader叫啥?脚本如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// traceXposed.js
function hook(){
Java.perform(function(){
// enumerateClassLoaders 是 frida 的 api
Java.enumerateClassLoaders({
onMatch: function(loader){
try{
if(loader.findClass("de.robv.android.xposed.XposedBridge")){
console.log("found!")
console.log(loader)
// 切换 classLoader
Java.classFactory.loader = loader
}
} catch(error){}
}, onComplete: function(){
console.log("end")
}
})
})
}
setImmediate(hook)

 结果如下所示:

image-20231020215424099

 对比发现,Xposed插件在/data/app/com.roysue.xposed1-1/base.apk中,而Xposed的api在/system/framework/XposedBridge.jar中。结合代码,可以得出结论,Xposed插件实现了Xposed框架的IXposedHookLoadPackage接口,而XposedBridge则是调用XposedBridge.jar中的函数。

 再次分析上面xposed插件的代码,发现beforeHookedMethod与afterHookedMethod函数都在使用new XC_MethodHook构建的内部匿名类HookTest$1中,因此需要将ClassLoader切换为HookTest$1所在的loader。针对上面xposed插件,对beforeHookedMethod与afterHookedMethod进行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
// hook.js
function traceMethod(){
Java.perform(function(){
Java.enumerateClassLoaders({
onMatch: function(loader){
try{
// 切换为 com.roysue.xposed1.HookTest$1 类的 loader
if(loader.findClass("com.roysue.xposed1.HookTest$1")){
console.log("found!")
console.log(loader)
Java.classFactory.loader = loader
}
} catch(error){}
}, onComplete: function(){
console.log("end")
}
})
var targetClassMethod = "com.roysue.xposed1.HookTest$1.beforeHookedMethod"
var delim = targetClassMethod.lastIndexOf(".")
var targetClass = targetClassMethod.slice(0, delim)
var targetMethod = targetClassMethod.slice(delim + 1, targetClassMethod.length)
var hook = Java.use(targetClass)
// targetMethod 的重载数量
var overloadCount = hook[targetMethod].overloads.length
console.log("Tracing " + targetClassMethod + " [" + overloadCount + " overload(s)]");

for (var i = 0; i < overloadCount; i++) {
hook[targetMethod].overloads[i].implementation = function() {
console.warn("\n*** entered " + targetClassMethod)
// 打印参数 arguments
for (var j = 0; j < arguments.length; j++) {
console.log("arg[" + j + "]: " + arguments[j])
}
// 打印返回值 retval
var retval = this[targetMethod].apply(this, arguments)
console.log("retval: " + retval)
return retval
}
}
})
}

 之后点击button,显示:

image-20231021164742074

 同理,钩取afterHookedMethod,显示:

image-20231021164848886

 接下来,以GravityBox为例,来描述如何对beforeHookedMethod与afterHookedMethod进行hook。

(1)GravityBox.apk下载链接,下载并安装此插件。

(2)以修改状态栏颜色的类com.ceco.nougat.gravitybox.ModStatusbarColor为例,找其相关的匿名类(不使用之前的objection方法):

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
// hook.js
// 寻找思想:默认存在 ModStatusbarColor$1 匿名类,获取相应的 loader,ModStatusbarColor$1 的其它匿名类也应该使用此 loader。因此,找寻此 loader 加载的所有 gravitybox 匿名类,且匿名类应该继承于 XC_MethodHook,匿名类中应该有 ModStatusbarColor 字符串。
function lookClass(){
Java.perform(function(){
// 查找所有的类,以它们为 loader,从而查找 ModStatusbarColor$1 匿名类,并将其作为 loader
Java.enumerateClassLoaders({
onMatch: function (loader) {
try {
if(loader.findClass("com.ceco.nougat.gravitybox.ModStatusbarColor$1")){
Java.classFactory.loader = loader ;
}
}
catch(error){}
},
onComplete: function () {}
})
// ModStatusbarColor$1 相应的 loader 可能会加载其他的匿名类
Java.enumerateLoadedClasses ({
onMatch:function(className){
try {
// 找到 gravitybox 的所有匿名类
if(className.toString().indexOf("gravitybox") > 0 && className.toString().indexOf("$") > 0){
// 获得匿名类的父类,应该是 XC_MethodHook 才对,这种匿名类有 beforeHookedMethod 与 afterHookedMethod 函数
if(Java.use(className).class.getSuperclass()){
var superClass = Java.use(className).class.getSuperclass().getName()
if (superClass.indexOf("XC_MethodHook") > 0){
console.log(className)
// traceClass(className)
}
}
}
} catch(error){}
}, onComplete:function(){
console.log("search completed!")
}
})
})
}

 这种寻找方法一个ModStatusbarColor匿名类也没找到,显然是错误的。这是为什么呢?这是因为,由于Frida是进程级别的,而xposed在将代码对进程进行注入时做了进程的判断,例如xposed的ModStatusbarColor类是要对com.android.systemui进行注入,从而改变颜色。因此,只有判断进程是com.android.systemui时,才会初始化ModStatusbarColor类。但是,Frida脚本注入到了插件xposed1中,名字不为com.android.systemui,因此com.android.systemui类并未被初始化,也就找不到其匿名类。原理如下图所示(Xposed开机时会钩取所有进程,此时给systemui注入了ModStatusbarColor):

image-20231021180801457

 那么,之后我们对systemui进程进行frida注入(hook.js代码无需修改),并进行搜索:

1
frida -U com.android.systemui -l hook.js --no-pause

 结果如下所示:

image-20231021181737662

 之后,对上述寻找到的目标类中所有的函数进行hook,即实现上述代码中注释掉的traceClass函数,如下所示:

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
function traceMethod(targetClassMethod) {
var delim = targetClassMethod.lastIndexOf(".");
var targetClass = targetClassMethod.slice(0, delim)
var targetMethod = targetClassMethod.slice(delim + 1, targetClassMethod.length)

var hook = Java.use(targetClass);
var overloadCount = hook[targetMethod].overloads.length
// hook 类中的所有函数的所有重载
for (var i = 0; i < overloadCount; i++) {
hook[targetMethod].overloads[i].implementation = function() {
console.warn("\n*** entered " + targetClassMethod)
// print args
for (var j = 0; j < arguments.length; j++) {
console.log("arg[" + j + "]: " + arguments[j])
}
// print retval
var retval = this[targetMethod].apply(this, arguments)
console.log("\nretval: " + retval)
return retval
}
}
}

function uniqBy(array, key) {
var seen = {};
return array.filter(function(item) {
var k = key(item);
return seen.hasOwnProperty(k) ? false : (seen[k] = true);
});
}

function traceClass(targetClass) {
// 使用 Java.use 新建对象
var hook = Java.use(targetClass)
// 利用反射的方式,拿到当前类的所有方法
var methods = hook.class.getDeclaredMethods()
// 将对象释放掉
hook.$dispose
// 将方法名保存到数组中
var parsedMethods = []
methods.forEach(function(method) {
// method_name
parsedMethods.push(method.toString().replace(targetClass + ".", "TOKEN").match(/\sTOKEN(.*)\(/)[1])
})
// 去掉重复值
var targets = uniqBy(parsedMethods, JSON.stringify)
// 对数组中所有的方法进行 hook
targets.forEach(function(targetMethod) {
traceMethod(targetClass + "." + targetMethod)
})
}

 上述代码中,traceClass函数必须先获得要钩取类的类名才行。那么,如果我不知道类名,还要hook所有的beforeHookedMethod与afterHookedMethod,该用啥骚姿势?

 已经知道,afterHookedMethod/beforeHookedMethod是使用XC_MethodHook的抽象类实现的,前面插件中的HookTest$1,是匿名内部类,也是XC_MethodHook的子类,它有afterHookedMethod/beforeHookedMethod函数。我们只需要关心XC_MethodHook即可。相关脚本如下所示:

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
// getAllXCMethodHook.js
function getAllXCMethodHook(){
Java.perform(function(){
Java.enumerateClassLoaders({
onMatch: function (loader) {
try {
if(loader.findClass("com.ceco.nougat.gravitybox.ModStatusbarColor$1")){
Java.classFactory.loader = loader ;
}
}
catch(error){}
},
onComplete: function () {}
})

Java.enumerateLoadedClasses ({
onMatch:function(className){
try {
if(className.toString().indexOf("gravitybox") > 0 && className.toString().indexOf("$") > 0){
if(Java.use(className).class.getSuperclass()){
var superClass = Java.use(className).class.getSuperclass().getName()
if (superClass.indexOf("XC_MethodHook") > 0){
console.log(superClass + " | " + className)
}
}
}
} catch(error){}
}, onComplete:function(){
console.log("search completed!")
}
})
})
}

 注入到com.android.systemui中,上述脚本与hook.js好类似(一模一样),结果输出如下(输出了所有父类为XC_MethodHook的子类):

image-20231021223136596

 结合上述描述,Xposed的原理是:通过新建专门用于实现hook的PathClassLoader,并注入目标进程,从而完成对目标函数的插桩。PathClassLoader加载的类列表中包含对目标进程Hook的逻辑,而原生的Xposed API则处于指向XposedBridge.jar文件的ClassLoader。

Xposed 主动调用与 RPC 实现

Xposed 主动调用函数

 以example.apk为例,目的是获取pin值,我们要获得正确的pin。app标识符为org.teamsik.ahe17.qualification.easy

(1)使用 objection 分析:

1
2
objection -g org.teamsik.ahe17.qualification.easy explore
android hooking list classes

 发现org.teamsik.ahe17.qualification.MainActivity类(仅有这一个),之后钩取MainActivity的所有函数:

1
android hooking watch class org.teamsik.ahe17.qualification.MainActivity

 之后点击VERIFY PIN,定位到验证函数:org.teamsik.ahe17.qualification.MainActivity.verifyPasswordClick

(2)jeb找到verifyPasswordClick,源码如下,静态分析可以发现,PIN码长度为4,使用SHA1进行加密,并与指定密文对比:

image-20231021232633646

 思路:使用Xposed主动调用,暴力穷举,并与密文对比。Xposed关于主动调用的API为callMethod(用于调用对象实例所的动态静态函数)/callStaticMethod(调用类的静态函数)。其函数签名各自为:

1
2
3
callMethod(Object obj, String methodName, Object... args)
// "Class<?> clazz" 代表 clazz 是类就行
callStaticMethod(Class<?> clazz, String methodName, Object... args)

 使用Xposed要主动调用encodePassword,使用callStaticMethod函数。相关代码如下(Xposed插件):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 静态调用 encodePassword
public void handleLoadPackage(XC_LoadPackage.LoadPackageParam loadPackageParam) throws Throwable {
if(loadPackageParam.packageName.equals("org.teamsik.ahe17.qualification.easy")){
XposedBridge.log("inner: " + loadPackageParam.processName);
// 获取 verifier 类
Class clazz = XposedHelpers.findClass("org.teamsik.ahe17.qualification.Verifier", loadPackageParam.classLoader);
// 正确答案
byte[] p = "09042ec2c2c08c4cbece042681caf1d13984f24a".getBytes();
String pStr = new String(p);
for(int i = 999; i < 10000; i++){
// 静态调用 encodePassword
byte[] v = (byte[])XposedHelpers.callStaticMethod(clazz, "encodePassword", String.valueOf(i));
String vStr = new String(v);
// 作比较
if(vStr.equals(pStr)){
XposedBridge.log("right.pin => "+ String.valueOf(i));
}
}
}
}

 安装好后,之后打开example.apk,再打开日志,发现9083为正确的pin。其实,java本身就存在主动调用函数的方式,即invoke反射,Xposed的主动调用函数本质上也是调用Invoke函数。因此,可以更改代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public void handleLoadPackage(XC_LoadPackage.LoadPackageParam loadPackageParam) throws Throwable {
if(loadPackageParam.packageName.equals("org.teamsik.ahe17.qualification.easy")){
XposedBridge.log("inner: " + loadPackageParam.processName);
// 反射获取 verifier 类与 Method
Class clazz = loadPackageParam.classLoader.loadClass("org.teamsik.ahe17.qualification.Verifier");
Method encodePassword = clazz.getDeclaredMethod("encodePassword", String.class);
// 允许函数通过外部反射调用,这里主要针对目标函数是 private 私有函数
encodePassword.setAccessible(true);
// 正确答案
byte[] p = "09042ec2c2c08c4cbece042681caf1d13984f24a".getBytes();
String pStr = new String(p);
for(int i = 999; i < 10000; i++){
// 静态调用 encodePassword
byte[] v = (byte[])encodePassword.invoke(null, String.valueOf(i));
String vStr = new String(v);
// 作比较
if(vStr.equals(pStr)){
XposedBridge.log("right.pin => "+ String.valueOf(i));
}
}
}
}

 与frida主动调用类似,xposed主动调用面对的问题有:参数构造;针对动态函数如何获取对象实例

 针对参数构造问题,要么就hook相同类型的数据(略),要么就自己构造参数。由于xposed是原生java而言的,因此,xposed相比frida在参数构造上有优势。例如,若想构造verifyPassword(Context,String)的Context参数,frida代码如下所示:

1
2
3
var ActivityThread = Java.use("android.app.ActivityThread")
var Context = Java.use("android.content.Context");
var ctx = Java.case(ActivityThread.currentApplication().getApplicationContext(), Context)

 而xposed代码如下所示:

1
Context context = AndroidAppHelper.currentApplication();

 相应的,xposed完整的主动调用verifyPassword函数的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public void handleLoadPackage(XC_LoadPackage.LoadPackageParam loadPackageParam) throws Throwable {
if(loadPackageParam.packageName.equals("org.teamsik.ahe17.qualification.easy")){
XposedBridge.log("inner: " + loadPackageParam.processName);
// 反射获取 verifier 类与 Method
Class clazz = loadPackageParam.classLoader.loadClass("org.teamsik.ahe17.qualification.Verifier");
Method verifyPassword = clazz.getDeclaredMethod("verifyPassword", Context.class, String.class);

Context context = AndroidAppHelper.currentApplication();
for(int i = 999; i < 10000; i++){
// invoke 主动调用 verifyPassword
if((boolean)verifyPassword.invoke(null, context, String.valueOf(i))){
XposedBridge.log("right.pin => "+ String.valueOf(i));
}
}
}
}

 上面是xposed构造复杂参数。在主动构造实例对象时,可以使用java Constructor类的newInstance进行构造,也可以使用封装过的XposedHelpers.newInstance()进行构造,相应代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// java Constructor类的newInstance
public void handleLoadPackage(XC_LoadPackage.LoadPackageParam loadPackageParam) throws Throwable {
if(loadPackageParam.packageName.equals("org.teamsik.ahe17.qualification.easy")){
// 获取 Verifier 对象
Constructor cons = XposedHelpers.findConstructorExact("org.teamsik.ahe17.qualification.Verifier", loadPackageParam.classLoader);
Object Verifier = cons.newInstance();

Context context = AndroidAppHelper.currentApplication();
for(int i = 999; i < 10000; i++){
// Verifier 对象调用 verifyPassword
if((boolean)XposedHelpers.callMethod(Verifier, "verifyPassword", context, String.valueOf(i))){
XposedBridge.log("right.pin => "+ String.valueOf(i));
}
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// XposedHelpers.newInstance()
public void handleLoadPackage(XC_LoadPackage.LoadPackageParam loadPackageParam) throws Throwable {
if(loadPackageParam.packageName.equals("org.teamsik.ahe17.qualification.easy")){
// 获取 Verifier 对象
Class clazz = XposedHelpers.findClass("org.teamsik.ahe17.qualification.Verifier", loadPackageParam.classLoader);
Object Verifier = XposedHelpers.newInstance(clazz);

Context context = AndroidAppHelper.currentApplication();
for(int i = 999; i < 10000; i++){
// Verifier 对象调用 verifyPassword
if((boolean)XposedHelpers.callMethod(Verifier, "verifyPassword", context, String.valueOf(i))){
XposedBridge.log("right.pin => "+ String.valueOf(i));
}
}
}
}

上文并未介绍xposed hook拿到实例对象的方法,这是因为verifier类中函数全为static的,因此verifier类并未初始化,也就hook不到了。那咋办?换一个类然后再介绍hook拿实例对象呗。以MainActivity中的showSuccessDialog为例(不是静态函数),首先hook MainActivity的onCreate方法,创建MainActivity对象,在hook的afterHookMethod中,主动调用showSuccessDialog函数。代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public void handleLoadPackage(XC_LoadPackage.LoadPackageParam loadPackageParam) throws Throwable {
if(loadPackageParam.packageName.equals("org.teamsik.ahe17.qualification.easy")){
// 获取 MainActivity 类
Class clazz = loadPackageParam.classLoader.loadClass("org.teamsik.ahe17.qualification.MainActivity");

// hook onCreate
XposedBridge.hookAllMethods(clazz, "onCreate", new XC_MethodHook(){
@Override
protected void afterHookedMethod(MethodHookParam param) throws Throwable{
super.afterHookedMethod(param);
// 获取 MainActivity 对象
Object mMainActivity = param.thisObject;
// 主动调用 showSuccessDialog 函数
XposedHelpers.callMethod(mMainActivity, "showSuccessDialog");
}
});
}
}

 这样的话,打开example.apk就显示congratulation的成功提示。

Xposed 结合 NanoHTTPD 实现 RPC 调用

 Xposed本身未提供RPC调用支持,但是可以结合NanoHTTPD(轻量级HTTP服务器)将主动调用导出为web服务实现。以demoso1工程为例,此工程中存在两个native函数,在应用打开后被循环调用。其中method01是静态函数,对输入进行加密并返回,method02是成员函数,对密文解密并返回。

 首先用xposed主动调用method01与method02:

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
public void handleLoadPackage(XC_LoadPackage.LoadPackageParam loadPackageParam) throws Throwable {
if(loadPackageParam.packageName.equals("com.example.demoso1")){
Class clazz = loadPackageParam.classLoader.loadClass("com.example.demoso1.MainActivity");
// 方法1:通过 hook 获取 MainActivity 对象实例
// 得到 object 之前必须 hook onCreate 方法
XposedBridge.hookAllMethods(clazz, "onCreate", new XC_MethodHook(){
// 由于 method01 与 method02 是 native 的,因此在 onCreate 执行前 method01/02 就已经准备就绪
@Override
protected void beforeHookedMethod(MethodHookParam param) throws Throwable{
super.beforeHookedMethod(param);
// 获取 MainActivity 对象
Object mMainActivity = param.thisObject;
// 主动调用 method01 与 method02 函数
String cipherText = (String)XposedHelpers.callMethod(mMainActivity, "method01", "wd2711");
String clearText = (String)XposedHelpers.callMethod(mMainActivity, "method02", "47fcda3822cd10a8e2f667fa49da783f");
XposedBridge.log("(1) cipherText => " + cipherText);
XposedBridge.log("(1) clearText => " + clearText);
}
});
// 方法2:通过 newInstance 获取对象实例
Object newMainActivity = XposedHelpers.newInstance(clazz);
String cipherText = (String)XposedHelpers.callMethod(newMainActivity, "method01", "wd2711");
String clearText = (String)XposedHelpers.callMethod(newMainActivity, "method02", "47fcda3822cd10a8e2f667fa49da783f");
XposedBridge.log("(2) cipherText => " + cipherText);
XposedBridge.log("(2) clearText => " + clearText);
// logic-4
}
}

 结果如下所示:

image-20231022154106062

 接下来,导入NanoHTTPD以进行RPC:

(1)在app/build.gradle文件的dependencies下增加:

1
implementation 'org.nanohttpd:nanohttpd:2.3.1'

 Nanohttpd只存在一个抽象类NanoHTTPD,主要有:start(启动web服务器)、stop(停止web服务器)、serve(收到web请求后的回调函数)。serve只有一个参数,为IHTTPSession类型,可用于判断浏览器请求内容,包括请求方法、参数、URL等。NanoHTTPD的构造函数可以指定web服务监听的端口。在此,简单的以NanoHTTPD实现hello world界面(应该加在上述代码的logic-4中):

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
class App extends NanoHTTPD{
// App 构造函数
public App() throws IOException{
// 指定监听端口
super(8899);
// start 启动 HTTP 服务
start(NanoHTTPD.SOCKET_READ_TIMEOUT, true);
XposedBridge.log("Running!");
}
// serve 回调函数
@Override
public NanoHTTPD.Response serve(IHTTPSession session){
// 获取 HTTP 方法:POST、GET
Method method = session.getMethod();
// 获取 URI
String uri = session.getUri();
// 获取访问者 IP
String RemoteIP = session.getRemoteIpAddress();
// 获取访问者 HostName
String RemoteHostName = session.getRemoteHostName();
Log.i("nanohttpd", "Method => " + method);
Log.i("nanohttpd", "URI => " + uri);
Log.i("nanohttpd", "Remote_IP => " + RemoteIP);
Log.i("nanohttpd", "RemoteHostName => " + RemoteHostName);
// 页面返回值
String msg = "<html><body>Hello wd2711\n";
return newFixedLengthResponse(Response.Status.OK, NanoHTTPD.MIME_PLAINTEXT, msg);
}
}
new App();

 安装好xposed插件,启动demoso1,并访问手机IP+端口,结果显示如下:

image-20231022160559470

 浏览器访问结果如下:

image-20231022160635723

(2)经过上面的测试,NanoHTTPD服务启动正常,接着更改serve内容,以增加函数主动调用:

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
@Override
public NanoHTTPD.Response serve(IHTTPSession session){
// 获取 URI
String uri = session.getUri();
// 解析 POST 方法访问时的传参内容
String paramBody = "";
Map<String, String>params = new HashMap<>();
try{
// session.parseBody(params) 的目的是试着 parse 一下,看 session 是否是合理的
session.parseBody(params);
paramBody = session.getQueryParameterString();
} catch(IOException e){
e.printStackTrace();
} catch(ResponseException e){
e.printStackTrace();
}
String result = "";
if(uri.contains("encrypt")){
// getActivity() 函数返回之前获取的 MainActivity 对象
result = (String)XposedHelpers.callMethod(getActivity(), "method01", paramBody);
} else if(uri.contains("decrypt")){
result = (String)XposedHelpers.callMethod(getActivity(), "method02", paramBody);
}
return newFixedLengthResponse(Response.Status.OK, NanoHTTPD.MIME_PLAINTEXT, result);
}

 安装好xposed插件,启动demoso1,使用 curl进行post,结果如下:

image-20231022164520100

(3)最后,使用python进行进一步封装:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import requests

def encrypt(param):
url = "http://192.168.48.14:8899/encrypt"
headers = {"Content-Type":"application/x-www-form-urlencoded"}
r = requests.post(url = url, data = param, headers = headers)
print(r.content)

def decrypt(param):
url = "http://192.168.48.14:8899/decrypt"
headers = {"Content-Type":"application/x-www-form-urlencoded"}
r = requests.post(url = url, data = param, headers = headers)
print(r.content)

if __name__ == "__main__":
encrypt("wd")
decrypt("169dc260893dab88cd619d5e35e17634")

总结

 Hook上,frida与xposed不分伯仲。但是在细节上,xposed支持使用setAdditionalInstanceField、setAdditionalStaticField等函数给实例对象添加动静态成员,而frida支持Java.choose从进程堆中搜索目标对象。在宏观上,frida可以热重载(不用重启),作用对象是特定进程,Hook原理类似于调试器,而xposed针对所有进程,类似于系统框架级服务。

留言

© 2024 wd-z711

⬆︎TOP