说点什么
复现某网站字幕加密的wasm分析过程,并使用python完成对wasm的调用以获取字幕解密key
之前其实有写一篇wasm调用的文章,但是没有公开
既然现在有一篇现成的,那不如就拿它做个演示~
环境和工具
- chrome-standalone-v91.zip 最新免安装版Chrome
注意,文章中的代码均为完整代码的片段
过程
首先打开网址,观察一下它的请求
可以看到wasm和字幕是挨着加载的
这个时候,推荐使用polyv加密key解密过程定位教程里提到的方法来定位加密字幕的解密位置
关键点追踪和异常定位法搭配使用
首先点开字幕请求的Initiator
,点开第一个位置,在这里后一行下断点,刷新网页
然后会停在这里,点下一步
可以看到这里的一些东西就是字幕请求结果的东西
然后在e.changeSuccess()
下断点,跳到这里
显然这里就是加密字幕内容了
现在是时候展现异常定位法的效果了,通过下面的代码将加密的内容设置为连续0的字符串
e._data.forEach((item) => {console.log(item.sub = "00000000000000000000000000000000000000000000000000000000000000000000000000")});
去掉断点,让代码接着运行
很好!现在有报错提示了,如果没有可能是当前播放的时间点没有字幕要显示,自行拉一下进度条
直接点过去看看
在这个位置下断点,很快会停在这里,这个时候看不出来解密位置,不慌跟着调用栈看看
哗的一下,很快啊,就定位到关键位置了
d
是修改后的“加密字幕内容”内容,经过函数s
后就出现了看到的异常提示,显然它是在解密
在函数s
这里下断点,继续运行代码,停在断点这里之后,单步跟进去
是ccall
,那是wasm解密没跑了,这个关键词是浏览器使用wasm的一个标志性关键词
浏览器上JavaScript和WebAssembly具体怎么交互,这里就不详细展开了,请自行查阅MDN的文档
这里主要说下js调用wasm的几个关键流程
首先开辟一块内存
wasmMemory = new WebAssembly.Memory({ "initial": INITIAL_INITIAL_MEMORY / WASM_PAGE_SIZE, "maximum": 2147483648 / WASM_PAGE_SIZE })
初始化缓冲区和视图
function updateGlobalBufferAndViews(buf) { buffer = buf; Module["HEAP8"] = HEAP8 = new Int8Array(buf); Module["HEAP16"] = HEAP16 = new Int16Array(buf); Module["HEAP32"] = HEAP32 = new Int32Array(buf); Module["HEAPU8"] = HEAPU8 = new Uint8Array(buf); Module["HEAPU16"] = HEAPU16 = new Uint16Array(buf); Module["HEAPU32"] = HEAPU32 = new Uint32Array(buf); Module["HEAPF32"] = HEAPF32 = new Float32Array(buf); Module["HEAPF64"] = HEAPF64 = new Float64Array(buf) } updateGlobalBufferAndViews(buffer);
将动态基址写入特定位置
HEAP32[DYNAMICTOP_PTR >> 2] = DYNAMIC_BASE;
- 定义一些外部函数,即wasm内调用js这边的函数,用处很多,比如交换数据,检查环境等等
将外部函数、内存等作为一个对象传递给WebAssembly.instantiate,完成初始化
var asmLibraryArg = { "a": ___sys_fcntl64, "d": ___sys_ioctl, "e": ___sys_open, "f": ___sys_rmdir, "g": ___sys_unlink, "h": _clock, "l": _emscripten_memcpy_big, "m": _emscripten_resize_heap, "j": _emscripten_run_script, "r": _emscripten_run_script_int, "q": _emscripten_run_script_string, "n": _environ_get, "o": _environ_sizes_get, "c": _fd_close, "p": _fd_read, "k": _fd_seek, "b": _fd_write, "memory": wasmMemory, "table": wasmTable, "i": _time }; var info = { "a": asmLibraryArg };
将wasm的导出函数和一些有意义函数名进行绑定,这一步可有可无
var ___wasm_call_ctors = Module["___wasm_call_ctors"] = function() { return (___wasm_call_ctors = Module["___wasm_call_ctors"] = Module["asm"]["s"]).apply(null, arguments) }; var _monalisa_set_canvas_id = Module["_monalisa_set_canvas_id"] = function() { return (_monalisa_set_canvas_id = Module["_monalisa_set_canvas_id"] = Module["asm"]["t"]).apply(null, arguments) };
然后就是调用交互了
function ccall(ident, returnType, argTypes, args, opts) { var toC = { "string": function(str) { var ret = 0; if (str !== null && str !== undefined && str !== 0) { var len = (str.length << 2) + 1; ret = stackAlloc(len); stringToUTF8(str, ret, len) } return ret }, "array": function(arr) { var ret = stackAlloc(arr.length); writeArrayToMemory(arr, ret); return ret } }; function convertReturnValue(ret) { if (returnType === "string") return UTF8ToString(ret); if (returnType === "boolean") return Boolean(ret); return ret } var func = getCFunc(ident); var cArgs = []; var stack = 0; if (args) { for (var i = 0; i < args.length; i++) { var converter = toC[argTypes[i]]; if (converter) { if (stack === 0) stack = stackSave(); cArgs[i] = converter(args[i]) } else { cArgs[i] = args[i] } } } var ret = func.apply(null, cArgs); ret = convertReturnValue(ret); if (stack !== 0) stackRestore(stack); return ret }
有了上述知识后,那么想用python调用,只要完成上面的部分过程即可
也就是下面这些问题
- 内存给多大,怎么给?
- python怎么定义导入函数?
- 初始化参数准备好了怎么初始化?
- 初始化完成了怎么调用导出函数?
- 调用了函数怎么拿到返回结果?
好在已经有比较完善的python库可以完成上述过程,它就是wasmer-python
详细的案例可以参考
首先初始化一个存储器
store = Store(engine.JIT(Compiler))
接着载入wasm,因为需要修改wasm内容,所以这里读取的是wat,后面会讲
wasm = Path(r'iq_wasm/files/libmonalisa-v3.0.6-browser.wasm').read_bytes()
module = Module(store, wasm)
然后初始化内存
memory = Memory(store, MemoryType(256, 256, shared=False))
最后是生成导入对象,初始化实例
import_object = ImportObject()
import_object.register('a', _import_object)
asm = Instance(module, import_object=import_object)
现在测试一下正不正常
然而报错了,这是为什么呢,根据我的经验,似乎这是因为wasmer-python
仍然有一些地方不完善
如果导入的对象中,有table,那就会这个报错,虽然提供了Table类型的函数,但是有一个变量一直有问题
基于这个原因,需要修改wasm,当然不是直接改wasm,而是修改对应的wat文件
用wabt进行转换
..\\..\\wabt\\bin\\wasm2wat.exe libmonalisa-v3.0.6-browser.wasm -o libmonalisa-v3.0.6-browser.wat
然后把table
这一行改一下
(table $a.table (;0;) 48 48 funcref)
这样就不用导入这个了
对应的,现在用另外一种方式读取载入wasm
wasm = wat2wasm(Path(r'iq_wasm/files/libmonalisa-v3.0.6-browser.wat').read_text(encoding='utf-8'))
现在有两个关键点
- 导入对象中的具体函数怎么写
- 怎么调用导出函数才能和浏览器结果一样
先说导入对象的函数,以_environ_get
为例
这是python中的写法,简单来说wasm和js交互的时候就是在改变其内存区域的数据,按对应的逻辑操作内存数据就行了
def _environ_get(self, __environ, environ_buf):
print('call _environ_get', __environ, environ_buf)
bufSize = 0
strings = self.getEnvStrings()
for index, string in enumerate(strings):
ptr = environ_buf + bufSize
self.memory.int32_view()[__environ + index * 4 >> 2] = ptr
self.writeAsciiToMemory(string, ptr)
bufSize += len(string) + 1
return 0
def writeAsciiToMemory(self, string, buffer, dontAddNull: int = 0):
for num in list(string.encode('utf-8')):
self.memory.int8_view()[buffer >> 0] = num
buffer += 1
if dontAddNull == 0:
self.memory.int8_view()[buffer >> 0] = 0
然后按下面这样用Function
包裹函数,然后构建一个字典
实例化ImportObject
后通过register
完成导入对象的设置,最后进行wasm实例化
self.import_object = ImportObject()
self.import_object.register('a', _import_object)
self.asm = WasmerInstance(module, import_object=self.import_object)
Function
后面的FunctionType
怎么来的呢
这个可以通过查看wat代码对应,准备好wasm相关的工具wabt
先用wasm2wat
将wasm转换为wat文件
这里可以看到导入函数的参数与结果类型,按这个去写就可以了
另外导入函数只要定义了就行,不一定需要具体返回什么
为了达到调用目的,还需要写一个python的ccall
先看看js这里怎么写的,翻译一份,ccall
部分如下
def ccall(self, func_name: str, returnType: 'type', *args):
def convertReturnValue(_ptr: int):
if returnType == str:
return self.UTF8ToString(_ptr)
elif returnType == bool:
return bool(returnType)
return _ptr
stack = 0
_args = []
for arg in args:
if isinstance(arg, str):
if stack == 0:
stack = self.stackSave()
max_write_length = (len(arg) << 2) + 1
ptr = self.stackAlloc(max_write_length)
self.stringToUTF8(arg, ptr, max_write_length)
_args.append(ptr)
elif isinstance(arg, list):
ptr = self.stackAlloc(len(arg))
ptr = self.writeArrayToMemory(arg, ptr)
_args.append(ptr)
else:
_args.append(arg)
ptr = self.export_configs[func_name](*_args)
ret = convertReturnValue(ptr)
if stack != 0:
self.stackRestore(stack)
return ret
其他子函数参见完整代码
现在就可以传递参数了,但在此之前需要知道浏览器初始化wasm的时候都做了哪些动作
所以先在所有的导出函数下断点,刷新调试,下图是一部分
导入部分的函数调用不下断点是因为可以通过python调用的时候看出来
刷新网页,等停在断点
可以看到依次调用了
___wasm_call_ctors
_monalisa_context_alloc
stackSave
到stackSave
的时候是从ccall
来的,那么这之前应该先调用上面两个函数
___wasm_call_ctors
是直接调用的,且没有参数
_monalisa_context_alloc
同上
那么初始化函数就这样写
def doinit(self):
self.___wasm_call_ctors()
self._monalisa_context_alloc()
然后是stackSave
,这个时候根据调用栈,可以知道这里是在设置license
切换下调用栈,参数有4个
按下面的代码调用
def _moSetLicense(self):
license = 'AIUACgMAAAAAAAAAAAQChgACATADhwAnAgAg3UBbUdVCWXAjkgoUgmICmHvomvZai0jGglWe+oaQC+MCAAAAA4gANwEAMHStDt3ZksUi3Q7ZpevUL0ce2NA73SNiqOJW3Wc0P6B62xYg8yiWFF92KXEGjljOeQEAAgD/iQAkAQAAIKgivL0LDGFSYlwcToEO6LCRYFZjE3lycoiZDPxiNXFo'
ret = self.ccall('monalisa_set_license', int, self._ctx, license, len(license), '0')
print('_moSetLicense', ret)
下一个是_monalisa_set_canvas_id
,这里参数也比较明了
def _moSetCanvasId(self):
_canvasId = 'subtitlesubtitle'
ret = self.ccall('_monalisa_set_canvas_id', int, self._ctx, _canvasId, len(_canvasId))
print('_moSetCanvasId', ret)
同理,接着是_monalisa_version_get
def _moGetVersion(self):
ret = self.export_configs['_monalisa_version_get']()
print('_moGetVersion', ret)
然后是monalisa_get_line_number
def get_line_number(self):
enc_text = 'F6vjGQAlaXWf/rYLFkGHQLok+aI/X+7MBJI6EmzYtZ4='
ret = self.ccall('monalisa_get_line_number', int, self._ctx, enc_text, len(enc_text), '(?:<br\\/>|\\n)')
print('get_line_number', ret)
然后跑python代码出了异常
这是因为在_emscripten_run_script_int
这里没有给返回结果
分析js确定这里实际上返回的是解密后字幕的行数,一般都是1,直接返回1就行
def _emscripten_run_script_int(self, param_0):
text = self.UTF8ToString(param_0)
# 原函数这里是运行一段js代码 这里是python 所以运行不了
# 按执行对应的结果给返回即可 必须这里是根据<br>返回字幕行数的
# eval(text)
print('call _emscripten_run_script_int', text)
return 1
然后是调用_monalisa_set_canvas_font
,这个时候后面就没有必要继续模拟了
因为这个时候明文已经有了,所以达成了调用目的了
上面调用过程说的差不多了,那解密key在哪儿呢
首先参考渔哥的帖子,先把wasm编译为.o
文件
这里用逍遥一仙
的工具
然后拖入IDA
根据已知信息,可以知道w2c_f94
的参数一就是解密的密钥
那到浏览器对应位置下断点看看,开始几轮没有,然后就有了
那解密key就是
1dd28b0cb4ba5926ed75b9821d0235b4
在monalisa_set_license
后保存一下全部内存,搜索一下
可以看到key在内存中
由于wasm的运算机制,输入不变,那中间产生的变量啊什么的,都会在固定位置
那完全可以在调用完monalisa_set_license
之后读取这个位置的内容就能拿到key了
至此调用wasm获取解密key完成