记一次 Android App 的逆向工程(二)

1 前言

在前文中,凭借 mitmproxy 的神奇表现, HTTPS 的加密防线被成功攻破。

不过 115 客户端的通信协议就像俄罗斯套娃,剥开一层还有一层。里面的一层加密显然是程序层面做的,因此也只能从程序层面去找答案。

本文将介绍如何通过修改 apk 的程序代码,拿到加密前和解密后的通信内容。


由于这篇文章拖了太久,以至成文时,一些工具已经更新了版本,因此文中也对应使用了最新版本,包括:

  • 115 客户端: 25.3.0 -> 26.1.0
  • 工作平台: macOS Catalina 10.15.7 -> macOS Big Sur 11.0.1
  • jadx-gui: 1.1.0 -> 1.2.0

2 反编译 Apk

2.1 工具

动手修改之前,首先要定位到加解密部分的关键代码。

这里使用反编译工具 jadx-gui,它可以直接浏览 apk 的内部结构,并且反编译其中的 Java 代码。此外,它还具有如下特性:

  • 支持点击方法名跳到定义部分,以及反查所有对方法的调用。
  • 支持全文搜索关键字。
  • 1.2.0 版本新增了反混淆重命名功能。

这些特性为追踪代码流程提供了极大的便利。

2.2 定位关键代码

整个 apk 中有 2 万多个类,如果想要全看一遍…… 嗯,估计到《全职猎人》复刊都看不完。

所以需要借助一些技巧,快速地定位到加解密的代码。

这里使用逆向思维来推理:

  1. 既然加密的是 HTTPS 响应(Response)的整个内容,那么客户端程序里面,应该会在拿到内容的第一时间进行解密。
  2. 按照常规的程序设计逻辑,发送请求(Request)和解析响应(Response),一般会封装在同一个类中。
  3. 结合前文中 mitmproxy 截获的 HTTPS 通信,每个请求都带一个 k_ec 参数,就以这个作为突破口。

选择菜单”Navigation -> Text search”,打开文本搜索功能,以 k_ec 作为关键字,搜索目标选择”Code”:

可以看到,关键字集中出现在一个类中:com.main.common.component.base.u,这个类很有可能就是封装了与 Server 通信的核心类。

2.3 代码阅读理解

接下来的工作,就是阅读这个关键类的代码。

由于大部分的包名、类名和方法名都被混淆过了,因此这部分工作会比较枯燥。主要是通过 jadx-gui 的”Find Usage” 和”Go to declaration” 功能,追踪方法间的调用流程。

读代码的过程,没有什么捷径可言。个人建议是,将自己代入开发者的视角,思考一下 “如果换做自己来实现,会怎么写”,通过这种方式来梳理整个代码的思路。

分析代码时,可以开启”Tools -> Deobfuscation” 功能,对于明白了功能的方法或者类,通过右键菜单中的”Rename” 功能,为其定义一个有意义的名字。

此处略去几个小时的工作量,最终得到的结论是:

  • com.main.common.component.base.u 确实是与 Server 通信的核心类,负责添加通用参数,组装请求,以及解密响应。
  • 加解密的方法,对应 com.android.jni.ec115 类的 EncodeDecode 方法,这是两个 JNI 方法,具体实现在 libec115.so 中。
  • ec115.Encode 方法将返回两个值:第一个值是 String 类型,作为 k_ec 参数。第二个值是 byte[] 类型,作为 POST 请求的内容。

2.4 修改方案

可以看到,加解密的调用逻辑非常简单,由 u 类直接调用 ec115 类的 EncodeDecode 方法:

因此,想要监听这二者之间的数据交互,只需要在它们中间安插一个代理类即可。

假定代理类的类名为 Ec115Proxy,它以 ec115 为父类,并覆盖 EncodeDecode 方法。在内部实现上,先调用父类对应的方法,然后把明文和密文打印出来。

这样,只需要修改 u 类,让它将 Ec115Proxy 类作为 ec115 类来调用即可。

Encode 方法为例,引入代理类之后,调用的流程如下:

3 修改 Apk

有了思路和目标,就可以着手进行修改的工作了。

3.1 解包 Apk

首先将 apk 解包,并反编译 Java 的代码。

这里用到的工具是 apktool,在终端执行如下命令,将 apk 的内容解包到 115 文件夹中:

1
apktool d -r -o 115 115_26.1.0.apk

apk 中的 Java 代码,都被反编译成 smali 指令集,存在以”smali” 开头的几个文件夹中:

3.2 修改 Java 代码

3.2.1 编写代理类

在这个修改方案里,主要的逻辑都在 Ec115Proxy 类中,直接基于 smali 指令集手写显然不太现实。

这里选择曲线救国的方式:先用 Java 编写,然后构建成 apk ,再通过 apktool 反编译,拿到 smali 指令集文件。

启动 Android Studio,新建一个标准的 Android App 项目。

首先,在项目中创建 Java 类 com.android.jni.ec115,并为其定义与 115 客户端中完全一样的 EncodeDecode 方法。这里不要将其声明为 native 方法,只要写一个空的实现即可。此外,也不需要将 ec115 的所有方法都声明出来,只声明要覆盖的那些方法即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.android.jni;

import android.content.Context;

public class ec115 {

public byte[] Decode(Context context, byte[] bArr) {
return null;
}

public Object[] Encode(Context context, String str, int i) {
return null;
}

}

接下来,编写它的子类 Ec115Proxy,覆盖 EncodeDecode 方法,并把参数和返回值打印到 Log 中:

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
package deadblue.hijack115.proxy;

import android.content.Context;
import android.util.Log;

import com.android.jni.ec115;

import java.nio.charset.StandardCharsets;

public class Ec115Proxy extends ec115 {

private static final String TAG = "Ec115Proxy";

@Override
public Object[] Encode(Context context, String str, int i) {
Object[] result = super.Encode(context, str, i);
Log.i(TAG, String.format("Encode(str=\"%s\", i=%d) => (\"%s\", %s)",
str, i, result[0], hexEncode((byte[])result[1])
));
return result;
}

@Override
public byte[] Decode(Context context, byte[] bArr) {
byte[] result = super.Decode(context, bArr);
Log.i(TAG, String.format("Decode(bArr=%s) => %s",
hexEncode(bArr), bytes2String(result)
));
return result;
}

private static String hexEncode(byte[] bArr) {
StringBuilder sb = new StringBuilder("[");
if(bArr != null) {
String sep = "";
for(byte b : bArr) {
sb.append(sep).append(String.format("%02x", b));
sep = ", ";
}
}
sb.append("]");
return sb.toString();
}

private static String bytes2String(byte[] bArr) {
if(bArr == null) {
return "null";
} else {
return new String(bArr, StandardCharsets.UTF_8);
}
}

}

Android Studio 中构建项目,生成 apk 。

再使用 apktool 反编译 apk ,从中找到 Ec115Proxy.smali 文件:

1
2
3
4
5
apktool d -r -o hijack115 /path/to/app-debug.apk

find hijack115 -type f -name Ec115Proxy.smali
# Output:
# hijack115/smali_classes2/deadblue/hijack115/proxy/Ec115Proxy.smali

3.2.2 注入代理类

接下来,将 smali 指令集格式的 Ec115Proxy 类,注入到 115 客户端中。

115/smali 中,创建目录 deadblue/hijack115/proxy,然后将 Ec115Proxy.smali 复制过去:

1
2
3
4
mkdir -p 115/smali/deadblue/hijack115/proxy

cp hijack115/smali_classes2/deadblue/hijack115/proxy/Ec115Proxy.smali \
115/smali/deadblue/hijack115/proxy/

3.2.3 修改通信核心类

编辑文件 115/smali/com/main/common/component/base/u.smali,找到如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
.method static constructor <clinit>()V
.locals 1

.line 73
new-instance v0, Lcom/android/jni/ec115;

invoke-direct {v0}, Lcom/android/jni/ec115;-><init>()V

sput-object v0, Lcom/main/common/component/base/u;->a:Lcom/android/jni/ec115;

return-void
.end method

将其修改为:

1
2
3
4
5
6
7
8
9
10
11
12
.method static constructor <clinit>()V
.locals 1

.line 73
new-instance v0, Ldeadblue/hijack115/proxy/Ec115Proxy;

invoke-direct {v0}, Ldeadblue/hijack115/proxy/Ec115Proxy;-><init>()V

sput-object v0, Lcom/main/common/component/base/u;->a:Lcom/android/jni/ec115;

return-void
.end method

修改后,u 类将实例化 Ec115Proxy 类,并将其作为 ec115 类来使用。

这样,通过仅仅修改两行 smali 指令,就达到了目的。

3.3 破解签名验证

为了防止 apk 被篡改,115 客户端还对 apk 的签名进行了校验。为了保证修改后的 apk 能够正常运行,需要干掉这部分逻辑。

3.3.1 原理

当第三方对 apk 进行修改后,必然要重新进行签名。而第三方通常是拿不到 apk 原开发者的签名私钥的,因此修改后的 apk 签名势必发生变化。

针对这一点,有些 apk 就会在运行时读取自己的签名信息,并进行校验。

不过,获取签名离不开几个固定的 Android API,所以定位起来也容易。校验代码一定要调用 android.content.pm.PackageManager.getPackageInfo() 来获取包信息,然后从返回的 PackageInfo 对象中获取签名信息。

因此,直接按关键字去搜索,再看看前后的代码,就知道究竟有没有签名验证了。

3.3.2 破解

115 客户端做得就比较绝,将校验逻辑做到了 native 代码(即 so 文件)中,而且在三个 so 文件中都做了校验,分别是:

  • libec115.so
  • libm115_encode.so
  • libsig115.so

虽然关于 ARM 反汇编的内容准备留到下一篇文章再讲,但是这个破解是绕不过的坎儿。因此就在这里先简单介绍一下,权当是热身运动,正好这部分代码的逻辑也十分简单。

libec115.so 为例,使用 Cutter 加载文件。待反编译结束后,从左边的”Functions” 中搜索 verify_OK,就可以看到校验签名的函数代码:

这里用到的都是入门级的 ARM 汇编指令,而且代码也不长,很容易读懂。将其转化为 C 语言代码的话,大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
static bool verify_result = false;

bool verify_OK(JNIEnv * jEnv, jobject * jObj) {
if(verify_result) {
return true;
}
long signature = get_apk_signature(jEnv, jObj);
if(signature == 0x8339d847) {
verify_result = true;
}
return verify_result;
}

理解了逻辑之后,破解的方法也就明确了:直接跳过最后的判断,让它始终返回 true

对应到 ARM 汇编中,将 0x0000e02a0x0000e02c 这两行修改为空指令(nop)即可。在这两行上面依次点击鼠标右键,在菜单中选择”Edit -> Nop Instruction”,修改后的汇编代码应该是这个样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
34: verify_OK(_JNIEnv*, _jobject*) (int16_t arg1, int16_t arg2);
; arg int16_t arg1 @ r0
; arg int16_t arg2 @ r1
0x0000e014 push {r3, r4, r5, lr} ; verify_OK(_JNIEnv*, _jobject*)
;-- aav.0x0000e015:
0x0000e015 unaligned
0x0000e016 ldr r5, [aav.aav.0x000722e4] ; 0xe038
0x0000e018 add r5, pc ; 0x80300
; loc.__bss_start
0x0000e01a ldr r4, [r5] ; 0x80300
; loc.__bss_start
0x0000e01c cbnz r4, 0xe032
0x0000e01e bl get_apk_signature(_JNIEnv*, _jobject*) ; sym.get_apk_signature__JNIEnv____jobject
; get_apk_signature(_JNIEnv*, _jobject*)
0x0000e022 movw r3, 0xd847
0x0000e026 movt r3, 0x8339
0x0000e02a nop
0x0000e02c nop
0x0000e02e movs r4, 1
0x0000e030 str r4, [r5]
0x0000e032 mov r0, r4
0x0000e034 pop {r3, r4, r5, pc}

其他两个 so 中,校验函数的名称和逻辑也基本一致,参考上面的思路进行修改即可。

3.4 构建 Apk

在终端中执行如下命令,将 115 文件夹重新打包,生成 115-hijacked.apk

1
apktool b -o 115-hijacked.apk 115

虽然构建 26.1.0 版本 apk 时一切顺利,但是在之前构建 25.3.0 版本时,遇到过如下错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
I: Using Apktool 2.4.1
I: Checking whether sources has changed...
I: Smaling smali folder into classes.dex...
Exception in thread "main" org.jf.util.ExceptionWithContext: Exception occurred while writing code_item for method Landroid/support/v4/util/LongSparseArray;->clone()Landroid/support/v4/util/LongSparseArray;
at org.jf.dexlib2.writer.DexWriter.writeDebugAndCodeItems(DexWriter.java:917)
at org.jf.dexlib2.writer.DexWriter.writeTo(DexWriter.java:341)
at org.jf.dexlib2.writer.DexWriter.writeTo(DexWriter.java:297)
at brut.androlib.src.SmaliBuilder.build(SmaliBuilder.java:61)
at brut.androlib.src.SmaliBuilder.build(SmaliBuilder.java:36)
at brut.androlib.Androlib.buildSourcesSmali(Androlib.java:418)
at brut.androlib.Androlib.buildSources(Androlib.java:349)
at brut.androlib.Androlib.build(Androlib.java:301)
at brut.androlib.Androlib.build(Androlib.java:268)
at brut.apktool.Main.cmdBuild(Main.java:251)
at brut.apktool.Main.main(Main.java:79)
Caused by: org.jf.util.ExceptionWithContext: Error while writing instruction at code offset 0x13
at org.jf.dexlib2.writer.DexWriter.writeCodeItem(DexWriter.java:1190)
at org.jf.dexlib2.writer.DexWriter.writeDebugAndCodeItems(DexWriter.java:913)
... 10 more
Caused by: org.jf.util.ExceptionWithContext: Unsigned short value out of range: 65538
at org.jf.dexlib2.writer.DexDataWriter.writeUshort(DexDataWriter.java:116)
at org.jf.dexlib2.writer.InstructionWriter.write(InstructionWriter.java:356)
at org.jf.dexlib2.writer.DexWriter.writeCodeItem(DexWriter.java:1150)
... 11 more

发生该错误的原因是:构建 classes.dex 文件时,里面的方法引用数超过了上限。

这是由于 Android 限制了一个 dex 文件中,最多可以有 65536 个方法引用。当对 Java 代码进行修改时,很容易导致一个 dex 中的方法引用数超过上限。因为正常构建 apk 时,本来就是在接近或达到极限值的时候,才分割 dex 文件。

遇到这种错误时,只需要将一部分 smali 文件移动到其他 classes 文件夹中即可。


此外,在构建过程中,可能遇到如下警告信息:

1
W: Unknown file type, ignoring: 115/smali/.DS_Store

这是由于 macOS Finder 会在每个访问过的文件夹下,生成一个.DS_Store 文件,通过下述命令就可以暂时批量删除它们:

1
find . -name .DS_Store -exec rm -f {} \;

3.5 重新签名 Apk

通过 apktool 构建出来的 apk 是没有签名的,无法安装到设备上,因此需要重新为其签名。

这里介绍一下如何通过命令行工具为 apk 签名。


首先需要准备用于签名的密钥和证书。如果是做过 Android 开发的同学,那么手里应该已经有了 jks 或者 p12 文件,可以跳过这一步。

这里使用 Java 提供的 keytool 生成,命令如下:

1
2
3
keytool -genkeypair -storetype pkcs12 \
-storepass 12345678 -keypass 12345678 \
-validity 3650 -keystore android.p12

按照提示输入相关信息,最终生成文件 android.p12。这个文件里就存放了密钥和证书信息,它的有效期是自生成之日起 3650 天,密码是 12345678

妥善保管好这个 p12 文件,后续如果需要再修改 apk 时,还要使用它来签名。


签名 apk 的工具包含在 Android SDK 中,路径为:<Android-SDK>/build-tools/<SDK-Version>/apksigner。为了便于操作,建议将它所在的目录追加到 PATH 环境变量中,或者在任意一个 PATH 目录中创建它的软链接。

执行下列命令,对之前生成的 115-hijacked.apk 进行签名,签名后的文件另存为 115-hijacked-sgined.apk

1
2
3
apksigner sign --v2-signing-enabled --ks /path/to/android.p12 \
--ks-pass "pass:12345678" --key-pass "pass:12345678" \
--in 115-hijacked.apk --out 115-hijacked-sgined.apk

4 安装及实测

最后一个登场的工具,是爱丁堡adb,它也包含在 Android SDK 中,路径为 <Android-SDK>/platform-tools/adb。同样建议在任意一个 PATH 目录中创建它的软连接,因为这个工具非常强大,用途很多。

要使用 adb,首先需要在设备上开启开发者选项,然后用 USB 数据线将手机与电脑连接即可。

如果设备上之前安装了官方版本的 115 客户端,需要先将其卸载:

1
adb uninstall com.ylmf.androidclient

安装修改后的 apk :

1
adb install 115-hijacked-sgined.apk

安装完成后,执行下列命令,监控 Ec115Proxy 输出的日志:

1
adb logcat | grep Ec115Proxy

最后,在设备上启动修改后的 115 客户端,查看终端的输出:


5 总结

修改 apk 是一个比较常见的需求,应用场景包括但不限于:修改单机游戏数值,移除应用广告等等。

本文以 115 客户端作为示例,演练了一下修改 apk 的一般工作流程,并且针对过程中遇到的坑,给出了原因和解决方案。

不过,这个系列文章的目的是分析 115 的通信协议,因此下一篇文章将从 ARM 汇编的角度,分析 115 通信中使用的加解密算法。

6 参考资料