by devseed, 本贴论坛和我的博客同时发布
上篇链接:Galgame汉化中的逆向(四) _IDA静态分析psv游戏
为了方便,本篇的测试用例和代码我同时上传到附件上了。
0x0 前言
之前我们谈论的基本上都是静态汉化。所谓静态汉化,即分析文件结构、二进制脚本opcode,然后进行静态封包等方法。与类似于静态编译的语言类似,在运行前数据类型等已经确定完成,程序运行时按照既定的逻辑执行,静态汉化显示的是我们提前准备好的汉化文本。大部分的主机游戏汉化都是静态汉化,因为权限等问题,主机几乎不可能动态调试(即使有,gdbserver等用起来也挺费劲,也可能有兼容性问题调试失败)。再加上在主机上hook也很麻烦,测试极不方便,所以大部分主机游戏汉化以静态汉化为主,有模拟器的可能会结合一些动态调试辅助分析(不过别指望模拟器的调试有多好用了...)。
静态汉化是基础,对于常见文件结构、二进制脚本、算法等有了一定了解后,我们才能更好地找到关键位置dump、文本注入点等,因此我之前的汉化教程都是以静态汉化为主。与静态汉化相对的是动态汉化,往往不需要进行复杂的文件分析和二进制脚本分析,通常也不用考虑封包问题。动态汉化中,文本显示是程序运行时动态注入和替换的,重点是找到:
目前关于动态汉化的分析帖相对来说比较少,下面我们就以Majirov3引擎为例,来谈谈如何进行动态分析、如何进行动态汉化、以及如何解决一些动态汉化中出现的问题。
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-8或sjis下没有有意义字符串,可以断定mjo是加密或压缩的
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
当然了,这个封包结构很简单,直接静态黑箱分析也完全能猜出来,上面只是为了演示一下动态分析的一些思路。分析封包文件结构不是必须的,但是可以帮助我们更好的找到解密文本的位置。之后,定位到fopen,scenario.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 | fread
// read scenerio mjo
char *__usercall sub_440AB0@(int a1@, int a2@, int a3@, 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
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) 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]全局变量中。下面为此函数返回处的反汇编代码:
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 | 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 |
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点的位置,原则上越接近原始位置(读取二进制文本的位置)越好 。直接搜索显示在屏幕上的文本,得到的搜索结果可能有多个,分别修改一下看看对游戏产生什么影响。同时也要兼顾能否找到当前文本在脚本中的偏移来进行定位,在查找到文本缓存的周围(如堆栈中的指针,寄存器,或者反汇编指令里引用的全局变量)来找找有没有标识当前文本在二进制脚本中位置的指针。下面的反汇编为本游戏的一些显示文本位置:
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 | 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 |
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 | 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 | 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编码查看。得到的内容如下:
____
/ _ | 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● ここが、どこなのかも分からなかった。
其实有时候如果我们实在找不到文本标识的偏移,也可以强行把游戏从从头到尾过一遍,把每句输出的文本提取出来。汉化的时候,再用hashmap或Longest 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了,操作如下:
[ol]
[/ol]
不过我们不用再自己解析函数开头处的机器码了,直接用微软的detours的Inline hook即可。细节上和上述可能有些区别,不过原理都是一样的。detours用法如下:
#include "detours.h"
int inline_hooks(PVOID pfnOlds[], PVOID pfnNews[])
{
int i=0;
DetourRestoreAfterWith();
DetourTransactionBegin();
DetourUpdateThread(GetCurrentThread());
for(i=0; pfnNews!=NULL ;i++)
DetourAttach(&pfnOlds, pfnNews);
DetourTransactionCommit();
return i;
}
LoadDLL
上述hook代码编译成的载体是DLL,我们还需要把此DLL注入目标到exe中,接管某些函数改变其功能。
有三种常用方法:
[ol]
[/ol]
代码如下,详见我的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语言函数的对接, 注意cdecl和stdcall,汇编调用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 %lx\n", i, (unsigned long)pfnOlds, (unsigned long)pfnNews);
}
inline_hooks(pfnOlds, pfnNews);
g_showtext = pfnOlds[0];
printf("After inline hooks\n");
for(int i=0;i %lx\n", i, (unsigned long)pfnOlds, (unsigned long)pfnNews);
}
}
查找对应中文文本
此处用双向链表数据结构来存储文件名与文本项索引,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为字符类型映射表,如下图所示。
解决方法也很简单,直接用内联汇编来替换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里面的,此处不再赘述。
折腾了半天,现在我们的动态汉化终于成功运行了!完整代码详见我的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。
# 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)/[email protected] $(INCS) $(LIBDIRS) $(LIBS) $(CFLAGS) -shared
.PHONY: prepare all clean
vscode调试
clang编译的时候加入-g调试信息到dll中,直接用mklink符号链接把dll链接到exe所在的路径下,vscode里面launch.jsonlldb中program填写对应的exe。在C源代码下断点,F5启动exe即可调试。launch.json如下:
{
"version": "0.2.0",
"configurations": [
{
"type": "lldb",
"request": "launch",
"name": "debug winter_poler_hook(lldb)",
"program": "D:\\Tmp\\WinterPolaris\\Polaris_chs.exe",
"args": [],
"cwd": "D:\\Tmp\\WinterPolaris\\"
},
]
}
不过vscode目前好像不支持内联汇编调试,那么就用x64dbg调试吧,可以读取到调试信息并显示源码行数。这里有个小技巧,我们可以打印出来Inlinehook的地址,然后再用x64dbg调试,方便定位。