实例讲解未知游戏文件格式的逆向分析方法(下)

fanyeee 技术 2019年5月7日发布
Favorite收藏

导语:在本文中,我们将通过实例为读者详细讲解如何对未知的游戏文件格式进行逆向分析。

接上文) 

现在,我们知道v8绝对是一个类。接下来,让我们暂时离开这里,回到50B9A2,考察一下50B91E函数:

    _BYTE *__stdcall sub_50B91E(int a1, int a2, unsigned int a3)
    {
      unsigned int v3; // [email protected]
      unsigned int v4; // [email protected]
      _BYTE *result; // [email protected]
    
      v3 = 0;
      v4 = 0;
      if ( a3 )
      {
        do
        {
          result = (_BYTE *)(v4 + a2);
          *result ^= *(_BYTE *)(a1 + v3++ + 116);
          if ( v3 >= 0x1000 )
            v3 = 0;
          ++v4;
        }
        while ( v4 < a3 );
      }
      return result;
    }

首先,让我们重命名最后两个参数,因为我们已经知道它们是什么:

    _BYTE *__stdcall sub_50B91E(int a1, int pDestBuffer, unsigned int uiSize)
    {
      unsigned int v3; // [email protected]
      unsigned int v4; // [email protected]
      _BYTE *result; // [email protected]
    
      v3 = 0;
      v4 = 0;
      if ( uiSize )
      {
        do
        {
          result = (_BYTE *)(v4 + pDestBuffer);
          *result ^= *(_BYTE *)(a1 + v3++ + 116);
          if ( v3 >= 0x1000 )
            v3 = 0;
          ++v4;
        }
        while ( v4 < uiSize );
      }
      return result;
    }

在这里,最重要的一行代码是:

    *result ^= *(_BYTE *)(a1 + v3++ + 116);

我们知道,XOR通常用于加密/解密过程。如您所见,它会逐字节地处理位于a1+116+v3处的数组,并对该数组的加密缓冲区中的每个字节都进行异或操作。如果v3等于4096(0x1000h),则将其重置为0。XOR处理将持续到v4(在每次迭代结束时都会递增)大于或等于参数uiSize时为止。

看上去,这可能有点难以理解,不过不要担心,我们可以借助于动态分析工具来搞定它。

使用OllyDbg进行动态分析

首先,在OllyDbg中加载pbclient.exe,然后,单击“E”按钮并选择“pbclient.exe”模块,以确保我们处于正确的模块中。

我们将在CreateFileA调用(还记得吗?它位于0x52AE36中)的第一个参数处设置一个断点,它实际上就是我们的文件名。

(注意:设置断点后不要删除,我们以后还会需要它们)

它位于0x52AE82处:

1.png

让我们现在就开始动手吧!如果它崩溃了,建议先下载Stealth64插件,如果您使用的是64位Windows 7系统的话。同时,我们还启用了除“Misc”部分之外的所有选项。

如果一切顺利,它现在将会在图示中的位置中断:

1.png

我们已经知道,它将使用anim.bus(同时,我们也得到了它使用这个函数的确认)。让我们在调用MapViewOfFile之后立即设置另一个断点,以获取映射视图的地址(返回值通常存储在EAX中):

1.png

(顺便说一句,一定要确保Anim.bus没有在任何地方打开,否则,它可能会失败!)

如果成功,映射视图的地址将存放到EAX寄存器中,因此,请在“Registers”窗口中右键单击它,然后,单击“Follow in Dump”选项:

1.png

让我们直接转到0x50B91E(执行XOR的地方),并在那里设置另一个断点,然后恢复游戏从而再次触发断点。

1.png

使用F8单步跟踪指令,直到:

  0050B931  |. 8A540A 74      |MOV DL,BYTE PTR DS:[EDX+ECX+74] ; 74h => 114

这里,ECX用作数组的计数器。这里,它会与EDX的值相加,而EDX就是a1(记住,a1是一个类)。然后,加上距离数组开头的偏移量(74h/114)。

下面给出更加易于理解的伪代码:

    uint8_t DL = a1->xor_array[ECX]; //xor_array starts 74h into a1, and ECX is incremented after each iteration

我们来看看下面一行代码:

    0050B935  |. 03C6           |ADD EAX,ESI

EAX保存的是目标缓冲区的地址。它也会在每次迭代结束时递增,直到“uiSize”字节被异或为止。

之后,继续在转储中跟踪EAX,并在循环(POP ESI)指令后设置断点,恢复游戏并查看转储窗口:

1.png

它好像已经解密了缓冲区中的内容!

1.png

我们跳过33个字节,并解密了272个字节的内容。但是如果我们仔细查看Data文件夹中的Anim.bus,会发现文件Anim.bus的大小是305个字节。而272+33正好等于……

305!这说明整个Anim.bus文件都被解密了。

那么,解密缓冲区后,我们能够得到哪些有用的信息呢?不多,只不过看起来更像个文件夹名称而已。

接下来,我们可以编写一个简单应用程序,让它执行与游戏完全相同的操作,即使用CreateFile、OpenFileMapping和MapViewOfFile将文件映射到内存,并解密缓冲区中的内容(记住,起始位置是缓冲区地址+33个字节!)。要想完成解密,还需要获取用作XOR密钥的字节数组中的内容,该数组长度为4096字节(4KB)。为此,需要重新关闭并运行该游戏,并触发该指令:

    0050B931  |. 8A540A 74      |MOV DL,BYTE PTR DS:[EDX+ECX+74] ; 74h => 114

读取EDX的值,加上114,即数组开头的地址。在转储窗口中跟踪它,将该地址加上4096,即数组末尾的地址。之后,只需复制这两个地址之间的所有字节,并利用Notepad++格式化所有内容即可。

如果在此过程中遇到麻烦,可以访问下列地址:

· http://pastebin.com/skZKAx27

下面给出解密函数:

    bool DecryptData(uint8_t* puiBuffer, const size_t uiSize)
    {
             if (!uiSize)
             {
                     return false;
             }
    
             for (size_t i{ 0 }, j{ 0 }; i < uiSize; ++i)
             {
                     puiBuffer[i] ^= bus::crypt::XOR_ARRAY[j++];
                     if (xor_idx >= XOR_ARRAY_SIZE) { j = 0; }
             }
             return true;
    }

让我们回到IDA。我们已经弄懂了这两个函数的用途:一个用于复制内存并调用解密函数,而另一个则是解密函数本身。

既然如此,我们可以重命名这两个函数:

1.png

这样,当我们再次遇到它们时,我们就不需要再浪费时间来研究它们了,因为我们已经知道其作用了。

现在,我们快速总结一下前面的研究成果:

· 大部分游戏数据都存储在.bus文件中

· 这些.bus文件可以同时存储多个文件,因此,它们实际上是文件存档

· 该游戏使用Gamebryo引擎,因为存储的文件似乎是NIF/KFM/DDS格式的

· 游戏客户端使用CreateFile、OpenFileMapping和MapViewOfFile函数将整个.bus文件映射到内存中

· 然后,从映射视图的基地址+33字节处解密272个字节

让我们回到调用CopyAndDecrypt的函数那里。首先,它检查目标缓冲区是否存在,如果不存在的话,就直接返回0。让我们直接跳到这一行:

    v11 = *(char **)(*(_DWORD *)(v5 + 104) + 268); //dest_buffer + 268

它首先获取目标缓冲区的地址,然后,又偏移0x10C(268d)字节。下面的代码要更容易理解一些:

    v11 = *reinterpret_cast<uint32_t*>(dest_buffer + 268); //this does the same

对于Anim.bus的情形中,v11为0。

让我们分析下一行代码:

    v12 = 276 * *(_DWORD *)(*(_DWORD *)(v5 + 104) + 268) + 305;

我们可以将其简化为:

    v12 = (276 * v11) + 305;

305……是不是有点耳熟啊?是的,这是Anim.bus的字节大小。

现在,我们已经搞清楚了两种不同的结构:

· 第一个结构(我们称之为S1)的大小为272字节,包含一个字符数组形式的字符串,其实是一个文件夹得名称。该结构的最后4个字节(偏移量268处)是一个整数(v11)。

· 第二个结构(我们称之为S2)的大小为276字节,单个.bus文件似乎可以存储多个这种结构。如果我们查看上面的代码,我们可以安全地假设.bus文件中至少有“v11”个S2结构。

· S2结构列表从偏移量305处开始。这是因为,第一个结构占用了272个字节,再加上文件的前33个字节(它们总是0),正好等于305。

我们现在可以更好地了解.bus文件的元数据是如何布局的:

OFFSET 0:零字节(大小:33)

OFFSET 33:S1结构(尺寸:272)

OFFSET 305:第一个S2结构(大小:276)

OFFSET 581(305+272):第二S2结构(大小:276)

……

现在,应该更清楚为什么Anim.bus的大小只有305个字节了吧?这是因为该文件中根本就没有S2结构。

让我们继续往下分析:

    if ( !v11 )
          {
            sub_498D74(&v45);
            v52 = 1;
            v13 = stlp_std::locale::locale((stlp_std::locale *)&Src);
            LOBYTE(v52) = 2;
            sub_42E050(v13);
            LOBYTE(v52) = 1;
            stlp_std::locale::~locale((stlp_std::locale *)&Src);
            v14 = stlp_std::locale::locale((stlp_std::locale *)&Src);
            LOBYTE(v52) = 3;
            sub_50C2A7(&v45, v14);
            LOBYTE(v52) = 1;
            stlp_std::locale::~locale((stlp_std::locale *)&Src);
            a3 = 0;
            v51 = 0;
            v49 = &v33;
            BYTE3(Src) = 0;
            BYTE3(a1) = 1;
            sub_52C06B(&v51, (int)&v33, (_BYTE *)&a1 + 3, (_BYTE *)&Src + 3, &a3, v5 + 68);
            v28 = v15;
            v27 = v15;
            LOBYTE(v52) = 4;
            Src = &v27;
            std::string::ctor_1(&v27, &v45);
            LOBYTE(v52) = 1;
            sub_52BFEC(&v41, v16, v27, v28, v29, v30, v31, v32, v33, v34, v35, v36);
            LOBYTE(v52) = 6;
            sub_52C09F(&v42);
            LOBYTE(v52) = 7;
            sub_52BCA1(&v42);
            LOBYTE(v52) = 6;
            sub_50CA47(&v42);
            LOBYTE(v52) = 1;
            sub_50CA47(&v41);
            sub_52BCF1(a5);
            v52 = -1;
            stlp_std::string::dtor(&v45);
            return 1;
          }

这是我们现在感兴趣的部分,因为v11等于0,所以,这部分代码将被执行,然后,函数将返回。

但是等等……这真的还让我们感兴趣吗?anim.bus文件已经完全解密,它看起来只是存放了一个文件夹名。我们并不希望完全弄清楚客户端如何处理所有事情的,我们只需要了解客户端如何解密.bus文件并解析它们,以便我们可以在没有客户端帮助的情况下重现它并提取它们。

因此,下一步将是找到一个文件,其中包含以下内容:

    *reinterpret_cast<uint32_t*>(dest_buffer + 268);

……不会返回0。接下来,我们需要:

首先,删除您在0x50B91E处设置的断点。

然后,将断点设置到0x52B94F处。这是初始化v11的指令。如果[EAX+0x10C](10Ch: 268)为0,那么,它就对我们没有什么帮助。

按F9键恢复游戏,这将再次触发CreateFileA断点。复制文件名,以防万一。再次按F9键,直到触发0x52B94F处断点。如上所述,如果[EAX+0x10C](EAX保存我们S1结构的地址)为0,我们就可以跳过它。

继续按F9键,直到[EAX+0x10C]不再为0为止。

正确完成所有操作后,[EAX+0x10C]将等于0x47(或71),并且映射到内存的.bus文件的名称应该为“Anim_Mob_Zombie_Melee_Zako.bus”。根据我们之前提到的结论,这意味着文件中有71个S2结构,其地址从偏移量305处开始。

由于v11不再为0,因此,将执行以下指令:

    a1 = 0;
    if ( !v11 )
      return 1;
    a5 = 276;
    while ( 1 )
    {
      v52 = (int *)operator new(0x114u);
      v54 = 8;
      if ( v52 )
      {
        v19 = sub_52B7F1((int)v19);
        v12 = v53;
        v20 = v19;
      }
      else
      {
        v20 = 0;
      }
      v54 = -1;
      dest_buffer = (char *)v20;
      v41 = 276;
      v40 = (char *)Src + v50 + 305;
      v39 = Src;
      v21 = sub_40CF95();
      CopyAndDecrypt(v21, v40, v41, dest_buffer);
      if ( v20 )
      {
        v50 = a5;
        sub_49C45F(&v46, v20);
        v54 = 9;
        sub_498D74(&v47);
        LOBYTE(v54) = 10;
        v22 = stlp_std::locale::locale((stlp_std::locale *)&v49);
        LOBYTE(v54) = 11;
        sub_42E050(v22);
        LOBYTE(v54) = 10;
        stlp_std::locale::~locale((stlp_std::locale *)&v49);
        v23 = stlp_std::locale::locale((stlp_std::locale *)&v48);
        LOBYTE(v54) = 12;
        sub_50C2A7((int)&v47, v23);
        LOBYTE(v54) = 10;
        stlp_std::locale::~locale((stlp_std::locale *)&v48);
        v52 = (int *)(v12 + *(_DWORD *)(v20 + 264));
        v45 = &v35;
        sub_52C06B((_DWORD *)(v20 + 268), (int)&v35, (_BYTE *)(v20 + 273), (_BYTE *)(v20 + 272), &v52, v5 + 68);
        v30 = v24;
        v29 = v24;
        LOBYTE(v54) = 13;
        v52 = &v29;
        std::string::ctor_1(&v29, &v47);
        LOBYTE(v54) = 10;
        v26 = (_DWORD *)sub_52BFEC((int)&v43, v25, v29, v30, v31, v32, v33, v34, v35, v36, v37, v38);
        LOBYTE(v54) = 15;
        sub_52C09F(v26, (int)&v44);
        LOBYTE(v54) = 16;
        sub_52BCA1(&v44);
        LOBYTE(v54) = 15;
        sub_50CA47(&v44);
        LOBYTE(v54) = 10;
        sub_50CA47(&v43);
        v27 = *(_DWORD *)(v5 + 96);
        dest_buffer = &v46;
        v41 = v27;
        if ( v27 == *(_DWORD *)(v5 + 100) )
        {
          sub_52BDBB(v41, dest_buffer);
        }
        else
        {
          sub_52C0F4(v41, dest_buffer);
          *(_DWORD *)(v5 + 96) += 24;
        }
        LOBYTE(v54) = 9;
        stlp_std::string::dtor(&v47);
        v54 = -1;
        sub_402821(&v46);
        v12 = v53;
      }
      v28 = *(_DWORD *)(v20 + 264) + *(_DWORD *)(v20 + 268);
      dest_buffer = (char *)v20;
      if ( v12 + v28 > (unsigned int)a3 )
        break;
      operator delete(dest_buffer);
      ++a1;
      a5 += 276;
      if ( a1 >= (unsigned int)v51 )
        return 1;
    }
    operator delete(dest_buffer);

我们立即注意到的第一件事是,在堆上分配了sizeof(S2) (276)字节的内存空间。sub_52B7F1似乎是一个构造函数,让我们来看看其代码:

    int __usercall [email protected]<eax>(int [email protected]<esi>)
    {
      *(_BYTE *)(a1 + 272) = 0;
      *(_BYTE *)(a1 + 273) = 0;
      memset((void *)a1, 0, 261u);
      return a1;
    }

这为我们提供了S2结构方面的一些信息:

· O

· FFSET 272:未知字节(大小:1)

· OFFSET 273:未知字节(大小:1)

由于我们已经重命名了CopyAndDecrypt函数,因此,它肯定会出现在反编译器的输出结果中,因此,我们可以修改相应变量的名称:

    v55 = -1;
    dest_buffer = (char *)v21;
    size = 276;
    curr_src_buffer = (char *)Src + v51 + 305;
    v40 = Src;
    v22 = sub_40CF95();
    CopyAndDecrypt(v22, curr_src_buffer, size, dest_buffer);

在这里,下面这行代码是最重要的:

    curr_src_buffer = (char *)Src + v51 + 305;

Src是映射文件视图的地址。我们知道305是S2结构起始地址的偏移量。至于v51,如果我们查看前面的代码,就会发现它被初始化为0。

太好了,下面开始解密S2结构!为此,请切换到OllyDbg,并单步跟踪代码,直到该调用的参数被压入堆栈为止:

    0052BACE  |. 57             |PUSH EDI
    0052BACF  |. 68 14010000    |PUSH 114
    0052BAD4  |. 8D8401 3101000>|LEA EAX,DWORD PTR DS:[ECX+EAX+131]
    0052BADB  |. 50             |PUSH EAX
    0052BADC  |. 51             |PUSH ECX
    0052BADD  |. E8 B314EEFF    |CALL pbclient.0040CF95
    0052BAE2  |. 59             |POP ECX                                 ; |
    0052BAE3  |. 50             |PUSH EAX                                ; |Arg1
    0052BAE4  |. E8 B9FEFDFF    |CALL pbclient.0050B9A2                  ; \pbclient.0050B9A2
    0052BAE9  |. 85FF           |TEST EDI,EDI

获取EDI的值,并在转储窗口中跟踪它,这就是我们的目标缓冲区。现在,我们只需要单步执行代码,直到执行对CopyAndDecrypt(pbclient.0050B9A2)的调用为止。

下面展示的是调用后的转储窗口:

1.png

在我看来,已经显而易见了!我们之前发现未加密的模版/纹理/动画都是存储在.bus文件中的。这个S2结构存储归档中文件的文件名。同时,由于我们也知道在一个存档中可以有多个S2结构,这意味着每个文件必须有一个这种结构。那么,我们怎么才能知道有多少个S2结构呢?

    v11 = *reinterpret_cast<uint32_t*>(dest_buffer + 268); //S1 structure

对!我们现在可以将v11重命名为“file_count”,因为这就是其真正用途。从这里可以看出,Anim.bus中并没有存储文件,这正好解释了file_count被设置为0的原因。

继续单步跟踪,直到运行至下列代码:

    0052BB62  |. 8B87 08010000  |MOV EAX,DWORD PTR DS:[EDI+108] ; + 264
    0052BB68  |. 03C6           |ADD EAX,ESI

我们现在知道,该结构包含4个字节的数据(一个整数),这正好就是我们所感兴趣得,它们位于偏移量264处。但是,在这个结构中这个整数被设置为0。

EAX中存放的就是这个表达式的结果(我们之前已经考察过了,还记得吗?):

    v12 = (276 * v11) + 305;
    //Which we now know is...
    v12 = (sizeof(S2) * file_count) + sizeof(S1) + 33;

那v12究竟是什么呢?它是S2结构列表末尾的偏移量。下面,让我们将它添加到.bus元数据布局中:

[ METADATA]

OFFSET 0:零字节(大小:33)

OFFSET 33:S1结构(尺寸:272)

OFFSET 305:第一个S2结构(大小:276)

OFFSET 581(305+272):第二S2结构(大小:276)

……

OFFSET v12:S2结构的尾部

让我们继续单步跟踪代码,直到跟踪至下列代码:

    0052BB75  |. 8D43 44        |LEA EAX,DWORD PTR DS:[EBX+44]
    0052BB78  |. 50             |PUSH EAX
    0052BB79  |. 8D45 EC        |LEA EAX,DWORD PTR SS:[EBP-14]
    0052BB7C  |. 50             |PUSH EAX
    0052BB7D  |. 8D87 10010000  |LEA EAX,DWORD PTR DS:[EDI+110] ;+ 272
    0052BB83  |. 50             |PUSH EAX
    0052BB84  |. 8D87 11010000  |LEA EAX,DWORD PTR DS:[EDI+111] ;+ 273
    0052BB8A  |. 50             |PUSH EAX
    0052BB8B  |. 8D87 0C010000  |LEA EAX,DWORD PTR DS:[EDI+10C] ;+ 268
    0052BB91  |. E8 D5040000    |CALL pbclient.0052C06B

有趣的是,它正在使用我们的一些S2结构数据并将其地址作为参数传递给该调用。下面,让我们单步进入pbclient.0052C06B(F7):

    0052C06B  /$ 55             PUSH EBP
    0052C06C  |. 8BEC           MOV EBP,ESP
    0052C06E  |. 51             PUSH ECX
    0052C06F  |. 8B00           MOV EAX,DWORD PTR DS:[EAX]
    0052C071  |. FF75 14        PUSH DWORD PTR SS:[EBP+14]
    0052C074  |. 8365 FC 00     AND DWORD PTR SS:[EBP-4],0
    0052C078  |. 8906           MOV DWORD PTR DS:[ESI],EAX
    0052C07A  |. 8B45 08        MOV EAX,DWORD PTR SS:[EBP+8]
    0052C07D  |. 8A00           MOV AL,BYTE PTR DS:[EAX]
    0052C07F  |. 8846 04        MOV BYTE PTR DS:[ESI+4],AL
    0052C082  |. 8B45 0C        MOV EAX,DWORD PTR SS:[EBP+C]
    0052C085  |. 8A00           MOV AL,BYTE PTR DS:[EAX]
    0052C087  |. 8846 08        MOV BYTE PTR DS:[ESI+8],AL
    0052C08A  |. 8B45 10        MOV EAX,DWORD PTR SS:[EBP+10]
    0052C08D  |. 8B00           MOV EAX,DWORD PTR DS:[EAX]
    0052C08F  |. 8D4E 10        LEA ECX,DWORD PTR DS:[ESI+10]
    0052C092  |. 8946 0C        MOV DWORD PTR DS:[ESI+C],EAX
    0052C095  |. FF15 442DCD00  CALL DWORD PTR DS:[<&stlport.5.2.??0?$ba>;  [email protected][email protected]@[email protected]@[email protected]@[email protected]@[email protected]@[email protected]@@Z
    0052C09B  |. 8BC6           MOV EAX,ESI
    0052C09D  |. C9             LEAVE
    0052C09E  \. C3             RETN

这似乎是在建立另一种结构。这对我们很重要吗?现在来看,好像不是的……我们可以根据需要制作自己的附加结构,但我们最感兴趣的是这些字段的实际含义,此外,由于这种结构可能对我们有所帮助,所以,我们不妨记住它。

现在我们对S2结构有了更多的了解:

[S2 STRUCTURE]

OFFSET 0:文件名(大小:264(它可以更小一些,该数字只是为了满足当前的要求))

OFFSET 264 [EDI + 0x108]:未知整数(大小:4)

OFFSET 268 [EDI + 0x10C]:未知整数(大小:4)

OFFSET 272 [EDI + 0x110]:未知字节(大小:1)

OFFSET 273 [EDI + 0x111]:未知字节(大小:1)

等一下……如果仔细检查每一个偏移量,就会发现在偏移量268处有一个值:0xA53F(十进制为42303)。并且,如果我们跳到这一行:

    v28 = *(_DWORD *)(v20 + 264) + *(_DWORD *)(v20 + 268);

这是什么意思呢?在偏移量264处也有一个值!将这两个偏移量处的值加在一起,如果结果大于“a3”(这是传递给当前函数的第三个参数),则会中断。

现在,a3可能是什么?让我们找到对当前函数(sub_52b8ba)的交叉引用:

    if ( (unsigned __int8)sub_52B8BA(v12, (void *)v13, v57, a1 + 32, (int)&v48) )

a3是第三个参数,那么,v57是什么呢?实际上,如果我们向上翻看一下……

    v57 = GetFileSize(v7, 0);

这里就不用多解释了吧?v57只是这个bus格式的文件的大小,所以,文件的大小将作为第三个参数传递给sub_52B8BA。

现在,我们可以更新一下参数名称了:

    char __stdcall sub_52B8BA(int a1, void *Src, int uiFileSize, int a4, int a5)

向下滚动,我们将看到:

    v28 = *(_DWORD *)(v20 + 264) + *(_DWORD *)(v20 + 268);
     dest_buffer = (char *)v20;
     if ( v12 + v28 > (unsigned int)uiFileSize )

另外,到目前为止,我们对当前迭代的了解是:

ITERATION 0: OFFSET 264: 0 - OFFSET 268: 42303

如果我们看一下循环的其余部分:

    ++a1;
    a5 += 276;
    if ( a1 >= (unsigned int)v51 )
       return 1;

如果向上翻看代码,会发现v51实际上就是我们的文件数。

我们现在知道该函数将返回,如果满足下列条件的话:

·  [S2 + 264] + [S2 + 268] > 文件大小

·  i (a1) >= 文件数量

此外,a5的增量为sizeof(S2) (276)。如果我们向上翻看,就会发现它之前被初始化为相同的值(276)。

由于a1在每次迭代期间都会递增,所以,我们可以将其视为for循环:

    //pseudocode
    for(size_t i{ 0 }, j{ 0 }; i < file_count; ++i, j += sizeof(S2))
    {
        /*
        Decrypt data, ...
        */
       
        if(value_at_offset264 + value_at_offset268 > uiFileSize)
        {
          break;
        }
    }

让我们更新我们对迭代的认知:

ITERATION 0: OFFSET 264: 0 - OFFSET 268: 42303 - a5: 552 (276 + 276)

现在,最需要关心的是第二次迭代。

    dest_buffer = (char *)v20;
    v41 = 276;
    v40 = (char *)Src + v50 + 305;

如果仔细检查代码,我们就会发现v51在函数开头部分被初始化为0,之后,它会取a5的值(即552)。

让我们返回OllyDbg并继续步进调试,直到循环结束为止:

    0052BACE  |. 57             |PUSH EDI
    0052BACF  |. 68 14010000    |PUSH 114
    0052BAD4  |. 8D8401 3101000>|LEA EAX,DWORD PTR DS:[ECX+EAX+131]
    0052BADB  |. 50             |PUSH EAX
    0052BADC  |. 51             |PUSH ECX
    0052BADD  |. E8 B314EEFF    |CALL pbclient.0040CF95
    0052BAE2  |. 59             |POP ECX                                 ; |
    0052BAE3  |. 50             |PUSH EAX                                ; |Arg1
    0052BAE4  |. E8 B9FEFDFF    |CALL pbclient.0050B9A2                  ; \pbclient.0050B9A2

同样,EDI是我们的目标缓冲区,所以,我们需要读取其值,并在转储中跟踪它。

不出所料,它是另一个文件名。我们更感兴趣的是偏移264和268处的值。记住,这是第一次迭代:

ITERATION 0: OFFSET 264: 0 - OFFSET 268: 42303 - a5: 552 (276 + 276)

现在,如果我们再次检查这些偏移量处的值,我们会看到:

ITERATION 0: OFFSET 264: 42303 - OFFSET 268: 12125

这是否变得更清楚了?如果不断重复这些循环,就能清楚地看到一个模式。

OFFSET 264:存档中文件开头的位置

OFFSET 268:文件的长度

这样,我们就可以进一步更新我们的S2结构了!

OFFSET 0:文件名(大小:264。其实它可以更小,这里取这个值只是便于说明)

OFFSET 264 [EDI + 0x108]:文件中的位置(大小:4)

OFFSET 268 [EDI + 0x10C]:文件长度(大小:4)

OFFSET 272 [EDI + 0x110]:未知字节(大小:1)

OFFSET 273 [EDI + 0x111]:未知字节(大小:1)

如果在存档中的位置为0的话,则没有任何意义,因为它必须与某些东西相关联……现在,不妨回顾一下前面提到的布局:

[ METADATA]

OFFSET 0:零字节(大小:33)

OFFSET 33:S1结构(大小:272)

OFFSET 305:第一个S2结构(大小:276)

OFFSET 581(305 + 272):第二S2结构(大小:276)

……

OFFSET v12:S2结构的尾部

因此,v12很可能是实际文件数据的起始位置!我们现在有足够的信息来验证这一点:

· 我们知道.bus文件“Anim_Mob_Zombie_Melee_Zako.bus”包含71个文件

· S2结构的大小为276,因此276 * 71 = 19596

· 我们必须加上S1结构的大小:19596 + 272 = 19868

· 最后,我们必须加上33个零字节:19868 + 33 = 19901

让我们使用HexEdit打开Anim_Mob_Zombie_Melee_Zako.bus并转到该位置,然后,输入目标地址:

1.png

我们将来到这里:

1.png

它确实是文件的起始位置。我们知道文件的大小是42303字节,所以,让我们将这个值与19901相加:

1.png

又一个文件!

虽然目前似乎还不太明显,但我们已经拥有制作提取器所需的一切信息。下面,让我来展示一些你可能已经想到的事情:

    //S1 Structure + 33 bytes
    struct FILE_HEADER
    {
             char data[33];
             char folder_path[256]; //0x0021
             char pad[12]; // 0x100
             size_t file_count; //0x10C
    }; //Size: 0x131 (305)
 
    #pragma pack(push, 1)
    S2 Structure
    struct FILE_INFO
    {
             char path[256]; //0x0000
             char pad[8]; //0x0100
             uint32_t offset; //0x0108
             uint32_t length; //0x010C
             uint8_t unk_01; //0x0110
             bool encrypted; //0x0111
             uint8_t unk_03; //0x0112
             uint8_t unk_04; //0x0113
    }; //Size: 0x114 (276)
    #pragma pack(pop)

下面,让我们直接使用相应的名称,而不是S1和S2,这样代码会更容易理解。

那么,该如何制作从.bus存档中提取文件的程序呢?下面,让我来详细加以解释:

· 使用MapViewOfFile将整个文件映射到内存中

· 解密位于映射视图的基址+33处的S1结构,而file_count值则可以指出该存档中有多少个FILE_INFO结构。实际上,FILE_INFO结构的数量与文件数量是一致的。

· 如果文件数量不为0,则让偏移量指向FILE_INFO结构列表的开头处:

    sizeof(FILE_HEADER); //305

遍历所有FILE_INFO结构:

    std::vector<FILE_INFO> v_info;
    for (uint32_t i{ 0 }; i < header.file_count; ++i)
    {
             auto fi = *reinterpret_cast<FILE_INFO*>(sizeof(FILE_HEADER) + (i * sizeof(FILE_INFO));
             v_info.push_back(fi);
    }

使用以下公式计算指向文件数据开头的偏移量:

    sizeof(FILE_HEADER) + (file_count * sizeof(FILE_INFO))

现在,我们就可以读取每个文件了,例如:

    const size_t file_data_offset{ sizeof(FILE_HEADER) + (header.file_count * sizeof(FILE_INFO) };
    size_t processed_size { 0 };
    for (const auto& it : v_info)
    {
             auto src_buffer = reinterpret_cast<uint8_t*>(file_data_offset + processed_size);
             auto dest_buffer = std::make_unique<uint8_t[]>(it.length);
             memcpy_s(dest_buffer.get(), it.length, src_buffer, it.length);
             /*
             The current file data will now be contained in dest_buffer. You can implement your own function that dumps it to a file and call it here.
             */
      processed_size += it.length;
      //Also, don't forget error checking, etc! I am only showing this as an example
    }

如果您想知道提取各个文件所需的详细位置,请查看FILE_INFO结构中的“path”字段。

1.png

您可以使用这些信息重新创建整个目录结构。

提取操作完成之后,可以在数据文件夹的根目录下面看到下列内容。

1.png

本文翻译自:https://www.unknowncheats.me/forum/general-programming-and-reversing/332048-example-reversing-undocumented-game-file-format.html如若转载,请注明原文地址: https://www.4hou.com/technology/17602.html
点赞 0
  • 分享至
取消

感谢您的支持,我会继续努力的!

扫码支持

打开微信扫一扫后点击右上角即可分享哟

发表评论