练习
条项 | 说明 |
---|---|
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 发送请求,修改个人信息成功。