爬蟲之-某生鮮APP加密引數逆向分析

王平發表於2018-02-20

本文是跟我學習爬蟲的小夥伴:彭良懷的投稿,稿費是500。本文寫得非常好,完全可以當著APP逆向抓取的教程來學。從逆向思路的分析,逆向工具的搭配使用,到逆向知識結構的掌握,顯示出了他紮實的爬蟲逆向基礎功底。

 PS:他在北京,有看上的老闆可以私信我,為人也不錯。

一、前言

學了一段時間 APP 逆向,剛剛入門,我以某生鮮 APP 為例,記錄一下逆向過程和一些知識點。為了不影響對方的利益,我文中特意隱去了該APP的名字資訊,本文僅供學習交流,請勿用作其他用途。
使用到的工具如下:
  • 一部 root 後的安卓手機,模擬器也可以
  • 抓包工具:Charles
  • 查殼工具:APK Messenger
  • APK反編譯工具:jadx-gui 1.1
  • SO檔案分析工具:IDA_Pro_v7.0
  • Hook 框架:frida

二、抓包分析

首先手機配置好代理,開啟APP,用Charles抓一下包,還好直接就抓到了,如下圖所示:
 
爬蟲之-某生鮮APP加密引數逆向分析
可以看到很多的請求引數,翻頁再抓包一次,把兩次抓到的引數進行比對,看看哪些引數固定,哪些是變化的。這麼多引數,要是自己用肉眼看,那就太費勁了,而且還容易看漏,所以直接用線上文字對比工具吧,我用的是這個網站:https://qqe2.com/word/diff,把兩次抓包的引數複製上去,如下圖所示:
 
爬蟲之-某生鮮APP加密引數逆向分析
不同的引數都高亮出來了,一目瞭然。我簡單分析如下:
  • signKey:密文,長度 32 位,可能為 MD5、HmacMD5 加密或隨機 UUID
  • signKeyV1:密文,長度 64 位,可能為 SHA256、HmacSHA256 加密
  • t :13 位時間戳
  • traceId:等於 deviceId (固定的裝置ID)加兩個13位時間戳
  • currentPage:頁碼
  • lastStoreId:上一頁最後一家店鋪 ID
其他固定引數很好理解,我就不闡述了。通過模擬請求驗證,修改任意引數的值都無法獲取資料,所以推測 signKey 和 signKeyV1 是由其他請求引數加密生成的。那接下來就去看看 java 程式碼吧。
 

三、Java層分析

 

1. 查殼

在反編譯 apk 之前,首先查下殼,因為加殼(加固)後的 apk 直接反編譯是看不到有用資訊的。查殼工具很多,這裡我使用的是 APK Messenger,開啟後,直接將 apk 包拖入介面,即可看到有沒有加殼,如下圖所示:
 
爬蟲之-某生鮮APP加密引數逆向分析
結果疑似無殼,接下來就可以使用 jadx 反編譯 apk了。需要注意的是,查殼功能的實現往往只是遍歷 APK 內檔案和目錄,以加固廠商(騰訊、360、阿里、百度、梆梆等)的常用檔名作為判斷特徵,比如百度的加固一般在 lib 目錄下有一個 libbaiduprotest.so 檔案,但有可能人家使用了新的名字,所以查殼有一定的誤判率。你還可以在 Apk Messenger 中檢視、增加或編輯加固的判別特徵。
 

2. 分析關鍵 Java 程式碼

用 jadx 開啟 apk ,反編譯為 java 程式碼,然後按 Ctrl + Shift + F 全域性搜尋 signKeyV1,直接可以定位到如下程式碼:
 
爬蟲之-某生鮮APP加密引數逆向分析
這段程式碼很好理解,明顯是在組裝引數,可以從中得出以下資訊:
  • t 為當前時間戳;
  • subVersion 為當前APP版本號;
  • signKey 是由 k 方法生成的;
  • signKeyV1 等於 KEY_NEW_SIGN, KEY_NEW_SIGN 又是由 k2 方法生成的;
  • 傳入方法 k2 的引數為 formatQueryParaMap 方法的返回值;
  • 方法 k 和 k2 都在 native 層,載入的是 libjdpdj.so 檔案;

3. 分析 formatQueryParaMap 方法

k 和 k2 都在 native 層,我們還是先看看 formatQueryParaMap 方法吧,按住 Ctrl 鍵同時滑鼠左鍵點選 formatQueryParaMap 即可跳轉到該方法,如下圖所示:
 
爬蟲之-某生鮮APP加密引數逆向分析
這段程式碼也好理解,傳入該方法的第一個引數為 Map 型別,類似 Python 中的字典,它先根據 key 進行排序,然後再把 value 用 & 字元進行拼接(functionId 的值除外 ),用 Python 程式碼實現如下:
 
def formatQueryParaMap(param: dict) -> str:
    return '&'.join(param[k] for k in sorted(param.keys()) if k != 'functionId')

4. Hook formatQueryParaMap 方法

如果看不懂或不想分析 formatQueryParaMap() 也沒關係,我們直接用 frida hook 一下這個方法,看看它的輸入和輸出,也能反向推測出這個方法是做什麼的,hook 程式碼如下:
 
Java.perform(function () {
    var util = Java.use('jd.net.ASCIISortUtil');
    util.formatQueryParaMap.implementation = function (arg1, arg2) {
        console.log('param1: ', arg1);
        console.log('param2: ', arg2);
        var result = this.formatQueryParaMap(arg1, arg2);
        console.log('return: ', result);
        return result;
    };})
列印結果如下:
 

爬蟲之-某生鮮APP加密引數逆向分析

很明顯,param1 就是最開始抓包到的那些請求引數,那麼我們就知道了方法 k2 的輸入引數要怎麼構造了,接下來分析方法 k 、 k2 是怎麼加密的,就不得不分析 .so 檔案了。
 

5. 關於反除錯

這裡提一下,該 APP 有反除錯,開啟 frida-server 後 ,啟動 APP 就立即閃退,可別急著去破解它的反除錯,即找到反除錯的地方幹掉後重新打包簽名,可這樣做就很麻煩了,不知道得掉多少頭髮。還好,先啟動 APP 等進入主介面後再啟動 frida-server,就能正常進行 hook了,雖然偶爾還是會被強制閃退,但頻率不高,影響不大。
 

四、Native層分析

通過 Java 層的分析知道 ,signKey 和 signKeyV1 分別是方法 k 和 k2生成的,而這兩個方法又是定義在 native 層的,那麼就得先找到 k、k2 在 native 層中對應的函式,然後再分析具體的加密過程。為了便於理解,我先講知識點,再講操作。
 

1. 靜態註冊和動態註冊

因為 java 層和 native 層的程式碼往往相互呼叫,使用的是一種叫 JNI (Java Native Interface) 的技術,在 java 層中呼叫 native 函式之前, 要對 java 中 native 關鍵字定義的方法進行註冊,註冊方式有兩種:靜態註冊和動態註冊。下面簡單介紹一下:
  • 靜態註冊:
    靜態註冊是通過固定格式方法名進行關聯,命名規則如下:
    native 函式名 = Java + 包名 + 類名 + 方法名
    例如,包名: com.example.test,類名:jd.net.z,方法名:k
    如果是靜態註冊的話,那麼 native 中的函式名就該為:Java_com_example_test_jd_net_z_k

     

  • 動態註冊:
    動態註冊是通過 RegisterNative() 這個 JNI 函式動態新增對映關係來進行關聯的,這種方式可以隨便命名函式名,比較靈活。其申明示例如下:
    
    
  • jint RegisterNatives(JNIEnv *env, jclass clazz, const JNINativeMethod* methods, jint nMethods)
     
    第 1 個引數是 JNIEnv 指標,所有 JNI 函式第一個引數都是它;
    第 2 個引數 clazz 是註冊方法對應 Java 層中的類,由 FindClass 函式獲取;
    第 3 個引數 methods 是一個陣列,其中包含了註冊方法結構體資訊,我們可以從中找到註冊前後的方法名,所以我們注意這個引數就行了;
    第 4 個引數 nMethods 是動態註冊方法的數量。

     

2. 找到 k、k2 對應的 native 函式

知道了 native 函式的兩種註冊方式,那就開始具體的操作吧。用 IDA 開啟 libjdpdj.so 檔案,切換到 Exports 視窗,我們先按照靜態註冊的命名規則搜尋:Java,並沒有搜到,那麼便是動態註冊了。
 
因為 JNI_OnLoad() 是載入 so檔案的初始函式,可以從中找到 RegisterNative()。那麼搜尋 JNI_OnLoad ,雙擊進入,按 F5 把彙編轉成偽 C 程式碼,你會發現並沒有找到 RegisterNative,別急,這是因為 IDA 不能準確的識別函式宣告或變數型別,反編譯不完全正確造成的,但我們可手動將其還原。
 
凡是看到類似 (*(_DWORD *)v2 + 860))(v2, …) 這種程式碼的其實都是 JNI 函式,我們選中引數 v2 後按 Y 鍵會彈出視窗,輸入JNIEnv * ,點選 OK 即可還原函式名,還原後如下所示:
 
爬蟲之-某生鮮APP加密引數逆向分析
根據前面的介紹,我們只需要看第 3 個引數即可,雙擊 &off_117004 跳轉到如下彙編程式碼:
 
爬蟲之-某生鮮APP加密引數逆向分析
從 117004 偏移量那一行開始,每 3 行為一個結構體,一共 8 個。我們看第一個,其第一行右邊的註釋 “k” 就是 java 層的方法名,第二行為 JNI 欄位描述符,描述了該方法的引數型別和返回值型別,第三行就是我們要找的動態註冊後的函式名,可以看到為:gk;同樣的,”k2″ 對應的就是:gk2。
搜搜看,這就很容易找到了:
 
爬蟲之-某生鮮APP加密引數逆向分析
不過有些 APP 為了防止被靜態分析,對註冊函式做了混淆,通過這種方式並不能直接找到,這裡我就不討論了,遇到的童鞋可以參考趙四這篇部落格:http://www.520monkey.com/archives/1289
 

3. JNI 靜態除錯的一些技巧

在分析 gk 函式之前我先談談靜態分析 native 函式的一些技巧和個人經驗。
 
(1) 批量還原 JNI 函式名
native 函式中經常會用到很多的 JNI 函式,而 IDA 並不能很好的識別,每次我們都要一個個手動修改未免太麻煩了點,所以我介紹一個可以批量轉換的方式:
  • 按 Ctrl + F9 ,選擇 jni.h 標頭檔案匯入
  • 匯入成功後,滑鼠左鍵點選其中一個 JNI 函式的引數,然後右鍵選擇 Convert to Struct *
  • 在彈出的 Select a structure 視窗中 選擇 _JNIEnv,點選OK
這樣就可以把當前開啟的 native 函式裡面所有 JNI 函式名一次性還原了。注意 jni.h 標頭檔案第一匯入會報錯,需要根據報錯資訊修改 jni.h 對應的程式碼。
 
(2) 強制調出函式引數
有時會遇到 IDA 反編譯出來的函式連引數都沒有,如下面的 GetArrayLength 函式後面的引數為空:
 
爬蟲之-某生鮮APP加密引數逆向分析
這時需要滑鼠左鍵點選該函式,然後滑鼠右鍵選擇 Force call type ,就能強制把引數調出來。
 
(3) 常用快捷鍵
  • shift + F12:檢視so檔案中所有常量字串的值;
  • tab鍵:彙編和偽 C 程式碼之間相互切換;
  • / 鍵:新增註釋;
  • N 鍵:變數重新命名;
  • X 鍵:檢視某變數的所有引用;
  • = 鍵:消除冗餘的中間變數;
    由於 IDA 反編譯出來總是會有很多冗餘的中間變數,如:
    v2 = v1;
    result = encrypt(v2);
    
    
    選中 v2,按鍵盤上的 = 鍵,再點選 OK,即可消除中間變數 v2:
    result = encrypt(v1);
    
    
(4) 靜態除錯思路
  • 根據函式入參,至上而下分析
  • 根據函式返回值,至下而上分析
  • 尋找關鍵的函式進行分析,一般可以把函式分為以下幾種:
    ① 標準庫函式:如 strlen(),計算字串的長度,見名知意;
    ② JNI 函式:如 FindClass(),呼叫 Java中的類,JNI 函式一般也是見名知意;
    ③ 使用者自定義的函式:如 MD5::MD5(),一看就知道是 MD5 加密,這類需特別注意;
    ④ IDA命名的函式:如 sub_567C(),IDA 會對沒有名字的函式自動命名,命名規則就是 sub_ + 函式地址,這類函式也是重點。
    從追求效率的角度來說,最好先找關鍵函式,看看有沒有常見的加密函式名,找到後直接用frida hook,一些簡單的往往能夠一擊中的,快速破解。從學技術的角度來說,可以多嘗試一行一行程式碼地分析,鍛鍊看程式碼的能力。當然複雜點的還不得不分析 arm 指令,要是被混淆後就更加難了,難的我也不會,以後多練多學吧。

4. 靜態分析 gk 函式

接下來開始具體操作吧,雙擊 gk 函式後看到彙編 arm 指令,按 F5 鍵反彙編為偽 C 程式碼,並把 JNI 函式名還原。我這裡就不一一分析每行程式碼了,直接先找關鍵函式,很容易就找到如下程式碼:
 
爬蟲之-某生鮮APP加密引數逆向分析
很明顯是 MD5 加密,MD5Init() 是一個初始化函式,MD5Update() 才是 MD5 的主計算過程,所以直接 hook MD5Update() ,用 frida hook native 層函式得需要找到目標函式的絕對地址,而目標函式可能是匯出函式,也可能是未匯出函式,我先分別介紹一下怎麼獲取他們的地址吧:
獲取匯出函式的絕對地址:
// JNI_OnLoad 肯定是匯出函式,可直接根據名字獲取
var onload_addr = Module.getExportByName('libjdpdj.so', 'JNI_OnLoad');
獲取未匯出函式的絕對地址,我列舉以下 3 種方式:
  • 方式一:
    
    
  • // 獲取JNI_OnLoad的地址:
    var onload_addr = Module.getExportByName('libjdpdj.so', 'JNI_OnLoad');
    // 基地址 = JNI_OnLoad地址 - JNI_OnLoad偏移:
    var base_addr = parseInt(onload_addr ) - parseInt('0x34D6C');
    // MD5Update地址 = 基地址 + MD5Update偏移:
    var md5_update_addr = ptr(base_addr + parseInt('0x34E18'));
  • 方式二:
  • var onload_addr = Module.getExportByName('libjdpdj.so', 'JNI_OnLoad');
    var md5_update_addr = onload_addr.sub(0x34D6C).add(0x34E18);
  • 方式三:
  • var md5_update_addr = Module.findBaseAddress("libjdpdj.so").add(0x34E18 + 1);
    方式一看註釋很好理解,方式二其實就是方式一的簡化,用 frida 提供的的 add() 和 sub() 函式進行地址的加減。方式三是進一步簡化,但是用這種方式一定要記得對地址 +1,為什麼要 +1 呢?我引用趙四的原話解釋吧:
    因為thumb和arm指令的區分,地址最後一位的奇偶性來進行標誌
    獲取未匯出函式地址的方式也完全適用於匯出函式,所以不管匯出還是未匯出,我都用方式三獲取,程式碼簡單優雅。

     

那麼我們 hook MD5Update() 的程式碼如下:
var pointer = Module.findBaseAddress("libjdpdj.so").add(0x34E18 + 1);
console.log('MD5Update pointer:', pointer);
Interceptor.attach(pointer, {
    onEnter: function(args) {
        console.log('引數1:', args[0]);
        console.log('引數2:', Memory.readCString(args[1]));
  // Memory.readCString()就是讀取地址為字串
        console.log('引數3:', parseInt(args[2]));
        console.log('----------------');    },
    onLeave: function(retval) {    }
})
hook 的時候我們同時對其抓包,以便驗證,hook 列印的結果如下:
MD5Update pointer: 0xaed5ae19
引數1: 0xbef0eb8c
引數2: {"city":"重慶市","latitude":29.57252,"longitude":106.53355,"address":"觀音橋",
"coordType":"2","channelId":"4037","appVersion":"7.4.0","platform":"2","currentPage":1,
"pageSize":10,"areaCode":4,"ref":"home","ctp":"channel"}923047ae3f8d11d8b19aeb9f3d1bc002
引數3: 259
—————-
可以看到引數2 為部分請求引數再上加尾部的鹽值,這便是加密前的原文。我們把它拿去用 MD5 線上加密一下,其結果和抓包到的 signKey 進行對比,經驗證完全相同,那麼 signKey 被一擊中的,具體的程式碼都不用去分析了。其實伺服器並沒有對該引數進行校驗,我們直接生成一個隨機的 32 位字元就行,我這裡主要是講一下方法。
 

5. 靜態分析 gk2 函式

然後再來看 gk2 函式,同樣首先找有沒有常見的加密,很快在最後幾行看到如下程式碼:
 
爬蟲之-某生鮮APP加密引數逆向分析
很明顯是 hmac_sha256 加密,看到它有 6 個引數,往上追溯可知,第 1 個引數 s 為加密前的字串,第 2 個引數 v23 為 s 的長度,這裡 v23 – 32 說明加密前需要去掉最後 32 個字元,第 3 個引數為金鑰,第 4 個引數是金鑰的長度,最後兩個引數沒有什麼操作,不用管。那麼我們就直接用 frida hook hmac_sha256 函式,列印一下引數看看,程式碼如下 :
var pointer = Module.findBaseAddress("libjdpdj.so").add(0x361B8 + 1);
console.log("hmac_sha256 pointer: ", pointer);
Interceptor.attach(pointer, {
    onEnter: function(args) {
        console.log("引數1:", Memory.readUtf8String(args[0]));
        console.log("引數2:", parseInt(args[1]));
        console.log("引數3:", Memory.readCString(args[2]));
        console.log("引數4:", parseInt(args[3]));
        console.log('---------------');
    },
    onLeave:function(retval){    }
});
hook 的時候我們同時對其抓包,以便驗證,hook 列印的結果如下:
 
爬蟲之-某生鮮APP加密引數逆向分析
引數 1 去掉末尾的 32 位字元就是入參,引數 3 是金鑰,於是把入參拿去用 HmacSHA256 加密一下,其結果再和抓包到 signKeyV1 進行對比,經驗證完全相同,由此 signKeyV1 也被一擊中的。
抱著學習的心態再去分析一下偽 C 程式碼,具體分析過程我就不介紹了,就說一下大致的邏輯:
  • 先呼叫 java 層的 getsign 方法獲取基礎 key,
  • 對基礎 key 每個字元的 ASCII 碼進行修改,同時拼接到輸入引數的尾部,
  • 取出入參尾部的 32 位作為金鑰,
  • 最後對輸入引數進行 hmac_sha256 加密,通過指標返回加密結果。
逆向到這兒就結束了,後面用 python 實現不難,我就不貼程式碼了,關鍵過程講清楚了就行。
 

6. native 函式的引數

我再囉嗦一下,native 函式要比 java 層對應方法多 2 個引數,它們前兩個引數是固定的,第 1 個引數為 JNIEnv 指標;第 2 個為 jobject 或 jclass;從第 3 個引數開始才是 java 層傳遞過來的。比如:gk() 函式的申明如下:
 
爬蟲之-某生鮮APP加密引數逆向分析
其中 a3 才是 java 層 k() 方法的引數。前兩個引數之所以是 int 型別,前面也說過,是因為 IDA 經常不能正確識別引數型別,這裡按 Y 鍵手動轉換一下,或者直接忽略,沒什麼影響。
 

五、總結

本篇文章的案例 APP 也是大廠開發的,而我們對其 java 層和 native 層的加密函式分析都不難,沒有複雜難懂的邏輯,也沒有混淆,只有個雞肋的反除錯,直接靜態分析加 frida hook 就搞定了。其實目前市面上大多數 APP 的加密引數都能通過這種方式搞定,當然很難的也不少,學習逆向是個無底洞,但我們做爬蟲的不要怕逆向,我們只是逆向它的那個加密引數而已,先要有信心,多學習多實操多總結,一點點深入,會學有所成。共勉!
 
再次跨一下這篇文章,非常不錯,繼續接受投稿,稿費還不錯300-500/篇,快來投稿吧。
 
PS,給自己廣告一下:我繼續在教爬蟲,真正的爬蟲技術。教APP逆向抓取/JS逆向抓取/大規模爬蟲框架設計/利用爬蟲技術做被動收入。
感興趣的加我微信私聊,備註:爬蟲。
最近打算建一個爬蟲技術交流群,感興趣的也可以加我。

爬蟲之-某生鮮APP加密引數逆向分析

 

猿人學banner宣傳圖

我的公眾號:猿人學 Python 上會分享更多心得體會,敬請關注。

***版權申明:若沒有特殊說明,文章皆是猿人學 yuanrenxue.com 原創,沒有猿人學授權,請勿以任何形式轉載。***

相關文章