安卓逆向破解
入坑安卓很久了,之前在酷安上一直接触各种破解软件,觉得非常有意思,正巧这次实训课在讲安卓逆向,索性记录了一些简单的逆向方法。
环境配置
安卓本地环境介绍
由于我本人之前是做安卓开发的,并且对 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
。这样当支付失败的时候,程序也会认为已经付款成功了。重新打包并签名