浏览器环境下JavaScript的模拟执行

在前面两节中,我们了解了利用 PyExecJS 和 Nodejs 对 JavaScript 进行模拟执行的方法,但在某些复杂的情况下可能还是有一定的局限性。

分析

比如说:我们在浏览器中找到了一个类似的加密算法,其生成逻辑如下:

const token = encrypt(a, b)

我们最终需要获取的就是 token 这个变量究竟是什么。这个 token 模拟出来了,就可以直接拿着去构造请求进行数据爬取了。但这个 token 是由一个 encrypt 方法返回的,参数是 ab。对于参数 ab,我们可能比较容易找到它们是怎么生成的,但是这个 encrypt 方法非常复杂,其内部又关联了许多变量和对象,甚至方法内部的逻辑也进行了混淆等操作,向内追踪非常困难。

这时候如果我们要用 Python 和 Nodejs 来模拟整个调用过程,关键其实就两步:

  • 把所有的依赖库都下载到本地;

  • 使用 PyExecJS 或 Nodejs 来加载依赖库并模拟调用 encrypt 方法

但在某些情况下可能存在一定的问题,我们分两个方面来进行探讨。

环境差异

前面提到过,Node.js 中没有全局 window 对象,取而代之的是 global 对象。如果 JavaScript 文件中有任何引用 window 对象的方法,就没法在 Nodejs 环境中运行。我们需要做的就是把 window 对象改写成 global 对象,或者把一些浏览器中的对象用其他方法代替。

依赖库查找

在上面的例子中,encrypt 所依赖的全部逻辑和依赖库其实都已经加载到浏览器。如果我们要在其他环境中模拟执行,要从中完全剥离出 encrypt 所依赖的 JavaScript 库,肯定还需要费一些功夫。一旦缺少了必备的依赖库,就会导致 encrypt 方法无法成功运行。

对于一些复杂的情况,为什么我们不直接用测览器作为执行环境来辅助逆向呢?

本节中,我们就来介绍一个借助浏览器模拟辅助逆向的方法,可以实现任意位置的代码注入和修改,同时可以实现全局和任意时刻调用,非常方便。

准备工作

本节中,我们使用 playwright 来实现浏览器辅助逆向。首先,安装 playwright,相关命令如下:

pip3 install playwright
playwright install

运行如上两条命令之后,会安装 playwright 库,并安装 Chromium、Firefox、WebKit 三个内核的浏览器供 playwright 直接使用。具体的安装方法可以参考; https://setup.scrape.center/playwright

案例介绍

本节中,我们要分析的目标站点是 https://spa2.scrape.center/ 。可以看到,其 Ajax 请求参数带有一个 token,并且每次都会变化,如图 11-75 所示。

图11-75 Ajax请求参数

添加 XHR 断点并通过调用栈找到 token 的生成入口,如图11-76所示。

图11-76 通过 XHR 断点寻找入口

可以发现,请求参数的 token 就是变量 e,它的生成过程如下:

var a = (this.page - 1) * this.limit, e = Object(i["a"])(this.$store.state.url.index, a);

在此处添加断点调试一下,看看具体的变量值,如图 11-77 所示。

图11-77 查看变量值

经过对比,可以很容易发现,变量 a 其实就是请求数据的 offset,数据一页 10 条,所以第一页 offset 就是 0,第二页 offset 就是 10,所以变量 a 就是 0、10,以此类推。this.$store.state.url.index 是一个固定的字符串 /api/movie,但是调用 Object(i["a"]) 方法之后,结果 e 也就是最终的 token 就得到了。

因此,我们可以断定 Object(i["a"]) 里面就是核心的加密逻辑,我们再把 i["a"] 方法追踪一下,可以看到如图 11-78 所示的逻辑。

图11-78 i 方法对应的逻辑

我们大致可以看到,这里又掺杂了时间、SHA1、Base64、列表等各种操作。要深人分析,还是需要花费一些时间的。

现在,可以说核心方法已经找到了,参数我们也知道怎么构造了,就是方法内部比较复杂,但我们想要的其实就是这个方法的运行结果,即最终的 token

这时候大家可能就产生了这样的疑问。

  • 怎么在不分析该方法逻辑的情况下拿到方法的运行结果呢?该方法完全可以看成黑盒。

  • 要直接拿到方法的运行结果,就需要模拟调用了,怎么模拟调用呢?

  • 这个方法并不是全局方法,所以没法直接调用,该怎么办呢?其实是有方法的。

  • 模拟调用当然没有问题,问题是在哪里模拟调用。根据上文的分析,既然浏览器中都已经把上下文环境和依赖库都加载成功了,为何不直接用浏览器呢?

  • 怎么模拟调用局部方法呢?很简单,只需要将局部方法挂载到全局 window 对象上不就好了吗?

  • 怎么把局部方法挂载到全局 window 对象上呢?最简单的方法就是直接改源码。

  • 既然已经在浏览器中运行了,又怎么改源码呢?当然可以,比如利用 playwright 的 Request Interception 机制将想要替换的任意文件进行替换即可。

实战

首先,我们来实现 Object(i["a"]) 的全局挂载,只需要将其赋值给 window 对象的一个属性即可,属性名称任意,只要不和现有的属性冲突即可。

比如我们需要在代码:

var a = (this.page-1) * this.limit,e = Object(i["a"])(this.$store.state.url.index,a);

下方添加如下用于挂载全局 window 对象的代码:

window.encrypt = Object(i["a"]);

比如,这里我们将 Object(i["a"]) 挂载给 window 对象的 encrypt 属性。这样只要该行代码执行完毕,我们调用 window.encrypt 方法就相当于调用了 Object["a"] 方法。

接着,我们将修改后的整个 JavaScript 代码文件保存到本地,并将其命名为 chunk.js,如图 11-79 所示。

图 11-79 chunk.js 文件

接下来,我们利用 playwright 启动一个测览器,并使用 Request Interception 将 JavaScript 文件替换,实现如下:

from playwright.sync_api import sync_playwright

BASE_URL = "https://spa2.scrape.center"
context = sync_playwright().start()
browser = context.chromium.launch()
page = browser.new_page()
page.route(
    "/js/chunk-10192a00.243cb8b7.js",
    lambda route: route.fulfill(path="./chunk.js")
)
page.goto(BASE_URL)

这里首先使用 playwright 创建一个 Chromium 无头测览器,然后利用 new_page 方法创建一个新的页面,并定义了一个关键的路由:

page.route(
    "/js/chunk-10192a00.243cb8b7.js",
    lambda route: route.fulfill(path="./chunk.js")
)

这里路由的第一个参数是原本加载的文件路径,比如原本加载的 JavaScript 路径为 /j5/chunk-10192a00.243cb8b7.js,如图 11-80 所示。

图 11-80 原 JavaScript 文件加载路径

第二个参数利用 routefulfill 方法指定本地的文件,也就是我们修改后的文件 chunk.js

这样 playwright 加载 /js/chunk-10192a00.243cb8b7.js 文件的时候,其内容就会被替换为我们本地保存的 chunk.js 文件。当执行之后,Object(i["a"]) 也就被挂载给 window 对象的 encrypt 属性了,所以调用 window.encrypt 方法就相当于调用了 object(i["a"]) 方法了。

怎么模拟调用呢?很简单,只需要在 playwright 环境中额外执行 JavaScript 代码即可,比如可以定义如下的方法:

def get_token(offset):
    result = page.evaluate('''() => (
        return window.encrypt("%s","%s")
    }''' %('/api/movie', offset))
    return result

这里我们声明了 get_token 方法,经过上文的分析,模拟执行方法需要传入两个参数,第一个参数是固定值 /api/movie,另一个参数是变值,所以将其当作参数传入。

在模拟执行的过程中,我们直接使用 page 对象的 evaluate 方法,传入 JavaScript 字符串即可,这个 JavaScript 字符串是一个方法,返回的就是 window.encrypt 方法的执行结果。最后将结果赋给 result 变量,并返回。

到此为止,核心代码就说完了。最后,我们只需要完善一下逻辑,将上面的代码串联调用即可。最终整理的代码如下:

from playwright.sync_api import sync_playwright
import time
import requests

BASE_URL = 'https://spa2.scrape.center'
INDEX_URL = BASE_URL + '/api/movie?limit={limit}&offset={offset}&token={token}'
MAX_PAGE = 10
LIMIT = 10

context = sync_playwright().start()
browser = context.chromium.launch(headless=False)
page = browser.new_page()
page.route(
    "/js/chunk-10192a00.243cb8b7.js",
    lambda route: route.fulfill(path="./chunk.js")
)
page.goto(BASE_URL)


def get_token(offset):
    result = page.evaluate('''() => {
        return window.encrypt("%s", "%s")
    }''' % ('/api/movie', offset))
    return result


for i in range(MAX_PAGE):
    offset = i * LIMIT
    token = get_token(offset)
    index_url = INDEX_URL.format(limit=LIMIT, offset=offset, token=token)
    response = requests.get(index_url)
    print('response', response.json())

time.sleep(10)

这里我们遍历了 10 页,然后构造了 offset 变量,传给 get_token 方法获取 token 即可,最终运行结果如下:

可以看到,每一页的数据就被成功爬取到了,简单方便。

总结

本节中,我们介绍了在浏览器环境中模拟执行 JavaScript 来辅助 JavaScript 逆向的方法,这会在一定程度上减轻逆向的压力。熟练掌握此技能,我们可以少走很多弯路。