加载中...

Android 逆向学习笔记


练习

条项 说明
APP YZ.apk
目标 A 绕过 root 限制
目标 B 拿到 abService 签名 key 并通过中间人改至少改一个包(通过签名验证)

参考资料

内容 推荐度 说明
Android Studio 用户指南 低 IDE 说明文档,暂时还用不到
Mac 上可测试开发的安卓模拟器 低 MAC上搭建安卓模拟环境
在 VirtualBox 中安装 Android 系统 低 MAC上安装各种安卓模拟器失败后的折中方法
Android 反编译及调试利器 低 一些常用工具
微信 Android SDK 反编译还原源码 进行修改重新编译 低 非入门级的思路指导
移动安全: 通过逆向 APK 绕过 Android root 检测 高 apk为什么要限制root,检测root的原理是什么
Android 反编译后重新打包 高 入门级思路指导
Android 逆向基础:Smali 语法 高 逆向后如何修改代码(非源码的中间代码)
Smali 基础知识 高 逆向后如何修改代码(非源码的中间代码)
安卓 apk 反编译、修改、重新打包、签名全过程 高 反编译后如何重新封包(使用JDK)
Android 反编译 Apk,修改资源,重新打包,签名发布 中 反编译后如何重新封包(使用工具)
Android逆向之旅 — IDA动态调试SO 高 分析某拍短视频的数据请求加密协议
Inspeckage 安装教程 低 看看就好,可以不用到 Inspeckage
深入 Android 动态分析工具 Inspeckage 低 实例教程
Xposed知多少 低 入门科普
inspeckage xposed 插件分析 app 及微信 Log 低 Inspeckage 案例参考
Android App 逆向工程初探 高 案例参考,逆向后如何找到 HTTP 签名算法

工具

工具 说明
jadx 用于 apk 反编译(为可读的 java 源码)
apktool 用于 apk 反编译(为不易读的 Smali 中间码)
用于 Smali 重打包为 apk
JDK keytool:用于生成自签名证书
jarsigner:用于给 apk 自签名
网易 MuMu 安卓模拟器(蓝叠不支持MAC,夜神又安装失败,最后没办法选了这个)
Xposed 用来胁持系统 API 的 hook 框架 (需要 root 系统才能装)
Inspeckage Xposed 的一个模块,用于动态分析 app(事实上用不到)
TrustMeAlready Xposed 的一个模块,用于绕过 SSL Pinning 防抓包(如果 APP 没有防抓包则用不到)
本来应该装 JustTrustMe 的,但是搜不到,用了这个替代
Burp Suite 用于抓包和中间人攻击

【目标 A】绕过 root 限制

A.1. 解题思路

通过文章《移动安全: 通过逆向 APK 绕过 Android root 检测》可以知道,apk 的 root 检测都是通过(java)代码实现的,因此要绕过 root 的思路也很直白了:

  • 反编译 apk 安装包
  • 找到 root 检测代码
  • 修改检测逻辑,使其不生效(删除或固化其检测结果均可,只要没有多处埋点,一般只有程序入口一处)
  • 重新打包为 apk 安装包
  • apk 自签名(apk 有防篡改机制,所有文件都会生成 hash 校验码,签名目的是重新生成这些校验码)
  • 在启用 root 的模拟器上验证

A.2. 解题过程

A.2.1. 找到 root 检测入口

首先需要 root 检测入口的点在哪。

使用模拟器安装 YZ.apk 并运行,会提示【禁止在 Root 设备运行】,然后会直接关掉 APP 进程,而明显这就是我们需要绕过的逻辑。

鉴于已经有明显的检测提示【禁止在Root设备运行】,那么使用 jadx 反编译 YZ.apk,然后直接搜索源码哪里出现这个字符串即可,检测逻辑必定在附近。

jadx 反编译得到的是 java 源码

为了方便后续操作,jadx → 另存为 Gradle 项目(这里导出保存目录为 yz_jadx)

Gradle 的定位类似于 Maven

暂时不需要用到 IDE, 直接使用 ST3 打开导出的 Gradle 项目,全局搜索关键字【禁止在】,在文件 src/main/res/values-zh/strings.xml 找到唯一一条定义:【禁止在Root设备运行】

从目录编排上不难判断 src/main/res/values-xx 就是用于定义某国 xx 语言的提示字符串的 xml 配置文件,很明显这是一个多国语言的 APP。

而且从 xml 格式和内容来看,很明显定义了两个变量:

  • warn_root_disabled = “禁止在Root设备运行”
  • warn_emulator_disabled = “禁止在模拟器上运行”

与这两个变量相关的逻辑极可能都是我们需要绕过的。

再次全局搜索关键字【warn_root_disabled】,在文件 src/main/java/com/xxx/oooooooooo/ui/main/MainActivity.java 找到唯一一次代码引用:

查看 MainActivity.java 类的源码,发现明显被混淆过了。

不过不难判断方法 private void e() 内实现了【root 检测】和【模拟器检测】。因为前面搜索到的两个目标变量都在这里出现了:

  • warn_root_disabled = “禁止在Root设备运行”
  • warn_emulator_disabled = “禁止在模拟器上运行”

A.2.2. 修改 root 检测逻辑(java源码)

其实从源码不难解读到:

  • e.b() 是用于检测模拟器的
  • e.a() 是用于检测 root 的
  • k.a() 应该是输出提示消息到 UI 的

而最开始在模拟器上运行 YZ.apk 所诱发的提示【禁止在Root设备运行】,明显就是 k.a((int) R.string.warn_root_disabled); 这行代码执行结果。

那么要达到无法检测 root 的目标,只需要让 k.a() 所在的条件分支代码无法执行即可,例如这样修改代码:

但是修改 java 源码后,使用 apktool b yz_jadx 命令重新编译打包会报错:【brut.directory.PathNotExist: apktool.yml】

从 Github issue 找到原因,这是因为 yz_jadx 是通过 jadx 逆向得到的项目,亦即 yz_jadx 并不是 apktool d 命令逆向生成的,导致 apktool 找不到 apktool.yml 文件,从而无法重新打包。

A.2.3. 修改 root 检测逻辑(Smali中间码)

于是这里变更逆向的方法:使用 apktool d YZ.apk 命令反编译 YZ.apk ,得到目录名为 YZ 项目。

在目录下果然可以找到 apktool.yml 文件,在里面还记录了每个类的 JDK 编译版本为 8 (即 1.8)。

需要注意的是,使用 apktool 逆向出来的代码不是 java,而是名为 smali 的中间码。

根据前面所找到的 java 文件 src/main/java/com/xxx/oooooooooo/ui/main/MainActivity.java,可以对应找到的 smali 文件 smali/com/xxx/oooooooooo/ui/main/MainActivity.smali。

稍微学习一下 smali 语法,不难找到 smali 的 .method private e()V 就是 java 的 private void e()。

两种代码的对应关系整理如下:

序号 smali 中间码 java 源码 分析说明
01 .method private e()V
…
.end method
private void e() {
…
}
定义方法
02 .locals 4 - 声明方法内的局部变量个数
03 .prologue - 声明代码块开始位置
04 .line 178 - 声明对应 java 源码的行数
05 const/4 v1, 0x1 boolean z = true; 变量赋值,v1 即 z
06 const/4 v0, 0x0 boolean z2 = false; 变量赋值,v0 即 z2
07 const-string v2, "" e.f4245a = "" 变量赋值,v2 即 e.f4245a
08 sput-object v2, Lcom/xxx/oooooooooo/common/g/e;->a:Ljava/lang/String; - 根据操作指令 sput-object 猜测是初始化类 e 内的 String 成员变量 a,类似于 e.a = null ,因此怀疑这是执行了类 e 的构造函数
09 invoke-static {}, Lcom/xxx/oooooooooo/common/g/e;->b()Z
move-result v2
e.b() 从上下文推测,实际效果是 e.f4245a = e.b(),即 e 的成员变量 f4245a 会因为 b() 的结果而改变,而 b() 的作用前面分析过是检测是否在模拟器运行
10 if-eqz v2, :cond_0 if (e.b()) 满足条件时,跳转到 :cond_0 位置
11 const v0, 0x7f0d0464
invoke-static {v0}, Lcom/xxx/oooooooooo/c/k;→a(I)V
move v0, v1

k.a((int) R.string.warn_emulator_disabled);
z2 = true;
0x7f0d0464 值为 2131559524,没意义,估计是初始化参数寄存器。k.a() 前面分析过是输出提示消息的,这里就是禁止模拟器运行分支,既是要绕过的目标之一,最后利用 z2 设置了分支 flag 标记
12 :cond_0
invoke-static {}, Lcom/xxx/oooooooooo/common/g/e;→a()Z
move-result v2

e.a()
从上下文推测,实际效果是 e.f4245a = e.a(),即 e 的成员变量 f4245a 会因为 a() 的结果而改变,而 a() 的作用前面分析过是检测是否在 root 环境运行
13 if-eqz v2, :cond_2 if (e.a()) 满足条件时,跳转到 :cond_2 位置
14 const v0, 0x7f0d0465
invoke-static {v0}, Lcom/xxx/oooooooooo/c/k;->a(I)V

k.a((int) R.string.warn_root_disabled);
0x7f0d0465 值为 2131559525 ,没意义,估计是初始化参数寄存器,k.a() 前面分析过是输出提示消息的,这里就是禁止 root 运行分支,绕过目标之一
15 :goto_0 - 从上下文分析,这个标签是从后面的代码回溯回来的
16 if-eqz v1, :cond_1 if (z) 满足条件时,跳转到 :cond_1 位置
17 .line 192
…
j.a().postDelayed(new Runnable() {
public void run() {
com.xxx.oooooooooo.common.a.a.c();
}
}
这段代码的作用,根据上下文推测就是 终止进程
18 :cond_1
return-void
return; 直接从当前函数返回
19 :cond_2
move v1, v0

z = z2;
利用 z 设置了分支 flag 标记
20 goto :goto_0 - 无条件跳转到 :goto_0 位置

更具体的对应关系见下图:

有了对应关系,结合前面修改 java 代码的思路,其实就很容易修改 smali 代码了:

  • 方案1:只要使得在执行 if (z) 时(对应上表16行),令 z == false 即可
  • 方案2:直接注释/删除掉终止进程的代码(对应上表17行)

方案2其实是最简单的,但是我有个顾虑就是:smali 用 .line 标记了对应 java 源码的行数,我担心 smali 会像汇编一样,如果不使用类似 nop 的空语句去填充会使得重编译后的文件无法执行。

因此我采用了方案1,不改动代码行数的前提下,通过跳转实现条件分支的绕过。

其实方法也很简单,只需要把上表第 10 行和第 13 行的 if-eqz 条件跳转改成无条件跳转即可。

修改前 修改后
if-eqz v2, :cond_0 goto :cond_0
if-eqz v2, :cond_2 goto :cond_2

A.2.4. 重新编译打包

因为前面已经从 apktool.yml 知道这个 apk 原本是使用 JDK 1.8 编译的,

重新编译前需要注意设置系统环境变量,把 JDK 版本切换到 1.8 :

执行命令 apktool b YZ,在目录 YZ/dist/ 下会生成新的 YZ.apk 文件 :

A.2.5. 签名

新生成的 YZ.apk 是不能够安装的,原因是安卓有防篡改机制,会把所有文件的 Hash 校验码记录到 META-INF/MANIFEST.MF 文件。

而刚刚我们修改过代码,Hash 校验码必定已经改变。

而对安装包签名目的之一是重新生成 META-INF/MANIFEST.MF 。

签名采用自签名即可,可以直接使用 JDK 自带的工具执行。

首先生成证书:keytool -genkey -alias demo.keystore -keyalg RSA -validity 40000 -keystore demo.keystore

根据提示随便填写即可。

然后使用这个证书对刚才重新编译的 YZ.apk 自签名:jarsigner -verbose -keystore demo.keystore YZ.apk demo.keystore

得到最终的 YZ.apk。

使用 jadx 打开签名后的 YZ.apk,可以确认到刚才的签名信息:

在模拟器上安装 YZ.apk 并运行,成功绕过 root 检测。

【目标 B】通过 abServer 签名验证

B.1. 解题思路

  • 在模拟器运行 YZ,并在 YZ 中执行某些操作
  • 利用 Burp Suite 对模拟器抓包
  • 观察所执行的操作对应触发的 HTTP 请求
  • 通过 HTTP 请求的特征(URL、参数等)在反编译代码中定位大概位置
  • 找到 HTTP 请求参数中 signature 签名值的来源,分析其生成算法
  • 篡改 HTTP 请求参数并对其签名,并通过算法生成新的 signature 并发送到服务器,实现中间人攻击

B.2. 解题过程

B.2.1. 搭建抓包环境

【搭建 xposed 环境(可选)】

在安卓模拟器上安装 Xposed,再安装 Inspeckage 和 TrustMeAlready 模块,然后通过 Inspeckage 启动 YZ :

MAC 下的相关命令:

  • 重启 adb 服务并连接到 adb shell: adb kill-server && adb server && adb shell
  • 转发 Inspeckage 的 web 服务端口:adb forward tcp:8008 tcp:8008
  • 转发 Inspeckage 的 LogCat 服务端口:adb forward tcp:8887 tcp:8887

此时通过浏览器打开 http://127.0.0.1:8008/ 即可查看 YZ 详细的 APP 信息:

【搭建 Burp Suite 环境(必须)】

依次点选:Burp Suite → Proxy → Options → Proxy Listeners → Add → Binding

-【Bind to port】随便填写即可(如当前为 8080)
-【Bind to address】选择 Specific address 并选择局域网 IP (如当前为 192.168.1.104)

在网易 MuMu 依次点选: 设置 → WLAN → 长按 → 修改网络 → 高级选型 → 代理 → 手动

-【代理服务器主机名】填写 Burp Suite 监听的 IP (如当前为 192.168.1.104)
-【代理服务器端口】填写 Burp Suite 监听的端口(如当前为 8080)

如上配置好后,即可从 Burp Suite → Proxy → HTTP history 查看抓到的各个 HTTP 请求:

B.2.2. 观察 & 分析包特征

不难发现,在启动 YZ 后,已经发出了相当一部分 HTTP 请求。观察这些请求参数的特征,几乎都带了 token 和 signature 两个参数。

测试发现:

  • token:当前会话的固定值,每次重新登录 YZ 后会变化
  • signature:
    o 大部分都是 32 位,形似 MD5
    o 少量(如 /bifrost/msg/scanWithSecurityMsg) 是一个 RSA 的数字签名 (事实上从代码发现,签名用的是 SHA256withRSA,加密用的是 AES )
    o 直接计算请求参数的 MD5 ,与 signature 并不一致,说明 signature 加了 salt
    o 不同请求的 signature 都不一样,但重发同一个请求,服务端均返回成功,说明 signature 没有用时间作为 salt
    o 修改请求参数中的任意内容并发送,服务端返回 400: signature is empty or is not equal ,结合前面几点推测,salt 可能是一个固定值
    o 修改 token 并发送,服务端返回 401: token is empty or not exists,推测 signature 的计算过程中并没有 token 参与

B.2.3. 代码逆向分析

从前一节分析已经知道,signature 含有两种(甚至以上的形式):MD5 和 RSA 。

要实现题目要求的中间人攻击,则需要成功伪造签名,亦即需要找到 signature 的计算方法。

简单起见,下面以 MD5 形式的 signature 作为目标展开分析。

这里从修改 YZ 个人信息入手,例如修改【电话】。

保存修改后,在 Burp Suite 捕获到 URL 为【/xxx-abService/ab/employee/phone】的请求。

在逆向的 java 代码中全局查找关键字【/xxx-abService/ab/employee/phone】

可以从 src/main/java/com/xxx/oooooooooo/core/d/f.java 类中找到唯一值【UPDATE_USER_PHONE(“/xxx-abService/ab/employee/phone”)】

而且很明显,src/main/java/com/xxx/oooooooooo/core/d/f.java 类是定义了所有 URL 的枚举对象。

注:为了便于分析代码逻辑(调用跳转等),建议先把逆向代码导入 Eclipse 等 IDE 再分析。

再全局查找关键字【UPDATE_USER_PHONE】,可以从 src/main/java/com/xxx/oooooooooo/core/d/d.java 类中找到唯一调用它的函数【public c<UserPhoneResponse> c(String str, String str2)】。

简单分析代码可以知道:

  • src/main/java/com/xxx/oooooooooo/core/d/d.java 类是 abService 所有 API 的接口实现
  • 【public c<UserPhoneResponse> c(String str, String str2)】函数是接口 “/xxx-abService/ab/employee/phone“ 的实现
  • 该函数的主要作用是构造接口的 HTTP 请求参数 params (Builder 类使用了构造者模式,其作用就是填充 HTTP 结构体的内容)
  • 从实现逻辑看,只是构造了参数 params ,而 token 和 signature 参数并未被构造
  • 观察其他接口实现的函数,均未构造 token 和 signature 参数
  • 推测 token 和 signature 被封装到同一个地方实现,而最可疑的就是【return a( .... )】函数

因为逆向代码被混淆的关系,出现了很多同名类、同名方法,导致 Eclipse 的函数调用跳转功能出现偏差。

若直接跳转到【return a( .... )】函数的实现,会跳到一个接口【com.xxx.oooooooooo.data.api.g.java】定义的方法【c<GroupsResponse> a(long j, int i);】

事实上从函数定义的入参表就可以发现,这是不对的(当时分析代码的时候就被这里卡住了好长时间)。

因此这里只能通过人工分析【return a( .... )】函数的实现位置:

  • 由于函数 a 在调用时没有声明类名,它应该不是静态方法,因此应该在父类或本类中
  • 从实际传给函数 a 的入参推测,其函数定义应该形如【c<T> a(Class<T> xxx, Builder yyy)】
  • 但是因为代码混淆导致变量名随机化的关系,这个函数定义中可以确定用于全局搜索的部分只有【> a(Class<】
    根据这些特征搜索,发现该函数的实现就是在本类的最开头的位置,名为【private <RESULT extends NoProguard> c<RESULT> a(Class<RESULT> cls, Builder builder)】

但是从设个函数中并不能直观地看到 token 和 signature 被构造的痕迹,不过可以发现 builder 参数确实被送到函数 a(builder) 中被处理了。

遗憾的是,同样因为混淆的关系,这些 a 类、a 方法全部都关联到了错误的位置。

不过可以从侧面分析还是可以得到一些信息的:

  • 首先这是一个模板函数,函数的出参命名为 <RESULT>,因此猜测【this.f4523c.a( ... )】原本应该是类似于【response = this.APIs.call( request )】的定义
  • f4523c 的对象类型为 com.xxx.oooooooooo.data.api.e.java 类,这个类有不少诸如 ApiResponse、HttpResponse 的处理,佐证了前面的猜想

那么问题的关键再次回到函数【this.f4523c.a( ... )】的入参【new a( ... )】

这个类定义在哪里? 它很可能就是为 Builder 添加 token 和 signature 的关键。

这里其实还有一个隐晦的提示,在 Java 中:

  • 同一个类中若 import 两个同名类,至少其中一个要使用全类路径
  • 同 package 下的非私有类可以无需 import 直接使用

而【new a( ... )】的使用方式极可能就是后者,于是在 d.java 类的相同 package 下找到了 src/main/java/com/xxx/oooooooooo/core/d/a.java 类。

在这个类中发现一些特征极可能就是我们要找的:

  • import 中的 okhttp3 是用于处理 HTTP 请求的
  • 函数【public Response intercept(Chain chain)】中涉及了 POST/GET 请求下关于 token 和 signature 的处理逻辑

B.2.4. 签名算法

分析这块代码可知,signature 一共有三种算法:

  • POST 请求有两种算法
  • GET 请求有一种算法(暂时不关注,下面就不分析了)

针对 POST 方法的 signature 的两种具体算法分析如下:

  • signature = RSA(rsakey, token + params + millis) (暂时不关注,不再详细分析)
  • signature = MD5("params" + params + "DnWX83SksBoctyoVDmEkyEDuzB6i2RUyWY4A9m**********xxxhin31rzDU")

回到最开始的抓包请求:

  • Method: POST
  • URL: /xxx-abService/ab/employee/phone
  • token: 4E07B642-5209-CE11-52AB-3326EE2E0899
  • params: {"phoneNumber":"***********","userName":"******"}
  • signature: da7ba0142b4a8d746147dd386c021725

随便找一个 MD5 计算工具(如 http://www.jsons.cn/md5/s/ ),代入前面的公式计算,若得到相同的 signature,说明成功得到 signature 的签名算法,可以实现中间人攻击。

注:测试发现 signature 必须全小写,大写不能通过验证。

B.2.5. 实现中间人改包

  • 修改 params 为 {"phoneNumber":"00000000000","userName":"******"}
  • 计算 signature 签名值为 993fee1a8f4ca2a54fbc5ec589ede5bd
  • 通过 Burp Suite 发送请求,修改个人信息成功。


文章作者: EXP
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 EXP !
赏
  目录