相关文章推荐
面冷心慈的保温杯  ·  什么是匿名举报·  1 年前    · 
阳刚的消防车  ·  《白毛女》百度云网盘下载.1080P下载.国 ...·  2 年前    · 
近视的橙子  ·  飞天意面神教能入党吗-抖音·  2 年前    · 
兴奋的蘑菇  ·  《中国哲学简史》读后感- 者行孙1304 - 简书·  2 年前    · 
含蓄的保温杯  ·  德国那些音乐教育专业- 知乎·  2 年前    · 
小百科  ›  Galgame汉化中的逆向(六):动态汉化分析_以MAJIROv3引擎为例 ...
galgame汉化 文本分析 offset函数 char函数 dword
刚失恋的蚂蚁
11 月前
avatar
Articles
54
Tags
28
Categories
5

Schnee
Home
Archives
Tags
Categories
Comments
Expr
  • Image
  • Music
  • Project
  • NetDisk
WebApp
  • C3Cube (THREE, WEBGL)
  • CircleDanmaku (SDL)
  • GLPhongDemo (SDL,WEBGL)
  • Lifegame (OnscripterYuri)
  • Luasnow (OnscripterYuri)
About
YuriSizuku
Search
Schnee
Home
Archives
Tags
Categories
Comments
Expr
  • Image
  • Music
  • Project
  • NetDisk
WebApp
  • C3Cube (THREE, WEBGL)
  • CircleDanmaku (SDL)
  • GLPhongDemo (SDL,WEBGL)
  • Lifegame (OnscripterYuri)
  • Luasnow (OnscripterYuri)
About

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

Created 2021-07-18 20:01 | Reverse
| Word count: 8.1k | Reading time: 37min | Post View:

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

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

0x0 前言

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

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

  • 显示相应文本的函数
  • 区分字符串的标识符(一般与文件中的对应文本偏移相关)
  • 目前关于动态汉化的分析帖相对来说比较少,下面我们就以Majirov3引擎为例,来谈谈如何进行动态分析、如何进行动态汉化、以及如何解决一些动态汉化中出现的问题。

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

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

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    .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库函数进行文件读取。代码如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    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。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    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-8 或 sjis 下没有有意义字符串,可以断定 mjo 是加密或压缩的

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    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

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

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    143
    144
    145
    146
    147
    148
    149
    150
    151
    152
    153
    154
    155
    156
    157
    158
    159
    160
    161
    162
    163
    164
    165
    166
    167
    168
    169
    170
    171
    172
    173
    174
    175
    176
    177
    178
    179
    180
    181
    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讲解为主,此处就不再详细分析解密函数了。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    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,返回值 eax 为 mjo_struct 指针,同时储存在 [4DC350] 全局变量中。下面为此函数返回处的反汇编代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    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)) 解密函数,可以得出下面结论:

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

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    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点的位置,原则上 越接近原始位置(读取二进制文本的位置)越好 。直接搜索显示在屏幕上的文本,得到的搜索结果可能有多个,分别修改一下看看对游戏产生什么影响。同时也要兼顾能否找到 当前文本在脚本中的偏移 来进行定位,在查找到文本缓存的周围(如堆栈中的指针,寄存器,或者反汇编指令里引用的全局变量)来找找有没有标识当前文本在二进制脚本中位置的指针。下面的反汇编为本游戏的一些显示文本位置:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    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,总结如下:

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

    写个脚本hook验证一下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    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编码查看。得到的内容如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
         ____
    / _ | 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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    ○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● ここが、どこなのかも分からなかった。

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

    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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    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 了,操作如下:

  • 将hook点的前6个字节替换成 FF 24 xxxxxxxx ,或前5个字节替换为 E9 xxxxxxxx ,对应绝对地址和相对地址跳转, xxxxxxxx 为hook我们编写的函数地址。
  • 将原函数开头处被破坏的完整指令搬到 Trampoline ( VirtualAlloc 的一段可执行区域),后面跟一条 jmp 指令,跳转到原函数 jmp 机器码(5位或6位)替换后的下一条完整指令
  • 之后可以通过 jmp Trampoline 来返回原函数
  • 不过我们不用再自己解析函数开头处的机器码了,直接用微软的 detours 的 Inline hook 即可。细节上和上述可能有些区别,不过原理都是一样的。 detours 用法如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    #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中,接管某些函数改变其功能。

    有三种常用方法:

  • 在exe的导入表中静态添加DLL
  • code cave 进行 LoadLibrayA DLL
  • VirtualAllocEx 、 WriteProcessMemory 和 CreateRemoteThread 来动态注入DLL。
  • 代码如下,详见我的github injectdll.py , win_hook,c

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    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)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    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语言函数的对接, 注意 cdecl 和 stdcall ,汇编调用C函数要自己保存寄存器

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

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

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    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 格式。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    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 。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    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 为字符类型映射表,如下图所示。

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    __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里面的,此处不再赘述。

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

    0x6 后记与补充

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

    Clang与Makefile编译

    因为windows下没有 regex.h 头文件,所以一开始我是用 mingw 的 gcc 来编译的。有个问题是,无法链接 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 。

     
    推荐文章
    面冷心慈的保温杯  ·  什么是匿名举报
    1 年前
    阳刚的消防车  ·  《白毛女》百度云网盘下载.1080P下载.国语无字.(1950)_人人电影网 ...
    2 年前
    近视的橙子  ·  飞天意面神教能入党吗-抖音
    2 年前
    兴奋的蘑菇  ·  《中国哲学简史》读后感- 者行孙1304 - 简书
    2 年前
    含蓄的保温杯  ·  德国那些音乐教育专业- 知乎
    2 年前
    今天看啥   ·   Py中国   ·   codingpro   ·   小百科   ·   link之家   ·   卧龙AI搜索
    删除内容请联系邮箱 2879853325@qq.com
    小百科 - 百科知识指南
    © 2024 ~ 沪ICP备11025650号