吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 12866|回复: 80
收起左侧

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

  [复制链接]
小木曾雪菜 发表于 2021-7-18 20:13
本帖最后由 小木曾雪菜 于 2022-2-3 23:18 编辑

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

by devseed, 本贴论坛和我的博客同时发布
上篇链接:Galgame汉化中的逆向(四) _IDA静态分析psv游戏
为了方便,本篇的测试用例和代码我同时上传到附件上了。

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 sub_440AB0@<eax>(int a1@<ebx>, int a2@<edi>, int a3@<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 $@  $(INCS) $(LIBDIRS) $(LIBS) $(CFLAGS)

$(BUILD_DIR)/win_hook.o: ./../../script/windows/win_hook.c
        $(CC) -c $^ -o $@ -D _DETOURS $(INCS) $(LIBDIRS) $(LIBS) $(CFLAGS)

$(BUILD_DIR)/winterpolaris_hook.o: winterpolaris_hook.c
        $(CC) -c $^ -o $@ $(INCS) $(CFLAGS)

winterpolaris_hook: $(addprefix $(BUILD_DIR)/, binary_text.o  winterpolaris_hook.o win_hook.o)
        $(CC) $^ -o $(BUILD_DIR)/$@.dll $(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

免费评分

参与人数 61吾爱币 +64 热心值 +56 收起 理由
IvanB + 1 + 1 我很赞同!
ReAllTh + 1 + 1 谢谢@Thanks!
迷茫的陈Sir + 1 + 1 我很赞同!
圆圆圆123 + 1 + 1 用心讨论,共获提升!
落花时节又逢君 + 1 + 1 用心讨论,共获提升!
showwindows + 1 + 1 谢谢@Thanks!
sH4N3 + 1 + 1 用心讨论,共获提升!
NikoBellic + 1 我很赞同!
gaosld + 1 + 1 热心回复!
Kermond + 1 + 1 谢谢@Thanks!
cold52whale + 1 我很赞同!
KYOU + 1 + 1 谢谢@Thanks!
OYyunshen + 1 牛,确认大佬无疑!
FJFJ + 1 + 1 我很赞同!
抱歉、 + 1 用心讨论,共获提升!
lyslxx + 1 + 1 我很赞同!
dqw_ + 1 膜拜大佬
zhaozhao1 + 1 + 1 用心讨论,共获提升!
t浅ky + 1 + 1 热心回复!
MagiaR + 1 + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
shourouzhou + 1 + 1 我很赞同!
zzzglory + 1 + 1 谢谢@Thanks!
qw4wer + 1 + 1 谢谢@Thanks!
chocolate2020 + 1 + 1 热心回复!
cryfly + 1 + 1 我玩Galgame时血在小头,大佬是血在大头…
skeleta + 1 用心讨论,共获提升!
nuanyy110 + 1 + 1 用心讨论,共获提升!
lookerJ + 1 热心回复!
AKaLYang + 1 热心回复!
Pengshanqian + 1 + 1 我很赞同!
qw11111 + 1 + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
Chiba + 1 + 1 我很赞同!
poipoly + 1 + 1 大佬
CainSakura + 1 + 1 谢谢@Thanks!
xiacan + 1 nice
冰棍好烫啊 + 2 + 1 热心回复!
colc + 1 谢谢@Thanks!
wj808505 + 1 + 1 我很赞同!
xmmyzht + 1 + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
fwlw + 1 + 1 我很赞同!
独行风云 + 1 + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
fengbolee + 2 + 1 欢迎分析讨论交流,吾爱破解论坛有你更精彩!
victos + 1 + 1 谢谢@Thanks!
yixi + 1 + 1 谢谢@Thanks!
iTruth + 2 + 1 谢谢@Thanks!
雾满拦江 + 2 + 1 崇拜大佬,
DancingLight + 1 + 1 热心回复!
闪的好快啊 + 1 强啊,确实是大佬
笙若 + 1 + 1 谢谢@Thanks!
fjzry + 1 + 1 我很赞同!
KylinYang + 1 + 1 我很赞同!
努力加载中 + 1 + 1 热心回复!
圣甲炫 + 1 + 1 欢迎分析讨论交流,吾爱破解论坛有你更精彩!
笨鸟不一定先飞 + 2 + 1 用心讨论,共获提升!
风绕柳絮轻敲雪 + 4 + 1 我很赞同!
圣光启示 + 1 + 1 热心回复!
OrkWard + 1 + 1 牛逼
大牙船长 + 1 + 1 谢谢@Thanks!
千城忆梦 + 2 + 1 谢谢@Thanks!
一团黄线 + 1 + 1 欢迎分析讨论交流,吾爱破解论坛有你更精彩!
Razuri + 1 + 1 给大佬跪了。

查看全部评分

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

 楼主| 小木曾雪菜 发表于 2021-7-22 15:14
yzhiyu 发表于 2021-7-22 14:03
收藏学习了,将来说不定能用到
另外上一期是不是应该是第五期啊,链接错了吗

因为当时先发的五再发的四,就按着时间顺序来了。因为第一时间搞完方根胶卷的中文文本后,顺便写完了教程,而四的内容还没有准备,就先发五了。
大牙船长 发表于 2021-7-18 20:57
厉害!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
陌上等花开 发表于 2021-7-18 21:53
一路无忧 发表于 2021-7-18 23:03
java的操作能用win10吗 有个emusaka软件
zhanglei0546 发表于 2021-7-19 09:17
大佬就是厉害
tabirs 发表于 2021-7-19 09:58

大佬就是厉害
2498206022 发表于 2021-7-19 11:14
这也太强了吧
lpkesia 发表于 2021-7-19 13:04
牛啊 这个题材。。
tanwen19932 发表于 2021-7-19 17:09
玩游戏都有不一样 我就只能看看图
shenhuen10 发表于 2021-7-19 18:59
原来汉化这么麻烦啊,头次知道
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - LCG - LSG ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2024-11-15 09:53

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表