美文网首页
JS逆向之红薯中文网隐式CSS反爬

JS逆向之红薯中文网隐式CSS反爬

作者: 成长之路丶 | 来源:发表于2020-07-21 09:19 被阅读0次

已经很久没有写JS逆向相关的文章了,距离上一篇JS逆向文章的发布时间已经过了大半年了,之前把红薯中文网网页版的反爬讲完之后就说过有机会把红薯中文网手机版隐式Style-CSS反爬给大家分析一下,今天我就把这篇久违的文章给大家奉上。

目标分析

跟网页版网站一样,红薯中文网手机版网站的反爬也主要在小说的正文内容,只不过反爬的技术种类不一样,我们随便找一本小说然后进行分析,值得我们注意的是手机版和网页版网站一样依旧对小说的正文页禁用了鼠标右键,要检查网页元素(Ctrl+Shift+I)或者查看网页源代码(Ctrl+U)我们可以使用Chrome浏览器的快捷键:

随便选择小说正文内容的一行,通过检查网页元素可以发现浏览器渲染后的Elements中小说内容的某些字并没有渲染出来而是一个span标签,span标签的文本内容是::before,CSS类名是context_kw18,鼠标点击::before我们可以在后边的Styles选项卡中看到相关的CSS信息,可以发现刚才我们选择的那个没有渲染的字居然出现在CSS样式的content属性中,我们还可以发现这个CSS的文件地址并不是一个CSS路径地址,而是<style>,这说明这个CSS是动态生成的,肯定是通过JS的DOM操作生成的。

我们还可以发现其他没有渲染的字也是这样,不过它们的CSS类名不一样,但是CSS类名都是context_kw开头,只是后面接不一样的数字编号,context_kw18对应的是"天"字以及context_kw10对应的是"之"字等。

我们再查看一下网页源代码:

我们可以发现网页源代码里<span class="context_kw18"></span>里并没有什么东西,其实在前面看到CSS样式中的::before的时候,如果CSS样式学得好的话就知道这是CSS的隐式,隐式Style-CSS也是目前比较流行的一种反爬手段。

隐式Style–CSS

先来说说什么是隐式Style–CSS

  • CSS中,::before 创建一个伪元素,其将成为匹配选中的元素的第一个子元素。常通过content属性来为一个元素添加修饰性的内容。

    引用自:developer.mozilla.org/zh-CN/docs/…

上面的这段话对于没做过前端开发的朋友而言,看着可能会有点难懂,没关系,我用一个例子简单地演示一下。

我们新建一个 HTML 文件输入下面这样的内容:

  <span>欢迎大家来到我的简书,我是成长之路丶</span>,<span>今天我们要说的是红薯中文网隐式style-CSS反爬</span>

并在这个 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"

通过搜索发现符合条件的就一个请求,并且这个请求就是小说正文内容请求(网页源代码),点击这个请求来到Sources调试面板,点击左下角的大括号({})把代码美化一下,然后再搜索一下".context_kw" 通过搜索我们在众多结果中找到了关键代码,如图所示,可以发现这个DOM操作的JS被混淆加密了,但是通过混淆代码中document字眼我们还是可以看出DOM操作的痕迹,它是通过循环把循环的i变量拼接".context_kw"这样通过DOM操作之后就会得到我们在页面上看到的".context_kw10"等加上了数字编号的CSS类名,混淆代码后面还加上了words[i]这个变量,看名字、代码的位置以及代码逻辑,我们可以推测这个words[i]很有可能是每个隐式Style-CSScontent属性中的文字,也就是我们要逆向获取的内容,我们可以在Console面板中把它输出一下: 可以发现words这个变量确实是我们要的数据,并且我们发现words是一个数组,数组的索引都能跟渲染后的CSS类名编号对上,比如:数组第10个是"天"字,页面上".context_kw10"对应的也是"天"字,所以现在我们要找到words是怎么生成的,继续在该文件全局搜索words 找到words生成的地方我们可以发现,它是定义一个数组,并且涉及到了secwords这个变量以及_0xa5c1这个变量,'0x18'是十六进制转换成十进制是24,有图可以看到secwords是通过CryptoJS这个库decrypted(解密)得到

,我们先找到_0xa5c1变量然后逐步分析:

在Console面板中把输出_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长度的数组,secWordsdecrypted解密然后按照','切割出来的数组,decryptedAES解密,解密需要datakeywordsiv

AES加密解密

AES是一种加密方式,它有多种加密模式: ECBCBCOFB等,它加密解密需要keymodel(也就是加密模式,如: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')

进一步分析

既然我们知道secwordsAES逆向解密,并且我们了解了AES的基础原理,那么我们就需要在JS中找到AES密文keymodel以及iv

  1. 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"

  1. model,你把加密的CryptoJS代码看一遍,你会发现这个AES加密用的modelCBC(搜索出现了CBC字眼),并且发现它使用的Base64密文加密解密
  2. key,其实key就是上面的keywords变量"DC3A49D549646237"(分析方法在上面,其实ivkey密文都是一样的分析方法)
  3. 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],我们分析这个循环(这里就全部还原这个循环了,有空可以自己还原一下,这里只还原部分关键代码):
  1. 定义一个循环,i起始为0,i<secwords.lenght; i++,也就是说i<21后停止循环
  2. 定义了一个变量_0x475a5f(这个一个混淆后的代码,其实你为了好读把它叫a变量也是可以),它的值是var _0x475a5f = '0|4|1|3|5|2'.split('|'),也就是这个字符串按照"|"切割,所以_0x475a5f的值其实等于[0,4,1,3,5,2],还定义一个_0xbddd40变量初始值为0
  3. 再定义一个whie循环,因为条件"!![]"一直为true,所以这是一个死循环
  4. 定义一个swith循环,循环的条件是_0x475a5f[_0xbddd40++],这也说明switch会依次执行case0、case4、case1、case3、case5、case2
  5. 分别看每个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
  6. 明白了每个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标签再提取数据。

相关文章

网友评论

      本文标题:JS逆向之红薯中文网隐式CSS反爬

      本文链接:https://www.haomeiwen.com/subject/opvunhtx.html