记一次 Android 客户端的逆向

仰天大笑出门去,我辈岂是蓬蒿人。这篇文章主要讲述记一次 Android 客户端的逆向相关的知识,希望能为你提供帮助。
主角:

记一次 Android 客户端的逆向

文章图片

描述:
  • 湖南省教育局推的一款大学生 App,需要每个学生看完里面的一个课程的视频,共 8 章,每章 10 - 23 个视频(连续播放大约 24 小时),每个视频每隔不定时间就会弹出一个选择题答题界面,题目完成后将继续播放该视频。视频进度条只能拖动至该视频已看的最大位置,上面的视频看完后才能继续向下观看。
思路:
  1. 目标,允许 android 端直接拖动视频进度条至视频末尾;方案,绕开进度条拖动限制代码。
  2. 目标,视频默认倍速播放,取消显示题目;方案,修改 App 中应用视频播放器的默认设置。
  3. 目标,自动化工具模拟 Android 端操作;方案,抓取接口并调用。
工具:
  • Android Killer v1.3.1.0
  • JEB v2.2.7.201608151620,
  • Android Studio v3.1.2
  • Burp Suite v1.7.37
  • Intellij IDEA v2018.1
  • 有 Root 权限的手机或 ARM 架构的模拟器
其他:
为了方便截手机的图,写了一个下面的 bat 脚本
@echo off set file_name=/sdcard/sc_%RANDOM%.png adb shell screencap -p %file_name% adb pull %file_name% . adb shell rm %file_name%

过程:
先在 Android Killer 中打开该 APK,打开 Manifest,看到 application 标签里没有 android:debuggable="true" 属性(release 版本默认是没有的),添加上后保存(Android Killer 不会自动保存),然后编译,adb install "C:\\Program Files\\AndroidKiller\\projects\\CrackMe\\Bin\\CrackMe_killer.apk" 将其安装在手机上。
打开应用,使用一下,点击一个视频,尝试拖动,会得到如下信息
记一次 Android 客户端的逆向

文章图片

主要信息,“不能快进更多”,转 Unicode 后用 Android Killer 搜索,结果如下
记一次 Android 客户端的逆向

文章图片

很幸运,有且只有一处,VideoView$1.smali,是一个内部类,推断是个拖动的监听器之类的,Android Killer 定位到代码位置,向上看代码,寻找代码分支,首先看到的是
记一次 Android 客户端的逆向

文章图片

但是调用的参数是 videoisfinish ,用这个去判断最大时长,感觉不大对,以 cond_1 作为线索继续向上,
记一次 Android 客户端的逆向

文章图片
不难看出来,这是一个或判断,跟到 cond_1 后发现其调用了 invoke-virtual {p1, v0, v1}, Lcn/jzvd/JZMediaInterface; -> seekTo(J)V 是进度调整代码无疑了,随便更改或判断的一处即可,这里把 if-le p1, v0, :cond_1 改为 goto :cond_1
Android Killer 保存,编译,重新安装。
OK,可以拖动了,但是发现课程拖动完后外面的课程总进度并没有变(这可能是犯的第一个大错,已经没有办法再验证了,进度条之所以没变是因为课程数目多,两节课太短所以并没有计入),看来不仅在这里做了校验,打开 JEB,Bytecode/Hierarchay,定位到 VideoView,Decompile。该类扩展自 JZVideoPlayerStandard,双击查看源码,包 cn.jzvd,像是国人做的库,Google 之,果然,GitHub 上的开源项目。速览一遍 VideoView(后来证明在这里犯了第二个大错,主要验证代码以及服务器同步应该都是在这里)没有什么发现,大致认为是对父类方法的重写(有一点可以证明我在这里的推测是错误的,上面对于进制跳转的代码是在这里做的验证)。
类名上 x 查看交叉引用,除了自身就指向 VideoPlayerActivity,现在把主要精力集中在 VideoPlayerActivity,通读代码,其使用 SharedPreference 获取与保存一些数据,网络相关的都是些无关紧要的操作,并没有进度同步的代码,这让我很不解,唯一的一处不知道内部做了什么的就只有 jcVideoPlayerStandard 这个 VideoView 对象(所以为什么不进去看看?)。
第一个思路进行不下去了。
下来看一看 jiaozivideoplayer 的 ReadMe,发现其提供了倍速播放的功能,在于 JZMediaManager.setSpeed(.) 方法,打开 JEB 查看方法列表,发现 JZMediaManager 并没有 setSpeed(.) 方法
记一次 Android 客户端的逆向

文章图片

推测是版本不一致,打开 GitHub,现版本为 6.x,打开 JZMediaManager,确实有 setSpeed(.) 方法,定位至 5.x 版本,发现 5.x 版本的时候还叫 JCMediaManager,6.2 版本是一年前的,此时也确实没有调速方法。
发现文档里有一句,基于 MediaPlayer。但是 MediaPlayer 6.0+ 起就提供了调速功能,尝试直接修改播放器代码达到倍速效果,使用 Android Killer 搜索 MediaPlayer,与 JZVideoPlayer 有关的如下
记一次 Android 客户端的逆向

文章图片

很遗憾,JZMediaPlayer并没有创建MediaPlayer 对象,惊喜的是 JZMediaSystem 里有 MediaPlayer 对象
记一次 Android 客户端的逆向

文章图片

在 JZMediaSystem 的 prepare(.) 开始处添加代码倍速播放代码
invoke-virtual {v0}, Landroid/media/MediaPlayer; -> getPlaybackParams()Landroid/media/PlaybackParams; move-result-object v1const/high16 v2, 0x41200000# 10.0finvoke-virtual {v1, v2}, Landroid/media/PlaybackParams; -> setSpeed(F)Landroid/media/PlaybackParams; move-result-object v1invoke-virtual {v0, v1}, Landroid/media/MediaPlayer; -> setPlaybackParams(Landroid/media/PlaybackParams; )V

保存,编译,安装,发现并没有用。用 Android Studio 动态调试一下 Smali 代码,给 prepare() 打上断点,发现该方法根本没有调用
方案二失败。
Burp Suite 抓包,发现接口参数都异常简单,决定自己写一个简单的工具直接模拟 Android 端调用服务器接口,这个方法比较简单。
【记一次 Android 客户端的逆向】需要格外注意这里的请求头,个人不喜欢用 OkHttp,所以自己的封装请求类如下,主要是模拟请求头的添加(失败重试机制没有放在这里是一个很大的失误)
package com.seliote.crackcjyykt.network; import com.seliote.crackcjyykt.exception.SetCookieParseException; import com.seliote.crackcjyykt.util.Constants; import com.seliote.crackcjyykt.util.HttpUtils; import com.seliote.crackcjyykt.util.Pair; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.zip.GZIPInputStream; /** * Package: com.seliote.crackcjyykt.network * FileName: CjyyktHttpUtils * Describe: CJYYKT 专用 Http 请求工具,自动设置请求头与 Cookie * Author: seliote * Email: seliote@hotmail.com * Date: 2018/12/30 14:48 */ @SuppressWarnings("WeakerAccess") public class CjyyktHttpUtils {private final Map< String, String> COOKIE_MAP; public CjyyktHttpUtils() { COOKIE_MAP = new LinkedHashMap< > (); }/** * 获取已存储的所有 Cookie * @return 已存储的所有 Cookie */ @SuppressWarnings("unused") public Map< String, String> getCookies() { return COOKIE_MAP; }/** * 以请求头中字符串形式返回所有 Cookie 的字符串表示 * @return 请求头中字符串形式返回所有 Cookie 的字符串表示 */ public String getCookieString() { StringBuilder stringBuilder = new StringBuilder(); for (Map.Entry< String, String> entry : COOKIE_MAP.entrySet()) { //noinspection StringConcatenationInsideStringBufferAppend stringBuilder.append(entry.getKey() + "=" + entry.getValue() + "; "); } if (stringBuilder.length() > 1) { return stringBuilder.substring(0, stringBuilder.length() -1); } return stringBuilder.toString(); }/** * 添加 Cookie * * @param aKeyCookie 键值 * @param aValue Cookie 值 */ public void addCookie(@NotNull String aKey, @NotNull String aValue) { if (aValue.equals("") || aValue.equals("\\"\\"")) { deleteCookie(aKey); return; }COOKIE_MAP.put(aKey, aValue); }/** * 删除 Cookie * * @param aKey 要删除的 Cookie 名 * @return 已删除的 Cookie 的值,如果不存在,返回 null */ @SuppressWarnings({"UnusedReturnValue"}) @Nullable public String deleteCookie(@NotNull String aKey) { return COOKIE_MAP.remove(aKey); }/** * 模拟 CJYYKT Android 端 Get 方式请求 * * @param aUrl 请求的 URL * @return 服务器的返回值 * @throws IOException 连接或读取异常 */ public String get(@NotNull String aUrl) throws IOException {Pair< Map< String, List< String> > , byte[]> pair = HttpUtils.get( aUrl, 10000, 10000, generateHeader("GET") ); handleHeader(pair.getFirst()); return new String(unGzip(pair.getSecond()), StandardCharsets.UTF_8); }/** * 模拟 CJYYKT Android 端 Post 方式请求 * * @param aUrl请求的 URL * @param aPostBody 请求体 * @return 服务器的返回值字符串形式 * @throws IOException 连接或读取异常 */ public String post(@NotNull String aUrl, @Nullable String aPostBody) throws IOException { Pair< Map< String, List< String> > , byte[]> pair = HttpUtils.post( aUrl, 10000, 10000, generateHeader("POST"), aPostBody, StandardCharsets.UTF_8 ); handleHeader(pair.getFirst()); return new String(unGzip(pair.getSecond()), StandardCharsets.UTF_8); }public String post(@NotNull String aUrl, @Nullable Map< String, String> aPostBodyMap) throws IOException { return post(aUrl, mapToPostBody(aPostBodyMap)); }/** * Map 生成 key1=value1& key2=value2 类似形式的字符串,转换完成后清空参数 Map * @param aPostBody 需要格式化的参数 Map * @return 格式化后的字符串 */ public String mapToPostBody(Map< String, String> aPostBody) { StringBuilder result = new StringBuilder(); for (Map.Entry< String, String> entry : aPostBody.entrySet()) { result.append(entry.getKey()); result.append("="); result.append(entry.getValue()); result.append("& "); }// 可千万别放下面了 aPostBody.clear(); if (result.length() > 1) { // substring(..) 返回的是一个 String... return result.substring(0, result.length() - 1); }return result.toString(); }/** * 生成请求头 * * @param aMethod POST 或 GET * @return 生成的请求头 */ public Map< String, String> generateHeader(String aMethod) { // 这个值和 Cookie 里的 SESSION 相等,但是不知道他为什么要单独写一个请求头? String headerSession = COOKIE_MAP.getOrDefault(Constants.Api.Header.SESSION, ""); String headerCookie = Constants.Api.Header.COOKIE_JLXCKID + "=" + COOKIE_MAP.getOrDefault(Constants.Api.Header.COOKIE_JLXCKID, "") + "; SESSION=" + COOKIE_MAP.getOrDefault(Constants.Api.Header.COOKIE_SESSION, ""); Map< String, String> map = new LinkedHashMap< > (); map.put(Constants.Api.Header.SESSION, headerSession); map.put(Constants.Api.Header.COOKIE, headerCookie); map.put(Constants.Api.Header.IP, Constants.Api.Header.IP_VALUE); map.put(Constants.Api.Header.VERSION, Constants.Api.Header.VERSION_VALUE); map.put(Constants.Api.Header.CONNECTION, Constants.Api.Header.CONNECTION_VALUE); // OkHttp 自动设置 Content-Encoding: GZIP 并解压返回值,现在需要自己去解压返回值 map.put(Constants.Api.Header.ACCEPT_ENCODING, Constants.Api.Header.ACCEPT_ENCODING_VALUE); map.put(Constants.Api.Header.USER_AGENT, Constants.Api.Header.USER_AGENT_VALUE); // POST 请求的话需要多一个 Content-Type 请求头 if (aMethod.equalsIgnoreCase("POST")) { map.put(Constants.Api.Header.CONTENT_TYPE, Constants.Api.Header.CONTENT_TYPE_VALUE); } return map; }/** * 处理请求头,读取并添加 Cookie * * @param aHeader HttpUtils.get(...) 或 HttpUtils.post(...) 返回的 Pair 的请求头部分 */ @SuppressWarnings("UnnecessaryContinue") public void handleHeader(Map< String, List< String> > aHeader) { // 没有直接取出 Set-Cookie List,因为之后可能还要做其他处理 for (Map.Entry< String, List< String> > entry : aHeader.entrySet()) { // HTTP 响应的第一行响应头信息被存在了 null => ${value} 中 if (entry.getKey() == null) { continue; } else if (entry.getKey().equalsIgnoreCase("Set-Cookie")) { String regex = "^([^=]*)=([^; ]*).*$"; Pattern pattern = Pattern.compile(regex); for (String setCookie : entry.getValue()) { Matcher matcher = pattern.matcher(setCookie.trim()); if (matcher.matches()) { addCookie(matcher.group(1).trim(), matcher.group(2).trim()); } else { throw new SetCookieParseException("Set-Cookie 匹配出错:" + setCookie); } } } } }/** * 解压 GZIP 压缩的数据 * * @param aBytes 需要解压的 byte[] * @return 解压后的 byte[] * @throws IOException 读取出错时抛出 */ public byte[] unGzip(byte[] aBytes) throws IOException { // 如果是在抓包,BurpSuite 会自动解压缩 Gzip 流 if (Constants.Config.DEBUG) { return aBytes; }ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(aBytes); GZIPInputStream gzipInputStream = new GZIPInputStream(byteArrayInputStream); byte[] buffer = new byte[1024]; int length; while ((length = gzipInputStream.read(buffer)) != -1) { byteArrayOutputStream.write(buffer, 0, length); }byte[] result = byteArrayOutputStream.toByteArray(); byteArrayOutputStream.close(); byteArrayInputStream.close(); gzipInputStream.close(); return result; } }

当然这也是需要抓包的,启动类还需要加上这个才行
if (Constants.Config.DEBUG) { System.setProperty("http.proxyHost", "127.0.0.1"); System.setProperty("https.proxyHost", "127.0.0.1"); System.setProperty("http.proxyPort", "8888"); System.setProperty("https.proxyPort", "8888"); }

然后就是获取数据循环调用接口,除了发现接口命名混乱外基本没有什么其他问题了,实际测试也是正常的,可以刷课了。
总结:
本来简简单单的 Android 逆向硬是被自己的几个重大失误搞成了这样,以后一定耐心看完代码再下结论。还有就是要结合抓包和逆向,单搞一个信息不完全

    推荐阅读