[前端逆向] Shinym@s 初探(4) 资源获取(4) – resource_hash

发布日期:分类:283前端逆向

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

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

WebAssembly 文本格式

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

下面就直接开始了(

开始

从二进制到文本文件

二进制显然是看不了的,这里我们就需要用到 wasmwat 的工具了。我这里使用的是 wasm2wat

输入命令:

./wasm2wat ./resource_hash.wasm > resource_hash.wat

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

JavaScript 层入口

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

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

WASM 入口

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

  • decryptResourcef304
  • encryptPathf302

这里就用到查表了,我们虽然不一定要完全弄清楚 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

于是 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 倍,强行转过去不是自己给自己增加难度吗(

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

1条评论

  1. 感谢分享!
    成功拿到4轮结华的立绘模型文件
    四舍五入我也算抽到了结华(

发表评论

您的电子邮箱地址不会被公开。 必填项已用*标注