WebAssembly案例分析和爬取实战
WebAssembly 是一种可以使用非 JavaScript 编程语言编写代码并且能在测览器上运行的技术方案。
前面我们也简单介绍过了,借助 Emscripten 编译工具,我们能将 C/C++ 文件转成 wasm 格式的文件,JavaScript 可以直接调用该文件执行其中的方法。
这样做的好处如下:
-
一些核心逻辑(比如 API 参数的加密逻辑)使用 C/C++ 实现,这样这些逻辑就可以 “隐藏” 在编译生成的
wasm文件中,其逆向难度比 JavaScript 更大。 -
一些逻辑是基于 C/C++ 编写的,有更高的执行效率,这使得以各种语言编写的代码都可以以接近原生的速度在 Web 中运行。
对于这种类型的网站,一般我们会看到网站会加载一些 wasm 后缀的文件,这就是 WebAssembly 技术常见的呈现形式,即原生代码被编译成了 wasm 后缀的文件,JavaScript 通过调用 wasm 文件得到对应的计算结果,然后配合其他 JavaScript 代码实现页面数据的加载和页面的喧染。
本节中,我们就来通过一个集成 WebAssembly 的案例网站来认识下 WebAssembly,并通过简易的模拟技术来实现网站的爬取。
案例介绍
下面我们来看一个案例,网址是 htps://spa14.scrape.center/ ,这个网站表面上和之前非常类似,但是实际上其 API 的加密参数是通过 WebAssembly 实现的。
首先,我们还是像之前一样,加载首页,然后通过 Network 面板分析 Ajax 请求,如图 11-107 所示。
图 11-107 Network 面板
可以看到,这里就找到了第一页数据的 Ajax 请求。和之前的案例类似,limit、offset 参数用来控制分页,sign 参数用来做校验,它的值是一个数字。通过观察后面几页的内容,我们发现 sign 的值一直在变化。
因此,这里的关键就在于找到 sign 值的生成逻辑,我们再模拟请求即可。接下来,我们就进行一下逆向,先看看这个参数生成的逻辑在哪里吧。
这里我们还是设置一个 Ajax 断点,在 Sources 面板的 XHR/fetch Breakpoints 这里添加一个断点,内容为 /api/movie,就是在请求加载数据的时候进入断点,如图 11-108 所示。
图 11-108 添加断点
接下来,重新刷新页面,可以看到页面执行到断点的位置后停了下来,如图 11-109 所示。
图 11-109 在断点处停止
这里我们还是通过 Call Stack 找到构造逻辑。经过简单的查找和推测,可以判断逻辑的入口在 onFetchData 方法里面,如图 11-110 所示。
图 11-110 找到逻辑入口
点击 onFetchData 方法,找到方法所在的 JavaScript 代码位置,如图 11-111 所示。
图 11-111 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 和一个时间戳构造出来了。
接下来,我们进一步在此处调试一下,在 2100 行添加断点,如图 11-112 所示。
图 11-112 在 2100 行添加断点
重新刷新页面,可以发现页面运行到该断点的位置并停下来了,如图 11-113 所示。
图 11-113 页面运行到该断点的位置并停下来
这相当于 JavaScript 上下文处于 onFetchData 方法内部,所以现在我们可以访问方法内部的所有变量,比如 this、this.$wasm 等。
接下来,我们就在 Watch 面板中添加一个变量 this.$wasm,先看看它是什么对象,如图11-114 所示。
可以看到,这个 this.$wasm 对象里面又定义了很多对象和方法,其中就包括了 asm 对象。因为代码中又调用了 asm 对象的 encrypt 来产生 sign,所以我们进一步看看 asm 对象、encrypt 方法都是什么。将图 11-114 中的 asm 对象直接展开即可,如图 11-115 所示。
图11-114 变量 this.$wasm
图11-115 展开 asm 对象
这时候我们可以看到 asm 对象里面又包含了几个对象和方法,比较重要的就是 encrypt 方法了,其中它的 指向了另外一个位置,名称是 ab728922:0xd9。因为我们就是想知道这个方法内部究竟是什么逻辑,所以直接点击进入,如图 11-116 所示。
图 11-116 展开 encrypt 方法
可以看到,我们进入了一个似乎不是 JavaScript 代码的位置,文件名称叫作 ab728922。通过左侧的 Page,可以看到它在 wasm 路径下,代码跳转的位置可以看到 encrypt 字样,其代码定义如下:
(func $encrypt (;4;) (export "encrypt")(param $varo 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 后缀的文件,如图 11-117 所示。
图 11-117 搜索 wasm 后缀的文件
可以看到,这里就有一个 wasm 后缀的文件,其逻辑就是刚才看到的内容。
到了这里,wasm 代码已经完全看不懂了,接下来怎么做呢?
有两种办法,一种是直接把 wasm 文件进行反编译,还原成 C++ 代码,此种方法上手难度大,需要了解 WebAssembly 和逆向相关的知识;另外一种就是通过模拟执行的方式来直接得到加密结果。
本节中,我们主要来了解第二种方案。拿到 wasm 文件,然后通过 Python 模拟执行的方式调用 wasm 文件,模拟调用它的 encrypt 方法,传入对应的参数即可。
模拟执行
首先,我们把 wasm 文件下载下来,地址为 https://spa14.scrape.center/js/Wasm.wasm ,将其保存为 Wasm.wasm 文件。
要使用 Python 模拟执行 wasm,可以使用两个 Python 库,一个叫作 pywasm,另一个叫作 wasmer-python,前者使用更加简单,后者功能更为强大。我们使用任意一个库都可以完成 wasm 文件的模拟,下面我们来分别予以介绍。
pywasm
这个库比较简单,其主要功能就是加载一个 wasm 文件,然后用 Python 执行。安装命令如下:
pip3 install pywasm
安装完成之后,我们可以用如下代码来加载 wasm 文件:
import pywasm
runtime = pywasm.load('./wasm.wasm)
print(runtime)
这里我们调用了 pywasm 的 load 方法,直接将 wasm 文件的路径传入,实现了 wasm 文件的读取,输出结果如下:
<pywasm.Runtimeobjectatox7fbd88oefd1o>
可以看到,返回结果就是一个 pywasm.Runtime 类型的对象。
有了这个 Runtime 对象之后,我们就可以调用它的 exec 方法来模拟执行 Wasm 里面的方法。
比如,在网页中我们可以看到它执行了 encrypt 方法,并传入了两个参数。我们也来试一下,要模拟调用 wasm 的方法,只需要调用 Runtime 对象的 exec 方法并传入对应的方法名和参数内容即可。我们可以将代码改写如下:
import pywasm
runtime = pywasm.load('./wasm.wasm')
result = runtime.exec('encrypt', [1,2])
print(result)
这里我们调用了 exec 方法,第一个参数就是要调用的 wasm 中的方法名,这里我们传入字符串 encrypt,第二个参数是一个列表,代表 encrypt 方法所接收的参数,如果是两个,那么列表长度就是 2,参数和列表的元素一一对应即可。
运行结果如下: 16359
调用成功了!
成功输出了结果,但是这似乎并不是我们想要的,因为这里传入的参数其实是我们自定义的。要真正模拟网站的 Ajax 请求,就要用网站里面的真实参数。
通过分析逻辑,我们知道传入的参数其实一个是 offset,一个是时间戳。其中后者的实现是这样的:
parseInt(Math.round((newDate).getTime()/1e3).toString())
这是 JavaScript 中的实现,我们将其输出到控制台,可以看到运行结果如图 11-118 所示
图 11-118 运行结果
输出的其实就是一个时间戳,结果是数值类型,位数是 10 位。使用 Python 实现同样的结果,可以这样写:
import time int(time.time())
最终,我们可以将爬虫逻辑实现,具体如下:
import pywasm
import time
import requests
BASE_URL = "https://spa14.scrape.center"
TOTAL_PAGE = 10
runtime = pywasm.load('./Wasm.wasm')
for i in range(TOTAL_PAGE):
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())
这里我们先定义了 TOTAL_PAGE 是 10O,就是 10 页,然后开始一个 for 循环遍历,i 就是 0~9 的数字,offset 就是 0、10、20、…、90,sign 就利用刚才的实现,将参数转化为 offset 变量和时间戳,最后构造 URL 请求即可。
运行结果如下:
可以看到,Ajax 请求被成功模拟了!成功爬取到了结果。
wasmer-python
除了使用 pywasm 库,我们还可以使用另一个库 wasmer-python 来完成同样的操作。相比 pywasm,wasmer-python 的功能更为强大,它提供了更为底层的 API。如果遇到更为复杂的 wasm 调用情形,推荐使用 wasmer-python。
要安装 wasmer-python 这个库,依然使用 pip3 即可,命令如下:
pip3 install wasmer-python
要读取 wasm 文件,我们需要先声明一个 Store 对象,然后将 wasm 对象转化为 Module 对象,再将其转化为 Instance 对象,写法类似如下:
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)
这里我们还是调用了 encrypt 方法并传入了 1 和 2 两个参数,运行结果如下: 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)
BASEURL = "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())
运行结果也一样,这里不再列出。这里我们也成功使用 wasmer-python 库完成了 wasm 的模拟执行,并成功爬取到了数据。