WebAssembly 简介
WebAssembly 是一种可以使用非 JS 编程语言编写代码并且能在浏览器上运行的技术
借助 Emscripten 工具,我们能将 C/C++ 文件转成 wasm 格式的文件, JS 可以直接调用该文件执行其中的方法
这样做的好处如下:
一些核心逻辑(比如 API 参数的加密逻辑) 使用 C/C++ 实现,这样这些逻辑就可以"隐藏" 在编译生成的 wasm 文件中, 其逆向难度比 JS 更大
一些逻辑基于 C/C++ 编写的, 有更高的执行效率, 这使得以各种语言编写的代码都可以以接近原生的速度在 Web 中运行
对于这种类型的网站,我们一般会看到网站会加载一些 wasm 后缀的文件,这就是 WebAssembly 技术常见的呈现形式, 即原生代码被编译成了 wasm 后缀的文件, JS 通过调用 wasm 文件得到对应的计算结果, 然后配合其他 JS 代码实现页面数据的加载和页面的渲染
案例介绍
网站: https://spa14.scrape.center
我们按照常规的方法,加载首页,然后通过 Nerwork 面板分析 Ajax 请求,可以看到,这里就找到了第一页数据的 Ajax 请求, limit , offset 参数用来控制分页, sign 参数用来做校验,它的值是一个数字。通过观察后面几页的内容,我们发现 sign 的值一直在变化
因此,这里关键就是找到 sign 值的生成逻辑, 我们再模拟请求即可
我们这里设置一个 Ajax 断点, 在 Sources 面板的 XHR/fetch Breakpins 这里添加一个断点, 内容为 /api/movie , 就是在请求加载数据的时候进入断点
然后翻页,就可以看到页面执行到断点的位置就停了下来
这里我们通过 Call Stack 找到构造逻辑, 经过简单的查找和推测, 我们可以判断逻辑入口在 onFetchData 方法里
我们可以看到, params 有三个参数,分别是 limit , offset , sign 这和 Ajax 请求一致。
如果在平时可以继续添加断点,进一步验证其正确性
这里的关键参数就是 sign 了, 可以看到它的值是用变量 e 表示的,而 e 的生成代码就在上面
var n = (this.page - 1) * this.limit
, e = this.$wasm.asm.encrypt(n, parseInt(Math.round((new Date).getTime() / 1e3).toString()));
可以看到,它通过调用了 this.$wasm.asm 对象的 encrypt 方法传入了 n 和一个时间戳构造出来的,接下来我们进一步调试, 在
var n = (this.page - 1) * this.limit
添加断点,重新刷新页面,运行到该断点停下来
这相当于 JS 上下文处于 onFetchData 方法内部,所以我们可以访问方法内部的所有变量, 比如 this , this.$wasm 等
接下来我们就在 Watch 面板添加一个 this.$wasm , 先看看它是什么对象
可以看到,这个 this.$wasm 对象里面又定义了很多对象和方法,其中包括了 asm 对象。因为代码中又调用了 asm 对象的 encrypt 来产生 sign , 所以我们进一步看看 asm 对象, encrypt 方法都是什么? 我们可以看到 asm 对象里面又包含了几个对象和方法, 比较重要的就是 encrypt 方法了, 其中它的 [[ function ]] 指向另一个位置, 名称是 Wasm.wasm:0xd9 。因为我们就是想知道这个方法内部是什么逻辑,所以直接点击进入
可以看到我们进入了一个不是 JS 代码的位置,文件名称叫作 Wasm.wasm ,在代码中我们可以看到 encrypt 字样
(func $encrypt (;4;) (export "encrypt") (param $var0 i32) (param $var1 i32) (result i32)
local.get $var0
local.get $var1
i32.const 3
i32.div_s
i32.add
i32.const 16358
i32.add
如果你了解汇编语言的话,这里会发现有点汇编语言的味道
这其实就是 wasm 文件,这里面的逻辑其实原本是用 C++ 编写的, 通过 Emscripten 转化为 wasm 文件, 就成了现在的样子
这时候我们找下 Network 请求, 搜索 wasm 后缀文件
可以看到,这里就有 wasm 后缀的文件, 其逻辑就是刚才看到的内容。到了这里代码就看不懂了
解决方法有两种: 一种是直接把 wasm 文件反编译, 还原成 C++ 代码,这种方法上手难度大,需要了解 WebAssembly 和逆向相关的知识,另一种就是通过模拟执行的方式来直接得到加密结果
这里我们主要使用第二种方案,拿到 wasm 文件, 然后通过 Python 模拟执行的方式调用 wasm 文件, 模拟调用它的 encrypt 方法,传入对应参数即可
模拟执行
首先我们可以把文件下载下来,复制,或者使用 Overrides 另存都可以
要使用 python 模拟执行 wasm ,可以使用两个库, 一个叫作 pywasm 另一个叫作 wasmer-python 前者使用简单,后者功能强大
pywasm
这个库比较简单,其主要功能就是加载 wasm 文件, 然后用 Python 执行
安装 pip install pywasm
然后是加载文件
import pywasm runtime = pywasm.load("./Wasm.wasm") print(runtime)
<pywasm.Runtime object at 0x00000163CA23C150>
这里我们调用了 pywasm 的 load 方法,直接将 wasm 文件的路径传入, 实现了 wasm 文件的读取, 返回的结果是一个 pywasm.Runtime 类型的对象
有了这个 Runtime 对象之后,我们就可以调用它的 exec 方法来模拟执行 Wasm 里面的方法
比如,在网页中我们可以看到它执行了 encrypt 方法,并传入了两个参数。我们来试一下, 要调用 wasm 的方法, 只需要调用 Runtime 对象的 exec 方法并传入对应的方法名和参数内容即可
runtime = pywasm.load("./Wasm.wasm") result = runtime.exec('encrypt', [1, 2]) print(result)
16359
这里我们调用了 exec 方法,第一个参数就是要调用的 wasm 中的方法名, 这里我们传入字符串 encrypt , 第二个参数是一个列表, 代表 encrypt 方法所接收的参数, 如果是两个,那么列表的长度就是 2, 参数与列表元素一 一对应即可,结果出来了,但似乎并不是我们想要的,因为参数是我们自定义的,而要想真正模拟 Ajax 请求, 就要用网站里的实参。通过逻辑分析,我们知道传入的参数其实是一个 offset 和一个时间戳
后者的实现是这样的
parseInt(Math.round((new Date).getTime() / 1e3).toString())
这时 JS 的实现,我们将其输出到控制台
输出的其实是一个时间戳,结果是数值类型, 位数是10位。 使用 python 实现同样的结果
import time
int(time.time())
最终,我们可以将爬虫逻辑实现如下
import pywasm import time import requests BASE_URL = 'https://spa14.scrape.center' TOTAL_PATE = 10 runtime = pywasm.load('./Wasm.wasm') for i in range(TOTAL_PATE): offset = i * 10 sign = runtime.exec('encrypt', [offset, int(time.time())]) url = f'{BASE_URL}/api/movie/?limit=10&offset={offset}&sign={sign}' response = requests.get(url) print(response.json())
{'count': 103, 'results': [{'id': 21, 'name': '黄金三镖客', 'alias': 'Il buono, il brutto, il cattivo.', 'cover':
这里省略了很多内容。。。。。。
'https://p0.meituan.net/movie/b0d986a8bf89278afbb19f6abaef70f31206570.jpg@464w_644h_1e_1c', 'categories': ['剧情', '历史', '战争'], 'published_at': '1993-11-30', 'minute': 195, 'score': 9.5, 'regions': ['美国']}, {'id': 100, 'name': '魂断蓝桥', 'alias': 'Waterloo Bridge', 'cover': 'https://p0.meituan.net/movie/58782fa5439c25d764713f711ebecd1e201941.jpg@464w_644h_1e_1c', 'categories': ['剧情', '爱情', '战争'], 'published_at': '1940-05-17', 'minute': 108, 'score': 9.5, 'regions': ['美国']}]}
这里我们先定义了 TOTAL_PAGE 是 10, 就是 10 页, 然后开始一个 for 循环遍历, i 就是 0-9 的数字 offset 就是 0, 10 ,20.....90 , sign 就是利用刚才的实现,将参数转化为 offset 变量和时间戳,最后构造出 URL 即可
wasmer-python
除了使用 pywasm 库, 我们还可以使用另一个库 wasmer-python 来完成同样的操作。 相比 pywasm , wasmer-python 的更能更为 强大,它提供了更为底层的 API 。如果遇到更为复杂的wasm 调用情形, 推荐使用 wasmer-python
安装: pip install wasmer wasmer_compiler_cranelift
注意: 2024. 8.12 , 截止今天,python 3.10 以上的版本不支持
from wasmer import engine, Store, Module, Instance from wasmer_compiler_cranelift import Compiler store = Store(engine.JIT(Compiler)) module = Module(store, open('./Wasm.wasm', 'rb').read()) instance = Instance(module) result = instance.exports.encrypt(1, 2) print(result)
在 pychrm 中会提示,但是可以正常执行
16359
更多关于 API 参考: https://wasmerio.github.io/wasmer-python/api/wasmer
根据前面的逻辑,我们再实现一下爬取过程
import requests import time import pywasm from wasmer import engine, Store, Module, Instance from wasmer_compiler_cranelift import Compiler store = Store(engine.JIT(Compiler)) module = Module(store, open('Wasm.wasm', 'rb').read()) instance = Instance(module) BASE_URL = 'https://spa14.scrape.center' TOTAL_PAGE = 10 runtime = pywasm.load('./Wasm.wasm') for i in range(TOTAL_PAGE): offset = i * 10 sign = instance.exports.encrypt(offset, int(time.time())) url = f'{BASE_URL}/api/movie/?limit=10&offset={offset}&sign={sign}' response = requests.get(url) print(response.json())
运行结果一样