简单方便的 JavaScript 逆向辅助模拟方法
The following article is from 崔庆才丨静觅 Author 崔庆才
比如说:我们找到了一个类似的加密算法,其生成逻辑如下:
const token = encrypt(a, b)
我们最终需要获取的就是 token 这个变量究竟是什么,这个 token 模拟出来了,那就可以直接拿着去构造请求进行数据爬取。但这个 token 是由一个 encrypt 方法返回的,参数是 a 和 b,这时候 a 和 b 我们也知道是怎么来的。但是这个 encrypt 方法非常复杂,其内部又关联了许多变量和对象,甚至方法内部的逻辑也进行了混淆等操作,向内追踪非常困难。
这时候应该怎么办呢?有的朋友可能会说,我们可以不关心其内部的逻辑,把 encrypt 所依赖的 JavaScript 库单独拿出来,然后模拟执行 encrypt 方法就好了。
这时候就带来了两个问题:
在哪里模拟执行 encrypt 方法?
encrypt 方法依赖哪些 JavaScript 库?
方案探讨
模拟执行位置
首先我们知道当前 encrypt 方法的执行环境是浏览器,它所依赖的库也被加载在浏览器中了,浏览器本身其实就已经是一个模拟执行环境了。另外还有很多其他的执行环境,比如 Node.js,它也可以执行 JavaScript 代码,但它的上下文环境和浏览器还是有所差距的,比如 Node.js 中就没有全局 window 对象,取而代之的是 global 对象,所以如果要把浏览器中的 JavaScript 放到 Node.js 运行还是有一定的成本和风险的。还有其他的一些 JavaScript 运行环境就不再赘述了,原理和浏览器或 Node.js 大同小异。
依赖库查找
我们刚才也说到,encrypt 所依赖的全部逻辑和依赖库都已经加载到浏览器,如果我们要在其他环境中模拟执行,要从中完全剥离出 encrypt 所依赖的 JavaScript 库肯定还是需要费一些功夫的,一旦缺少了必备的依赖库,就会导致 encrypt 方法无法成功运行。
所以,回过头来想想,为什么我们不直接用浏览器作为执行环境来辅助逆向呢?
本节,我们就来介绍一个借助浏览器模拟辅助逆向的方法,可以实现任意位置的代码注入和修改,同时可以实现全局和任意时刻调用,非常方便。
准备工作
本节我们使用 playwright 来实现浏览器辅助逆向,需要安装下 playwright,安装方法如下:
pip3 install playwright
playwright install
运行如上两条命令之后,会安装 playwright Python 库并安装 Chromium、Firefox、Webkit 三个内核的浏览器供 playwright 直接使用。
案例介绍
本节我们要分析的目标站点是 https://spa2.scrape.center/,分析可以看到其 Ajax 请求参数带有一个 token,并且每次都会变化,如图所示:
添加 XHR 断点并通过调用栈找到 token 的生成入口,如图所示:
可以发现请求参数的 token 就是 变量 e,它的生成过程如下:
var a = (this.page - 1) * this.limit, e = Object(i["a"])(this.$store.state.url.index, a);
在此处打断点调试下,看看具体的变量值:
经过对比观察可以容易地发现,变量 a 其实就是请求数据的 offset,数据一页 10 条,所以第一页 offset 就是 0,第二页 offset 就是 10,所以变量 a 就是 0、10,以此类推。this.$store.state.url.index
是一个固定的字符串 /api/mobie
,但是经过调用 Object(i['a'])
方法之后,结果 e 也就是最终的 token 就得到了。
因此我们可以断定 Object(i['a'])
里面就是核心的加密逻辑,我们再把 i['a']
方法追踪一下,可以看到如下逻辑:
大致可以看到这里又掺杂了时间、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.encrypt = Object(i["a"]);
比如这里我们将 Object(i['a'])
挂载给 window 对象的 encrypt 属性。这样只要该行代码执行完毕,我们调用 window.encrypt
方法就相当于调用了 Object['a']
方法。
接着我们将当前修改后的整个 JavaScript 代码文件保存到本地,比如命名文件名称叫做 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 路径为 /js/chunk-10192a00.243cb8b7.js
,如图所示:
第二个参数就可以利用 route 的 fulfill 方法指定本地的文件,也就是刚才我们修改后的文件 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()
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())
这里我们遍历了 10 页页码,然后构造了 offset 变量,传给 get_token 方法获取 token 即可,最终运行结果如下:
{'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': 10, 'name': '狮子王', 'alias': 'The Lion King', 'cover': 'https://p0.meituan.net/movie/27b76fe6cf3903f3d74963f70786001e1438406.jpg@464w_644h_1e_1c', 'categories': ['动画', '歌舞', '冒险'], 'published_at': '1995-07-15', 'minute': 89, 'score': 9.0, 'regions': ['美国']}]}
...
可以看到每一页的数据就被成功爬取到了,简单方便。
总结
本节我们介绍了在浏览器环境中模拟执行 JavaScript 来辅助 JavaScript 逆向的方法,这会在一定程度上减轻逆向的压力,熟练掌握此技能可以避免走很多弯路。
- EOF -
觉得本文对你有帮助?请分享给更多人
推荐关注「Python开发者」,提升Python技能
点赞和在看就是最大的支持❤️