Skip to content

Shinym@s 初探 04 - resource_hash

Published: at 06:59

才过去一天,我又在研究 SC 的东西了(你的作业呢!!!!!!!!

嘛,我感觉我要死了,但是研究都研究了,时间也花了,没研究出什么不是很浪费,于是抱着这样的心态我开始接触 WASM。估计大部分人初次接触 WebAssembly 应该都是以各种其他语言开始的吧,然而谁让我在逆向呢(悲)。

ToC

WebAssembly 文本格式

这部分的内容由于校赛需要被移到这里了:

WebAssembly 逆向简述

下面就直接开始了(

开始

从二进制到文本文件

二进制显然是看不了的,这里我们就需要用到 wasmwat 的工具了。我这里使用的是 [wasm2wat](https://webassembly.github.io/wabt/doc/wasm2wat.1.html)

输入命令:

Terminal window
./wasm2wat ./resource_hash.wasm > resource_hash.wat

我们就得到了逆向所需要的基本文件。

JavaScript 层入口

一切都需要有个入口,而 WebAssembly 的逆向又需要分为 JS 层面和 WebAssembly 层面的。JS 层面的入口我选择了虾泥助手对应的函数,你也可以自行选择。

选择入口的目的是打断点。虽然 ChromeWASM 调试还有些问题,但动态分析还是能很大程度上帮助我们了解函数执行的全貌(帮助我们更好地猜出实现)。

WASM 入口

WASM 的入口位于 f289,也就是 (;289;)。这个函数通过相对调用的方式调配了 decryptResourceencryptPath 两个函数的执行。真正的入口其实是:

这里就用到查表了,我们虽然不一定要完全弄清楚 Table,但使用还是要会的(

decryptResource

既然是调用的 f304,那我们直接来看:

(func (;304;) (type 4) (param i32 i32 i32) (result i32) ;; decryptResource called
(local i32)
(;
arg#0, arg#1, arg#2, local#3
;)
global.get 6 ;; global#6
local.set 3 ;; local#3 = global$6
;; save current global$6
global.get 6
i32.const 16
i32.add
global.set 6 ;; global#6 += 10000b
;; (the following 4 lines describe the stack after i32.const 3)
local.get 3 ;; local#0 // fn
local.get 1 ;; local#2 // byteLength
local.get 2 ;; local#1 // toDecrypt
local.get 0 ;; local#3 // local#3(global$6 + 10000b)
i32.const 3
i32.and ;; local$0 -> local$0 & 011b
i32.const 453
i32.add ;; p0 -> p0 & 011b + 111000101b
call_indirect (type 2) ;; type2: (param i32 i32 i32) with no return
;; result: 454, jump to 596
local.get 3
call 165
local.set 0
local.get 3
call 31
local.get 3
global.set 6 ;; restore global$6
local.get 0)

可以看到,这里仍然没有对数据作什么太多的操作,我们跟着进 f596

(func (;596;) (type 2) (param i32(;local#0;) i32(;local#1 toDecrypt;) i32(;local#2 byteLength;)) ;; decryptResource, called by f304
(local i32 (;local#3;))
global.get 6
local.set 3 ;; save current global#6
global.get 6
i32.const 16
i32.add
global.set 6 ;; global$6 += 16(4 bytes)
local.get 3
local.get 1
i32.store ;; store local#1(toDecrypt) to [local#3]
local.get 3
local.get 2
i32.store offset=4 ;; store local#2(byteLength) to [local#3+4]
local.get 0 ;; current local#0(original global#6)
local.get 3
i32.const 21514
call 300 ;; call f300(local#3, 21514) ;; might be part of decrypt
local.tee 0 ;; save _malloc addr to local#0
i32.load ;; load (second malloc addr)
local.get 0
i32.load offset=4 ;; load byteLength
call 299 ;; f299(local#0, second malloc addr, byteLength)
local.get 0
i32.load
call 33 ;; _free
local.get 0
call 33 ;; _free
local.get 3 ;; recover global#6
global.set 6)

这里就有点意思了,出现了一个有点意思的未知数字:21514。这里我们还不知道它是什么,去看 f300

;; 前半
(func (;300;) (type 1) (param i32 i32) (result i32) ;; called by decryptResource-2
(local i32 i32 i32 i32 i32 i32 i32) ;; 7 local variables
i32.const 8
call 47 ;; f47 is _malloc, _malloc(8)
local.tee 3 ;; tee_local: like set_local, but also returns the set value
;; save _malloc return value to local#3, also keep it in stack
i32.const 0
i32.store ;; clear front 4 bytes of _malloced space
local.get 3
i32.const 4
i32.add
local.tee 6 ;; save mid addr of _malloced space to local#6
i32.const 0
i32.store ;; clear latter 4 bytes of _malloced space
local.get 0
i32.load ;; load one byte from local#0(local#3 of the caller) ->
;; local#0: |------------|
;; -> | toDecrypt |
;; | byteLength |
;; |------------|
i32.eqz
if ;; label = @1
local.get 3 ;; return malloced space directly, which should be zero
return
end
local.get 0
i32.load offset=4 ;; load byteLength
local.set 4 ;; save byteLength to local#4
local.get 1 ;; 21514
call 60
local.set 7 ;; save return value to local#7 ;; strlen(HEADER)
local.get 4 ;; get byteLength
i32.const 1
i32.add
local.tee 2 ;; save byteLength+1 to local#2
call 47 ;; _malloc(byteLength+1)
local.tee 5 ;; save return value to local#5
i32.const 0
local.get 2
call 84 ;; f84(byteLength + 1, 0, __malloc_space__) // looks like memset // TODO
drop ;; explictly pop value from stack, ignore the return value
local.get 0
i32.load ;; load [local#0] ;; toDecrypt
local.set 8 ;; local#8 = [local#0] ;; toDecrypt

这里出现了超级敏感的操作:分配内存,而且还做了两次,一次是分配了两个 i32 的大小,而另一次则是和我们的输入有关的,byteLength + 1 大小。然而到这里,当时的我还是一无所知,不过看下面:

;; 中段
i32.const 0
local.set 2 ;; local#2 = 0
i32.const 0
local.set 0 ;; local#0 = 0
loop ;; label = @1 ;; for (int local#0=0;local#0 != local#4;local#0++)
local.get 0
local.get 4
i32.ne
if ;; label = @2 ;; if local#0 != local#4 ;; byteLength;;--------------------------------------------------------------------------
;; for (int i=0;i != byteLength;i++)
;; local#0: i
;; local#1: 21514, addr of HEADER string
;; local#4: byteLength
;; local#5: addr of _malloc(byteLength + 1)
;; local#7: length of HEADER string
;; local#8: toDecrypt
local.get 5
local.get 0
i32.add ;; local#5 + local#0(*) ;; malloc_addr + offset
local.get 8
local.get 0
i32.add
i32.load8_s ;; load a byte from [local#8+local#0](*) ;; toDecrypt[offset]
local.get 1 ;; local#1(*) ;; 21514
i32.const 0
local.get 2
local.get 2
local.get 7
i32.eq ;; local#2 == local#7 ? 0 : local#2 ;; local#2 = local2 % local#7 ;; j %= strlen(HEADER);
select
local.tee 2 ;; save to local#2 and keep in stack
i32.add ;; result local#1
i32.load8_s ;; load [result + local#1] ;; HEADER[local#2]
i32.xor ;; (local#8 + local#0) ^ [result + local#1] ;; toDecrypt[local#0] ^ HEADER[local#2]
i32.store8 ;; store a byte to [local#5+local#0] ;; toDecrypt[offset] ;; malloced[i] = toecrypt[i] ^ HEADER[j];
local.get 2
i32.const 1
i32.add
local.set 2 ;; local#2++ ;; j++;
local.get 0 ;; ----------------------------------------------------------------------------------------------------------
i32.const 1
i32.add
local.set 0 ;; local#0++
br 1 (;@1;) ;; goto @1
end
end

这里出现了一个循环和一个 xor。这已经是很明显的特征了,然而当时的我并没有领悟,懵懵懂懂地看了过去,直到机缘巧合下返回来看……

我们看到,这个循环是对整个输入数组进行的,而循环的过程中对另一个量(实际上是数组,不过我们假装不知道)进行的循环的偏移访问,由此,我们可以推断:另一个量也是一个数组,并且存储的很有可能就是 XOR 的 key。

这时候就要问一个问题了:21514 究竟是什么?

数据段

仍然是 MDN 告诉我们:数据段允许字符串字节在实例化时被写在一个指定的偏移量。而且,它与原生的可执行格式中的数据(.data)段是类似的。而 21514 在执行过程中不断被当作地址使用,我们很容易推断:这是内存空间的一个地址。

于是我们来看 resource_hash.wasm 定义的 data

纵向全貌
纵向全貌

顺藤摸瓜,我们看到了熟悉的东西:

眼熟吧(
眼熟吧(

这个 WL[ 基本可以算是刻在 DNA 里的特征了,而且正好这个字符串开头就是对应的 21514,于是此时我们大胆推断:call 60 得到的结果就是字符串的长度。

想要验证这个推断是否正确,可以使用静态分析或者动态分析,这里我直接用了 Chrome 的调试工具,最终证明推断是正确的。但在推断之前,我也静态分析了很长一段时间,因此很难说到底是谁的功劳,应该是二者都有吧,没有静态分析我就不会作出这样的假设,而没有动态分析,估计这时候我还没把第一个函数推出来不是(

XOR 解密

之后就是随手搓一个 XOR 的时间了,这里给出一个 xjb 写的 C 程序:

#include
#define KEY_LEN 54
char key[KEY_LEN] = "B'KYWL[DI\\vqUIyw_we_are_hiring_https://knocknote.co.jp";
int main() {
FILE* fi = fopen("test.in", "rb");
FILE* fo = fopen("test.out", "wb");
char c;
int offset = 0;
do {
c = fgetc(fi);
fputc(c ^ key[offset], fo);
offset = (offset + 1) % KEY_LEN;
} while (!feof(fi));
fclose(fi);
fclose(fo);
}

然后随便下一个资源下来,我用的是 asset-map-aac011a61415a220560587aaf0177b2d98e8c7897d7697db4dd51b509162040d,执行,binwalk,然后我就傻了:

再一看 Dolphin

连图标都变了
连图标都变了

ArkKate打开:

好嘛,游戏结束了
好嘛,游戏结束了

wtcl
wtcl

于是 decryptResource 成果解密(我之前怎么就没想到是 gzip 的魔数呢

encryptPath

数据段

有了上一个函数的经验,这里我们首先看的就是 data 段:

果不其然,甚至和刚才那段是同一行,还在那段的前面,出现了我们想要的信息:picosha2。于是我们推断:最终结果是某一个值的 sha256 结果。

(其实也没那么容易作出判断,这里我是和 picosha2 的源码作比对之后确认了 sha256 计算在最后才得出的推论,但今后其实可以大胆一点,毕竟推错了也没什么太大影响,但一旦对了就能节省不少时间(

f302

同样是从入口开始,这次是 f302

(func (;302;) (type 4) (param i32(;2;) i32(;path;) i32(;tail;)) (result i32)
(local i32 i32)
global.get 6
local.set 3
global.get 6
i32.const 48
i32.add
global.set 6 ;; global#6 += 48
local.get 3
i32.const 12
i32.add
local.tee 4 ;; local#4 = global#6(original) + 12
local.get 1 ;; path
call 164 ;; f164(global#6_original + 12, path)
local.get 3
local.get 2
call 164 ;; f164(global#6_original, tail)
local.get 3
i32.const 24
i32.add
local.tee 1 ;; local#1 = global#6_original + 24, keep in stack* ;; +24
local.get 4 ;; local#4* ;; after_path +12
local.get 3 ;; global#6_original* ;; after_tail +0
local.get 0
i32.const 3
i32.and
i32.const 453
i32.add ;; 2 & 3 + 453
call_indirect (type 2) ;; 455, jump to f262
local.get 1
call 165
local.set 0
local.get 1 ;; _frees
call 31
local.get 3
call 31
local.get 4
call 31
local.get 3
global.set 6
local.get 0)

可以看到,这里经过了两次 f164,不知道干了什么,但这里我当时选择了先跳过,于是我们来看 f262(并且后来证明 f164 并不大影响理解(

f262

不得不说的是,这部分的解密带有运气的色彩

(func (;262;) (type 2) (param i32 i32 i32)
(local i32 i32 i32 i32)
global.get 6
local.set 3
global.get 6
i32.const 32
i32.add
global.set 6 ;; local#6 = global#6 + 32
local.get 2
i32.load8_s offset=11
i32.const 0
i32.lt_s
if ;; label = @1
local.get 2
i32.load
local.set 2
end
local.get 3
i32.const 20
i32.add
local.set 5
local.get 3
i64.const 0
i64.store
local.get 2
i32.load8_s
local.set 4 ;; save ascii of first character of tail to local#4
local.get 2
local.get 2
call 60 ;;strlen(tail)
i32.const -1
i32.add ;; strlen - 1
i32.add ;; local#2 + strlen - 1 (tail)
i32.load8_s ;; ascii of last character of tail
local.set 6 ;; save to local#6
local.get 3
i32.const 8
i32.add
local.tee 2 ;; local#2 = #global(ori) + 8
local.get 4
i32.store ;; [local#2] = local#4(first character)
local.get 2
local.get 6
i32.store offset=4 ;; [local#2+4] = local#6(last character)
local.get 3 ;; global_original
i32.const 8 ;; 8
i32.const 21154 ;; %c%c
local.get 2 ;; global_original + 8

这里最大的发现是它截取了 tail(也就是第二个参数)的首尾字母,并且下面的 21154 的值恰好就是 %c%c,于是猜测这两个字母是连续的。测试了一下随便更换 tail 的中间字母,发现也没有任何变化,基本上可以证实了:

完 全 一 致
完 全 一 致

函数本体
函数本体

接下来就是这两个字母到底是怎么放的问题了,于是我又开始猜测了:是不是开头/结尾?果不其然,结果是开头:

这是我万万没想到的
这是我万万没想到的

不过总算是结束了(
不过总算是结束了(

就此,整个 resource_hash.wasm 宣告解密完毕。

结语

这次的逆向过程可以说是我第一次认真对汇编级别的源码进行分析的过程。中间有试过用 wasm2c 转成 C 再用 gcc 编译再导入 IDA,但效果并不尽如人意(还是我不大会 x86 汇编的锅)。相比之下,WASM 本身比 x86 的指令简单了 114514 倍,强行转过去不是自己给自己增加难度吗(

一晚上就这样过去了,不过总算是有结果了,也算是不辜负我这一晚上的爆肝吧(


Previous Post
Shinym@s 初探 03 - 获取卡面信息
Next Post
Shinym@s 初探 05 - request_hash