数字旗手

电气化、自动化、数字化、智能化、智慧化

0%

52讲轻松搞定网络爬虫笔记6

资料

52讲轻松搞定网络爬虫

你有权限吗?解析模拟登录基本原理

在很多情况下,一些网站的页面或资源我们通常需要登录才能看到。比如访问 GitHub 的个人设置页面,如果不登录是无法查看的;比如 12306 买票提交订单的页面,如果不登录是无法提交订单的;再比如要发一条微博,如果不登录是无法发送的。

我们之前学习的案例都是爬取的无需登录即可访问的站点,但是诸如上面例子的情况非常非常多,那假如我们想要用爬虫来访问这些页面,比如用爬虫修改 GitHub 的个人设置,用爬虫提交购票订单,用爬虫发微博,能做到吗?

答案是可以,这里就需要用到一些模拟登录相关的技术了。那么本课时我们就先来了解模拟登录的一些基本原理和实现吧。

网站登录验证的实现

我们要实现模拟登录,那就得首先了解网站登录验证的实现。

登录一般需要两个内容,用户名和密码,有的网站可能是手机号和验证码,有的是微信扫码,有的是 OAuth 验证等等,但根本上来说,都是把一些可供认证的信息提交给了服务器。

比如这里我们就拿用户名和密码来举例吧。用户在一个网页表单里面输入了内容,然后点击登录按钮的一瞬间,浏览器客户端就会向服务器发送一个登录请求,这个请求里面肯定就包含了用户名和密码信息,这时候,服务器需要处理这些信息,然后返回给客户端一个类似“凭证”的东西,有了这个“凭证”以后呢,客户端拿着这个“凭证”再去访问某些需要登录才能查看的页面,服务器自然就能“放行”了,然后返回对应的内容或执行对应的操作就好了。

形象地说,我们以登录发微博和买票坐火车这两件事来类比。发微博就好像要坐火车,没票是没法坐火车的吧,要坐火车怎么办呢?当然是先买票了,我们拿钱去火车站买了票,有了票之后,进站口查验一下,没问题就自然能去坐火车了,这个票就是坐火车的“凭证”。

发微博也一样,我们有用户名和密码,请求下服务器,获得一个“凭证”,这就相当于买到了火车票,然后在发微博的时候拿着这个“凭证”去请求服务器,服务器校验没问题,自然就把微博发出去了。

那么问题来了,这个“凭证“”到底是怎么生成和验证的呢?目前比较流行的实现方式有两种,一种是基于 Session + Cookies 的验证,一种是基于 JWT(JSON Web Token)的验证,下面我们来介绍下。

Session 和 Cookies

我们在模块一了解了 Session 和 Cookies 的基本概念。简而言之,Session 就是存在服务端的,里面保存了用户此次访问的会话信息,Cookies 则是保存在用户本地浏览器的,它会在每次用户访问网站的时候发送给服务器,Cookies 会作为 Request Headers 的一部分发送给服务器,服务器根据 Cookies 里面包含的信息判断找出其 Session 对象,不同的 Session 对象里面维持了不同访问用户的状态,服务器可以根据这些信息决定返回 Response 的内容。

我们以用户登录的情形来举例,其实不同的网站对于用户的登录状态的实现可能是不同的,但是 Session 和 Cookies 一定是相互配合工作的。

梳理如下:

  • 比如,Cookies 里面可能只存了 Session ID 相关信息,服务器能根据 Cookies 找到对应的 Session,用户登录之后,服务器会在对应的 Session 里面标记一个字段,代表已登录状态或者其他信息(如角色、登录时间)等等,这样用户每次访问网站的时候都带着 Cookies 来访问,服务器就能找到对应的 Session,然后看一下 Session 里面的状态是登录状态,就可以返回对应的结果或执行某些操作。
  • 当然 Cookies 里面也可能直接存了某些凭证信息。比如说用户在发起登录请求之后,服务器校验通过,返回给客户端的 Response Headers 里面可能带有 Set-Cookie 字段,里面可能就包含了类似凭证的信息,这样客户端会执行 Set Cookie 的操作,将这些信息保存到 Cookies 里面,以后再访问网页时携带这些 Cookies 信息,服务器拿着这里面的信息校验,自然也能实现登录状态检测了。

以上两种情况几乎能涵盖大部分的 Session 和 Cookies 登录验证的实现,具体的实现逻辑因服务器而异,但 Session 和 Cookies 一定是需要相互配合才能实现的。

JWT

Web 开发技术是一直在发展的,近几年前后端分离的趋势越来越火,很多 Web 网站都采取了前后端分离的技术来实现。而且传统的基于 Session 和 Cookies 的校验也存在一定问题,比如服务器需要维护登录用户的 Session 信息,而且不太方便分布式部署,也不太适合前后端分离的项目。

所以,JWT 技术应运而生。JWT,英文全称叫作 JSON Web Token,是为了在网络应用环境间传递声明而执行的一种基于 JSON 的开放标准。实际上就是每次登录的时候通过一个 Token 字符串来校验登录状态。

JWT 的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其他业务逻辑所必须的声明信息,所以这个 Token 也可直接被用于认证,也可传递一些额外信息。

有了 JWT,一些认证就不需要借助于 Session 和 Cookies 了,服务器也无需维护 Session 信息,减少了服务器的开销。服务器只需要有一个校验 JWT 的功能就好了,同时也可以做到分布式部署和跨语言的支持。

JWT 通常就是一个加密的字符串,它也有自己的标准,类似下面的这种格式:

eyJ0eXAxIjoiMTIzNCIsImFsZzIiOiJhZG1pbiIsInR5cCI6IkpXVCIsImFsZyI6IkhTMjU2In0.eyJVc2VySWQiOjEyMywiVXNlck5hbWUiOiJhZG1pbiIsImV4cCI6MTU1MjI4Njc0Ni44Nzc0MDE4fQ.pEgdmFAy73walFonEm2zbxg46Oth3dlT02HR9iVzXa8

可以发现中间有两个“.”来分割开,可以把它看成是一个三段式的加密字符串。它由三部分构成,分别是 Header、Payload、Signature。

  • Header,声明了 JWT 的签名算法,如 RSA、SHA256 等等,也可能包含 JWT 编号或类型等数据,然后整个信息 Base64 编码即可。
  • Payload,通常用来存放一些业务需要但不敏感的信息,如 UserID 等,另外它也有很多默认的字段,如 JWT 签发者、JWT 接受者、JWT 过期时间等等,Base64 编码即可。
  • Signature,这个就是一个签名,是把 Header、Payload 的信息用秘钥 secret 加密后形成的,这个 secret 是保存在服务器端的,不能被轻易泄露。这样的话,即使一些 Payload 的信息被篡改,服务器也能通过 Signature 判断出来是非法请求,拒绝服务。

这三部分通过“.”组合起来就形成了 JWT 的字符串,就是用户的访问凭证。

所以这个登录认证流程也很简单了,用户拿着用户名密码登录,然后服务器生成 JWT 字符串返回给客户端,客户端每次请求都带着这个 JWT 就行了,服务器会自动判断其有效情况,如果有效,那自然就返回对应的数据。但 JWT 的传输就多种多样了,可以放在 Request Headers,也可以放在 URL 里,甚至有的网站也放在 Cookies 里,但总而言之,能传给服务器校验就好了。

好,到此为止呢,我们就已经了解了网站登录验证的实现了。

模拟登录

好,了解了网站登录验证的实现后,模拟登录自然就有思路了。下面我们也是分两种认证方式来说明。

Session 和 Cookies

基于 Session 和 Cookies 的模拟登录,如果我们要用爬虫实现的话,其实最主要的就是把 Cookies 的信息维护好,因为爬虫就相当于客户端浏览器,我们模拟好浏览器做的事情就好了。

那一般情况下,模拟登录一般可以怎样实现呢,我们结合之前所讲的技术来总结一下:

  • 第一,如果我们已经在浏览器里面登录了自己的账号,我们要想用爬虫模拟的话,可以直接把 Cookies 复制过来交给爬虫就行了,这也是最省事省力的方式。这就相当于,我们用浏览器手动操作登录了,然后把 Cookies 拿过来放到代码里面,爬虫每次请求的时候把 Cookies 放到 Request Headers 里面,就相当于完全模拟了浏览器的操作,服务器会通过 Cookies 校验登录状态,如果没问题,自然可以执行某些操作或返回某些内容了。
  • 第二,如果我们不想有任何手工操作,可以直接使用爬虫来模拟登录过程。登录的过程其实多数也是一个 POST 请求,我们用爬虫提交用户名密码等信息给服务器,服务器返回的 Response Headers 里面可能带了 Set-Cookie 的字段,我们只需要把这些 Cookies 保存下来就行了。所以,最主要的就是把这个过程中的 Cookies 维护好就行了。当然这里可能会遇到一些困难,比如登录过程还伴随着各种校验参数,不好直接模拟请求,也可能网站设置 Cookies 的过程是通过 JavaScript 实现的,所以可能还得仔细分析下其中的一些逻辑,尤其是我们用 requests 这样的请求库进行模拟登录的时候,遇到的问题可能比较多。
  • 第三,我们也可以用一些简单的方式来实现模拟登录,即把人在浏览器中手工登录的过程自动化实现,比如我们用 Selenium 或 Pyppeteer 来驱动浏览器模拟执行一些操作,如填写用户名、密码和表单提交等操作,等待登录成功之后,通过 Selenium 或 Pyppeteer 获取当前浏览器的 Cookies 保存起来即可。然后后续的请求可以携带 Cookies 的内容请求,同样也能实现模拟登录。

以上介绍的就是一些常用的爬虫模拟登录的方案,其目的就是维护好客户端的 Cookies 信息,然后每次请求都携带好 Cookies 信息就能实现模拟登录了。

JWT

基于 JWT 的真实情况也比较清晰了,由于 JWT 的这个字符串就是用户访问的凭证,那么模拟登录只需要做到下面几步即可:

  • 第一,模拟网站登录操作的请求,比如携带用户名和密码信息请求登录接口,获取服务器返回结果,这个结果中通常包含 JWT 字符串的信息,保存下来即可。
  • 第二,后续的请求携带 JWT 访问即可,一般情况在 JWT 不过期的情况下都能正常访问和执行对应的操作。携带方式多种多样,因网站而异。
  • 第三,如果 JWT 过期了,可能需要重复步骤一,重新获取 JWT。

当然这个模拟登录的过程也肯定带有其他的一些加密参数,需要根据实际情况具体分析。

优化方案

如果爬虫要求爬取的数据量比较大或爬取速度比较快,而网站又有单账号并发限制或者访问状态检测并反爬的话,可能我们的账号就会无法访问或者面临封号的风险了。这时候一般怎么办呢?

我们可以使用分流的方案来解决,比如某个网站一分钟之内检测一个账号只能访问三次或者超过三次就封号的话,我们可以建立一个账号池,用多个账号来随机访问或爬取,这样就能数倍提高爬虫的并发量或者降低被封的风险了。

比如在访问某个网站的时候,我们可以准备 100 个账号,然后 100 个账号都模拟登录,把对应的 Cookies 或 JWT 存下来,每次访问的时候随机取一个来访问,由于账号多,所以每个账号被取用的概率也就降下来了,这样就能避免单账号并发过大的问题,也降低封号风险。

以上,我们就介绍完了模拟登录的基本原理和实现以及优化方案,希望你可以好好理解。

模拟登录爬取实战案例

在上一课时我们了解了网站登录验证和模拟登录的基本原理。网站登录验证主要有两种实现,一种是基于 Session + Cookies 的登录验证,另一种是基于 JWT 的登录验证,那么本课时我们就通过两个实例来分别讲解这两种登录验证的分析和模拟登录流程。

准备工作

在本课时开始之前,请你确保已经做好了如下准备工作:

  • 安装好了 Python (最好 3.6 及以上版本)并能成功运行 Python 程序;

  • 安装好了 requests 请求库并学会了其基本用法;

  • 安装好了 Selenium 库并学会了其基本用法。

下面我们就以两个案例为例来分别讲解模拟登录的实现。

案例介绍

这里有两个需要登录才能抓取的网站,链接为 https://login2.scrape.center/https://login3.scrape.center/,前者是基于 Session + Cookies 认证的网站,后者是基于 JWT 认证的网站。

首先看下第一个网站,打开后会看到如图所示的页面。
image.png
它直接跳转到了登录页面,这里用户名和密码都是 admin,我们输入之后登录。

登录成功之后,我们便看到了熟悉的电影网站的展示页面,如图所示。
image (1).png

这个网站是基于传统的 MVC 模式开发的,因此也比较适合 Session + Cookies 的认证。

第二个网站打开后同样会跳到登录页面,如图所示。

image (2).png
用户名和密码是一样的,都输入 admin 即可登录。

登录之后会跳转到首页,展示了一些书籍信息,如图所示。
image (3).png
这个页面是前后端分离式的页面,数据的加载都是通过 Ajax 请求后端 API 接口获取,登录的校验是基于 JWT 的,同时后端每个 API 都会校验 JWT 是否是有效的,如果无效则不会返回数据。

接下来我们就分析这两个案例并实现模拟登录吧。

案例一

对于案例一,我们如果要模拟登录,就需要先分析下登录过程究竟发生了什么,首先我们打开 https://login2.scrape.center/,然后执行登录操作,查看其登录过程中发生的请求,如图所示。

image (4).png
这里我们可以看到其登录的瞬间是发起了一个 POST 请求,目标 URL 为 https://login2.scrape.center/login,通过表单提交的方式提交了登录数据,包括 username 和 password 两个字段,返回的状态码是 302,Response Headers 的 location 字段是根页面,同时 Response Headers 还包含了 set-cookie 信息,设置了 Session ID。

由此我们可以发现,要实现模拟登录,我们只需要模拟这个请求就好了,登录完成之后获取 Response 设置的 Cookies,将 Cookies 保存好,以后后续的请求带上 Cookies 就可以正常访问了。

好,那么我们接下来用代码实现一下吧。

requests 默认情况下每次请求都是独立互不干扰的,比如我们第一次先调用了 post 方法模拟登录,然后紧接着再调用 get 方法请求下主页面,其实这是两个完全独立的请求,第一次请求获取的 Cookies 并不能传给第二次请求,因此说,常规的顺序调用是不能起到模拟登录的效果的。

我们先来看一个无效的代码:

import requests
from urllib.parse import urljoin

BASE_URL = 'https://login2.scrape.center/'
LOGIN_URL = urljoin(BASE_URL, '/login')
INDEX_URL = urljoin(BASE_URL, '/page/1')
USERNAME = 'admin'
PASSWORD = 'admin'

response_login = requests.post(LOGIN_URL, data={
   'username': USERNAME,
   'password': PASSWORD
})

response_index = requests.get(INDEX_URL)
print('Response Status', response_index.status_code)
print('Response URL', response_index.url)

这里我们先定义了几个基本的 URL 和用户名、密码,接下来分别用 requests 请求了登录的 URL 进行模拟登录,然后紧接着请求了首页来获取页面内容,但是能正常获取数据吗?

由于 requests 可以自动处理重定向,我们最后把 Response 的 URL 打印出来,如果它的结果是 INDEX_URL,那么就证明模拟登录成功并成功爬取到了首页的内容。如果它跳回到了登录页面,那就说明模拟登录失败。

我们通过结果来验证一下,运行结果如下:

Response Status 200
Response URL https://login2.scrape.center/login?next=/page/1

这里可以看到,其最终的页面 URL 是登录页面的 URL,另外这里也可以通过 response 的 text 属性来验证页面源码,其源码内容就是登录页面的源码内容,由于内容较多,这里就不再输出比对了。

总之,这个现象说明我们并没有成功完成模拟登录,这是因为 requests 直接调用 post、get 等方法,每次请求都是一个独立的请求,都相当于是新开了一个浏览器打开这些链接,这两次请求对应的 Session 并不是同一个,因此这里我们模拟了第一个 Session 登录,而这并不能影响第二个 Session 的状态,因此模拟登录也就无效了。
那么怎样才能实现正确的模拟登录呢?

我们知道 Cookies 里面是保存了 Session ID 信息的,刚才也观察到了登录成功后 Response Headers 里面是有 set-cookie 字段,实际上这就是让浏览器生成了 Cookies。

Cookies 里面包含了 Session ID 的信息,所以只要后续的请求携带这些 Cookies,服务器便能通过 Cookies 里的 Session ID 信息找到对应的 Session,因此服务端对于这两次请求就会使用同一个 Session 了。而因为第一次我们已经完成了模拟登录,所以第一次模拟登录成功后,Session 里面就记录了用户的登录信息,第二次访问的时候,由于是同一个 Session,服务器就能知道用户当前是登录状态,就可以返回正确的结果而不再是跳转到登录页面了。

所以,这里的关键就在于两次请求的 Cookies 的传递。所以这里我们可以把第一次模拟登录后的 Cookies 保存下来,在第二次请求的时候加上这个 Cookies 就好了,所以代码可以改写如下:

import requests
from urllib.parse import urljoin

BASE_URL = 'https://login2.scrape.center/'
LOGIN_URL = urljoin(BASE_URL, '/login')
INDEX_URL = urljoin(BASE_URL, '/page/1')
USERNAME = 'admin'
PASSWORD = 'admin'

response_login = requests.post(LOGIN_URL, data={
   'username': USERNAME,
   'password': PASSWORD
}, allow_redirects=False)

cookies = response_login.cookies
print('Cookies', cookies)

response_index = requests.get(INDEX_URL, cookies=cookies)
print('Response Status', response_index.status_code)
print('Response URL', response_index.url)

由于 requests 可以自动处理重定向,所以模拟登录的过程我们要加上 allow_redirects 参数并设置为 False,使其不自动处理重定向,这里登录之后返回的 Response 我们赋值为 response_login,这样通过调用 response_login 的 cookies 就可以获取到网站的 Cookies 信息了,这里 requests 自动帮我们解析了 Response Headers 的 set-cookie 字段并设置了 Cookies,所以我们不需要手动解析 Response Headers 的内容了,直接使用 response_login 对象的 cookies 属性即可获取 Cookies。

好,接下来我们再次用 requests 的 get 方法来请求网站的 INDEX_URL,不过这里和之前不同,get 方法多加了一个参数 cookies,这就是第一次模拟登录完之后获取的 Cookies,这样第二次请求就能携带第一次模拟登录获取的 Cookies 信息了,此时网站会根据 Cookies 里面的 Session ID 信息查找到同一个 Session,校验其已经是登录状态,然后返回正确的结果。

这里我们还是输出了最终的 URL,如果其是 INDEX_URL,那就代表模拟登录成功并获取到了有效数据,否则就代表模拟登录失败。

我们看下运行结果:

Cookies <RequestsCookieJar[<Cookie sessionid=psnu8ij69f0ltecd5wasccyzc6ud41tc for login2.scrape.center/>]>
Response Status 200
Response URL https://login2.scrape.center/page/1

这下就没有问题了,这次我们发现其 URL 就是 INDEX_URL,模拟登录成功了!同时还可以进一步输出 response_index 的 text 属性看下是否获取成功。

接下来后续的爬取用同样的方式爬取即可。

但是我们发现其实这种实现方式比较烦琐,每次还需要处理 Cookies 并进行一次传递,有没有更简便的方法呢?

有的,我们可以直接借助于 requests 内置的 Session 对象来帮我们自动处理 Cookies,使用了 Session 对象之后,requests 会将每次请求后需要设置的 Cookies 自动保存好,并在下次请求时自动携带上去,就相当于帮我们维持了一个 Session 对象,这样就更方便了。

所以,刚才的代码可以简化如下:

import requests
from urllib.parse import urljoin

BASE_URL = 'https://login2.scrape.center/'
LOGIN_URL = urljoin(BASE_URL, '/login')
INDEX_URL = urljoin(BASE_URL, '/page/1')
USERNAME = 'admin'
PASSWORD = 'admin'

session = requests.Session()

response_login = session.post(LOGIN_URL, data={
   'username': USERNAME,
   'password': PASSWORD
})

cookies = session.cookies
print('Cookies', cookies)

response_index = session.get(INDEX_URL)
print('Response Status', response_index.status_code)
print('Response URL', response_index.url)

可以看到,这里我们无需再关心 Cookies 的处理和传递问题,我们声明了一个 Session 对象,然后每次调用请求的时候都直接使用 Session 对象的 post 或 get 方法就好了。

运行效果是完全一样的,结果如下:

Cookies <RequestsCookieJar[<Cookie sessionid=ssngkl4i7en9vm73bb36hxif05k10k13 for login2.scrape.center/>]>

Response Status 200

Response URL https://login2.scrape.center/page/1

因此,为了简化写法,这里建议直接使用 Session 对象来进行请求,这样我们就无需关心 Cookies 的操作了,实现起来会更加方便。

这个案例整体来说比较简单,但是如果碰上复杂一点的网站,如带有验证码,带有加密参数等等,直接用 requests 并不好处理模拟登录,如果登录不了,那岂不是整个页面都没法爬了吗?那么有没有其他的方式来解决这个问题呢?当然是有的,比如说,我们可以使用 Selenium 来通过模拟浏览器的方式实现模拟登录,然后获取模拟登录成功后的 Cookies,再把获取的 Cookies 交由 requests 等来爬取就好了。

这里我们还是以刚才的页面为例,我们可以把模拟登录这块交由 Selenium 来实现,后续的爬取交由 requests 来实现,代码实现如下:

from urllib.parse import urljoin
from selenium import webdriver
import requests
import time

BASE_URL = 'https://login2.scrape.center/'
LOGIN_URL = urljoin(BASE_URL, '/login')
INDEX_URL = urljoin(BASE_URL, '/page/1')
USERNAME = 'admin'
PASSWORD = 'admin'

browser = webdriver.Chrome()
browser.get(BASE_URL)
browser.find_element_by_css_selector('input[name="username"]').send_keys(USERNAME)
browser.find_element_by_css_selector('input[name="password"]').send_keys(PASSWORD)
browser.find_element_by_css_selector('input[type="submit"]').click()
time.sleep(10)

# get cookies from selenium
cookies = browser.get_cookies()
print('Cookies', cookies)
browser.close()

# set cookies to requests
session = requests.Session()
for cookie in cookies:
   session.cookies.set(cookie['name'], cookie['value'])

response_index = session.get(INDEX_URL)
print('Response Status', response_index.status_code)
print('Response URL', response_index.url)

这里我们使用 Selenium 先打开了 Chrome 浏览器,然后跳转到了登录页面,随后模拟输入了用户名和密码,接着点击了登录按钮,这时候我们可以发现浏览器里面就提示登录成功,然后成功跳转到了主页面。

这时候,我们通过调用 get_cookies 方法便能获取到当前浏览器所有的 Cookies,这就是模拟登录成功之后的 Cookies,用这些 Cookies 我们就能访问其他的数据了。

接下来,我们声明了 requests 的 Session 对象,然后遍历了刚才的 Cookies 并设置到 Session 对象的 cookies 上面去,接着再拿着这个 Session 对象去请求 INDEX_URL,也就能够获取到对应的信息而不会跳转到登录页面了。

运行结果如下:

Cookies [{'domain': 'login2.scrape.center', 'expiry': 1589043753.553155, 'httpOnly': True, 'name': 'sessionid', 'path': '/', 'sameSite': 'Lax', 'secure': False, 'value': 'rdag7ttjqhvazavpxjz31y0tmze81zur'}]

Response Status 200

Response URL https://login2.scrape.center/page/1

可以看到这里的模拟登录和后续的爬取也成功了。所以说,如果碰到难以模拟登录的过程,我们也可以使用 Selenium 或 Pyppeteer 等模拟浏览器操作的方式来实现,其目的就是取到登录后的 Cookies,有了 Cookies 之后,我们再用这些 Cookies 爬取其他页面就好了。

所以这里我们也可以发现,对于基于 Session + Cookies 验证的网站,模拟登录的核心要点就是获取 Cookies,这个 Cookies 可以被保存下来或传递给其他的程序继续使用。甚至说可以将 Cookies 持久化存储或传输给其他终端来使用。另外,为了提高 Cookies 利用率或降低封号几率,可以搭建一个 Cookies 池实现 Cookies 的随机取用。

案例二

对于案例二这种基于 JWT 的网站,其通常都是采用前后端分离式的,前后端的数据传输依赖于 Ajax,登录验证依赖于 JWT 本身这个 token 的值,如果 JWT 这个 token 是有效的,那么服务器就能返回想要的数据。

下面我们先来在浏览器里面操作登录,观察下其网络请求过程,如图所示。

image (5).png
这里我们发现登录时其请求的 URL 为 https://login3.scrape.center/api/login,是通过 Ajax 请求的,同时其 Request Body 是 JSON 格式的数据,而不是 Form Data,返回状态码为 200。

然后再看下返回结果,如图所示。

image (6).png
可以看到返回结果是一个 JSON 格式的数据,包含一个 token 字段,其结果为:

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxLCJ1c2VybmFtZSI6ImFkbWluIiwiZXhwIjoxNTg3ODc3OTQ2LCJlbWFpbCI6ImFkbWluQGFkbWluLmNvbSIsIm9yaWdfaWF0IjoxNTg3ODM0NzQ2fQ.ujEXXAZcCDyIfRLs44i_jdfA3LIp5Jc74n-Wq2udCR8

这就是我们上一课时所讲的 JWT 的内容,格式是三段式的,通过“.”来分隔。

那么有了这个 JWT 之后,后续的数据怎么获取呢?下面我们再来观察下后续的请求内容,如图所示。

image (7).png
这里我们可以发现,后续获取数据的 Ajax 请求中的 Request Headers 里面就多了一个 Authorization 字段,其结果为 jwt 然后加上刚才的 JWT 的内容,返回结果就是 JSON 格式的数据。

image (8).png
没有问题,那模拟登录的整个思路就简单了:
模拟请求登录结果,带上必要的登录信息,获取 JWT 的结果。

后续的请求在 Request Headers 里面加上 Authorization 字段,值就是 JWT 对应的内容。
好,接下来我们用代码实现如下:

import requests
from urllib.parse import urljoin

BASE_URL = 'https://login3.scrape.center/'
LOGIN_URL = urljoin(BASE_URL, '/api/login')
INDEX_URL = urljoin(BASE_URL, '/api/book')
USERNAME = 'admin'
PASSWORD = 'admin'

response_login = requests.post(LOGIN_URL, json={
   'username': USERNAME,
   'password': PASSWORD
})
data = response_login.json()
print('Response JSON', data)
jwt = data.get('token')
print('JWT', jwt)

headers = {
   'Authorization': f'jwt {jwt}'
}
response_index = requests.get(INDEX_URL, params={
   'limit': 18,
   'offset': 0
}, headers=headers)
print('Response Status', response_index.status_code)
print('Response URL', response_index.url)
print('Response Data', response_index.json())

这里我们同样是定义了登录接口和获取数据的接口,分别为 LOGIN_URL 和 INDEX_URL,接着通过 post 请求进行了模拟登录,这里提交的数据由于是 JSON 格式,所以这里使用 json 参数来传递。接着获取了返回结果中包含的 JWT 的结果。第二步就可以构造 Request Headers,然后设置 Authorization 字段并传入 JWT 即可,这样就能成功获取数据了。

运行结果如下:

Response JSON {'token': 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxLCJ1c2VybmFtZSI6ImFkbWluIiwiZXhwIjoxNTg3ODc4NzkxLCJlbWFpbCI6ImFkbWluQGFkbWluLmNvbSIsIm9yaWdfaWF0IjoxNTg3ODM1NTkxfQ.iUnu3Yhdi_a-Bupb2BLgCTUd5yHL6jgPhkBPorCPvm4'}

JWT eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxLCJ1c2VybmFtZSI6ImFkbWluIiwiZXhwIjoxNTg3ODc4NzkxLCJlbWFpbCI6ImFkbWluQGFkbWluLmNvbSIsIm9yaWdfaWF0IjoxNTg3ODM1NTkxfQ.iUnu3Yhdi_a-Bupb2BLgCTUd5yHL6jgPhkBPorCPvm4

Response Status 200
Response URL https://login3.scrape.center/api/book/?limit=18&offset=0
Response Data {'count': 9200, 'results': [{'id': '27135877', 'name': '校园市场:布局未来消费群,决战年轻人市场', 'authors': ['单兴华', '李烨'], 'cover': 'https://img9.doubanio.com/view/subject/l/public/s29539805.jpg', 'score': '5.5'},
...
{'id': '30289316', 'name': '就算這樣,還是喜歡你,笠原先生', 'authors': ['おまる'], 'cover': 'https://img3.doubanio.com/view/subject/l/public/s29875002.jpg', 'score': '7.5'}]}

可以看到,这里成功输出了 JWT 的内容,同时最终也获取到了对应的数据,模拟登录成功!

类似的思路,如果我们遇到 JWT 认证的网站,也可以通过类似的方式来实现模拟登录。当然可能某些页面比较复杂,需要具体情况具体分析。

总结

以上我们就通过两个示例来演示了模拟登录爬取的过程,以后遇到这种情形的时候就可以用类似的思路解决了。

代码:https://github.com/Python3WebSpider/ScrapeLogin2https://github.com/Python3WebSpider/ScrapeLogin3

令人抓狂的JavaScript混淆技术

我们在爬取网站的时候,经常会遇到各种各样类似加密的情形,比如:

  • 某个网站的 URL 带有一些看不懂的长串加密参数,想要抓取就必须要懂得这些参数是怎么构造的,否则我们连完整的 URL 都构造不出来,更不用说爬取了。
  • 分析某个网站的 Ajax 接口的时候,可以看到接口的一些参数也是加密的,或者 Request Headers 里面也可能带有一些加密参数,如果不知道这些参数的具体构造逻辑就无法直接用程序来模拟这些 Ajax 请求。
  • 翻看网站的 JavaScript 源代码,可以发现很多压缩了或者看不太懂的字符,比如 JavaScript 文件名被编码,JavaScript 的文件内容被压缩成几行,JavaScript 变量也被修改成单个字符或者一些十六进制的字符,导致我们不好轻易根据 JavaScript 找出某些接口的加密逻辑。

这些情况,基本上都是网站为了保护其本身的一些数据不被轻易抓取而采取的一些措施,我们可以把它归为两大类:

  • 接口加密技术;
  • JavaScript 压缩、混淆和加密技术。

本课时我们就来了解下这两类技术的实现原理。

数据保护

当今大数据时代,数据已经变得越来越重要,网页和 App 现在是主流的数据载体,如果其数据的接口没有设置任何保护措施,在爬虫工程师解决了一些基本的反爬如封 IP、验证码的问题之后,那么数据还是可以被轻松抓取到。

那么,有没有可能在接口或 JavaScript 层面也加上一层防护呢?答案是可以的。

接口加密技术

网站运营商首先想到防护措施可能是对某些数据接口进行加密,比如说对某些 URL 的一些参数加上校验码或者把一些 ID 信息进行编码,使其变得难以阅读或构造;或者对某些接口请求加上一些 token、sign 等签名,这样这些请求发送到服务器时,服务器会通过客户端发来的一些请求信息以及双方约定好的秘钥等来对当前的请求进行校验,如果校验通过,才返回对应数据结果。

比如说客户端和服务端约定一种接口校验逻辑,客户端在每次请求服务端接口的时候都会附带一个 sign 参数,这个 sign 参数可能是由当前时间信息、请求的 URL、请求的数据、设备的 ID、双方约定好的秘钥经过一些加密算法构造而成的,客户端会实现这个加密算法构造 sign,然后每次请求服务器的时候附带上这个参数。服务端会根据约定好的算法和请求的数据对 sign 进行校验,如果校验通过,才返回对应的数据,否则拒绝响应。

JavaScript 压缩、混淆和加密技术

接口加密技术看起来的确是一个不错的解决方案,但单纯依靠它并不能很好地解决问题。为什么呢?

对于网页来说,其逻辑是依赖于 JavaScript 来实现的,JavaScript 有如下特点:

  • JavaScript 代码运行于客户端,也就是它必须要在用户浏览器端加载并运行。
  • JavaScript 代码是公开透明的,也就是说浏览器可以直接获取到正在运行的 JavaScript 的源码。

由于这两个原因,导致 JavaScript 代码是不安全的,任何人都可以读、分析、复制、盗用,甚至篡改。

所以说,对于上述情形,客户端 JavaScript 对于某些加密的实现是很容易被找到或模拟的,了解了加密逻辑后,模拟参数的构造和请求也就是轻而易举了,所以如果 JavaScript 没有做任何层面的保护的话,接口加密技术基本上对数据起不到什么防护作用。

如果你不想让自己的数据被轻易获取,不想他人了解 JavaScript 逻辑的实现,或者想降低被不怀好意的人甚至是黑客攻击。那么你就需要用到 JavaScript 压缩、混淆和加密技术了。

这里压缩、混淆、加密技术简述如下。

  • 代码压缩:即去除 JavaScript 代码中的不必要的空格、换行等内容,使源码都压缩为几行内容,降低代码可读性,当然同时也能提高网站的加载速度。
  • 代码混淆:使用变量替换、字符串阵列化、控制流平坦化、多态变异、僵尸函数、调试保护等手段,使代码变得难以阅读和分析,达到最终保护的目的。但这不影响代码原有功能。是理想、实用的 JavaScript 保护方案。
  • 代码加密:可以通过某种手段将 JavaScript 代码进行加密,转成人无法阅读或者解析的代码,如将代码完全抽象化加密,如 eval 加密。另外还有更强大的加密技术,可以直接将 JavaScript 代码用 C/C++ 实现,JavaScript 调用其编译后形成的文件来执行相应的功能,如 Emscripten 还有 WebAssembly。

下面我们对上面的技术分别予以介绍。

接口加密技术

数据一般都是通过服务器提供的接口来获取的,网站或 App 可以请求某个数据接口获取到对应的数据,然后再把获取的数据展示出来。

但有些数据是比较宝贵或私密的,这些数据肯定是需要一定层面上的保护。所以不同接口的实现也就对应着不同的安全防护级别,我们这里来总结下。

完全开放的接口

有些接口是没有设置任何防护的,谁都可以调用和访问,而且没有任何时空限制和频率限制。任何人只要知道了接口的调用方式就能无限制地调用。

这种接口的安全性是非常非常低的,如果接口的调用方式一旦泄露或被抓包获取到,任何人都可以无限制地对数据进行操作或访问。此时如果接口里面包含一些重要的数据或隐私数据,就能轻易被篡改或窃取了。

接口参数加密

为了提升接口的安全性,客户端会和服务端约定一种接口校验方式,一般来说会使用到各种加密和编码算法,如 Base64、Hex 编码,MD5、AES、DES、RSA 等加密。

比如客户端和服务器双方约定一个 sign 用作接口的签名校验,其生成逻辑是客户端将 URL Path 进行 MD5 加密然后拼接上 URL 的某个参数再进行 Base64 编码,最后得到一个字符串 sign,这个 sign 会通过 Request URL 的某个参数或 Request Headers 发送给服务器。服务器接收到请求后,对 URL Path 同样进行 MD5 加密,然后拼接上 URL 的某个参数,也进行 Base64 编码得到了一个 sign,然后比对生成的 sign 和客户端发来的 sign 是否是一致的,如果是一致的,那就返回正确的结果,否则拒绝响应。这就是一个比较简单的接口参数加密的实现。如果有人想要调用这个接口的话,必须要定义好 sign 的生成逻辑,否则是无法正常调用接口的。

以上就是一个基本的接口参数加密逻辑的实现。

当然上面的这个实现思路比较简单,这里还可以增加一些时间戳信息增加时效性判断,或增加一些非对称加密进一步提高加密的复杂程度。但不管怎样,只要客户端和服务器约定好了加密和校验逻辑,任何形式加密算法都是可以的。

这里要实现接口参数加密就需要用到一些加密算法,客户端和服务器肯定也都有对应的 SDK 实现这些加密算法,如 JavaScript 的 crypto-js,Python 的 hashlib、Crypto 等等。

但还是如上文所说,如果是网页的话,客户端实现加密逻辑如果是用 JavaScript 来实现,其源代码对用户是完全可见的,如果没有对 JavaScript 做任何保护的话,是很容易弄清楚客户端加密的流程的。

因此,我们需要对 JavaScript 利用压缩、混淆、加密的方式来对客户端的逻辑进行一定程度上的保护。

JavaScript 压缩、混淆、加密

下面我们再来介绍下 JavaScript 的压缩、混淆和加密技术。

JavaScript 压缩

这个非常简单,JavaScript 压缩即去除 JavaScript 代码中的不必要的空格、换行等内容或者把一些可能公用的代码进行处理实现共享,最后输出的结果都被压缩为几行内容,代码可读性变得很差,同时也能提高网站加载速度。

如果仅仅是去除空格换行这样的压缩方式,其实几乎是没有任何防护作用的,因为这种压缩方式仅仅是降低了代码的直接可读性。如果我们有一些格式化工具可以轻松将 JavaScript 代码变得易读,比如利用 IDE、在线工具或 Chrome 浏览器都能还原格式化的代码。

目前主流的前端开发技术大多都会利用 Webpack 进行打包,Webpack 会对源代码进行编译和压缩,输出几个打包好的 JavaScript 文件,其中我们可以看到输出的 JavaScript 文件名带有一些不规则字符串,同时文件内容可能只有几行内容,变量名都是一些简单字母表示。这其中就包含 JavaScript 压缩技术,比如一些公共的库输出成 bundle 文件,一些调用逻辑压缩和转义成几行代码,这些都属于 JavaScript 压缩。另外其中也包含了一些很基础的 JavaScript 混淆技术,比如把变量名、方法名替换成一些简单字符,降低代码可读性。

但整体来说,JavaScript 压缩技术只能在很小的程度上起到防护作用,要想真正提高防护效果还得依靠 JavaScript 混淆和加密技术。

JavaScript 混淆

JavaScript 混淆完全是在 JavaScript 上面进行的处理,它的目的就是使得 JavaScript 变得难以阅读和分析,大大降低代码可读性,是一种很实用的 JavaScript 保护方案。

JavaScript 混淆技术主要有以下几种:

  • 变量混淆

将带有含意的变量名、方法名、常量名随机变为无意义的类乱码字符串,降低代码可读性,如转成单个字符或十六进制字符串。

  • 字符串混淆

将字符串阵列化集中放置、并可进行 MD5 或 Base64 加密存储,使代码中不出现明文字符串,这样可以避免使用全局搜索字符串的方式定位到入口点。

  • 属性加密

针对 JavaScript 对象的属性进行加密转化,隐藏代码之间的调用关系。

  • 控制流平坦化

打乱函数原有代码执行流程及函数调用关系,使代码逻变得混乱无序。

  • 僵尸代码

随机在代码中插入无用的僵尸代码、僵尸函数,进一步使代码混乱。

  • 调试保护

基于调试器特性,对当前运行环境进行检验,加入一些强制调试 debugger 语句,使其在调试模式下难以顺利执行 JavaScript 代码。

  • 多态变异

使 JavaScript 代码每次被调用时,将代码自身即立刻自动发生变异,变化为与之前完全不同的代码,即功能完全不变,只是代码形式变异,以此杜绝代码被动态分析调试。

  • 锁定域名

使 JavaScript 代码只能在指定域名下执行。

  • 反格式化

如果对 JavaScript 代码进行格式化,则无法执行,导致浏览器假死。

  • 特殊编码

将 JavaScript 完全编码为人不可读的代码,如表情符号、特殊表示内容等等。

总之,以上方案都是 JavaScript 混淆的实现方式,可以在不同程度上保护 JavaScript 代码。

在前端开发中,现在 JavaScript 混淆主流的实现是 javascript-obfuscator 这个库,利用它我们可以非常方便地实现页面的混淆,它与 Webpack 结合起来,最终可以输出压缩和混淆后的 JavaScript 代码,使得可读性大大降低,难以逆向。

下面我们会介绍下 javascript-obfuscator 对代码混淆的实现,了解了实现,那么自然我们就对混淆的机理有了更加深刻的认识。

javascript-obfuscator 的官网地址为:https://obfuscator.io/,其官方介绍内容如下:

A free and efficient obfuscator for JavaScript (including ES2017). Make your code harder to copy and prevent people from stealing your work.

它是支持 ES8 的免费、高效的 JavaScript 混淆库,它可以使得你的 JavaScript 代码经过混淆后难以被复制、盗用,混淆后的代码具有和原来的代码一模一样的功能。

怎么使用呢?首先,我们需要安装好 Node.js,可以使用 npm 命令。

然后新建一个文件夹,比如 js-obfuscate,随后进入该文件夹,初始化工作空间:

npm init

这里会提示我们输入一些信息,创建一个 package.json 文件,这就完成了项目初始化了。

接下来我们来安装 javascript-obfuscator 这个库:

npm install --save-dev javascript-obfuscator

接下来我们就可以编写代码来实现混淆了,如新建一个 main.js 文件,内容如下:

const code = `
let x = '1' + 1
console.log('x', x)
`

const options = {
   compact: false,
   controlFlowFlattening: true

}

const obfuscator = require('javascript-obfuscator')
function obfuscate(code, options) {
   return obfuscator.obfuscate(code, options).getObfuscatedCode()
}
console.log(obfuscate(code, options))

在这里我们定义了两个变量,一个是 code,即需要被混淆的代码,另一个是混淆选项,是一个 Object。接下来我们引入了 javascript-obfuscator 库,然后定义了一个方法,传入 code 和 options,来获取混淆后的代码,最后控制台输出混淆后的代码。

代码逻辑比较简单,我们来执行一下代码:

node main.js

输出结果如下:

var _0x53bf = ['log'];
(function (_0x1d84fe, _0x3aeda0) {
   var _0x10a5a = function (_0x2f0a52) {
       while (--_0x2f0a52) {
           _0x1d84fe['push'](_0x1d84fe['shift']());
      }
  };
   _0x10a5a(++_0x3aeda0);
}(_0x53bf, 0x172));
var _0x480a = function (_0x4341e5, _0x5923b4) {
   _0x4341e5 = _0x4341e5 - 0x0;
   var _0xb3622e = _0x53bf[_0x4341e5];
   return _0xb3622e;
};
let x = '1' + 0x1;
console[_0x480a('0x0')]('x', x);

看到了吧,这么简单的两行代码,被我们混淆成了这个样子,其实这里我们就是设定了一个“控制流扁平化”的选项。

整体看来,代码的可读性大大降低,也大大加大了 JavaScript 调试的难度。

好,接下来我们来跟着 javascript-obfuscator 走一遍,就能具体知道 JavaScript 混淆到底有多少方法了。

代码压缩

这里 javascript-obfuscator 也提供了代码压缩的功能,使用其参数 compact 即可完成 JavaScript 代码的压缩,输出为一行内容。默认是 true,如果定义为 false,则混淆后的代码会分行显示。

示例如下:

const code = `
let x = '1' + 1
console.log('x', x)
`
const options = {
   compact: false
}

这里我们先把代码压缩 compact 选项设置为 false,运行结果如下:

let x = '1' + 0x1;
console['log']('x', x);

如果不设置 compact 或把 compact 设置为 true,结果如下:

var _0x151c=['log'];(function(_0x1ce384,_0x20a7c7){var _0x25fc92=function(_0x188aec){while(--_0x188aec){_0x1ce384['push'](_0x1ce384['shift']());}};_0x25fc92(++_0x20a7c7);}(_0x151c,0x1b7));var _0x553e=function(_0x259219,_0x241445){_0x259219=_0x259219-0x0;var _0x56d72d=_0x151c[_0x259219];return _0x56d72d;};let x='1'+0x1;console[_0x553e('0x0')]('x',x);

可以看到单行显示的时候,对变量名进行了进一步的混淆和控制流扁平化操作。

变量名混淆

变量名混淆可以通过配置 identifierNamesGenerator 参数实现,我们通过这个参数可以控制变量名混淆的方式,如 hexadecimal 则会替换为 16 进制形式的字符串,在这里我们可以设定如下值:

  • hexadecimal:将变量名替换为 16 进制形式的字符串,如 0xabc123。
  • mangled:将变量名替换为普通的简写字符,如 a、b、c 等。

该参数默认为 hexadecimal。

我们将该参数修改为 mangled 来试一下:

const code = `
let hello = '1' + 1
console.log('hello', hello)
`
const options = {
  compact: true,
  identifierNamesGenerator: 'mangled'
}

运行结果如下:

var a=['hello'];(function(c,d){var e=function(f){while(--f){c['push'](c['shift']());}};e(++d);}(a,0x9b));var b=function(c,d){c=c-0x0;var e=a[c];return e;};let hello='1'+0x1;console['log'](b('0x0'),hello);

可以看到这里的变量命名都变成了 a、b 等形式。

如果我们将 identifierNamesGenerator 修改为 hexadecimal 或者不设置,运行结果如下:

var _0x4e98=['log','hello'];(function(_0x4464de,_0x39de6c){var _0xdffdda=function(_0x6a95d5){while(--_0x6a95d5){_0x4464de['push'](_0x4464de['shift']());}};_0xdffdda(++_0x39de6c);}(_0x4e98,0xc8));var _0x53cb=function(_0x393bda,_0x8504e7){_0x393bda=_0x393bda-0x0;var _0x46ab80=_0x4e98[_0x393bda];return _0x46ab80;};let hello='1'+0x1;console[_0x53cb('0x0')](_0x53cb('0x1'),hello);

可以看到选用了 mangled,其代码体积会更小,但 hexadecimal 其可读性会更低。

另外我们还可以通过设置 identifiersPrefix 参数来控制混淆后的变量前缀,示例如下:

const code = `
let hello = '1' + 1
console.log('hello', hello)
`
const options = {
  identifiersPrefix: 'germey'
}

运行结果:

var germey_0x3dea=['log','hello'];(function(_0x348ff3,_0x5330e8){var _0x1568b1=function(_0x4740d8){while(--_0x4740d8){_0x348ff3['push'](_0x348ff3['shift']());}};_0x1568b1(++_0x5330e8);}(germey_0x3dea,0x94));var germey_0x30e4=function(_0x2e8f7c,_0x1066a8){_0x2e8f7c=_0x2e8f7c-0x0;var _0x5166ba=germey_0x3dea[_0x2e8f7c];return _0x5166ba;};let hello='1'+0x1;console[germey_0x30e4('0x0')](germey_0x30e4('0x1'),hello);

可以看到混淆后的变量前缀加上了我们自定义的字符串 germey。

另外 renameGlobals 这个参数还可以指定是否混淆全局变量和函数名称,默认为 false。示例如下:

const code = `
var $ = function(id) {
  return document.getElementById(id);
};
`
const options = {
  renameGlobals: true
}

运行结果如下:

var _0x4864b0=function(_0x5763be){return document['getElementById'](_0x5763be);};

可以看到这里我们声明了一个全局变量 $,在 renameGlobals 设置为 true 之后,$ 这个变量也被替换了。如果后文用到了这个 $ 对象,可能就会有找不到定义的错误,因此这个参数可能导致代码执行不通。

如果我们不设置 renameGlobals 或者设置为 false,结果如下:

var _0x239a=['getElementById'];(function(_0x3f45a3,_0x583dfa){var _0x2cade2=function(_0x28479a){while(--_0x28479a){_0x3f45a3['push'](_0x3f45a3['shift']());}};_0x2cade2(++_0x583dfa);}(_0x239a,0xe1));var _0x3758=function(_0x18659d,_0x50c21d){_0x18659d=_0x18659d-0x0;var _0x531b8d=_0x239a[_0x18659d];return _0x531b8d;};var $=function(_0x3d8723){return document[_0x3758('0x0')](_0x3d8723);};

可以看到,最后还是有 $ 的声明,其全局名称没有被改变。

字符串混淆

字符串混淆,即将一个字符串声明放到一个数组里面,使之无法被直接搜索到。我们可以通过控制 stringArray 参数来控制,默认为 true。

我们还可以通过 rotateStringArray 参数来控制数组化后结果的元素顺序,默认为 true。
还可以通过 stringArrayEncoding 参数来控制数组的编码形式,默认不开启编码,如果设置为 true 或 base64,则会使用 Base64 编码,如果设置为 rc4,则使用 RC4 编码。
还可以通过 stringArrayThreshold 来控制启用编码的概率,范围 0 到 1,默认 0.8。

示例如下:

const code = `
var a = 'hello world'
`
const options = {
  stringArray: true,
  rotateStringArray: true,
  stringArrayEncoding: true, // 'base64' or 'rc4' or false
  stringArrayThreshold: 1,
}

运行结果如下:

var _0x4215=['aGVsbG8gd29ybGQ='];(function(_0x42bf17,_0x4c348f){var _0x328832=function(_0x355be1){while(--_0x355be1){_0x42bf17['push'](_0x42bf17['shift']());}};_0x328832(++_0x4c348f);}(_0x4215,0x1da));var _0x5191=function(_0x3cf2ba,_0x1917d8){_0x3cf2ba=_0x3cf2ba-0x0;var _0x1f93f0=_0x4215[_0x3cf2ba];if(_0x5191['LqbVDH']===undefined){(function(){var _0x5096b2;try{var _0x282db1=Function('return\x20(function()\x20'+'{}.constructor(\x22return\x20this\x22)(\x20)'+');');_0x5096b2=_0x282db1();}catch(_0x2acb9c){_0x5096b2=window;}var _0x388c14='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';_0x5096b2['atob']||(_0x5096b2['atob']=function(_0x4cc27c){var _0x2af4ae=String(_0x4cc27c)['replace'](/=+$/,'');for(var _0x21400b=0x0,_0x3f4e2e,_0x5b193b,_0x233381=0x0,_0x3dccf7='';_0x5b193b=_0x2af4ae['charAt'](_0x233381++);~_0x5b193b&&(_0x3f4e2e=_0x21400b%0x4?_0x3f4e2e*0x40+_0x5b193b:_0x5b193b,_0x21400b++%0x4)?_0x3dccf7+=String['fromCharCode'](0xff&_0x3f4e2e>>(-0x2*_0x21400b&0x6)):0x0){_0x5b193b=_0x388c14['indexOf'](_0x5b193b);}return _0x3dccf7;});}());_0x5191['DuIurT']=function(_0x51888e){var _0x29801f=atob(_0x51888e);var _0x561e62=[];for(var _0x5dd788=0x0,_0x1a8b73=_0x29801f['length'];_0x5dd788<_0x1a8b73;_0x5dd788++){_0x561e62+='%'+('00'+_0x29801f['charCodeAt'](_0x5dd788)['toString'](0x10))['slice'](-0x2);}return decodeURIComponent(_0x561e62);};_0x5191['mgoBRd']={};_0x5191['LqbVDH']=!![];}var _0x1741f0=_0x5191['mgoBRd'][_0x3cf2ba];if(_0x1741f0===undefined){_0x1f93f0=_0x5191['DuIurT'](_0x1f93f0);_0x5191['mgoBRd'][_0x3cf2ba]=_0x1f93f0;}else{_0x1f93f0=_0x1741f0;}return _0x1f93f0;};var a=_0x5191('0x0');

可以看到这里就把字符串进行了 Base64 编码,我们再也无法通过查找的方式找到字符串的位置了。

如果将 stringArray 设置为 false 的话,输出就是这样:

var a='hello\x20world';

字符串就仍然是明文显示的,没有被编码。

另外我们还可以使用 unicodeEscapeSequence 这个参数对字符串进行 Unicode 转码,使之更加难以辨认,示例如下:

const code = `
var a = 'hello world'
`
const options = {
  compact: false,
  unicodeEscapeSequence: true
}

运行结果如下:

var _0x5c0d = ['\x68\x65\x6c\x6c\x6f\x20\x77\x6f\x72\x6c\x64'];
(function (_0x54cc9c, _0x57a3b2) {
  var _0xf833cf = function (_0x3cd8c6) {
    while (--_0x3cd8c6) {
      _0x54cc9c['push'](_0x54cc9c['shift']());
    }
};
_0xf833cf(++_0x57a3b2);
}(_0x5c0d, 0x17d));
var _0x28e8 = function (_0x3fd645, _0x2cf5e7) {
  _0x3fd645 = _0x3fd645 - 0x0;
  var _0x298a20 = _0x5c0d[_0x3fd645];
  return _0x298a20;
};
var a = _0x28e8('0x0');

可以看到,这里字符串被数字化和 Unicode 化,非常难以辨认。

在很多 JavaScript 逆向的过程中,一些关键的字符串可能会作为切入点来查找加密入口。用了这种混淆之后,如果有人想通过全局搜索的方式搜索 hello 这样的字符串找加密入口,也没法搜到了。

代码自我保护

我们可以通过设置 selfDefending 参数来开启代码自我保护功能。开启之后,混淆后的 JavaScript 会强制以一行形式显示,如果我们将混淆后的代码进行格式化(美化)或者重命名,该段代码将无法执行。

例如:

const code = `
console.log('hello world')
`
const options = {
  selfDefending: true
}

运行结果如下:

var _0x26da=['log','hello\x20world'];(function(_0x190327,_0x57c2c0){var _0x577762=function(_0xc9dabb){while(--_0xc9dabb){_0x190327['push'](_0x190327['shift']());}};var _0x35976e=function(){var _0x16b3fe={'data':{'key':'cookie','value':'timeout'},'setCookie':function(_0x2d52d5,_0x16feda,_0x57cadf,_0x56056f){_0x56056f=_0x56056f||{};var _0x5b6dc3=_0x16feda+'='+_0x57cadf;var _0x333ced=0x0;for(var _0x333ced=0x0,_0x19ae36=_0x2d52d5['length'];_0x333ced<_0x19ae36;_0x333ced++){var _0x409587=_0x2d52d5[_0x333ced];_0x5b6dc3+=';\x20'+_0x409587;var _0x4aa006=_0x2d52d5[_0x409587];_0x2d52d5['push'](_0x4aa006);_0x19ae36=_0x2d52d5['length'];if(_0x4aa006!==!![]){_0x5b6dc3+='='+_0x4aa006;}}_0x56056f['cookie']=_0x5b6dc3;},'removeCookie':function(){return'dev';},'getCookie':function(_0x30c497,_0x51923d){_0x30c497=_0x30c497||function(_0x4b7e18){return _0x4b7e18;};var _0x557e06=_0x30c497(new RegExp('(?:^|;\x20)'+_0x51923d['replace'](/([.$?*|{}()[]\/+^])/g,'$1')+'=([^;]*)'));var _0x817646=function(_0xf3fae7,_0x5d8208){_0xf3fae7(++_0x5d8208);};_0x817646(_0x577762,_0x57c2c0);return _0x557e06?decodeURIComponent(_0x557e06[0x1]):undefined;}};var _0x4673cd=function(){var _0x4c6c5c=new RegExp('\x5cw+\x20*\x5c(\x5c)\x20*{\x5cw+\x20*[\x27|\x22].+[\x27|\x22];?\x20*}');return _0x4c6c5c['test'](_0x16b3fe['removeCookie']['toString']());};_0x16b3fe['updateCookie']=_0x4673cd;var _0x5baa80='';var _0x1faf19=_0x16b3fe['updateCookie']();if(!_0x1faf19){_0x16b3fe['setCookie'](['*'],'counter',0x1);}else if(_0x1faf19){_0x5baa80=_0x16b3fe['getCookie'](null,'counter');}else{_0x16b3fe['removeCookie']();}};_0x35976e();}(_0x26da,0x140));var _0x4391=function(_0x1b42d8,_0x57edc8){_0x1b42d8=_0x1b42d8-0x0;var _0x2fbeca=_0x26da[_0x1b42d8];return _0x2fbeca;};var _0x197926=function(){var _0x10598f=!![];return function(_0xffa3b3,_0x7a40f9){var _0x48e571=_0x10598f?function(){if(_0x7a40f9){var _0x2194b5=_0x7a40f9['apply'](_0xffa3b3,arguments);_0x7a40f9=null;return _0x2194b5;}}:function(){};_0x10598f=![];return _0x48e571;};}();var _0x2c6fd7=_0x197926(this,function(){var _0x4828bb=function(){return'\x64\x65\x76';},_0x35c3bc=function(){return'\x77\x69\x6e\x64\x6f\x77';};var _0x456070=function(){var _0x4576a4=new RegExp('\x5c\x77\x2b\x20\x2a\x5c\x28\x5c\x29\x20\x2a\x7b\x5c\x77\x2b\x20\x2a\x5b\x27\x7c\x22\x5d\x2e\x2b\x5b\x27\x7c\x22\x5d\x3b\x3f\x20\x2a\x7d');return!_0x4576a4['\x74\x65\x73\x74'](_0x4828bb['\x74\x6f\x53\x74\x72\x69\x6e\x67']());};var _0x3fde69=function(){var _0xabb6f4=new RegExp('\x28\x5c\x5c\x5b\x78\x7c\x75\x5d\x28\x5c\x77\x29\x7b\x32\x2c\x34\x7d\x29\x2b');return _0xabb6f4['\x74\x65\x73\x74'](_0x35c3bc['\x74\x6f\x53\x74\x72\x69\x6e\x67']());};var _0x2d9a50=function(_0x58fdb4){var _0x2a6361=~-0x1>>0x1+0xff%0x0;if(_0x58fdb4['\x69\x6e\x64\x65\x78\x4f\x66']('\x69'===_0x2a6361)){_0xc388c5(_0x58fdb4);}};var _0xc388c5=function(_0x2073d6){var _0x6bb49f=~-0x4>>0x1+0xff%0x0;if(_0x2073d6['\x69\x6e\x64\x65\x78\x4f\x66']((!![]+'')[0x3])!==_0x6bb49f){_0x2d9a50(_0x2073d6);}};if(!_0x456070()){if(!_0x3fde69()){_0x2d9a50('\x69\x6e\x64\u0435\x78\x4f\x66');}else{_0x2d9a50('\x69\x6e\x64\x65\x78\x4f\x66');}}else{_0x2d9a50('\x69\x6e\x64\u0435\x78\x4f\x66');}});_0x2c6fd7();console[_0x4391('0x0')](_0x4391('0x1'));

如果我们将上述代码放到控制台,它的执行结果和之前是一模一样的,没有任何问题。
如果我们将其进行格式化,会变成如下内容:

var _0x26da = ['log', 'hello\x20world'];
(function (_0x190327, _0x57c2c0) {
    var _0x577762 = function (_0xc9dabb) {
        while (--_0xc9dabb) {
            _0x190327['push'](_0x190327['shift']());
        }
    };
    var _0x35976e = function () {
        var _0x16b3fe = {
            'data': {
                'key': 'cookie',
                'value': 'timeout'
            },
            'setCookie': function (_0x2d52d5, _0x16feda, _0x57cadf, _0x56056f) {
                _0x56056f = _0x56056f || {};
                var _0x5b6dc3 = _0x16feda + '=' + _0x57cadf;
                var _0x333ced = 0x0;
                for (var _0x333ced = 0x0, _0x19ae36 = _0x2d52d5['length']; _0x333ced < _0x19ae36; _0x333ced++) {
                    var _0x409587 = _0x2d52d5[_0x333ced];
                    _0x5b6dc3 += ';\x20' + _0x409587;
                    var _0x4aa006 = _0x2d52d5[_0x409587];
                    _0x2d52d5['push'](_0x4aa006);
                    _0x19ae36 = _0x2d52d5['length'];
                    if (_0x4aa006 !== !![]) {
                        _0x5b6dc3 += '=' + _0x4aa006;
                    }
                }
                _0x56056f['cookie'] = _0x5b6dc3;
            }, 'removeCookie': function () {
                return 'dev';
            }, 'getCookie': function (_0x30c497, _0x51923d) {
                _0x30c497 = _0x30c497 || function (_0x4b7e18) {
                    return _0x4b7e18;
                };
                var _0x557e06 = _0x30c497(new RegExp('(?:^|;\x20)' + _0x51923d['replace'](/([.$?*|{}()[]\/+^])/g, '$1') + '=([^;]*)'));
                var _0x817646 = function (_0xf3fae7, _0x5d8208) {
                    _0xf3fae7(++_0x5d8208);
                };
                _0x817646(_0x577762, _0x57c2c0);
                return _0x557e06 ? decodeURIComponent(_0x557e06[0x1]) : undefined;
            }
        };
        var _0x4673cd = function () {
            var _0x4c6c5c = new RegExp('\x5cw+\x20*\x5c(\x5c)\x20*{\x5cw+\x20*[\x27|\x22].+[\x27|\x22];?\x20*}');
            return _0x4c6c5c['test'](_0x16b3fe['removeCookie']['toString']());
        };
        _0x16b3fe['updateCookie'] = _0x4673cd;
        var _0x5baa80 = '';
        var _0x1faf19 = _0x16b3fe['updateCookie']();
        if (!_0x1faf19) {
            _0x16b3fe['setCookie'](['*'], 'counter', 0x1);
        } else if (_0x1faf19) {
            _0x5baa80 = _0x16b3fe['getCookie'](null, 'counter');
        } else {
            _0x16b3fe['removeCookie']();
        }
    };
    _0x35976e();
}(_0x26da, 0x140));
var _0x4391 = function (_0x1b42d8, _0x57edc8) {
    _0x1b42d8 = _0x1b42d8 - 0x0;
    var _0x2fbeca = _0x26da[_0x1b42d8];
    return _0x2fbeca;
};
var _0x197926 = function () {
    var _0x10598f = !![];
    return function (_0xffa3b3, _0x7a40f9) {
        var _0x48e571 = _0x10598f ? function () {
            if (_0x7a40f9) {
                var _0x2194b5 = _0x7a40f9['apply'](_0xffa3b3, arguments);
                _0x7a40f9 = null;
                return _0x2194b5;
            }
        } : function () {};
        _0x10598f = ![];
        return _0x48e571;
    };
}();
var _0x2c6fd7 = _0x197926(this, function () {
    var _0x4828bb = function () {
            return '\x64\x65\x76';
        },
        _0x35c3bc = function () {
            return '\x77\x69\x6e\x64\x6f\x77';
        };
    var _0x456070 = function () {
        var _0x4576a4 = new RegExp('\x5c\x77\x2b\x20\x2a\x5c\x28\x5c\x29\x20\x2a\x7b\x5c\x77\x2b\x20\x2a\x5b\x27\x7c\x22\x5d\x2e\x2b\x5b\x27\x7c\x22\x5d\x3b\x3f\x20\x2a\x7d');
        return !_0x4576a4['\x74\x65\x73\x74'](_0x4828bb['\x74\x6f\x53\x74\x72\x69\x6e\x67']());
    };
    var _0x3fde69 = function () {
        var _0xabb6f4 = new RegExp('\x28\x5c\x5c\x5b\x78\x7c\x75\x5d\x28\x5c\x77\x29\x7b\x32\x2c\x34\x7d\x29\x2b');
        return _0xabb6f4['\x74\x65\x73\x74'](_0x35c3bc['\x74\x6f\x53\x74\x72\x69\x6e\x67']());
    };
    var _0x2d9a50 = function (_0x58fdb4) {
        var _0x2a6361 = ~-0x1 >> 0x1 + 0xff % 0x0;
        if (_0x58fdb4['\x69\x6e\x64\x65\x78\x4f\x66']('\x69' === _0x2a6361)) {
            _0xc388c5(_0x58fdb4);
        }
    };
    var _0xc388c5 = function (_0x2073d6) {
        var _0x6bb49f = ~-0x4 >> 0x1 + 0xff % 0x0;
        if (_0x2073d6['\x69\x6e\x64\x65\x78\x4f\x66']((!![] + '')[0x3]) !== _0x6bb49f) {
            _0x2d9a50(_0x2073d6);
        }
    };
    if (!_0x456070()) {
        if (!_0x3fde69()) {
            _0x2d9a50('\x69\x6e\x64\u0435\x78\x4f\x66');
        } else {
            _0x2d9a50('\x69\x6e\x64\x65\x78\x4f\x66');
        }
    } else {
        _0x2d9a50('\x69\x6e\x64\u0435\x78\x4f\x66');
    }
});
_0x2c6fd7();
console[_0x4391('0x0')](_0x4391('0x1'));

如果把这段代码放到浏览器里面,浏览器会直接卡死无法运行。这样如果有人对代码进行了格式化,就无法正常对代码进行运行和调试,从而起到了保护作用。

控制流平坦化

控制流平坦化其实就是将代码的执行逻辑混淆,使其变得复杂难读。其基本思想是将一些逻辑处理块都统一加上一个前驱逻辑块,每个逻辑块都由前驱逻辑块进行条件判断和分发,构成一个个闭环逻辑,导致整个执行逻辑十分复杂难读。

我们通过 controlFlowFlattening 变量可以控制是否开启控制流平坦化,示例如下:

const code = `
(function(){
    function foo () {
        return function () {
            var sum = 1 + 2;
            console.log(1);
            console.log(2);
            console.log(3);
            console.log(4);
            console.log(5);
            console.log(6);
        }
    }

    foo()();
})();
`
const options = {
  compact: false,
  controlFlowFlattening: true
}

输出结果如下:

var _0xbaf1 = [
    'dZwUe',
    'log',
    'fXqMu',
    '0|1|3|4|6|5|2',
    'chYMl',
    'IZEsA',
    'split'
];
(function (_0x22d342, _0x4f6332) {
    var _0x43ff59 = function (_0x5ad417) {
        while (--_0x5ad417) {
            _0x22d342['push'](_0x22d342['shift']());
        }
    };
    _0x43ff59(++_0x4f6332);
}(_0xbaf1, 0x192));
var _0x1a69 = function (_0x8d64b1, _0x5e07b3) {
    _0x8d64b1 = _0x8d64b1 - 0x0;
    var _0x300bab = _0xbaf1[_0x8d64b1];
    return _0x300bab;
};
(function () {
    var _0x19d8ce = {
        'chYMl': _0x1a69('0x0'),
        'IZEsA': function (_0x22e521, _0x298a22) {
            return _0x22e521 + _0x298a22;
        },
        'fXqMu': function (_0x13124b) {
            return _0x13124b();
        }
    };
    function _0x4e2ee0() {
        var _0x118a6a = {
            'LZAQV': _0x19d8ce[_0x1a69('0x1')],
            'dZwUe': function (_0x362ef3, _0x352709) {
                return _0x19d8ce[_0x1a69('0x2')](_0x362ef3, _0x352709);
            }
        };
        return function () {
            var _0x4c336d = _0x118a6a['LZAQV'][_0x1a69('0x3')]('|'), _0x2b6466 = 0x0;
            while (!![]) {
                switch (_0x4c336d[_0x2b6466++]) {
                case '0':
                    var _0xbfa3fd = _0x118a6a[_0x1a69('0x4')](0x1, 0x2);
                    continue;
                case '1':
                    console['log'](0x1);
                    continue;
                case '2':
                    console[_0x1a69('0x5')](0x6);
                    continue;
                case '3':
                    console[_0x1a69('0x5')](0x2);
                    continue;
                case '4':
                    console[_0x1a69('0x5')](0x3);
                    continue;
                case '5':
                    console[_0x1a69('0x5')](0x5);
                    continue;
                case '6':
                    console[_0x1a69('0x5')](0x4);
                    continue;
                }
                break;
            }
        };
    }
    _0x19d8ce[_0x1a69('0x6')](_0x4e2ee0)();
}());

可以看到,一些连续的执行逻辑被打破,代码被修改为一个 switch 语句,我们很难再一眼看出多条 console.log 语句的执行顺序了。

如果我们将 controlFlowFlattening 设置为 false 或者不设置,运行结果如下:

var _0x552c = ['log'];
(function (_0x4c4fa0, _0x59faa0) {
    var _0xa01786 = function (_0x409a37) {
        while (--_0x409a37) {
            _0x4c4fa0['push'](_0x4c4fa0['shift']());
        }
    };
    _0xa01786(++_0x59faa0);
}(_0x552c, 0x9b));
var _0x4e63 = function (_0x75ea1a, _0x50e176) {
    _0x75ea1a = _0x75ea1a - 0x0;
    var _0x59dc94 = _0x552c[_0x75ea1a];
    return _0x59dc94;
};
(function () {
    function _0x507f38() {
        return function () {
            var _0x17fb7e = 0x1 + 0x2;
            console[_0x4e63('0x0')](0x1);
            console['log'](0x2);
            console['log'](0x3);
            console[_0x4e63('0x0')](0x4);
            console[_0x4e63('0x0')](0x5);
            console[_0x4e63('0x0')](0x6);
        };
    }
    _0x507f38()();
}());

可以看到,这里仍然保留了原始的 console.log 执行逻辑。

因此,使用控制流扁平化可以使得执行逻辑更加复杂难读,目前非常多的前端混淆都会加上这个选项。

但启用控制流扁平化之后,代码的执行时间会变长,最长达 1.5 倍之多。

另外我们还能使用 controlFlowFlatteningThreshold 这个参数来控制比例,取值范围是 0 到 1,默认 0.75,如果设置为 0,那相当于 controlFlowFlattening 设置为 false,即不开启控制流扁平化 。

僵尸代码注入

僵尸代码即不会被执行的代码或对上下文没有任何影响的代码,注入之后可以对现有的 JavaScript 代码的阅读形成干扰。我们可以使用 deadCodeInjection 参数开启这个选项,默认为 false。
示例如下:

const code = `
(function(){
    if (true) {
        var foo = function () {
            console.log('abc');
            console.log('cde');
            console.log('efg');
            console.log('hij');
        };

        var bar = function () {
            console.log('klm');
            console.log('nop');
            console.log('qrs');
        };

        var baz = function () {
            console.log('tuv');
            console.log('wxy');
            console.log('z');
        };

        foo();
        bar();
        baz();
    }
})();
`
const options = {
  compact: false,
  deadCodeInjection: true
}

运行结果如下:

var _0x5024 = [
    'zaU',
    'log',
    'tuv',
    'wxy',
    'abc',
    'cde',
    'efg',
    'hij',
    'QhG',
    'TeI',
    'klm',
    'nop',
    'qrs',
    'bZd',
    'HMx'
];
var _0x4502 = function (_0x1254b1, _0x583689) {
    _0x1254b1 = _0x1254b1 - 0x0;
    var _0x529b49 = _0x5024[_0x1254b1];
    return _0x529b49;
};
(function () {
    if (!![]) {
        var _0x16c18d = function () {
            if (_0x4502('0x0') !== _0x4502('0x0')) {
                console[_0x4502('0x1')](_0x4502('0x2'));
                console[_0x4502('0x1')](_0x4502('0x3'));
                console[_0x4502('0x1')]('z');
            } else {
                console[_0x4502('0x1')](_0x4502('0x4'));
                console[_0x4502('0x1')](_0x4502('0x5'));
                console[_0x4502('0x1')](_0x4502('0x6'));
                console[_0x4502('0x1')](_0x4502('0x7'));
            }
        };
        var _0x1f7292 = function () {
            if (_0x4502('0x8') === _0x4502('0x9')) {
                console[_0x4502('0x1')](_0x4502('0xa'));
                console[_0x4502('0x1')](_0x4502('0xb'));
                console[_0x4502('0x1')](_0x4502('0xc'));
            } else {
                console[_0x4502('0x1')](_0x4502('0xa'));
                console[_0x4502('0x1')](_0x4502('0xb'));
                console[_0x4502('0x1')](_0x4502('0xc'));
            }
        };
        var _0x33b212 = function () {
            if (_0x4502('0xd') !== _0x4502('0xe')) {
                console[_0x4502('0x1')](_0x4502('0x2'));
                console[_0x4502('0x1')](_0x4502('0x3'));
                console[_0x4502('0x1')]('z');
            } else {
                console[_0x4502('0x1')](_0x4502('0x4'));
                console[_0x4502('0x1')](_0x4502('0x5'));
                console[_0x4502('0x1')](_0x4502('0x6'));
                console[_0x4502('0x1')](_0x4502('0x7'));
            }
        };
        _0x16c18d();
        _0x1f7292();
        _0x33b212();
    }
}());

可见这里增加了一些不会执行到的逻辑区块内容。

如果将 deadCodeInjection 设置为 false 或者不设置,运行结果如下:

var _0x402a = [
    'qrs',
    'wxy',
    'log',
    'abc',
    'cde',
    'efg',
    'hij',
    'nop'
];
(function (_0x57239e, _0x4747e8) {
    var _0x3998cd = function (_0x34a502) {
        while (--_0x34a502) {
            _0x57239e['push'](_0x57239e['shift']());
        }
    };
    _0x3998cd(++_0x4747e8);
}(_0x402a, 0x162));
var _0x5356 = function (_0x2f2c10, _0x2878a6) {
    _0x2f2c10 = _0x2f2c10 - 0x0;
    var _0x4cfe02 = _0x402a[_0x2f2c10];
    return _0x4cfe02;
};
(function () {
    if (!![]) {
        var _0x60edc1 = function () {
            console[_0x5356('0x0')](_0x5356('0x1'));
            console[_0x5356('0x0')](_0x5356('0x2'));
            console[_0x5356('0x0')](_0x5356('0x3'));
            console['log'](_0x5356('0x4'));
        };
        var _0x56405f = function () {
            console[_0x5356('0x0')]('klm');
            console['log'](_0x5356('0x5'));
            console['log'](_0x5356('0x6'));
        };
        var _0x332d12 = function () {
            console[_0x5356('0x0')]('tuv');
            console[_0x5356('0x0')](_0x5356('0x7'));
            console['log']('z');
        };
        _0x60edc1();
        _0x56405f();
        _0x332d12();
    }
}());

另外我们还可以通过设置 deadCodeInjectionThreshold 参数来控制僵尸代码注入的比例,取值 0 到 1,默认是 0.4。

僵尸代码可以起到一定的干扰作用,所以在有必要的时候也可以注入。

对象键名替换

如果是一个对象,可以使用 transformObjectKeys 来对对象的键值进行替换,示例如下:

const code = `
(function(){
    var object = {
        foo: 'test1',
        bar: {
            baz: 'test2'
        }
    };
})(); 
`
const options = {
  compact: false,
  transformObjectKeys: true
}

输出结果如下:

var _0x7a5d = [
    'bar',
    'test2',
    'test1'
];
(function (_0x59fec5, _0x2e4fac) {
    var _0x231e7a = function (_0x46f33e) {
        while (--_0x46f33e) {
            _0x59fec5['push'](_0x59fec5['shift']());
        }
    };
    _0x231e7a(++_0x2e4fac);
}(_0x7a5d, 0x167));
var _0x3bc4 = function (_0x309ad3, _0x22d5ac) {
    _0x309ad3 = _0x309ad3 - 0x0;
    var _0x3a034e = _0x7a5d[_0x309ad3];
    return _0x3a034e;
};
(function () {
    var _0x9f1fd1 = {};
    _0x9f1fd1['foo'] = _0x3bc4('0x0');
    _0x9f1fd1[_0x3bc4('0x1')] = {};
    _0x9f1fd1[_0x3bc4('0x1')]['baz'] = _0x3bc4('0x2');
}());

可以看到,Object 的变量名被替换为了特殊的变量,这也可以起到一定的防护作用。

禁用控制台输出

可以使用 disableConsoleOutput 来禁用掉 console.log 输出功能,加大调试难度,示例如下:

const code = `
console.log('hello world')
`
const options = {
    disableConsoleOutput: true
}

运行结果如下:

var _0x3a39=['debug','info','error','exception','trace','hello\x20world','apply','{}.constructor(\x22return\x20this\x22)(\x20)','console','log','warn'];(function(_0x2a157a,_0x5d9d3b){var _0x488e2c=function(_0x5bcb73){while(--_0x5bcb73){_0x2a157a['push'](_0x2a157a['shift']());}};_0x488e2c(++_0x5d9d3b);}(_0x3a39,0x10e));var _0x5bff=function(_0x43bdfc,_0x52e4c6){_0x43bdfc=_0x43bdfc-0x0;var _0xb67384=_0x3a39[_0x43bdfc];return _0xb67384;};var _0x349b01=function(){var _0x1f484b=!![];return function(_0x5efe0d,_0x33db62){var _0x20bcd2=_0x1f484b?function(){if(_0x33db62){var _0x77054c=_0x33db62[_0x5bff('0x0')](_0x5efe0d,arguments);_0x33db62=null;return _0x77054c;}}:function(){};_0x1f484b=![];return _0x20bcd2;};}();var _0x19f538=_0x349b01(this,function(){var _0x7ab6e4=function(){};var _0x157bff;try{var _0x5e672c=Function('return\x20(function()\x20'+_0x5bff('0x1')+');');_0x157bff=_0x5e672c();}catch(_0x11028d){_0x157bff=window;}if(!_0x157bff[_0x5bff('0x2')]){_0x157bff[_0x5bff('0x2')]=function(_0x7ab6e4){var _0x5a8d9e={};_0x5a8d9e[_0x5bff('0x3')]=_0x7ab6e4;_0x5a8d9e[_0x5bff('0x4')]=_0x7ab6e4;_0x5a8d9e[_0x5bff('0x5')]=_0x7ab6e4;_0x5a8d9e[_0x5bff('0x6')]=_0x7ab6e4;_0x5a8d9e[_0x5bff('0x7')]=_0x7ab6e4;_0x5a8d9e[_0x5bff('0x8')]=_0x7ab6e4;_0x5a8d9e[_0x5bff('0x9')]=_0x7ab6e4;return _0x5a8d9e;}(_0x7ab6e4);}else{_0x157bff[_0x5bff('0x2')][_0x5bff('0x3')]=_0x7ab6e4;_0x157bff[_0x5bff('0x2')][_0x5bff('0x4')]=_0x7ab6e4;_0x157bff[_0x5bff('0x2')]['debug']=_0x7ab6e4;_0x157bff[_0x5bff('0x2')][_0x5bff('0x6')]=_0x7ab6e4;_0x157bff[_0x5bff('0x2')][_0x5bff('0x7')]=_0x7ab6e4;_0x157bff[_0x5bff('0x2')][_0x5bff('0x8')]=_0x7ab6e4;_0x157bff[_0x5bff('0x2')][_0x5bff('0x9')]=_0x7ab6e4;}});_0x19f538();console[_0x5bff('0x3')](_0x5bff('0xa'));

此时,我们如果执行这段代码,发现是没有任何输出的,这里实际上就是将 console 的一些功能禁用了,加大了调试难度。

调试保护

我们可以使用 debugProtection 来禁用调试模式,进入无限 Debug 模式。另外我们还可以使用 debugProtectionInterval 来启用无限 Debug 的间隔,使得代码在调试过程中会不断进入断点模式,无法顺畅执行。
示例如下:

const code = `
for (let i = 0; i < 5; i ++) {
    console.log('i', i)
}
`
const options = {
    debugProtection: true
}

运行结果如下:

var _0x41d0=['action','debu','stateObject','function\x20*\x5c(\x20*\x5c)','\x5c+\x5c+\x20*(?:_0x(?:[a-f0-9]){4,6}|(?:\x5cb|\x5cd)[a-z0-9]{1,4}(?:\x5cb|\x5cd))','init','test','chain','input','log','string','constructor','while\x20(true)\x20{}','apply','gger','call'];(function(_0x69147e,_0x180e03){var _0x2cc589=function(_0x18d18c){while(--_0x18d18c){_0x69147e['push'](_0x69147e['shift']());}};_0x2cc589(++_0x180e03);}(_0x41d0,0x153));var _0x16d2=function(_0x3d813e,_0x59f7b2){_0x3d813e=_0x3d813e-0x0;var _0x228f98=_0x41d0[_0x3d813e];return _0x228f98;};var _0x241eee=function(){var _0xeb17=!![];return function(_0x5caffe,_0x2bb267){var _0x16e1bf=_0xeb17?function(){if(_0x2bb267){var _0x573619=_0x2bb267['apply'](_0x5caffe,arguments);_0x2bb267=null;return _0x573619;}}:function(){};_0xeb17=![];return _0x16e1bf;};}();(function(){_0x241eee(this,function(){var _0x5de4a4=new RegExp(_0x16d2('0x0'));var _0x4a170e=new RegExp(_0x16d2('0x1'),'i');var _0x5351d7=_0x227210(_0x16d2('0x2'));if(!_0x5de4a4[_0x16d2('0x3')](_0x5351d7+_0x16d2('0x4'))||!_0x4a170e[_0x16d2('0x3')](_0x5351d7+_0x16d2('0x5'))){_0x5351d7('0');}else{_0x227210();}})();}());for(let i=0x0;i<0x5;i++){console[_0x16d2('0x6')]('i',i);}function _0x227210(_0x30bc32){function _0x1971c7(_0x19628c){if(typeof _0x19628c===_0x16d2('0x7')){return function(_0x3718f7){}[_0x16d2('0x8')](_0x16d2('0x9'))[_0x16d2('0xa')]('counter');}else{if((''+_0x19628c/_0x19628c)['length']!==0x1||_0x19628c%0x14===0x0){(function(){return!![];}[_0x16d2('0x8')]('debu'+_0x16d2('0xb'))[_0x16d2('0xc')](_0x16d2('0xd')));}else{(function(){return![];}[_0x16d2('0x8')](_0x16d2('0xe')+_0x16d2('0xb'))[_0x16d2('0xa')](_0x16d2('0xf')));}}_0x1971c7(++_0x19628c);}try{if(_0x30bc32){return _0x1971c7;}else{_0x1971c7(0x0);}}catch(_0x58d434){}}

如果我们将代码粘贴到控制台,其会不断跳到 debugger 代码的位置,无法顺畅执行。

域名锁定

我们可以通过控制 domainLock 来控制 JavaScript 代码只能在特定域名下运行,这样就可以降低被模拟的风险。

示例如下:

const code = `
console.log('hello world')
`
const options = {
    domainLock: ['cuiqingcai.com']
}

运行结果如下:

var _0x3203=['apply','return\x20(function()\x20','{}.constructor(\x22return\x20this\x22)(\x20)','item','attribute','value','replace','length','charCodeAt','log','hello\x20world'];(function(_0x2ed22c,_0x3ad370){var _0x49dc54=function(_0x53a786){while(--_0x53a786){_0x2ed22c['push'](_0x2ed22c['shift']());}};_0x49dc54(++_0x3ad370);}(_0x3203,0x155));var _0x5b38=function(_0xd7780b,_0x19c0f2){_0xd7780b=_0xd7780b-0x0;var _0x2d2f44=_0x3203[_0xd7780b];return _0x2d2f44;};var _0x485919=function(){var _0x5cf798=!![];return function(_0xd1fa29,_0x2ed646){var _0x56abf=_0x5cf798?function(){if(_0x2ed646){var _0x33af63=_0x2ed646[_0x5b38('0x0')](_0xd1fa29,arguments);_0x2ed646=null;return _0x33af63;}}:function(){};_0x5cf798=![];return _0x56abf;};}();var _0x67dcc8=_0x485919(this,function(){var _0x276a31;try{var _0x5c8be2=Function(_0x5b38('0x1')+_0x5b38('0x2')+');');_0x276a31=_0x5c8be2();}catch(_0x5f1c00){_0x276a31=window;}var _0x254a0d=function(){return{'key':_0x5b38('0x3'),'value':_0x5b38('0x4'),'getAttribute':function(){for(var _0x5cc3c7=0x0;_0x5cc3c7<0x3e8;_0x5cc3c7--){var _0x35b30b=_0x5cc3c7>0x0;switch(_0x35b30b){case!![]:return this[_0x5b38('0x3')]+'_'+this[_0x5b38('0x5')]+'_'+_0x5cc3c7;default:this[_0x5b38('0x3')]+'_'+this[_0x5b38('0x5')];}}}()};};var _0x3b375a=new RegExp('[QLCIKYkCFzdWpzRAXMhxJOYpTpYWJHPll]','g');var _0x5a94d2='cuQLiqiCInKYkgCFzdWcpzRAaXMi.hcoxmJOYpTpYWJHPll'[_0x5b38('0x6')](_0x3b375a,'')['split'](';');var _0x5c0da2;var _0x19ad5d;var _0x5992ca;var _0x40bd39;for(var _0x5cad1 in _0x276a31){if(_0x5cad1[_0x5b38('0x7')]==0x8&&_0x5cad1[_0x5b38('0x8')](0x7)==0x74&&_0x5cad1[_0x5b38('0x8')](0x5)==0x65&&_0x5cad1[_0x5b38('0x8')](0x3)==0x75&&_0x5cad1[_0x5b38('0x8')](0x0)==0x64){_0x5c0da2=_0x5cad1;break;}}for(var _0x29551 in _0x276a31[_0x5c0da2]){if(_0x29551[_0x5b38('0x7')]==0x6&&_0x29551[_0x5b38('0x8')](0x5)==0x6e&&_0x29551[_0x5b38('0x8')](0x0)==0x64){_0x19ad5d=_0x29551;break;}}if(!('~'>_0x19ad5d)){for(var _0x2b71bd in _0x276a31[_0x5c0da2]){if(_0x2b71bd[_0x5b38('0x7')]==0x8&&_0x2b71bd[_0x5b38('0x8')](0x7)==0x6e&&_0x2b71bd[_0x5b38('0x8')](0x0)==0x6c){_0x5992ca=_0x2b71bd;break;}}for(var _0x397f55 in _0x276a31[_0x5c0da2][_0x5992ca]){if(_0x397f55['length']==0x8&&_0x397f55[_0x5b38('0x8')](0x7)==0x65&&_0x397f55[_0x5b38('0x8')](0x0)==0x68){_0x40bd39=_0x397f55;break;}}}if(!_0x5c0da2||!_0x276a31[_0x5c0da2]){return;}var _0x5f19be=_0x276a31[_0x5c0da2][_0x19ad5d];var _0x674f76=!!_0x276a31[_0x5c0da2][_0x5992ca]&&_0x276a31[_0x5c0da2][_0x5992ca][_0x40bd39];var _0x5e1b34=_0x5f19be||_0x674f76;if(!_0x5e1b34){return;}var _0x593394=![];for(var _0x479239=0x0;_0x479239<_0x5a94d2['length'];_0x479239++){var _0x19ad5d=_0x5a94d2[_0x479239];var _0x112c24=_0x5e1b34['length']-_0x19ad5d['length'];var _0x51731c=_0x5e1b34['indexOf'](_0x19ad5d,_0x112c24);var _0x173191=_0x51731c!==-0x1&&_0x51731c===_0x112c24;if(_0x173191){if(_0x5e1b34['length']==_0x19ad5d[_0x5b38('0x7')]||_0x19ad5d['indexOf']('.')===0x0){_0x593394=!![];}}}if(!_0x593394){data;}else{return;}_0x254a0d();});_0x67dcc8();console[_0x5b38('0x9')](_0x5b38('0xa'));

这段代码就只能在指定域名 cuiqingcai.com 下运行,不能在其他网站运行,不信你可以试试。

特殊编码

另外还有一些特殊的工具包,如使用 aaencode、jjencode、jsfuck 等工具对代码进行混淆和编码。

示例如下:

var a = 1

jsfuck 的结果:

[][(![]+[])[!+[]+!![]+!![]]+([]+{})[+!![]]+(!![]+[])[+!![]]+(!![]+[])[+[]]][([]+{})[!+[]+!![]+!![]+!![]+!![]]+([]+{})[+!![]]+([][[]]+[])[+!![]]+(![]+[])[!+[]+!![]+!![]]+(!![]+[])[+[]]+(!![]+[])[+!![]]+([][[]]+[])[+[]]+([]+{})[!+[]+!![]+!![]+!![]+!![]]+(!![]+[])[+[]]+([]+{})[+!![]]+(!![]+[])[+!![]]]([][(![]+[])[!+[]+!![]+!![]]+([]+{})[+!![]]+(!![]+[])[+!![]]+(!![]+[])[+[]]][([]+{})[!+[]+!![]+!![]+!![]+!![]]+([]+{})[+!![]]+([][[]]+[])[+!![]]+(![]+[])[!+[]+!![]+!![]]+(!![]+[])[+[]]+(!![]+[])[+!![]]+([][[]]+[])[+[]]+([]+{})[!+[]+!![]+!![]+!![]+!![]]+(!![]+[])[+[]]+([]+{})[+!![]]+(!![]+[])[+!![]]]((!![]+[])[+!![]]+([][[]]+[])[!+[]+!![]+!![]]+(!![]+[])[+[]]+([][[]]+[])[+[]]+(!![]+[])[+!![]]+([][[]]+[])[+!![]]+([]+{})[!+[]+!![]+!![]+!![]+!![]+!![]+!![]]+([][[]]+[])[+[]]+([][[]]+[])[+!![]]+([][[]]+[])[!+[]+!![]+!![]]+(![]+[])[!+[]+!![]+!![]]+([]+{})[!+[]+!![]+!![]+!![]+!![]]+(+{}+[])[+!![]]+([]+[][(![]+[])[!+[]+!![]+!![]]+([]+{})[+!![]]+(!![]+[])[+!![]]+(!![]+[])[+[]]][([]+{})[!+[]+!![]+!![]+!![]+!![]]+([]+{})[+!![]]+([][[]]+[])[+!![]]+(![]+[])[!+[]+!![]+!![]]+(!![]+[])[+[]]+(!![]+[])[+!![]]+([][[]]+[])[+[]]+([]+{})[!+[]+!![]+!![]+!![]+!![]]+(!![]+[])[+[]]+([]+{})[+!![]]+(!![]+[])[+!![]]]((!![]+[])[+!![]]+([][[]]+[])[!+[]+!![]+!![]]+(!![]+[])[+[]]+([][[]]+[])[+[]]+(!![]+[])[+!![]]+([][[]]+[])[+!![]]+([]+{})[!+[]+!![]+!![]+!![]+!![]+!![]+!![]]+(![]+[])[!+[]+!![]]+([]+{})[+!![]]+([]+{})[!+[]+!![]+!![]+!![]+!![]]+(+{}+[])[+!![]]+(!![]+[])[+[]]+([][[]]+[])[!+[]+!![]+!![]+!![]+!![]]+([]+{})[+!![]]+([][[]]+[])[+!![]])(!+[]+!![]+!![]+!![]+!![]))[!+[]+!![]+!![]]+([][[]]+[])[!+[]+!![]+!![]])(!+[]+!![]+!![]+!![])([][(![]+[])[!+[]+!![]+!![]]+([]+{})[+!![]]+(!![]+[])[+!![]]+(!![]+[])[+[]]][([]+{})[!+[]+!![]+!![]+!![]+!![]]+([]+{})[+!![]]+([][[]]+[])[+!![]]+(![]+[])[!+[]+!![]+!![]]+(!![]+[])[+[]]+(!![]+[])[+!![]]+([][[]]+[])[+[]]+([]+{})[!+[]+!![]+!![]+!![]+!![]]+(!![]+[])[+[]]+([]+{})[+!![]]+(!![]+[])[+!![]]]((!![]+[])[+!![]]+([][[]]+[])[!+[]+!![]+!![]]+(!![]+[])[+[]]+([][[]]+[])[+[]]+(!![]+[])[+!![]]+([][[]]+[])[+!![]]+([]+{})[!+[]+!![]+!![]+!![]+!![]+!![]+!![]]+([][[]]+[])[!+[]+!![]+!![]]+(![]+[])[!+[]+!![]+!![]]+([]+{})[!+[]+!![]+!![]+!![]+!![]]+(+{}+[])[+!![]]+([]+[][(![]+[])[!+[]+!![]+!![]]+([]+{})[+!![]]+(!![]+[])[+!![]]+(!![]+[])[+[]]][([]+{})[!+[]+!![]+!![]+!![]+!![]]+([]+{})[+!![]]+([][[]]+[])[+!![]]+(![]+[])[!+[]+!![]+!![]]+(!![]+[])[+[]]+(!![]+[])[+!![]]+([][[]]+[])[+[]]+([]+{})[!+[]+!![]+!![]+!![]+!![]]+(!![]+[])[+[]]+([]+{})[+!![]]+(!![]+[])[+!![]]]((!![]+[])[+!![]]+([][[]]+[])[!+[]+!![]+!![]]+(!![]+[])[+[]]+([][[]]+[])[+[]]+(!![]+[])[+!![]]+([][[]]+[])[+!![]]+([]+{})[!+[]+!![]+!![]+!![]+!![]+!![]+!![]]+(![]+[])[!+[]+!![]]+([]+{})[+!![]]+([]+{})[!+[]+!![]+!![]+!![]+!![]]+(+{}+[])[+!![]]+(!![]+[])[+[]]+([][[]]+[])[!+[]+!![]+!![]+!![]+!![]]+([]+{})[+!![]]+([][[]]+[])[+!![]])(!+[]+!![]+!![]+!![]+!![]))[!+[]+!![]+!![]]+([][[]]+[])[!+[]+!![]+!![]])(!+[]+!![]+!![]+!![]+!![])(([]+{})[+[]])[+[]]+(!+[]+!![]+!![]+!![]+!![]+!![]+!![]+[])+(!+[]+!![]+!![]+!![]+!![]+!![]+[]))+(+{}+[])[+!![]]+(!![]+[])[+!![]]+([]+{})[!+[]+!![]+!![]+!![]+!![]+!![]+!![]]+(+{}+[])[+!![]]+([]+{})[!+[]+!![]+!![]+!![]+!![]+!![]+!![]]+[][(![]+[])[!+[]+!![]+!![]]+([]+{})[+!![]]+(!![]+[])[+!![]]+(!![]+[])[+[]]][([]+{})[!+[]+!![]+!![]+!![]+!![]]+([]+{})[+!![]]+([][[]]+[])[+!![]]+(![]+[])[!+[]+!![]+!![]]+(!![]+[])[+[]]+(!![]+[])[+!![]]+([][[]]+[])[+[]]+([]+{})[!+[]+!![]+!![]+!![]+!![]]+(!![]+[])[+[]]+([]+{})[+!![]]+(!![]+[])[+!![]]]((!![]+[])[+!![]]+([][[]]+[])[!+[]+!![]+!![]]+(!![]+[])[+[]]+([][[]]+[])[+[]]+(!![]+[])[+!![]]+([][[]]+[])[+!![]]+([]+{})[!+[]+!![]+!![]+!![]+!![]+!![]+!![]]+([][[]]+[])[+[]]+([][[]]+[])[+!![]]+([][[]]+[])[!+[]+!![]+!![]]+(![]+[])[!+[]+!![]+!![]]+([]+{})[!+[]+!![]+!![]+!![]+!![]]+(+{}+[])[+!![]]+([]+[][(![]+[])[!+[]+!![]+!![]]+([]+{})[+!![]]+(!![]+[])[+!![]]+(!![]+[])[+[]]][([]+{})[!+[]+!![]+!![]+!![]+!![]]+([]+{})[+!![]]+([][[]]+[])[+!![]]+(![]+[])[!+[]+!![]+!![]]+(!![]+[])[+[]]+(!![]+[])[+!![]]+([][[]]+[])[+[]]+([]+{})[!+[]+!![]+!![]+!![]+!![]]+(!![]+[])[+[]]+([]+{})[+!![]]+(!![]+[])[+!![]]]((!![]+[])[+!![]]+([][[]]+[])[!+[]+!![]+!![]]+(!![]+[])[+[]]+([][[]]+[])[+[]]+(!![]+[])[+!![]]+([][[]]+[])[+!![]]+([]+{})[!+[]+!![]+!![]+!![]+!![]+!![]+!![]]+(![]+[])[!+[]+!![]]+([]+{})[+!![]]+([]+{})[!+[]+!![]+!![]+!![]+!![]]+(+{}+[])[+!![]]+(!![]+[])[+[]]+([][[]]+[])[!+[]+!![]+!![]+!![]+!![]]+([]+{})[+!![]]+([][[]]+[])[+!![]])(!+[]+!![]+!![]+!![]+!![]))[!+[]+!![]+!![]]+([][[]]+[])[!+[]+!![]+!![]])(!+[]+!![]+!![]+!![])([][(![]+[])[!+[]+!![]+!![]]+([]+{})[+!![]]+(!![]+[])[+!![]]+(!![]+[])[+[]]][([]+{})[!+[]+!![]+!![]+!![]+!![]]+([]+{})[+!![]]+([][[]]+[])[+!![]]+(![]+[])[!+[]+!![]+!![]]+(!![]+[])[+[]]+(!![]+[])[+!![]]+([][[]]+[])[+[]]+([]+{})[!+[]+!![]+!![]+!![]+!![]]+(!![]+[])[+[]]+([]+{})[+!![]]+(!![]+[])[+!![]]]((!![]+[])[+!![]]+([][[]]+[])[!+[]+!![]+!![]]+(!![]+[])[+[]]+([][[]]+[])[+[]]+(!![]+[])[+!![]]+([][[]]+[])[+!![]]+([]+{})[!+[]+!![]+!![]+!![]+!![]+!![]+!![]]+([][[]]+[])[!+[]+!![]+!![]]+(![]+[])[!+[]+!![]+!![]]+([]+{})[!+[]+!![]+!![]+!![]+!![]]+(+{}+[])[+!![]]+([]+[][(![]+[])[!+[]+!![]+!![]]+([]+{})[+!![]]+(!![]+[])[+!![]]+(!![]+[])[+[]]][([]+{})[!+[]+!![]+!![]+!![]+!![]]+([]+{})[+!![]]+([][[]]+[])[+!![]]+(![]+[])[!+[]+!![]+!![]]+(!![]+[])[+[]]+(!![]+[])[+!![]]+([][[]]+[])[+[]]+([]+{})[!+[]+!![]+!![]+!![]+!![]]+(!![]+[])[+[]]+([]+{})[+!![]]+(!![]+[])[+!![]]]((!![]+[])[+!![]]+([][[]]+[])[!+[]+!![]+!![]]+(!![]+[])[+[]]+([][[]]+[])[+[]]+(!![]+[])[+!![]]+([][[]]+[])[+!![]]+([]+{})[!+[]+!![]+!![]+!![]+!![]+!![]+!![]]+(![]+[])[!+[]+!![]]+([]+{})[+!![]]+([]+{})[!+[]+!![]+!![]+!![]+!![]]+(+{}+[])[+!![]]+(!![]+[])[+[]]+([][[]]+[])[!+[]+!![]+!![]+!![]+!![]]+([]+{})[+!![]]+([][[]]+[])[+!![]])(!+[]+!![]+!![]+!![]+!![]))[!+[]+!![]+!![]]+([][[]]+[])[!+[]+!![]+!![]])(!+[]+!![]+!![]+!![]+!![])(([]+{})[+[]])[+[]]+(!+[]+!![]+!![]+[])+([][[]]+[])[!+[]+!![]])+([]+{})[!+[]+!![]+!![]+!![]+!![]+!![]+!![]]+(+!![]+[]))(!+[]+!![]+!![]+!![]+!![]+!![]+!![]+!![])

aaencode 的结果:

゚ω゚ノ= /`m´)ノ ~┻━┻ / ['_']; o=(゚ー゚) =_=3; c=(゚Θ゚) =(゚ー゚)-(゚ー゚); (゚Д゚) =(゚Θ゚)= (o^_^o)/ (o^_^o);(゚Д゚)={゚Θ゚: '_' ,゚ω゚ノ : ((゚ω゚ノ==3) +'_') [゚Θ゚] ,゚ー゚ノ :(゚ω゚ノ+ '_')[o^_^o -(゚Θ゚)] ,゚Д゚ノ:((゚ー゚==3) +'_')[゚ー゚] }; (゚Д゚) [゚Θ゚] =((゚ω゚ノ==3) +'_') [c^_^o];(゚Д゚) ['c'] = ((゚Д゚)+'_') [ (゚ー゚)+(゚ー゚)-(゚Θ゚) ];(゚Д゚) ['o'] = ((゚Д゚)+'_') [゚Θ゚];(゚o゚)=(゚Д゚) ['c']+(゚Д゚) ['o']+(゚ω゚ノ +'_')[゚Θ゚]+ ((゚ω゚ノ==3) +'_') [゚ー゚] + ((゚Д゚) +'_') [(゚ー゚)+(゚ー゚)]+ ((゚ー゚==3) +'_') [゚Θ゚]+((゚ー゚==3) +'_') [(゚ー゚) - (゚Θ゚)]+(゚Д゚) ['c']+((゚Д゚)+'_') [(゚ー゚)+(゚ー゚)]+ (゚Д゚) ['o']+((゚ー゚==3) +'_') [゚Θ゚];(゚Д゚) ['_'] =(o^_^o) [゚o゚] [゚o゚];(゚ε゚)=((゚ー゚==3) +'_') [゚Θ゚]+ (゚Д゚) .゚Д゚ノ+((゚Д゚)+'_') [(゚ー゚) + (゚ー゚)]+((゚ー゚==3) +'_') [o^_^o -゚Θ゚]+((゚ー゚==3) +'_') [゚Θ゚]+ (゚ω゚ノ +'_') [゚Θ゚]; (゚ー゚)+=(゚Θ゚); (゚Д゚)[゚ε゚]='\\'; (゚Д゚).゚Θ゚ノ=(゚Д゚+ ゚ー゚)[o^_^o -(゚Θ゚)];(o゚ー゚o)=(゚ω゚ノ +'_')[c^_^o];(゚Д゚) [゚o゚]='\"';(゚Д゚) ['_'] ( (゚Д゚) ['_'] (゚ε゚+(゚Д゚)[゚o゚]+ (゚Д゚)[゚ε゚]+(゚Θ゚)+ ((o^_^o) +(o^_^o))+ ((o^_^o) +(o^_^o))+ (゚Д゚)[゚ε゚]+(゚Θ゚)+ (゚ー゚)+ (゚Θ゚)+ (゚Д゚)[゚ε゚]+(゚Θ゚)+ ((o^_^o) +(o^_^o))+ ((o^_^o) - (゚Θ゚))+ (゚Д゚)[゚ε゚]+(゚ー゚)+ (c^_^o)+ (゚Д゚)[゚ε゚]+(゚Θ゚)+ (゚ー゚)+ (゚Θ゚)+ (゚Д゚)[゚ε゚]+(゚ー゚)+ (c^_^o)+ (゚Д゚)[゚ε゚]+((゚ー゚) + (o^_^o))+ ((゚ー゚) + (゚Θ゚))+ (゚Д゚)[゚ε゚]+(゚ー゚)+ (c^_^o)+ (゚Д゚)[゚ε゚]+((o^_^o) +(o^_^o))+ (゚Θ゚)+ (゚Д゚)[゚o゚])(゚Θ゚))((゚Θ゚)+(゚Д゚)[゚ε゚]+((゚ー゚)+(゚Θ゚))+(゚Θ゚)+(゚Д゚)[゚o゚]);

jjencode 的结果:

$=~[];$={___:++$,$$$$:(![]+"")[$],__$:++$,$_$_:(![]+"")[$],_$_:++$,$_$$:({}+"")[$],$$_$:($[$]+"")[$],_$$:++$,$$$_:(!""+"")[$],$__:++$,$_$:++$,$$__:({}+"")[$],$$_:++$,$$$:++$,$___:++$,$__$:++$};$.$_=($.$_=$+"")[$.$_$]+($._$=$.$_[$.__$])+($.$$=($.$+"")[$.__$])+((!$)+"")[$._$$]+($.__=$.$_[$.$$_])+($.$=(!""+"")[$.__$])+($._=(!""+"")[$._$_])+$.$_[$.$_$]+$.__+$._$+$.$;$.$$=$.$+(!""+"")[$._$$]+$.__+$._+$.$+$.$$;$.$=($.___)[$.$_][$.$_];$.$($.$($.$$+"\""+"\\"+$.__$+$.$$_+$.$$_+$.$_$_+"\\"+$.__$+$.$$_+$._$_+"\\"+$.$__+$.___+$.$_$_+"\\"+$.$__+$.___+"=\\"+$.$__+$.___+$.__$+"\"")())();

这些混淆方式比较另类,但只需要输入到控制台即可执行,其没有真正达到强力混淆的效果。

以上便是对 JavaScript 混淆方式的介绍和总结。总的来说,经过混淆的 JavaScript 代码其可读性大大降低,同时防护效果也大大增强。

JavaScript 加密

不同于 JavaScript 混淆技术,JavaScript 加密技术可以说是对 JavaScript 混淆技术防护的进一步升级,其基本思路是将一些核心逻辑使用诸如 C/C++ 语言来编写,并通过 JavaScript 调用执行,从而起到二进制级别的防护作用。

其加密的方式现在有 Emscripten 和 WebAssembly 等,其中后者越来越成为主流。
下面我们分别来介绍下。

Emscripten

现在,许多 3D 游戏都是用 C/C++ 语言写的,如果能将 C / C++ 语言编译成 JavaScript 代码,它们不就能在浏览器里运行了吗?众所周知,JavaScript 的基本语法与 C 语言高度相似。于是,有人开始研究怎么才能实现这个目标,为此专门做了一个编译器项目 Emscripten。这个编译器可以将 C / C++ 代码编译成 JavaScript 代码,但不是普通的 JavaScript,而是一种叫作 asm.js 的 JavaScript 变体。

因此说,某些 JavaScript 的核心功能可以使用 C/C++ 语言实现,然后通过 Emscripten 编译成 asm.js,再由 JavaScript 调用执行,这可以算是一种前端加密技术。

WebAssembly

如果你对 JavaScript 比较了解,可能知道还有一种叫作 WebAssembly 的技术,也能将 C/C++ 转成 JavaScript 引擎可以运行的代码。那么它与 asm.js 有何区别呢?

其实两者的功能基本一致,就是转出来的代码不一样:asm.js 是文本,WebAssembly 是二进制字节码,因此运行速度更快、体积更小。从长远来看,WebAssembly 的前景更光明。

WebAssembly 是经过编译器编译之后的字节码,可以从 C/C++ 编译而来,得到的字节码具有和 JavaScript 相同的功能,但它体积更小,而且在语法上完全脱离 JavaScript,同时具有沙盒化的执行环境。

利用 WebAssembly 技术,我们可以将一些核心的功能利用 C/C++ 语言实现,形成浏览器字节码的形式。然后在 JavaScript 中通过类似如下的方式调用:

WebAssembly.compile(new Uint8Array(`
  00 61 73 6d  01 00 00 00  01 0c 02 60  02 7f 7f 01
  7f 60 01 7f  01 7f 03 03  02 00 01 07  10 02 03 61
  64 64 00 00  06 73 71 75  61 72 65 00  01 0a 13 02
  08 00 20 00  20 01 6a 0f  0b 08 00 20  00 20 00 6c
  0f 0b`.trim().split(/[\s\r\n]+/g).map(str => parseInt(str, 16))
)).then(module => {
  const instance = new WebAssembly.Instance(module)
  const { add, square } = instance.exports
  console.log('2 + 4 =', add(2, 4))
  console.log('3^2 =', square(3))
  console.log('(2 + 5)^2 =', square(add(2 + 5)))
})

这种加密方式更加安全,因为作为二进制编码它能起到的防护效果无疑是更好的。如果想要逆向或破解那得需要逆向 WebAssembly,难度也是很大的。

总结

以上,我们就介绍了接口加密技术和 JavaScript 的压缩、混淆和加密技术,知己知彼方能百战不殆,了解了原理,我们才能更好地去实现 JavaScript 的逆向。
本节代码:https://github.com/Python3WebSpider/JavaScriptObfuscate

参考文献

# JavaScript逆向爬取实战(上)

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

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

如果我们不使用 Selenium 或 Pyppeteer 等工具来模拟浏览器的形式爬取的话,要想直接从接口层面上获取数据,基本上需要一点点调试分析 JavaScript 的调用逻辑、堆栈调用关系来弄清楚整个网站加密的实现方法,我们可以称这个过程叫 JavaScript 逆向。这些接口的加密参数往往都是一些加密算法或编码的组合,完全搞明白其中的逻辑之后,我们就能把这个算法用 Python 模拟出来,从而实现接口的请求了。

案例介绍

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

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 状态码。

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

image (3).png

这里我们把看似是第一部电影的返回结果全展开了,但是刚才我们观察到第一部电影的 URL 的链接却为 https://dynamic6.scrape.center/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.center/api/movie/?limit=10&offset=0&token=NTRhYWJhNzAyYTZiMTc0ZThkZTExNzBiNTMyMDJkN2UxZWYyMmNiZCwxNTg4MTc4NTYz,可以看到带有 offset、limit、token 三个参数,入口寻找关键就是找 token,我们全局搜索下 token 是否存在,可以点击开发者工具右上角的下拉选项卡,然后点击 Search,如图所示。

image (8).png

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

image (9).png

观察一下,下面的两个结果可能是我们想要的,我们点击进入第一个看下,定位到了一个 JavaScript 文件,如图所示。

image (10).png

这时候可以看到整个代码都是压缩过的,只有一行,不好看,我们可以点击左下角的 {} 按钮,美化一下 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

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

image (19).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 的代码位置,如图所示。

image (22).png

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

这时候我们就进入了一个新的名字为 _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.center/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())

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

JavaScript逆向爬取实战(下)

详情页加密 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.center/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 注入。

控制台注入

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

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

image (27).png

执行完这段代码之后,相当于我们就已经把 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 脚本,包括我们自行创建的,也包括从第三方网站下载安装的。

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

image (38).png

初始化的代码如下:

// ==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.center/
// @version     0.1
// @description Hook Base64 encode function
// @author       Germey
// @match       https://dynamic6.scrape.center/
// @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.center/api/movie?limit={limit}&offset={offset}&token={token}'
DETAIL_URL = 'https://dynamic6.scrape.center/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 逆向分析。

本节代码:https://github.com/Python3WebSpider/ScrapeDynamic6