記半次元App數據解密記錄

最近發現一個有意思的應用,半次元,這個應用中有很多Cosplay美圖,很感興趣便想試試能否通過抓包分析獲取相應的接口,沒想到自己實際上已經跳到了一個大大的深坑之中,一起來看下吧。


萬里長征第一步:抓包分析

本次分析採用Fiddler和Charles皆可,若不會配置,請自行百度相關軟件的使用,另外因爲半次元採用的https的接口,所以這裏必須要先配置CA證書,不太瞭解的同學可以參考:
Charles抓取https
fiddler抓包https
本文以Fiddler爲例,配置完成後在Fiddler界面可以看到頁面請求的https連接了,以Cos周榜爲例:
這裏寫圖片描述
如圖,我們從這次post請求可以看到url,query參數,請求體body以及返回的json數據,完整url如下:
https://api.bcy.net/api/coser/topList?iid=36046028020&device_id=46019894537&ac=wifi&channel=huawei&aid=1250&app_name=banciyuan&version_code=412&version_name=4.1.2&device_platform=android&ssmix=a&device_type=BKL-AL20&device_brand=HONOR&language=zh&os_api=26&os_version=8.0.0&uuid=866953034499460&openudid=56028d53b0cb3095&manifest_version_code=20180605&resolution=1080*2160&dpi=480&update_version_code=412&_rticket=1531992443394
這裏請求體body的key是data,加密內容是:
Lc59D72k2R4YFB0XMOPQRgpNKWGco6f1e86WkOur0ZArCiT+R6VlSvHQYEUtFtTVXrYpx4tE3WZV5vf043AL8XwSxskW592ULRAzrh6oEcMW9FBDzBrB+l9QIGFengtJ

因爲是Post請求,這裏如果直接點擊打開會出現默認的提示,不會返回任何數據,所以我們可以通過Postman等工具進行請求測試,如下:
這裏寫圖片描述

那麼問題來了,data是加密的的,我們需要的是通過動態的生成data來自由根據參數獲取數據,那麼該怎麼做呢?


萬里長征第二步:反編譯

爲了能看到請求體中的data參數到底如何產生的,我們就需要通過反編譯去分析下源碼看看。

首先從官網下載半次元apk的安裝包,這裏地址是https://bcy.net/static/app,這裏直接使用快捷方便的Android killer來完成反編譯邏輯,成功反編譯後如下:
這裏寫圖片描述

很容易的我們找到了班次元的源碼路徑,完整內容是com.banciyuan.bcywebview,由於我們能直接看到的是smali彙編源碼,所以這裏沒太大意義,這裏通過java查看器查看java源碼,考慮到是網絡請求,這裏我們重點尋找http相關的內容,經過搜索查看,我注意到其中一個HttpUtils的文件,如下:
這裏寫圖片描述

如圖紅框部分,可以看出data是在這裏通過Encrypt加密後傳遞給服務器的,所以我們調到Encrypt來看,

  public static String a(String paramString)
  {
    return a(paramString, 0);
  }

在Encrypt有這樣一個方法,我們繼續查看調用

  public static String a(String paramString, int paramInt)
  {
    return a(b(paramString, paramInt));
  }

嗯,繼續看b方法

  private static byte[] b(String paramString, int paramInt)
  {
    Object localObject = getRandomString(paramInt);
    if (localObject != null)
    {
      if (paramInt == 0) {}
      try
      {
        if (((String)localObject).length() != 16) {
          return null;
        }
        localObject = new SecretKeySpec(((String)localObject).getBytes(Charset.defaultCharset()), "AES");
        Cipher localCipher = Cipher.getInstance("AES/ECB/PKCS7Padding", "BC");
        localCipher.init(1, (Key)localObject);
        paramString = localCipher.doFinal(paramString.getBytes("utf-8"));
        return paramString;
      }
      catch (Exception paramString) {}
    }
    return null;
  }

哦,yes,看到這裏應該很明白了,通過Cipher針對data數據進行了AES加密,那麼密鑰是什麼呢?


萬里長征第三步:模擬native調用

我們雖然看到了加密的邏輯,但是通過代碼我們看到密鑰key是通過getRandomString(paramInt)方法獲取的,而這個方法又是一個native方法,代碼裏是這樣定義的

private static native String getRandomString(int paramInt);

ok,既然是native方法,那我們也直接去在項目裏調用打印出這個密鑰的真實數值就行了,於是乎,我查看到這個native方法來自於librandom.so,

  static
  {
    System.loadLibrary("random");
  }

在我自己的項目中引入該so文件,寫一個類似的調用代碼,如下:

public class Encrypt {

    static
    {
        System.loadLibrary("random");
    }

    public void Test(){
        Log.e("Encrypt",getRandomString(0));
    }

    private static native String getRandomString(int paramInt);
}

外部調用:

        new Encrypt().Test();

嗯,執行代碼結果並沒有和想象的一樣打印出來,反而提示找不到getRandomString這個native方法,百思不得其解,經過研究發現,native方法的命名是按照下面格式來的:
Java_com_banciyuan_bcywebview_utils_encrypt_Encrypt_getRandomString
如果你在任意項目去使用getRandomString,就會出現找不到的情況
ok,解決方案就是重新創建一個包名和半次元一致的工程,完整包名是com.banciyuan.bcywebview,再次調用就能成功打印了,結果如下:
這裏寫圖片描述
注:這裏爲了防止被人商業使用,決定隱藏部分密鑰內容

哈哈,到這裏算是拿到了半次元的加密密鑰,很多時候我們都是把密鑰放到常量直接寫在代碼裏,半次元爲了這裏可謂也是用心良苦了。


萬里長征第四步:data數據解密

寫到這裏也算是清楚了半次元的加密過程以及密鑰結果,理論上來講通過這個密鑰結果我們就可以僞造data進行任意的Post請求了,那麼真的ok麼?

如下,我在Android下寫了一塊解密的邏輯:

    private void decryptData() {
        Object localObject = "com_banciyuan_AI";
        String paramString = "Lc59D72k2R4YFB0XMOPQRgpNKWGco6f1e86WkOur0ZArCiT+R6VlSvHQYEUtFtTVXrYpx4tE3WZV5vf043AL8XwSxskW592ULRAzrh6oEcMW9FBDzBrB+l9QIGFengtJ";
        if (((String) localObject).length() != 16) {
            return;
        }
        localObject = new SecretKeySpec(((String) localObject).getBytes(Charset.defaultCharset()), "AES");
        Cipher localCipher = null;
        try {
            localCipher = Cipher.getInstance("AES/ECB/PKCS7Padding", "BC");
            localCipher.init(DECRYPT_MODE, (Key) localObject);
            paramString = new String(localCipher.doFinal(Base64.decode(paramString.getBytes("utf-8"), Base64.DEFAULT)), "UTF-8");
            Log.e("Main", paramString);
        } catch (NoSuchAlgorithmException e) {
            e.printStackTrace();
        } catch (NoSuchProviderException e) {
            e.printStackTrace();
        } catch (NoSuchPaddingException e) {
            e.printStackTrace();
        } catch (BadPaddingException e) {
            e.printStackTrace();
        } catch (IllegalBlockSizeException e) {
            e.printStackTrace();
        } catch (InvalidKeyException e) {
            e.printStackTrace();
        } catch (UnsupportedEncodingException e) {
            e.printStackTrace();
        }
    }

那麼,paramString打印出來到底是什麼呢

{“date”:“20180719”,“grid_type”:“timeline”,“token”:“4b98b48cb5b20e72”,“p”:“1”,“type”:“week”}

吼吼吼,這些正是Post的核心數據了,同樣的,藉助於在線的加解密網站也可以看到,比如http://tool.chacuo.net/cryptaes,解密後是這樣的:

這裏寫圖片描述

結果是一樣的,美滋滋,最後我們可能需要僞造data數據去請求api以達到某些不可告人的祕密==


萬里長征第五步:嘗試僞造body中的data

這裏使用java的話應該比較簡單,按照解密逆向處理就可以了,具體方法就是將DECRYPT_MODE修改爲ENCRYPT_MODE,然後Base64在外層,內層仍然使用AES進行加密即可,具體可自行測試,這裏我重點講下通過Python來實現。
我們藉助於Crytodome實現AES加解密,首先導入以下模塊:

import base64
from Cryptodome.Cipher import AES 

# str不是16的倍數那就補足爲16的倍數
def add_to_16(text):
    while len(text) % 16 != 0:
        text += '5'
    return str.encode(text)  # 返回bytes

def generate_encrypt_data():
    key = 'com_banxxxx' #隱藏部分密鑰內容
    dict_data = {"date": "20180719", "grid_type": "timeline", "token": "4b98b48cb5b20e72", "p": "1", "type": "week"}
    data_json = json.dumps(dict_data).replace(' ', '')  # 移除多餘的空格
    print(data_json)
    cipher = AES.new(add_to_16(key), AES.MODE_ECB)
    encrypted_text = str(base64.encodebytes(cipher.encrypt(add_to_16(data_json))), encoding='utf8')  # 加密
    print(encrypted_text)

generate_encrypt_data()

這裏寫圖片描述

對比我們通過fiddler抓取到的data數據,如下:

Lc59D72k2R4YFB0XMOPQRgpNKWGco6f1e86WkOur0ZArCiT+R6VlSvHQYEUtFtTVXrYpx4tE3WZV5vf043AL8XwSxskW592ULRAzrh6oEcMW9FBDzBrB+l9QIGFengtJ
啊,什麼鬼,後面幾位不一樣是什麼鬼,不應該啊,去網頁上把自己生成的解密看一下看看,
這裏寫圖片描述
很ok啊,完全沒問題,怎麼編碼後會不一樣呢?

可能的遠因:

  1. dict產生的data原數據和實際原數據不一樣
  2. 填充問題,爲了保證爲16位的倍數,這裏使用**‘\0’**作爲填充

首先來看看第一個我分別放上兩次的dict轉換出的數據和Android編譯產生的數據:

{“date”:“20180719”,“grid_type”:“timeline”,“token”:“4b98b48cb5b20e72”,“p”:“1”,“type”:“week”}
{“date”:“20180719”,“grid_type”:“timeline”,“token”:“4b98b48cb5b20e72”,“p”:“1”,“type”:“week”}

這,一致的讓我無言一隊啊,完全對的上啊,再看看填充的問題,我首先查看了反編譯的源碼,並沒有找到明確的填充邏輯,於是考慮通過全部的asiil碼生成對應的加密數據在和正確的進行對比看看
經過測試,全部asciil碼無一生成一致的加密數據(放棄)

經過審查代碼發現,生成Cilper的邏輯是這樣的

Cipher.getInstance("AES/ECB/PKCS7Padding", "BC")

這裏採用的是PKCS7Padding的方式,但是在py中並沒有哪裏可以指定設置這個,那是如何保持一致呢?關於PKCS7Padding可以參考關於PKCS5Padding與PKCS7Padding的區別

這裏我突然產生了新的靈感,如果填充不一致,那麼兩個原數據應該也是不一致的,但是肉眼看起來完全是一樣的啊,即使通過文本對比也是一樣,那麼怎麼去檢查呢?
將兩處分別產生的原數據轉化爲list,然後進行打印,進行字符級別的對比,新代碼如下

def generate_encrypt_data():
    key = 'com_banciyuan_AI'
    dict_data = {"date": "20180719", "grid_type": "timeline", "token": "4b98b48cb5b20e72", "p": "1", "type": "week"}
    data_json = json.dumps(dict_data).replace(' ', '')  # 移除多餘的空格
    print(len(data_json))
    print(list(data_json))
    cipher = AES.new(add_to_16(key), AES.MODE_ECB)
    encrypted_text = str(base64.encodebytes(cipher.encrypt(add_to_16(data_json))), encoding='utf8')  # 加密
    # print(encrypted_text)
    encrypted_text = "Lc59D72k2R4YFB0XMOPQRgpNKWGco6f1e86WkOur0ZArCiT+R6VlSvHQYEUtFtTVXrYpx4tE3WZV5vf043AL8XwSxskW592ULRAzrh6oEcMW9FBDzBrB+l9QIGFengtJ" #正確的加密數據
    text_decrypted = str(cipher.decrypt(base64.decodebytes(bytes(encrypted_text, encoding='utf8'))).rstrip(b'\0').decode("utf8"))  # 解密
    print(len(text_decrypted))
    print(list(text_decrypted))

結果:

91(dict生成的)
[’{’, ‘"’, ‘d’, ‘a’, ‘t’, ‘e’, ‘"’, ‘:’, ‘"’, ‘2’, ‘0’, ‘1’, ‘8’, ‘0’, ‘7’, ‘1’, ‘9’, ‘"’, ‘,’, ‘"’, ‘g’, ‘r’, ‘i’, ‘d’, ‘_’, ‘t’, ‘y’, ‘p’, ‘e’, ‘"’, ‘:’, ‘"’, ‘t’, ‘i’, ‘m’, ‘e’, ‘l’, ‘i’, ‘n’, ‘e’, ‘"’, ‘,’, ‘"’, ‘t’, ‘o’, ‘k’, ‘e’, ‘n’, ‘"’, ‘:’, ‘"’, ‘4’, ‘b’, ‘9’, ‘8’, ‘b’, ‘4’, ‘8’, ‘c’, ‘b’, ‘5’, ‘b’, ‘2’, ‘0’, ‘e’, ‘7’, ‘2’, ‘"’, ‘,’, ‘"’, ‘p’, ‘"’, ‘:’, ‘"’, ‘1’, ‘"’, ‘,’, ‘"’, ‘t’, ‘y’, ‘p’, ‘e’, ‘"’, ‘:’, ‘"’, ‘w’, ‘e’, ‘e’, ‘k’, ‘"’, ‘}’]

96(java解密出來的)
[’{’, ‘"’, ‘d’, ‘a’, ‘t’, ‘e’, ‘"’, ‘:’, ‘"’, ‘2’, ‘0’, ‘1’, ‘8’, ‘0’, ‘7’, ‘1’, ‘9’, ‘"’, ‘,’, ‘"’, ‘g’, ‘r’, ‘i’, ‘d’, ‘_’, ‘t’, ‘y’, ‘p’, ‘e’, ‘"’, ‘:’, ‘"’, ‘t’, ‘i’, ‘m’, ‘e’, ‘l’, ‘i’, ‘n’, ‘e’, ‘"’, ‘,’, ‘"’, ‘t’, ‘o’, ‘k’, ‘e’, ‘n’, ‘"’, ‘:’, ‘"’, ‘4’, ‘b’, ‘9’, ‘8’, ‘b’, ‘4’, ‘8’, ‘c’, ‘b’, ‘5’, ‘b’, ‘2’, ‘0’, ‘e’, ‘7’, ‘2’, ‘"’, ‘,’, ‘"’, ‘p’, ‘"’, ‘:’, ‘"’, ‘1’, ‘"’, ‘,’, ‘"’, ‘t’, ‘y’, ‘p’, ‘e’, ‘"’, ‘:’, ‘"’, ‘w’, ‘e’, ‘e’, ‘k’, ‘"’, ‘}’, ‘\x05’, ‘\x05’, ‘\x05’, ‘\x05’, ‘\x05’]

對比發現,首先長度是不一致的,後面的多了五個**’\x05’,吼吼吼,問題就在這裏了,填充在Android端半次元使用這個二進制字符來完成,我們再修改add_to_16將填充改成’\x05’**,修改後如下:

def generate_encrypt_data():
    key = 'com_banciyuan_AI'
    dict_data = {"date": "20180719", "grid_type": "timeline", "token": "4b98b48cb5b20e72", "p": "1", "type": "week"}
    data_json = json.dumps(dict_data).replace(' ', '')  # 移除多餘的空格
    print(data_json+"\n")
    cipher = AES.new(add_to_16(key), AES.MODE_ECB)
    encrypted_text = str(base64.encodebytes(cipher.encrypt(add_to_16(data_json))), encoding='utf8')  # 加密
    print(encrypted_text)
    text_decrypted = str(cipher.decrypt(base64.decodebytes(bytes(encrypted_text, encoding='utf8'))).rstrip(b'\x05').decode("utf8"))  # 解密
    print(text_decrypted)

執行代碼:

這裏寫圖片描述

好,到這裏本文算是徹底完結了,其實這是很主流的移動端加密傳輸方式了,Https+AES+Native密鑰存儲,可以看到半次元在這塊做的還是很充分的,如有疑問,請在評論指出。


參考博客鏈接:
Android Native方法找不到的問題
在線加解密
python3.6執行AES加密及解密方法

鄭重聲明:本文僅用於學習交流,禁止用於任何商業用途

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章