Galgame汉化中的逆向:动态汉化分析-以AZsystem引擎为例
2023-3-5 18:1:56 Author: 看雪学苑(查看原文) 阅读量:15 收藏


本文为看雪论坛精华文章

看雪论坛作者ID:devseed

本贴代码开源详见我的github: GalgameReverse(https://github.com/YuriSizuku/GalgameReverse), ReverseUtil(https://github.com/YuriSizuku/ReverseUtil)。

上篇链接:Galgame汉化中的逆向:动态汉化分析-以MAJIROv3引擎为例(https://bbs.kanxue.com/thread-268508.htm

0

前言

上节 Galgame汉化中的逆向(六):动态汉化分析_以MAJIROv3引擎为例,我们介绍了动态汉化。动态汉化不用分析封包结构,不用分析opcode,看上去很方便,但是动态汉化解决同步问题会很麻烦,比如说改完文本后backlog文本仍是日文、返回主界面再载入文本没有变动等问题。动态汉化也有可能出现莫名其妙的崩溃bug,且这些bug不容易被调试。

针对动态汉化的上述缺点,本节我们将介绍一种这种半动态汉化的方案。与上节的方法不同,本节不进行文本级替换,而是文件级别的替换。即去hook相关函数,动态将解密后的缓冲区替换为我们汉化后的文件。适合于那种封包与加密特别麻烦或复杂的游戏。

本文将以azsystem为例,来分析:

引擎如何加载游戏脚本,如何定位关键点提取脚本。
引擎如何加载图片,如何解压各通道数据,如何将图片数据送入帧缓存渲染。
汉化如何用inline hook对加载后的内容进行替换。


脚本文件分析与提取

(1) asb文件的分析

和上节相同,第一步先分析文件,无论静态分析算法还是动态dump缓冲区,先把文件提取出来。
由于方法差不多,这里不再详细展开了。
这个游戏封包为.arc文件,用文件长度哈希值来作为加密密钥,里面有若干个.asb脚本文件。IDA里面直接搜.asb字符串就能找到相关函数了,读取脚本文件函数如下:
int __thiscall sub_43112A(_DWORD *this, char *script_name){  char *raw_data; // edi  int v4; // eax  unsigned int v5; // ecx  _DWORD *v7[4]; // [esp+8h] [ebp-34h] BYREF  int v8; // [esp+18h] [ebp-24h] BYREF  unsigned int compressed_size; // [esp+1Ch] [ebp-20h]  unsigned int raw_size; // [esp+20h] [ebp-1Ch]  int v11; // [esp+24h] [ebp-18h]  int (__thiscall **v12)(void *, char); // [esp+28h] [ebp-14h]  char *compressed_data; // [esp+2Ch] [ebp-10h]  int v14; // [esp+38h] [ebp-4h]   v7[0] = off_460A6C;  sub_40BD95(v7);  v14 = 1;  v12 = &off_462CDC;  v11 = 0;  sub_430FC9((int)this);  if ( fopen_40C102(v7, script_name, 0x80000000) != 1 )  {    logprintf_407C41("CScript::Create", byte_4679CC, script_name);    goto LABEL_13;  }  readfile_40C03E(v7, (char *)&v8, 0xC);  if ( v8 == 0x1A425341 ) // asb\x1a  {    compressed_data = (char *)operator new(compressed_size);    raw_data = (char *)operator new(raw_size);    readfile_40C03E(v7, compressed_data, compressed_size);    if ( sub_430F6A(compressed_data, compressed_size, raw_size) )    {      v4 = decompress_40AB65(compressed_data, compressed_size, raw_data, raw_size);// decompress      v5 = raw_size;      if ( v4 == raw_size )      {        this[4] = 0;        this[1] = raw_data;        this[2] = v5;        this[3] = raw_data;        this[5] = raw_data;        v11 = 1;LABEL_10:        if ( compressed_data )          j__free(compressed_data);        goto LABEL_13;      }      logprintf_407C41("CScript::Create", byte_467A38, script_name);    }    else    {      logprintf_407C41("CScript::Create", byte_467A0C, script_name);// error    }    if ( raw_data )      j__free(raw_data);    goto LABEL_10;  }LABEL_13:  v14 = -1;  v12 = &off_462CDC;  v7[0] = off_460A6C;  sub_40BFDD(v7);  return v11;}
简单分析后,我们可以得到asb的文件头结构、校验文本函数、解压函数以下结论,具体如下:
typedef struct {        s8 magic[4];                        /* "ASB" */        u32 comprlen;        u32 uncomprlen;        u32 unknown;} asb_header_t; typedef struct {        s8 magic[4];                        /* "ASB\x1a" 通过此magic来定位*/        u32 comprlen;        u32 uncomprlen;} asb1a_header_t; // CScript.constructor, 这里不再自己构造了,在游戏调用的时候记录下this指针void *__thiscall sub_43277F(_DWORD *this) // check_validBOOL __stdcall sub_430F6A(char *compressed_data, int compressed_size, int raw_size) // decompresssub_40AB65(char *compressed_data, int compressed_len, char *raw_data, int raw_len) 0043112A    | B8 9EE54500  | mov eax,lamune.45E59E |load_script(char* name) 004311D4    | FF75 E4  | push dword ptr ss:[ebp-1C]  | raw_len004311D7    | 8D4D EC  | lea ecx,dword ptr ss:[ebp-14]004311DA    | 57         | push edi  | raw_data004311DB    | FF75 E0    | push dword ptr ss:[ebp-20] | compressed_len004311DE    | FF75 F0    | push dword ptr ss:[ebp-10] | compressed_data004311E1    | E8 7F99FDFF| call lamune.40AB65| decompress

(2) asb文件的解密与提取

提取只需要hooksub_40AB65,frida代码如下:
/*        for lamune.exe v1.0        open the game to title, then        frida -l lamune_hook.js -n lamune.exe        next go to the prologue to dump all asbs*/function install_decompress_hook(outdir='./dump'){       // hook decompress function to dump       const addr_decompress = ptr(0x40AB65);       var raw_asbname = "";       var raw_asbdata = ptr(0);       var raw_asbsize = 0;       Interceptor.attach(addr_decompress, {           onEnter: function(args){               raw_asbdata = ptr(args[2]);               raw_asbsize = args[3].toUInt32();               raw_asbname = ptr(this.context.ebp).add(8).                               readPointer().readAnsiString();           },           onLeave: function(retval){               //var asbname = asbname_buf.readAnsiString();               var asbname = raw_asbname;               console.log(asbname,                   ", raw_asbdata addr at", raw_asbdata,                   ", raw_asbsize ", raw_asbsize)               try{                   var fp = new File(outdir+"/"+asbname, 'wb');                   fp.write(raw_asbdata.readByteArray(raw_asbsize));                   fp.close();               }               catch(e)               {                   console.log("file error!", e);               }            }       })} function dump_asbs(names, outdir="./dump"){    const addr_loadscript = ptr(0x43112A);    const load_script = new NativeFunction(addr_loadscript,         'void', ['pointer', "pointer"], 'thiscall');    console.log("load_script at:", load_script)     // use this to store c++ context    var pthis = ptr(0)    Interceptor.attach(addr_loadscript, {        onEnter: function(args){            pthis = ptr(this.context.ecx)        }    })    install_decompress_hook(outdir)     // wait for c++ context    while(!pthis.toInt32())    {        Thread.sleep(0.2);    }     // dump all scripts    var name_buf = Memory.alloc(0x100);    for(var i=0;i<names.length;i++)    {        console.log("try to dump", names[i], ", this=",pthis);        name_buf.writeAnsiString(names[i]);        load_script(pthis, name_buf);    }    console.log("dump asbs finished!\n");} function dump_scenario(){    var names_v103 = ["00suzuk.asb"]    dump_asbs(names_v103)}
用其他工具如arc unpack可以得到arc封包的文件名,把文件名录入frida脚本,即可dump出全部asb脚本。


动态替换脚本文件

(1) 替换解密的asb缓冲区

结合上面文件分析,我们可以在004311E1| E8 7F99FDFF| call lamune.40AB65| decompress进行inlinehook,在此直接加载我们已经解密并汉化的asb文件。解密的缓冲区是前面new出来的,我们还需要修改缓冲区大小。另外还要nop掉缓冲区crc校验的函数。
上节我们用了detours,这期我们来手动inlinehook,步骤如下:
① 在需要hook的位置用5字节call(E9)或 jmp(E8) 进行相对跳转到我们的函数上,机器码为E8 XXXXXXXX, E9 XXXXXXXX。XXXXXXXX为相对于下一条指令的偏移,即targetva - (va + 5)。
② 执行完后hook的函数后,结尾手动修复一下被我们修改5字节破坏的代码,跳转到下个指令处。
动态替换解密后的缓冲区脚本代码如下:
/* for hook new decompressed buffer0043119A   | FF75 E0   | push dword ptr ss:[ebp-20]0043119D   | E8 A1510000  | call lamune.436343 | new004311A2   | FF75 E4    | push dword ptr ss:[ebp-1C]  | [ebp-1c] raw_size004311A5   | 8945 F0  | mov dword ptr ss:[ebp-10],eax004311A8   | E8 96510000         | call lamune.436343  | new raw_buf*/const DWORD g_newrawbufi_4311A2 = 0x4311A2;const DWORD g_newrawbufo_4311A8 = 0x4311A8; /* for hook decompress asb.text:004311D4 FF 75 E4          push    [ebp+raw_size]  ; raw_len.text:004311D7 8D 4D EC          lea     ecx, [ebp+var_14].text:004311DA 57                push    edi             ; raw_data.text:004311DB FF 75 E0    push [ebp+compressed_size] ; compressed_len.text:004311DE FF 75 F0    push [ebp+compressed_data] ; compressed_data.text:004311E1 E8 7F 99 FD FF    call    decompress_40AB65*/const DWORD g_decompressasbi_4311E1 = 0x4311E1;const DWORD g_decompressasbo_40AB65 = 0x40AB65; // inlinehook stubsvoid __declspec(naked) newrawbuf_hook_4311A2(){    __asm{        pushad;        xor eax, eax;        // size_t __stdcall load_rawasb(char *name, PBYTE buf)        push eax;        push [ebp+8];        call load_rawasb;        test eax, eax;        je newrawbuf_hook_end;        mov [ebp-0x1c], eax; // change raw buf size        newrawbuf_hook_end:        popad;         // fix origin code        push dword ptr [ebp-0x1c];        mov dword ptr [ebp-0x10], eax;        jmp dword ptr ds:[g_newrawbufo_4311A8];    }} void __declspec(naked) decompressasb_hook_4311E1(){    //sub_40AB65(char *compressed_data, int compressed_len, char *raw_data, int raw_len)    __asm {        push [esp+0xc]; // after push ret addr, above, raw_buf        push [ebp+0x8];  // asbname        call load_rawasb;        test eax, eax;        je decompress_origin;        ret 0x10;        decompress_origin:        mov eax, 0x99E15CB4; // this is the original corrent crc value        mov dword ptr ds:[0x0047E718], eax; // this is not worked...        jmp dword ptr ds:[g_decompressasbo_40AB65];    }} // hook install functionsvoid install_asbhook(){    /* inlinehook check_valid    .text:0040AB8A 6A 00             push    0    .text:0040AB8C 8D 43 FC          lea     eax, [ebx-4]    .text:0040AB8F 50                push    eax    .text:0040AB90 8D 77 04          lea     esi, [edi+4]    .text:0040AB93 56                push    esi    .text:0040AB94 E8 27 D9 FF FF    call    makecrc_4084C0    .text:0040AB99 83 C4 0C          add     esp, 0Ch    .text:0040AB9C 39 07             cmp     [edi], eax    .text:0040AB9E 75 64             jnz     short loc_40AC04    */    BYTE nop2[0x2]={0x90, 0x90};    winhook_patchmemory((LPVOID)0x4311d2,        nop2, sizeof(nop2));    winhook_patchmemory((LPVOID)0x40AB9E,        nop2, sizeof(nop2));     // inlinehook newrawdata    BYTE jmpE8buf[0x5]={0xE9}; // jmp relative    *(DWORD*)(jmpE8buf+1) = (DWORD)newrawbuf_hook_4311A2-         ((DWORD)g_newrawbufi_4311A2 + sizeof(jmpE8buf));    winhook_patchmemory((LPVOID)g_newrawbufi_4311A2,        jmpE8buf, sizeof(jmpE8buf));     // inlinehook decompress    BYTE callE9buf[0x5]={0xE8}; // call relative    *(DWORD*)(callE9buf+1) =(DWORD)decompressasb_hook_4311E1-         ((DWORD)g_decompressasbi_4311E1 + sizeof(jmpE8buf));    winhook_patchmemory((LPVOID)g_decompressasbi_4311E1,        callE9buf, sizeof(callE9buf));}
上面代码中load_rawasb即为我们读取对应解密文件的代码,这里为了减少零碎文件,我采取了从zip文件中读取的方法。
此处不再赘述,详见我的github(https://github.com/YuriSizuku/GalgameReverse)。

(2) 修改sjis检测字节支持gbk编码

导入中文文本后,经测试发现一大堆半角乱码。
这是因为有sjis首字节字符编码范围检测,不在sjis范围内的字符将被解析为单字节文本。
与其他游戏不同,此游戏不是用cmp ax, 0x81等指令来检测sjis字符,而且位置过多过于分散,修改起来很麻烦。
这部分定位我们可以在TextOutA下断点,往上慢慢找,可以看到下图位置:
这里非常巧妙,用一条c^0x20 + 0x5f > 0x3B就可以判断是否为sjis首字符了,具体分析如下:
.text:004340F6 loc_4340F6:.text:004340F6 mov     ecx, [ebp+74h+var_4].text:004340F9 mov     cl, [ecx].text:004340FB mov     dl, cl.text:004340FD xor     dl, 20h.text:00434100 add     dl, 5Fh ; '_'.text:00434103 cmp     dl, 3Bh ; ';'.text:00434106 ja      loc_434215
修改方法也很简单,把上面xor和add用noppatch,编码检测改为cmp dl, 0x80即可。
修改完后,虽然文本框正确了,但我们发现backlog中文本还有乱码。
这时候就要在搜索其他地方的检测字符函数了,可以试着搜cmp al|bl|cl|dl, 0x3b,逐个下断点,启动backlog看哪里断下。

(3) asb opcode分析

以0nana.asb为例,这个opcode是对齐的,很工整,如下图:
总结起来就是optype 4, oplengh 4, payload n结构,超长文本只需要修正一下oplengh和jmp相关的指令就行了,如下:
// from the file start, there are several opcodes entriesoptype 4, oplengh 4, payload n [26|27 00 00 00], oplengh 4, [00]*0x10, optext // 26 music, 27 text[0d 00 00 00], oplengh 4, [00]*4, option_num 4, [00] * 8, text1, 00, text2 ... // option[0a 00 00 00], [18 00 00 00] , addr 4, [00]*4, unknow1 4, unknow2 4 // jmp[0b 00 00 00], [1c 00 00 00] , addr 4, [00]*4, unknow1 4, unknow2 4 // option jmp 00 00 00 00 FF FF FF FF FF FF FF FF 00 00 00 0000 00 00 00 00 00 00 00 // end with that
将测试文本导入后,我们可以完成超长文本的汉化测试了。


图片文件的加载和渲染分析

(1) 定位图片显示缓冲区

这个游戏是通过Windows compatible DC进行绘图的,我们可以在CreateDIBinfo下断点,然后一层层往上跟,找到在缓冲区填充像素的函数,之后bitblt到帧缓冲位置。这里有个麻烦事,这游戏有很多虚函数通过虚表来寻址,如v3=(*(**v7+12))(*v7, v5, v10,a3这种。静态跟起来很费劲,可以尝试动态来看虚表。由于跟踪过于繁琐了,具体流程从略了,callback和具体调用流程如下:
0019FD80  0040EF75            50        return to lamune.sub_40EE6F+106 from ???           User // CreateDIBinfo0019FDD0  00401E77  0040EE6F  34        return to lamune.sub_401D0F+168 from lamune.sub_40 User0019FE04  0040955D            24        return to lamune.sub_40951B+42 from ???            User0019FE28  0040686C  0040951B  24        return to lamune.sub_406813+59 from lamune.sub_409 User0019FE4C  004383EA  00406813  A4        return to lamune.EntryPoint+184 from lamune.sub_40 User0019FEF0  0043827E  0043F210  84        return to lamune.EntryPoint+18 from lamune.sub_43F User DWORD __thiscall sub_42A199(int *this) // neko_logo.cpb| loadimg_419E03(off_473088, "neko_logo.cpb", this + 0x214);  | readcpb_40C03E((_DWORD **)this + 1, cpb_header, 0x10);  | (*(_DWORD *)*v7+4))(*v7, cpb_header) //check magic cpb\x1a  | (*(v8 + 0x3C))(v10[4], v10[5])   // 0041D3FB, read full  | v3=(*(**v7+12))(*v7, v5, v10,a3);// 0041D453, check depth    |return (*(*v6 + 0x10))(v6, a2, a4);// 0041E36F, 0041ddb8,decompress          |decompress2_40AA38(char *compressed_buf, size_t |compressed_size, char *raw_buf, size_t raw_len) // lzss?| sub_40C9C1(DWORD *this, int a2, int a3, int *a4, DWORD *a5)  |sub_4101EB(v9 + 2, a2, a3, a4, *a5, a5[1], a5[2], a5[3], 0);// bltalpha  |(*(this[2] + 0x48))(this + 2, a2, a3, a4, *a5, a5[1], a5[2], a5[3], 0xCC0020);// 004123E1, to bitblt

(2) cpb图片加载

上面我们来讲了一下定位方法,和整体加载流程。在这节我们来分析一下cpb文件如何读取和加载渲染到屏幕上的。

.1 cpb结构

cpb中像素是分通道存储的,数据结构如下:
00000000 cpb1a_header_t  struc ; (sizeof=0x20, mappedto_128)00000000 ; XREF: decompresscpb_41E36F/r00000000 magic           db 4 dup(?) ; string(C)00000004 unknow1         db ?00000005 color_depth     db ?00000006 unknow2         db ?00000007 version         db ?00000008 width           dw ? ; XREF: decompresscpb_41E36F+39/r0000000A height          dw ? ; XREF: decompresscpb_41E36F+3E/r0000000C max_comprlen    dd ? ; XREF: decompresscpb_41E36F+56/r00000010 comprlen dd 4 dup(?); XREF: decompresscpb_41E36F+93/r00000010  ; decompresscpb_41E36F+B7/r ...00000020 cpb1a_header_t  ends

.2 prepare DC

在渲染图片之前,游戏引擎先进行DC的初始化。
void *__thiscall sub_40FDC2(void **this, LONG width, int height){  void *result; // eax  HBITMAP v5; // eax  HDC dc; // eax  int (__thiscall **v7)(void **, _DWORD); // eax  BITMAPINFO pbmi; // [esp+8h] [ebp-2Ch] BYREF   if ( (void *)width == this[41] && (void *)height == this[42] )    return (void *)(*((int (__thiscall **)(void **, _DWORD))*this + 26))(this, 0);  (*((void (__thiscall **)(void **))*this + 13))(this);  if ( width > 0 && height > 0 )  {    memset(&pbmi, 0, sizeof(pbmi));    pbmi.bmiHeader.biHeight = -height;    pbmi.bmiHeader.biSize = 0x28;// struct size    pbmi.bmiHeader.biWidth = width;    pbmi.bmiHeader.biPlanes = 1;  // must be 1    pbmi.bmiHeader.biBitCount = 32;    // rgba    pbmi.bmiHeader.biCompression = 0;    v5 = CreateDIBSection(0, &pbmi, 0, this + 0x28, 0, 0); // this+0x28    this[37] = v5;    if ( v5 )    {      dc = CreateCompatibleDC(0);      this[0x27] = dc;      if ( dc )      {        this[0x26] = SelectObject(dc, this[0x25]);        this[0x29] = (void *)width;        this[0x2A] = (void *)height;        this[0x2E] = (void *)(height - 1);        v7 = (int (__thiscall **)(void **, _DWORD))*this;        this[0x2B] = 0;        this[0x2C] = 0;        this[0x2D] = (void *)(width - 1);        result = (void *)v7[0x1A](this, 0); // 0041295A, FillRect        this[0x52] = result;        return result;      }    }    (*((void (__thiscall **)(void **))*this + 0xD))(this);  }  return 0;}

.3 load cpb

这部分是读取cpb到内存里,并检验文件头等信息。
int __thiscall loadimg_419E03(_DWORD *this, char *filename, int *a3){  int v3; // ebx  _DWORD **v5; // edi  _DWORD *v7; // esi  int v8; // eax  int v9; // eax  int v10[7]; // [esp+Ch] [ebp-2Ch] BYREF  char cpb_header[16]; // [esp+28h] [ebp-10h] BYREF  int i; // [esp+40h] [ebp+8h]   v3 = 0;  if ( !filename || !a3 )    return 0;  v5 = (this + 1);  if ( fopen_40C102(this + 1, filename, 0x80000000) != 1 )  {    logprintf_407C41("CGraphicLoader::GDILoad", "指定されたファイルが見つかりません [%s]", filename);    return 0;  }  readcpb_40C03E(this + 1, cpb_header, 0x10);   // this+1 fp  i = 0;  v7 = this + 5;  // for test magic?  do  {    if ( *v7 )    {      if ( (*(**v7 + 4))(*v7, cpb_header) == 1 )// 0041D0E8, 0041D3E9     // check magic cpb\x1a,      {        sub_40C0A0(v5, 0, 0);        memset(v10, 0, sizeof(v10));        v3 = (*(**v7 + 8))(*v7, v5, v10); // 0041D3FB, read full header        if ( v3 == 1 )        {          v8 = *a3;   // 0041D3FB, read full header          v9 = v10[3] == 1 ? (*(v8 + 0x3C))(v10[4], v10[5]) : (*(v8 + 0x38))(v10[4], v10[5]);          v3 = v9;          if ( v9 == 1 )          {            sub_40C0A0(v5, 0, 0);            v3 = (*(**v7 + 12))(*v7, v5, v10, a3);// 0041D453, check depth and decompress            if ( v3 == 1 )              break;          }        }      }    }    ++i;    ++v7;  }  while ( i < 4 );  sub_40BFDD(v5);  return v3;}
加载后,会根据通道数不同调用不同的解压缩函数。
int __thiscall sub_41D453(_DWORD *this, int a2, int a3, int a4){  int v6; // esi  int v8; // ebx  int v9; // [esp+8h] [ebp-4h]   v9 = 0;  if ( (*(*a4 + 0x2C))(a4) != 1 )    // 00401291, mov    return 0;  v6 = this[*(a3 + 0x18) + 1];  if ( !v6 )    return 0;  if ( (*(*a4 + 0x1C))(a4) == 8 )// 00401278, mov  {    if ( *(a3 + 4) == 8 )   // 8bit with color panel      return (*(*v6 + 4))(v6, a2, a4);  }  else if ( (*(*a4 + 0x1C))(a4) == 32 ) // 32bit rgba  {    v8 = *(a3 + 4);    if ( v8 == 8 )      return (*(*v6 + 8))(v6, a2, a4);    if ( v8 == 24 )      return (*(*v6 + 0xC))(v6, a2, a4);  // 0041e1c8 decompresscpb24    if ( v8 != 32 || (*(*a4 + 0x30))(a4) != 1 ) // 00401298, mov      return v9;    return (*(*v6 + 0x10))(v6, a2, a4); // 0041E36F,decompresscpb32  }  return v9;}

.4 decompress cpb

这个游戏有多个cpb解压函数,对应着不同通道数的文件,这里以32位图为例分析。
注意这里vv1 = (*(*obja + 0xC))(obja)中的vv1值为prepare dc中的v5 = CreateDIBSection(0, &pbmi, 0, this + 0x28, 0, 0) 此句的DIB缓冲区。
我们可以替换decompress_channel_40AA38后的缓冲区为汉化后的图片,然后让游戏引擎帮我们复制到DIB缓冲区内。
int __thiscall decompresscpb32_41E36F(void *this, int *obj){  int v3; // eax  size_t pixels; // esi  char *raw_buf; // eax MAPDST  char *pchanel1; // ebx  int vv2; // eax  char *pchannel0; // edi  _BYTE *v11; // esi  _BYTE *v12; // eax  int v13; // edx  cpb1a_header_t cpb_header; // [esp+408h] [ebp-58h] BYREF  int v16; // [esp+428h] [ebp-38h]  int v17; // [esp+42Ch] [ebp-34h]  char *v18; // [esp+430h] [ebp-30h]  int width; // [esp+434h] [ebp-2Ch] MAPDST  char *compressed_buf; // [esp+438h] [ebp-28h] MAPDST  int pcurvv2; // [esp+43Ch] [ebp-24h]  int i; // [esp+444h] [ebp-1Ch]  char *pchanel3; // [esp+448h] [ebp-18h]  _BYTE *vv1; // [esp+44Ch] [ebp-14h]  int j; // [esp+450h] [ebp-10h]  int v27; // [esp+45Ch] [ebp-4h]  char *obja; // [esp+46Ch] [ebp+Ch] MAPDST  char *pchanel2; // [esp+46Ch] [ebp+Ch]   v27 = 0;  j = 0;  if ( readcpb_40C03E(obj, cpb_header.magic, 0x20) )  {    v3 = *obja;    width = cpb_header.width;    i = cpb_header.height;    pixels = cpb_header.width * cpb_header.height;    v17 = (*(v3 + 0x24))(obja);                 // 00401283, mov    compressed_buf = operator new(cpb_header.max_comprlen);    raw_buf = operator new(4 * pixels);    pchanel1 = &raw_buf[pixels];    pchanel2 = &raw_buf[pixels + pixels];    pchanel3 = &pchanel2[pixels];    vv1 = (*(*obja + 0xC))(obja); // 0040125C, mov this+0x28, get hdc buffer        //  CreateDIBSection(0, &pbmi, 0, this + 0x28, 0, 0);    vv2 = (*(*obja + 0x20))(obja);    pcurvv2 = vv2;    if ( readcpb_40C03E(obj, compressed_buf, cpb_header.comprlen[0])      && decompress_channel_40AA38(compressed_buf, cpb_header.comprlen[0], raw_buf, pixels) != -1      && readcpb_40C03E(obj, compressed_buf, cpb_header.comprlen[1])      && decompress_channel_40AA38(compressed_buf, cpb_header.comprlen[1], pchanel1, pixels) != -1      && readcpb_40C03E(obj, compressed_buf, cpb_header.comprlen[2])      && decompress_channel_40AA38(compressed_buf, cpb_header.comprlen[2], pchanel2, pixels) != -1      && readcpb_40C03E(obj, compressed_buf, cpb_header.comprlen[3])      && decompress_channel_40AA38(compressed_buf, cpb_header.comprlen[3], pchanel3, pixels) != -1 )    {      if ( i > 0 )      {        pchannel0 = &pchanel1[-pixels];        ++vv1;        j = i;        do    // copy data to dc buf        {          if ( width > 0 )          {            v11 = vv1;            v16 = pchanel2 - pchanel1;            v18 = (pchanel3 - pchanel1);            v12 = pchanel1;            v13 = pcurvv2 - pchanel1;            i = width;            do            {              v11[1] = v12[pchannel0 - pchanel1];              *v11 = *v12;              *(v11 - 1) = v12[v16];              v12[v13] = v12[v18];              ++v12;              v11 += 4;              --i;            }            while ( i );          }          pchanel2 += width;          pchanel3 += width;          vv1 += 4 * width;          pcurvv2 += v17;          pchanel1 += width;          pchannel0 += width;          --j;        }        while ( j );      }      j = 1;    }    if ( raw_buf )      j__free(raw_buf);    if ( compressed_buf )      j__free(compressed_buf);  }  return j;}
解压各通道算法,看起来有点像lzss改版?
int __stdcall decompress_channel_40AA38(char *compressed_buf, size_t compressed_size, char *raw_buf, size_t raw_len){  char *v5; // ebx  char *v6; // edx  char *v7; // esi  char *v8; // edi  unsigned int v9; // ecx  signed int v10; // eax  unsigned int v11; // ecx  char *v12; // esi  char v13; // cf  bool v14; // cc  unsigned int v15; // [esp+Ch] [ebp-4h]  signed int dstsizea; // [esp+24h] [ebp+14h]   if ( *(compressed_buf + 4) > raw_len )    return -1;  v5 = compressed_buf + 20;  v6 = &compressed_buf[*(compressed_buf + 1) + 20];  v7 = &v6[*(compressed_buf + 2)];  dstsizea = *(compressed_buf + 4);  v8 = raw_buf;  v15 = 0x80808080;  do  {    if ( (v15 & *v5) != 0 )    {      v9 = *v6;      v6 += 2;      v10 = (v9 >> 13) + 3;      qmemcpy(v8, &v8[-(v9 & 0x1FFF) - 1], v10);      v8 += v10;    }    else    {      v11 = *v7 + 1;      v12 = v7 + 1;      v10 = v11;      qmemcpy(v8, v12, v11);      v7 = &v12[v11];      v8 += v11;    }    v13 = v15 & 1;    v15 = __ROR4__(v15, 1);    if ( v13 )      ++v5;    v14 = dstsizea <= v10;    dstsizea -= v10;  }  while ( !v14 );  return v8 - raw_buf;}

.5 bitblt screen dc

最后再通过bitblt到屏幕帧缓存中,至此整个游戏图片渲染分析完毕。
// for bitbltBOOL __thiscall sub_4123E1(void *this, int x, int y, int a4, int x1, int a6, int a7, int a8, DWORD rop){  int v10; // edi  int v11; // ebx  int v12; // eax  int y1; // edi  int v14; // eax  int x_c; // ebx  HDC hdc; // eax  HDC srchdc; // [esp-10h] [ebp-20h]   v10 = a7 - x1 + 1;  v11 = a8 - a6 + 1;  if ( x >= 0 )  {    if ( x + v10 > (*(*this + 16))(this) )// 00401263, mov    {      if ( (*(*this + 16))(this) - x <= 0 )        return 0;      a7 = (*(*this + 16))(this) + x1 - x - 1;    }  }  else  {    if ( v10 + x <= 0 )      return 0;    x1 -= x;    v12 = (*(*this + 16))(this) + x1 - 1;    if ( v12 < a7 )      a7 = v12;    x = 0;  }  if ( y >= 0 )  {    if ( y + v11 > (*(*this + 20))(this) )// 0040126A, mov    {      if ( (*(*this + 20))(this) - y <= 0 )        return 0;      a8 = (*(*this + 20))(this) + a6 - y - 1;    }    y1 = a6;  }  else  {    if ( v11 + y <= 0 )      return 0;    y1 = a6 - y;    v14 = (*(*this + 20))(this) + a6 - 1 - y;    if ( v14 < a8 )      a8 = v14;    y = 0;  }  x_c = a7 - x1 + 1;  if ( x_c > 0 && a8 - y1 + 1 > 0 )  {    srchdc = (*(*a4 + 4))(a4);// 0040124E, mov    hdc = (*(*this + 4))(this);// 0040124E, mov    return BitBlt(hdc, x, y, x_c, a8 - y1 + 1, srchdc, x1, y1, rop);  }  return 0;


动态替换图片文件

为了搞明白这个游戏游戏引擎图像如何渲染的,我把很多的虚函数都跟了一遍。
其实汉化图片只需要逆向到如何解压cpb文件那里就足够了。这个游戏麻烦地方在于不同通道对应的不同处理函数,要依次来hook替换缓冲区。另外在读取文件适合要记录一些文件名,用于缓冲区动态替换我们汉化的图片。
以24位图片代码替换为例,代码如下:
/* for hook decompressed cpb24 buffer0041E2DB   | 8B55 0C  | mov edx,dword ptr ss:[ebp+C]0041E2DE   | 8BC7   | mov eax,edi0041E2E0   | 2BC6   | sub eax,esi0041E2E2   | 42   | inc edx0041E2E3   | 8955 0C  | mov dword ptr ss:[ebp+C],edx0041E2E6   | 894D EC  | mov dword ptr ss:[ebp-14],ecx0041E2E9   | 85DB | test ebx,ebx0041E2EB   | 7E 35 | jle lamune_chs.41E322*/const char* g_curcpbname = NULL;const DWORD g_copycpb24i_41E2DB = 0x41E2DB;const DWORD g_copycpb24o_41E2E0 = 0x41E2E0; void __declspec(naked) loadcpb_hook_419E03(){    __asm {        push eax;        mov eax, dword ptr [esp+8]; // after push eax        mov g_curcpbname, eax;        pop eax;         // fix origin code        push ebp;        mov ebp, esp;        sub esp, 0x2c;        jmp dword ptr ds:[g_loadcpbo_419E09];    }} void __declspec(naked) copycpb24_hook_41E2DB(){    __asm {        pushad;        push [ebp-0x20];        push g_curcpbname;        // size_t __stdcall load_rawcpb(char *name, PBYTE buf)        call load_rawcpb;        popad;        // fix origin code        mov edx,dword ptr [ebp+0xC];        mov eax,edi;        jmp dword ptr ds:[g_copycpb24o_41E2E0];    }} void install_cpbhook(){    // inlinehook loadcpb    BYTE jmpE8buf[0x5]={0xE9}; // jmp relative    *(DWORD*)(jmpE8buf+1) = (DWORD)loadcpb_hook_419E03-         ((DWORD)g_loadcpbi_419E03 + sizeof(jmpE8buf));    winhook_patchmemory((LPVOID)g_loadcpbi_419E03,        jmpE8buf, sizeof(jmpE8buf));     // inlinehook copycpb24    *(DWORD*)(jmpE8buf+1) = (DWORD)copycpb24_hook_41E2DB-         ((DWORD)g_copycpb24i_41E2DB + sizeof(jmpE8buf));    winhook_patchmemory((LPVOID)g_copycpb24i_41E2DB,        jmpE8buf, sizeof(jmpE8buf));}
这里采取的是png格式存储的汉化图片,为了方便用了stb(https://github.com/nothings/stb)进行加载。
size_t __stdcall load_rawcpb(char *name, PBYTE buf){    char path[MAX_PATH] = {SYSGRAPH_DIR "/" "\0"};    strcat(path, name);    strcpy(path + strlen(path)-                  strlen(SYSGRAPH_EXT),SYSGRAPH_EXT);     int width, height, channel;    printf("load_rawcpb(%s, %p)", path, buf);    size_t entry_size = load_arc_entry(path, NULL);    const BYTE *tmpbuf = (BYTE*)malloc(entry_size);    load_arc_entry(path, (PBYTE)tmpbuf);    char* img = (char*)stbi_load_from_memory(tmpbuf,        entry_size, &width, &height, &channel, 0);    free((void*)tmpbuf);     if(!img)    {        printf(" not found!\n");        return 0;    }    printf(" width=%d, heigth=%d, channel=%d\n",        width, height, channel);    for(int y=0;y<height;y++)    {        for(int x=0;x<width;x++)        {            char r = *(img + channel * (width*y + x) + 0);            char g = *(img + channel * (width*y + x) + 1);            char b = *(img + channel * (width*y + x) + 2);              *(buf + 0*height*width + width*y+x) = r;            *(buf + 1*height*width + width*y+x) = g;            *(buf + 2*height*width + width*y+x) = b;            if(channel==4)            {                char a = *(img + channel * (width*y + x) + 3);                *(buf + 3*height*width + width*y+x) = a;            }        }    }     stbi_image_free(img);    return width*height*channel;}
加载后遇到渲染bug,我们把对应缓冲区dump出来放到ct2中进行查看,确定原因。
这里发现原来是stbi_load_from_memory函数对于tga格式有些问题,换成png格式最后参数为0,问题解决。
至此,图片汉化问题全部解决。


后记

这个游戏我逆向了一周多把引擎的加载方式搞明白了,之后又测试导入翻译断断续续修复bug一个月,基本上汉化完美了。这里有个坑,通关后没法打开gallary。这是官方的bug,下载了升级补丁可以修复。但是之前给我的文件是初版游戏,我说基于这个版本分析的。还得把旧版搬到新版上,非常麻烦。这个故事告诉我们,以后汉化要第一时间检查更新补丁。
整体来讲,这游戏有三大难点。难点之一在封包上,有加密和校验非常麻烦,因此我们采取了动态替换解密后的缓冲区;其二,图像缓冲区不好找,里面有大量虚函数,需要一点点跟;其三,sjis字符检测过于分散,需要手动一个个调整,而且也是用非主流方式判断的。因此,我认为此游戏比较适合半动态汉化。这种基于文件的替换方式可以免去复杂的封包,同时相比文本层面上的全动态汉化,可以更方便调试,少引发一些文本同步之类的问题。
另外我用stb(https://github.com/nothings/stb)加载图片,这里遇到了问题,xp上运行会崩溃。
调试定位在了mov eax, large fs:2Ch上,这是因为这个库用了__declspec(thread),在win xp上LoadLibrary遇到tls就会崩,定义宏#define STBI_NO_THREAD_LOCALS即可解决。
然后进行了若干测试,我这个汉化兼容补丁性还不错~ win xp, win7, win8 , win10甚至连linux wine,exagear都测试了,可以说是全平台兼容了~ 完结撒花~

看雪ID:devseed

https://bbs.kanxue.com/user-home-617776.htm

*本文由看雪论坛 devseed 原创,转载请注明来自看雪社区

# 往期推荐

1、Xposed检测绕过

2、源代码静态分析方法——代码属性图Code Property Graphs

3、Windows 2000系统的一个0day漏洞发现过程

4、源代码静态分析方法——代码属性图Code Property Graphs

5、wibu证书 - asn1码流

6、COM 进程注入技术-编程技术

球分享

球点赞

球在看

点击“阅读原文”,了解更多!


文章来源: http://mp.weixin.qq.com/s?__biz=MjM5NTc2MDYxMw==&mid=2458496413&idx=1&sn=04fb33a23d7f577e89ba9d3e316c4824&chksm=b18e9d1786f914017fb4236de7c8e5911c736db390fdb1cb71d1e6c704b473565ddc82eb9159#rd
如有侵权请联系:admin#unsafe.sh