已經(jīng)很久沒有寫JS逆向相關(guān)的文章了串前,距離上一篇JS逆向文章的發(fā)布時(shí)間已經(jīng)過了大半年了荡碾,之前把紅薯中文網(wǎng)網(wǎng)頁版的反爬講完之后就說過有機(jī)會(huì)把紅薯中文網(wǎng)手機(jī)版隱式Style-CSS反爬給大家分析一下,今天我就把這篇久違的文章給大家奉上劳殖。
目標(biāo)分析
跟網(wǎng)頁版網(wǎng)站一樣哆姻,紅薯中文網(wǎng)手機(jī)版網(wǎng)站的反爬也主要在小說的正文內(nèi)容矛缨,只不過反爬的技術(shù)種類不一樣,我們隨便找一本小說然后進(jìn)行分析灵妨,值得我們注意的是手機(jī)版和網(wǎng)頁版網(wǎng)站一樣依舊對(duì)小說的正文頁禁用了鼠標(biāo)右鍵泌霍,要檢查網(wǎng)頁元素(Ctrl+Shift+I
)或者查看網(wǎng)頁源代碼(Ctrl+U
)我們可以使用Chrome瀏覽器的快捷鍵:
::before
呀酸,CSS類名是context_kw18
性誉,鼠標(biāo)點(diǎn)擊::before
我們可以在后邊的Styles選項(xiàng)卡中看到相關(guān)的CSS信息错览,可以發(fā)現(xiàn)剛才我們選擇的那個(gè)沒有渲染的字居然出現(xiàn)在CSS樣式的content屬性中倾哺,我們還可以發(fā)現(xiàn)這個(gè)CSS的文件地址并不是一個(gè)CSS路徑地址,而是<style>
忌愚,這說明這個(gè)CSS是動(dòng)態(tài)生成的硕糊,肯定是通過JS的DOM操作生成的简十。
我們還可以發(fā)現(xiàn)其他沒有渲染的字也是這樣,不過它們的CSS類名不一樣恢恼,但是CSS類名都是context_kw
開頭场斑,只是后面接不一樣的數(shù)字編號(hào)和簸,context_kw18
對(duì)應(yīng)的是"天"
字以及context_kw10
對(duì)應(yīng)的是"之"
字等碟刺。
我們?cè)俨榭匆幌戮W(wǎng)頁源代碼:
<span class="context_kw18"></span>
里并沒有什么東西,其實(shí)在前面看到CSS樣式中的::before
的時(shí)候吴菠,如果CSS樣式學(xué)得好的話就知道這是CSS的隱式做葵,隱式Style-CSS也是目前比較流行的一種反爬手段酿矢。
隱式Style–CSS
先來說說什么是隱式Style–CSS
:
-
CSS中瘫筐,::before 創(chuàng)建一個(gè)偽元素,其將成為匹配選中的元素的第一個(gè)子元素肛捍。常通過content屬性來為一個(gè)元素添加修飾性的內(nèi)容拙毫。
上面的這段話對(duì)于沒做過前端開發(fā)的朋友而言缀蹄,看著可能會(huì)有點(diǎn)難懂,沒關(guān)系袍患,我用一個(gè)例子簡(jiǎn)單地演示一下诡延。
我們新建一個(gè) HTML 文件輸入下面這樣的內(nèi)容:
<span>歡迎大家來到我的簡(jiǎn)書肆良,我是成長(zhǎng)之路丶</span>惹恃,<span>今天我們要說的是紅薯中文網(wǎng)隱式style-CSS反爬</span>
并在這個(gè) HTML 中引用下面這個(gè)CSS樣式文件:
span::before {
content: "“";
color: blue;
}
span::after {
context: "”";
color: red;
}
最后在瀏覽器中展示的內(nèi)容是這樣的:可以看到在上面的例子里巫糙,我在HTML源碼里隱藏了文字前后的符號(hào)参淹,但是經(jīng)過瀏覽器渲染后浙值,文字前后的符號(hào)就出現(xiàn)了开呐,是不是很神奇?目前很多網(wǎng)站都使用了類似這樣的反爬蟲技術(shù)卵惦,用來保護(hù)自己的內(nèi)容不被爬蟲爬取鸵荠。
逆向過程
既然我們知道了紅薯中文網(wǎng)手機(jī)版網(wǎng)站的小說正文內(nèi)容是隱式Style-CSS反爬
蛹找,并且CSS是通過JS的DOM操作動(dòng)態(tài)生成的庸疾,那么我們就需要逆向分析它是如何通過JS把一些文字放到CSS樣式中然后動(dòng)態(tài)生成該CSS当编。
找逆向入口
因?yàn)镴S動(dòng)態(tài)渲染的CSS類名相似,只是CSS類名后面的數(shù)字編號(hào)不一樣臊泌,所以我們可以在Chrome瀏覽器調(diào)試面板全局搜索".context_kw"
:
".context_kw"
:document
字眼我們還是可以看出DOM操作的痕跡儡循,它是通過循環(huán)把循環(huán)的i
變量拼接".context_kw"這樣通過DOM操作之后就會(huì)得到我們?cè)陧撁嫔峡吹降?code>".context_kw10"等加上了數(shù)字編號(hào)的CSS類名择膝,混淆代碼后面還加上了words[i]
這個(gè)變量检激,看名字叔收、代碼的位置以及代碼邏輯饺律,我們可以推測(cè)這個(gè)words[i]
很有可能是每個(gè)隱式Style-CSS
里content
屬性中的文字,也就是我們要逆向獲取的內(nèi)容乒省,我們可以在Console面板中把它輸出一下:words
這個(gè)變量確實(shí)是我們要的數(shù)據(jù)袖扛,并且我們發(fā)現(xiàn)words
是一個(gè)數(shù)組攻锰,數(shù)組的索引都能跟渲染后的CSS類名編號(hào)對(duì)上妓雾,比如:數(shù)組第10個(gè)是"天"
字械姻,頁面上".context_kw10"
對(duì)應(yīng)的也是"天"
字楷拳,所以現(xiàn)在我們要找到words是怎么生成的欢揖,繼續(xù)在該文件全局搜索words
:words
生成的地方我們可以發(fā)現(xiàn)她混,它是定義一個(gè)數(shù)組坤按,并且涉及到了secwords
這個(gè)變量以及_0xa5c1
這個(gè)變量臭脓,'0x18'
是十六進(jìn)制轉(zhuǎn)換成十進(jìn)制是24来累,有圖可以看到secwords
是通過CryptoJS這個(gè)庫decrypted
(解密)得到
嘹锁,我們先找到_0xa5c1
變量然后逐步分析:
_0xa5c1
變量:_0xa5c1
變量以及十六進(jìn)制索引后然后還原一下關(guān)鍵的代碼:
原代碼:
var data = 'oJ3emFyc2SlOa4rTzCDYmjWmNjE8moH9tMXjvt0bFSa3TPymTswvxwRG65UthgN1IMjSK9TI81tBckTSfMh0zB24WvumsfvuiULCzO1DTOc/vWmvBHJG8BztW3X7lbB7KOrUzlbvtjGQKBkRRYkvDxva7PaCKQrbJk454/9/zkslehlXnUl+SGWXesXWkTVE';
var keywords = CryptoJS['enc'][_0x1a5c('0xb')][_0x1a5c('0xc')](_0x1a5c('0xd'));
var decrypted = CryptoJS[_0x1a5c('0x13')][_0x1a5c('0x14')](data, keywords, {
'iv': iv,
'padding': CryptoJS[_0x1a5c('0x0')][_0x1a5c('0x15')]
});
var secWords = decrypted['toString'](CryptoJS[_0x1a5c('0x16')][_0x1a5c('0x17')])['split'](',');
var words = new Array(secWords[_0x1a5c('0x18')]);
還原之后代碼:
var data = 'oJ3emFyc2SlOa4rTzCDYmjWmNjE8moH9tMXjvt0bFSa3TPymTswvxwRG65UthgN1IMjSK9TI81tBckTSfMh0zB24WvumsfvuiULCzO1DTOc/vWmvBHJG8BztW3X7lbB7KOrUzlbvtjGQKBkRRYkvDxva7PaCKQrbJk454/9/zkslehlXnUl+SGWXesXWkTVE';
var keywords = CryptoJS.enc.Latin1.parse.("DC3A49D549646237");
var decrypted = CryptoJS.AES.decrypt(data, keywords, {
'iv': iv,
'padding': CryptoJS.pad.ZeroPadding
});
var secWords = decrypted.toString(CryptoJS.enc.Utf8).spilt(',');
var words = new Array(secWords.length);
可以發(fā)現(xiàn)words
初始定義為一個(gè)secWords
長(zhǎng)度的數(shù)組求冷,secWords
是decrypted
解密然后按照','
切割出來的數(shù)組匠题,decrypted
是AES解密
韭山,解密需要data
冷溃、keywords
似枕,iv
等
AES加密解密
AES
是一種加密方式凿歼,它有多種加密模式: ECB
答憔、CBC
虐拓、 OFB
等,它加密解密需要key
另凌,model
(也就是加密模式,如:CBC
)诗茎,iv
(偏移向量)敢订,在JS中通過CryptoJS
庫可以實(shí)現(xiàn)加密解密楚午,舉個(gè)列子:
//這是http://www.hongweipeng.com/index.php/archives/1936/頁面中的一個(gè)AES解密JS
function crypto_decode(encode_text) {
let decode = CryptoJS.AES.decrypt(encode_text, CryptoJS.enc.Utf8.parse('lXMdrvEz90yXdVo7'), {
iv: CryptoJS.enc.Utf8.parse('DkebZOLIhUKizj2L'),
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7
}).toString(CryptoJS.enc.Utf8);
return decode;
}
現(xiàn)在有好多網(wǎng)站在前端都使用了AES加密解密矾柜。
Python也有AES的加密解密庫pycryptodome
(這個(gè)庫不止封裝了AES加密的相關(guān)還有其他加密算法怪蔑,具體自己去看文檔)缆瓣,根據(jù)AES加密結(jié)果輸出密文形式不同弓坞,它的加密解密稍微有些差異渡冻,比如加密輸出hash密文
和Base64密文
的加密解密方法就有點(diǎn)不一樣:
CBC模式hash密文的加密解密:
from Crypto.Cipher import AES
from binascii import b2a_hex, a2b_hex
class AesCrypt(object):
def __init__(self, key):
self.key = key.encode('utf-8')
self.model = AES.MODE_CBC
# 加密方法
def encrypt(self, text):
text = text.encode('utf-8')
# key菩帝,model呼奢,iv
cryptor = AES.new(self.key, self.model, b'DkebZOLIhUKizj2L')
lenght = 16
count = len(text)
if count < lenght:
add = (lenght - count)
text = text + ('\0' * add).encode('utf-8')
elif count > lenght:
add = (lenght - (count % lenght))
text = text + ('\0' * add).encode('utf-8')
self.ciphertext = cryptor.encrypt(text)
return b2a_hex(self.ciphertext)
# 解密方法
def decrpt(self, text):
# key握础,model,iv
cryptor = AES.new(self.key, self.model, b'DkebZOLIhUKizj2L')
plain_text = cryptor.decrypt(a2b_hex(text))
return bytes.decode(plain_text).rstrip('\0')
CBC模式Base64密文的加密解密:
import base64
from Crypto.Cipher import AES
from binascii import b2a_hex, a2b_hex
class AesCrypt(object):
def __init__(self, key):
self.key = key.encode('utf-8')
self.model = AES.MODE_CBC
# 加密方法
def encrypt(self, text):
text = text.encode('utf-8')
# key禀综,model简烘,iv
cryptor = AES.new(self.key, self.model, b'DkebZOLIhUKizj2L')
lenght = 16
count = len(text)
if count < lenght:
add = (lenght - count)
text = text + ('\0' * add).encode('utf-8')
elif count > lenght:
add = (lenght - (count % lenght))
text = text + ('\0' * add).encode('utf-8')
self.ciphertext = cryptor.encrypt(text)
# 將加密后的數(shù)據(jù)base64編碼返回base64格式數(shù)據(jù)
return base64.b64encode(self.ciphertext)
# 解密方法
def decrypt(self, text):
# key,model定枷,iv
cryptor = AES.new(self.key, self.model, b'146385F634C9CB00')
# 將密文base64解碼
decryptBytes = base64.b64decode(text)
plain_text = cryptor.decrypt(decryptBytes)
return bytes.decode(plain_text).rstrip('\0')
進(jìn)一步分析
既然我們知道secwords
是AES逆向解密
孤澎,并且我們了解了AES的基礎(chǔ)原理,那么我們就需要在JS中找到AES密文
欠窒、key
覆旭、model
以及iv
岖妄。
- 找
iv
型将,全局搜索iv
找到相關(guān)代碼。-
iv
原始混淆代碼
-
var iv = '';
try {
if (top[_0x1a5c('0xe')][_0x1a5c('0xf')][_0x1a5c('0x10')] != window[_0x1a5c('0xf')][_0x1a5c('0x10')]) {
top[_0x1a5c('0xe')][_0x1a5c('0xf')][_0x1a5c('0x10')] = window[_0x1a5c('0xf')][_0x1a5c('0x10')];
}
iv = CryptoJS['enc'][_0x1a5c('0xb')][_0x1a5c('0xc')](_0x1a5c('0x11'));
} catch (_0x249434) {
iv = CryptoJS['enc'][_0x1a5c('0xb')][_0x1a5c('0xc')](_0x1a5c('0x12'));
}
-
iv
還原后的代碼
var iv = '';
try {
if (top.window.location.href != window.location.href) {
top.window.location.href = window.location.href;
}
iv = CryptoJS.enc.Latin1.parse("A61BFB423D6C6EB8");
} catch (_0x249434) {
iv = CryptoJS.enc.Latin1.parse("146385F634C9CB00");
}
所以iv
就是在_0x1a5c
數(shù)組變量的第11個(gè)索引數(shù)據(jù)荐虐,也就是"A61BFB423D6C6EB8"
- 找
model
七兜,你把加密的CryptoJS代碼看一遍,你會(huì)發(fā)現(xiàn)這個(gè)AES加密用的model
是CBC
(搜索出現(xiàn)了CBC
字眼)福扬,并且發(fā)現(xiàn)它使用的Base64
密文加密解密 - 找
key
腕铸,其實(shí)key
就是上面的keywords
變量"DC3A49D549646237"
(分析方法在上面惜犀,其實(shí)iv
、key
恬惯、密文
都是一樣的分析方法) - AES密文向拆,其實(shí)密文就是上面的
data變量
的值"oJ3emFyc2SlOa4rTzCDYmjWmNjE8moH9tMXjvt0bFSa3TPymTswvxwRG65UthgN1IMjSK9TI81tBckTSfMh0zB24WvumsfvuiULCzO1DTOc/vWmvBHJG8BztW3X7lbB7KOrUzlbvtjGQKBkRRYkvDxva7PaCKQrbJk454/9/zkslehlXnUl+SGWXesXWkTVE"
找齊這寫需要的值之后就可以解密得到secword
:
import base64
from Crypto.Cipher import AES
from binascii import b2a_hex, a2b_hex
class AesCrypt(object):
def __init__(self, key):
self.key = key.encode('utf-8')
self.model = AES.MODE_CBC
# 加密方法
def encrypt(self, text):
text = text.encode('utf-8')
# key,model酪耳,iv
cryptor = AES.new(self.key, self.model, b'A61BFB423D6C6EB8')
lenght = 16
count = len(text)
if count < lenght:
add = (lenght - count)
text = text + ('\0' * add).encode('utf-8')
elif count > lenght:
add = (lenght - (count % lenght))
text = text + ('\0' * add).encode('utf-8')
self.ciphertext = cryptor.encrypt(text)
# 將加密后的數(shù)據(jù)base64編碼返回base64格式數(shù)據(jù)
return base64.b64encode(self.ciphertext)
# 解密方法
def decrypt(self, text):
# key浓恳,model,iv
cryptor = AES.new(self.key, self.model, b'A61BFB423D6C6EB8')
# 將密文base64解碼
decryptBytes = base64.b64decode(text)
plain_text = cryptor.decrypt(decryptBytes)
return bytes.decode(plain_text).rstrip('\0')
if __name__ == '__main__':
# 傳入key
pc = AesCrypt("DC3A49D549646237")
# 傳入需要解密的密文
d = pc.decrypt('oJ3emFyc2SlOa4rTzCDYmjWmNjE8moH9tMXjvt0bFSa3TPymTswvxwRG65UthgN1IMjSK9TI81tBckTSfMh0zB24WvumsfvuiULCzO1DTOc/vWmvBHJG8BztW3X7lbB7KOrUzlbvtjGQKBkRRYkvDxva7PaCKQrbJk454/9/zkslehlXnUl+SGWXesXWkTVE')
print(d)
解密出來的結(jié)果如下:
"65291, 30339, 65282, 26160, 26471, 21074, 19967, 19982, 36826, 20101, 20044, 8222, 8219, 22660, 23477, 20181, 20203, 22311, 22826, 20009, 20461"
可以發(fā)現(xiàn)是一組數(shù)字?jǐn)?shù)據(jù)碗暗,但是這些數(shù)據(jù)是一個(gè)整體颈将,是一個(gè)字符串,我們?cè)诳刂婆_(tái)輸出一下secword
對(duì)比一下我們解密的結(jié)果:
var words = new Array(secWords.length);
就說明words
是一個(gè)長(zhǎng)度為21的空數(shù)組晴圾,那么JS是如何把words
和那些字產(chǎn)生關(guān)系的呢?
我們接著在跟words
相關(guān)的代碼噪奄,全局搜索words[i]
(搜索words[i]
不搜索words
是因?yàn)?code>words搜出的結(jié)果太多死姚,并且words
是跟i
有關(guān)系):
words[i]
,我們分析這個(gè)循環(huán)(這里就全部還原這個(gè)循環(huán)了勤篮,有空可以自己還原一下都毒,這里只還原部分關(guān)鍵代碼):
- 定義一個(gè)循環(huán),
i
起始為0碰缔,i<secwords.lenght; i++
账劲,也就是說i<21
后停止循環(huán) - 定義了一個(gè)變量
_0x475a5f
(這個(gè)一個(gè)混淆后的代碼,其實(shí)你為了好讀把它叫a變量也是可以)金抡,它的值是var _0x475a5f = '0|4|1|3|5|2'.split('|')
瀑焦,也就是這個(gè)字符串按照"|"
切割,所以_0x475a5f
的值其實(shí)等于[0,4,1,3,5,2]
梗肝,還定義一個(gè)_0xbddd40
變量初始值為0
- 再定義一個(gè)
whie循環(huán)
榛瓮,因?yàn)闂l件"!![]"
一直為true
,所以這是一個(gè)死循環(huán) - 定義一個(gè)
swith循環(huán)
巫击,循環(huán)的條件是_0x475a5f[_0xbddd40++]
禀晓,這也說明switch會(huì)依次執(zhí)行case0、case4喘鸟、case1、case3驻右、case5什黑、case2
- 分別看每個(gè)case都做什么:
- case0:將
secwords
的第i個(gè)值賦值給一個(gè)變量_0x2e1b2c
- case1:定義一個(gè)變量把一個(gè)函數(shù)賦值給這個(gè)變量,這個(gè)函數(shù)里有定義了三個(gè)函數(shù)堪夭,經(jīng)過還原其實(shí)可以發(fā)現(xiàn)就是傳遞兩個(gè)數(shù)字到函數(shù)愕把,一個(gè)是
secwords
的第i
個(gè)值拣凹,另一個(gè)是3
,返回兩個(gè)數(shù)相加 - case2:調(diào)用
Sting.fromCharCode()
方法恨豁,然后傳遞數(shù)字嚣镜,這個(gè)方法就是將Unicode
編碼轉(zhuǎn)為一個(gè)字符 ,如:var n = String.fromCharCode(65);
結(jié)果是:A
- case3:將變量
_0x2e1b2c
重新賦值橘蜜,調(diào)用_0x15d2ab
然后把_0x2e1b2c
傳遞給這個(gè)方法菊匿,_0x15d2ab
這個(gè)方法其實(shí)在case4 - case4:case4跟case1差不多,只不過它的返回值是一個(gè)三元運(yùn)算符计福,如果
_0x2e1b2c
是偶數(shù)
就返回這個(gè)數(shù)減2
跌捆,奇數(shù)
就返回這個(gè)數(shù)減4
- case5:將變量
_0x2e1b2c
重新賦值,調(diào)用_0x5bb6ca
然后把_0x2e1b2c
傳遞給這個(gè)方法象颖,_0x15d2ab
這個(gè)方法其實(shí)在case1
- case0:將
- 明白了每個(gè)case在做什么佩厚,然后按照case順序執(zhí)行,就能得出結(jié)論:把
secword
的各項(xiàng)如果是偶數(shù)減一
说订,奇數(shù)加一
然后使用fromCharCode
方法將數(shù)字轉(zhuǎn)換成字符串
這樣就能得到我們要的數(shù)據(jù)抄瓦,比如"天"
字倒數(shù)第三個(gè),secwords
倒數(shù)第三個(gè)數(shù)字是" 22826"
陶冷,是偶數(shù)
钙姊,減一
然后使用fromCharCode
方法將數(shù)字轉(zhuǎn)換成字符串,結(jié)果為"天"
總結(jié)
通過上面的分析我們可以得出結(jié)論:
- 紅薯中文網(wǎng)手機(jī)版網(wǎng)站是隱式Style-CSS反爬埃叭,反爬的CSS是通過JS的DOM操作動(dòng)態(tài)生成的
- 它操作DOM的JS代碼進(jìn)行了混淆摸恍,隱式Style-CSS中的content屬性中的值是AES解密后數(shù)據(jù)
偶數(shù)減一,奇數(shù)加一然后將數(shù)字Unicode 轉(zhuǎn)換成字符串
注意:
我嘗試過很多小說赤屋,發(fā)現(xiàn)它們上面的case順序可能會(huì)不一樣(上面數(shù)字字符串切割出來的數(shù)字順序相對(duì)不一樣)立镶,AES的key、iv类早、data等都可能不一樣媚媒,如:放的位置也可能不一樣(如data可能放在數(shù)組變量里),表現(xiàn)形式也不一樣(如有的iv是直接給出涩僻,有的是需要從變量還原出來)缭召,但是結(jié)論都是先AES解密(自己提取data,key逆日,iv等數(shù)據(jù))再偶數(shù)減一嵌巷,奇數(shù)加一將數(shù)字Unicode 轉(zhuǎn)換成字符串
解密
我們可以使用Python的AES的Base64解密方法解密,然后對(duì)2求余判斷奇偶室抽,偶數(shù)減一搪哪,奇數(shù)加一,再使用Python數(shù)字Unicode 轉(zhuǎn)換字符串的函數(shù)(Python3內(nèi)置函數(shù)chr())轉(zhuǎn)成目標(biāo)字符坪圾,其次建立CSS類名索引和目標(biāo)字符的映射(CSS類名索引和結(jié)果的字典)晓折,最后源代碼中替換每個(gè)span標(biāo)簽再提取數(shù)據(jù)惑朦。