入坑安卓很久了,之前在酷安上一直接触各种破解软件,觉得非常有意思,正巧这次实训课在讲安卓逆向,索性记录了一些简单的逆向方法。
环境配置安卓本地环境介绍
由于我本人之前是做安卓开发的,并且对 XPosed 很熟悉,本次实验恰好涉及到软件逆向及跨签名安装,为了方便实验,我直接在我自己的手机进行实验了。手机已经 root 过并且装了 Magisk 和 LSPosed。下面仅给出最终截图,具体安装步骤可以在酷安找到。
Magsik
安卓 14 的系统需要安装 25.2 的版本,通过修补 boot.img 后在 recovery 中刷入即可。
LSPosed
LSPosed 是最新的 XPosed 管理器,拥有更高性能和更广的支持。首先在 Magisk 中刷入 LSPosed 模块并重启,之后安装 LSPosed 管理器。打开管理器即可激活 LSPosed:
可以看到现在已经激活了 Zygisk。LSPosed 能够直接在 Runtime 劫持 zygote 进程来动态的更改应用程序的功能。
安装核心破解
为了避免在后续实验时因为签名校验不通过而反复卸载程序的情况,需要在 LSPosed 中安装核心破解,并将作用域设置为系统框架。核心破解(CorePatch)是一款基于Xposed 模块开发的工具,可以用来去除系统签名校验,直接安装修改过的未签名APK,禁用APK签名验证、覆盖安装不同签名应用等功能。
电脑环境配置安装反编译软件 JADX
JADX 是一款用于反编译 Android 应用 APK 文件的强大工具,能将 DEX 文件转化为可读的 Java 源代码。它支持 APK 和 AAR 文件,并提供图形用户界面(GUI)和命令行接口,便于不同需求的用户使用。JADX 还能查看 Smali 代码,提取资源文件,并在Windows、macOS 和 Linux 等多个平台上运行。安装简单,无需复杂配置。使用 JADX可以有效地分析和理解应用程序的内部结构,但反编译的代码可能不完全可读,特别是经过混淆的代码。
1
brew install jadx
安装 Apktool
Apktool 是一款开源工具,用于反编译和重新编译 Android 应用的 APK 文件。它可以将 APK 文件反编译成 Smali 代码(类似于汇编语言),并提取资源文件(如 XML、图片等),使开发者和逆向工程师能够修改应用的代码和资源。反编译后,可以对应用进行定制和修改,然后重新编译生成新的 APK 文件。Apktool 支持命令行操作,适用于 Windows、macOS 和 Linux 等多个平台。其主要用途包括调试、翻译、定制和安全分析。
1
brew install apktool
赛尔号破解静态分析解包
使用 apktool 对 2seh.apk 进行解包,得到反汇编的源码
进入到目录里然后 tree 一下,可以看到如下结构:
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
.
├── assets
│ ├── CHANNEL
│ ├── CMGC
│ ├── SeerFightingUI
│ ├── about
│ ├── data
│ ├── edfile
│ ├── res
│ ├── sound
│ └── ui
├── lib
│ └── armeabi
├── original
│ └── META-INF
├── res
│ ├── drawable
│ ├── drawable-hdpi-v4
│ ├── drawable-ldpi-v4
│ ├── drawable-mdpi-v4
│ ├── drawable-xhdpi-v4
│ ├── layout
│ ├── layout-v16
│ ├── menu
│ ├── raw
│ └── values
├── smali
│ ├── cn
│ ├── com
│ ├── com1010
│ ├── com1111
│ ├── com11111
│ ├── com122
│ ├── com222
│ ├── com2222
│ ├── com31
│ ├── com44
│ ├── com66
│ ├── com77
│ ├── com88
│ ├── com99
│ ├── org
│ └── u
└── unknown
└── com
其中 res/ 中存放了所有的资源文件,包括图标、布局等等;lib/ 存放所有 so 库;smali 中即为代码文件了,但是里面的代码并不可读,需要其他工具进行转换。
使用 JADX 进行反编译
启动 JADX:
1
jadx-gui
打开 2seh.apk:
之后在侧栏中的 Source Code 中即可查看所有反编译的 java 代码了:
软件逆向破解为了方便实验,后续的破解我直接在手机上的 mt 管理器中进行。
安装软件并赋予所有权限
软件分析
启动游戏,打开购买界面:
可以看到总共分为两种购买模式,我们点击第一个六块钱的,在支付栏跳出来后关掉购买界面,可以看到程序弹出了一个 toast 提示我们取消购买:
OK,那么我们得到了软件的运行逻辑:
1
点击购买按钮 -> 购买的业务逻辑 -> 根据结果进行弹窗提示用户
源码分析
在 mt 管理器中打开 apk 文件,点击查看:
可以看到已经解包得到了跟 apktool 类似的结构。这里我们点击 classes.dex(这个是所有的 java 代码经过 Android Studio 编译后得到的 dex 字节码)并用 Dex 编辑器 ++ 打开
刚刚我们已经得到了软件的业务逻辑,关键字是 “购买道具”,那么我们可以直接从根目录进行搜索:
可以看到 “购买道具” 总共出现在了两个文件中,我们依次进行查看。首先打开 dsd,能够看到这个字符串,但是所有的代码都是 smali,我们在右上角进行反编译转成 java:
做过安卓开发的人都比较熟悉这段代码,他是通过重写接口的 onResult() 函数来进行业务逻辑判断,然后根据支付结果向其父线程发送不同消息。但是有个问题,一般来说对于一个 Message msg 对象,应该填写 msg.what 和 msg.obj 两个成员,分别用于记录消息的类型和内容,然后再通过 handler 进行发送,这里却没有填写 .what,这个绝对是有问题的。
我们回到文件的开头,看一下这个类:
他实现了一个叫做 IPayCallback 的接口,所以我们换到另一个出现了“购买道具”的文件,他是 IAPCallBack。还是按照上面的步骤将其反编译:
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
package cn.seerFighting.cpp;
import android.content.Context;
import android.os.Message;
import android.util.Log;
import android.widget.Toast;
import cn.cmgame.billing.api.GameInterface;
public class IAPCallBack implements GameInterface.IPayCallback {
AppActivity activity;
IAPHandlerPay iapHandler;
private volatile boolean isCallback = false;
public IAPCallBack(AppActivity activity, IAPHandlerPay iapHandler) {
this.activity = activity;
this.iapHandler = iapHandler;
}
public void onResult(int resultCode, String billingIndex, Object obj) {
String result;
if (!this.isCallback) {
this.isCallback = true;
Log.i("result", billingIndex);
switch (resultCode) {
case 1:
result = "购买道具:["
+ IAPUtilPay.getShopName(billingIndex)
+ "] 成功!";
Message successMsg = this.iapHandler.obtainMessage();
successMsg.what = 10005;
successMsg.obj = result;
this.iapHandler.sendMessage(successMsg);
break;
case 2:
result = "购买道具:["
+ IAPUtilPay.getShopName(billingIndex)
+ "] 失败!";
Message failMsg = this.iapHandler.obtainMessage();
failMsg.what = 10006;
failMsg.obj = result;
this.iapHandler.sendMessage(failMsg);
break;
default:
result = "购买道具:["
+ IAPUtilPay.getShopName(billingIndex)
+ "] 取消!";
Message cancelMsg = this.iapHandler.obtainMessage();
cancelMsg.what = 10006;
cancelMsg.obj = result;
this.iapHandler.sendMessage(cancelMsg);
break;
}
Toast.makeText(
(Context) this.activity, (CharSequence) result, 0
).show();
}
}
}
其业务逻辑如下:
根据支付结果设置提示信息:购买成功:msg.what = 10005购买失败:msg.what = 10006购买取消:msg.what = 10006向父线程发送消息弹出一个 toast 提示用户这样以来就很清晰了,我们得到的提示就是通过 53-55 行实现的,而我们触发的是 default。
软件破解
根据上述源码分析,我们的思路就非常清晰了:不管什么支付结果如何,全都让 handler 发送支付成功的消息。
实现起来也十分简单,就是把所有的 .what 全都改成支付成功,也就是从 10006 改成 10005。
我们回到 smali 代码中搜索一下 10006,发现一个都搜不到,我们知道在编译后系统很喜欢 16 进制,所以我们尝试换成他的 16 进制 0x2716,这次找到了:
我们只需要将这个值改成 10005 (0x2715) 即可。为了让结果更明显,我在这里还更改了支付取消的字符串,在前面加入了 “hooking” 字眼。然后保存并返回:
可以看到 mt 管理器已经识别到字节码被更改,我们进行打包并重签名。签名是为了让文件能够被系统所认证。
下面进行安装:
由于我们没有赛尔号官方的签名,因此我当时使用的是我自己开发软件时用的签名,这个和官方是有差异的,在一个正常的系统中,不同签名但是相同包名的软件是无法直接覆盖安装的,需要将原始的软件卸载。但是由于我的设备是装过核心破解的,因此可以绕过签名验证直接覆盖。
破解验证启动软件进入购买界面
可以看到此时我们没有任何钻石
购买钻石
我们点击购买 80 钻石,然后点击取消购买,可以看到程序弹出了一个 toast,里面的内容是 “hooking:购买道具:[钻石小包箱] 取消!” 这正是我修改过的字符串,同时在钻石栏也开始增加钻石,最终在 80 个时停止。
浅塘破解软件分析打开浅塘应用,选择一个皮肤:
跳到支付宝后我们取消支付,然后返回应用,可以看到出现了一个“购买失败”的弹窗。因此我们可以按照之前的思路去源码中进行寻找
源码分析Dex 分析
我们还是按照之前的步骤,去 dex 里搜索“购买失败”,发现搜不到,那有没有可能是放在 string 里了?去 resource 中搜索发现也搜不到:
支付分析
我们现在搜不到任何结果,那只剩下一个入手点,就是软件的支付方式。我去搜了一下得到支付宝的异步调用返回值,在成功时是 9000。因此去 dex 中搜索 9000:
在 com.ttzgame.pay 中发现了 smali 代码的赋值语句 const-string v1, "9000" 这应该就是要找的地方了。把相关代码反编译:
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
package com.ttzgame.pay;
import android.text.TextUtils;
import com.alipay.sdk.app.PayTask;
class a$1 implements Runnable {
final String a;
final String b;
final a c;
a$1(a aVar, String str, String str2) {
this.c = aVar;
this.a = str;
this.b = str2;
}
@Override
public void run() {
a aVar;
String str;
boolean z = true;
String a = new c(new PayTask(this.c.d()).pay(this.a, true)).a();
if (TextUtils.equals(a, "9000")) {
aVar = this.c;
str = this.b;
} else {
if (TextUtils.equals(a, "8000")) {
return;
}
aVar = this.c;
str = this.b;
z = false;
}
aVar.a(str, z);
}
}
整个类通过实现 Runnable 接口并重写 run() 方法来形成一个线程。可以看到当支付成功时,a == "9000"。 而失败时,则将 z 赋值为 false 来表示没有付款。所以我们可以通过修改 z 的值来进行破解。
软件破解
结合 smali 代码和反编译出的 java,我们能够看到在 106 行的位置即为 z = false 的语句。将 0x0 改成 0x1。这样当支付失败的时候,程序也会认为已经付款成功了。
重新打包并签名
破解验证打开付款界面
选择 1280 个金币,点击支付
取消付款
取消付款然后回到应用中,可以看到已经成功获得了 1280 个金币。