记一次 Android 客户端(CJYYKT)的逆向
主角:
描述:
- 湖南省教育局推的一款大学生 App,需要每个学生看完里面的一个课程的视频,共 8 章,每章 10 - 23 个视频(连续播放大约 24 小时),每个视频每隔不定时间就会弹出一个选择题答题界面,题目完成后将继续播放该视频。视频进度条只能拖动至该视频已看的最大位置,上面的视频看完后才能继续向下观看。
 
思路:
- 目标,允许 Android 端直接拖动视频进度条至视频末尾;方案,绕开进度条拖动限制代码。
 - 目标,视频默认倍速播放,取消显示题目;方案,修改 App 中应用视频播放器的默认设置。
 - 目标,自动化工具模拟 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" 将其安装在手机上。
打开应用,使用一下,点击一个视频,尝试拖动,会得到如下信息
主要信息,“不能快进更多”,转 Unicode 后用 Android Killer 搜索,结果如下
很幸运,有且只有一处,VideoView$1.smali,是一个内部类,推断是个拖动的监听器之类的,Android Killer 定位到代码位置,向上看代码,寻找代码分支,首先看到的是
但是调用的参数是 videoisfinish ,用这个去判断最大时长,感觉不大对,以 cond_1 作为线索继续向上,
 不难看出来,这是一个或判断,跟到 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(.) 方法
推测是版本不一致,打开 GitHub,现版本为 6.x,打开 JZMediaManager,确实有 setSpeed(.) 方法,定位至 5.x 版本,发现 5.x 版本的时候还叫 JCMediaManager,6.2 版本是一年前的,此时也确实没有调速方法。
发现文档里有一句,基于 MediaPlayer。但是 MediaPlayer 6.0+ 起就提供了调速功能,尝试直接修改播放器代码达到倍速效果,使用 Android Killer 搜索 MediaPlayer,与 JZVideoPlayer 有关的如下
很遗憾,JZMediaPlayer  并没有创建MediaPlayer 对象,惊喜的是 JZMediaSystem 里有 MediaPlayer 对象
在 JZMediaSystem 的 prepare(.) 开始处添加代码倍速播放代码
    invoke-virtual {v0}, Landroid/media/MediaPlayer;->getPlaybackParams()Landroid/media/PlaybackParams;
    move-result-object v1
    const/high16 v2, 0x41200000    # 10.0f
    invoke-virtual {v1, v2}, Landroid/media/PlaybackParams;->setSpeed(F)Landroid/media/PlaybackParams;
    move-result-object v1
    invoke-virtual {v0, v1}, Landroid/media/MediaPlayer;->setPlaybackParams(Landroid/media/PlaybackParams;)V
保存,编译,安装,发现并没有用。用 Android Studio 动态调试一下 Smali 代码,给 prepare() 打上断点,发现该方法根本没有调用
方案二失败。
Burp Suite 抓包,发现接口参数都异常简单,决定自己写一个简单的工具直接模拟 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 aKey   Cookie 键值
     * @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 逆向硬是被自己的几个重大失误搞成了这样,以后一定耐心看完代码再下结论。还有就是要结合抓包和逆向,单搞一个信息不完全
记一次 Android 客户端(CJYYKT)的逆向的更多相关文章
- 接入新浪、腾讯微博和人人网的Android客户端实例 接入新浪、腾讯微博和人人网的Android客户端实例
		
做了个Android项目,需要接入新浪微博,实现时也顺带着研究了下腾讯微博和人人网的Android客户端接入,本文就跟大家分享下三者的Android客户端接入方法. 一.实例概述 说白了,接入微博就是 ...
 - Android客户端和服务器端数据交互
		
网上有很多例子来演示Android客户端和服务器端数据如何实现交互不过这些例子大多比较繁杂,对于初学者来说这是不利的,现在介绍几种代码简单.逻辑清晰的交互例子,本篇博客介绍第四种: 一.服务器端: 代 ...
 - appium 自动化测试之知乎Android客户端
		
appium是一个开源框架,相对来说还不算很稳定.转载请注明出处!!!! 前些日子,配置好了appium测试环境,至于环境怎么搭建,参考:http://www.cnblogs.com/tobecraz ...
 - 仿优酷Android客户端图片左右滑动(自动滑动)
		
最终效果: 页面布局main.xml: <?xml version="1.0" encoding="utf-8"?> <LinearLayou ...
 - 【原创】轻量级即时通讯技术MobileIMSDK:Android客户端开发指南
		
申明:MobileIMSDK 目前为个人维护的原创开源工程,现陆续整理了一些资料,希望对需要的人有用.如需与作者交流,见文章底签名处,互相学习. MobileIMSDK开源工程的代码托管地址请进入 G ...
 - 基于SuperSocket的IIS主动推送消息给android客户端
		
在上一篇文章<基于mina框架的GPS设备与服务器之间的交互>中,提到之前一直使用superwebsocket框架做为IIS和APP通信的媒介,经常出现无法通信的问题,必须一天几次的手动回 ...
 - Android客户端性能优化(魅族资深工程师毫无保留奉献)
		
本文由魅族科技有限公司资深Android开发工程师degao(嵌入式企鹅圈原创团队成员)撰写,是degao在嵌入式企鹅圈发表的第一篇原创文章,毫无保留地总结分享其在领导魅族多个项目开发中的Androi ...
 - 微信Android客户端架构演进之路
		
这是一个典型的Android应用在从小到大的成长过程中的“踩坑”与“填坑”的历史.互联网的变化速度如此之快,1年的时间里,可以发生翻天覆地的变化.今天在这里,重新和大家回顾微信客户端架构的演进过程,以 ...
 - Android 客户端设计之解决方案
		
解决方案,是正对与需求来谈的.一个抽象的需求,需要一个较为上层抽象的解决方案来处理,这是病和药的关系.但是一个解决方案,可能会包含多个功能,每个功能都是解决方案上的一个节点.一个优秀的解决方案必然需要 ...
 
随机推荐
- js中构造函数和普通函数的区别
			
this简介: this永远指向当前正在被执行的函数或方法的owner.例如: 1 2 3 4 5 function test(){ console.log(this); } test(); // ...
 - OC基础数据类型-NSArray
			
1.数组的初始化 NSArray *array = [[NSArray alloc] initWithObjects:@"One", @"Two", @&quo ...
 - 事件总线(Event Bus)
			
事件总线(Event Bus)知多少 源码路径:Github-EventBus简书同步链接 1. 引言 事件总线这个概念对你来说可能很陌生,但提到观察者(发布-订阅)模式,你也许就很熟悉.事件总线是对 ...
 - 如何写Paper
			
如何写文章,如何写好文章,是每一个科研工作者想弄懂或者已经弄懂了的问题.剑桥大学某研究人员分享了他的写作思路. 我从该视频中学到了以下几点经验: 正确的顺序是:Idea——>Write——> ...
 - 【BZOJ3757】苹果树(树上莫队)
			
点此看题面 大致题意: 每次问你树上两点之间路径中有多少种颜色,每次询问可能会将一种颜色\(a\)看成\(b\). 树上莫队 这题是一道树上莫队板子题. 毕竟求区间中有多少种不同的数是莫队算法的经典应 ...
 - tp3.2上一篇下一篇功能
			
1. 后台 //上一页 $map1['a_id'] = array('gt',$a_id); $map1['cate_id'] = array('eq',$cate_id); $front=$arc- ...
 - mac 删除自带 ABC 输入法的方法
			
首先需要关闭 mac 系统的 SIP ,不然删不掉,不会关的可以查看我的另一篇文章:mac 关闭系统完整性保护 SIP(System Integrity Protection)的方法 . 关闭 SIP ...
 - socket相关的开机初始化分析
			
针对内核3.9 系统开启时,会使用init/main.c,然后再里面调用kernel_init(),在里面会再调用do_basic_setup(),调用do_initcalls(),调用do_one_ ...
 - ssm分页
			
pom.xml配置文件中增加相关的插件. <dependency> <groupId>com.github.pagehelper</groupId> <art ...
 - 使用Fiddler做抓包分析
			
转载:http://blog.csdn.net/ohmygirl/article/details/17849983 Fiddler抓取HTTP请求. 抓包是Fiddler的最基本的应用,以本博客为例, ...