Android逆向入门教程

1. 初识APK、Dalvik字节码以及Smali

后缀名为.apk是安卓手机app的格式。它的实质是一个ZIP压缩包,将它的后缀名修改为.zip便可以看到内部的文件结构。解压出来后一般有以下文件:

文件 作用
asset文件夹 资源目录1,asset和res都是资源目录但有所区别
lib文件夹 so库存放位置,一般由NDK编译得到,常见于使用游戏引擎或JNI native调用的工程中
META-INF文件夹 存放工程一些属性文件,例如Manifest.MF
res文件夹 资源目录2,asset和res都是资源目录但有所区别
AndroidManifest.xml Android工程的基础配置属性文件
classes.dex Java代码编译得到的Dalvik VM能直接执行的文件
resources.arsc 对res目录下的资源的一个索引文件,保存了原工程中strings.xml等文件内容
其他文件夹 etc.

1.1 asset VS. res

res目录下的资源文件在编译时会自动生成索引文件(R.java),在Java代码中用R.xxx.yyy来引用;而asset目录下的资源文件不需要生成索引,在Java代码中需要用AssetManager来访问。

一般来说,除了音频和视频资源(需要放在raw或asset下),使用Java开发的Android工程使用到的资源文件都会放在res下;使用C++游戏引擎(或使用Lua Unity3D等)的资源文件均需要放在asset下。

1.2 Dalvik字节码

Dalvik字节码是学习破解的基础。Dalvik是Google专门为Android操作系统设计的一个虚拟机,经过深度的优化。虽然Android上的程序是使用Java来开发的,但是Dalvik和标准的Java虚拟机JVM还是两回事。Dalvik VM是基于寄存器的,而JVM是基于栈的;Dalvik有专属的文件执行格式dex(dalvik executable),而JVM则执行的是Java字节码。Dalvik VM比JVM速度更快,占用空间更少。

通过Dalvik的字节码不能直接看到原来的逻辑代码,这是需要借助如Apktool或dex2jar+jd-gui工具来帮助查看。但是,需要注意的是最终我们修改APK需要操作的文件是.smali文件,而不是导出来的Java文件重新编译。

1.3 Smali

Smali是破解的重中之重。Smali,Baksmali分别是指安卓系统里的Java虚拟机(Dalvik)所使用的一种dex格式文件的汇编器,反汇编器。其语法是一种宽松式的Jasmin/dedexer语法,而且它实现了.dex格式所有功能(注解,调试信息,线路信息等)。

当对APK文件进行反编译后,便会生成此类的文件。在Dalvik字节码中,寄存器都是32位的,能够支持任何类型;64位类型(Long/Double)用2个寄存器表示。Dalvik字节码有两种类型:原始类型、引用类型(包括对象和数组)。

原始类型简写 原始类型
B byte
C char
D double
F float
I int
J long
S short
V void
Z boolean
[XXX array
Lxxx/yyy object

数组的表示方式是:在基本类型前加上中括号[,例如int数组和float数组分别表示为:[I[F;对象的表示则以L作为开头,格式是Lpackage/objectName;(注意必须有个分号跟在后面),例如String对象在Smali中为:Ljava/lang/String;,其中java/lang对应java.lang包,String就是定义在该包中的一个对象。

或许有人问,既然类是用LpackageName/objectName;来表示,那类里面的内部类又如何在smali中引用呢?LpackageName/objectName$subObjectName;,也就是在内部类前加$符号。

方法的定义一般为:

​ Func-Name(Para-Type1Para-Type2Para-Type3…)Return-Type

注意参数与参数之间没有任何分隔符。

方法 意义
hello ()V void hello()
hello (III)Z boolean hello(int, int, int)
hello (Z[I[ILjava/lang/String;J)Ljava/lang/String; String hello(boolean, int[], int[], String, long)

1.3.1 Smali基本语法

基本语法 含义
.field private isFlag:Z 定义变量
.method 方法
.parameter 方法参数
.prologue 方法开始
.line 123 此方法位于第123行
invoke-super 调用父函数
const/high16 v0, 0x7f03 把0x7f03赋值给v0
invoke-direct 调用函数
return-void 函数返回void
.end method 函数结束
new-instance 创建实例
input-object 对象赋值
iget-object 调用对象
invoke-static 调用静态函数

1.3.2 条件跳转分支

用法 含义
if-eq vA, vB, :cond_0 如果vA等于vB则跳转到:cond_0
if-ne vA, vB, :cond_0 如果vA不等于vB则跳转到:cond_0
if-lt vA, vB, :cond_0 如果vA小于vB则跳转到:cond_0
if-gt vA, vB, :cond_0 如果vA大于vB则跳转到:cond_0
if-ge vA, vB, :cond_0 如果vA大于等于vB则跳转到:cond_0
if-le vA, vB, :cond_0 如果vA小于等于vB则跳转到:cond_0
if-eqz vA, :cond_0 如果vA等于0则跳转到:cond_0
if-nez vA, :cond_0 如果vA不等于0则跳转到:cond_0
if-ltz vA, :cond_0 如果vA小于0则跳转到:cond_0
if-gtz vA, :cond_0 如果vA大于0则跳转到:cond_0
if-gez vA, :cond_0 如果vA大于等于0则跳转到:cond_0
if-lez vA, :cond_0 如果vA小于等于0则跳转到:cond_0

1.3.3 Smali中的包信息

.class public Lcom/aaaaa;:是com这个package下的一个类aaaaa

.super Lcom/bbbbb;:继承自com.bbbbb这个类

.source "ccccc.java":由ccccc.java编译得到的smali文件

一般来说在smali文件中是这样子的:

1
2
3
4
5
6
7
# annotations
.annotation system Ldalvik/annotation/MemberClasses;
value={
Lcom/aaa$qqq;,
Lcom/aaa$www;
}
.end annotation

这个声明是内部类的声明:aaa这个类它有两个成员内部类——qqq和www。

1.3.4 Smali中的成员变量

格式:.field public/private [static][final] varName:<类型>

对于不同的成员变量也有不同的指令。一般来说,获取的指令有:iget, sget, iget-boolean, sget-boolean, iget-object, sget-object等。操作的指令有:iput, sput, iput-boolean, sput-boolean, iput-object, sput-object等。

没有-object后缀的表示操作的成员变量对象是基本数据类型,带-object表示操作的成员变量是对象类型。特别地,boolean类型则使用带-boolean的指令操作。

例1:

1
sget-object v0, Lcom/aaa;->ID:Ljava/lang/String;

sget-object就是用来获取变量值并保存到紧接着的参数的寄存器中,本例中,它获取ID这个String类型的成员变量并放到v0这个寄存器中。注意,前面需要该变量所属的类的类型,后面需要加一个冒号和该成员变量的类型,中间的“->”表示所属关系。

例2:

1
iget-object v0, p0, Lcom/aaa;->view:Lcom/aaa/view;

可以看到iget-object指令比sget-object多了一个参数,就是该变量所在类的对象,p0即“this”。

获取array的话可以用aget和aget-object,指令使用方法和上述一致。

例3:put指令的使用和get指令是统一的如下:

1
2
const/4 v3, 0x0
sput-object v3, Lcom/aaa;->timer:Lcom/aaa/timer;

相当于this.timer=null;

注意,这里是因为赋值object所以是null;若是boolean的话应该是0。

例4:

1
2
3
.local v0, args:Landroid/os/Message;
const/4 v1, 0x12
iput v1, v0, Landroid/os/Message;->what:l

相当于args.what=18;(args是Message的对象)。

1.3.5 Smali中函数的调用

smali中的函数和成员变量一样也分为两种类型,分别为direct和virtual之分。那么direct method和virtual method有什么区别呢?

简单来说,direct method就是private函数,其余的public和protected函数都属于virtual method。所以在调用函数时,有invoke-direct,invoke-virtual,另外还有invoke-static、invoke-super以及invoke-interface等几种不同的指令。

当然其实还有invoke-XXX/range 指令的,这是参数多于4个的时候调用的指令,比较少见,了解下即可。

invoke-static

用于调用static函数的。

例如:

1
invoke-static {}, Lcom/aaa;->CheckSignature()Z

这里注意到invoke-static后面有一对大括号“{}”,其实是调用该方法的实例+参数列表,由于这个方法既不需参数也是static的,所以{}内为空。

再看一个:

1
2
const-string v0, "NDKLIB" 
invoke-static {v0}, Ljava/lang/System;->loadLibrary(Ljava/lang/String;)V

这个是调用static void System.loadLibrary(v0)来加载NDK编译的so库用的方法,v0就是参数“NDKLIB”。

invoke-super

调用父类方法用的指令,一般用于调用onCreate、onDestroy等方法。

invoke-direct

调用private函数。

1
invoke-direct {p0}, Landroid/app/TabActivity;-><init>()V

这里init()就是定义在TabActivity中的一个private函数。

invoke-virtual

用于调用protected或public函数,同样注意修改smali时不要错用invoke-direct或invoke-static。

1
2
sget-object v0, Lcom/dddd;->bbb:Lcom/ccc;
invoke-virtual {v0, v1}, Lcom/ccc;->Messages(Ljava/lang/Object;)V

v0是bbb:Lcom/ccc,v1是传递给Messages方法的Ljava/lang/Object参数。

invoke-xxxxx/range

当方法的参数多于5个时(含5个),不能直接使用以上的指令,而是在后面加上“/range”,range表示范围,使用方法也有所不同。

1
invoke-direct/range {v0 .. v5}, Lcmb/pb/ui/PBContainerActivity;->h(ILjava/lang/CharSequence;Ljava/lang/String;Landroid/content/Intent;I)Z

需要传递v0到v5一共6个参数,这时候大括号内的参数采用省略形式,且需要连续。

1.3.6 Smali中函数返回的结果的操作

在Java代码中调用函数和返回函数结果可以用一条语句完成,而在Smali里则需要分开来完成,在使用上述指令后,如果调用的函数返回非void,那么还需要用到move-result(返回基本数据类型)和move-result-object(返回对象)指令:

1
2
3
const-string v0, "Eric"
invoke-static {v0}, Lcmb/pbi;->t(Ljava/lang/String;)Ljava/lang/String;
move-result-object v2

v2保存的就是调用t方法返回String字符串。

1.3.7 Smali中函数实体分析—if函数分析

1
2
3
4
5
6
7
8
9
10
11
12
13
.method private ifRegistered()Z
.locals 2 //在这个函数中本地寄存器的个数,2个
.prologue
const/4 v0, 0x1 // v0赋值为1
.local v0, tempFlag:Z
if-eqz v0, :cond_0 // 判断v0是否等于0,等于0则跳到cond_0执行
const/4 v1, 0x1 // 符合条件分支
:goto_0 //标签
return v1 //返回v1的值
:cond_0 //标签
const/4 v1, 0x0 // cond_0分支
goto :goto_0 //跳到goto_0执行 即返回v1的值 这里可以改成return v1 也是一样的
.end method

1.3.8 Smali中函数实体分析—for函数分析

1
2
3
4
5
6
7
8
9
10
11
const/4 v0, 0x0   //v0 = 0;
.local v0, i:I
:goto_0
if-lt v0, v3, :cond_0 // v0小于v3 则跳到cond_0并执行分支 :cond_0
return-void
:cond_0 // 标签
iget-object v1, p0, Lcom/aaa/MainActivity;->listStrings:Ljava/util/List; // 引用对象
const-string v2, "Eric"
invoke-interface {v1, v2}, Ljava/util/List;->add(Ljava/lang/Object;)Z // List是接口, 执行接口方法add
add-int/lit8 v0, v0, 0x1    // 将第二个v0寄存器中的值,加上0x1的值放入第一个寄存器中, 实现自增长
goto :goto_0 // 回去:goto_0标签

1.3.9 课后习题

翻译成Java代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
.local 4                   //本地寄存器4个,即v0,v1,v2,v3
const/4 v2, 0x1 //4字节常量v2=1
const/16 v1, 0x10 //16字节常量v1=16
:local v1, "length":I //本地寄存器int length=v1
if-nez v1,:cond_1 //如果v1不等于0,这跳转至cond_1
:cond_0 //cond_0标签
:goto_0 //goto_0标签
return v2 //返回v2的值
:cond_1 //开始执行cond_1标签代码
const/4 v0,0x0 //4字节常量v0=0
:local v0, "i":I //本地寄存器int i=v0
:goto_1 //开始执行goto_1标签代码
if-lt v0, v1, :cond_2 //如果v0小于v1,则跳转至cond_2
const/16 v3,0x28 //接上:如果v0大于等于v1,则执行下面语句: 16字节常量v3=40
if-le v1,v3, :cond_0 //接上:如果v1小于等于v3,则跳转至cond_0,即返回v2的值
const/4 v2, 0x0 //接上:如果v1大于v3,则4字节常量v2=0
goto:goto_0 //跳转至goto_0,即返回v2的值
:cond_2 //cond_2标签
xor-int/lit8 v1, v1, 0x3b //将第二个v1寄存器中的值与0x3b(59)进行异或运算,得到的值赋值给第一个v1寄存器中
add-int/lit8 v0, v0, 0x1 //将第二个v0寄存器中的值加上0x1(1),所得的值放入第一个v0寄存器中
goto:goto_1 //跳转值goto_1标签,这里可以看到cond_2实际上是一个for循环,而不是简单的IF判断
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
v2 = 1;
v1 = 16;
if (v1 != 0)
{
v0 = 0;
for(v0 < v1)
{
v1 = v1 ^ 59;
v0 = v0 + 1;
}
v3 = 40;
if(v1 <= v3)
{
return v2;
}
v2 = 0;
return v2;
}

1.4 寄存器

在smali里的所有操作都必须经过寄存器来进行:

  • 本地寄存器用v开头数字结尾的符号来表示,如v0, v1, v2…
  • 参数寄存器则使用p开头数字结尾的符号来表示,如p0, p1, p2…

特别注意的是,p0不一定是函数中的第一个参数,在非static函数中,p0代指“this”,p1表示函数的第一个参数,p2表示函数的第二个参数…而在static函数中p0才对应第一个参数(因为Java的static方法中没有this方法)。

1.4.1 简单对象分析

1
2
const/4 v0, 0x1
iput-boolean v0, p0, Lcom/aaa;->IsRegistered:Z

它使用了本地寄存器v0,并把值0x1存到v0中。用iput-boolean这个指令把v0作为参数p0赋值到com.aaa.IsRegistered这个成员变量中。即相当于:this.IsRegistered=true;

2. 破解第一个Android程序

破解Android程序需要静态反编译程序Android Killer,打开后第一步配置JDK的安装路径。

https://down.52pojie.cn/Tools/Android_Tools/ShakaApktool_3.0.0-20170503-release.jar 下载ShakaApktool_3.0.0-20170503-release.jar,将它放到AndroidKiller_v1.3.1\bin\apktool\apktool目录下,按照下图完成操作。

将需要反编译的.apk文件拖进Android Killer后会自动反编译,但最后显示“正在反编译APK源码,请稍等…”时可能会卡住,需要关闭软件再次打开。

找到历史工程重新打开.apk文件,点击入口即可看到smali文件。

如果经常卡住可以试试替换AK目录下的rtl230.bpl

既然了解了流程,就可以动手破解程序了。

第一种:知道了账户密码,可直接拿那个账户密码登录。

1
2
用户名:hfdcxy
密码:1234

第二种:将验证账户密码的两条跳转语句修改。

1
2
if-eqz v0, :cond_0		->	if-nez v0, :cond_0
if-eqz v0, :cond_0 -> if-nez v0, :cond_0

第三种:直接将验证账户密码的两条跳转语句删除。

第四种:用goto语句直接跳到登录成功处。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
move-result v0

goto :goto_3 #添加goto

if-eqz v0, :cond_0

const-string v0, "1234"

invoke-virtual {p2, v0}, Ljava/lang/String;->equals(Ljava/lang/Object;)Z

move-result v0

if-eqz v0, :cond_0

.line 30
:goto_3 #添加goto
const-string v0, "\u767b\u5f55\u6210\u529f" #Unicode编码,“登录成功”

smali修改完成后 Ctrl+S 保存,点击左上角的编译。

下载雷电模拟器 充当手机,可以在电脑上运行.apk文件,找到雷电模拟器设备,安装。

然后在雷电模拟器中运行程序,输入错误的用户名和密码会提示登录成功,说明破解成功。

3. 破解第一个Android游戏

运行一下程序,发现购买会出现“支付失败”字样,其Unicode为\u652F\u4ED8\u5931\u8D25。拖入AK反编译,按照下图搜索字符串,但没有找到。

再找“失败”,可直接在搜索框输入“失败”,再点左下角的编码转换即可转换为Unicode码。找到很多有关“失败”的字符串,一一排除。最后找到一个“购买失败”。

再上下看看可以看到有“购买取消”、“购买成功”等字样。如果看smali难看懂,可以转换成java源码,但转换的源码可读性比较差,还是建议读smali,而且修改必须是在smali里修改才可以成功编译。

1
2
3
4
5
6
7
8
9
10
11
.method public payResultCancel()V
...
.end method #以上为支付取消的代码

.method public payResultFalse()V
...
.end method #支付失败

.method public payResultSuccess()V
...
.end method #支付成功

首先来个简单粗暴的方法,直接将public void payResultSuccess()方法里的代码全都复制到public void payResultCancel()public void payResultFalse()中。再删除可能会产生费用的危险权限:在AndroidManifest.xml里搜索(或者直接搜索)android.permission.SEND_SMSandroid.permission.CALL_PHONE,删掉 <uses-permission android:name="android.permission.SEND_SMS"/><uses-permission android:name="android.permission.CALL_PHONE"/> 即可。

第二种方法,再观察一下代码,到底是哪里开始分岔到“购买成功”、“购买取消”、“购买失败”的呢?搜索payResultFalse找到有跳转处的地方。

同上,再删除可能会产生费用的危险权限,编译。

4. AS动态调试smali代码

下载Android Studio,这里有个非常大的坑耗了我两天时间,由于在官网下载不了,导致我去别的地方下载了无数版本的AS,最后安装smalidea插件造成各种问题。

解决办法:把官网链接https改为http即可。

http://redirector.gvt1.com/edgedl/android/studio/install/2021.1.1.22/android-studio-2021.1.1.22-windows.exe

动态调试需要smalidea插件,下载最新版的smalidea-0.06.zip压缩包。最后直接导入插件,不要解压。安装、导入自行百度。可以新建一个项目直接连入模拟器看AS是否能够正常运行。第一次新建项目花费时间长一点。

.apk文件拖进AK反编译成.smali文件,文件入口为hfdcxy.com.myapplication.MainActivity。在application标签里找android:debuggable="true"这句代码。如果没有这句代码就调试不了,如果是false则改为true,重新编译签名。将新编译好的.apk安装在模拟器上。

.apk文件右键,打开文件路径。

把整个project目录复制到某处,用AS导入。给smali目录设置Sources Root。

Run -> Edit configurations -> + -> Remote JVM Debug -> 设置Name,设置端口号为8700。

打开CMD,运行以下命令,以Debug模式启动应用:

1
2
3
4
5
6
C:\Users\dell>adb devices
List of devices attached
emulator-5554 device

C:\Users\dell>adb shell am start -D -n hfdcxy.com.myapplication/hfdcxy.com.myapplication.MainActivity
Starting: Intent { cmp=hfdcxy.com.myapplication/.MainActivity }

如果显示“’adb’ 不是内部或外部命令,也不是可运行的程序或批处理文件。”请看 https://www.cnblogs.com/plsmile/p/11172693.html

执行完第二条adb后,模拟器进入调试页面,记下PID为2160。不要点模拟器任何东西!!

绑定远程调试窗口:

1
2
C:\Users\dell>adb forward tcp:8700 jdwp:2160
8700

回到AS设置断点,Run -> Attch Debugger to Android Process。

回到模拟器,输入用户名和密码,点击登录。AS停在断点处,看到我们刚才输入的变量。

单步F8,运行F9,与OD一样。可以下多几个断点,看寄存器的值,但需要自己添加想看的寄存器。

5. 在smali代码中插入Log

新建一个项目在MainActivity.java里面写一段switch case语句。注意,新建时语言要选择Java。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class MainActivity extends AppCompatActivity
{
String name;
@Override
protected void onCreate(Bundle savedInstanceState)
{
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
name = "v5le0n9";
switch(name)
{
case "v5le0n9":
Log.i("Hello","v5le0n9哈哈");
break;
case "l30n9ry0n":
Log.i("Hello","l30n9ry0n啦啦");
break;
default:
Log.i("Hello","没有符合的name值");
break;
}
}
}

点击三角符号运行,或Shift+F10,程序被安装到模拟器上。在Logcat查找“hello”,找到对应Log。

要想.apk文件保存在电脑上,按照下图操作。

D:\Java\Android\MyApplication\app\build\intermediates\apk\debug找到.apk文件。拖进AK反编译。点击入口进入MainActivity.smali,分析代码。

注意,能修改smali文件的前提是smali文件没有丢失,否则修改了也不能编译成功。再注意,为什么我们编写出来的程序放到AK反编译会显示文件已丢失?可能是因为Android Studio2.0+的Instant Run导致的。

解决方法:关闭Android Stuio的Instant Run:File -> Setting -> Build, Execution,Deployment -> Debugger -> HotSwap ,取消选中,点击OK。点击Build -> APK重新打包。

结果还是不行!!直接生成release版本的apk试试,build -> Geberate signed apk -> APK。如果没有keystore则需要创建一个新的。

创建keystore看 https://blog.csdn.net/qq_24349695/article/details/78540982

在点击finish时又给我抛出错误:Error:Execution failed for task ‘:app:lintVitalRelease’,解决方法:

在app的build.gradle里的android{}中添加如下代码,然后再次运行Generate Signed Apk。例如:

1
2
3
4
5
6
android{
lintOptions {
checkReleaseBuilds false
abortOnError false
}
}

这次用release版本终于没有丢失smali文件了。已知代码运行出现的Log是:cond_1里面的信息,所以在:cond_1添加我们想看到的信息,保存编译。

AK连上模拟器,因为模拟器之前有我们在AS直接安装的程序,所以先卸载,再编译安装修改过的程序。回到AS就可以看到多了一条Log信息,但AS中的java语言并没有被修改。

所以Log有什么用呢?很多情况下插入Log是为了打印出程序中某个变量的值。

用以下程序完成三个任务:

  1. 添加Log打印出fun2,fun3的值 (其实就是函数的返回值)

  2. 添加Log打印出fun3里面String类型str的值

  3. 添加Log打印出fun3里面int类型value3的值

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
//MainActivity.java
public class MainActivity extends AppCompatActivity {

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
fun1();
fun2();
fun3();
Log.i("这个值是",String.valueOf(fun1()));

}
public int fun1()
{
int value = Test.value;
return value;
}
public int fun2()
{
int value2 = Test.value2;
return value2;
}
public String fun3()
{
String str = Test.str;
String str2 = Test.str2;
int value3 = Test.value3;
return str2;
}
}
1
2
3
4
5
6
7
8
//Test.java
public class Test {
public static int value = 888;
public static int value2 = 777;
public static int value3 = 666;
public static String str = "www.52pojie.cn";
public static String str2 = "码完代码去看东方明珠";
}

一样release版本拿去AK反编译。

任务一:添加Log打印出fun2, fun3的值

.line 17是执行Log代码的内容,有趣的是在AS中Log.i("这个值是",String.valueOf(fun1()));刚好是第17行。

由于fun2和fun1的返回值类型一致,所以可直接复制这些代码,区别只是将fun1改为fun2。

fun2和fun3函数都是int类型,通过String.valueOf这个函数转换成的String类型。但是fun3这个函数本身就是String类型,这里如果还通过String.valueOf函数转换的话程序会报错。所以将执行String.valueOf的代码去掉即可。

保存 -> 卸载 -> 编译 -> 安装。

任务二:添加Log打印出fun3里面String类型str的值

翻译一下就是:

1
2
3
4
5
6
7
public String fun3()
{
String v0 = str;
String v0 = str2;
int v1 = value3;
return v0;
}

即str的值被str2覆盖了,所以可以直接删掉.line 33代码。

任务三:添加Log打印出fun3里面int类型value3的值

将fun3的smali代码按照fun2的smali代码修改返回值类型。

最后还要添加修改Log处的代码。

6. 编写第一个so

Android开发中,我们经常会用到.so文件。原因有很多,比如部分方法不想暴露,如加密规则。比如部分秘钥需要存储,哪怕最简单的一个String我们使用.so调用获取这个String,也比直接明文写在代码中要来的安全。那么逆向破解也是一样, 为了避免以后破解so时知其然而不知其所以然,要破解一个so就得先学习这个so是怎么编写的。

生成so文件需要NDK,由于本人安装NDK安装得太混乱了,出了各种各种的问题最后莫名其妙就成功了,所以以下步骤仅供参考,如果发现错误及时百度。

创建一个native C++项目,一路next。

创建项目时会自动下载NDK,所以不用管。及时看build窗口信息,问题或异常会在build窗口显示。可以看到在D:\Java\Android\sdk\ndk\21.4.7075529就下载好了ndk的21.4版本。

打开Project的local.properties文件添加NDK路径。

此时,可以在 File -> Project Structure -> SDK Location 就可以看到NDK路径了,说明NDK已经安装好并且能用了。

那么现在就可以正式编写so文件了。在MainActivity.java的父目录里新建一个类,命名为myJNI

声明native方法。这个类是java与C/C++交互的中介,方法由java声明,由C/C++实现。

1
2
3
4
5
6
7
8
public class myJNI {
  //加载so库
static {
System.loadLibrary("JniTest");//so库名字
}
  //native方法
public static native String sayHello();//调用so库中的sayHello()方法
}

在左侧栏右键myJNI.java,复制路径,在AS下面的终端编译myJNI类,生成myJNI.class文件。

1
javac D:\Java\Android\MyApplication4\app\src\main\java\com\example\myapplication\myJNI.java

记住包名为com.example.myapplication,类名为myJNI。在AS终端上去到java目录,生成.h头文件。

1
javah -jni com.example.myapplication.myJNI

将生成的头文件拖到cpp目录下,并且将native-lib.cpp强制删去。在cpp目录下新建.c文件。

将文件头包括进来,实现sayHello()方法。

1
2
3
4
5
#include "com_example_myapplication_myJNI.h"
JNIEXPORT jstring JNICALL Java_com_example_myapplication_myJNI_sayHello(JNIEnv *env, jclass jobj)
{
return (*env)->NewStringUTF(env,"hello 52pojie!");
}

由于我们使用CMake来生成so的, 所以要修改CMakeLists.txt来指定so名称和so的源文件的相对路径。点击上方“大象”同步一下。

完成以上步骤之后,生成release版本的apk,要不然将来想要修改.so文件后不能在模拟器上运行。

生成的so在app\build\intermediates\cmake\debug\obj\app\build\intermediates\merged_native_libs\debug\out\libapp\build\intermediates\stripped_native_libs\debug\out\lib\。为什么相同的东西要分别放在三个地方,不懂。随便一个目录看看:

发现这几个目录里面都有libJniTest.so,不同处理器使用的文件不一样。雷电模拟器就是x86架构的。

  • armeabi-v7a: 第7代及以上的 ARM 处理器。2011年15月以后的生产的大部分Android设备都使用它。
  • arm64-v8a: 第8代、64位ARM处理器,很少设备,三星 Galaxy S6是其中之一。
  • armeabi: 第5代、第6代的ARM处理器,早期的手机用的比较多。
  • x86: 平板、模拟器用得比较多。
  • x86_64: 64位的平板。

app/src/main下新建jnilib目录, 并将生成的SO文件拷贝到该文件夹下。

打开MainActivity.java插入一条log来调用so中的sayHello()方法,并连接模拟器调试。

7. IDA破解第一个so

7.1 预备知识与环境配置

下载最新版apktool.jarapktool.bat一起放到C:\Windows目录下,不想下载也可以在AK目录下找到它们,大概在D:\Java\AndroidKiller_v1.3.1\bin\apktool,再放到C:\Windows。不想放到C:\Windows也可以把环境变量设到上面路径中,随你喜欢。反正最后的结果是可以在命令窗口使用apktool。记住两个关键命令:

1
2
apktool d test.apk		//解包
apktool b test //重打包

重打包后的apk由于没有签名,所以这里需要对重打包后的apk进行签名后才能在手机上安装并运行。打开AK,工具->APK签名,将要签名的apk拖拉到软件中进行签名,点执行后将会在当前目录生成hello_sign.apk

7.2 破解so文件

打开AS,在Logcat模块连接好模拟器,将.apk文件安装到模拟器上,运行.apk,回到Logcat搜索“52pojie”。

我们的目的是修改这句话。

在主机上将.apk解包后,进入lib目录,发现有4个目录。

那我们用IDA(这里用IDAv6.6,因为IDAv7.0没有modifyfile插件)打开x86目录下的so文件,Shift + F12打开字符串窗口,Ctrl + F 查找“52pojie”,双击进入找到其内存地址。

选中字符串,按照下图操作去到十六进制视图。

将我们想写入的内容转换为十六进制。

1
68 65 6c 6c 6f 20 76 35 6c 65 30 6e 39

回到IDA,选中需要修改的首字节右键->Edit。

修改好,再次右键 -> Apply changes。

Edit -> Plugins -> modifyfile -> 确认更改。另存到某处。

如果lib目录中有多种模式,如果修改32位.so则把所有32位处理器目录下的.so都更换为新的.so文件,64位同理。我们只修改的是32位的,所以只要在x86目录下替换即可。

重新打包并签名。安装在模拟器上,运行。

8. IDA爆破签名验证(IDA静态分析)

我发现会飞的丑小鸭特别油麦,下面是他为了引出主题写的一个场景,我觉得特别逗就拿过来给你们看看。

李华是一个很有天赋 的Android程序员 他用了半年时间含辛茹苦,挑灯夜战,摧枯拉朽的编写了一款黑宝宝游戏。当然这几个词形容的并不恰当,但是李华确实为了这个apk的上线付出了很多努力。谁知游戏刚一上线就被破解了,生不生气?难不难过?

吸取了这次的教训,李华决定要反击。他通过书籍了解到一个apk只有一个签名,于是他有了一个很大胆的想法:如果别人要破解我的apk,他一定会对我的apk进行重打包,但是重打包后的签名就不是我原来的签名了,我可以在代码中判断,如果签名不是我的签名,那么就让程序退出。这样不就达到防止别人破解的目的了,哈哈哈,太佩服我自己了。

他知道你最近在学习Android逆向,他想在游戏上线前让你测试一下他新加的签名验证是否能防住别人的破解。
下面是李华编写的黑宝宝apk
链接:https://pan.baidu.com/s/1h6pX2ARE3qtiKiYbcnJ-3g 密码:duv5

你拿到这个apk直接反编译重打包后安装到手机上,刚一运行程序就退出,你懵了,明明我什么都没改!接着看了一会反编译后的代码说:他的签名验证是写在so里面的,但是我不会so的破解,大哥你教教我吧!

我说:好吧!

下面开始本节课的课程,请同学们认真听课。

用apktool解包apk后将项目载入AS,在myJNI.smali里有check函数,应该就是验证签名是否一致的函数。

libJniTest.so载入IDA,需要注意的是,IDA众多窗口中,有两个窗口与so有关:Exports窗口是导出表,能让外部调用so中的函数;Imports窗口是导入表,能让so调用外部的函数。所以根据上面的信息,so里有check函数,所以check可以被外部调用,应该在导出表里找check函数。

双击进去到汇编代码,F5进入反汇编代码。以下两种情况是根据不同版本的IDA对so文件修改的处理。

8.1 IDA v7.0+

去到反汇编代码后,看到很多字符串,暂时我们还不知道有什么用,但我们熟悉Log,下面这三条应该是输出Log语句。

先进去unk_223C看看里面是什么。好吧,就算16进制转文本也翻译不出来。

1
2
3
4
4A 4E 49 E8 8E B7 E5 8F 96 E5 88 B0 E7 9A 84 E7 AD BE E5 
J N I
90 8D E6 98 AF 25 73
% s

那就在模拟器运行一下用AS获取Log吧。

再拿去16进制转文本,这跟上面的16进制代码有半毛钱关系吗?!这里我真不知道怎么回事,哪位大牛来告诉我。

已知那一大串数字是签名,如果v9与字符串一致,则跳到unk_261A显示“签名一致”,那unk_262E自然就是“签名不一致”,退出程序。我们破解的思路是,就算v9与字符串不一致,也要让它跳到unk_261A去。

回到汇编视图,在左侧的函数窗口找到check函数双击来到图形化窗口,找到关键跳转。

BNE:数据跳转指令,标志寄存器中Z标志位不等于零时, 跳转到BNE后标签处。
BEQ:数据跳转指令,标志寄存器中Z标志位等于零时, 跳转到BEQ后标签处。

所以我们把BNE修改为BEQ即可。BNE的机器码为D1BEQ的机器码为D0。按照下图操作修改机器码。

修改完后保存so文件。

armeabiarmeabi-v7a下的libJniTest.so替换成修改后的so,再删掉x86目录。打包签名。

这里为什么要删掉x86目录,可能是因为雷电模拟器是x86架构的,它默认使用x86目录下的libJniTest.so,所以删掉才有可能使用armeabiarmeabi-v7a目录下的libJniTest.so

8.2 IDA v6.6

去到反汇编代码后,看到一条很长的字符串,暂时我们还不知道有什么用,但我们熟悉Log,下面这三条应该是输出Log语句。

由于中文乱码,所以设置编码为UTF-8,Options -> ASCII string style -> Set default encodings -> 8-bit… -> Change -> UTF-8 -> OK。

F5重新反编译一下,乱码问题解决。

分析一下程序流程,getSignature是获取程序签名,获取的签名与那一长串比较,如果相等则“签名一致”。破解的思路是即使获取的签名与存储的签名不一致,也可以让程序跳到“签名一致”处,本质就是修改跳转指令。

回到汇编视图,在左侧的函数窗口找到check函数双击,按空格来到图形化窗口,找到关键跳转。

BNE:数据跳转指令,标志寄存器中Z标志位不等于零时, 跳转到BNE后标签处。
BEQ:数据跳转指令,标志寄存器中Z标志位等于零时, 跳转到BEQ后标签处。

所以我们把BNE修改为BEQ即可。BNE的机器码为D1BEQ的机器码为D0。老方法,去到hex dump处修改十六进制代码。保存so文件,删掉x86目录,打包签名。

9. IDA动态破解登录验证

9.1 预备知识与环境配置

jeb工具的使用 https://www.52pojie.cn/thread-742250-1-1.html

我觉得jeb就是AK+AS,可以看看,如果熟悉AK和AS,jeb很容易上手。jeb的优点是反编译回Java的可读性比AK强。

Android逆向必会命令 https://www.52pojie.cn/thread-742284-1-1.html

连手机和连模拟器是一模一样的命令,不需要担心。

so文件如果是arm架构的,用x86架构的雷电模拟器可能会出问题,所以最好用真机或安卓原生模拟器或Genymotion调试。真机需要root权限,否则IDA在附加上程序时出现不了包名。但小米手机不是默认root,搞个root权限要花很长时间。原生模拟器也太卡了…但卡归卡,调试时还是很友好的。Genymotion会出现各种各样的问题,我佛了。

Genymotion安装及ARM支持 https://blog.csdn.net/fidelhl/article/details/85239238

Genymotion-ARM-Translation https://www.jianshu.com/p/97b8250f359e

adb devices检测不到genymotion模拟器 https://blog.csdn.net/qq_15158911/article/details/75304011

关闭端口:

1
2
3
4
5
C:\Users\dell>netstat -ano | findstr 23946
TCP 127.0.0.1:23946 0.0.0.0:0 LISTENING 13752

C:\Users\dell>taskkill -pid 13752 -f
成功: 已终止 PID 为 13752 的进程。

9.2 动态破解登录验证

拿到一个.apk程序,先在模拟器上安装,运行一下熟悉流程。程序与第8节的几乎一样,但第8节的程序没有android:debuggable="true"

.apk文件用AK打开,因为需要调试,所以必须保证application标签里的android:debuggable="true"

找到MainActivity入口类,并反编译成java代码。通过静态分析java代码可知,用户在输入用户名和密码后程序会调用Native方法check。

解包将libJniTest.so载入IDA分析check方法的具体实现。这个程序有3个lib,具体分析哪个libJniTest.so,看模拟器或真机默认使用哪个so。Genymotion虽然安装了arm架构,但如果有x86的so文件它还是使用x86目录下的。真机是arm架构的,用armeabi-v7aarmeabi都没问题,但修改完后要把两个目录下的so文件都替换成新的。

将x86目录下的so文件载入IDA,几乎与armeabi目录下的差不多,但汇编代码是我们熟悉的PC逆向,感觉来了!

开始动态调试。将IDA_Pro_7.5\dbgsrv目录下的android_x86_server push 到模拟器/data/local/tmp/目录下,给777权限并运行android_x86_server。注意,真机 push android_server

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
C:\Users\dell>adb push D:\CTF\tools\IDA_Pro_v7.5\dbgsrv\android_x86_server /data/local/tmp
D:\CTF\tools\IDA_Pro_v7.5\dbgsrv\android_x86_server: 1 file pushed, 0 skipped. 693.5 MB/s (1130104 bytes in 0.002s)

C:\Users\dell>adb shell
vbox86p:/ # cd /data/local/tmp
vbox86p:/data/local/tmp # ls -al
total 1900
drwxrwx--x 2 shell shell 4096 2022-03-08 19:53 .
drwxr-x--x 3 root root 4096 2022-03-07 00:13 ..
-rwxrwxrwx 1 root root 786868 2020-12-31 11:00 android_server
-rw-rw-rw- 1 root root 1130104 2020-12-31 11:00 android_x86_server
vbox86p:/data/local/tmp # chmod 777 android_x86_server
vbox86p:/data/local/tmp # ./android_x86_server
IDA Android x86 32-bit remote debug server(ST) v7.5.26. Hex-Rays (c) 2004-2020
Listening on 0.0.0.0:23946...

新开一个cmd,执行端口转发命令:

1
2
C:\Users\dell>adb forward tcp:23946 tcp:23946
23946

模拟器运行该程序,回到刚才打开的IDA,确保已经载入主机该程序的so文件,且与模拟器使用的so文件一致。Debugger -> Select debugger 。选择Linux debugger。

Debugger -> Process options ,确认端口号。

Debugger -> Attach to process ,找到我们需要附加的包名。

确认so文件是否一致。

等它加载,在某个地方停下来,此时,EIP指向停止处。

Ctrl + S找so文件,找到有执行权限且最开始的so文件。

或在Modules窗口找so文件,在so文件里找check方法。

也可以F5查看伪代码,根据伪代码在汇编代码中找到几个跳转语句下断。

F9运行程序,输入用户名555和密码3333,点击登录,IDA停在第一个断点处。因为我们没有重新编译签名,所以签名是一致的,不跳转,继续往下执行。

F8往下执行或F9来到下个断点处,可以看到寄存器窗口ESI指向我们输入的用户名,EDI指向真正的用户名,将两个进行对比,由于不一致,所以跳转实现。第三个断点一样,不再赘述。

接下来修改,因为我们修改完so文件,要重新编译打包签名,所以签名校验一定要绕过,用户名和密码也要爆破,所以总共要修改三处跳转。

jz的机器码为74,jnz的机器码为75。选中要修改的字节,Edit -> Patch program -> Change Byte 。

Edit -> Patch program -> Apply patches to input file ,保存so文件。重打包,在AK中签名。模拟器安装程序,验证,登录成功。

注意,我们只修改了x86目录下的so文件,如果想要程序在所有架构都能“登录成功”,必须要修改它所有拥有的so文件。

10. 动态调试反调试apk

10.1 反调试及反反调试

10.1.1 IDA调试端口检测

原理:调试器远程调试时,会占用一些固定的端口号,如23946。

解决方法:修改调试端口号。端口号范围从0到65535,0不使用,1到1023为BSD保留端口,也是系统端口,1024到5000是BSD临时端口,5001到65535为用户自定义端口。

1
./android_server -p6666

10.1.2 调试器进程名检测

原理:远程调试要在手机中运行android_servergdbservergdb等进程。

解决方法:修改调试器server名字。

1
rename android_server heiboy

10.1.3 ptrace检测

原理:一个进程只能被ptrace一次,可以自己ptrace自己,如果被调试器ptrace了,自己ptrace肯定ptrace不了,根据返回值进行判断。

解决方法:

  1. 修改系统源码,让ptrace返回值恒为0
  2. hook ptrace

10.2 反反调试apk

拿到一个.apk程序,先在AS原生模拟器上安装,运行一下熟悉流程。如果只有arm架构so文件的最好用AS原生模拟器,因为genymotion即使支持arm架构还是调试不了。

1
adb install AliCrackme_2_killer.apk

.apk文件用AK打开,因为需要调试,所以必须保证application标签里有android:debuggable="true"。如果没有必须加上,重新编译打包,卸载模拟器里的程序,重新安装。

找到MainActivity入口类,并反编译成java代码。通过静态分析java代码可知,程序调用了Native方法securityCheck,且放在了libcrackme.so文件中。

解包发现只有armeabi目录的libcrackme.so,载入IDA分析securityCheck方法的具体实现,伪代码和汇编代码配合使用。

盲猜 v5 == v3,但很遗憾,失败了,所以v3一定是经过某种转换才等于“wojiushidaan”。选中v5后面的v3右键->Set Ivar Type,通过JNIEnv*还原类似((_DWORD )v3 + 676))格式的指令。

要想知道怎么变换,需要动态调试libcrackme.so文件。

打开cmd,将IDA_Pro_v7.5\dbgsrv目录下的android_server push 到模拟器/data/local/tmp/目录下,给777权限并运行android_server

1
2
3
4
5
6
7
8
9
10
11
12
13
14
C:\Users\dell>adb push D:\CTF\tools\IDA_Pro_v7.5\dbgsrv\android_server /data/local/tmp
D:\CTF\tools\IDA_Pro_v7.5\dbgsrv\android_server: 1 file pushed, 0 skipped. 494.3 MB/s (589588 bytes in 0.001s)

C:\Users\dell>adb shell
vbox86p:/ # cd /data/local/tmp
vbox86p:/data/local/tmp # ls -al
total 596
drwxrwx--x 2 shell shell 4096 2022-03-07 09:01 .
drwxr-x--x 3 root root 4096 2022-03-07 00:13 ..
-rw-rw-rw- 1 root root 589588 2017-09-14 03:08 android_server
vbox86p:/data/local/tmp # chmod 777 android_server
vbox86p:/data/local/tmp # ./android_server
IDA Android 32-bit remote debug server(ST) v1.22. Hex-Rays (c) 2004-2017
Listening on 0.0.0.0:23946...

新开一个cmd,执行端口转发命令:

1
2
C:\Users\dell>adb forward tcp:23946 tcp:23946
23946

按照第9节的照做一遍,PC指向程序停止处。 Ctrl + S 找到有执行权限的libcrackme.so,在Modules窗口找到的securityCheck函数的图形化界面竟然没有显示“wojiushidaan”,而是“aiyou,bucuoo”。

回到汇编代码再函数起始处下断,F9运行,程序直接退出。说明程序有反调试功能。

先不管那么多,输入“aiyou,bucuoo”试试,成功了!

好啦好啦你肯定又跟我说我学的是破解!回归正题,反调试的基本原理是这样的:IDA使用android_server在root环境下注入到被调试的进程中,用到的技术是Linux中的ptrace,当Android中的一个进程被另外一个进程ptrace之后,在其status文件中有一个字段TracerPid可以标识是被哪一个进程trace了(Linux中的/proc/pid/status文件)。这里有两个地方是so动态加载完毕前执行的,.init_array是一个so最先加载的一个段信息,时机最早,现在一般so解密操作都是在这里做的;JNI_OnLoad是so被System.loadLibrary调用的时候执行的,它的时机早于native方法的执行。

反调试机制很可能在JNI_Onload处就让程序退出的,所以我们得先去掉反调试机制,才能继续进行破解。那如何断在JNI_OnLoad函数指令处呢?Debugger -> Debugger options -> 勾选下面三个选项。

这三个选项意味着:

  • 第一个:在APK程序入口处停止。
  • 第二个:有线程启动运行或者退出时,暂停。
  • 第三个:当动态库(apk中的so文件)加载或者取消加载时,暂停。

但是由于被调试程序一运行就会执行static中的语句,因此需要让程序停在加载so文件之前,故可以使用debug方式来启动:

1
2
C:\Users\dell>adb shell am start -D -n com.yaotong.crackme/.MainActivity
Starting: Intent { cmp=com.yaotong.crackme/.MainActivity }

在载入so文件的IDA下点击 Debugger -> Attach to process ,找到我们需要附加的包名。等它加载到PC停止。

此时,so文件还没有被加载到内存中去,所以还要让程序跑起来。启动 DDMS(进入sdk安装目录\sdk\tools下,运行monitor.bat脚本启动),在DDMS上选择相应进程后,使用指令使apk继续运行,成功后,DDMS上进程将显示绿色,否则是红色。

1
2
3
4
5
C:\Users\dell>jdb -connect com.sun.jdi.SocketAttach:port=8700,hostname=localhost
设置未捕获的java.lang.Throwable
设置延迟的未捕获的java.lang.Throwable
正在初始化jdb...
>

如果在DDMS中找不到相应进程,点一下重置adb,再选中目标进程,输入命令。

点击运行几次,直至弹窗。

Ctrl + S 看到有执行权限的libcrackme.so文件,在Modulus窗口找到libcrackme.so中的JNI_Oload函数,在函数起始处下断,F9运行。

然后F8步过,来到此位置。经多次调试,运行到BLX R7时会跳到另一段代码处。这段代码的用途是创建线程。

为什么要在JNI_Oload里创建线程呢?很有可能是ptrace检测。thread_create函数在init_array段里,这个函数创建了一个线程循环来读取/proc/pid/status文件下的TracePid的值,如果大于0说明程序正在被调试,退出程序。直接nop掉这行代码试试。arm的ANDEQ R0对应x86的nop,机器码为00 00

保存,重打包,签名。现在用第9节的方法再试一遍,看是否能在libcrackme.so中的securityCheck方法中断下来。先下断点,再在app中输入密码,点击按钮,IDA成功停在断点处。

接下来如何破解?F8步过,运行到此处,查看R0寄存器,存的是输入的“555”。

如果要使输入任何都成功,则需要修改循环里的两个跳转语句。呃不知道为什么这里BNE的机器码不是D1,所以只能全都改为nop语句也是可以的。

卸载模拟器中旧的app:

1
adb uninstall com.yaotong.crackme

安装新的,运行。无论输入什么都会跳转到成功页面。

11. 编写Xposed模块

Xpose是一款特殊的安卓应用,诞生于著名的XDA论坛,它的原理是替换安卓系统/system/bin目录下的app_process来控制zygote进程,使得app_pross在启动时会加载XposedBridge.jar,从而实现对zygode进程以及其创建的虚拟机的劫持,最终对系统的某些功能实现接管。

优点:Xpose可以在我们不破坏apk自身的情况下实现对函数的hook,修改函数的参数和返回值,改变函数的结构并执行我们自己的代码,用好了Xposed可以对我们的逆向过程起到事半功倍的作用。

缺点:本身不能对so中的函数进行修改,需要结合其他框架。

在模拟器上安装Xposed框架 https://blog.csdn.net/weixin_48140105/article/details/118359568

编写一个Xposed模块,也就是开发一个安卓app。和普通程序本质上是一样的,不一样的点在于:

  • 让EdXposed知道我们安装的这个程序是个Xposed模块。
  • 模块里要包含有Xposed的API的jar包,以实现下一步的hook操作。
  • 这个模块里面要有对目标程序进行hook操作的方法。
  • 要让手机上的Xposed框架知道,我们编写的Xposed模块中,哪一个方法是实现hook操作的,也就是hook类的入口。

先在AS中创建一个Empty Activity项目,在界面创建一个按钮,实现某种功能。

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
package com.example.xposemk;

import androidx.appcompat.app.AppCompatActivity;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.Toast;

public class MainActivity extends AppCompatActivity {
private Button mBtn;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
mBtn = (Button) findViewById(R.id.btn);
mBtn.setOnClickListener(new View.OnClickListener(){
@Override
public void onClick(View v){
Toast.makeText(MainActivity.this, message(), Toast.LENGTH_SHORT).show();
}
});
}
public String message() {
return "红红火火恍恍惚惚";
}
}

连接好模拟器,安装app运行。

现在通过编写一个Xposed模块修改按钮被点击后显示的弹框信息。

下载XposedBridgeAPI模块 https://github.com/924587628/XposedBridgeAPI ,将下载的API拖进libs文件夹。

右击jar包 -> Add As Library -> OK。

app -> src -> main -> AndroidManifest.xml ,在application标签中加入Xpose配置信息。

1
2
3
4
5
6
7
8
9
<meta-data
android:name="xposedmodule"
android:value="true" />
<meta-data
android:name="xposeddescription"
android:value="Easy example" />
<meta-data
android:name="xposedminversion"
android:value="89" />

app -> build.gradle,在dependencies段里修改。

1
2
3
4
dependencies {
...
compileOnly files('libs\\XposedBridgeAPI-89.jar')
}

MainActivity.java同目录里新建一个hook.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
package com.example.xposemk;
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 hook implements IXposedHookLoadPackage{
@Override
public void handleLoadPackage(XC_LoadPackage.LoadPackageParam loadPackageParam) throws Throwable{
if(loadPackageParam.packageName.equals("com.example.xposemk")){
XposedBridge.log("hooking...");
Class cls = loadPackageParam.classLoader.loadClass("com.example.xposemk.MainActivity");
XposedHelpers.findAndHookMethod(cls, "message", new XC_MethodHook(){
@Override
protected void beforeHookedMethod(MethodHookParam param) throws Throwable{
super.beforeHookedMethod(param);
}
@Override
protected void afterHookedMethod(MethodHookParam param) throws Throwable{
Object obj = param.getResult();
XposedBridge.log(obj.toString());
param.setResult("biubiubiu");
}
});
}
}
}

右击main,New -> Folder -> Assets Folder。main -> assets 右键 -> New -> file ,新建xposed_init文件,将内容编辑为包名+类名。

模拟器卸载原本的app,重新安装。打开Xposed Installer,在模块栏勾选对应进程。

重启模拟器,运行app,发现显示的弹框信息已被修改。

12. Xpose实战

hook一个函数需要知道以下三点:
(1)方法的包名+类名
(2)方法名
(3)方法的参数类型

用jeb打开apk,查看MainActivity反编译的源码。

发现有好多a,这里应该是做了简单混淆。那就一个个来看吧。

com.hfdcxy.android.by.a包中有一个类a,其中有一个方法a和一个属性a。a.a.a的作用是输出Log语句。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.hfdcxy.android.by.a;
import android.util.Log;
public class a {//类a
private static boolean a;//属性a

static {
a.a = false;//属性a一开始为false
}

public static void a(String arg1) {//方法a
if(a.a) {//如果属性a为true,输出Log
Log.i("Tiger_test", arg1);
}
}
}

com.hfdcxy.android.by.test包中有一个类a,其中有一个方法a。test.a.a的作用是MD5加密。

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
package com.hfdcxy.android.by.test;
import java.security.MessageDigest;
public class a {//类a
public static String a(String arg6) {//方法a
MessageDigest v2;
int v1 = 0;
try {
v2 = MessageDigest.getInstance("MD5");
}
catch(Exception v0) {
System.out.println(v0.toString());
v0.printStackTrace();
String v0_1 = "";
return v0_1;
}

char[] v3 = arg6.toCharArray();
byte[] v4 = new byte[v3.length];
int v0_2;
for(v0_2 = 0; v0_2 < v3.length; ++v0_2) {
v4[v0_2] = ((byte)v3[v0_2]);
}

byte[] v0_3 = v2.digest(v4);
StringBuffer v2_1 = new StringBuffer();
while(v1 < v0_3.length) {
int v3_1 = v0_3[v1] & 255;
if(v3_1 < 16) {
v2_1.append("0");
}

v2_1.append(Integer.toHexString(v3_1));
++v1;
}

return v2_1.toString();
}
}

因为解锁成功与否的过程没用到b.a,暂时先不分析它。

重新看这条关键代码,它的意思是v0等于当前手机的android_id经过MD5加密后与固定字符串hfdcxy1011进行拼接后再进行一次MD5加密得到的值截取前6位。

1
String v0 = a.a(a.a(Settings$System.getString(this.a.getContentResolver(), "android_id")) + "hfdcxy1011").substring(0, 6);

这里有三种方式可以把这个解锁码打印出来:

(1)我们知道test.a.a方法是最后一层加密,我们可以hook这个a方法把它的返回值打印出来,然后取其前6位为解锁码;

(2)因为整个apk只有一处对substring的调用,我们可以hook系统函数substring把函数返回值打印出来;

(3)通过分析知道a.a.a方法为log打印的方法,我们可以hook这个a方法的参数,把解锁码通过Log打印出来。

这里取第一种。

方法的包名+类名:com.hfdcxy.android.by.test.a
方法名:a
方法的参数类型:String

过滤下包名防止Xposed找不到包名对应的类报错,这里的包名是manifest标签下的包名com.ss.android.ugc.aweme

在第11节程序里面的hook类编写hook代码。就是模板,往里塞参数就行。

连上模拟器,安装app,Xposed Installer勾选相应程序模块,重启模拟器。运行解锁程序.apk,随意输入解锁码,点击解锁。回到AS搜索Log。

因为test.a.a共调用了两次,第一次MD5(android_id),第二次MD5(MD5(android_id)+hfdcxy1011),取最后一次的前6位才是解锁码116f58。

输入解锁码,进入充值页面。

点几下充值金币,再点开启宝箱,发现金币不足。回jeb继续分析代码。我们已经进入“解锁成功”的代码里去,看到里面调用了DrawActivity类。

进去看看。

也就是需要点击9999次才能开启宝箱,达咩!我们的思路是直接hook第一个按钮,修改test.b.a方法,使按一次就有10000金币。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package com.hfdcxy.android.by.test;
import android.content.SharedPreferences$Editor;
import android.content.SharedPreferences;
import android.widget.TextView;
public class b {//类b
public b() {
super();
}

public void a(SharedPreferences arg5, TextView arg6, int arg7) {//方法a,arg7是点击一次增加的金币数
SharedPreferences$Editor v0 = arg5.edit();
v0.putInt("coin", arg5.getInt("coin", 0) + arg7);
v0.commit();
arg6.setText(String.valueOf(arg5.getInt("coin", 0)));
}
}

方法的包名+类名:com.hfdcxy.android.by.test.b
方法名:a
方法的参数类型:SharedPreferences、TextView、int

编写hook代码:

同样操作走一次,开启宝箱。

我们可以尝试一下获取解锁码的第二第三种方法。

第二种:substring是一个Java系统内部的方法,百度搜一下它的构造方法。

1
2
3
4
5
public String substring(int beginIndex)



public String substring(int beginIndex, int endIndex)

方法的包名+类名:java.lang.String
方法名:substring
方法的参数类型:int、int

编写Xpose代码:

哈哈哈好像不止一个,但很容易知道哪个是解锁码,但是下面这样写是不行。

方法的包名+类名:com.hfdcxy.android.by.test.a.a
方法名:substring
方法的参数类型:int、int

第三种:通过a.a.a方法打印Log。因为v0是解锁码的前6位,刚好下一行就是Log输出v0的代码。

1
2
String v0 = a.a(a.a(Settings$System.getString(this.a.getContentResolver(), "android_id")) + "hfdcxy1011").substring(0, 6);
com.hfdcxy.android.by.a.a.a("解锁码" + v0);

方法的包名+类名:com.hfdcxy.android.by.a.a
方法名:a
方法的参数类型:String

13. Xpose实战2

目标:hook修改极品美女找茬游戏中的金币余额为999。

运行apk,每次评论或分享都可以获得50金币。

AK查看apk,manifest标签的包名为com.jimmy.beauty.pick,application标签添加android_debugable=”true”。

jeb中在smali代码中搜索“金币”,发现它在SOSActivity中。

进入SOSActivity反编译成Java代码,在某个case中发现CommentActivity。

继续进去CommentActivity看看。有个按钮事件,一个是“现在去给”评价,另一个是“以后再说”。修改的思路是将giveComment方法放到“以后再说”,再将giveComment方法里的前3行都去掉,因为那几行代码是构造支付链接。这些都是在AK中修改smali代码完成的。

进去setMoney方法,一个参数为Context类型,一个参数为int类型。

此时可以hook这个setMoney方法了。

连上模拟器,安装app,Xposed Installer勾选相应程序模块,重启模拟器。运行程序,开一局游戏,求助 -> 评论 -> 以后再说,就可以获得999金币!

14. adb注意事项

每次打开AS模拟器都会弹出如下图所示的错误:

是因为5037端口被占用了。在cmd输入netstat -ano|findstr "5037"查看被哪个进程占用。

1
2
C:\Users\dell>netstat -ano|findstr "5037"
TCP [::1]:3425 [::1]:5037 SYN_SENT 18760

输入taskkill -f -pid 18760杀死相应的进程。

15. Frida hook

Native层hook当属Frida。Frida超详细安装实战教程

15.1 Java层hook

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import frida  #导入frida模块
import sys #导入sys模块

jscode = """ #从此处开始定义用来Hook的javascript代码
Java.perform(function(){
var MainActivity = Java.use('com.example.testfrida.MainActivity'); //获得MainActivity类
MainActivity.testFrida.implementation = function(){ //Hook testFrida函数,用js自己实现
send('Statr! Hook!'); //发送信息,用于回调python中的函数
return 'Change String!' //劫持返回值,修改为我们想要返回的字符串
}
});
"""

def on_message(message,data): #js中执行send函数后要回调的函数
print(message)

process = frida.get_remote_device().attach('com.example.testfrida') #得到设备并劫持进程com.example.testfrida(该开始用get_usb_device函数用来获取设备,但是一直报错找不到设备,改用get_remote_device函数即可解决这个问题)
script = process.create_script(jscode) #创建js脚本
script.on('message',on_message) #加载回调函数,也就是js中执行send函数规定要执行的python函数
script.load() #加载脚本
sys.stdin.read()

具体可看Frida逆向与利用自动化,对frida hook Java层的API记录得非常详细了。

15.2 Native层hook

15.2.1 Native hook 返回值为int类型

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
import frida
import sys

jscode = """
Java.perform(function(){
//下面这一句代码是指定要Hook的so文件名和要Hook的函数名,函数名就是上面IDA导出表中显示的那个函数名
Interceptor.attach(Module.findExportByName("libfridaso.so","Java_com_example_fridaso_FridaSoDefine_FridaSo"),{
//onEnter: function(args)顾名思义就是进入该函数前要执行的代码,其中args是传入的参数,一般so层函数第一个参数都是JniEnv,第二个参数是jclass,从第三个参数开始才是我们java层传入的参数
onEnter: function(args) {
send("Hook start");
send("args[2]=" + args[2]); //打印我们java层第一个传入的参数
send("args[3]=" + args[3]); //打印我们java层传入的第二个参数
},
//onLeave: function(retval)是该函数执行结束要执行的代码,其中retval参数即是返回值
onLeave: function(retval){
send("return:"+retval); //打印返回值
retval.replace(0); //替换返回值为0
}
});
});
"""
def printMessage(message,data):
if message['type'] == 'send':
print('{0}'.format(message['payload']))
else:
print(message)

process = frida.get_remote_device().attach('com.example.fridaso')
script = process.create_script(jscode)
script.on('message',printMessage)
script.load()
sys.stdin.read()

15.1.3 Native hook 返回值为String类型

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
import frida
import sys

jscode = """
Java.perform(function(){
Interceptor.attach(Module.findExportByName("libfridaso.so","Java_com_example_fridasostring_fridaSoString_FridaSo"),{
onEnter: function(args) {
send("Hook start");
send("args[2]=" + args[2]);
},
onLeave: function(retval){
send("return:"+retval);
var env = Java.vm.getEnv(); //获取env对象,也就是native函数的第一个参数
var jstrings = env.newStringUtf("tamper"); //因为返回的是字符串指针,使用我们需要构造一个newStringUtf对象,用来代替这个指针
retval.replace(jstrings); //替换返回值
}
});
});
"""
def printMessage(message,data):
if message['type'] == 'send':
print('{0}'.format(message['payload']))
else:
print(message)

process = frida.get_remote_device().attach('com.example.fridasostring')
script = process.create_script(jscode)
script.on('message',printMessage)
script.load()
sys.stdin.read()

15.3 Native hook实战

攻防世界 ill-intentions