Galgame汉化中的逆向(六):动态汉化分析_以MAJIROv3引擎为例


  • Galgame汉化中的逆向(六):动态汉化分析_以MAJIROv3引擎为例

    by devseed, 本贴论坛和我的博客同时发布

    0x0 前言

    之前我们谈论的基本上都是静态汉化。所谓静态汉化,即分析文件结构、二进制脚本opcode,然后进行静态封包等方法。与类似于静态编译的语言类似,在运行前数据类型等已经确定完成,程序运行时按照既定的逻辑执行,静态汉化显示的是我们提前准备好的汉化文本。大部分的主机游戏汉化都是静态汉化,因为权限等问题,主机几乎不可能动态调试(即使有,gdbserver等用起来也挺费劲,也可能有兼容性问题调试失败)。再加上在主机上hook也很麻烦,测试极不方便,所以大部分主机游戏汉化以静态汉化为主,有模拟器的可能会结合一些动态调试辅助分析(不过别指望模拟器的调试有多好用了...)。

    静态汉化是基础,对于常见文件结构、二进制脚本、算法等有了一定了解后,我们才能更好地找到关键位置dump、文本注入点等,因此我之前的汉化教程都是以静态汉化为主。与静态汉化相对的是动态汉化,往往不需要进行复杂的文件分析和二进制脚本分析,通常也不用考虑封包问题。动态汉化中,文本显示是程序运行时动态注入和替换的,重点是找到:

    • 显示相应文本的函数
    • 区分字符串的标识符(一般与文件中的对应文本偏移相关)

    目前关于动态汉化的分析帖相对来说比较少,下面我们就以Majirov3引擎为例,来谈谈如何进行动态分析、如何进行动态汉化、以及如何解决一些动态汉化中出现的问题。

    winterpolaris_dynamic_chs1

    0x1 动态hook定位解密函数与分析文件结构

    动态汉化的第一步,动态dump封包中已经解密完成的二进制脚本,从中提取文本和对应的偏移。那么如何去找呢?通常可以在游戏运行时候去搜索内存中的特定文本,找出最像是二进制脚本的那部分(可能有多个搜索结果,但有些并不是源头,类似于用CE去搜索会有多个数值匹配),然后下硬件访问断点,看是哪些代码生成的。

    但是这个方法有个问题,解密文本的位置可能是malloc动态生成的缓冲区,重启调试器后位置会改变,导致断点失效。这时候我们可以考虑hook文件访问的API,如fopen,CreateFile等,来顺藤摸瓜找到读取封包和解密文本的位置。有可能没有动态链接msvcrt.dll,而是静态链接到exe里了,导致导入表没有此函数。一般ida可以识别出这些静态链接的C库函数,如下:

    .text:00488F86 ; FILE *__cdecl fopen(const char *FileName, const char *Mode)
    .text:00488F86 _fopen          proc near               ; CODE XREF: sub_42D210+177↑p
    .text:00488F86                                         ; sub_42D210+3F2↑p ...
    .text:00488F86
    .text:00488F86 FileName        = dword ptr  8
    .text:00488F86 Mode            = dword ptr  0Ch
    .text:00488F86
    .text:00488F86                 push    ebp
    .text:00488F87                 mov     ebp, esp
    .text:00488F89                 push    40h ; '@'       ; ShFlag
    .text:00488F8B                 push    [ebp+Mode]      ; Mode
    .text:00488F8E                 push    [ebp+FileName]  ; FileName
    .text:00488F91                 call    __fsopen
    .text:00488F96                 add     esp, 0Ch
    .text:00488F99                 pop     ebp
    .text:00488F9A                 retn
    .text:00488F9A _fopen          endp
    

    之后我们可以对这些函数进行hook,此游戏用的都是c库函数进行文件读取。代码如下:

    var g_base =  0x400000; 
    
    function hook_fopen_fread() // print fopen and fread to investigate file structor
    {
        var memove = new NativeFunction(ptr(g_base + 0x8aa80), 
            'void', ["pointer", "pointer", "int"]);
        var sprintf = new NativeFunction(ptr(g_base + 0x89493), 
            'int', ["pointer", "pointer", "..."], "mscdecl");
        var fopen = new NativeFunction(ptr(g_base + 0x88F86), 
            'pointer', ["pointer", "pointer"]); // in this game, all file function is static link
        var fread = new NativeFunction(ptr(g_base + 0x8B609), 
            'size_t', ['pointer', 'size_t', 'size_t', 'size_t']);
        var fseek = new NativeFunction(ptr(g_base + 0x8DAD2), 
            'int', ["pointer", "int", "int"]);
        var ftell = new NativeFunction(ptr(g_base + 0x8EEF6), 
            'int', ["pointer"]);
        var g_fargs = [];
        Interceptor.attach(fopen, {
            onEnter: function(args)
            {
                g_fargs.push(args[0].readCString());
            },
            onLeave: function(retval)
            {
                var ret_addr = this.context.esp.readPointer();
                var filepath = g_fargs[0];
                if(retval.toInt32()!=0)
                {
                    console.log(ret_addr, 
                        "fopen", 
                        filepath.split('\\')[filepath.split('\\').length-1],
                        "fp=" + retval);
                }
                g_fargs = []
            }
        })
        Interceptor.attach(fread, {
            onEnter: function(args)
            {
                var ret_addr = this.context.esp.readPointer();
                var fp = args[3];
                var offset = ftell(fp);
                console.log(ret_addr, 
                    "fread(" + args[0]+", " + args[1]+", " + args[2] + ", " + fp + ")", 
                    "offset=0x" + offset.toString(16));
            }
        })
    }
    

    之后我们可以查看日志,在进入章节的时候,看看是哪些函数调用了文件API。

    0x47a51f fopen scenario.arc fp=0x4ca198 // first test the file size
    0x47a547 fread(0xd12d2c, 0x1c, 0x1, 0x4ca198) offset=0x0
    0x47a796 fread(0xd12d98, 0x3a0, 0x1, 0x4ca198) offset=0x1c
    0x47a80f fread(0x80f304, 0x1, 0x1, 0x4ca198) offset=0x14de4a //end
    0x47a82a fread(0x80f304, 0x1, 0x1, 0x4ca198) offset=0x14de4b
    0x47b705 fopen scenario.arc fp=0x4ca198
    0x440cd6 fread(0x80f3b0, 0x10, 0x1, 0x4ca198) offset=0x114dfb
    0x440d50 fread(0xba4477c, 0x4, 0x1, 0x4ca198) offset=0x114e0b
    0x440d6c fread(0xba44780, 0x4, 0x1, 0x4ca198) offset=0x114e0f
    0x440d88 fread(0xba44774, 0x4, 0x1, 0x4ca198) offset=0x114e13
    0x440db4 fread(0x766f1f0, 0x28, 0x1, 0x4ca198) offset=0x114e17
    0x440dcc fread(0xba44778, 0x4, 0x1, 0x4ca198) offset=0x114e3f
    0x440df0 fread(0xb99ac90, 0xeab, 0x1, 0x4ca198) offset=0x114e43 // read mjo content
    0x47b705 fopen scenario.arc fp=0x4ca198
    0x440cd6 fread(0x80f220, 0x10, 0x1, 0x4ca198) offset=0x12e5d2
    0x440d50 fread(0xba446a4, 0x4, 0x1, 0x4ca198) offset=0x12e5e2
    0x440d6c fread(0xba446a8, 0x4, 0x1, 0x4ca198) offset=0x12e5e6
    0x440d88 fread(0xba4469c, 0x4, 0x1, 0x4ca198) offset=0x12e5ea
    0x440db4 fread(0xb9654b8, 0x570, 0x1, 0x4ca198) offset=0x12e5ee
    0x440dcc fread(0xba446a0, 0x4, 0x1, 0x4ca198) offset=0x12eb5e
    0x440df0 fread(0xbbb9850, 0x17e39, 0x1, 0x4ca198) offset=0x12eb62
    0x47b705 fopen scenario.arc fp=0x4ca198
    0x440cd6 fread(0x80f220, 0x10, 0x1, 0x4ca198) offset=0xea802
    0x440d50 fread(0xba4318c, 0x4, 0x1, 0x4ca198) offset=0xea812
    0x440d6c fread(0xba43190, 0x4, 0x1, 0x4ca198) offset=0xea816
    0x440d88 fread(0xba43184, 0x4, 0x1, 0x4ca198) offset=0xea81a
    0x440db4 fread(0x76774c8, 0x10, 0x1, 0x4ca198) offset=0xea81e
    0x440dcc fread(0xba43188, 0x4, 0x1, 0x4ca198) offset=0xea82e
    0x440df0 fread(0xbb95f08, 0x11ea, 0x1, 0x4ca198) offset=0xea832
    

    fread的读取数据的大小可能和二进制文件的结构相关,比如说第一个fread先在scenario.arc文件开头读取了0x1c大小,我们可以推测文件头的大小是0x1c。用同样的方法,可以顺便把封包结构分析出来了,包括封包内的每个子项(mjo)数据结构。下图为scenario.arc在开头和0x114dfb位置的内容,观察发现在utf-8sjis下没有有意义字符串,可以断定mjo是加密或压缩的

    whiterpolaris_scenario_arc

    whiterpolaris_scenario_arc_114dfb

    majiroV3封包文件结构总结如下:

    scenario.arc, header size: 1C
    0~0x10 MajiroArcV3.000
    0x10~0x1C  index_count 4, name_table_offset 4, frist_mjo_offset 4 
              // 41 00 00 00 2C 04 00 00 AA 07 00 00
    0x1C~0x42C arc_index[index_count] // arc_block_num * 0x10 = 0x410
        | unknow1 4  // hash?
    	| unknow2 4
    	| mjo_offset 4 
    	| mjo_size 4
    	// CA 91 E5 51 F5 10 EE 87 67 C6 0A 00 7B B7 00 00
    0x42C~0x7AA name_table
    0x7AA~ mjo[index_count]
    
    mjo_entry at 0x114dfb
    0x0~0x10  MajiroObjX1.000
    0x10~0x1c n1 4, unknow2 4, mjo_block_num 4 // E9 06 00 00 00 00 00 00 05 00 00 00
    0x1c~0x44 mjo_block // mjo_block_num*8 = 0x28
    0x44~0x48 mjo_size 4
    

    当然了,这个封包结构很简单,直接静态黑箱分析也完全能猜出来,上面只是为了演示一下动态分析的一些思路。分析封包文件结构不是必须的,但是可以帮助我们更好的找到解密文本的位置。之后,定位到fopenscenario.arc后的fread,在缓冲区下写入断点(此地址就是之前所说的每次都会变的malloc地址),即可定位到解密文本的内容。

    或者通过日志0x440cd6 fread(0x80f3b0, 0x10, 0x1, 0x4ca198) offset=0x114dfb的返回地址0x440cd6,找到准备读取每一个mjo的函数,即sub_440AB0。这个引擎定位还是比较容易的,有日语错误信息辅助定位,默认显示乱码,需要把IDA的Cstyle default-8bit encoding改成Shift-jis编码。

    0047A537  | 6A 01            | push 1                             |
    0047A539  | 8DBE 04020000    | lea edi,dword ptr ds:[esi+204]     | edi:"MajiroArcV3.000", esi+204:"MajiroArcV3.000"
    0047A53F  | 6A 1C            | push 1C                            |
    0047A541  | 57               | push edi                           | edi:"MajiroArcV3.000"
    0047A542  | E8 C2100100      | call <polaris_chs.sub_48B609>      | fread
    
    // read scenerio mjo
    char *__usercall [email protected]<eax>(int [email protected]<ebx>, int [email protected]<edi>, int [email protected]<esi>, char *FullPath)
    {
      char *v4; // ecx
      char *context; // esi
      int v7; // ebx
      int v8; // edx
      int v9; // edx
      FILE *fp; // eax MAPDST
      char *v12; // ecx
      char *v13; // edx
      bool v14; // cf
      char *v15; // ecx
      char *v16; // edx
      void *buf_mjoblock; // eax
      void *buf_mjo; // eax
      char *v19; // ecx
      char v20; // al
      size_t mjo_block_size; // [esp-1Ch] [ebp-32Ch]
      size_t mjo_size; // [esp-1Ch] [ebp-32Ch]
      int v28; // [esp+4h] [ebp-30Ch]
      int v29; // [esp+4h] [ebp-30Ch]
      int v30; // [esp+8h] [ebp-308h]
      char Buffer[255]; // [esp+Ch] [ebp-304h] BYREF
      char v32; // [esp+10Bh] [ebp-205h] BYREF
      char mjo_Filename[512]; // [esp+10Ch] [ebp-204h] BYREF
    
      _splitpath(FullPath, 0, 0, mjo_Filename, 0);
      v4 = &v32;
      while ( *++v4 )
        ;
      strcpy(v4, ".mjo");
      tolower((unsigned __int8 *)mjo_Filename);
      if ( strlen(mjo_Filename) > 0x7F )
        sub_441150(
          "ファイル名[%s]が長すぎます%d文字以内にしてください。",
          (int)mjo_Filename,
          127,
          (int)FullPath,
          v28);
      context = dword_4DC350;
      v7 = 0;
      v30 = 0;
      if ( !dword_4DC350 )
        goto LABEL_12;
      while ( sub_47C550(context, mjo_Filename) )   // strcmp?
      {
        context = (char *)*((_DWORD *)context + 0x2A);
        if ( !context )
        {
    LABEL_13:
          context = (char *)try_malloc(0xB0);
          memset(context, 0, 0xB0u);
          while ( 1 )
          {
            if ( sub_47BE30(mjo_Filename) )         // if not find target mjo, to load scenario
              goto LABEL_16;
            sub_47A310("scenario", 0);              // test scenario files
            sub_47A310("scenario9", 0);
            sub_47A310("scenario8", 0);
            sub_47A310("scenario7", 0);
            sub_47A310("scenario6", 0);
            sub_47A310("scenario5", 0);
            sub_47A310("scenario4", 0);
            sub_47A310("scenario3", 0);
            sub_47A310("scenario2", 0);
            sub_47A310("scenario1", 0);
            if ( sub_47BE30(mjo_Filename) )
            {
    LABEL_16:
              *((_DWORD *)context + 0x20) = ((int (__cdecl *)(LPCSTR))sub_479FE0)(mjo_Filename);
              *((_DWORD *)context + 0x21) = v9;
              fp = (FILE *)try_fopen(a2, (int)context, mjo_Filename, "rb");// fopen
              if ( fp && fread(Buffer, 0x10u, 1u, fp) == 1 )// MajiroObjV1.000
              {
                v12 = off_4C7ABC[0];
                v13 = Buffer;
                a2 = 12;
                do
                {
                  if ( *(_DWORD *)v12 != *(_DWORD *)v13 )
                  {
                    v15 = off_4C7AC0;
                    v16 = Buffer;
                    a2 = 12;
                    while ( *(_DWORD *)v15 == *(_DWORD *)v16 )
                    {
                      v15 += 4;
                      v16 += 4;
                      v14 = (unsigned int)a2 < 4;
                      a2 -= 4;
                      if ( v14 )
                      {
                        v29 = 1;
                        goto LABEL_26;
                      }
                    }
                    goto LABEL_32;
                  }
                  v12 += 4;
                  v13 += 4;
                  v14 = (unsigned int)a2 < 4;
                  a2 -= 4;
                }
                while ( !v14 );
                v29 = 0;
    LABEL_26:
                if ( fread(context + 0x94, 4u, 1u, fp) == 1 && fread(context + 0x98, 4u, 1u, fp) == 1 )// read n1, n2
                {
                  a2 = (int)(context + 0x8C);
                  if ( fread(context + 0x8C, 4u, 1u, fp) == 1 )// read mjo_block_num
                  {
                    buf_mjoblock = try_malloc(8 * *(_DWORD *)a2 + 0x20);// malloc
                    mjo_block_size = 8 * *(_DWORD *)a2;
                    *((_DWORD *)context + 0x28) = buf_mjoblock;
                    if ( fread(buf_mjoblock, mjo_block_size, 1u, fp) == 1 )// read mjo_block
                    {
                      a2 = (int)(context + 0x90);
                      if ( fread(context + 0x90, 4u, 1u, fp) == 1 )
                      {
                        buf_mjo = try_malloc(*(_DWORD *)a2 + 0x20);// malloc
                        mjo_size = *(_DWORD *)a2;
                        *((_DWORD *)context + 0x29) = buf_mjo;
                        if ( fread(buf_mjo, mjo_size, 1u, fp) == 1 )
                        {
                          fclose(fp);
                          if ( v29 )
                            sub_478E70(*((__m128i **)context + 0x29), *((_DWORD *)context + 0x24));// decrypt mjo, dword 0x24 is context+0x90
                          v19 = mjo_Filename;
                          do
                          {
                            v20 = *v19++;
                            v19[context - mjo_Filename - 1] = v20;
                          }
                          while ( v20 );
                          *((_DWORD *)context + 0x22) = sub_478E10(context);
                          if ( !v30 )
                          {
                            *((_DWORD *)context + 0x2A) = dword_4DC350;
                            dword_4DC350 = context;
                          }
                          *((_DWORD *)context + 0x27) = sub_43A370(context, *((_DWORD *)context + 0x26));
                          return context;
                        }
                      }
                    }
                  }
                }
              }
    LABEL_32:
              v7 = v30;
            }
            sub_4793F0("MajiroObj : ファイル [%s] の読み込みで失敗しました", (int)FullPath, a2, a3, a1);
            if ( *((_DWORD *)context + 0x28) )
              free(*((void **)context + 0x28));
            if ( *((_DWORD *)context + 0x29) )
              free(*((void **)context + 0x29));
            free(context);
    LABEL_12:
            if ( !v7 )
              goto LABEL_13;
          }
        }
      }
      if ( *((_DWORD *)context + 0x20) != ((int (__cdecl *)(LPCSTR))sub_479FE0)(mjo_Filename)
        || *((_DWORD *)context + 0x21) != v8 )
      {
        v7 = 1;
        v30 = 1;
        free(*((void **)context + 0x28));
        free(*((void **)context + 0x29));
        goto LABEL_12;
      }
      return context;
    }
    

    0x2 dump解密二进制脚本

    我们已经找到了二进制脚本读取的函数,稍微分析一下不难找到文本解密函数sub_478E70。虽然用了SSE指令集优化,但是不难分析的,典型的xor加密,ida伪代码可读性已经很强了。本节以动态dump讲解为主,此处就不再详细分析解密函数了。

    char __cdecl sub_478E70(__m128i *buf, unsigned int size)
    {
      __m128i *cur; // esi
      __int32 v3; // eax
      signed int v4; // edx
      unsigned int v5; // edi
      unsigned int i; // ecx
      int v7; // ecx
      int v8; // ecx
    
      cur = buf;
      LOBYTE(v3) = sub_479070(0xFFFFFFFF, (int)buf, 0);
      v4 = size;
      if ( size >= 0x400 )
      {
        v5 = size >> 10;
        v4 = -1024 * (size >> 10) + size;
        do
        {
          if ( cur > (__m128i *)&unk_5CB5C4 || (__m128i *)((char *)&cur[63].m128i_u64[1] + 4) < &stru_5CB1C8 )
          {
            v3 = (__int32)&unk_5CB1D8;
            v7 = 0x20;
            do
            {
              v3 += 0x20;
              *cur = _mm_xor_si128(_mm_loadu_si128((const __m128i *)(v3 - 0x30)), _mm_loadu_si128(cur));
              cur[1] = _mm_xor_si128(_mm_loadu_si128((const __m128i *)(v3 - 0x20)), _mm_loadu_si128(cur + 1));
              cur += 2;
              --v7;
            }
            while ( v7 );
          }
          else
          {
            for ( i = 0; i < 256; ++i )
            {
              v3 = stru_5CB1C8.m128i_i32[i];
              cur->m128i_i32[0] ^= v3;
              cur = (__m128i *)((char *)cur + 4);
            }
          }
          --v5;
        }
        while ( v5 );
      }
      if ( v4 > 0 )
      {
        v8 = (char *)&stru_5CB1C8 - (char *)cur;
        do
        {
          LOBYTE(v3) = cur->m128i_i8[v8];
          cur = (__m128i *)((char *)cur + 1);
          cur[-1].m128i_i8[15] ^= v3;
          --v4;
        }
        while ( v4 > 0 );
      }
      return v3;
    }
    

    关于具体的dump点,可以在sub_440AB0的末尾进行hook,返回值eaxmjo_struct指针,同时储存在[4DC350]全局变量中。下面为此函数返回处的反汇编代码:

    sub_440AB0
    ...
    00440E9C  | A1 50C34D00      | mov eax,dword ptr ds:[4DC350]      | eax:"SUB_TITLE.MJO", 004DC350:&"SUB_TITLE.MJO"
    00440EA1  | 8986 A8000000    | mov dword ptr ds:[esi+A8],eax      | eax:"SUB_TITLE.MJO"
    00440EA7  | 8935 50C34D00    | mov dword ptr ds:[4DC350],esi      | 004DC350:&"SUB_TITLE.MJO"
    00440EAD  | FFB6 98000000    | push dword ptr ds:[esi+98]         |
    00440EB3  | 56               | push esi                           |
    00440EB4  | E8 B794FFFF      | call <polaris_chs.sub_43A370>      | sub_43A370
    00440EB9  | 83C4 08          | add esp,8                          |
    00440EBC  | 8986 9C000000    | mov dword ptr ds:[esi+9C],eax      | eax:"SUB_TITLE.MJO"
    00440EC2  | 8B4D FC          | mov ecx,dword ptr ss:[ebp-4]       |
    00440EC5  | 8BC6             | mov eax,esi                        | eax:"SUB_TITLE.MJO"
    00440EC7  | 5F               | pop edi                            |
    00440EC8  | 5E               | pop esi                            |
    00440EC9  | 33CD             | xor ecx,ebp                        |
    00440ECB  | 5B               | pop ebx                            |
    00440ECC  | E8 66950400      | call <polaris_chs.sub_48A437>      |
    00440ED1  | 8BE5             | mov esp,ebp                        |
    00440ED3  | 5D               | pop ebp                            |
    00440ED4  | C3               | ret                                | load mjo end; 
    

    根据sub_440AB0反汇编伪代码(见上节)中的sub_478E70(*((__m128i **)context + 0x29), *((_DWORD *)context + 0x24))解密函数,可以得出下面结论:

    [eax] mjo name , [4DC350] // at 00440ED4
    [[eax+0x29*4]] decrypted mjo buf, 
    [eax+0x24*4] mjo size //雨的边界同理,貌似没什么明显的特征,要手动找函数位置
    

    有了这些信息,我们可以写dump解密文本函数了,如下:

    function dump_mjo(mjo_name, dump_dir="./dump/") // to dump decrypted mjo
    {    
        // better to attach process, after initial, or access violation
        var decrypt_func = new NativeFunction(ptr(g_base + 0x40AB0),
            'pointer', ['pointer'], 'stdcall');
        var name_buf = Memory.alloc(256).writeAnsiString(mjo_name);
        var decrypt_ret = decrypt_func(name_buf);
        let mjo_size = decrypt_ret.add(0x24*4).readU32();
        let mjo_buf  = decrypt_ret.add(0x29*4).readPointer();
        console.log(mjo_name, mjo_buf, mjo_size);
        var fp = new File(dump_dir + mjo_name, "wb");
        fp.write(mjo_buf.readByteArray(mjo_size));
        fp.close()
    }
    

    dump完后,查看一下,二进制脚本已经解密。至于提取剧本,简单观察大概是这样的结构40 08 [size 2] text 00,匹配这种结构即可,当然也可以直接检测sjis编码提取,详见我写的binary_text.py

    0x3 寻找文本显示位置

    动态汉化的好处是我们不用去费半天劲逆向封包算法、不用再去分析二进制指令opcode。

    但是同样动态汉化也有一些问题:

    • 找到hook的关键点可能不是那么容易。因为显示字符串的函数可能有多个、在不同时机内存的内容也可能不一样,要在恰当的时机恰当的位置hook。
    • 动态汉化同样也需要考虑兼容性问题,去动态注入可能会引发其他的问题。比如说丢失特定字符引发的一些脚本执行问题、一些索引和长度没有同时修改等。

    因此,选择文件hook点的位置,原则上越接近原始位置(读取二进制文本的位置)越好 。直接搜索显示在屏幕上的文本,得到的搜索结果可能有多个,分别修改一下看看对游戏产生什么影响。同时也要兼顾能否找到当前文本在脚本中的偏移来进行定位,在查找到文本缓存的周围(如堆栈中的指针,寄存器,或者反汇编指令里引用的全局变量)来找找有没有标识当前文本在二进制脚本中位置的指针。下面的反汇编为本游戏的一些显示文本位置:

    a. showtext_screen
    004453E9   | 50                   | push eax                                | [[5D1A58]] current text addr in mjo buffer
    004453EA   | 53                   | push ebx                                |
    004453EB   | 8D85 FCFBFFFF        | lea eax,dword ptr ss:[ebp-404]          |
    004453F1   | 50                   | push eax                                |
    004453F2   | FF35 E4CC5200        | push dword ptr ds:[52CCE4]              | 0052CCE4:"煨R"
    004453F8   | 68 90065300          | push polaris_chs.530690                 |
    004453FD   | E8 1ED4FFFF          | call <polaris_chs.sub_442820>           | showtext_screen
    
    b. move to next text
    0043A750  | 8B15 581A5D00                 | mov edx,dword ptr ds:[5D1A58]      | edx:&"1"
    0043A756  | 8B0A                          | mov ecx,dword ptr ds:[edx]         | [edx]:"1"
    0043A758  | 0FBF01                        | movsx eax,word ptr ds:[ecx]        | get_text_len
    0043A75B  | 83C1 02                       | add ecx,2                          | move to text
    0043A75E  | 890A                          | mov dword ptr ds:[edx],ecx         | [edx]:"1"
    0043A760  | C3                            | ret                                |
    
    c. preshow_text
    00445140  | 55               | push ebp                           |
    00445141  | 8BEC             | mov ebp,esp                        |
    00445143  | 81EC 180C0000    | sub esp,C18                        |
    00445149  | A1 10A04C00      | mov eax,dword ptr ds:[4CA010]      |
    0044514E  | 33C5             | xor eax,ebp                        |
    00445150  | 8945 FC          | mov dword ptr ss:[ebp-4],eax       |
    00445153  | 8B0D E4CC5200    | mov ecx,dword ptr ds:[52CCE4]      | [52cce4] text
    00445159  | 85C9             | test ecx,ecx                       |
    0044515B  | 74 19            | je polaris_chs.445176              |
    0044515D  | 8039 00          | cmp byte ptr ds:[ecx],0            |
    00445160  | 75 24            | jne polaris_chs.445186             |
    00445162  | C781 00040000 00 | mov dword ptr ds:[ecx+400],0       |
    0044516C  | C705 E4CC5200 00 | mov dword ptr ds:[52CCE4],0        | 0052CCE4:"杼R"
    00445176  | 33C0             | xor eax,eax                        |
    00445178  | 8B4D FC          | mov ecx,dword ptr ss:[ebp-4]       |
    0044517B  | 33CD             | xor ecx,ebp                        |
    0044517D  | E8 B5520400      | call <polaris_chs.sub_48A437>      |
    00445182  | 8BE5             | mov esp,ebp                        |
    00445184  | 5D               | pop ebp                            |
    00445185  | C3               | ret                                |
    00445186  | 8B15 581A5D00    | mov edx,dword ptr ds:[5D1A58]      | [[5D1A58]] current text addr in mjo buffer
    0044518C  | A1 94CB4D00      | mov eax,dword ptr ds:[4DCB94]      |
    00445191  | 53               | push ebx                           |
    00445192  | 33DB             | xor ebx,ebx                        |
    00445194  | 3B02             | cmp eax,dword ptr ds:[edx]         |
    00445196  | 74 1D            | je polaris_chs.4451B5              |
    00445198  | 53               | push ebx                           | extra always 0 ?
    00445199  | 51               | push ecx                           | buf
    0044519A  | E8 C1D5FFFF      | call <polaris_chs.sub_442760>      | show_text
    
    d. memove, copy string to showbuf
    00445BA0  | C780 E8D05200 01000000        | mov dword ptr ds:[eax+52D0E8],1    |
    00445BAA  | 8D80 E8CC5200                 | lea eax,dword ptr ds:[eax+52CCE8]  | eax:L"簀簀簀簀簀簀簀簀簀"
    00445BB0  | 8B35 581A5D00                 | mov esi,dword ptr ds:[5D1A58]      | 5D1A58, mjo decrypt text(some of)
    00445BB6  | 53                            | push ebx                           | size
    00445BB7  | A3 E4CC5200                   | mov dword ptr ds:[52CCE4],eax      | write 52cce4
    00445BBC  | FF36                          | push dword ptr ds:[esi]            | src: [esi] mjo decrypt text
    00445BBE  | 50                            | push eax                           | dst: 52cce4, show test
    00445BBF  | E8 BC4E0400                   | call <polaris_chs.sub_48AA80>      | memmove
    00445BC4  | 83C4 0C                       | add esp,C                          |
    00445BC7  | 011E                          | add dword ptr ds:[esi],ebx         |
    00445BC9  | 5E                            | pop esi                            |
    00445BCA  | 5B                            | pop ebx                            |
    00445BCB  | C3                            | ret                                |
    
    

    根据测试a. showtext_screen这个位置最适合作为动态hook替换文本的位置,显示内容最接近实际显示的,而且修改后的字符串也会被记录到游戏backlog里,可供回看文本。其他的hook点有些会调用多次、有些文本不全、有些会显示过多字符(如人名,这些会作为语音标识,但不会显示在对话中)。对于显示函数的hook,总结如下:

    [52CCE4] current text,貌似没有字节数量什么的,直接替换即可// at 00442820
    [[5D1A58]] current text addr in mjo buffer // but [[5D1A58]] pointer at str end with byte "42 08"
    

    写个脚本hook验证一下:

    function hook_showtext() //  for investigating the text structure(offset and content) and  substitude text
    {
        Interceptor.attach(ptr(g_base+ 0x42820), {
            onEnter: function(args)
            {
                var mjo_struct = ptr(g_base + 0XDC350).readPointer();
                var mjo_name = mjo_struct.readAnsiString();
                var mjo_addr_base = mjo_struct.add(0x29*4).readPointer();
                var mjo_addr_cur = ptr(g_base + 0x5D1A58 - 0x400000).readPointer().readPointer();
    
                // because point at "42 08", go to the start of str buf addr
                while(mjo_addr_cur.readU8()!=0) mjo_addr_cur=mjo_addr_cur.sub(1); 
                mjo_addr_cur=mjo_addr_cur.sub(1)
                while(mjo_addr_cur.readU8()!=0) mjo_addr_cur=mjo_addr_cur.sub(1); 
                mjo_addr_cur=mjo_addr_cur.add(1);
                
                var text_addr = ptr(g_base + 0x52CCE4 - 0x400000).readPointer(); // you can replace your own text here
                var text = text_addr.readAnsiString();
                //text_addr.writeAnsiString("+0x"+(mjo_addr_cur - mjo_addr_base).toString(16));
                console.log(mjo_name, mjo_addr_base, "+0x"+(mjo_addr_cur - mjo_addr_base).toString(16), text);
            },
        });
    }
    

    frida -l winterpolaris_hook.js -f Polaris_chs.exe >1.txt将脚本注入游戏,由于控制台无法显示sjis字符,因此将输出内容重定向到文件中,然后用sjis编码查看。得到的内容如下:

    winterpolaris_frida_showtext

         ____
        / _  |   Frida 15.0.0 - A world-class dynamic instrumentation toolkit
       | (_| |
        > _  |   Commands:
       /_/ |_|       help      -> Displays the help system
       . . . .       object?   -> Display information about 'object'
       . . . .       exit/quit -> Exit
       . . . .
       . . . .   More info at https://frida.re/docs/home/
    Spawning `Polaris_chs.exe`...
    Spawned `Polaris_chs.exe`. Resuming main thread!
    [Local::Polaris_chs.exe]-> A01.MJO 0xbd55010 +0x74 -東京 1923-
    A01.MJO 0xbd55010 +0xb9 雪が降っていた。
    A01.MJO 0xbd55010 +0xe0 目を開けると、ゆらゆらと舞い降りる六花が見えた。
    A01.MJO 0xbd55010 +0x121 耳からは、なにかが燃える音もした。
    A01.MJO 0xbd55010 +0x1ba ツバキ「……ここは?」
    A01.MJO 0xbd55010 +0x1e1 目の前にはどこかの大きな屋敷。
    A01.MJO 0xbd55010 +0x210 暗い夜を照らすように、煌々と燃えていた。
    A01.MJO 0xbd55010 +0x283 主人公「気がついたか」
    A01.MJO 0xbd55010 +0x2aa 見知らぬ男の人の声。
    A01.MJO 0xbd55010 +0x2cf わたしのすぐ隣に立っていた。
    A01.MJO 0xbd55010 +0x336 主人公「お前……自分の名前がわかるか?」
    A01.MJO 0xbd55010 +0x3a0 ツバキ「ううん、わからない……」
    A01.MJO 0xbd55010 +0x3d1 何故だか思い出せなかった。
    A01.MJO 0xbd55010 +0x3fc ここが、どこなのかも分からなかった。
    A01.MJO 0xbd55010 +0x46b 主人公「では、これを持っていけ」
    A01.MJO 0xbd55010 +0x4cd ツバキ「あ、はい……」
    A01.MJO 0xbd55010 +0x4f4 そう言って、たくさんの金貨やお金をくれた。
    A01.MJO 0xbd55010 +0x52f 他にも、何かの手紙のような物もわたしに手渡した。
    A01.MJO 0xbd55010 +0x5a7 ツバキ「えと、あなたは?」
    A01.MJO 0xbd55010 +0x606 主人公「通りすがりだ」
    A01.MJO 0xbd55010 +0x62d それだけを言うと、軽く手を上げて背を向ける男の人。
    A01.MJO 0xbd55010 +0x670 そのまま去って行くのかと思うと……
    A01.MJO 0xbd55010 +0x69f 一度だけ振り返り……
    

    对照我们用binary_text.py提取的文本, 偏移(当前位置指针-解密缓冲区基址)正好是文件中的偏移,至此这个游戏动态汉化的理论研究已经完成,之后就是用c和内联汇编写程序实践了。下面是我们用于翻译的文本格式,白点列用于原文,黑点列用于译文,每行是●|num|addr|size●的索引格式,详见binary_text.h

    ○00001|000074|012○ −東京 1923−
    ●00001|000074|012● −東京 1923−
    
    ○00002|0000B9|010○ 雪が降っていた。
    ●00002|0000B9|010● 雪が降っていた。
    
    ○00003|0000E0|030○ 目を開けると、ゆらゆらと舞い降りる六花が見えた。
    ●00003|0000E0|030● 目を開けると、ゆらゆらと舞い降りる六花が見えた。
    
    ○00004|000121|022○ 耳からは、なにかが燃える音もした。
    ●00004|000121|022● 耳からは、なにかが燃える音もした。
    
    ○00005|0001AB|006○ ツバキ
    ●00005|0001AB|006● ツバキ
    
    ○00006|0001BA|010○ 「……ここは?」
    ●00006|0001BA|010● 「……ここは?」
    
    ○00007|0001E1|01E○ 目の前にはどこかの大きな屋敷。
    ●00007|0001E1|01E● 目の前にはどこかの大きな屋敷。
    
    ○00008|000210|028○ 暗い夜を照らすように、煌々と燃えていた。
    ●00008|000210|028● 暗い夜を照らすように、煌々と燃えていた。
    
    ○00009|000274|006○ 主人公
    ●00009|000274|006● 主人公
    
    ○00010|000283|010○ 「気がついたか」
    ●00010|000283|010● 「気がついたか」
    
    ○00011|0002AA|014○ 見知らぬ男の人の声。
    ●00011|0002AA|014● 見知らぬ男の人の声。
    
    ○00012|0002CF|01C○ わたしのすぐ隣に立っていた。
    ●00012|0002CF|01C● わたしのすぐ隣に立っていた。
    
    ○00013|000327|006○ 主人公
    ●00013|000327|006● 主人公
    
    ○00014|000336|022○ 「お前……自分の名前がわかるか?」
    ●00014|000336|022● 「お前……自分の名前がわかるか?」
    
    ○00015|000391|006○ ツバキ
    ●00015|000391|006● ツバキ
    
    ○00016|0003A0|01A○ 「ううん、わからない……」
    ●00016|0003A0|01A● 「ううん、わからない……」
    
    ○00017|0003D1|01A○ 何故だか思い出せなかった。
    ●00017|0003D1|01A● 何故だか思い出せなかった。
    
    ○00018|0003FC|024○ ここが、どこなのかも分からなかった。
    ●00018|0003FC|024● ここが、どこなのかも分からなかった。
    

    其实有时候如果我们实在找不到文本标识的偏移,也可以强行把游戏从从头到尾过一遍,把每句输出的文本提取出来。汉化的时候,再用hashmapLongest Common Subsequencedp计算当前文本与文本数据库中的相似度,选取相似度最高的匹配用于替换。

    0x4 IAT hook与Inline hook, LoadDll

    以上,我们谈了谈如何进行动态汉化的相关分析,方便起见都是用的frida进行hook。但是frida属于测试环境,不可能要求每个人电脑上都有这个环境,而且也可能有python版本冲突等问题。需要用尽可能少的依赖制作汉化,因此就要结合C与内联汇编来写汉化程序了。在制作汉化程序之前,来科普一下汉化游戏常用的hook方法。

    IAT hook

    即把相应函数的导入表的地址(FirstThunk)替换成我们的函数,实现hook。关于IAT结构和导入表相关内容,可以参考我之前写的文章SimpleDpack。下面是IAT hook的代码,兼容64位,详见我的github, win_hook,c

    BOOL iat_hook(LPCSTR targetDllName, PROC pfnOrg, PROC pfnNew)
    {
        return iat_hook_module(targetDllName, NULL, pfnOrg, pfnNew);
    }
    
    BOOL iat_hook_module(LPCSTR targetDllName, LPCSTR moduleDllName, PROC pfnOrg, PROC pfnNew)
    {;
    #ifdef _WIN64
    #define VA_TYPE ULONGLONG
    #else
    #define VA_TYPE DWORD
    #endif
        DWORD dwOldProtect = 0;
        VA_TYPE imageBase = GetModuleHandleA(moduleDllName);
        LPBYTE pNtHeader = *(DWORD *)((LPBYTE)imageBase + 0x3c) + imageBase; 
    #ifdef _WIN64
        VA_TYPE impDescriptorRva = *((DWORD*)&pNtHeader[0x90]);
    #else
        VA_TYPE impDescriptorRva = *((DWORD*)&pNtHeader[0x80]); 
    #endif
        PIMAGE_IMPORT_DESCRIPTOR pImpDescriptor = (PIMAGE_IMPORT_DESCRIPTOR)(imageBase + impDescriptorRva); 
        for (; pImpDescriptor->Name; pImpDescriptor++) // find the dll IMPORT_DESCRIPTOR
        {
            LPCSTR pDllName = (LPCSTR)(imageBase + pImpDescriptor->Name);
            if (!_stricmp(pDllName, targetDllName)) // ignore case
            {
                PIMAGE_THUNK_DATA pFirstThunk = (PIMAGE_THUNK_DATA)(imageBase + pImpDescriptor->FirstThunk);
                for (; pFirstThunk->u1.Function; pFirstThunk++) // find the iat function va
                {
                    if (pFirstThunk->u1.Function == (VA_TYPE)pfnOrg)
                    {
                        VirtualProtect((LPVOID)&pFirstThunk->u1.Function, 4, PAGE_EXECUTE_READWRITE, &dwOldProtect);
                        pFirstThunk->u1.Function = (VA_TYPE)pfnNew;
                        VirtualProtect((LPVOID)&pFirstThunk->u1.Function, 4, dwOldProtect, &dwOldProtect);
                        return TRUE;
                    }
                }
            }
        }
        return FALSE;
    }
    

    Inline hook

    IAThook只适用于动态链接外部DLL的函数,对于exe内部的函数,就需要Inline hook了,操作如下:

    1. 将hook点的前6个字节替换成FF 24 xxxxxxxx,或前5个字节替换为E9 xxxxxxxx,对应绝对地址和相对地址跳转,xxxxxxxx为hook我们编写的函数地址。
    2. 将原函数开头处被破坏的完整指令搬到TrampolineVirtualAlloc的一段可执行区域),后面跟一条jmp指令,跳转到原函数jmp机器码(5位或6位)替换后的下一条完整指令
    3. 之后可以通过jmp Trampoline来返回原函数

    不过我们不用再自己解析函数开头处的机器码了,直接用微软的detoursInline hook即可。细节上和上述可能有些区别,不过原理都是一样的。detours用法如下:

    #include "detours.h"
    int inline_hooks(PVOID pfnOlds[], PVOID pfnNews[])
    {
        int i=0;
        DetourRestoreAfterWith();
        DetourTransactionBegin();
        DetourUpdateThread(GetCurrentThread());
        for(i=0; pfnNews[i]!=NULL ;i++)
            DetourAttach(&pfnOlds[i], pfnNews[i]);
        DetourTransactionCommit();
        return i;
    }
    

    LoadDLL

    上述hook代码编译成的载体是DLL,我们还需要把此DLL注入目标到exe中,接管某些函数改变其功能。

    有三种常用方法:

    1. 在exe的导入表中静态添加DLL
    2. code cave进行LoadLibrayADLL
    3. VirtualAllocExWriteProcessMemoryCreateRemoteThread来动态注入DLL。

    代码如下,详见我的githubinjectdll.py, win_hook,c

    import lief
    def injectdll(exepath, dllpath, outpath="out.exe"): # can not be ASLR
        binary_exe = lief.parse(exepath)
        binary_dll = lief.parse(dllpath)
        
        dllname = os.path.basename(dllpath)
        dll_imp = binary_exe.add_library(dllname)
        print("the import dll in " + exepath)
        for imp in binary_exe.imports:
            print(imp.name)
    
        for exp_func in binary_dll.exported_functions:
            dll_imp.add_entry(exp_func.name)
            print(dllname + ", func "+ exp_func.name + " added!")
    
        # disable ASLR
        exe_oph =  binary_exe.optional_header;
        exe_oph.remove(lief.PE.DLL_CHARACTERISTICS.DYNAMIC_BASE)
    
        builder = lief.PE.Builder(binary_exe)
        builder.build_imports(True).patch_imports(True)
        builder.build()
        builder.write(outpath)
    
    
    BOOL inject_dll(HANDLE hProcess, LPCSTR dllname)
    {
        LPVOID param_addr = VirtualAllocEx(hProcess, 0, 0x100, MEM_COMMIT, PAGE_READWRITE);
        SIZE_T count;
        if (param_addr == NULL) return FALSE;
        WriteProcessMemory(hProcess, param_addr, dllname, strlen(dllname)+1, &count);
    
        HMODULE kernel = GetModuleHandleA("Kernel32");
        FARPROC pfnLoadlibraryA = GetProcAddress(kernel, "LoadLibraryA");
        HANDLE threadHandle = CreateRemoteThread(hProcess, NULL, NULL, 
            (LPTHREAD_START_ROUTINE)pfnLoadlibraryA, param_addr, NULL, NULL); 
       
        if (threadHandle == NULL) return FALSE;
        WaitForSingleObject(threadHandle, -1);
        VirtualFreeEx(hProcess, param_addr, 0x100, MEM_COMMIT);
    
        return TRUE;
    }
    

    0x5 内联汇编与C编写动态汉化程序

    到此,主要问题我们都搞清楚了,现在可以愉快地编写动态汉化程序了。动态汉化程序主要包括下面几个部分:

    • Inlinehook处汇编环境与C语言函数的对接, 注意cdeclstdcall,汇编调用C函数要自己保存寄存器

    • 维护日文文本与汉化文本的对应关系,文本偏移的定位等数据。并且用二分法等算法来查找替换文本等。

    • 编码和字体的hook,以使其适配汉语gb2312编码等(如CreateFontIndirectA,改变charset)

    文本显示Inline hook

    这里采取的__declspec(naked)形式进行内联汇编,进行获取当前文本指针、计算在文件中的偏移、调用相应的C函数查找字符串、替换汉化文本等操作。此处为了方便使用了一些全局变量,以g_前缀开头。

    void* g_base = (void*)0x400000; // app base addr
    void* g_showtext = (void*)0x442760; // replaced text buffer
    PMJO_NODE g_mjos=NULL, g_cur_mjo=NULL; // pointer to index structure
    char g_textbuf[2048] = {0}; // for showing replaced text
    __declspec(naked) void showtext_hook() // replace text to chs, inline hook code
    {
        __asm{
            pushad        
            mov ecx, g_base
            add ecx, 0xdc350 
            mov ecx, dword ptr ds:[ecx] ;mjo struct
            push ecx ;because the function might change the register
            push ecx ;mjo_name
            call search_mjo_ftexts
            pop ecx ;restore ecx for mjo struct
            lea eax, [ecx+29h*4]
            mov eax, dword ptr ds:[eax] ;mjo_addr_base
            mov ebx, g_base
            add ebx, 5D1A58h - 400000h
            mov ebx, dword ptr ds:[ebx]
            mov ebx, dword ptr ds:[ebx] ;mjo_addr_cur
            
            inc ebx
            loop1: ; do while
            dec ebx
            cmp byte ptr[ebx], 0
            jne loop1
            
            loop2:
            dec ebx
            cmp byte ptr[ebx], 0
            jne loop2
            inc ebx
    
            sub ebx, eax
            push ebx
            call find_mjo_chstext
            lea esi, g_textbuf
            cmp byte ptr [esi], 0 ; if g_textbuf is empty, just use origin buffer
            je leave
            mov edi, g_base
            add edi, 52CCE4h - 400000h
            mov edi, dword ptr ds:[edi] ;text_addr
            
            replace_text:
            mov al, byte ptr [esi]
            mov byte ptr [edi], al
            test al, al
            jz leave
            inc esi
            inc edi
            jmp replace_text
            
            leave:
            popad
            jmp dword ptr ds:[g_showtext]
        }
    }
    
    void install_text_hook()
    {
        // inline hook for replace text
        PVOID pfnOlds[3] = {g_base+0x42820, g_base+0x7EE00, NULL};
        PVOID pfnNews[3] = {showtext_hook, is_twobyte, NULL};
        printf("Before inline hooks\n");
        for(int i=0;i<sizeof(pfnOlds)/sizeof(PVOID)-1;i++)
        {
            printf("%d, %lx -> %lx\n", i, (unsigned long)pfnOlds[i], (unsigned long)pfnNews[i]); 
        }
        inline_hooks(pfnOlds, pfnNews);
        g_showtext = pfnOlds[0];
        printf("After inline hooks\n");
        for(int i=0;i<sizeof(pfnOlds)/sizeof(PVOID)-1;i++)
        {
            printf("%d, %lx -> %lx\n", i, (unsigned long)pfnOlds[i], (unsigned long)pfnNews[i]); 
        }
    }
    

    查找对应中文文本

    此处用双向链表数据结构来存储文件名与文本项索引,g_mjos全局变量来指向索引链表,g_cur_mjo指向当前文本索引位置。当游戏加载脚本时会查询当前链表中是否已经加载过,可以避免重复加载造成的内存泄露。PFTEXTS数据结构详见我的通用汉化文本格式,binary_text.h

    由于我们用的是日文和中文对照文本,因此文件用的utf-8格式存储,动态替换汉化文本要转换为gb2312格式。

    typedef struct _MJO_NODE MJO_NODE, *PMJO_NODE;
    struct _MJO_NODE
    {
        char mjo_name[256];
        PFTEXTS text_index;
        PMJO_NODE previous;
        PMJO_NODE next; // end with next=NULL
    };
    #define MJO_TEXT_DIR "./mjotext/"
    
    // try load mjo decrypt text from file, result to g_cur_mjo
    void load_mjo_ftexts(char* mjo_name)
    {
        char path[256]=MJO_TEXT_DIR;
        strcat(path, mjo_name);
        strcat(path, ".txt");
        FILE *fp=fopen(path, "r");
        if(fp)
        {
            fclose(fp);
            printf("load_mjo_ftexts, %s found!\n", path);
            g_cur_mjo->text_index = load_ftexts_file(path);
            strcpy(g_cur_mjo->mjo_name, mjo_name);
        }
        else
        {
            printf("load_mjo_ftexts, %s not found!\n", path);
        }
    }
    
    // serarch if already load the mjo decrypt texts, g_cur_mjo will move to the target mjo node
    void __stdcall search_mjo_ftexts(char* mjo_name)
    {
        if(g_mjos==NULL)
        {
            printf("search_mjo_ftexts, creating MJO_NODE with %s...\n", mjo_name);
            g_mjos = malloc(sizeof(MJO_NODE));
            memset(g_mjos, 0, sizeof(MJO_NODE));
            g_cur_mjo = g_mjos;
            load_mjo_ftexts(mjo_name);
        }
        else if(strcmp(mjo_name, g_cur_mjo->mjo_name)) // cur mjo_node not target mjo
        {
            g_cur_mjo = g_mjos; // to search from first
            while (g_cur_mjo->next) // serach for already loaded node
            {
                if(!strcmp(g_cur_mjo->mjo_name, mjo_name)) 
                {
                    printf("search_mjo_ftexts, %s is in the list at %lx\n", mjo_name, (unsigned long)g_cur_mjo);
                    return;
                }
                g_cur_mjo = g_cur_mjo->next;
            }
            if(g_cur_mjo->text_index!=NULL) // add new node
            {
                printf("search_mjo_ftexts, %s not in the list, trying to load...\n", mjo_name);
                PMJO_NODE tmp_mjo_node = malloc(sizeof(MJO_NODE));
                memset(tmp_mjo_node, 0, sizeof(MJO_NODE));
                tmp_mjo_node->previous = g_cur_mjo;
                g_cur_mjo->next = tmp_mjo_node;
                g_cur_mjo = g_cur_mjo->next;
                load_mjo_ftexts(mjo_name);
            }
        }
    }
    
    // find target chs text and write to g_textbuf
    void __stdcall find_mjo_chstext(size_t addr) 
    

    IAT hook 适配汉语字体

    lplf->lfCharSet改为0x86即可,字体改成simhei

    HFONT WINAPI CreateFontIndirectA_hook(LOGFONTA *lplf)
    {
        lplf->lfCharSet = GB2312_CHARSET;
        lplf->lfHeight+=2; // for showing '「 ', the default height is not enough
        strcpy(lplf->lfFaceName , "simhei");
        return CreateFontIndirectA(lplf);
    }
    
    void install_font_hook()
    {
        if(!iat_hook("Gdi32.dll", (PROC)CreateFontIndirectA, (PROC)CreateFontIndirectA_hook))
        {
            MessageBoxA(NULL, "CreateFontIndirectA iat hook failed!", "error", 0);
        }
    
        if(!iat_hook("User32.dll", (PROC)CreateWindowExA, (PROC)CreateWindowExA_hook))
        {
            MessageBoxA(NULL, "CreateWindowExA iat hook failed!", "error", 0);
        }
    }
    

    当然改完后读取gb2312也可能没法正常显示,因为游戏可能对字符进行限制。未处于sjis区间的字符可能会显示成方框,也可能会被当成单字节字符显示,造成接下来运行错误。

    这个游戏比较特殊,没有用cmp xx 81h等直接判断,而是用了charmap映射了当前字节数值的类型,与“是否能构成sjis字符”相关。bp TextOutA可以发现非sjis字符会被当成单字节字符。再稍微跟一下,可以看见其通过查表确定是否为sjis字符,0x4AE2E9为字符类型映射表,如下图所示。

    winterpolaris_tonextchar

    解决方法也很简单,直接用内联汇编来替换sub_47EE00,去除sjis范围限制。当然也可以去修改映射表,但是不确定是不是其他的函数也用这个映射表,改了后可能会出现问题。

    __declspec(naked) void is_twobyte() // cdecl
    {
        __asm
        {
            mov eax, [esp+0x4]
            movzx eax, al
            cmp eax, 0x80
            ja twobyte
            xor eax, eax
            ret
            twobyte:
            mov eax, 1
            ret
        }
    }
    

    最后就是处理一些小问题了,比如说有些字符没有显示全,可能是因为字体高度不够;菜单乱码等问题,可能对应的文本是通过其他函数显示的,或是菜单文本本身是在exe里面的,此处不再赘述。

    winterpolaris_textheight_before

    winterpolaris_textheight

    折腾了半天,现在我们的动态汉化终于成功运行了!完整代码详见我的github, winterpolaris_hook.c

    winterpolaris_dynamic_chs2

    0x6 后记与补充

    虽然难度不大,但是这篇教程写了也快一天才完成,之前搜集素材、编写程序、调试等断断续续地也用了将近一周。主要是想着如何叙述得容易理解,如何使得结构清晰有条理性。其实动态汉化更多的意义在于折腾,自己一步步地探索与改造的乐趣,就像是DIY的乐趣。下面再补充一些关于编译与调试的内容。

    Clang与Makefile编译

    因为windows下没有regex.h头文件,所以一开始我是用mingwgcc来编译的。有个问题是,无法链接msvc编译的detours.lib(很多符号找不到,报错),也不太清楚怎么用gcc编译detours。而且gcc貌似没法声明naked函数类型?

    于是就用clang了,因为-target i686-pc-windows-msvc可以兼容msvc的link, 同时语法上也接近gcc用起来会比较方便。但是这个模式就无法链接GNU的静态库了如libxxx.a。虽然强行把libregex.dll.a改名为regex.lib倒是也能识别,但是没法静态链接,会附加一大堆mingw的dll。makefile如下,里面会用到我以前写的一些文件,现在都已上传到GalgameReverse

    # use clang because of detours and naked asm
    CC:=clang
    # change this to your mingw32 dir
    MINGW_DIR:= D:/AppExtend/msys2/mingw32
    BUILD_DIR:=./build
    INCS:=-I./../../script -I./../../script/windows -I./../../thirdparty/include -I$(MINGW_DIR)/include
    LIBDIRS:=-L./../../thirdparty/lib32 -L$(MINGW_DIR)/lib
    LIBS:=-ldetours -luser32 -lgdi32 # -lregex change the name libregex.dll.a to regex.lib, but it need correspond dll
    CFLAGS:=-target i686-pc-windows-msvc -D _CRT_SECURE_NO_DEPRECATE -DNO_REGEX -D_DEBUG -g
    
    all: prepare winterpolaris_hook
    
    prepare:
    	if not [ -d $(BUILD_DIR) ];then mkdir $(BUILD_DIR);fi
    
    clean:
    	rm -rf $(BUILD_DIR)*
    
    $(BUILD_DIR)/binary_text.o: ./../../script/binary_text.c
    	$(CC) -c $^ -o [email protected]  $(INCS) $(LIBDIRS) $(LIBS) $(CFLAGS)
     
    $(BUILD_DIR)/win_hook.o: ./../../script/windows/win_hook.c
    	$(CC) -c $^ -o [email protected] -D _DETOURS $(INCS) $(LIBDIRS) $(LIBS) $(CFLAGS)
    
    $(BUILD_DIR)/winterpolaris_hook.o: winterpolaris_hook.c
    	$(CC) -c $^ -o [email protected] $(INCS) $(CFLAGS)
    
    winterpolaris_hook: $(addprefix $(BUILD_DIR)/, binary_text.o  winterpolaris_hook.o win_hook.o)
    	$(CC) $^ -o $(BUILD_DIR)/[email protected] $(INCS) $(LIBDIRS) $(LIBS) $(CFLAGS) -shared
    
    .PHONY: prepare all clean
    

    vscode调试

    clang编译的时候加入-g调试信息到dll中,直接用mklink符号链接把dll链接到exe所在的路径下,vscode里面launch.jsonlldb中program填写对应的exe。在C源代码下断点,F5启动exe即可调试。launch.json如下:

    {
        "version": "0.2.0",
        "configurations": [
            {
                "type": "lldb",
                "request": "launch",
                "name": "debug winter_poler_hook(lldb)",
                "program": "D:\\Tmp\\WinterPolaris\\Polaris_chs.exe",
                "args": [],
                "cwd": "D:\\Tmp\\WinterPolaris\\"
            },
        ]
    }
    

    不过vscode目前好像不支持内联汇编调试,那么就用x64dbg调试吧,可以读取到调试信息并显示源码行数。这里有个小技巧,我们可以打印出来Inlinehook的地址,然后再用x64dbg调试,方便定位。

    winterpolaris_x32dbg_inlineasm

yurisbbs by @devseed since 2020, powered by nodebb.