記一次 Android 客戶端(CJYYKT)的逆向

主角:java

描述:android

  • 湖南省教育局推的一款大學生 App,須要每一個學生看完裏面的一個課程的視頻,共 8 章,每章 10 - 23 個視頻(連續播放大約 24 小時),每一個視頻每隔不定時間就會彈出一個選擇題答題界面,題目完成後將繼續播放該視頻。視頻進度條只能拖動至該視頻已看的最大位置,上面的視頻看完後才能繼續向下觀看。

思路:shell

  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%

過程:app

先在 Android Killer 中打開該 APK,打開 Manifest,看到 application 標籤裏沒有 android:debuggable="true" 屬性(release 版本默認是沒有的),添加上後保存(Android Killer 不會自動保存),而後編譯,adb install "C:\Program Files\AndroidKiller\projects\CrackMe\Bin\CrackMe_killer.apk" 將其安裝在手機上。ide

打開應用,使用一下,點擊一個視頻,嘗試拖動,會獲得以下信息工具

主要信息,「不能快進更多」,轉 Unicode 後用 Android Killer 搜索,結果以下post

很幸運,有且只有一處,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 逆向硬是被本身的幾個重大失誤搞成了這樣,之後必定耐心看完代碼再下結論。還有就是要結合抓包和逆向,單搞一個信息不徹底

相關文章
相關標籤/搜索