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库函数,如下:

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-8sjis下没有有意义字符串,可以断定mjo是加密或压缩的

whiterpolaris_scenario_arc

whiterpolaris_scenario_arc_114dfb

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

当然了,这个封包结构很简单,直接静态黑箱分析也完全能猜出来,上面只是为了演示一下动态分析的一些思路。分析封包文件结构不是必须的,但是可以帮助我们更好的找到解密文本的位置。之后,定位到fopenscenario.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,返回值eaxmjo_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编码查看。得到的内容如下:

winterpolaris_frida_showtext

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● ここが、どこなのかも分からなかった。

其实有时候如果我们实在找不到文本标识的偏移,也可以强行把游戏从从头到尾过一遍,把每句输出的文本提取出来。汉化的时候,再用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

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了,操作如下:

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

不过我们不用再自己解析函数开头处的机器码了,直接用微软的detoursInline 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中,接管某些函数改变其功能。

有三种常用方法:

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

代码如下,详见我的githubinjectdll.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语言函数的对接, 注意cdeclstdcall,汇编调用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为字符类型映射表,如下图所示。

winterpolaris_tonextchar

解决方法也很简单,直接用内联汇编来替换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里面的,此处不再赘述。

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

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
# 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如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"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