已经很久没有写JS逆向相关的文章了,距离上一篇JS逆向文章的发布时间已经过了大半年了,之前把红薯中文网网页版的反爬讲完之后就说过有机会把红薯中文网手机版隐式Style-CSS反爬给大家分析一下,今天我就把这篇久违的文章给大家奉上。
目标分析
跟网页版网站一样,红薯中文网手机版网站的反爬也主要在小说的正文内容,只不过反爬的技术种类不一样,我们随便找一本小说然后进行分析,值得我们注意的是手机版和网页版网站一样依旧对小说的正文页禁用了鼠标右键,要检查网页元素(Ctrl+Shift+I
)或者查看网页源代码(Ctrl+U
)我们可以使用Chrome浏览器的快捷键:
::before
,CSS类名是context_kw18
,鼠标点击::before
我们可以在后边的Styles选项卡中看到相关的CSS信息,可以发现刚才我们选择的那个没有渲染的字居然出现在CSS样式的content属性中,我们还可以发现这个CSS的文件地址并不是一个CSS路径地址,而是
,这说明这个CSS是动态生成的,肯定是通过JS的DOM操作生成的。
我们还可以发现其他没有渲染的字也是这样,不过它们的CSS类名不一样,但是CSS类名都是context_kw
开头,只是后面接不一样的数字编号,context_kw18
对应的是"天"
字以及context_kw10
对应的是"之"
字等。
我们再查看一下网页源代码:
里并没有什么东西,其实在前面看到CSS样式中的::before
的时候,如果CSS样式学得好的话就知道这是CSS的隐式,隐式Style-CSS也是目前比较流行的一种反爬手段。
隐式Style–CSS
先来说说什么是隐式Style–CSS
:
-
CSS中,::before 创建一个伪元素,其将成为匹配选中的元素的第一个子元素。常通过content属性来为一个元素添加修饰性的内容。
引用自:developer.mozilla.org/zh-CN/docs/…
上面的这段话对于没做过前端开发的朋友而言,看着可能会有点难懂,没关系,我用一个例子简单地演示一下。
我们新建一个 HTML 文件输入下面这样的内容:
欢迎大家来到我的,我是成长之路丶,今天我们要说的是红薯中文网隐式style-CSS反爬
并在这个 HTML 中引用下面这个CSS样式文件:
span::before {
content: "“";
color: blue;
}
span::after {
context: "”";
color: red;
}
最后在浏览器中展示的内容是这样的:
可以看到在上面的例子里,我在HTML源码里隐藏了文字前后的符号,但是经过浏览器渲染后,文字前后的符号就出现了,是不是很神奇?目前很多网站都使用了类似这样的反爬虫技术,用来保护自己的内容不被爬虫爬取。
逆向过程
既然我们知道了红薯中文网手机版网站的小说正文内容是隐式Style-CSS反爬
,并且CSS是通过JS的DOM操作动态生成的,那么我们就需要逆向分析它是如何通过JS把一些文字放到CSS样式中然后动态生成该CSS。
找逆向入口
因为JS动态渲染的CSS类名相似,只是CSS类名后面的数字编号不一样,所以我们可以在Chrome浏览器调试面板全局搜索".context_kw"
:
".context_kw"
:
document
字眼我们还是可以看出DOM操作的痕迹,它是通过循环把循环的i
变量拼接".context_kw"这样通过DOM操作之后就会得到我们在页面上看到的".context_kw10"
等加上了数字编号的CSS类名,混淆代码后面还加上了words[i]
这个变量,看名字、代码的位置以及代码逻辑,我们可以推测这个words[i]
很有可能是每个隐式Style-CSS
里content
属性中的文字,也就是我们要逆向获取的内容,我们可以在Console面板中把它输出一下:
words
这个变量确实是我们要的数据,并且我们发现words
是一个数组,数组的索引都能跟渲染后的CSS类名编号对上,比如:数组第10个是"天"
字,页面上".context_kw10"
对应的也是"天"
字,所以现在我们要找到words是怎么生成的,继续在该文件全局搜索words
:
words
生成的地方我们可以发现,它是定义一个数组,并且涉及到了secwords
这个变量以及_0xa5c1
这个变量,'0x18'
是十六进制转换成十进制是24,有图可以看到secwords
是通过CryptoJS这个库decrypted
(解密)得到
,我们先找到_0xa5c1
变量然后逐步分析:
_0xa5c1
变量:
_0xa5c1
变量以及十六进制索引后然后还原一下关键的代码:
原代码:
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);
可以发现words
初始定义为一个secWords
长度的数组,secWords
是decrypted
解密然后按照','
切割出来的数组,decrypted
是AES解密
,解密需要data
、keywords
,iv
等
AES加密解密
AES
是一种加密方式,它有多种加密模式: ECB
、CBC
、 OFB
等,它加密解密需要key
,model
(也就是加密模式,如:CBC
),iv
(偏移向量),在JS中通过CryptoJS
库可以实现加密解密,举个列子:
//这是http://www.hongweipeng.com/index.php/archives/1936/页面中的一个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;
}
现在有好多网站在前端都使用了AES加密解密。
Python也有AES的加密解密库pycryptodome
(这个库不止封装了AES加密的相关还有其他加密算法,具体自己去看文档),根据AES加密结果输出密文形式不同,它的加密解密稍微有些差异,比如加密输出hash密文
和Base64密文
的加密解密方法就有点不一样:
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)
# 将加密后的数据base64编码返回base64格式数据
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')
进一步分析
既然我们知道secwords
是AES逆向解密
,并且我们了解了AES的基础原理,那么我们就需要在JS中找到AES密文
、key
、model
以及iv
。
- 找
iv
,全局搜索iv
找到相关代码。-
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
数组变量的第11个索引数据,也就是"A61BFB423D6C6EB8"
- 找
model
,你把加密的CryptoJS代码看一遍,你会发现这个AES加密用的model
是CBC
(搜索出现了CBC
字眼),并且发现它使用的Base64
密文加密解密 - 找
key
,其实key
就是上面的keywords
变量"DC3A49D549646237"
(分析方法在上面,其实iv
、key
、密文
都是一样的分析方法) - AES密文,其实密文就是上面的
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)
# 将加密后的数据base64编码返回base64格式数据
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)
解密出来的结果如下:
"65291, 30339, 65282, 26160, 26471, 21074, 19967, 19982, 36826, 20101, 20044, 8222, 8219, 22660, 23477, 20181, 20203, 22311, 22826, 20009, 20461"
可以发现是一组数字数据,但是这些数据是一个整体,是一个字符串,我们在控制台输出一下secword
对比一下我们解密的结果:
var words = new Array(secWords.length);
就说明words
是一个长度为21的空数组,那么JS是如何把words
和那些字产生关系的呢?
我们接着在跟words
相关的代码,全局搜索words[i]
(搜索words[i]
不搜索words
是因为words
搜出的结果太多,并且words
是跟i
有关系):
words[i]
,我们分析这个循环(这里就全部还原这个循环了,有空可以自己还原一下,这里只还原部分关键代码):
- 定义一个循环,
i
起始为0,i
,也就是说 i<21
后停止循环 - 定义了一个变量
_0x475a5f
(这个一个混淆后的代码,其实你为了好读把它叫a变量也是可以),它的值是var _0x475a5f = '0|4|1|3|5|2'.split('|')
,也就是这个字符串按照"|"
切割,所以_0x475a5f
的值其实等于[0,4,1,3,5,2]
,还定义一个_0xbddd40
变量初始值为0
- 再定义一个
whie循环
,因为条件"!![]"
一直为true
,所以这是一个死循环 - 定义一个
swith循环
,循环的条件是_0x475a5f[_0xbddd40++]
,这也说明switch会依次执行case0、case4、case1、case3、case5、case2
- 分别看每个case都做什么:
- case0:将
secwords
的第i个值赋值给一个变量_0x2e1b2c
- case1:定义一个变量把一个函数赋值给这个变量,这个函数里有定义了三个函数,经过还原其实可以发现就是传递两个数字到函数,一个是
secwords
的第i
个值,另一个是3
,返回两个数相加 - case2:调用
Sting.fromCharCode()
方法,然后传递数字,这个方法就是将Unicode
编码转为一个字符 ,如:var n = String.fromCharCode(65);
结果是:A
- case3:将变量
_0x2e1b2c
重新赋值,调用_0x15d2ab
然后把_0x2e1b2c
传递给这个方法,_0x15d2ab
这个方法其实在case4 - case4:case4跟case1差不多,只不过它的返回值是一个三元运算符,如果
_0x2e1b2c
是偶数
就返回这个数减2
,奇数
就返回这个数减4
- case5:将变量
_0x2e1b2c
重新赋值,调用_0x5bb6ca
然后把_0x2e1b2c
传递给这个方法,_0x15d2ab
这个方法其实在case1
- case0:将
- 明白了每个case在做什么,然后按照case顺序执行,就能得出结论:把
secword
的各项如果是偶数减一
,奇数加一
然后使用fromCharCode
方法将数字转换成字符串
这样就能得到我们要的数据,比如"天"
字倒数第三个,secwords
倒数第三个数字是" 22826"
,是偶数
,减一
然后使用fromCharCode
方法将数字转换成字符串,结果为"天"
总结
通过上面的分析我们可以得出结论:
- 红薯中文网手机版网站是隐式Style-CSS反爬,反爬的CSS是通过JS的DOM操作动态生成的
- 它操作DOM的JS代码进行了混淆,隐式Style-CSS中的content属性中的值是AES解密后数据
偶数减一,奇数加一然后将数字Unicode 转换成字符串
注意:
我尝试过很多小说,发现它们上面的case顺序可能会不一样(上面数字字符串切割出来的数字顺序相对不一样),AES的key、iv、data等都可能不一样,如:放的位置也可能不一样(如data可能放在数组变量里),表现形式也不一样(如有的iv是直接给出,有的是需要从变量还原出来),但是结论都是先AES解密(自己提取data,key,iv等数据)再偶数减一,奇数加一将数字Unicode 转换成字符串
解密
我们可以使用Python的AES的Base64解密方法解密,然后对2求余判断奇偶,偶数减一,奇数加一,再使用Python数字Unicode 转换字符串的函数(Python3内置函数chr())转成目标字符,其次建立CSS类名索引和目标字符的映射(CSS类名索引和结果的字典),最后源代码中替换每个span标签再提取数据。