看了下 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 的三個形參,順便改個名字:p1
、p2
、p3
。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"
。然後刪除兩個宣告。
2.2.3. 簡化一下程式碼,越簡單越好懂
不過 constArray["shift"]()
這種寫法看起來很不習慣,最好能改成 constArray.shift()
—— 這就需要藉助一下 ESLint 了。將當前目錄初始化為 npm module 專案,安裝並初始化 eslint,然後在配置裡新增一條規則:
"dot-notation": "error"
這時候 VSCode 會提示
["shift"] is better written in dot notation.
將滑鼠移過去,使用快捷修復自動把所有 []
呼叫改為 .
呼叫。
2.2.4. 分析引數作用
接下就很有意思了,看 localFunc1(++p2, p3)
呼叫,只傳入了兩個引數,所以除了剛才去掉的 lp5
之外,形參 lp3
、lp4
並沒有起到引數的作用,而是當作區域性變數來用的。這裡可以把它們從引數列表中刪除,使用 let
定義為區域性變數 —— 當然,這一步做不做無所謂。
而 p2
和 p3
的值是外部 IIFE 傳入的:
(function (p2, p3) {
...
}(0x1c7, 0x1c700));
乍一看像變數,仔細一看都是 0x
字首,明明就是整數。而且 p3
就是比 p2
後面多綴兩個 0
。
再看 localFunc1
內部第一句話就是 lp2 = lp2 >> 0x8
(記住 lp2
是傳入的 p3
),這不就是把 0x1c700
後面兩個 0
給去掉變成 0x1c7
嗎 —— 現在 lp2
和 p2
的值一樣了。而 lp1
是傳入的 ++p2
,所以在現在 lp1 === lp2 + 1
。
這樣就滿足了 if
條件 (lp2 < lp1)
,這個 if
語句沒用了,可以直接解掉。
2.2.5. 神奇的迴圈
接下來是一個神奇的迴圈,while (--lp1) { }
,中間沒有 break
,也就是說,需要迴圈 0x1c7 + 1
次,也就是 456
次。基本上可以猜測這個迴圈乾的就是沒用的事情,浪費 CPU 而已。
來分析一下是不是:
既然剛才已經說了 lp3
和 lp4
就是區域性變數,不妨再改個名,分別改為 local1
和 local2
,好識別。現在的 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)
第一次執行的時候,lp1
和 lp2
就相等了,進入 if (lp2 === lp1)
分支;此後,都不會再進入這個分支,因為 lp1
一直在減小。
那麼第一次迴圈執行的內容可以寫成:
local2 = constArray.shift(); // toolVersion,即 "jsjiami.com.v6"
lp2 = local2; // "jsjiami.com.v6"
local1 = constArray.pop(); // "jsQHMujiLamiSP.Vcom.lrtZMLzvQ6=="
此後,這個迴圈中再沒有對 lp2
和 local1
賦過值。而此時 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;
通過後面的呼叫來用,應該是個比較有用的函式。為了方便識別,把兩個引數分別更名為 first
和 second
。
我們也把它摘出來拷貝到一個獨立的 .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. 然後是燒腦時刻
接下來的程式碼就是通過一大堆的數學計算,從 initValue
和 constArray[i]
把我們需要的字串恢復出來。演算法肯定是加密工具自己設計的,懶得去分析了。計算都不難,就是燒腦,需要仔細,一點不能出差錯。
4. 結束
是的,結束了,戛然而止。
寫這篇文章的目的並不是要把程式碼完全解出來,只是證明其可能性,同時介紹分析方法和工具應用。第 2 部分寫完就該結束的,因為後面也沒有用到什麼新的方法。
總的來說,jsjiami 向原始程式碼中新增了非常多無用而燒腦的程式來提高解碼的難度,這麼簡單的一句話都解了這麼久,生產程式碼就更不用說了。代價也是有的 —— 真燒 CPU。
好吧,我又幹了一件無聊的事情!