Android软件安全与逆向分析

这篇笔记是学习看雪“非虫”的《Android软件安全与逆向分析》一书中的内容所写。

1. Windows下环境搭建

1.1 安装JDK

https://www.oracle.com/java/technologies/downloads/

一定一定要安装Java8,我被其他版本搞死了。安装完成后,在CMD窗口输入以下三条命令,都有信息出来则表明安装成功:

1
2
3
java
javac
javap

1.2 安装Android SDK

https://dl.google.com/android/installer_r24.4.1-windows.exe

SDK要放在没有空格的路径中,否则在配置Android Studio时会报错。安装完成后,“以管理员身份运行”android-sdk目录下的SDK Manager.exe,为了方便后续操作,安卓系统选择Android 9(API 28),其余默认安装就好。安装完成后该目录下会多出来很多文件夹,将tools文件夹和platform-tools文件夹添加到系统的PATH环境变量中。

在CMD窗口输入以下两条命令,都有信息出来则表明安装成功:

1
2
emulator -version
adb version

如果输入第一条命令后显示如下错误,说明已经安装,提示是说命令格式不对。

1
2
C:\Users\v5le0n9>emulator -version
emulator: ERROR: No AVD specified. Use '@foo' or '-avd foo' to launch a virtual device named 'foo'

1.3 安装Android NDK

https://dl.google.com/android/repository/android-ndk-r25-windows.zip

傻瓜式操作,最好也不要放在有空格的路径中。

1.4 安装Android Studio

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

在官网下载将https修改为http即可下载,否则会一直打不开页面。

2. 分析Android程序

2.1 编写Android程序

使用Android Studio创建一个Empty Activity,命名为crackme。

首先设计显示界面。打开工程的activity_main.xml布局文件,添加用户名与注册码编辑框。

接着编写MainActivity.java,将注册的核心算法设计好,输入框与用户输入连接起来。在MainActivity类中添加一个checkSN()方法,代码如下:

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
private boolean checkSN(String username, String sn)
{
try
{
if((username == null) || (username.length() == 0))
return false;
if((sn == null) || (sn.length() != 16))
return false;
MessageDigest digest = MessageDigest.getInstance("MD5");//返回实现MD5摘要算法的 MessageDigest 对象
digest.reset();//将待摘要数据重置,即初始化
digest.update(username.getBytes());//将username转化为字节数组,作为待摘要数据
byte[] bytes = digest.digest();//执行摘要算法,结果存入字节数组
StringBuilder sb1 = new StringBuilder();
StringBuilder sb2 = new StringBuilder();
for (byte b : bytes)
{//从字节数组中依次取出1个字节
String hexstr = Integer.toHexString(b & 0xff);//将每个字节转化为十六进制字符串
if (hexstr.length() == 1)
{//如果得出的结果长度为1,则在前面加0,保证每个字节转字符串后加入序列时是两个字符
hexstr = "0" + hexstr;
}
sb1.append(hexstr);
}
for (int i=0; i<sb1.length(); i+=2)
sb2.append(sb1.charAt(i));//将摘要的奇位数作为sn
String userSN = sb2.toString();
if(!userSN.equalsIgnoreCase(sn))
return false;
}
catch(NoSuchAlgorithmException e)
{
e.printStackTrace();
return false;
}
return true;
}

MessageDigest 类为应用程序提供信息摘要算法的功能,如 MD5 或 SHA 算法。信息摘要是安全的单向哈希函数,它接收任意大小的数据,并输出固定长度的哈希值。MessageDigest 对象开始被初始化,该对象通过使用 update() 方法处理数据。任何时候都可以调用 reset() 方法重置摘要。一旦所有需要更新的数据都已经被更新了,应该调用 digest() 方法之一完成哈希计算。对于给定数量的更新数据,digest() 方法只能被调用一次。在调用 digest() 之后,MessageDigest 对象被重新设置成其初始状态。

在 StringBuilder 上的主要操作是 append() 和 insert() 方法。每个方法都能有效地将给定的数据转换成字符串,然后将该字符串的字符添加或插入到字符串生成器中。由于 StringBuilder 相较于 StringBuffer 有速度优势,所以多数情况下建议使用 StringBuilder 类。然而在应用程序要求线程安全的情况下,则必须使用 StringBuffer 类。它们原理一样。

charAt(int index)方法是一个能够用来检索特定索引下的字符的String实例的方法。

OnCreate()方法中加入注册按钮点击事件的监听器,如果用户名与注册码匹配就弹出注册成功的提示,不匹配则提示无效的用户名或注册码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
setTitle(R.string.unregister);
EditText edit_username = (EditText) findViewById(R.id.editTextTextPersonName);
EditText edit_sn = (EditText) findViewById(R.id.editTextTextPersonName2);
Button btn_register = (Button) findViewById(R.id.button);
btn_register.setOnClickListener(new View.OnClickListener()
{
public void onClick(View v)
{
if(!checkSN(edit_username.getText().toString().trim(), edit_sn.getText().toString().trim()))
Toast.makeText(MainActivity.this, R.string.unsuccessed, Toast.LENGTH_SHORT).show();
else
{
Toast.makeText(MainActivity.this, R.string.successed, Toast.LENGTH_SHORT).show();
btn_register.setEnabled(false);
setTitle(R.string.registered);
}
}
});
}

此时R.string.xxxx出错,这是因为我们还没在strings.xml中定义这些字符串。

1
2
3
4
<string name="unregister">程序未注册</string>
<string name="registered">程序已注册</string>
<string name="successed">注册成功!</string>
<string name="unsuccessed">注册失败!</string>

设置好后,代码就不报错了。使用Genymotion插件启动虚拟设备,点击绿色三角符号将项目打包成APK发送至虚拟设备并在虚拟设备上运行。

别忘了弄布局,否则下图就是反面教材。

我们刚才在strings.xml中定义的字符串可以在设计界面时就设置好。

可以用hint属性(灰色)来提示用户输入,如果用text属性(黑色)会直接在框中输入,用户如果需要输入则要删掉框中文本再输入,text属性如果使用一般是默认值。

效果如下图,有内味了。

2.2 破解Android程序

破解Android程序的第一步就是要将APK文件反编译,生成Smali格式的反汇编代码,阅读Smali文件的代码来理解程序的运行机制,或更进一步反编译成Java源码,更好理解程序流程。

将APK文件反汇编成Smali格式需要用到Apktool工具,官网也很明确地给出了用法,两个命令:

1
2
反汇编APK文件:apktool d[ecode] [OPTS] <file.apk> [<dir>]
编译APK文件:apktool b[uild] [OPTS] [<app_path>] [<out_file>]

也可以用Android Killer图形化工具(下称AK),它集成了apktool工具和APK签名工具,可以查看和修改Smali代码,将APK文件安装到虚拟设备等,所以还挺方便的。

点击Android Studio(下称AS)中的小锤子图标(Make Project)将上面代码打包成APK文件放到本地,路径为D:\Java\Android\crackme\app\build\intermediates\apk\debug\app-debug.apk,也可能在D:\Java\Android\crackme\app\build\outputs\apk\debug\app-debug.apk,多找找。将该APK文件反汇编成Smali代码到当前目录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
PS D:\Java\Android\crackme\app\build\intermediates\apk\debug> apktool d app-debug.apk
I: Using Apktool 2.6.1 on app-debug.apk
I: Loading resource table...
I: Decoding AndroidManifest.xml with resources...
I: Loading resource table from file: C:\Users\dell\AppData\Local\apktool\framework\1.apk
I: Regular manifest package...
I: Decoding file-resources...
I: Decoding values */* XMLs...
I: Baksmaling classes.dex...
I: Baksmaling classes3.dex...
I: Baksmaling classes2.dex...
I: Copying assets and libs...
I: Copying unknown files...
I: Copying original files...
PS D:\Java\Android\crackme\app\build\intermediates\apk\debug>

如何寻找突破口是分析一个程序的关键。对于一般的Android来说,错误提示信息通常是指引关键代码的风向标。在错误提示附近一般是程序的核心验证代码。错误提示是Android程序中的字符串资源,开发Android程序时,这些字符串可能硬编码到源码中,也可能引用自res\values\strings.xml。APK在打包时,strings.xml中的字符串被加密存储为resources.arsc文件保存到APK程序包中,APK被成功反编译后这个文件也被解密出来了。

如果是硬编码,字符串以Unicode编码的方式写在了Smali代码中;如果是strings.xml中,字符串就是我们看到的文本模样。

开发Android程序时,strings.xml文件中的所有字符串资源都在R.java文件的String类被标识,每个字符串都有唯一的int类型索引值,使用Apktool反编译APK文件后,所有的索引值保存在strings.xml文件同目录下的public.xml文件中。

unsuccessed的id值为0x7f0e006d,在smali目录中搜索含有内容为0x7f0e006d的文件,最后发现只有MainActivity$1.smali文件一处调用,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
# virtual methods
.method public onClick(Landroid/view/View;)V
.locals 3
.param p1, "v" # Landroid/view/View;

.line 29
iget-object v0, p0, Lcom/example/crackme/MainActivity$1;->this$0:Lcom/example/crackme/MainActivity;

iget-object v1, p0, Lcom/example/crackme/MainActivity$1;->val$edit_username:Landroid/widget/EditText;
# 取用户名存到v1
invoke-virtual {v1}, Landroid/widget/EditText;->getText()Landroid/text/Editable;

move-result-object v1

invoke-virtual {v1}, Ljava/lang/Object;->toString()Ljava/lang/String;

move-result-object v1

invoke-virtual {v1}, Ljava/lang/String;->trim()Ljava/lang/String;

move-result-object v1

iget-object v2, p0, Lcom/example/crackme/MainActivity$1;->val$edit_sn:Landroid/widget/EditText;
# 取注册码存到v2
invoke-virtual {v2}, Landroid/widget/EditText;->getText()Landroid/text/Editable;

move-result-object v2

invoke-virtual {v2}, Ljava/lang/Object;->toString()Ljava/lang/String;

move-result-object v2

invoke-virtual {v2}, Ljava/lang/String;->trim()Ljava/lang/String;

move-result-object v2

invoke-static {v0, v1, v2}, Lcom/example/crackme/MainActivity;->access$000(Lcom/example/crackme/MainActivity;Ljava/lang/String;Ljava/lang/String;)Z
# 将v0、v1、v2放进MainActivity中的access$000中进行运算,返回值为Boolean类型
move-result v0
# 将结果放到v0
const/4 v1, 0x0

if-nez v0, :cond_0
# 如果v0不等于0,则跳到:cond_0。否则继续往下执行。
# 继续往下执行就会执行到0x7f0e006d的地方,也就是注册失败。所以一定要跳到:cond_0
# 将if-nez修改为if-eqz
.line 30
iget-object v0, p0, Lcom/example/crackme/MainActivity$1;->this$0:Lcom/example/crackme/MainActivity;

const v2, 0x7f0e006d # unsuccessed

invoke-static {v0, v2, v1}, Landroid/widget/Toast;->makeText(Landroid/content/Context;II)Landroid/widget/Toast;

move-result-object v0

invoke-virtual {v0}, Landroid/widget/Toast;->show()V

goto :goto_0

.line 33
:cond_0
iget-object v0, p0, Lcom/example/crackme/MainActivity$1;->this$0:Lcom/example/crackme/MainActivity;

const v2, 0x7f0e006b # successed

invoke-static {v0, v2, v1}, Landroid/widget/Toast;->makeText(Landroid/content/Context;II)Landroid/widget/Toast;

move-result-object v0

invoke-virtual {v0}, Landroid/widget/Toast;->show()V

.line 34
iget-object v0, p0, Lcom/example/crackme/MainActivity$1;->val$btn_register:Landroid/widget/Button;

invoke-virtual {v0, v1}, Landroid/widget/Button;->setEnabled(Z)V

.line 35
iget-object v0, p0, Lcom/example/crackme/MainActivity$1;->this$0:Lcom/example/crackme/MainActivity;

const v1, 0x7f0e0068 # registered

invoke-virtual {v0, v1}, Lcom/example/crackme/MainActivity;->setTitle(I)V

.line 37
:goto_0
return-void
.end method

修改完后保存,使用Apktool编译。

1
apktool b app-debug

天啊编译完还要签名,还要配置签名工具。答应我,用AK就好,点击“编译”它就会自动编译并签名,很方便。

3. 进入Android Dalvik虚拟机

3.1 Dalvik虚拟机与Java虚拟机的区别

Dalvik虚拟机与传统的Java虚拟机有着许多不同点,两者并不兼容,它们显著的不同点主要表现在以下几个方面:

  • Java虚拟机运行的是Java字节码,Dalvik虚拟机运行的是Dalvik字节码。

    传统的Java程序经过编译,生成Java字节码保存在class文件中,Java虚拟机通过解码class文件中的内容来运行程序。而Dalvik虚拟机运行的是Dalvik字节码,所有的Dalvik字节码由Java字节码转换而来,并被打包到一个DEX(Dalvik Executable)可执行文件中,Dalvik虚拟机通过解释DEX文件来执行这些字节码。

  • Dalvik可执行文件体积更小。

    Android SDK中有一个叫dx(现名为d8)的工具负责将Java字节码转换为Dalvik字节码。dx工具对Java常量池的压缩,使得相同的字符串、常量在DEX文件中只出现一次,从而减少了文件的体积。

  • Java虚拟机与Dalvik虚拟机架构不同。

    Java虚拟机基于栈架构。程序在运行时虚拟机需要频繁地从栈上读取或写入数据,这个过程需要更多的指令分派与内存访问次数。

    Dalvik虚拟机基于寄存器架构。数据的访问通过寄存器间直接传递。

编写简单的Java代码来对比Java字节码与Dalvik字节码的区别:

1
2
3
4
5
6
7
8
9
10
11
12
13
//Hello.java
public class Hello
{
public int foo(int a,int b)
{
return (a + b) * (a - b);
}
public static void main(String[] args)
{
Hello hello = new Hello();
System.out.println(hello.foo(5, 3));
}
}

编译成class文件:

1
javac Hello.java

编译Hello.class生成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
31
32
33
34
PS C:\Users\dell\Desktop> javap -c Hello.class
Compiled from "Hello.java"
public class Hello {
public Hello();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return

public int foo(int, int);
Code:
0: iload_1
1: iload_2
2: iadd
3: iload_1
4: iload_2
5: isub
6: imul
7: ireturn

public static void main(java.lang.String[]);
Code:
0: new #2 // class Hello
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
11: aload_1
12: iconst_5
13: iconst_3
14: invokevirtual #5 // Method foo:(II)I
17: invokevirtual #6 // Method java/io/PrintStream.println:(I)V
20: return
}

将class文件编译成DEX文件:

1
java -jar dx.jar --dex --output=Hello.dex Hello.class

运行jar包命令:java -jar xxx.jar

使用dexdump.exeHello.dex文件编译成Dalvik字节码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
PS D:\Java\Android\sdk\build-tools\30.0.3> .\dexdump.exe -d C:\Users\Dell\Desktop\Hello.dex
Processing 'C:\Users\Dell\Desktop\Hello.dex'...
Opened 'C:\Users\Dell\Desktop\Hello.dex', DEX version '035'
Class #0 -
Class descriptor : 'LHello;'
Access flags : 0x0001 (PUBLIC)
Superclass : 'Ljava/lang/Object;'
Interfaces -
Static fields -
Instance fields -
Direct methods -
#0 : (in LHello;)
name : '<init>'
type : '()V'
access : 0x10001 (PUBLIC CONSTRUCTOR)
code -
registers : 1
ins : 1
outs : 1
insns size : 4 16-bit code units
00014c: |[00014c] Hello.<init>:()V
00015c: 7010 0400 0000 |0000: invoke-direct {v0}, Ljava/lang/Object;.<init>:()V // method@0004
000162: 0e00 |0003: return-void
catches : (none)
positions :
0x0000 line=1
locals :
0x0000 - 0x0004 reg=0 this LHello;

#1 : (in LHello;)
name : 'main'
type : '([Ljava/lang/String;)V'
access : 0x0009 (PUBLIC STATIC)
code -
registers : 5
ins : 1
outs : 3
insns size : 17 16-bit code units
000164: |[000164] Hello.main:([Ljava/lang/String;)V
000174: 2200 0100 |0000: new-instance v0, LHello; // type@0001
000178: 7010 0000 0000 |0002: invoke-direct {v0}, LHello;.<init>:()V // method@0000
00017e: 6201 0000 |0005: sget-object v1, Ljava/lang/System;.out:Ljava/io/PrintStream; // field@0000
000182: 1252 |0007: const/4 v2, #int 5 // #5
000184: 1233 |0008: const/4 v3, #int 3 // #3
000186: 6e30 0100 2003 |0009: invoke-virtual {v0, v2, v3}, LHello;.foo:(II)I // method@0001
00018c: 0a00 |000c: move-result v0
00018e: 6e20 0300 0100 |000d: invoke-virtual {v1, v0}, Ljava/io/PrintStream;.println:(I)V // method@0003
000194: 0e00 |0010: return-void
catches : (none)
positions :
0x0000 line=9
0x0005 line=10
0x0010 line=11
locals :
0x0000 - 0x0011 reg=4 (null) [Ljava/lang/String;

Virtual methods -
#0 : (in LHello;)
name : 'foo'
type : '(II)I'
access : 0x0001 (PUBLIC)
code -
registers : 5
ins : 3
outs : 0
insns size : 6 16-bit code units
000198: |[000198] Hello.foo:(II)I
0001a8: 9000 0304 |0000: add-int v0, v3, v4
0001ac: 9101 0304 |0002: sub-int v1, v3, v4
0001b0: b210 |0004: mul-int/2addr v0, v1
0001b2: 0f00 |0005: return v0
catches : (none)
positions :
0x0000 line=5
locals :
0x0000 - 0x0006 reg=2 this LHello;
0x0000 - 0x0006 reg=3 (null) I
0x0000 - 0x0006 reg=4 (null) I

source_file_idx : 1 (Hello.java)

可以看到,它们用各自的字节码描述foo()函数,Java需要8条指令,而Dalivik只需4条指令。

3.2 Android系统架构

Android系统架构采用分层思想,这样的好处是拥有减少各层之间的依赖性、便于独立分发、容易收敛问题和错误等优点。Android系统由Linux内核、函数库、Android运行时、应用程序框架、应用程序组成。Dalvik虚拟机属于Android运行时环境,它与一些核心库共同承担Android应用程序的运行工作。

Android系统启动加载完内核后,第一个执行的是init进程,init进程首先要做的是设备的初始化工作,然后读取inic.rc文件并启动系统中的重要外部程序Zygote。Zygote进程是Android所有进程的孵化器进程,它启动后会首先初始化Dalvik虚拟机,然后启动system_server并进入Zygote模式,通过socket等候命令。当执行一个Android应用程序时,system_server进程通过socket方式发送命令给Zygote,Zygote收到命令后通过fork自身创建一个Dalvik虚拟机的实例来执行应用程序的入口函数,这样一个程序就启动完成了。

Zygote提供了三种创建进程的方法:

  • fork(),创建一个Zygote进程;
  • forkAndSpecialize(),创建一个非Zygote进程;
  • forSystemServer(),创建一个系统服务进程。

当进程fork成功后,执行的工作就交给了Dalvik虚拟机。Dalvik虚拟机首先通过loadClassFromDex()函数完成类的装载工作,每个类被成功解析后都会拥有一个ClassObject类型的数据结构存储在运行时环境中,虚拟机使用gDvm.loadedClasses全局哈希表来存储与查询所有装载进来的类,字节码验证器使用dvmVerifyCodeFlow()函数对装入的代码进行校验,接着虚拟机调用FindClass()函数查找并装载main方法类,随后调用dvmInterpret()函数初始化解释器并执行字节码流。

3.3 DEX文件反汇编

DEX文件的反汇编工具我们用BakSmali,BakSmali提供反汇编功能的同时,还支持使用Smali工具打包反汇编代码重新生成DEX文件,这个功能被广泛应用于APK文件的修改、补丁、破解等场合。(如果是class文件转换为DEX文件可用dx.jarsmali.jar工具)

反汇编DEX文件,在baksmaliout目录下生成Hello.smali文件:

1
java -jar baksmali.jar d -o baksmaliout Hello.dex
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
.class public LHello;
.super Ljava/lang/Object;
.source "Hello.java"


# direct methods
.method public constructor <init>()V
.registers 1

.prologue
.line 1
invoke-direct {p0}, Ljava/lang/Object;-><init>()V

return-void
.end method

.method public static main([Ljava/lang/String;)V
.registers 5

.prologue
.line 9
new-instance v0, LHello;

invoke-direct {v0}, LHello;-><init>()V

.line 10
sget-object v1, Ljava/lang/System;->out:Ljava/io/PrintStream;

const/4 v2, 0x5

const/4 v3, 0x3

invoke-virtual {v0, v2, v3}, LHello;->foo(II)I

move-result v0

invoke-virtual {v1, v0}, Ljava/io/PrintStream;->println(I)V

.line 11
return-void
.end method


# virtual methods
.method public foo(II)I
.registers 5

.prologue
.line 5
add-int v0, p1, p2

sub-int v1, p1, p2

mul-int/2addr v0, v1

return v0
.end method

有关Smali与Dalvik的语法知识可以看我之前写的一篇笔记初识APK、Dalvik字节码以及Smali,虽然不全但应该够用。

4. Android可执行文件

DEX文件是由Java代码编译得到的Dalvik虚拟机能直接执行的文件。分析Android程序大多数时候是在和DEX文件打交道。

4.1 Android程序的生成步骤

  1. 打包资源文件,生成R.java文件;
  2. 处理aidl文件,生成相应的Java文件;
  3. 编译工程源码,生成相应的class文件;
  4. 转换所有的class文件,生成classes.dex文件;
  5. 打包生成APK文件;
  6. 对APK文件进行签名;
  7. 对签名后的APK文件进行对齐处理。

4.2 DEX文件格式

DEX文件使用到的数据类型:

类型 含义
u1 等同于uint8_t,1字节的无符号数
u2 等同于uint16_t,2字节的无符号数
u3 等同于uint32_t,4字节的无符号数
u4 等同于uint64_t,8字节的无符号数
sleb128 有符号LEB128,可变长度1~5字节
uleb128 无符号LEB128,可变长度1~5字节
uleb128p1 无符号LEB128值加1,可变长度1~5字节

sleb128、uleb128、uleb128p1是DEX文件中特有的LEB128数据类型。其中每个LEB128由1~5个字节组成,所有的字节组合在一起表示一个32位的数据。

每个字节只有7位有效位,如果第1个字节的最高位为1,标识LEB128需要用到第2个字节,如果第2个字节的最高位为1,表示会用到第3个字节,以此类推,直到最后的字节最高位为0。LEB128最多只会使用到5个字节,如果第5个字节的最高位仍为1,表示该DEX文件无效,Dalvik虚拟机在验证DEX文件时会失败返回。

DEX文件是由多个结构体组合而成。一个DEX文件由9部分组成,分别为dex header、string_ids、type_ids、proto_ids、field_ids、method_ids、class_def、data、link_data。

4.2.1 dex header

dex header指定了DEX文件的一些属性,并记录了其它8部分数据结构在DEX文件中的物理偏移与大小。

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
struct DexHeader 
{
u1 magic[8];//魔数,标识DEX文件
u4 checksum; //adler32校验
u1 signature[kSHA1DigestLen];//SHA-1哈希值
u4 file_size; //DEX文件大小
u4 header_size; //DexHeader大小
u4 endian_tag; //字节序标记
u4 link_size; //链接段大小
u4 link_off; //链接段偏移
u4 map_off;//DexMapList的文件偏移
u4 string_ids_size; //DexStringId的个数
u4 string_ids_off; //DexStringId的文件偏移
u4 type_ids_size; //DexTypeId的个数
u4 type_ids_off; //DexTypeId的文件偏移
u4 proto_ids_size; //DexProtoId的个数
u4 proto_ids_off; //DexProtoId的文件偏移
u4 field_ids_size;//DexFieldId的个数
u4 field_ids_off;//DexFieldId的文件偏移
u4 method_ids_size; //DexMethodId的个数
u4 method_ids_off; //DexMethodId的文件偏移
u4 class_defs_size; //DexClassDef的个数
u4 class_defs_off; //DexClassDef的文件偏移
u4 data_size;//数据段的大小
u4 data_off; //数据段的文件偏移
};
  • magic:表示了一个有效的DEX文件,目前它的值固定为64 65 78 0a 30 33 35 00。
  • checksum:DEX文件的校验和,通过它来判断DEX文件是否被损坏或篡改。
  • signature:识别最佳化之前的DEX文件。
  • header_size:记录了DexHeader结构本身占用的字节数,为0x70。
  • endian_tag:指定了dex运行环境的cpu字节序,预设值ENDIAN_CONSTANT等于0x12345678,默认采用Little-Endian字节序。
  • link_size和link_off:指定链接段的大小与文件偏移,大多数情况下它们的值都为0。
  • map_off:指定了DexMapList结构的文件偏移。

4.2.2 DexMapList

Dalvik虚拟机解析DEX文件的内容,最终将其映射成DexMapList数据结构。DexHeader结构的map_off字段指明了DexMapList结构在DEX文件中的偏移。

DexMapList数据结构声明如下:

1
2
3
4
5
struct DexMapList
{
u4 size;//DexMapItem的个数
DexMapItem list[1];//DexMapItem结构
};
  • size字段表示接下来有多少个DexMapItem结构,它的声明如下:
1
2
3
4
5
6
7
struct DexMapItem
{
u2 type;//kDexType开头的类型
u2 unused;//未使用,用于字节对齐
u4 size;//指定类型的个数
u4 offset;//指定类型数据的文件偏移
};
  • type字段为一个枚举常量,通过类型名称很容易判断它的具体类型。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
enum
{
kDexTypeHeaderItem = 0x0000,
kDexTypeStringIdItem = 0x0001,
kDexTypeTypeIdItem = 0x0002,
kDexTypeProtoIdItem = 0x0003,
kDexTypeFieldIdItem = 0x0004,
kDexTypeMethodIdItem = 0x0005,
kDexTypeClassDefItem = 0x0006,
kDexTypeMapList = 0x1000,
kDexTypeTypeList = 0x1001,
kDexTypeAnnotationSetRefItem = 0x1002,
kDexTypeAnnotationSetItem = 0x1003,
kDexTypeClassDataItem = 0x2000,
kDexTypeCodeItem = 0x2001,
kDexTypeStringDataItem = 0x2002,
kDexTypeDebugInfoItem = 0x2003,
kDexTypeAnnotationItem = 0x2004,
kDexTypeEncodeArrayItem = 0x2005,
kDexTypeAnnotationsDirectoryItem= 0x2006,
};
  • size字段制定了特定类型的个数,它们以特定的类型在DEX文件中连续存放。
  • offset字段为该类型的文件起始偏移地址。

Hello.dex为例,在dex header中找到DexMapList数据结构的物理偏移地址为0x290。

读取0x290处的一个双字值为0x0d,表明紧跟着13个DexMapItem结构。使用010 Editor的DEX模板分析DEX文件一目了然。

4.2.3 string_ids

1
2
3
4
struct DexStringId
{
u4 stringDataOff;//字符串数据偏移
};

DexStringId结构只有一个stringDataOff字段,直接指向字符串数据。

这里的字符串数据并非普通的ASCII字符串,它是由MUTF-8编码来表示的,不同于UTF-8。MUTF-8字符串的头部存放的是由uleb128编码的字符的个数,后面才是真正的字符串,最后以空字符0表示字符串结尾。

4.2.4 type_ids

1
2
3
4
struct DexTypeId
{
u4 descriptorIdx;//指向DexStringId列表的索引
};

descriptorIdx为指向DexStringId列表的索引,它对应的字符串代表了具体类的类型。

4.2.5 proto_ids

1
2
3
4
5
6
struct DexProtoId
{
u4 shortyIdx;//指向DexStringId列表的索引
u4 returnTypeIdx;//指向DexTypeId列表的索引
u4 parametersOff;//指向DexTypeList的偏移
};

DexProtoId是一个方法声明结构体,shortyIdx为方法声明字符串,returnTypeIdx为方法返回类型字符串,parametersOff指向一个DexTypeList结构体,存放了方法的参数列表,DexTypeList声明如下:

1
2
3
4
5
struct DexTypeList
{
u4 size;//DexTypeItem的个数
DexTypeItem list[1];//DexTypeItem结构
};

DexTypeItem声明如下:

1
2
3
4
struct DexTypeItem
{
u2 typeIdx;//指向DexTypeId列表的索引
};

DexTypeItem中的typeIdx最终也是指向一个字符串。

4.2.6 field_ids

1
2
3
4
5
6
struct DexFieldId
{
u2 classIdx;//类的类型,指向DexTypeId列表的索引
u2 typeIdx;//字段类型,指向DexTypeId列表的索引
u4 nameIdx;//字段名,指向DexStringId列表的索引
};

DexFieldId结构中的数据全部是索引值,指明了字段所在的类、字段的类型以及字段名。

4.2.7 method_ids

1
2
3
4
5
6
struct DexMethodId
{
u2 classIdx;//类的类型,指向DexTypeId列表的索引
u2 protoIdx;//声明类型,指向DexProtoId列表的索引
u4 nameIdx;//方法名,指向DexStringId列表的索引
};

DexMethodId结构的数据也都是索引,指明了方法所在的类、方法的声明以及方法名。

4.2.8 class_def

1
2
3
4
5
6
7
8
9
10
11
struct DexClassDef
{
u4 classIdx;//类的类型,执行DexTypeId列表的索引
u4 accessFlags;//访问标志
u4 superclassIdx;//父类类型,指向DexTypeId列表的索引
u4 interfacesOff;//接口,指向DexTypeList的偏移
u4 sourceFileIdx;//源文件名,指向DexStringId列表的索引
u4 annotationsOff;//注解,指向DexAnnotationsDirectoryItem结构
u4 classDataOff;//指向DexClassData结构的偏移
u4 staticValuesOff;//指向DexEncodedArray结构的偏移,记录类中的静态数据
};

4.2.9 data

1
2
3
4
5
6
7
8
struct DexClassData
{
DexClassDataHeader header;//指定字段与方法的个数
DexField * staticFields;//静态字段,DexField结构
DexField * instanceFields;//实例字段,DexField结构
DexMethod * directMethods;//直接方法,DexMethod结构
DexMethod * virtualMethods;//虚方法,DexMethod结构
};
  • DexClassDataHeader结构记录了当前类中字段与方法的数目,它的声明如下:
1
2
3
4
5
6
7
struct DexClassDataHeader
{
u4 staticFieldsSize;//静态字段个数
u4 instanceFieldsSize;//实例字段个数
u4 directMethodsSize;//直接方法个数
u4 virtualMethodsSize;//虚方法个数
};
  • DexField结构描述了字段的类型与访问标志,它的结构声明如下:
1
2
3
4
5
struct DexFiled
{
u4 fieldIdx;//指向DexFieldId的索引
u4 accessFlags;//访问标志
};

accessFlags字段与DexClassDef中的相应字段的类型相同。

  • DexMethod结构描述方法的原型、名称、访问标志以及代码数据块,它的声明如下:
1
2
3
4
5
6
struct DexMethod
{
u4 methodIdx;//指向DexMethodId的索引
u4 accessFlags;//访问标志
u4 codeOff;//指向DexCode结构的偏移
};
  • DexCode描述了方法更详细的信息以及方法中指令的内容,它的声明如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct DexCode
{
u2 registersSize;//使用的寄存器个数
u2 insSize;//参数个数
u2 outsSize;//调用其他方法时使用的寄存器个数
u2 triesSize;//Try/Catch个数
u4 debugInfoOff;//指向调试信息的偏移
u4 insnsSize;//指令集个数,以2字节为单位
u2 insns[1];//指令集
//2字节空间用于结构对齐
//try_item[triesSize] DexTry结构
//Try/Catch中handler的个数
//catc_handler_item[handlersSize] DexCatchHandler结构
};

4.3 ODEX文件格式

ODEX是OptimizedDEX的缩写,表示经过优化的DEX文件。ODEX文件仅存在于Android 4.4以前,ART模式下DEX则被优化为OAT文件。

ODEX有两种存在方式:一种是从APK程序中提取出来,与APK文件存放在同一目录且文件后缀为.odex的文件,这类ODEX文件多是Android ROM的系统程序;另一种是dalvik-cache缓存文件,这类ODEX文件仍然以.dex为后缀,存放在cache/dalvik-cache目录,保存的形式为“apk路径@apk名@classes.dex”,例如“system@app@Calculator.apk@classes.dex”表示安装在system/app目录下Calculator.apk程序的ODEX文件。

由于Android程序的APK文件为ZIP压缩包格式,Dalvik虚拟机每次加载它们时需要从APK中读取classes.dex文件,这会耗费很多CPU时间。而采用ODEX方式优化的DEX文件,已经包含了加载DEX必须的依赖库文件列表,Dalvik虚拟机只需检测并加载所需的依赖库即可执行相应的DEX文件,这大大缩短了读取DEX文件所需的时间,而对于部分Android系统的ROM,由于将系统App全部转换成外置的ODEX文件与APK文件放在同一目录,这样系统在启动加载这些程序时会节省更多的时间,启动速度自然也会更快。

4.3.1 生成ODEX文件

下载dexopt-wrapper(提取码:w9nc)工具,将dexopt-wrapper程序push到Android设备(Android 4.4可行,Android 9出错)上并赋予执行权限,执行以下命令:

1
2
adb push dexopt-wrapper /data/local
adb shell chmod 777 /data/local/dexopt-wrapper

Hello.dex文件改名为classes.dex并打包成ZIP文件,将ZIP文件push到与dexopt-wrapper工具同目录下。

1
adb push classes.zip /data/local

调用dexopt-wrapper来生成ODEX文件,进入/data/local运行dexopt-wrapper工具:

1
2
3
4
5
6
root@vbox86p:/data/local # ./dexopt-wrapper classes.zip Hello.odex
--- BEGIN 'classes.zip' (bootstrap=0) ---
--- waiting for verify+opt, pid=2191
--- would reduce privs here
--- END 'classes.zip' (success) ---
root@vbox86p:/data/local #

将ODEX文件pull出来以备后续分析:

1
adb pull /data/local/Hello.odex

4.3.2 ODEX文件结构

ODEX文件的结构可以理解为DEX文件的一个父集(超集),ODEX文件在DEX文件头部添加了一些数据,在DEX文件尾部添加了DEX文件的依赖库以及一些辅助数据。

odex文件头
dex文件
依赖库
辅助数据

4.3.2.1 odex文件头

1
2
3
4
5
6
7
8
9
10
11
12
struct DexOptHeader
{
u1 magic[8];//魔数,用于标识ODEX文件
u4 dexOffset;//dex文件头偏移
u4 dexLength;//dex文件总长度
u4 depsOffset;//odex依赖库列表偏移
u4 depsLength;//依赖库列表总长度
u4 optOffset;//辅助数据偏移
u4 optLength;//辅助数据总长度
u4 flags;//标志
u4 checksum;//依赖库与辅助数据的校验和
};
  • magic:标识一个有效的ODEX文件,值为64 65 79 0A 30 33 36 00。
  • dexOffset:dex文件头的偏移,总为0x28,也就是说odex文件头大小为0x28。
  • flags:DexoptFlags中的常量,标识了Dalvik虚拟机加载ODEX时的优化与验证选项。
  • checksum:ODEX文件的校验和,标识了ODEX是否有效。

4.3.2.2 依赖库

1
2
3
4
5
6
7
8
9
10
11
12
13
struct Dependences
{
u4 modWhen;//时间戳
u4 crc;//校验
u4 DALVIK_VM_BUILD;//Dalvik虚拟机版本号
u4 numDeps;//依赖库个数
struct
{
u4 len;//name字符串的长度
u1 name[len];//依赖库的名称
kSHA1DigestLen signature;//SHA-1哈希值
}table[numDeps];
};
  • modWhen:记录优化前classes.dex的时间戳。
  • crc:优化前classes.dex的crc校验值。

4.3.2.3 辅助数据

依赖库紧接着为3个Chunk块,它们被Dalvik虚拟机加载到一个称为auxillary的段中。

4.3.2.3.1 ChunkDexClassLookup

数据通过writeChunk()函数写入,该函数中定义了1个header。

1
2
3
4
5
6
7
8
9
union
{
char raw[8];
struct
{
u4 type;
u4 size;
}ts;
}header;

这个header结构占用了8个字节,writeChunk()函数在写入具体的结构时会先填充这个结构。

  • 其中的type字段为1个以kDexChunk开头的枚举常量,它的值定义如下:
1
2
3
4
5
6
enum
{
kDexChunkClassLookup = 0x434c4b50,
kDexChunkRegisterMaps = 0x524d4150,
kDexChunkEnd = 0x41454e44,
};
  • size则为需要填充的数据的字节数。写入kDexChunkClassLookup结构时writeOptData()函数向writeChunk()函数传递了1个DexClassLookup结构的指针,它的结构声明如下:
1
2
3
4
5
6
7
8
9
10
11
struct DexClassLookup
{
int size;
int numEntries;
struct
{
u4 classDescriptorHash;
int classDescriptorOffset;
int classDefOffset;
}table[1];
};

Dalvik虚拟机通过DexClassLoopup结构来检索DEX文件中所有的类,其中size字段为本结构的字节数,numEntries字段为接下来的table结构的项数,通常值为2,而table结构用来描述了类的信息,classDescriptorHash字段和classDescriptorOffset字段为类的哈希值与类的描述,classDefOffset字段指向DexClassDef结构的指针。

根据上面的分析,可以总结出ChunkDexClassLookup的结构声明如下:

1
2
3
4
5
struct ChunkDexClassLookup
{
Header header;
DexClassLookup lookup;
};
4.3.2.3.2 ChunkRegisterMapPool
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct ChunkRegisterMapool
{
Header header;
struct
{
struct RegisterMapClassPool
{
u4 numClasses;
u4 classDataOffset[1];
}classpool;
struct RegisterMapMethodPool
{
u2 methodCount;
u4 methodData[1];
}
}lookup;
};
4.3.2.3.3 ChunkEnd
1
2
3
4
struct ChunkEnd
{
Header header;
};

4.4 Anroid程序另类破解方法

Android程序的代码都存储在DEX文件中,通过修改代码中的执行路径是否就可以达到破解的目的呢?那么如何定位程序的破解点呢?IDA Pro可以非常方便地找到程序破解点对应的文件偏移。

用到我们之前编写的crackme程序,将app-debug.apk解压,有3个DEX文件,可用AK查看我们编写的代码在哪个DEX文件中。我的是在classes3.dex中,将其载入IDA Pro。Search -> Text(快捷键Alt + T),查找失败字符串的id值,定位到相关的Smali字节码附近。

选中关键跳那一行,去到Hex View-1视图,高亮部分就是我们选中的关键跳指令的二进制代码,为39 00 0D 00。第1个字节39是if-nez指令的操作码,而if-eqz指令的操作码为38,所以将39改为38即可。选中39右键 -> Edit,修改为38,右键 -> Apply changes。在工具栏 Edit -> Patch program -> Apply patches to input file即可。

APK程序安装时会调用dexopt对DEX进行验证和优化,DEX文件中DexHeader头的checksum字段标识了DEX文件的合法性,被篡改过的DEX文件在验证时计算checksum会失败,导致程序安装失败,因此,我们需要重新计算并写入checksum值。下载DexFixer工具,将修改过的DEX文件载入即可。(Win 11不兼容此工具)

将修复后的DEX文件重新放回原处,删除app-debug目录中的META-INF文件夹,压缩成ZIP文件,改后缀名为.apk,使用AK的签名工具给APK文件进行签名,安装到Android设备,同样也可以成功注册。

5. 静态分析Android程序

5.1 AndroidManifest.xml

每个APK文件都包含有一个AndroidManifest.xml文件,它记录着软件的一些基本信息,包括软件的报名、运行的系统版本、用到的组件等。

一个Android程序由一个或多个Activity以及其它组件组成,每个Activity都是相同级别的,不同的Activity实现不同的功能。每个Activity都是Android程序的一个显示“页面”,主要负责数据的处理及展示工作。

每个Android程序有且只有一个主Activity(隐藏程序除外,它没有主Activity),它是程序启动的第一个Activity。app-debug.apkAndroidManifest.xml如下所示:

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
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
android:compileSdkVersion="31"
android:compileSdkVersionCodename="12"
package="com.example.crackme"
platformBuildVersionCode="31"
platformBuildVersionName="12">

<application
android:allowBackup="true"
android:appComponentFactory="androidx.core.app.CoreComponentFactory"
android:debuggable="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Crackme">

<activity
android:exported="true"
android:name="com.example.crackme.MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
</application>
</manifest>

5.1.1 manifest标签

所有的XML文件都必须包含manifest标签,这是文件的根节点。它必须要包含application标签,并且指明xmlns:androidpackage属性。

5.1.1.1 xmlns:android

这个属性定义了Andrid命名空间,必须设置成”http://schemas.android.com/apk/res/android “,不能手动修改。

5.1.1.2 package

这是一个完整的Java语言风格包名。包名由英文字母(大小写均可)、数字和下划线组成。每个独立的名字必须以字母开头。

构建APK的时候,构建系统使用这个属性来做两件事:

  • 生成R.java类时用这个名字作为命名空间(用于访问App的资源)。比如:package被设置成com.sample.teapot,那么生成的R类就是:com.sample.teapot.R
  • 用来生成在manifest文件中定义的类的完整类名。比如package被设置成com.sample.teapot,并且activity元素被声明成,完整的类名就是com.sample.teapot.MainActivity。

包名也代表着唯一的application ID,用来发布应用。但是,要注意的一点是:在APK构建过程的最后一步,package名会被build.gradle文件中的applicationId属性取代。如果这两个属性值一样,那么万事大吉,如果不一样,那就要小心了。

5.1.1.3 adnroid:versionCode

内部的版本号。用来表明版本的更新。这个数字不会显示给用户。显示给用户的是android:versionName。这个数字必须是整数。不能用16进制,也就是说不接受”0x1”这种参数。

5.1.1.4 android:versionName

显示给用户看的版本号。

5.1.2 uses-feature标签

uses-feature标签是manifest标签里的元素,Google Play利用这个标签的值从不符合应用需要的设备上将应用过滤。

它的作用是将App所依赖的硬件或者软件条件告诉别人,说明了App的哪些功能可以随设备的变化而变化。

使用的时候要注意,必须在单独的元素中指定每个功能,如果要多个功能,需要多个元素。比如要求设备同时具有蓝牙和相机功能:

1
2
<uses-feature android:name="android.hardware.bluetooth" />
<uses-feature android:name="android.hardware.camera" />

5.1.2.1 android:name

该属性以字符串形式指定了APP要用的硬件或软件功能。

5.1.2.2 android:required

这项属性如果值为true表示需要这项功能,否则应用无法工作,如果为false表示应用在必要时会使用该功能,但是如果没有此功能应用也能工作。

5.1.2.3 android:glEsVersion

指明应用需要的Opengl ES版本。高16位表示主版本号,低16位表示次版本号。例如,如果是要3.2的版本,就是0x00030002。如果定义多个glEsVersion,应用会自动启用最高的设置。

5.1.3 application标签

该标签也是manifest标签中的一个元素,描述了应用的配置。这是一个必备的元素,它包含了很多子元素来描述应用的组件,它的属性影响到所有的子组件。许多属性(例如icon、label、permission、process、taskAffinity和allowTaskReparenting)都可以设置成默认值。

5.1.3.1 android:allowBackup

表示是否允许App加入到备份还原的结构中。如果设置成false,那么应用就不会备份还原。默认值为true。

5.1.3.2 android:fullBackupContent

这个属性指向了一个XML文件,该文件中包含了在进行自动备份时的完全备份规则。这些规则定义了哪些文件需要备份。此属性是一个可选属性。默认情况下,自动备份包含了大部分App文件。

5.1.3.3 android:supportsRtl

声明App是否支持RTL(Right To Left)布局。如果设置成true,并且targetSdkVersion被设置成17或更高。很多RTL API会被集火,这样你的应用就可以显示RTL布局了。如果设置成false或者targetSdkVersion被设置成16或更低,RTL API就不起作用了。该属性的默认的值是false。

5.1.3.4 android:icon

App的图标,以及每个组件的默认图标。可以在组件中自定义图标。这个属性必须设置成一个引用,指向一个可绘制的资源,这个资源必须包含图片。系统不设置默认图标。

5.1.3.5 android:label

一个用户可读的标签,以及所有组件的默认标签。子组件可以用它们的label属性定义自己的标签,如果没有定义,那么就用这个标签。

标签必须设置成一个字符串资源的引用。这样它们就能和其他东西一样被定位,比如@string/app_name。当然,为了开发方便,你也可以定义一个原始字符串。

5.1.3.6 android:theme

该属性定义了应用使用的主题的,它是一个指向style资源的引用。各个activity也可以用自己的theme属性设置自己的主题。

5.1.3.7 android:name

Application子类的全名。包括前面的路径。例如com.sample.teapot.TeapotApplication。当应用启动时,这个类的实例被第一个创建。这个属性是可选的,大多数APP都不需要这个属性。在没有这个属性的时候,Android会启动一个Application类的实例。

5.1.4 activity标签

activity标签是application标签中的一个元素,声明一个实现应用可视化界面的Activity(Activity类子类)。这是application标签中必要的子标签。所有Activity都必须由清单文件中的activity标签表示。任何未在该处声明的Activity对系统都不可见,并且永远不会被执行。

5.1.4.1 android:name

Activity类的名称,是Activity类的子类。该属性值为完全限定类名称,例如com.sample.teapot.TeapotNativeActivity。为了方便起见,如果第一个字符是点(’.’),就需要加上manifest标签中的包名。应用一旦发布,不应更改该名称。该元素没有默认值,必须指定该名称。

5.1.4.2 android:label

Activity标签,可以被用户读取。该标签会在Activity激活时显示在屏幕上。如果未设置,用application中的label属性。对属性的设置要求和application中一样。

5.1.4.3 android:configChanges

列出 Activity 将自行处理的配置更改消息。在运行时发生配置更改时,默认情况下会关闭 Activity 然后将其重新启动,但使用该属性声明配置将阻止 Activity 重新启动。 Activity 反而会保持运行状态,并且系统会调用其 onConfigurationChanged()方法。

注:应避免使用该属性,并且只应在万不得已的情况下使用。

该属性可以设置的项很多,这里列出常用的项:

  • orientation
    屏幕方向发生了变化,比如用户旋转了设备

  • keyboardHidden
    键盘无障碍功能发生了变化,比如用户显示了硬件键盘

  • android:launchMode
    关于如何启动Activity的指令。一共有四种指令:
    “standard”
    “singleTop”
    “singleTask”
    “singleInstance”
    默认情况下是standard。这些模式被分为两大类:

    “standard”和”singleTop”是一类。该模式的Activity可以多次实例化。实例可属于任何任务,并且可以位于Activity堆栈中的任何位置。

    “singleTask”和”singleInstance”是一类。该模式只能启动任务,它们始终位于Activity堆栈的根位置。此外,设备一次只能保留一个Activity实例。

    设置成singleTask后,系统在新任务的根位置创建Activity并向其传送Intent。如果已经存在一个Activity实例,则系统会通过调用该实例的onNewIntent()方法向其传送Intent而不是创建一个新的Activity实例。

5.1.4.4 android:theme

设定主题格式,与application标签中的theme属性类似。

5.1.5 meta-data标签

activity标签中的元素,指定额外的数据项,该数据项是一个name-value对,提供给其父组件。这些数据会组成一个Bundle对象,可以由PackageItemInfo.metaData字段使用。虽然可以使用多个meta-data标签,但是不推荐这么使用。如果有多个数据项要指定,推荐做法是:将多个数据项合并成一个资源,然后使用一个meta-data标签包含进去。

该标签有三个属性:

  • android:name:数据项名称,这是一个唯一值。
  • android:resource:一个资源的引用。
  • android:value:数据项的值。

5.1.6 intent-filter标签

activity标签中的元素,指明这个activity可以以什么样的意图(intent)启动。

5.1.7 action标签

intent-filter标签中的元素,表示activity作为一个什么动作启动,android.intent.action.MAIN表示作为主activity启动。

5.1.8 category标签

intent-filter标签中的元素,这是action元素的额外类别信息,android.intent.category.LAUNCHER表示这个activity为当前应用程序优先级最高的Activity。

5.1.9 总结

上面是我自己总结的AndroidManifest.xml文件,下面是别人的更为详细的总结。

5.2 定位关键代码的6种方法

  • 信息反馈法

    运行目标程序,然后根据程序运行时给出的反馈信息作为突破口寻找关键代码。比如在2.2时破解程序就是通过“注册失败”信息来定位关键代码的。通常情况下,程序中用到的字符串会存储在strings.xml文件或硬编码到程序代码中。如果是前者,字符串在程序中会以id的形式访问,只需在反汇编代码中搜索字符串的id值即可找到调用代码处;如果是后者,在反汇编代码中直接搜索字符串即可。

  • 特征函数法

    这种定位代码的方法与信息反馈法类似。在信息反馈法中,无论程序给出什么样的反馈信息,终究是需要调用Android SDK中提供的相关API函数来完成的。比如弹出“注册失败”的提示信息就需要调用Toast.MakeText().Show()方法,在反汇编代码中直接搜索Toast应该很快就能定位到调用代码。

  • 顺序查看法

    顺序查看法是指从软件的启动代码开始,逐行向下分析,掌握软件的执行流程,这种分析方法在病毒分析时经常用到。

  • 代码注入法

    属于动态调试,它的原理是手动修改APK文件的反汇编代码,加入Log输出,配合LogCat查看程序执行到特定点时的状态数据。这种方法在解密程序数据时经常使用。

  • 栈跟踪法

    动态调试方法,它的原理是输出运行时的栈跟踪信息,然后查看栈上的函数调用序列来理解方法的执行流程。

  • Method Profiling

    Method Profiling(方法剖析)属于动态调试,主要用于热点分析和性能优化。该功能除了可以记录每个函数占用的CPU时间之外,还能够跟踪所有的函数调用关系,并提供比栈跟踪法更详细的函数调用序列报告。

5.3 使用IDA Pro静态分析Android程序

使用IDA Pro分析DEX文件与使用AK分析APK文件都是差不多的,看的都是Smali代码,而且IDA需要手动解压APK文件提取出DEX文件,修改完后还需打包签名,所以用AK不香吗?希望IDA以后能打我脸。

6. Android NDK程序

6.1 Android原生程序与Android NDK程序的区别

原生程序应该是Java编写的,Android NDK程序是通过JNI(Java Native Interface)提供的API函数将原生C/C++代码与Java代码进行数据交换,使得使用C/C++代码也能写出功能强大的程序,甚至将大部分的Java代码转移到C/C++代码中来。

静态分析Android NDK程序与分析传统的原生程序有些不同,传统的原生程序中只调用了原生API函数,使用IDA Pro分析它们时会被自动识别出来,因此分析的难度转移到了理解ARM指令集序列的含义上。而Android NDK程序使用了JNI接口函数,在分析它们时IDA Pro并不能识别它们,这使得分析工作变得比较艰难。

6.2 分析Android NDK程序

如果使用C++代码来调用JNI接口函数,JNIEnv被定义成了_JNIEnv结构体,该结构体的第一个字段就是一个JNINativeInterface结构体的指针。

如果C代码调用JNI接口函数,JNIEnv则直接被定义成JNINativeInterface结构体的指针。

因此可以将JNIEnv的首地址解释成JNINativeInterface的首地址来使用,通过首地址加上索引值就能找到具体的函数。

7. 动态调试Android程序

DDMS(Dalvik Debug Monitor Server,Dalvik调试监视器服务)提供了设备截屏、查看运行的线程信息、文件浏览、LogCat、Method Profiling、广播状态信息、模拟电话呼叫、接收SMS、虚拟地理坐标等功能。它的文件浏览、LogCat、Method Profiling是使用最多的功能。文件浏览可以查看需要分析的程序在安装目录下生成的文件,分析这些文件的内容可以对程序的设置及生成的数据有初步的了解;LogCat则可以输出软件运行时的调试信息;Method Profilng用于跟踪程序的执行流程。

在Android SDK的tools目录下有一个ddms.bat,它就是DDMS的启动文件。

7.1 使用LogCat查看调试信息

Android SDK提供了android.util.Log类来输出调试信息。该类提供了Log.v()Log.d()Log.i()Log.w()以及Log.e()等5个调试信息输出方法。

  • v表示输出VERBOSE类型的信息
  • d表示输出DEBUG类型的信息
  • i表示输出INFO类型的信息
  • w表示输出WARN类型的信息
  • e表示输出ERROR类型的信息

启动Android设备,就可在DDMS中查看该设备所有运行中的进程。

去AS中查看LogCat信息会更加详细。

7.2 定位关键代码

7.2.1 代码注入法

通常一个程序在发布时不会保留Log输出信息,要想在程序的特定位置输出信息还需手动进行代码注入,所谓代码注入是指首先反编译Android程序,然后在反汇编出的Smali文件中添加Log调用的代码,最后重新打包程序运行来查看输出结果。

同样分析app-debug.apk中的Smali代码,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
.method private checkSN(Ljava/lang/String;Ljava/lang/String;)Z
.locals 11
.param p1, "username" # Ljava/lang/String;
.param p2, "sn" # Ljava/lang/String;
.annotation system Ldalvik/annotation/MethodParameters;
accessFlags = {
0x0,
0x0
}
names = {
"username",
"sn"
}
.end annotation

.line 39
const/4 v0, 0x0

if-eqz p1, :cond_7#检查username是否为空

:try_start_0
invoke-virtual {p1}, Ljava/lang/String;->length()I#获取username长度

move-result v1

if-nez v1, :cond_0#检查username长度是否为0

goto/16 :goto_3

.line 41
:cond_0
if-eqz p2, :cond_6#检查sn是否为空

invoke-virtual {p2}, Ljava/lang/String;->length()I#获取sn长度

move-result v1

const/16 v2, 0x10#v2=16

if-eq v1, v2, :cond_1#判断sn长度是否为16

goto :goto_2

.line 43
:cond_1
const-string v1, "MD5"

invoke-static {v1}, Ljava/security/MessageDigest;->getInstance(Ljava/lang/String;)Ljava/security/MessageDigest;

move-result-object v1

.line 44
.local v1, "digest":Ljava/security/MessageDigest;
invoke-virtual {v1}, Ljava/security/MessageDigest;->reset()V

.line 45
invoke-virtual {p1}, Ljava/lang/String;->getBytes()[B#将username转换为字节数组

move-result-object v2

invoke-virtual {v1, v2}, Ljava/security/MessageDigest;->update([B)V

.line 46
invoke-virtual {v1}, Ljava/security/MessageDigest;->digest()[B#username进行MD5摘要运算

move-result-object v2

.line 47
.local v2, "bytes":[B
new-instance v3, Ljava/lang/StringBuilder;

invoke-direct {v3}, Ljava/lang/StringBuilder;-><init>()V

.line 48
.local v3, "sb1":Ljava/lang/StringBuilder;
new-instance v4, Ljava/lang/StringBuilder;

invoke-direct {v4}, Ljava/lang/StringBuilder;-><init>()V

.line 49
.local v4, "sb2":Ljava/lang/StringBuilder;
array-length v5, v2#v5取username进行MD5摘要结果的长度

const/4 v6, 0x0#v6=0

:goto_0
const/4 v7, 0x1#v7=1

if-ge v6, v5, :cond_3

aget-byte v8, v2, v6#从v2中取下标为6的字节存入v8

.line 51
.local v8, "b":B
and-int/lit16 v9, v8, 0xff#v8与0xff相与存入v9

invoke-static {v9}, Ljava/lang/Integer;->toHexString(I)Ljava/lang/String;

move-result-object v9

.line 52
.local v9, "hexstr":Ljava/lang/String;
invoke-virtual {v9}, Ljava/lang/String;->length()I#获取v9的长度存到v10

move-result v10

if-ne v10, v7, :cond_2#如果长度为1,继续往下执行

.line 54
new-instance v7, Ljava/lang/StringBuilder;

invoke-direct {v7}, Ljava/lang/StringBuilder;-><init>()V

const-string v10, "0"

invoke-virtual {v7, v10}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;

invoke-virtual {v7, v9}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;

invoke-virtual {v7}, Ljava/lang/StringBuilder;->toString()Ljava/lang/String;

move-result-object v7

move-object v9, v7

.line 56
:cond_2
invoke-virtual {v3, v9}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;#将v9的结果加入到v3

.line 49
nop

.end local v8 # "b":B
.end local v9 # "hexstr":Ljava/lang/String;
add-int/lit8 v6, v6, 0x1

goto :goto_0

.line 58
:cond_3
const/4 v5, 0x0#v5=0

.local v5, "i":I
:goto_1
invoke-virtual {v3}, Ljava/lang/StringBuilder;->length()I#获取v3的长度到v6

move-result v6

if-ge v5, v6, :cond_4

.line 59
invoke-virtual {v3, v5}, Ljava/lang/StringBuilder;->charAt(I)C#取v3下标为v5的字符到v6

move-result v6

invoke-virtual {v4, v6}, Ljava/lang/StringBuilder;->append(C)Ljava/lang/StringBuilder;#将v6加入到v4

.line 58
add-int/lit8 v5, v5, 0x2#v5自增2

goto :goto_1

.line 60
.end local v5 # "i":I
:cond_4
invoke-virtual {v4}, Ljava/lang/StringBuilder;->toString()Ljava/lang/String;

move-result-object v5#获取v4内容到v5

.line 61
.local v5, "userSN":Ljava/lang/String;
invoke-virtual {v5, p2}, Ljava/lang/String;->equalsIgnoreCase(Ljava/lang/String;)Z#判断v5与sn的内容是否相等,返回值存到v6

move-result v6
:try_end_0
.catch Ljava/security/NoSuchAlgorithmException; {:try_start_0 .. :try_end_0} :catch_0

if-nez v6, :cond_5

.line 62
return v0

.line 68
.end local v1 # "digest":Ljava/security/MessageDigest;
.end local v2 # "bytes":[B
.end local v3 # "sb1":Ljava/lang/StringBuilder;
.end local v4 # "sb2":Ljava/lang/StringBuilder;
.end local v5 # "userSN":Ljava/lang/String;
:cond_5
nop

.line 69
return v7

.line 42
:cond_6
:goto_2
return v0

.line 64
:catch_0
move-exception v1

.line 66
.local v1, "e":Ljava/security/NoSuchAlgorithmException;
invoke-virtual {v1}, Ljava/security/NoSuchAlgorithmException;->printStackTrace()V

.line 67
return v0

.line 40
.end local v1 # "e":Ljava/security/NoSuchAlgorithmException;
:cond_7
:goto_3
return v0
.end method

.line 61是关键,如果前面分析不出来,那我们用LogCat将v5打印出来,不就知道v5的值是多少了吗?我们尽量不用它设置区间范围的寄存器,所以将该方法的寄存器加1,.locals 11修改为.locals 12,原本程序只需要用v0~v10,那我们用v11就好。在.line 61里注入Log.v()输出v5的值,插入下面两条代码:

1
2
const-string v11, "realSN"
invoke-static {v11, v5}, Landroid/util/Log;->v(Ljava/lang/String;Ljava/lang/Sting;)I

.line 61处的代码为:

1
2
3
4
5
6
7
.line 61
.local v5, "userSN":Ljava/lang/String;
const-string v11, "realSN"
invoke-static {v11, v5}, Landroid/util/Log;->v(Ljava/lang/String;Ljava/lang/Sting;)I
invoke-virtual {v5, p2}, Ljava/lang/String;->equalsIgnoreCase(Ljava/lang/String;)Z

move-result v6

到达.line 61处需要输入16位注册码,点击注册。发现闪退。回到LogCat查看信息,发现它说在android.util.Log包中没找到v()方法。怎么可能呢?我觉得可能是编写这个程序时没有导入android.util.Log包,导致利用不了该包中的方法。

如果用的是AK,可以直接在需要添加Log代码处右键 -> 插入代码 -> Log,会在需要添加代码的地方插入如下代码:

1
2
3
const-string v0, "you message"

invoke-static {v0}, Lcom/android/killer/Log;->LogStr(Ljava/lang/String;)V

此时将插入的代码修改一下,寄存器的值改为v5即可,部分.line 61的内容如下:

1
2
3
4
5
6
7
8
.line 61
.local v5, "userSN":Ljava/lang/String;

invoke-static {v5}, Lcom/android/killer/Log;->LogStr(Ljava/lang/String;)V

invoke-virtual {v5, p2}, Ljava/lang/String;->equalsIgnoreCase(Ljava/lang/String;)Z

move-result v6

再次运行,输入16位注册码,点击注册。此时LogCat信息中显示正确的注册码。

可以在程序中验证该注册码是否正确。

7.2.2 栈跟踪法

栈跟踪法同样属于代码注入的范畴。它主要是手动向反汇编后的Smali文件中加入栈跟踪信息输出的代码。与注入Log输出的代码不同的是,栈跟踪法只需知道大概的代码注入点。而且注入代码后的反馈信息比Log注入要详细得多。比如在app-debug.apk中,输入错误注册码会弹出Toast(),我们想知道这个Toast()是何时被调用的。

采用5.2所讲的特征函数法,在反汇编代码中查找Toast,发现有2个Toast.show()方法。而且它们的上面是字符串的id值。通过查找发现0x7f0e006c是注册失败,0x7f0e006a是注册成功。

因为我们是去到了注册失败的那条路,所以在注册失败即.line 26中,Toast.show()方法后添加输出栈跟踪信息的代码即可。

1
2
3
4
new-instance v0, Ljava/lang/Exception;
const-string v1, "print trace"
invoke-direct {v0, v1}, Ljava/lang/Exception;-><init>(Ljava/lang/String;)V
invoke-virtual {v0}, Ljava/lang/Exception;->printStackTrace()V

在LogCat中输出警告信息:

栈跟踪信息记录了程序从启动到printStackTrace()被执行期间所有被调用过的方法。从下往上查看栈跟踪信息,找到第一条以com.example.crackme开头的信息,发现最开始调用的是OnClick()方法,然后是OnCreate()方法。如此一来,函数的执行流程就一清二楚了。

7.2.3 Method Profiling

Method Profiling相当于OD的trace功能,它的作用是在执行程序时记录下每个被调用的API名称。

在DDMS中选中需要关注的程序,点击工具栏中的Start Method Profiling,在程序中执行想要分析的操作,操作完后点击Stop Method Profiling,等待片刻就会弹出TraceView窗口。

任意一个调用方法都有Parents和Children子项,其中Parents表示该方法被哪个方法调用,Children表示该方法调用了哪些方法。

如果我们想要Method Profiling的代码一开始就执行了,要想对它使用Method Profiling就需要查找开始点与结束点,然后手动注入代码。在android.os.Debug类中,提供了startMethodTracing()stopMethodTracing()两个方法来开启与关闭Method Profiling。

Java代码如下:

1
2
3
android.os.Debug.startMethodTracing("123");
a();
android.os.Debug.stopMethodTracing();

字符串“123”为trace文件名,上面的代码在执行后会在SD卡的根目录中生成123.trace文件,这个文件包含了a()方法执行过程中所有的方法调用与CPU占用时间等信息。生成的文件可以将它pull出来,用Android SDK中tools目录下的traceview打开:

1
traceview 123.trace

另外,注入的代码在运行时需要往SD卡中写入文件,因此还需要在反编译的AndroidManifest.xml文件中添加SD卡写入权限:

1
<uses-permission adnroid:name="android.permission.WRITE_EXTERNAL_STORACE" />

如果确定不了开始点与结束点,可以在Activity的OnCreate()方法中注入startMethodTracing()代码,反汇编代码如下:

1
2
const-string v0, "123"
invoke-static {v0}, Landroid/os/Debug;->startMethodTracing(Ljava/lang/String;)V

OnStop()方法中注入stopMethodTracing()代码,反汇编代码如下:

1
invoke-static {}, Landroid/os/Debug;->stopMethodTracing()V

这样当程序打开并关闭后就会生成123.trace文件,然后使用traceview工具分析即可。

7.3 使用IDA Pro动态调试so文件

详情请看我的另一篇文章:Android逆向入门教程 9.IDA动态破解登录验证

8. Android软件的破解技术

8.1 试用版软件

免费试用版软件是Android平台上比较常见的一种商业软件,这种软件的自我保护能力一般较弱,通常可以手动破解。

8.1.1 试用版软件的种类

Android平台的试用版软件大致分为三类:免费试用版、演示版与限制功能版免费版。

免费试用版的软件通常有一个免费试用期限或次数限制,当达到了期限或免费次数后,软件会提示过期,提醒用户购买软件。

演示版软件一般只提供软件的部分功能供用户使用,此类软件通常是“免费”的,用户要想使用软件的全部功能则需要向软件作者购买正式版的软件,作者会提供完整版的安装包及使用权限。

限制功能免费版的软件通常将软件根据功能分成几个级别,例如免费版、高级版、专业版等。免费版只提供最基础的功能,而专业版或高级版则提供更多或全部功能,根据作者的授权风格不同,这三种级别的软件可能使用同一个软件安装包,通过不同的授权来区别使用权限,或使用不同的安装包提供不同的软件功能。

8.1.2 实例破解——针对授权KEY方式的破解

破解试用版软件的前提是试用软件中提供了软件的完整功能(即有些功能显示灰色,需要购买才能使用),否则即使解除了软件的授权限制也无法使用完整的功能,失去了破解的意义。

在使用授权KEY方式分辨授权类型时,可能会用到createPackageContext()方法,它可以创建其它程序的Context,通过这个Context可以访问其它软件包的资源,甚至可以执行其它软件包的代码。但这个方法可能抛出java.lang.SecurityException异常,这个异常为安全异常,通常一个软件是不能够创建其它程序Context的,除非它们拥有相同的用户ID与签名。用户ID是一个字符串标识,在程序AndroidManifest.xml文件的manifest标签中指定,格式为android:sharedUserId=”xxx.xxx.xxx”,当两个程序中指定了相同的用户ID时,这两个程序将运行在同一个进程空间,它们之间的资源此时可以相互访问,如果它们的签名也相同的话,还可以相互执行软件包之间的代码。

8.2 序列号保护

序列号保护又称为注册码保护。通常在购买这种保护方式的软件是,用户需要向软件作者提供注册信息(用户名或机器码),软件作者通过自己编写的“算号”程序计算出注册码发回给用户,用户使用这个注册码完成整个注册过程。“算号”软件也称为注册机,在计算可逆加密算法程序的注册码时,它通常是软件加密算法的一个逆过程。

序列号保护建议:

  • 序列号加入机器码验证,做到一机一码。

  • 使用NDK编写注册模块。将软件注册版提供的功能进行加密,例如对相关代码或数据使用AES、DES等加密算法进行加密,软件在运行时检测注册信息,如果是注册版用户则根据注册信息生成正确的解密密钥,最后使用这个密钥对注册版功能进行解密。

    根据注册信息生成密钥的一种思路可以是:在判断用户注册码正确的情况下,取注册码的前8位对其每个字节进行异或运算,然后使用这8位异或后的字节作为加密密钥,对注册功能代码的解密密钥进行AES/DES加密运算(AES/DEX的加密密钥即为解密密钥),将生成的加密数据写入程序的配置文件(SharedProferences或File都可以),软件在运行时读取该数据对代码进行解密,解密成功即说明是注册版用户。

  • 加入其它类型的保护方式,多种保护方式结合。

8.3 网络验证

网络验证是指软件在运行时需要联网进行一些验证。网络连接方式可以是Socket连接与HTTP连接,验证的内容可以是软件注册信息验证、代码完整性验证以及软件功能解密等。

8.3.1 网络验证保护思路

软件通过网络向验证服务器请求反馈信息,这些信息可能是静态的(例如服务器上的某个文件),也可能是动态的(例如传递一些特定的参数访问服务器的ASP或PHP脚本,服务器根据不同的参数返回不同的数据),还有可能是交互的(例如软件定义了一套与服务器交互的协议,通过Socket方式进行通信)。

对于静态的反馈信息,分析人员能够手动访问网络获取所有信息的内容,这样的软件在破解时相对简单,只需要找到验证点补丁上相应的信息即可。

动态的反馈信息处理起来则比较麻烦,由于无法得知完整的信息内容,就需要尝试构造不用参数的信息来获取返回结果,这可能需要多次运行软件,并且效果可能并不理想,尤其在参数与反馈信息被加密的情况下,还需要花大量的时间来对信息进行解密。

交互式的网络验证是最难破解的,交互式网络验证的服务器能够对信息进行更好的控制,这种验证多用于对软件功能的保护以及对软件使用者合法性的检测上,软件功能保护将软件的核心功能从客户端转向了服务端,客户端软件只是成为了一个数据显示工具,而合法性检测例如常见的“心跳包”检测,一旦软件与服务器断开连接,软件就拒绝提供任何功能或者干脆停止运行。

8.3.2 实例破解——针对网络验证静态方式的破解

既然软件会联网访问服务器上的数据,那么可以先找出服务器的地址。除了使用静态分析查找服务器地址外,还可以通过网络抓包的方式来获取,网络抓包工具可以使用Android移植版的tcpdump工具,该工具在Android模拟器的system/xbin目录下。

执行抓包命令:

1
adb shell tcpdump -p -vv 0 -w /sdcard/capture.pcap

导出包文件:

1
adb pull /sdcard/capture.pcap

导出后可用Wireshark查看流量包,筛选出HTTP与TCP数据包,找到网址。

之后再根据网址的静态内容嵌入到Smali代码中,去掉网络验证即可。(具体实现还没有找到好的例子,以后可能补上)

8.4 重启验证

重启验证是一种常见的软件保护技术,它的保护强度与开发人员重启验证的保护思路有关。

8.4.1 重启验证保护思路

重启验证的通常做法是:在软件注册时不直接提示注册成功与否,而是将注册信息保存下来,然后在下次启动时读取并验证,如果失败则软件仍未注册,成功则开启注册版的功能。

Android系统保存信息的方法有限,只能是内部存储、外部存储、数据库与SharedProferences等4种方式。破解者通常可以在短时间内找到注册信息的保存位置,因此,在实际使用重启验证的过程中,注册信息必须要加密存储才能保证其保护强度。几种常见的保护方案如下:

  • 单一保护。重启验证保护模块使用Java代码编写,注册信息加密保存到内部存储中。
  • 单一保护。重启验证保护模块使用Native代码编写,注册信息加密保存到内部存储中。
  • 多重保护。重启验证保护模块使用Native代码编写,并在代码中加入网络验证。

9. Android程序的反破解技术

逆向Android程序的整个过程可分为反编译、静态分析、动态调试、重编译4个环节,从这4个环节出发,分析如何在每个环节中保护Android程序。

9.1 对抗反编译

对抗反编译是指APK文件无法通过反编译工具(如Apktool、BakSmali、dex2jar)对其进行反编译,或者反编译后无法得到软件正确的反汇编代码。

对抗反编译工具的思路是:寻找反编译工具在处理APK或DEX文件时的缺陷,然后在自己的软件中加以利用,让反编译工具处理这些“特制”的APK文件时抛出异常而反编译失败。这样编写出来的软件能够在手机上正常使用,但在反编译工具的眼里却是一个“畸形”的文件。

如何查找反编译工具的缺陷?两种方式:阅读反编译工具源码和压力测试。

阅读反编译工具源码

目前大多数Android软件的反汇编工具都是开源的,可以很方便通过阅读源码来查找缺陷。查找的思路可以根据APK文件的处理环节来展开,例如资源文件处理、DEX文件校验、DEX文件类代码解析等。但通常情况下,反编译工具在发布前都经过多次测试,要想找出代码的缺陷非常困难。因此这种方法具体实施起来比较困难。

压力测试

收集大量的APK文件存放进一个目录,编写脚本或程序调用反编译工具对目录下的所有APK文件进行反编译。不同的软件从大小、内容到结构组织都不尽相同,反编译工具在处理它们时有可能会出现异常。从反编译的出错信息中查找反编译工具的缺陷,然后在软件开发中加以利用。

9.2 对抗静态分析

9.2.1 代码混淆技术

使用Native代码代替Java代码是很好的代码保护手段,Google在Android 2.3的SDK中正式加入了ProGuard代码混淆工具。

ProGuard提供了压缩、混淆、优化Java代码以及反混淆栈跟踪的功能。ProGuard默认情况下会对class文件中所有的类、方法以及字段进行混淆,经过混淆的类会面目全非。

9.2.2 NDK保护

用Native代码代替Java代码。

9.2.3 外壳保护

外壳保护是一种代码加密技术,在Windows平台的软件中广泛被使用。经过外壳保护的软件,展现在分析人员面前的是外壳的代码,因此很大程度上保护了软件被人破解。

Java代码由于其语言自身的特殊性,没有外壳保护的概念,只能通过混淆方式对其进行保护。外壳保护重点针对使用Android NDK编写的Native代码,逆向Native代码本身就很困难了,再加上外壳保护难上加难。目前已知可用于ARM Linux内核程序的加壳工具只有UPX。

9.3 对抗动态调试

9.3.1 检测调试器

动态调试使用调试器来挂钩软件,获取软件运行时的数据,我们可以在软件中加入检测调试器的代码,当检测到软件被调试器连接时,中止软件的运行。

AndroidManifest.xml文件中application标签中加入android:debuggable="false"让程序不可调试,这样如果别人想调试该程序就必然会修改它的值,我们在代码中检查它的值来判断程序是否被修改过,代码如下:

1
2
3
4
5
if((getApplicationInfo().flags &= ApplicationInfo.FLAG_DEBUGGABLE) != 0)
{
Log.e("com.droider.antidebug","程序被修改为可调试状态");
android.os.Process.killProcess(android.os.Process.myPid());
}

ApplicationInfo.FLAG_DEBUGGABLE对应android:debuggable="true",如果该标志被置位,说明程序被修改,此时可以果断中止程序运行。

另外,Android SDK中提供了一个方法方便程序员来检测调试器是否已经连接,代码如下:

1
android.os.Debug.isDebuggerContected()

如果方法返回true,说明调试器已经连接。我们可以随机地在软件中插入这行代码来检测调试器,碰到有调试器连接就果断地结束运行。

9.3.2 检测模拟器

软件发布后会安装到用户的手机中运行,如果发现软件运行在模拟器中,很显然不合常理,可能是有人试图破解或分析它。

模拟器与真机有着许多差异,可以输入命令:

1
adb shell getprop

查看并对比它们的属性值,经过对比发现,有如下几个属性值可以用来判断软件是否运行在模拟器中:

  • ro.product.model:该值在模拟器中为sdk,通常在真机中它的值为手机型号。
  • ro.build.tags:该值在模拟器中为test-keys,通常在真机中它的值为release-keys。
  • ro.kernel.qemu:该值在模拟器中为1,通常在真机中没有该属性。

9.4 防止重编译

9.4.1 检查签名

破解者通常不可能拥有与开发人员相同的密钥文件(密钥文件被盗除外),因此签名成了Android软件一种有效的身份标识。如果软件运行时的签名与自己发布时的不同,说明软件被篡改过。

Android SDK中提供了检测软件签名的方法,可以调用PackageManager类的getPackageInfo()方法。由于返回的签名较长,可用其Hash值在代码中进行比较。

9.4.2 校验保护

重编译Android软件的实质是重新编译classes.dex文件,代码经过重新编译后,生成的classes.dex文件的Hash值已经改变,我们可以检查程序的安装后classes.dex文件的Hash值来判断软件是否被重打包过。

10. Android系统攻击与防范

10.1 Anroid手机ROOT原理

手机ROOT是通过已经公布的Android系统本地提权漏洞,借助漏洞利用程序来提升系统的用户权限。手机ROOT分为临时ROOT与永久ROOT。临时ROOT是指临时性的获取系统root权限,不对系统进行任何修改,而永久ROOT是指修改Android系统,手机可以随时获取root权限。

10.2 ROM安全

ROM,只读存储器。手机ROM指的是存放手机固件代码的存储器,可以理解为手机的“系统”,类似于Windows系统安装光盘。

10.2.1 ROM的种类

根据ROM制作者不同,Android系统的ROM分为如下三类:

  • 官方ROM:手机出厂时被刷入的ROM。
  • 第三方ROM:由第三方ROM制作团队或厂商制作的ROM。
  • 民间个人版ROM:个人在官方ROM或第三方ROM的基础上进行修改而成的ROM。

11. DroidKongFu变种病毒实例分析