Galgame汉化中的逆向(六):动态汉化分析_以MAJIROv3引擎为例
Galgame汉化中的逆向(六):动态汉化分析_以MAJIROv3引擎为例
0x0 前言
静态汉化是基础,对于常见文件结构、二进制脚本、算法等有了一定了解后,我们才能更好地找到关键位置dump、文本注入点等,因此我之前的汉化教程都是以静态汉化为主。与静态汉化相对的是动态汉化,往往不需要进行复杂的文件分析和二进制脚本分析,通常也不用考虑封包问题。动态汉化中,文本显示是程序运行时动态注入和替换的,重点是找到:
目前关于动态汉化的分析帖相对来说比较少,下面我们就以Majirov3引擎为例,来谈谈如何进行动态分析、如何进行动态汉化、以及如何解决一些动态汉化中出现的问题。
0x1 动态hook定位解密函数与分析文件结构
但是这个方法有个问题,解密文本的位置可能是malloc动态生成的缓冲区,重启调试器后位置会改变,导致断点失效。这时候我们可以考虑hook文件访问的API,如
fopen
,
CreateFile
等,来顺藤摸瓜找到读取封包和解密文本的位置。有可能没有动态链接
msvcrt.dll
,而是静态链接到exe里了,导致导入表没有此函数。一般ida可以识别出这些静态链接的C库函数,如下:
1 |
.text:00488F86 ; FILE *__cdecl fopen(const char *FileName, const char *Mode) |
之后我们可以对这些函数进行hook,此游戏用的都是c库函数进行文件读取。代码如下:
1 |
var g_base = 0x400000; |
之后我们可以查看日志,在进入章节的时候,看看是哪些函数调用了文件API。
1 |
0x47a51f fopen scenario.arc fp=0x4ca198 // first test the file size |
fread
的读取数据的大小可能和二进制文件的结构相关,比如说第一个
fread
先在
scenario.arc
文件开头读取了0x1c大小,我们可以推测文件头的大小是0x1c。用同样的方法,可以顺便把封包结构分析出来了,包括封包内的每个子项(
mjo
)数据结构。下图为
scenario.arc
在开头和
0x114dfb
位置的内容,观察发现在
utf-8
或
sjis
下没有有意义字符串,可以断定
mjo
是加密或压缩的
majiroV3封包文件结构总结如下:
1 |
scenario.arc, header size: 1C |
当然了,这个封包结构很简单,直接静态黑箱分析也完全能猜出来,上面只是为了演示一下动态分析的一些思路。分析封包文件结构不是必须的,但是可以帮助我们更好的找到解密文本的位置。之后,定位到
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 |
0047A537 | 6A 01 | push 1 | |
0x2 dump解密二进制脚本
1 |
char __cdecl sub_478E70(__m128i *buf, unsigned int size) |
关于具体的dump点,可以在
sub_440AB0
的末尾进行hook,返回值
eax
为
mjo_struct
指针,同时储存在
[4DC350]
全局变量中。下面为此函数返回处的反汇编代码:
1 |
sub_440AB0 |
根据
sub_440AB0
反汇编伪代码(见上节)中的
sub_478E70(*((__m128i **)context + 0x29), *((_DWORD *)context + 0x24))
解密函数,可以得出下面结论:
1 |
[eax] mjo name , [4DC350] // at 00440ED4 |
有了这些信息,我们可以写dump解密文本函数了,如下:
1 |
function dump_mjo(mjo_name, dump_dir="./dump/") // to dump decrypted mjo |
dump完后,查看一下,二进制脚本已经解密。至于提取剧本,简单观察大概是这样的结构
40 08 [size 2] text 00
,匹配这种结构即可,当然也可以直接检测sjis编码提取,详见我写的
binary_text.py
。
0x3 寻找文本显示位置
动态汉化的好处是我们不用去费半天劲逆向封包算法、不用再去分析二进制指令opcode。
但是同样动态汉化也有一些问题:
因此,选择文件hook点的位置,原则上 越接近原始位置(读取二进制文本的位置)越好 。直接搜索显示在屏幕上的文本,得到的搜索结果可能有多个,分别修改一下看看对游戏产生什么影响。同时也要兼顾能否找到 当前文本在脚本中的偏移 来进行定位,在查找到文本缓存的周围(如堆栈中的指针,寄存器,或者反汇编指令里引用的全局变量)来找找有没有标识当前文本在二进制脚本中位置的指针。下面的反汇编为本游戏的一些显示文本位置:
1 |
a. showtext_screen |
根据测试
a. showtext_screen
这个位置最适合作为动态hook替换文本的位置,显示内容最接近实际显示的,而且修改后的字符串也会被记录到游戏backlog里,可供回看文本。其他的hook点有些会调用多次、有些文本不全、有些会显示过多字符(如人名,这些会作为语音标识,但不会显示在对话中)。对于显示函数的hook,总结如下:
1 |
[52CCE4] current text,貌似没有字节数量什么的,直接替换即可// at 00442820 |
写个脚本hook验证一下:
1 |
function hook_showtext() // for investigating the text structure(offset and content) and substitude text
|
frida -l winterpolaris_hook.js -f Polaris_chs.exe >1.txt
将脚本注入游戏,由于控制台无法显示sjis字符,因此将输出内容重定向到文件中,然后用sjis编码查看。得到的内容如下:
1 |
____ |
对照我们用
binary_text.py
提取的文本, 偏移(当前位置指针-解密缓冲区基址)正好是文件中的偏移,至此这个游戏动态汉化的理论研究已经完成,之后就是用c和内联汇编写程序实践了。下面是我们用于翻译的文本格式,白点列用于原文,黑点列用于译文,每行是
●|num|addr|size●
的索引格式,详见
binary_text.h
1 |
○00001|000074|012○ −東京 1923− |
其实有时候如果我们实在找不到文本标识的偏移,也可以强行把游戏从从头到尾过一遍,把每句输出的文本提取出来。汉化的时候,再用
hashmap
或
Longest Common Subsequence
dp计算当前文本与文本数据库中的相似度,选取相似度最高的匹配用于替换。
0x4 IAT hook与Inline hook, LoadDll
IAT hook
即把相应函数的导入表的地址(
FirstThunk
)替换成我们的函数,实现hook。关于IAT结构和导入表相关内容,可以参考我之前写的文章
SimpleDpack
。下面是IAT hook的代码,兼容64位,详见我的github,
win_hook,c
1 |
BOOL iat_hook(LPCSTR targetDllName, PROC pfnOrg, PROC pfnNew) |
Inline hook
IAThook
只适用于动态链接外部DLL的函数,对于exe内部的函数,就需要
Inline hook
了,操作如下:
FF 24 xxxxxxxx
,或前5个字节替换为
E9 xxxxxxxx
,对应绝对地址和相对地址跳转,
xxxxxxxx
为hook我们编写的函数地址。
Trampoline
(
VirtualAlloc
的一段可执行区域),后面跟一条
jmp
指令,跳转到原函数
jmp
机器码(5位或6位)替换后的下一条完整指令
jmp Trampoline
来返回原函数
不过我们不用再自己解析函数开头处的机器码了,直接用微软的
detours
的
Inline hook
即可。细节上和上述可能有些区别,不过原理都是一样的。
detours
用法如下:
1 |
|
LoadDLL
上述hook代码编译成的载体是DLL,我们还需要把此DLL注入目标到exe中,接管某些函数改变其功能。
有三种常用方法:
code cave
进行
LoadLibrayA
DLL
VirtualAllocEx
、
WriteProcessMemory
和
CreateRemoteThread
来动态注入DLL。
代码如下,详见我的github injectdll.py , win_hook,c
1 |
import lief |
1 |
BOOL inject_dll(HANDLE hProcess, LPCSTR dllname) |
0x5 内联汇编与C编写动态汉化程序
到此,主要问题我们都搞清楚了,现在可以愉快地编写动态汉化程序了。动态汉化程序主要包括下面几个部分:
Inlinehook
处汇编环境与C语言函数的对接,
注意
cdecl
和
stdcall
,汇编调用C函数要自己保存寄存器
维护日文文本与汉化文本的对应关系,文本偏移的定位等数据。并且用二分法等算法来查找替换文本等。
CreateFontIndirectA
,改变charset)
文本显示Inline hook
1 |
void* g_base = (void*)0x400000; // app base addr |
查找对应中文文本
此处用双向链表数据结构来存储文件名与文本项索引,
g_mjos
全局变量来指向索引链表,
g_cur_mjo
指向当前文本索引位置。当游戏加载脚本时会查询当前链表中是否已经加载过,可以避免重复加载造成的内存泄露。
PFTEXTS
数据结构详见我的通用汉化文本格式,
binary_text.h
。
由于我们用的是日文和中文对照文本,因此文件用的
utf-8
格式存储,动态替换汉化文本要转换为
gb2312
格式。
1 |
typedef struct _MJO_NODE MJO_NODE, *PMJO_NODE; |
IAT hook 适配汉语字体
lplf->lfCharSet
改为0x86即可,字体改成
simhei
。
1 |
HFONT WINAPI CreateFontIndirectA_hook(LOGFONTA *lplf) |
当然改完后读取gb2312也可能没法正常显示,因为游戏可能对字符进行限制。未处于
sjis
区间的字符可能会显示成方框,也可能会
被当成单字节字符显示
,造成接下来运行错误。
这个游戏比较特殊,没有用
cmp xx 81h
等直接判断,而是用了
charmap
映射了当前字节数值的类型,与“是否能构成
sjis
字符”相关。
bp TextOutA
可以发现非
sjis
字符会被当成单字节字符。再稍微跟一下,可以看见其通过查表确定是否为
sjis
字符,
0x4AE2E9
为字符类型映射表,如下图所示。
解决方法也很简单,直接用内联汇编来替换
sub_47EE00
,去除
sjis
范围限制。当然也可以去修改映射表,但是不确定是不是其他的函数也用这个映射表,改了后可能会出现问题。
1 |
__declspec(naked) void is_twobyte() // cdecl |
最后就是处理一些小问题了,比如说有些字符没有显示全,可能是因为字体高度不够;菜单乱码等问题,可能对应的文本是通过其他函数显示的,或是菜单文本本身是在exe里面的,此处不再赘述。
折腾了半天,现在我们的动态汉化终于成功运行了!完整代码详见我的github, winterpolaris_hook.c 。
0x6 后记与补充
Clang与Makefile编译
于是就用
clang
了,因为
-target i686-pc-windows-msvc
可以兼容
msvc
的link, 同时语法上也接近
gcc
用起来会比较方便。但是这个模式就无法链接GNU的静态库了如
libxxx.a
。虽然强行把
libregex.dll.a
改名为
regex.lib
倒是也能识别,但是没法静态链接,会附加一大堆
mingw
的dll。
makefile
如下,里面会用到我以前写的一些文件,现在都已上传到
GalgameReverse
。