嘗試對 jsjiami 加密結果手工解密

邊城發表於2021-09-27

看了下 jsjiami,簡單的一個 console.log("James"),加密出來的結果居然有 3K,說明這個加密轉了不知道多少彎在裡面。如果要把真正一段業務程式碼拿來手工解密,應該會挺累的,但是本文不研究工作量的問題,只是嘗試一下手工解密,向各位讀者介紹一下分析方法和工具應用。

同一句話在 jsjiami 裡可能會加密出不同的結果,我相信這個工具上加入了隨機因素。但是為了節約篇幅,這裡就不貼我用於試驗的加密結果了。分析過程中會貼一些程式碼段。

1. 第一步,可讀化

毋庸置疑,要想人工識別,首先需要斷句。幸好目前美化(格式化)JS 的工具還是不少,隨便找兩個試下,看哪個效果好。我這裡是用的瀏覽器外掛 FeHelper。

然後注意到,所有變數都改了名字,數字加字母的,怎麼讀都難受。所以需要使用“重新命名”重構工具來改名。這事讓 VSCode 幹毫無壓力。

2. 然後,一點點來分析

2.1. 先看前兩行

var _0xodm = "jsjiami.com.v6",
    _0x47c5 = [_0xodm, "wrvCucKGS1U=", "CGdK", "jsQHMujiLamiSP.Vcom.lrtZMLzvQ6=="];

這一句宣告瞭兩個變數,一個顯然是 jsjiami 的版本版本;另一個是一個陣列,除版本資訊外,內容猜測是 Base64,上網用 Base64 解碼試了一下,解出來亂碼,所以先放著,後面再來看是啥。

為了便於識別,可以 rename 重構一下,順便按規範拆分宣告:

var toolVersion = "jsjiami.com.v6";
var constArray = [toolVersion, "wrvCucKGS1U=", "CGdK", "jsQHMujiLamiSP.Vcom.lrtZMLzvQ6=="];

2.2. 接下來是一個 IIFE

這個 IIFE 的三個形參,順便改個名字:p1p2p3。IIFE 裡定義了一個區域性函式,給它更名為 localFunc1。這個函式定義完之後直接呼叫,查了一下,沒有遞迴,所以相當於又是一個 IIFE。同樣,它的 5 個引數給改個沒啥意義,但是好識別的名字,結果:

(function (p1, p2, p3) {
    var localFunc1 = function (lp1, lp2, lp3, p14, lp5) {
        lp2 = lp2 >> 0x8, lp5 = "po";
        var _0x1e174c = "shift",
            _0x5428fe = "push";
        if (lp2 < lp1) {
            while (--lp1) {
                p14 = p1[_0x1e174c]();
                if (lp2 === lp1) {
                    lp2 = p14;
                    lp3 = p1[lp5 + "p"]();
                } else if (lp2 && lp3["replace"](/[QHMuLSPVlrtZMLzQ=]/g, "") === lp2) {
                    p1[_0x5428fe](p14);
                }
            }
            p1[_0x5428fe](p1[_0x1e174c]());
        }
        return 0xaa95b;
    };
    return localFunc1(++p2, p3) >> p2 ^ p3;
}(constArray, 0x1c7, 0x1c700));

2.2.1. 引數幹掉一個是一個

注意到,外層 IIFE 的 p1 就是上面改名為 constArray 的那個陣列,反正都是作用域內,乾脆一不做二不休,給它換掉:

  • p1 更名為 constArray,跟外面的陣列同名
  • 同時刪除外層 IIFE 的第一個形參和實參

2.2.2. 把繞遠的資料操作改回來

既然已經知道 constArray 是個陣列,作用在上面的所有屬性都應該跟陣列相關。就這幾行 程式碼,觀察一下不難發現:

  • lp5 只參與了一個表示式,結果是 "pop"
  • var _0x1e174c = "shift", _0x5428fe = "push" 兩個變數只是當常量使用的,把 var 改成 const 可以讓編輯器幫忙檢查是否有寫操作 —— 當然結果是沒有。

不過很遺憾,VSCode 沒提供內聯 (inline) 重構工具,所以只能手工操作,把這兩個變數直接替換成常量。以 _0x1e174c = "shift" 為例,先把 "shift"(含引號)複製到剪貼簿中,然後在 _0x1e174c 使用若干次 Ctrl+D 把所有 _0x1e174c 都選中,再 Ctrl+V 即可。如法炮製處理掉 _0x5428fe = "push"。然後刪除兩個宣告。

手工內聯.gif

2.2.3. 簡化一下程式碼,越簡單越好懂

不過 constArray["shift"]() 這種寫法看起來很不習慣,最好能改成 constArray.shift() —— 這就需要藉助一下 ESLint 了。將當前目錄初始化為 npm module 專案,安裝並初始化 eslint,然後在配置裡新增一條規則:

"dot-notation": "error"

這時候 VSCode 會提示

["shift"] is better written in dot notation.

將滑鼠移過去,使用快捷修復自動把所有 [] 呼叫改為 . 呼叫。

更改屬性呼叫方式.gif

2.2.4. 分析引數作用

接下就很有意思了,看 localFunc1(++p2, p3) 呼叫,只傳入了兩個引數,所以除了剛才去掉的 lp5 之外,形參 lp3lp4 並沒有起到引數的作用,而是當作區域性變數來用的。這裡可以把它們從引數列表中刪除,使用 let 定義為區域性變數 —— 當然,這一步做不做無所謂。

p2p3 的值是外部 IIFE 傳入的:

(function (p2, p3) {
   ...
}(0x1c7, 0x1c700));

乍一看像變數,仔細一看都是 0x 字首,明明就是整數。而且 p3 就是比 p2 後面多綴兩個 0

再看 localFunc1 內部第一句話就是 lp2 = lp2 >> 0x8(記住 lp2 是傳入的 p3),這不就是把 0x1c700 後面兩個 0 給去掉變成 0x1c7 嗎 —— 現在 lp2p2 的值一樣了。而 lp1 是傳入的 ++p2,所以在現在 lp1 === lp2 + 1

這樣就滿足了 if 條件 (lp2 < lp1),這個 if 語句沒用了,可以直接解掉。

2.2.5. 神奇的迴圈

接下來是一個神奇的迴圈,while (--lp1) { },中間沒有 break,也就是說,需要迴圈 0x1c7 + 1 次,也就是 456 次。基本上可以猜測這個迴圈乾的就是沒用的事情,浪費 CPU 而已。

來分析一下是不是:

既然剛才已經說了 lp3lp4 就是區域性變數,不妨再改個名,分別改為 local1local2,好識別。現在的 while 迴圈是這樣:

let local1, local2;
while (--lp1) {
    local2 = constArray.shift();
    if (lp2 === lp1) {
        lp2 = local2;
        local1 = constArray.pop();
    } else if (lp2 && local1.replace(/[QHMuLSPVlrtZMLzQ=]/g, "") === lp2) {
        constArray.push(local2);
    }
}

剛才還分析了 lp1 === lp2 + 1,所以 while (--lp1) 第一次執行的時候,lp1lp2 就相等了,進入 if (lp2 === lp1) 分支;此後,都不會再進入這個分支,因為 lp1 一直在減小。

那麼第一次迴圈執行的內容可以寫成:

local2 = constArray.shift();   // toolVersion,即 "jsjiami.com.v6"
lp2 = local2;                  // "jsjiami.com.v6"
local1 = constArray.pop();     // "jsQHMujiLamiSP.Vcom.lrtZMLzvQ6=="

此後,這個迴圈中再沒有對 lp2local1 賦過值。而此時 constArray 的值是

["wrvCucKGS1U=", "CGdK"]    // shift() 和 pop() 操作把頭尾的元素幹掉了

後面的 local1.replace(...) 這句話可以直接拿到控制檯去跑一下,結果讓人哭笑不得,就是 "jsjiami.com.v6"。從這個結果來看,else if (...) 條件除第一次不執行,之後都是 true,也就是說,總是執行,那不就和 else 一樣了嘛。

好嘛,除去第一次迴圈,這個迴圈變成了:

lp1 = 455; // 0x1c7
// 注意,第一次迴圈已經把頭尾兩個元素移出了陣列
constArray = ["wrvCucKGS1U=", "CGdK"];
while (--lp1) {
    local2 = constArray.shift();
    constArray.push(local2);
}

沒別的,就是轉圈,一共轉了 455 - 1 = 454 次!次數如果算不清楚,寫一個迴圈跑一下就知道了:

let a = 455;
let c = 0
while (--a) { c++ };
console.log(c);

local2 之後再沒使用,所以 while 中的兩句話可以合併成一句:

constArray.push(constArray.shift())

這和 while 迴圈之後那一句完全一樣。所以這句話執行的次數一共是 454 + 1,也就是 455 次。由於 constArray 現在有兩個元素,而 455 是奇數,所以跑完之後 constArray 是這樣:

constArray = ["CGdK", "wrvCucKGS1U="];

2.2.6. 都是沒用的程式碼

至此,第一小段程式碼分析完成,除了改變 constArray 沒幹任何有意義的事情。

至於這段程式碼裡的兩句 return,沒半點用,因為外層 IIFE 的返回值直接被丟棄了。所以返回語句裡的位運算,都懶得去算了。

整個這一段程式碼最終變成一句話:

constArray = ["CGdK", "wrvCucKGS1U="];

而且猜測 constArray 其實沒啥用

3. 剩下的程式碼簡單分析下

分析了半天,基本上沒啥有用的程式碼。而且基本上可以斷定,後面的幾十行程式碼也只是在浪費 CPU。

因為我們知道原始碼是 console.log("James")。所以為了加快分析速度,就不再一行行往下讀了,直接從後往前看。一眼就看到了

console[_0x2a10("0", "]o48")](_0x2a10("1", "WCmN"));

反推,_0x2a10("0", "]o48") 的結果就是 "log",而 _0x2a10("1", "WCmN") 的結果就是 "James"

猜測,_0x2a10 就是個拼字串的函式,而第 1 個引數,就是個標記,作分支用。

3.1. 來看 _0x2a10

既然都已經知道 _0x2a10 是拼字串的了,那改名叫 getString 吧。第一個引數是標記,改名為 flag,第二個引數多半是計算用的初始值,就叫 initValue 好了。

其中第一句:flag = ~~"0x".concat(flag);。這句就是把 flag 按 16 進位制轉換成數值型別的值而已。根據實際的呼叫引數,去控制檯跑一下 ~~"0x1"~~"0x2" 就知道了,還可以試驗一下 ~~"0xa"

接下來的 var _0x1fb2c5 = constArray[flag]; 也就好理解了,而且到這裡總算明白了,原來 constArray 是用來提供拼接字串的部分因素的。既然如此,給它更名為 factor

3.2. 接下來是個長長的 if 語句

如果不管這個長長的 if 語句內部那些複雜的邏輯,精簡下來就是:

var getString = function (flag, initValue) {
    if (getString.iOaiiU === undefined) {
        ...
        getString.LaMLHS = _0xbe9954;
        getString.WTsNMX = {};
        getString.iOaiiU = !![];
    }
    ...
}

也就是在第一次執行 getString 的時候對它進行初始化。

其中 .iOaiiU 只有兩處引用,一處判斷,一處賦值 —— 明顯是個初始化標記,可以改名為 initialized。只不過這時候 rename 重構工具似乎不能用,手工更名吧。

3.3. 確保 globalThis 上有 atob()

if 分支內第一段程式碼又是個 IIFE,單獨拷貝出來放到一個獨立的 js 檔案中,VSCode 並沒有提示找不到變數之類的事情。所以這段程式碼是可以獨立執行的。

(function () {
    var _0xea3c63 = typeof window !== "undefined"
        ? window
        : typeof process === "object" && typeof require === "function" && typeof global === "object"
            ? global
            : this;
    var _0x5b626 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
    _0xea3c63.atob || (_0xea3c63.atob = function (_0x1e0fac) {
        var _0x57beec = String(_0x1e0fac).replace(/=+$/, "");
        for (var _0x1f3b8d = 0x0, _0x154b1d, _0xad5277, _0x306ad8 = 0x0, _0xcb4400 = ""; _0xad5277 =
            _0x57beec.charAt(_0x306ad8++); ~_0xad5277 && (_0x154b1d = _0x1f3b8d % 0x4 ?
                _0x154b1d * 0x40 + _0xad5277 : _0xad5277, _0x1f3b8d++ % 0x4) ? _0xcb4400 +=
            String.fromCharCode(0xff & _0x154b1d >> (-0x2 * _0x1f3b8d & 0x6)) : 0x0) {
            _0xad5277 = _0x5b626.indexOf(_0xad5277);
        }
        return _0xcb4400;
    });
}());

第一句很明顯是在找 global 物件,相當於 var _0xea3c63 = globalThis

第二句先忽略,第三句明顯是看 globalThis 上有沒有 atob(),如果沒有就給它一個。既然 atob()在多數環境下都存在,那就不用糾結其內容了。

那麼,這段 IIFE 就是保證 atob() 可用,可以直接刪掉不看。

3.4. 一個看起來比較有用的函式

接下來又定義了一個函式,去掉內容,長這樣:

var _0xbe9954 = function (_0x333549, _0x3c0fbb) {
    ...
};
getString.LaMLHS = _0xbe9954;

通過後面的呼叫來用,應該是個比較有用的函式。為了方便識別,把兩個引數分別更名為 firstsecond

我們也把它摘出來拷貝到一個獨立的 .js 檔案中,發現也沒有缺失變數,說明可以單獨拿出來分析,就是個工具函式。

這個函式一來定義了 5 個變數,先不管,用到的時候再去找。

3.4.1. 用到了 atob

下面的程式碼是:

let _0x2591ef = "";   // 5 個變數中的一個
first = atob(first);
for (var i = 0x0, len = first.length; i < len; i++) {
    _0x2591ef += "%" + ("00" + first.charCodeAt(i).toString(0x10)).slice(-0x2);
}
first = decodeURIComponent(_0x2591ef);

這段程式碼不用仔細看,大概知道是把一個 Base64 轉成 %xx 的形式,而這個形式的字串用 decodeURICompoment() 可以再轉成字串(繞好大一圈)。

回想一下 constArray 的元素,確實長得像 Base64,所以這裡應該是處理那些元素了。

3.4.2. 然後是燒腦時刻

接下來的程式碼就是通過一大堆的數學計算,從 initValueconstArray[i] 把我們需要的字串恢復出來。演算法肯定是加密工具自己設計的,懶得去分析了。計算都不難,就是燒腦,需要仔細,一點不能出差錯。

4. 結束

是的,結束了,戛然而止。

寫這篇文章的目的並不是要把程式碼完全解出來,只是證明其可能性,同時介紹分析方法和工具應用。第 2 部分寫完就該結束的,因為後面也沒有用到什麼新的方法。

總的來說,jsjiami 向原始程式碼中新增了非常多無用而燒腦的程式來提高解碼的難度,這麼簡單的一句話都解了這麼久,生產程式碼就更不用說了。代價也是有的 —— 真燒 CPU。

好吧,我又幹了一件無聊的事情!

相關文章