虚拟文件系统的实现思路

发布时间 2023-09-29 16:36:05作者: Dir-A

虚拟文件系统的实现思路

VFS (Virtual File System) (虚拟文件系统)
这里讨论的VFS,是区别于系统中的VFS,更多的是指代自己实现的小型简易的文件系统。
像是常见的游戏封包,也可以作为一种VFS的数据结构部分。
全部情况都基于Windows平台进行讨论。

VFS 的架构概念

接口部分

需要把VFS应用在何处,就决定了接口的设计。

接口的本质问题,就是获取需要替换或读取的文件信息,并把处理好后的信息正确返回,同时响应读写操作。

比如需要用在任意程序上,那么接口部分应该针对系统API进行设计
如果单纯是用在某个游戏的引擎上,那么接口部分应该根据这个引擎的文件读取接口来设计
又或者,只想用在自己正常的项目内部,那么就可以舍弃掉一些针对系统API的设计

当然你也可以先定义公共的接口,在此基础上,为不同使用场景具体优化。

访问部分

这部分我们已经获得需要操作的文件信息,比如要打开某个文件,要读取某个文件,要移动文件指针。

这部分的目标就是正确的访问VFS自己维护的封包中的数据,和响应接口部分需要的操作。

首先就是应该正确的进行查找以确定该文件VFS中确实有,或者传入一个标识,要能确定该标识确实是VFS产生的并且在内部维护的,而不是系统的。为了保证标识的唯一性,应该采取何种方法。

比如对路径的处理,对文件名的处理,对文件指针的处理,对文件句柄的处理

像是路径有反斜杠,斜杠,相对路径,绝对路径的问题。文件名有大小写,匹配速度等问题,文件指针和句柄有是否与正常的文件读写冲突的问题,和文件关闭的时候处理。

接着就是读取的问题,这里我们就不讨论写入问题了,一般这种VFS没这样的需求。

这的复杂度,主要和你想实现的功能有关,比如有加密,我们应该在写入目标buffer之前正确的解密,压缩文件也是类似的要正确的解压,当然也需要考虑性能问题,还有小文件的优化,缓冲区之类的问题。

结构部分

结构这块实际上就和游戏的封包差不多了,当然这个结构也包括运行时的结构和VFS整个文件数据在硬盘上的结构,怎样去设计一个灵活又高效的结构,也是一个比较难的问题。

对于硬盘上的结构:

像是采用传统的文件头+索引头+数据段,这种如果要追加数据是不怎么方便的。

或者可以考虑把文件头和索引头放在末尾,即 数据段 + 索引头 + 文件头,这样追加文件只需要修改索引头和文件头即可,不需要整个文件都重写。

也有采用块结构的,类似PNG图片那种。

还有内部路径的问题,是保存每个文件的完整路径,还是分文件夹?

文件名的问题,是Hash,是定长,还是变长?

对于运行时的结构:

这部分主要是关乎到整个文件系统的运行效率和使用的方便与否。

VFS 目的和作用

很多的汉化补丁用到VFS,有些是自己编写的针对具体引擎的,有些则是一些商业软件通用的VFS,正如其名一样,VFS的目的是构建一个自己的文件系统来让游戏或程序读取文件。

一个经典的例子是,我们希望一个补丁,可以叠加在原版的游戏上,而不是影响原版游戏的正常运行,也就是希望汉化和日文版都可以正常运行,这时候可能有些文件需要替换,但是文件名重复了,大可以修改程序里的文件名,但是有了VFS,我们可以把文件封装到VFS的封包中,实现相同文件名的替换,从而实现补丁的共存。

当然如果数据封装到VFS自己的封包中,也就可以实现一些加密,压缩等功能。

还有个例子就是,很多游戏引擎都支持免封包功能,但是免封包带来的问题就是文件可能会非常多,或者有些同名的文件会替换掉原始的文件,如果采用VFS,就可以把全部零散的文件,打包到一起,从而避免了文件散落一地,频繁调用IO接口,影响性能,文件名重复等问题。

当然,如果你并不在乎文件数量和加密的问题,大可以把路径重定向,依然可以解决问题。

VFS的目的更多的是为了加密和打包而存在。

那么顺着这个话题继续往下。

游戏读取封包中的数据算不算一种VFS呢?

VFS 接口核心思路

在Windows下,打开文件是调用CreateFile这个系统API,即使是std::fstream,fopen,也只是对CreateFile的封装,这里揭露了一个事实,即很多标准库的函数,其实都是对系统API的封装。因为有一些操作,并不是单纯从算法上实现,而是需要依赖系统所提供的功能,比如硬件通信。

那么这里我们就需要回顾一下,文件的操作。
由于库函数对文件的操作也绕不开系统API,这里就不讨论,其实是大同小异的。

读取一个文件首先需要打开文件,就是调用CreateFile,向文件写入数据也一样,CreateFile的返回值如果成功,则是一个句柄,该句柄只是个标识,用于唯一标识这个文件,并用于其它文件操作函数的参数。在C语言下和文件指针类似,但是CreateFile返回的是一个内核对象的标识,句柄并不具备实际意义,只是个编号而已。如果这个时候我想读取文件,那么我应该给ReadFile指明是读取哪个文件,这个指明哪一个文件就是用句柄,WriteFile,GetFileSize,SetFilePointer,都需要文件句柄,这很好理解,不然你怎么知道你要操作的是哪个文件。从这我们就不难看出,操作文件的核心,就是在CrateFile之后返回的那个句柄。

所以核心的思路就是抓住句柄,处理好句柄,确保非VFS的句柄正确被传递给系统,确保VFS内部维护的句柄被正确处理,正确释放。

VFS 接口实现方向

自己开发这样一个简单的VFS是可行的

我们实现VFS的目的本质上就是去管理文件,更一般的,其实只是替换文件而已。

Hook 系统API

我们只需要在游戏打开文件的过程中,对需要的替换的文件进行筛选过滤,并接管其对该文件的操作,由于正常的程序,操作文件都会经过系统的API,所以我们只需要Hook系统的API就可以达到控制游戏的文件读写操作。

如果要通用性的话,无非就是Hook相关的API,像是CreateFile,WriteFile, ReadFile,CloseHandle,GetFileSize,GetFileAttributes,等,这些都是库函数最终要经过的API,换句话说,库函数也只是不过是对这些API的封装,再说的明白点,一般情况下Windows下操作文件都和这些API相关,不管你是何种程序。那么如果还需要再深入一些的话,事实上我们应该Hook的是从用户层到内核层的那个函数,像是NtCreateFile,说简单一点,其实CreateFile也只不过是对别的函数的封装,只不过在用户层上,我们最多就到NtCreateFile了,因为这个函数马上就调用了系统call进入了内核。一般程序是没有内核的范围权限的,正常开发也不需要直接操作内核。其它文件操作函数也是类似的,你只要Hook到进入内核层之前的部分,就可以把所有在用户层封装这个函数的地方都截获,当然,是否要这样处理,取决于你的VFS要做到何种功能。

Hook 程序内部接口

如果不想Hook系统的API,也可以选择Hook游戏内部读取文件的相关接口,因为游戏引擎一般不会每次打开文件都去CreateFile,fopen,fstream,而是内部封包好一个公共的接口来使用,那么这个接口和CreateFile这些又有什么差异呢?也只不过是多了些自己的功能和处理罢了。

正常接入项目

还有种情况是你想用在自己已有的项目上,那么依据你自己的程序逻辑正常接入就好了。

VFS 数据结构

没啥好写的,各有差异,具体可以参考那些游戏的封包结构,各种各样的都有。

内部结构,看你自己怎么处理,方法各异。

VFS 运行流程

举个例子,CreateFile的目的是打开一个文件,就像是fopen一样,获得一个对该文件对象的标识(CreateFile里叫文件句柄,fopen则是文件指针),后需要操作这个文件都需要这个标识,如果CreateFile一个不存在的问,自然是没办法返回这个标识的,但是我们可以Hook,CreateFile,发现它打开的文件虽然真正意义上的磁盘上并不存在该文件,但是我们可以假装返回一个我们自己内部维护的标识,让它以为成功了,然后ReadFile的时候它会把这个标识传进来,因为需要指定读取的是那一个已经打开的文件内容,Hook ReadFile,这时候我就可以捕获这个我们自己维护的标识,从而,不让ReadFile去操作系统里找这个根本就不实际存在的标识,然后我们可以自己在内部打开我们的自己的封包,或者别的数据结构之类的,找到这个具体的文件文件,并把数据读取到ReadFile传进来的Buffer里面。对于游戏程序而言,这就是一次正常的调用系统API,根本不会察觉到有什么不同。当然,如果不是为了通用性,其实并不用Hook CreateFile这些API,因为这个程序所有的打开文件操作都要经过这些API,我们没必要从那没多次调用里一个个字符串去对比,更一般的,我们会直接Hook游戏内部打开文件的相关函数,操作相关内存分配函数,和结构来达到替换文件的目的。

VFS 现成软件

那么有没有通用的VFS呢?显然是有的,比如:MoleBox,Enigma Virtual Box,Enigma,VMP内置的文件系统,Themida的XBundler。其中前三种比较流行,最流行的是Enigma Virtual Box,我们简称为EVB,其实EVB是Enigma这个加壳软件的一个子程序,独立出来的免费版。Enigma和VMP,Themida都是加壳软件,VFS只不过其自带的一个功能而已。只有 MoleBox和 EVB 才是独立的VFS软件。这些软件的作用就是修改EXE,并Hook系统API,从而达到文件操作重新定向到其自己内部的一个VFS上,说白了,就是他们自己会实现一个封包,里面也有文件名大小等信息,打开文件的时候就在封包里找有没对应的文件,有的话就替换,没有的话就让调用正常跑到系统里去。

这里的话,可以说个点,Enigma加载VFS的地方特别的早,导致很多Enigma的加壳验证软件,还在验证阶段就可以通过调用CreateFile来读取VFS的内部文件了,这也就导致了很多加壳软件的内部数据还没过验证就被提取走了,一些采用Enigma来验证的收费汉化组,就存在这类的问题,被大量破解,当然如果一个程序在某个机器上跑起来了,那么VFS其实是可以被随意提取的,因为VFS的本质就是给程序来读写文件的,只不过是有没特殊校验之类的问题。

VFS 文件提取

了解完了上述的知识点,我们就可以知道,当VFS挂到EXE上的时候,我们其实并不需要在意它,当我们成功打开文件,该怎么操作,就怎么操作。

而我们读取它的方式也是如此简单,只需要知道文件名就行了,就像是这个文件存在磁盘上一样操作。

获取文件名

所以说要提取一个VFS里的文件,只需要一个文件就行了,我们如何获取这个文件名呢?

其实有三种方法,

第一种是调用系统的API进去文件名的遍历,如果这个虚拟文件系统有Hook相关的API,我们就可以从中获得文件名,在遍历文件的时候,最好先把原目录下的文件尽量移除,这样我们才能断定出哪些文件是在VFS里的。还有种VFS会有特殊的保护,即大多数软件并没有遍历文件的需求,或者说,即使遍历了,也不影响虚拟文件系统,也就是说,你从文件遍历的API上没办法获得VFS里的文件名。但是你直接用对应的文件打开又可以正常被虚拟文件系统拦截到,这里我猜测是有两种实现,第一种可能是其内部存储文件名用了hash的方式,即你输入正确的文件名,然后计算hash然后与内部的hash进行对比,这样就避免了存储文件名,还有种可能就是单纯没Hook遍历文件的API罢了。当然这个是Hook系统API的情况,如果是Hook游戏引擎内部读取文件的接口,也是一样的道理,也可以有这几种实现方法。

第二种是可以Hook VFS的接口,记录所有的文件名,这样可以在后续移除目录下的文件,并调用接口传入文件名来判断那些文件是在VFS中的。

第三种,也是比较难的一种,这些需要解析VFS的内部实现,来获取文件名,一般可以在接口处跟踪对传入文件名的处理,比较简单的会调用strcmp这类的字符串对比函数进行查找内部释放有该文件,当然这种效率就很差了,不过好处是逆向方便,还有的会计算hash,内部只保存hash,又或者计算hash但是内部也有真实文件名,这块就需要具体问题具体分析了。

不过总的来说,通过Hook 接口,并记录文件名的方法一定程度上都可以获取到VFS内部的文件名情况,虽然可能并不能全部提取完整。

提取文件

得到文件名后就可以开始提取文件了,因为VFS本来就是为程序读取文件服务的,我们当然可以假装自己是程序本身,来调用VFS的接口,传入文件的信息来读取文件。

最简单的肯定是Hook 系统API的这类VFS,因为这类VFS的操作肯定是按照系统API的操作来设计的,我们只需要正常在程序里调用相关的API就可以提取成功,当然如果有些Hook的位置比较偏门,就需要自己找到相关的地址来操作,像是有些会Hook到Call API的地方,而不是API函数的函数头上,比如fopen内部会调用CreateFile,那么其实只需要Hook fopen调用CreateFile的那个Call就好了,并不需要Hook CreateFile的函数主体。

如果是Hook在了内部读取文件的接口上,又或者这个VFS就是游戏封包程序本身,那么这个时候就需要一定的能力来找到对应的接口了,还有接口的参数也一般不会是那些正常文件操作函数的样子,需要仔细去分析出来,可能涉及到内部的数据结构,像是会有个封包对象这样的。当然有些虽然是内部但是就和前面提到的fopen那个一样,只是在调用系统API的前面一点而已,本质上还是Hook 系统的API。

总结还是那句话,游戏怎么读,你就怎么读。