JS逆向实战:如何处理WASM字符串参数并将其转为C语言?

引言

大家好,还是原来的爬虫练习平台,上篇文章讲到了 wasm 的逆向,本篇文章讲 wasm 的进阶,调用 wasm 中入参和出参都是字符串的情况。

spa15

spa15 地址:https://spa15.scrape.center/

spa15 说明:

电影数据网站,数据通过 Ajax 加载,数据接口参数加密且有时间限制,加密过程通过字符串型 WASM 实现,适合 WASM 逆向分析。

还是老样子,wasm 加密,我们直接看wasm 调用位置,传参变成了两个字符串参数。

image-20250127152623835

image-20250127152551111

其中一个参数是当前的路径,另一个参数是时间戳字符串。继续看,调用的方式好像变化了,变成了 ccall 调用,我们步入看看是不是真的调用了 wasm。

image-20250127170810028

image-20250127170923233

image-20250127171149267

image-20250202215840642

两个字符串参数被转成了整形传入了 wasm,并不是直接将字符串传入。

跟着走了一遍,整体逻辑是在将字符串参数传入 wasm 之前,先要将字符串参数转成 ASCII 码,然后写入到一个通过 wasm 分配的内存中,猜测可能是 wasm 不能直接支持字符串参数,所以最后传入 wasm 的其实是两个字符串写入 wasm 内存的地址值。wasm 执行完成后,将数据存储到内存中,返回地址,然后 JS 再读取它,将其转回正常的字符串。由于流程比较长,这里就不贴所有的截图了。

wasm 内存

Wasm 内存是一个连续的线性字节数组,用于模块与宿主环境(如浏览器、Node.js)之间的数据交互,内存是从 0 开始的线性地址空间,以字节为单位寻址,支持高效随机访问。内存按页分配,每页固定为 64KB(65,536 字节),初始内存大小通过 initial 参数定义(默认至少 1 页),运行时可按需扩展,最大不超过 maximum 参数限制(可选配置)。

从上面的 JS 代码中也可以看到,其实在 ccall 调用的 L 函数中,就是先对当前的字符串进行了编码,然后将编码后的数据通过 N 函数调用 M 函数写入了一个超大的字节数组中,也就是变量 n,长度达到了 16777216,它就是 wasm 的内存。在调用函数后, 通过读取函数返回的地址值,在指定位置读取结果。

wasm2c

wasm 是一种低级的二进制指令格式,支持 C/C++、Rust、Go 等语言编译为 Wasm 模块,是一种类汇编语言,我们平时用到的 wasm 基本上都是从各种语言编译过来的。

一般对于 wasm 的逆向,有几种选择:

  • 一种是通过 Python 或者 nodejs 或者语言实现的 wasm 运行时,将 JS 代码抠出来,使用 wasm 运行时来运行wasm 代码,缺点是需要运行时才能执行代码,好处是方便,通用性比较强。
  • 或者使用 ida 等工具进行逆向分析,分析出详细的算法,缺点是耗时长,并且掉头发,通用性不强。
  • 还有一种方案就是将 wasm 翻译成等价的 C 语言代码,直接执行翻译后的 C 语言代码,这样不需要运行时,并且通用性还不错。

这里我们选择第三种方案。需要用到的工具:https://github.com/WebAssembly/wabt ,大家需要下载前面这个项目的 release,根据自己的系统版本来下载。同时要下载 https://github.com/WebAssembly/wabt/tree/main/wasm2c 文件夹中的代码,后面会用到。

翻译成 C语言

首先我们将下载下来的 wasm 文件放到 wasm2c 可执行文件目录中,执行命令:./wasm2c Wasm.wasm -o wasm.c,可以得到两个文件,wasm.cwasm.h,这两个文件就是 wasm 翻译好的 C 语言代码,查看 https://github.com/WebAssembly/wabt/blob/main/wasm2c/README.md 发现默认使用的是 C99 规范,下面我们来编译试试。

image-20250202234848036

image-20250202234858930

由于刚才翻译成的 C语言代码中只有 wasm 对应的代码,它是一个 lib 库,可以理解为是一个动态链接库(dll 或者 so),它是没有 main 函数的,在 C语言中,没有 main 函数就无法直接执行。

image-20250203132623925

为了能正常初始化 wasm 运行时代码,并且能验证 wasm 翻译成的 C 语言代码是否可以得到正确的结果,我们手动添加一个 main 函数,并且按照上图中 readme 的要求手动添加 wasm 初始化代码。例如添加一个 main.c 文件:

1
2
3
4
5
6
7
8
9
10
#include "wasm.h"
#include <stdio.h>
#include <stdlib.h>

int main() {
w2c_Wasm instance;
wasm_rt_init();
wasm2c_Wasm_instantiate(&instance, NULL);
return 0;
}

编译C语言

按照教程的要求初始化了wasm 运行时,并且导入了我们翻译后的 wasm 库的代码到 main.c 中。现在我们来编译一下试试,编译前别忘了将之前提到的 wasm 运行时代码放到和 main 文件相同的目录中,截止到现在,文件夹中应该有如下文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
❯ tree     
.
├── main.c
├── wasm-rt-impl-tableops.inc
├── wasm-rt-impl.c
├── wasm-rt-impl.h
├── wasm-rt-mem-impl-helper.inc
├── wasm-rt-mem-impl.c
├── wasm-rt.h
├── wasm.c
└── wasm.h

1 directory, 9 files

现在编译试下,我这里使用的是 MacOS 系统,clang 版本是 16.0:

1
2
3
4
5
6
❯ gcc main.c wasm-rt-impl.c wasm-rt-mem-impl.c wasm.c
Undefined symbols for architecture arm64:
"_w2c_wasi__snapshot__preview1_proc_exit", referenced from:
_w2c_Wasm_f19 in wasm-aa7a7b.o
ld: symbol(s) not found for architecture arm64
clang: error: linker command failed with exit code 1 (use -v to see invocation)

发现报错了,错误是缺少了一个符号引用。搜索了一下发现在 wasm.h 文件中,定义了函数,但是没有在 wasm.c 中找到具体的实现,既然没有实现,我们为了编译通过,先根据函数签名补充一个空的实现。

main 文件中开头位置添加以下代码:

1
void w2c_wasi__snapshot__preview1_proc_exit(struct w2c_wasi__snapshot__preview1*, u32){};

重新编译,发现编译通过了,现在来补充 encrypt 函数调用代码,根据 wasm.c 文件中具体的实现代码和 JS 代码的部分逻辑,手动转换成对应的 C 语言代码,添加以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
char* a1 = "/api/movie";//为了测试,先写死
printf("%s\n", a1);
char* a2 = "1738565280";
printf("%s\n", a2);

u32 str_len1 = strlen(a1) + 1;
u32 ptr1 = w2c_Wasm_stackAlloc(&instance,str_len1);
memcpy(w2c_Wasm_memory(&instance)->data + ptr1, a1, str_len1);
u32 str_len2 = strlen(a2) + 1;
u32 ptr2 = w2c_Wasm_stackAlloc(&instance,str_len2);
memcpy(w2c_Wasm_memory(&instance)->data + ptr2, a2, str_len2);
u32 out_ptr = w2c_Wasm_encrypt(&instance,ptr1, ptr2);
char* out_str = (char *)malloc(128);
memcpy(out_str, w2c_Wasm_memory(&instance)->data + out_ptr, 128);
w2c_Wasm_stackRestore(&instance,ptr1);
w2c_Wasm_stackRestore(&instance,ptr2);

printf("%s\n",out_str);
free(out_str);

重新编译一下,通过了,生成了可执行文件:a.out,执行一下看看:

1
2
3
4
❯ ./a.out                                            
/api/movie
1738565280
MmNlMTgxM2ViNzI0NzdmMjIzODU4MzJkOGQ0NWNhMWE2MDc5YmMwMywxNzM4NTY1Mjgw

image-20250203144854005

和网页上的结果对比一下,发现结果完全相同。

编译动态链接库

上面我们成功的将 wasm 翻译后的 C 语言代码编译成了可执行文件,并且验证了算法逻辑是正确的,为了便于后续给其他语言调用,最好将其编译成动态链接库。但是现在我们只有一个 main 函数,所有的逻辑都在 main 函数中,为了方便对外暴露接口,我们稍微修改代码。改完代码别忘了编译运行一下确认修改后的代码可以正常运行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include "wasm.h"
#include <stdio.h>
#include <stdlib.h>
void w2c_wasi__snapshot__preview1_proc_exit(struct w2c_wasi__snapshot__preview1*, u32){};

w2c_Wasm instance;//全局可用

void init_wasm(){
wasm_rt_init();
wasm2c_Wasm_instantiate(&instance, NULL);
}

char* encrypt(const char* a1,const char* a2){
u32 str_len1 = strlen(a1) + 1;
u32 ptr1 = w2c_Wasm_stackAlloc(&instance,str_len1);
memcpy(w2c_Wasm_memory(&instance)->data + ptr1, a1, str_len1);
u32 str_len2 = strlen(a2) + 1;
u32 ptr2 = w2c_Wasm_stackAlloc(&instance,str_len2);
memcpy(w2c_Wasm_memory(&instance)->data + ptr2, a2, str_len2);
u32 out_ptr = w2c_Wasm_encrypt(&instance,ptr1, ptr2);
char* out_str = (char *)malloc(128);
memcpy(out_str, w2c_Wasm_memory(&instance)->data + out_ptr, 128);
w2c_Wasm_stackRestore(&instance,ptr1);
w2c_Wasm_stackRestore(&instance,ptr2);
return out_str;
}

int main() {
init_wasm();
char* a1 = "/api/movie";//为了测试,先写死
printf("%s\n", a1);
char* a2 = "1738565280";
printf("%s\n", a2);

char* out_str=encrypt(a1,a2);

printf("%s\n",out_str);
free(out_str);

return 0;
}

修改后,使用下面的命令编译为 so 文件:

1
❯ gcc main.c wasm-rt-impl.c wasm-rt-mem-impl.c wasm.c -shared -fPIC -o wasm.so

编译后,查看一下是否有我们需要的导出函数 encryptinit_wasm,使用 nm 命令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
❯ nm -gU wasm.so                                                              
0000000000002fe8 T _encrypt
0000000000028000 S _g_wasm_rt_jmp_buf
0000000000002fc4 T _init_wasm
0000000000028100 S _instance
0000000000003124 T _main
0000000000006418 T _w2c_Wasm_0x5F_indirect_function_table
0000000000006430 T _w2c_Wasm_0x5Finitialize
00000000000045bc T _w2c_Wasm_encrypt
00000000000045a4 T _w2c_Wasm_memory
0000000000006504 T _w2c_Wasm_stackAlloc
00000000000064b0 T _w2c_Wasm_stackRestore
0000000000006464 T _w2c_Wasm_stackSave
...后续内容省略

Python 调用 so

so 文件确认没有问题后,可以使用 Python 或者其他语言来调用 so 计算我们需要的 token 了,我这里使用 Python,尝试以下代码调用 so 中的 encrypt 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import ctypes

dll = ctypes.CDLL('./c99/wasm.so')
dll.init_wasm() # 别忘了初始化

def encrypt(p1, p2):
print(p1, p2)
dll.encrypt.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
dll.encrypt.restype = ctypes.c_char_p
a1 = ctypes.c_char_p(p1.encode('utf-8'))
a2 = ctypes.c_char_p(p2.encode('utf-8'))
result = dll.encrypt(a1, a2)
return result.decode()


if __name__ == '__main__':
print(encrypt('/api/movie', '1738565280'))

查看输出结果,符合预期,大功告成!

1
2
/api/movie 1738565280
MmNlMTgxM2ViNzI0NzdmMjIzODU4MzJkOGQ0NWNhMWE2MDc5YmMwMywxNzM4NTY1Mjgw

完整爬取逻辑

接下来补充其他代码,完善爬取逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import ctypes
import time

import requests
import urllib3

urllib3.disable_warnings()
dll = ctypes.CDLL('./c99/wasm.so')
dll.init_wasm() # 别忘了初始化


def encrypt(p1, p2):
print(p1, p2)
dll.encrypt.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
dll.encrypt.restype = ctypes.c_char_p
a1 = ctypes.c_char_p(p1.encode('utf-8'))
a2 = ctypes.c_char_p(p2.encode('utf-8'))
result = dll.encrypt(a1, a2)
return result.decode()


def get_content():
for offset in range(0, 110):
sign = encrypt('/api/movie', str(int(time.time())))
url = f'https://spa15.scrape.center/api/movie/?limit=10&offset={offset}&token={sign}'
r = requests.get(url, verify=False).text
print(r)
time.sleep(0.5)

if __name__ == '__main__':
# print(encrypt('/api/movie', '1738565280'))
get_content()

image-20250203154659328

可以正常爬取数据了,完成!

最终代码见:https://github.com/libra146/learnscrapy/tree/main/js/spa15

总结

wasm 转成 C语言后,调用还是比较方便的,而且不需要 wasm 运行时,性能也可能会高一些,就是编译过程比较繁琐,如果遇到了需要从 JS 中导入函数的 wasm(进行环境检测),这种方式可能会更加复杂,所以具体选择什么方式还是要看具体的情况。

还有 wasm2c 最后生成的 C 语言代码是 C99 规范的,它默认不允许调用未在当前文件定义的函数,明明初始化函数在运行时中定义过了,但是我这里调用就是会编译报错,这里就花了我不少的时间进行调试,实际上只要在编译的时候加上对应的 C 文件就可以了,还是对 C 太不熟悉了哈哈。

断断续续的编译的过程花了我好多时间,期间一直反复的看文档查资料,因为找不到更多其他的参考,只有一个短短的官方文档,加上 AI 的帮助,终于是搞出来了,最终的效果还是很不错的。

本文章首发于个人博客 LLLibra146’s blog

本文作者:LLLibra146

更多文章请关注公众号 (LLLibra146):LLLibra146

版权声明:本博客所有文章除特别声明外,均采用 © BY-NC-ND 许可协议。非商用转载请注明出处!严禁商业转载!

本文链接
https://blog.d77.xyz/archives/d0897e99.html