记一次Virtools NLP文件解析过程

Virtools是Dassault的一个已经弃坑的3D游戏引擎软件。因为我一直研究Ballance,而Ballance又是由Virtools作成的,因此研究Virtools也是我研究课题中的一部分。每一个Virtools版本都会有其对于的创作环境程序。而在其目录下会有一个名为LanguagePacks的文件夹,里面放着一个叫english.nlp的文件。从字面上理解,Virtools其实具有多语言支持。但是显然目前只能支持英文,没有其他类型的语言包。由于我本人对使用英文软件没有什么问题,尤其是Ballance制图一直以来使用的都是英文Virtools,我没有需要中文或者其他语言Virtools的需求。以及这个nlp文件不是human readable的文件,也不好轻易做多语言,因此我也没去管它。

事情的契机来源于另一件事。HCF在Ballance吧发了个帖子,表示对Virtools的汉化遇到了困难。我本身对中文Virtools毫无好感,因此便快速略读了一番内容,然后离开。不料几日之后的回复让我又回到这里。用户名是两只无聊的小猫的用户和HCF之间的对话让我实在没有办法去接受。我没有办法接受没有严格的,科学的实验步骤就进行探究的行为。我无法接受这些,因此我昨日开始了对NLP文件的研究。

虽然我无法接受HCF的对话,然而在HCF的对话中,我还是获得了一些有用的内容。首先,我之前并非完全没研究过NLP文件,我上网搜过,也搜到了NirLauncher,在我仔细研究了NirLauncher网站的软件功能描述后,我就可以断定NirLauncher不能打开这个文件。而帖子中丝毫没有对此的研判,就做出建议,这是不可接受的。一昧的只接受网络上的消息而不经过自己的思考就给出建议是不负责的。其次就是空文件的报错,一个是LMResource.hNeMOLMMake.exe。我用Everything跑遍了Virtools SDK,没有找到匹配上述两段文字的内容,这就意味着我不能从Virtools SDK中轻易挖掘出这个文件的格式了。至于后者,那个exe文件,既然HCF已经发了出来,他肯定已经搜过了,况且Virtools那么旧的软件了,这种东西早就应该丢了,我个人是那么觉得的,尤其是看到NeMo这个词的时候,要知道NeMo是Virtools的旧称,在1.0到2.0版本的时候改成了Virtools。

下面我就请来了大杀器,IDA,加载了Virtools 3.5的devr.exe然后准备从反汇编着手了。首先既然是语言包,那它肯定要从LanguagePacks下读NLP文件,我先全局搜索了一下LanguagePacks,直接就找到了,太让人惊喜,我原本还打算搜其它的诸如nlpLMResource.hNeMOLMMake.exe什么的。找到了自然不必说,直接找引用,也非常顺利,只有一个引用,之后我进入了函数sub_4677C0,开了反编译之后开始看代码,花了一些时间感受到了这个可能是个读取全文件夹的NLP文件然后搞什么一些其他操作,忽略一些杂项之后我来到了最后。一番分析后,感觉sub_465B40函数是负责读文件,然后sub_465D90函数是解析文件,我当时期望最好读完就是个文本文件,不要给我上二进制,这样我就不用看解析了,因为解析那个函数里面有800多行而且非常乱。相比较而言,读文件的sub_465B40就很简单,点进去反编译了一下。边看边改一些变量的名字方便分析。

上来映入眼帘的就是CKComputeDataCRCCKUnPackData这两个函数。CKUnPackData是一个CK2写的zlib轮子,可以直接用zlib的函数代替执行。CKComputeDataCRC也是个轮子函数,用zlib的adler32代替就行。分析的思路就不怎么说了,我也是比较凌乱分析搞出来的,总之就是先从认识的函数着手然后开始分析,然后结合Virtools文档摸清楚参数含义,进而进一步扩展分析。

expectedDecompressedLength = -1 - (dword_6EBA28 ^ *(_DWORD *)&compressBuffer[compressedBufferLength - 8]);
decompressedBuffer = CKUnPackData(expectedDecompressedLength, compressBuffer, compressedBufferLength - 8);

首先从CKUnPackData开始分析。解压部分的核心代码就像上面写的。此前我有过一小段NLP研究经历,我曾把NLP文件塞到我的DecompSpiritTrail工程的zlib解压模块里,有反应,但是塞到最后它始终出不来结果,卡死在最后一段。之所以塞zlib函数是因为我猜测这NLP文件也是用了什么压缩技术搞得不可读。说来比较奇怪,之前我用Notepad++开NLP文件后,显示乱码,感觉它的乱码显示和CMO的乱码有那么几分相似(CMO也是zlib压缩的)。然后冥冥之中觉得NLP也应该是zlib压缩的就试了试。没想到真的有反应。之前还用Everything搜过电脑里的NLP文件,想看看能不能从已安装软件中得到什么线索,万一NLP是个什么不知名的通用翻译程序创建的呢。但是搜索的结果表明NLP很可能是各用各家的标准这样一种情况。在那时候,我对NLP的研究就截止了,因为我不知道为什么zlib卡住了。

而在这里,很明显看到有个长度的截断:不送最后8位字符。到这里就基本不用分析了,甚至连测试都不用做就知道怎么拆出文件了,但是由于制作语言包还需要复原文件,所以还需要知道这8位做了什么。由于CKUnPackData的第一个参数,expectedDecompressedLength指示着最终解压序列的长度,而溯源可以发现文件倒数第二个双字是表示最终解压序列的长度,但是要处理一下才是。找了下dword_6EBA28,是个定值0x0F956A82C对着反汇编代码跟着一通操作后,我读到了正确的数值。不过有意思的是,在这里我一开始一位后2个双字都是长度,一个UInt64,然后迷迷糊糊算错了,直到最后写回写部分才发现不是。

if ( CKComputeDataCRC(
        *(char **)(tsFileStorageOffset + filebody + 8),
        *(_DWORD *)(tsFileStorageOffset + filebody + 12) - 8,
        0)
    + 1072 != *(_DWORD *)(*(_DWORD *)(fileproperty[3] + tsFileStorageOffset + 12)
                        + *(_DWORD *)(tsFileStorageOffset + fileproperty[3] + 8)
                        - 4) )
return 0;

下面是CKComputeDataCRC的部分,这里就是将文件最后一个双字和计算出的CRC32+1072的数值进行比较,所以最后一个双字存储的就是压缩后文件的CRC32数值+1072。此外,通过查找CKComputeDataCRC函数定义知道了这个CRC32计算初始值要用0而不是通常的0xffffffff

do
{
    decompressedBuffer[result] ^= *(_BYTE *)((result & 0x7F) + off_6EBAA8);
    ++result;
}
while ( result < expectedDecompressedLength );

这样解压出来的文件仍然是不可读,我一度以为我解错了或者文件本身就是二进制的,直到我看到了上面的这个部分。这是一个加解密部分。off_6EBAA8是使用dd offset产生的一个对dword_6EBA28的偏移量,在反复研判之后(我比较菜,中间走了很多弯路),在dword_6EBA28对应部分找到了一个大小是0x80的表,这与result & 0x7F契合完美。表如下。比较尴尬的是对于这个打表的理解我理解了几个小时才理解出来,之中还问了反汇编大佬61,我还是太菜了。而之前的用于解析真正的解压后序列长度的dword_6EBA28,是这个表的前4字节组成的双字。

static readonly byte[] xorArray = new byte[0x80] {
    0x2C, 0xA8, 0x56, 0xF9, 0xBD, 0xA6, 0x8D, 0x15, 0x25, 0x38, 0x1A, 0xD4, 0x65, 0x58, 0x28, 0x37, 
    0xFA, 0x6B, 0xB5, 0xA1, 0x2C, 0x96, 0x13, 0xA2, 0xAB, 0x4F, 0xC5, 0xA1, 0x3E, 0xA7, 0x91, 0x8D, 
    0x2C, 0xDF, 0x78, 0x6D, 0x3C, 0xFC, 0x92, 0x1F, 0x1A, 0x62, 0xA7, 0x9C, 0x92, 0x29, 0x44, 0x6D, 
    0x3D, 0xA9, 0x2B, 0xE1, 0x91, 0xAD, 0x49, 0x3C, 0xE2, 0x33, 0xD2, 0x1A, 0x55, 0x92, 0xE7, 0x95, 
    0x8C, 0xDA, 0xD2, 0xCD, 0xA2, 0xCF, 0x92, 0x9A, 0xE1, 0xF9, 0x3A, 0x26, 0xFA, 0xC4, 0xA9, 0x23, 
    0xA9, 0x4D, 0x1A, 0x2C, 0x3C, 0x2A, 0xAC, 0x62, 0xA3, 0x92, 0xAC, 0x1F, 0x3E, 0xA6, 0xC9, 0xC8, 
    0x63, 0xCA, 0x52, 0xF9, 0xFB, 0x3A, 0x9C, 0x2A, 0xB2, 0x1A, 0x8D, 0x9A, 0x8C, 0x2A, 0x9C, 0x32, 
    0xAA, 0xC3, 0xA2, 0x97, 0x34, 0x92, 0xFA, 0x71, 0xBE, 0x3F, 0xAC, 0x28, 0x22, 0x9F, 0xAC, 0xE8
};

很明显,它对获得的数据进行了一个不是很高明的移位XOR解密。带入代码后,一个human readable的文本出现,其中甚至还有注释。这也就省去了我考虑二进制文件的时间。之后的回写也是轻松搞定,完成了NLP文件的分析。

最终可用的代码我放到了我的gist里