JavaScript 破解逆向爬取实战

JavaScript 破解逆向爬取实战
Photo by Pankaj Patel / Unsplash

网页防护技术包括接口加密和 JavaScript压缩、加密和混淆。这就引出了一个问题,如果我们碰到了这样的网站,那该怎么去分析和爬取呢?

本课时我通过一个案例来介绍一下这种网站的爬取思路,介绍的这个案例网站不仅在 API 接口层有加密,而且前端 JavaScript也带有压缩和混淆,其前端压缩打包工具使用了现在流行的 Webpack,混淆工具是使用了 javascript-obfuscator,这二者结合起来,前端的代码会变得难以阅读和分析。

如果我们不使用 Selenium 或 Pyppeteer 等工具来模拟浏览器的形式爬取的话,要想直接从接口层面上获取数据,基本上需要一点点调试分析JavaScript 的调用逻辑、堆栈调用关系来弄清楚整个网站加密的实现方法,我们可以称这个过程叫JavaScript
逆向。

这些接口的加密参数往往都是一些加密算法或编码的组合,完全搞明白其中的逻辑之后,我们就能把这个算法用 Python 模拟出来,从而实现接口的请求了。

项目介绍

案例的地址为:https://dynamic6.scrape.cuiqingcai.com/,页面如图所示。

image.png

初看之下并没有什么特殊的,但仔细观察可以发现其 Ajax 请求接口和每部电影的 URL 都包含了加密参数。

比如我们点击任意一部电影,观察一下 URL 的变化,如图所示。

image
1.png

这里我们可以看到详情页的 URL 包含了一个长字符串,看似是一个 Base64 编码的内容。

那么接下来直接看看 Ajax 的请求,我们从列表页的第 1 页到第 10 页依次点一下,观察一下 Ajax 请求是怎样的,如图所示。

image
2.png

可以看到 Ajax 接口的 URL 里面多了一个 token,而且不同的页码 token 是不一样的,这个 token 同样看似是一个 Base64
编码的字符串。

另外更困难的是,这个接口还是有时效性的,如果我们把 Ajax 接口 URL 直接复制下来,短期内是可以访问的,但是过段时间之后就无法访问了,会直接返回
401 状态码。

接下来我们再看下列表页的返回结果,比如我们打开第一个请求,看看第一部电影数据的返回结果。

这里我们把看似是第一部电影的返回结果全展开了,但是刚才我们观察到第一部电影的 URL 的链接却为
https://dynamic6.scrape.cuiqingcai.com/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIx,看起来是
Base64 编码,我们解码一下,结果为 ef34#teuq0btua#(-57w1q5o5--
j@98xygimlyfxs*-!i-0-mb1,但是看起来似乎还是毫无规律,这个解码后的结果又是怎么来的呢?返回结果里面也并不包含这个字符串,那这又是怎么构造的呢?

再然后,这仅仅是某一个详情页页面的 URL,其真实数据是通过 Ajax 加载的,那么 Ajax 请求又是怎样的呢,我们再观察下,如图所示。

image
4.png

好,这里我们发现其 Ajax 接口除了包含刚才所说的 URL 中携带的字符串,又多了一个 token,同样也是类似 Base64 编码的内容。

那么总结下来这个网站就有如下特点:

  • 列表页的 Ajax 接口参数带有加密的 token;
  • 详情页的 URL 带有加密 id;
  • 详情页的 Ajax 接口参数带有加密 id 和加密 token。

那如果我们要想通过接口的形式来爬取,必须要把这些加密 id 和 token 构造出来才行,而且必须要一步步来,首先我们要构造出列表页 Ajax 接口的
token 参数,然后才能获取每部电影的数据信息,然后根据数据信息构造出加密 id 和 token。

OK,到现在为止我们就知道了这个网站接口的加密情况了,我们下一步就是去找这个加密实现逻辑了。

由于是网页,所以其加密逻辑一定藏在前端代码中,但前面我们也说了,前端为了保护其接口加密逻辑不被轻易分析出来,会采取压缩、混淆的方式来加大分析的难度。

接下来,我们就来看看这个网站的源代码和 JavaScript 文件是怎样的吧。

首先看看网站源代码,我们在网站上点击右键,弹出选项菜单,然后点击“查看源代码”,可以看到结果如图所示。

image
5.png

内容如下:

<!DOCTYPE html><html lang=en><head><meta charset=utf-8><meta http-equiv=X-UA-Compatible content="IE=edge"><meta name=viewport content="width=device-width,initial-scale=1"><link rel=icon href=/favicon.ico><title>Scrape | Movie</title><link href=/css/chunk-19c920f8.2a6496e0.css rel=prefetch><link href=/css/chunk-2f73b8f3.5b462e16.css rel=prefetch><link href=/js/chunk-19c920f8.c3a1129d.js rel=prefetch><link href=/js/chunk-2f73b8f3.8f2fc3cd.js rel=prefetch><link href=/js/chunk-4dec7ef0.e4c2b130.js rel=prefetch><link href=/css/app.ea9d802a.css rel=preload as=style><link href=/js/app.5ef0d454.js rel=preload as=script><link href=/js/chunk-vendors.77daf991.js rel=preload as=script><link href=/css/app.ea9d802a.css rel=stylesheet></head><body><noscript><strong>We're sorry but portal doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id=app></div><script src=/js/chunk-vendors.77daf991.js></script><script src=/js/app.5ef0d454.js></script></body></html>

这是一个典型的 SPA(单页 Web 应用)的页面, 其 JavaScript 文件名带有编码字符、chunk、vendors 等关键字,整体就是经过
Webpack 打包压缩后的源代码,目前主流的前端开发,如 Vue.js、React.js 的输出结果都是类似这样的结果。

好,那么我们再看下其 JavaScript 代码是什么样子的,我们在开发者工具中打开 Sources 选项卡下的 Page 选项卡,然后打开 js
文件夹,这里我们就能看到 JavaScript 的源代码,如图所示。

image
6.png

我们随便复制一些出来,看看是什么样子的,结果如下:

\(window\['webpackJsonp'\]=window\['webpackJsonp'\]\|\|\[\]\)\['push'\]\(\[\['chunk\-19c920f8'\]\,\{'5a19':function\(\_0x3cb7c3\,\_0x5cb6ab\,\_0x5f5010\)\{\}\,'c6bf':function\(\_0x1846fe\,\_0x459c04\,\_0x1ff8e3\)\{\}\,'ca9c':function\(\_0x195201\,\_0xc41ead\,\_0x1b389c\)\{'use strict';var \_0x468b4e=\_0x1b389c\('5a19'\)\,\_0x232454=\_0x1b389c['n'](_0x468b4e);\_0x232454['a'];},'d504':...,[\_0xd670a1['\_v'](_0xd670a1%5B'_s'%5D(_0x2227b6)+'%5Cx0a%5Cx20%5Cx20%5Cx20%5Cx20%5Cx20%5Cx20%5Cx20%5Cx20%5Cx20%5Cx20%5Cx20%5Cx20%5Cx20%5Cx20')]);}),0x1),\_0x4ef533('div',{'staticClass':'m-v-sm\x20info'},[\_0x4ef533('span',[\_0xd670a1['\_v'](_0xd670a1%5B'_s'%5D(_0x1cc7eb%5B'regions'%5D%5B'join'%5D('%E3%80%81')))]),\_0x4ef533('span',[\_0xd670a1['\_v']('%5Cx20/%5Cx20')]),\_0x4ef533('span',[\_0xd670a1['\_v'](_0xd670a1%5B'_s'%5D(_0x1cc7eb%5B'minute'%5D)+'%5Cx20%E5%88%86%E9%92%9F')])]),\_0x4ef533('div',...,\_0x4ef533('el-col',{'attrs':{'xs':0x5,'sm':0x5,'md':0x4}},[\_0x4ef533('p',{'staticClass':'score\x20m-t-md\x20m-b-n-sm'},[\_0xd670a1['\_v'](_0xd670a1%5B'_s'%5D(_0x1cc7eb%5B'score'%5D%5B'toFixed'%5D(0x1)))\]\)\,\_0x4ef533\('p'\,\[\_0x4ef533\('el\-rate'\,\{'attrs':\{'value':\_0x1cc7eb\['score'\]/0x2\,'disabled':''\,'max':0x5\,'text\-color':'\#ff9900'\}\}\)\]\,0x1\)\]\)\]\,0x1\)\]\,0x1\);\}\)\,0x1\)\]\,0x1\)\,\_0x4ef533\('el\-row'\,\[\_0x4ef533\('el\-col'\,\{'attrs':\{'span':0xa\,'offset':0xb\}\}\,\[\_0x4ef533\('div'\,\{'staticClass':'pagination\x20m\-v\-lg'\}\,\[\_0x4ef533\('el\-pagination'\,\.\.\.:function\(\_0x347c29\)\{\_0xd670a1\['page'\]=\_0x347c29;\}\,'update:current\-page':function\(\_0x79754e\)\{\_0xd670a1\['page'\]=\_0x79754e;\}\}\}\)\]\,0x1\)\]\)\]\,0x1\)\]\,0x1\);\}\,\_0x357ebc=\[\]\,\_0x18b11a=\_0x1a3e60\('7d92'\)\,\_0x4369=\_0x1a3e60\('3e22'\)\,\.\.\.;var \_0x498df8=\.\.\.\['then'\]\(function\(\_0x59d600\)\{var \_0x1249bc=\_0x59d600\['data'\]\,\_0x10e324=\_0x1249bc\['results'\]\,\_0x47d41b=\_0x1249bc\['count'\];\_0x531b38\['loading'\]=\!0x1\,\_0x531b38\['movies'\]=\_0x10e324\,\_0x531b38\['total'\]=\_0x47d41b;\}\);\}\}\}\,\_0x28192a=\_0x5f39bd\,\_0x5f5978=\(\_0x1a3e60\('ca9c'\)\,\_0x1a3e60\('eb45'\)\,\_0x1a3e60\('2877'\)\)\,\_0x3fae81=Object\(\_0x5f5978\['a'\]\)\(\_0x28192a\,\_0x443d6e\,\_0x357ebc\,\!0x1\,null\,'724ecf3b'\,null\);\_0x6f764c\['default'\]=\_0x3fae81\['exports'\];\}\,'eb45':function\(\_0x1d3c3c\,\_0x52e11c\,\_0x3f1276\)\{'use strict';var \_0x79046c=\_0x3f1276\('c6bf'\)\,\_0x219366=\_0x3f1276['n'](_0x79046c);\_0x219366['a'];}}]);

就是这种感觉,可以看到一些变量都是一些十六进制字符串,而且代码全被压缩了。

没错,我们就是要从这里面找出 token 和 id 的构造逻辑,看起来是不是很崩溃?

要完全分析出整个网站的加密逻辑还是有一定难度的,不过不用担心,我们本课时会一步步地讲解逆向的思路、方法和技巧,如果你能跟着这个过程学习完,相信还是能学会一定的
JavaScript 逆向技巧的。

为了适当降低难度,本课时案例的 JavaScript 混淆其实并没有设置的特别复杂,并没有开启字符串编码、控制流扁平化等混淆方式。

列表页 Ajax 入口寻找

接下来,我们就开始第一步入口的寻找吧,这里简单介绍两种寻找入口的方式:

  • 全局搜索标志字符串;
  • 设置 Ajax 断点。

全局搜索标志字符串

一些关键的字符串通常会作为找寻 JavaScript 混淆入口的依据,我们可以通过全局搜索的方式来查找,然后根据搜索到的结果大体观察是否是我们想找的入口。

然后,我们重新打开列表页的 Ajax 接口,看下请求的 Ajax 接口,如图所示。

image
7.png

这里的 Ajax 接口的 URL 为
https://dynamic6.scrape.cuiqingcai.com/api/movie/?limit=10&offset=0&token=NTRhYWJhNzAyYTZiMTc0ZThkZTExNzBiNTMyMDJkN2UxZWYyMmNiZCwxNTg4MTc4NTYz,可以看到带有
offset、limit、token 三个参数,入口寻找关键就是找 token,我们全局搜索下 token
是否存在,可以点击开发者工具右上角的下拉选项卡,然后点击 Search,如图所示。

image
8.png

这样我们就能进入到一个全局搜索模式,我们搜索 token,可以看到的确搜索到了几个结果,如图所示。

image
9.png

观察一下,下面的两个结果可能是我们想要的,我们点击进入第一个看下,定位到了一个 JavaScript 文件,然后由于这时候可以看到整个代码都是压缩过的,只有一行,不好看,我们可以点击左下角的 {} 按钮,美化一下 JavaScript 代码,如图所示。

image
11.png

美化后的结果就是这样子了,如图所示。

image
12.png

这时可以看到这里弹出来了一个新的选项卡,其名称是 JavaScript 文件名加上了 :formatted,代表格式化后代码结果,在这里我们再次定位到
token 观察一下。

可以看到这里有 limit、offset、token,然后观察下其他的逻辑,基本上能够确定这就是构造 Ajax
请求的地方了,如果不是的话可以继续搜索其他的文件观察下。

那现在,混淆的入口点我们就成功找到了,这是一个首选的找入口的方法。

XHR 断点

由于这里的 token
字符串并没有被混淆,所以上面的这个方法是奏效的。之前我们也讲过,这种字符串由于非常容易成为找寻入口点的依据,所以这样的字符串也会被混淆成类似
Unicode、Base64、RC4 的一些编码形式,这样我们就没法轻松搜索到了。

那如果遇到这种情况,我们该怎么办呢?这里再介绍一种通过打 XHR 断点的方式来寻找入口。

XHR 断点,顾名思义,就是在发起 XHR 的时候进入断点调试模式,JavaScript 会在发起 Ajax
请求的时候停住,这时候我们可以通过当前的调用栈的逻辑顺着找到入口。怎么设置呢?我们可以在 Sources 选项卡的右侧,XHR/fetch
Breakpoints 处添加一个断点选项。

首先点击 + 号,然后输入匹配的 URL 内容,由于 Ajax 接口的形式是 /api/movie/?limit=10...
这样的格式,所这里我们就截取一段填进去就好了,这里填的就是 /api/movie,如图所示。

image
13.png

添加完毕之后重新刷新页面,可以发现进入了断点模式,如图所示。

image
14.png

好,接下来我们重新点下 {} 格式化代码,看看断点是在哪里,如图所示。

image
15.png

那这里看到有个 send 的字符,我们可以初步猜测这就是相当于发送 Ajax 请求的一瞬间。

到了这里感觉 Ajax 马上就要发出去了,是不是有点太晚了,我们想找的是构造 Ajax 的时刻来分析 Ajax
参数啊!不用担心,这里我们通过调用栈就可以找回去。我们点击右侧的 Call Stack,这里记录了 JavaScript 的方法逐层调用过程,如图所示。

image
16.png

这里当前指向的是一个名字为 anonymouns,也就是匿名的调用,在它的下方就显示了调用这个 anonymouns 的方法,名字叫作
_0x594ca1,然后再下一层就又显示了调用 _0x594a1 这个方法的方法,依次类推。

这里我们可以逐个往下查找,然后通过一些观察看看有没有 token 这样的信息,就能找到对应的位置了,最后我们就可以找到 onFetchData
这个方法里面实现了这个 token 的构造逻辑,这样我们也成功找到 token 的参数构造的位置了,如图所示。

image
17.png

好,到现在为止我们就通过两个方法找到入口点了。

其实还有其他的寻找入口的方式,比如 Hook 关键函数的方式,稍后的课程里我们会讲到,这里就暂时不讲了。

列表页加密逻辑寻找

接下来我们已经找到 token 的位置了,可以观察一下这个 token 对应的变量叫作 _0xa70fc9,所以我们的关键就是要找这个变量是哪里来的了。

怎么找呢?我们打个断点看下这个变量是在哪里生成的就好了,我们在对应的行打一个断点,如果打了刚才的 XHR 断点的话可以先取消掉,如图所示。

image
18.png

这时候我们就设置了一个新的断点了。由于只有一个断点,可以重新刷新下网页,这时候我们会发现网页停在了新的断点上面。

这里我们就可以观察下运行的一些变量了,比如我们把鼠标放在各个变量上面去,可以看到变量的一些值和类型,比如我们看 _0x18b11a
这个变量,会有一个浮窗显示,如图所示。

image
20.png

另外我们还可以通过在右侧的 Watch 面板添加想要查看的变量名称,如这行代码的内容为:


, _0xa70fc9 = Object(_0x18b11a['a'])(this['$store']['state']['url']['index']);

我们比较感兴趣的可能就是 _0x18b11a 还有 this 里面的这个值了,我们可以展开 Watch 面板,然后点击 + 号,把想看的变量添加到
Watch 面板里面,如图所示。

image
21.png

观察下可以发现 _0x18b11a 是一个 Object,它有个 a 属性,其值是一个 function,然后
this['$store']['state']['url']['index'] 的值其实就是 /api/movie,就是 Ajax 请求 URL 的
Path。_0xa70fc9 就是调用了前者这个 function 然后传入了 /api/movie 得到的。

那么下一步就是去寻找这个 function 在哪里了,我们可以把 Watch 面板的 _0x18b11a 展开,这里会显示一个
FunctionLocation,就是这个 function 的代码位置。

点击进入之后发现其仍然是未格式化的代码,再次点击 {} 格式化代码。

这时候我们就进入了一个新的名字为 _0xc9e475 的方法里面,这个方法里面应该就是 token
的生成逻辑了,我们再打上断点,然后执行面板右上角蓝色箭头状的 Resume 按钮,如图所示。

image
23.png

这时候发现我们已经单步执行到这个位置了。

接下来我们不断进行单步调试,观察这里面的执行逻辑和每一步调试过程中结果都有什么变化,如图所示。

image
24.png

在每步的执行过程中,我们可以发现一些运行值会被打到代码的右侧并带有高亮表示,同时在 watch 面板还能看到每步的变量的具体结果。

最后我们总结出这个 token 的构造逻辑如下:

  • 传入的 /api/movie 会构造一个初始化列表,变量命名为 _0x3dde76。
  • 获取当前的时间戳,命名为 _0x4c50b4,push 到 _0x3dde76 这个变量里面。
  • 将 _0x3dde76 变量用“,”拼接,然后进行 SHA1 编码,命名为 _0x46ba68。
  • 将 _0x46ba68 (SHA1 编码的结果)和 _0x4c50b4 (时间戳)用逗号拼接,命名为 _0x495a44。
  • 将 _0x495a44 进行 Base64 编码,命名为 _0x2a93f2,得到最后的 token。

以上的一些逻辑经过反复的观察就可以比较轻松地总结出来了,其中有些变量可以实时查看,同时也可以自己输入到控制台上进行反复验证,相信总结出这个结果并不难。

好,那现在加密逻辑我们就分析出来啦,基本的思路就是:

  • 先将 /api/movie 放到一个列表里面;
  • 列表中加入当前时间戳;
  • 将列表内容用逗号拼接;
  • 将拼接的结果进行 SHA1 编码;
  • 将编码的结果和时间戳再次拼接;
  • 将拼接后的结果进行 Base64 编码。

验证下逻辑没问题的话,我们就可以用 Python 来实现出来啦。

Python 实现列表页的爬取

要用 Python 实现这个逻辑,我们需要借助于两个库,一个是 hashlib,它提供了 sha1 方法;另外一个是 base64 库,它提供了
b64encode 方法对结果进行 Base64 编码。
代码实现如下:

import hashlib
import time
import base64
from typing import List, Any
import requests

INDEX\_URL = 'https://dynamic6.scrape.cuiqingcai.com/api/movie?limit={limit}&offset={offset}&token={token}'
LIMIT = 10
OFFSET = 0

def get\_token(args: List[Any]):
timestamp = str(int(time.time()))
args.append(timestamp)
sign = hashlib.sha1(','.join(args).encode('utf-8')).hexdigest()
return base64.b64encode(','.join([sign, timestamp]).encode('utf-8')).decode('utf-8')

args = ['/api/movie']
token = get\_token(args=args)
index\_url = INDEX\_URL.format(limit=LIMIT, offset=OFFSET, token=token)
response = requests.get(index\_url)
print('response', response.json())

这里我们就根据上面的逻辑把加密流程实现出来了,这里我们先模拟爬取了第一页的内容,最后运行一下就可以得到最终的输出结果了。

详情页加密 id 入口的寻找

好,我们接着上一课时的内容往下讲,我们观察下上一步的输出结果,我们把结果格式化一下,看看部分结果:

{
 'count': 100,
 'results': [
  {
     'id': 1,
     'name': '霸王别姬',
     'alias': 'Farewell My Concubine',
     'cover': 'https://p0.meituan.net/movie/ce4da3e03e655b5b88ed31b5cd7896cf62472.jpg@464w_644h_1e_1c',
     'categories': [
       '剧情',
       '爱情'
    ],
     'published_at': '1993-07-26',
     'minute': 171,
     'score': 9.5,
     'regions': [
       '中国大陆',
       '中国香港'
    ]
  },
   ...
]
}

这里我们看到有个 id 是 1,另外还有一些其他的字段如电影名称、封面、类别,等等,那么这里面一定有什么信息是用来唯一区分某个电影的。

但是呢,这里我们点击下第一个部电影的信息,可以看到它跳转到了 URL 为
https://dynamic6.scrape.cuiqingcai.com/detail/ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIx
的页面,可以看到这里 URL 里面有一个加密 id 为
ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIx,那么这个和电影的这些信息有什么关系呢?

这里,如果你仔细观察其实是可以比较容易地找出规律来的,但是这总归是观察出来的,如果遇到一些观察不出规律的那就不好处理了。所以还是需要靠技巧去找到它真正加密的位置。

这时候我们该怎么办呢?首先为我们分析一下,这个加密 id 到底是什么生成的。

我们在点击详情页的时候就看到它访问的 URL 里面就带上了
ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIx 这个加密 id
了,而且不同的详情页的加密 id 是不同的,这说明这个加密 id 的构造依赖于列表页 Ajax 的返回结果,所以可以确定这个加密 id 的生成是发生在
Ajax 请求完成后或者点击详情页的一瞬间。

为了进一步确定是发生在何时,我们看看页面源码,可以看到在没有点击之前,详情页链接的 href 里面就已经带有加密 id 了,如图所示。

image
25.png

由此我们可以确定,这个加密 id 是在 Ajax 请求完成之后生成的,而且肯定也是由 JavaScript 生成的了。

那怎么再去查找 Ajax 完成之后的事件呢?是否应该去找 Ajax 完成之后的事件呢?
可以是可以,你可以试试,我们可以看到在 Sources 面板的右侧,有一个 Event Listener Breakpoints,这里有一个 XHR
的监听,包括发起时、成功后、发生错误时的一些监听,这里我们勾选上 readystatechange 事件,代表 Ajax
得到响应时的事件,其他的断点可以都删除了,然后刷新下页面看下,如图所示。

image
26.png

这里我们可以看到就停在了 Ajax 得到响应时的位置了。

那我们怎么才能弄清楚这个 id 是怎么加密的呢?可以选择一个断点一个断点地找下去,但估计找的过程会崩溃掉,因为这里可能会逐渐调用到页面 UI
渲染的一些底层实现,甚至可能即使找到了也不知道具体找到哪里去了。

那怎么办呢?这里我们再介绍一种定位的方法,那就是 Hook。

Hook
技术中文又叫作钩子技术,它就是在程序运行的过程中,对其中的某个方法进行重写,在原有的方法前后加入我们自定义的代码。相当于在系统没有调用该函数之前,钩子程序就先捕获该消息,可以先得到控制权,这时钩子函数便可以加工处理(改变)该函数的执行行为。

通俗点来说呢,比如我要 Hook 一个方法 a,可以先临时用一个变量存一下,把它存成 _a,然后呢,我再重新声明一个方法
a,里面添加自己的逻辑,比如加点调试语句、输出语句等等,然后再调用 _a,这里调用的 _a 就是之前的 a。

这样就相当于新的方法 a 里面混入了我们自己定义的逻辑,同时又把原来的方法 a
也执行了一遍。所以这不会影响原有的执行逻辑和运行效果,但是我们通过这种改写便可以顺利在原来的 a 方法前后加上了我们自己的逻辑,这就是 Hook。

那么,我们这里怎么用 Hook 的方式来找到加密 id 的加密入口点呢?

想一下,这个加密 id 是一个 Base64 编码的字符串,那么生成过程中想必就调用了 JavaScript 的 Base64 编码的方法,这个方法名叫作
btoa,这个 btoa 方法可以将参数转化成 Base64 编码。当然 Base64 也有其他的实现方式,比如利用 crypto-js
这个库实现的,这个可能底层调用的就不是 btoa 方法了。

所以,其实现在并不确定是不是调用的 btoa 方法实现的 Base64 编码,那就先试试吧。要实现 Hook,其实关键在于将原来的方法改写,这里我们其实就是
Hook btoa 这个方法了,btoa 这个方法属于 window 对象,我们将 window 对象的 btoa 方法进行改写即可。

改写的逻辑如下:

(function () {
   'use strict'
   function hook(object, attr) {
       var func = object[attr]
       object[attr] = function () {
           console.log('hooked', object, attr, arguments)
           var ret = func.apply(object, arguments)
           debugger
           console.log('result', ret)
           return ret
      }
  }
   hook(window, 'btoa')
})()

我们定义了一个 hook 方法,传入 object 和 attr 参数,意思就是 Hook object 对象的 attr 参数。例如我们如果想 Hook
一个 alert 方法,那就把 object 设置为 window,把 attr 设置为 alert 字符串。这里我们想要 Hook Base64
的编码方法,那么这里就只需要 Hook window 对象的 btoa 方法就好了。

我们来看下,首先是 var func = object[attr],相当于先把它赋值为一个变量,我们调用 func
方法就可以实现和原来相同的功能。接着,我们再直接改写这个方法的定义,直接改写 object[attr],将其改写成一个新的方法,在新的方法中,通过
func.apply 方法又重新调用了原来的方法。

这样我们就可以保证,前后方法的执行效果是不受什么影响的,之前这个方法该干啥就还是干啥的。但是和之前不同的是,我们自定义方法之后,现在可以在 func
方法执行的前后,再加入自己的代码,如 console.log 将信息输出到控制台,如 debugger 进入断点等等。

这个过程中,我们先临时保存下来了 func 方法,然后定义一个新的方法,接管程序控制权,在其中自定义我们想要的实现,同时在新的方法里面再重新调回 func
方法,保证前后结果是不受影响的。所以,我们达到了在不影响原有方法效果的前提下,可以实现在方法的前后实现自定义的功能,就是 Hook 的完整实现过程。

最后,我们调用 hook 方法,传入 window 对象和 btoa 字符串即可。

那这样,怎么去注入这个代码呢?这里我们介绍三种注入方法。

  • 直接控制台注入;
  • 复写 JavaScript 代码;
  • Tampermonkey 注入。

控制台注入

对于我们这个场景,控制台注入其实就够了,我们先来介绍这个方法。

其实控制台注入很简单,就是直接在控制台输入这行代码运行,如图所示。

执行完这段代码之后,相当于我们就已经把 window 的 btoa 方法改写了,可以控制台调用下 btoa 方法试试,如:


btoa('germey')

回车之后就可以看到它进入了我们自定义的 debugger 的位置停下了,如图所示。

image
28.png

我们把断点向下执行,点击 Resume 按钮,然后看看控制台的输出,可以看到也输出了一些对应的结果,如被 Hook 的对象,Hook
的属性,调用的参数,调用后的结果等,如图所示。

image
29.png

这里我们就可以看到,我们通过 Hook 的方式改写了 btoa 方法,使其每次在调用的时候都能停到一个断点,同时还能输出对应的结果。

接下来我们看下怎么用 Hook 找到对应的加密 id 的加密入口?

由于此时我们是在控制台直接输入的 Hook 代码,所以页面一旦刷新就无效了,但由于我们这个网站是 SPA
式的页面,所以在点击详情页的时候页面是不会整个刷新的,所以这段代码依然还会生效。但是如果不是 SPA
式的页面,即每次访问都需要刷新页面的网站,这种注入方式就不生效了。

好,那我们的目的是为了 Hook 列表页 Ajax 加载完成后的加密 id 的 Base64
编码的过程,那怎么在不刷新页面的情况下再次复现这个操作呢?很简单,点下一页就好了。

这时候我们可以点击第 2 页的按钮,可以看到它确实再次停到了 Hook 方法的 debugger 处,由于列表页的 Ajax 和加密 id 都会带有
Base64 编码的操作,因此它每一个都能 Hook 到,通过观察对应的 Arguments
或当前网站的行为或者观察栈信息,我们就能大体知道现在走到了哪个位置了,从而进一步通过栈的调用信息找到调用 Base64 编码的位置。

我们可以根据调用栈的信息来观察这些变量是在哪一层发生变化的,比如最后的这一层,我们可以很明显看到它执行了 Base64 编码,编码前的结果是:


ef34#teuq0btua#(-57w1q5o5--j@98xygimlyfxs*-!i-0-mb1

编码后的结果是:


ZWYzNCN0ZXVxMGJ0dWEjKC01N3cxcTVvNS0takA5OHh5Z2ltbHlmeHMqLSFpLTAtbWIx

如图所示。

image
30.png

这里很明显。

那么核心问题就来了,编码前的结果 ef34#teuq0btua#(-57w1q5o5--
j@98xygimlyfxs*-!i-0-mb1又是怎么来的呢?我们展开栈的调用信息,一层层看看这个字符串的变化情况。如果不变那就看下一层,如果变了那就停下来仔细看看。

最后我们可以在第五层找到它的变化过程,如图所示。

image
31.png

那这里我们就一目了然了,看到了 _0x135c4d 是一个写死的字符串 ef34#teuq0btua#(-57w1q5o5--
j@98xygimlyfxs*-!i-0-mb,然后和传入的这个 _0x565f18 拼接起来就形成了最后的字符串。

那这个 _0x565f18 又是怎么来的呢?再往下追一层,那就一目了然了,其实就是 Ajax 返回结果的单个电影信息的 id。

所以,这个加密逻辑的就清楚了,其实非常简单,就是 ef34#teuq0btua#(-57w1q5o5--j@98xygimlyfxs*-!i-0-mb1
加上电影 id,然后 Base64 编码即可。

到此,我们就成功用 Hook 的方式找到加密的 id 生成逻辑了。

但是想想有什么不太科学的地方吗?刚才其实也说了,我们的 Hook
代码是在控制台手动输入的,一旦刷新页面就不生效了,这的确是个问题。而且它必须是在页面加载完了才注入的,所以它并不能在一开始就生效。

下面我们再介绍几种 Hook 注入方式

重写 JavaScript

我们可以借助于 Chrome 浏览器的 Overrides 功能实现某些 JavaScript 文件的重写和保存,它会在本地生成一个 JavaScript
文件副本,以后每次刷新的时候会使用副本的内容。

这里我们需要切换到 Sources 选项卡的 Overrides 选项卡,然后选择一个文件夹,比如这里我自定了一个文件夹名字叫作 modify,如图所示。

image
32.png

然后我们随便选一个 JavaScript 脚本,后面贴上这段注入脚本,如图所示。

image
33.png

保存文件。此时可能提示页面崩溃,但是不用担心,重新刷新页面就好了,这时候我们就发现现在浏览器加载的 JavaScript
文件就是我们修改过后的了,文件的下方会有一个标识符,如图所示。

image
34.png

同时我们还注意到这时候它就直接进入了断点模式,成功 Hook 到了 btoa 这个方法了。其实 Overrides
的这个功能非常有用,有了它我们可以持久化保存我们任意修改的 JavaScript 代码,所以我们想在哪里改都可以了,甚至可以直接修改 JavaScript
的原始执行逻辑也都是可以的。

Tampermonkey 注入

如果我们不想用 Overrides 的方式改写 JavaScript 的方式注入的话,还可以借助于浏览器插件来实现注入,这里推荐的浏览器插件叫作
Tampermonkey,中文叫作“油猴”。它是一款浏览器插件,支持 Chrome。利用它我们可以在浏览器加载页面时自动执行某些 JavaScript
脚本。由于执行的是 JavaScript,所以我们几乎可以在网页中完成任何我们想实现的效果,如自动爬虫、自动修改页面、自动响应事件等等。

首先我们需要安装 Tampermonkey,这里我们使用的浏览器是 Chrome。直接在 Chrome 应用商店或者在 Tampermonkey 的官网
https://www.tampermonkey.net/ 下载安装即可。

安装完成之后,在 Chrome 浏览器的右上角会出现 Tampermonkey 的图标,这就代表安装成功了。

image
35.png

我们也可以自己编写脚本来实现想要的功能。编写脚本难不难呢?其实就是写 JavaScript 代码,只要懂一些 JavaScript 的语法就好了。另外除了懂
JavaScript 语法,我们还需要遵循脚本的一些写作规范,这其中就包括一些参数的设置。

下面我们就简单实现一个小的脚本,实现某个功能。

首先我们可以点击 Tampermonkey 插件图标,点击“管理面板”按钮,打开脚本管理页面。

image
36.png

界面类似显示如下图所示。

image
37.png

在这里显示了我们已经有的一些 Tampermonkey 脚本,包括我们自行创建的,也包括从第三方网站下载安装的。

另外这里也提供了编辑、调试、删除等管理功能,我们可以方便地对脚本进行管理。接下来我们来创建一个新的脚本来试试,点击左侧的“+”号,会显示如图所示的页面。

初始化的代码如下:


// UserScript
// @name         New Userscript
// @namespace   http://tampermonkey.net/
// @version     0.1
// @description try to take over the world!
// @author       You
// @match       https://www.tampermonkey.net/documentation.php?ext=dhdg
// @grant       none
// /UserScript

(function() {
   'use strict';

   // Your code here...
})();

这里最上面是一些注释,但这些注释是非常有用的,这部分内容叫作 UserScript Header
,我们可以在里面配置一些脚本的信息,如名称、版本、描述、生效站点等等。

在 UserScript Header 下方是 JavaScript 函数和调用的代码,其中 use strict 标明代码使用 JavaScript
的严格模式,在严格模式下可以消除 Javascript
语法的一些不合理、不严谨之处,减少一些怪异行为,如不能直接使用未声明的变量,这样可以保证代码的运行安全,同时提高编译器的效率,提高运行速度。在下方 //
Your code here... 这里我们就可以编写自己的代码了。

我们可以将脚本改写为如下内容:

// ==UserScript==
// @name         HookBase64
// @namespace   https://scrape.cuiqingcai.com/
// @version     0.1
// @description Hook Base64 encode function
// @author       Germey
// @match       https://dynamic6.scrape.cuiqingcai.com/
// @grant       none
// @run-at     document-start
// ==/UserScript==
(function () {
   'use strict'
   function hook(object, attr) {
       var func = object[attr]
       console.log('func', func)
       object[attr] = function () {
           console.log('hooked', object, attr)
           var ret = func.apply(object, arguments)
           debugger
           return ret
      }
  }
   hook(window, 'btoa')
})()

这时候启动脚本,重新刷新页面,可以发现也可以成功 Hook 住 btoa 方法,如图所示。

image
39.png

然后我们再顺着找调用逻辑就好啦。

以上,我们就成功通过 Hook 的方式找到加密 id 的实现了。

详情页 Ajax 的 token 寻找

现在我们已经找到详情页的加密 id 了,但是还差一步,其 Ajax 请求也有一个 token,如图所示。

image
40.png

其实这个 token 和详情页的 token 构造逻辑是一样的了。

这里就不再展开说了,可以运用上文的几种找入口的方法来找到对应的加密逻辑。

Python 实现详情页爬取

现在我们已经成功把详情页的加密 id 和 Ajax 请求的 token 找出来了,下一步就能使用 Python
完成爬取了,这里我就只实现第一页的爬取了,代码示例如下:

import hashlib
import time
import base64
from typing import List, Any
import requests

INDEX_URL = 'https://dynamic6.scrape.cuiqingcai.com/api/movie?limit={limit}&offset={offset}&token={token}'
DETAIL_URL = 'https://dynamic6.scrape.cuiqingcai.com/api/movie/{id}?token={token}'
LIMIT = 10
OFFSET = 0
SECRET = 'ef34#teuq0btua#(-57w1q5o5--j@98xygimlyfxs*-!i-0-mb'

def get_token(args: List[Any]):
   timestamp = str(int(time.time()))
   args.append(timestamp)
   sign = hashlib.sha1(','.join(args).encode('utf-8')).hexdigest()
   return base64.b64encode(','.join([sign, timestamp]).encode('utf-8')).decode('utf-8') 

args = ['/api/movie']
token = get_token(args=args)
index_url = INDEX_URL.format(limit=LIMIT, offset=OFFSET, token=token)
response = requests.get(index_url)
print('response', response.json())

result = response.json()
for item in result['results']:
   id = item['id']
   encrypt_id = base64.b64encode((SECRET + str(id)).encode('utf-8')).decode('utf-8')
   args = [f'/api/movie/{encrypt_id}']
   token = get_token(args=args)
   detail_url = DETAIL_URL.format(id=encrypt_id, token=token)
   response = requests.get(detail_url)
   print('response', response.json())

这里模拟了详情页的加密 id 和 token 的构造过程,然后请求了详情页的 Ajax 接口,这样我们就可以爬取到详情页的内容了。

总结

本文内容很多,一步步介绍了整个网站的 JavaScript 逆向过程,其中的技巧有:

  • 全局搜索查找入口
  • 代码格式化
  • XHR 断点
  • 变量监听
  • 断点设置和跳过
  • 栈查看
  • Hook 原理
  • Hook 注入
  • Overrides 功能
  • Tampermonkey 插件
  • Python 模拟实现

掌握了这些技巧我就能更加快速地掌握JavaScript 逆向分析。