尚未完成,敬请期待

前言

ida中Func_开头的函数,Stru_开头的结构体。

基础知识

IDA中的结构体:从入门到进阶

在逆向大型工程的时候,我们不可避免地会遇到大量复杂的结构体。合理地利用ida定义结构体的功能,可以让我们的分析效率大大提高。本节我们从零开始,介绍如何在ida中定义结构体,并深入展开介绍相关进阶操作。

定义结构体√

新建、删除与重命名

Structures界面内(可通过shift+f9打开)右键并点击Add struct type...(或直接按Insert键)即可创建新结构体:

image-20231023151634554

在弹窗中输入要创建的结构体的名称:

image-20231107103245695

此时结构体为空:

image-20231107103516388

Delete键可以删除当前选中的结构体:

image-20231107104328117

n可以更改当前选中的结构体或变量的名称:

image-20231107104513845

image-20231107104612359

基本数据类型

d,可以新增结构体内的变量,并可切换设置数据类型为BYTE, WORD, DWORD, QWORD

image-20231107104207489

a,可以设置字符串:

image-20231107103845514

image-20231107104651666

*键,可以设置数组。注意默认会是BYTE数组,如果需要创建WORD, DWORD, QWORD的数组,则需要先创建一个WORD, DWORD, QWORD变量,选中这个变量后,再按*设置数组:

image-20231107105117552

image-20231107105315962

变量的删除与结构体的收缩

;键可以为结构中的某一行添加注释,这里就不演示了。

u可删除结构体内的变量。如果该变量不是结构体的最后一个变量,那么结构体大小不变,该变量对应位置变为undefined(比如我们删除了byData2):

image-20231107105533937

当被删除的变量下面没有其他的变量的时候,结构体大小会自动收缩(比如我们在上图的基础上又删除了szStringadwData):

image-20231107105659318

数据显示格式

h,可以切换设置该变量在IDA主窗口中显示为十进制或十六进制,按b设置为二进制:

image-20231107151506978

ctrl+o,可以设置该变量在IDA主窗口中显示为一个偏移(如果此偏移地址在IDA中是一个有效的地址或标签,则双击时可以跳转到对应的偏移地址处):

image-20231107151544398

比如下图中的结构体中红框里面的变量显示的就是一个偏移:

image-20231107111212131

如果我们不将此变量的数据格式设置为偏移,就会出现下图红框中的情形,不利于我们快速跳转过去(另外,没想到吧,ida主窗口中的结构体是可以通过点击左侧的箭头来向下展开的):

image-20231107111559465

函数指针

y,可以修改成员变量的声明(即修改其数据类型),不仅可以修改成常规的数据类型,也可以修改成函数类型、结构体类型等,比如:

image-20231108100358682

上图结构体中的Func_Wrapper_malloc_1400A6410变量其实是一个函数指针,但是目前并没有设置数据类型(所以默认应该是__int64类型),因而伪代码中需要先进行强制类型转换,转换成通过识别参数和返回值来确定出的函数类型,然后才能调用函数。

我们可以按y修改结构体中Func_Wrapper_malloc_1400A6410变量的声明。

复制上图伪代码中自动推断出的__int64 (__fastcall *)(__int64, __int64)作为结构体中变量的声明:

image-20231108100937291

也可以找到实际的Func_Wrapper_malloc_1400A6410函数的定义:

image-20231108102851470

void *__fastcall Func_Wrapper_malloc_1400A6410(__int64 a1, size_t a2)修改为void *__fastcall (*Func_Wrapper_malloc_1400A6410)(__int64 a1, size_t a2),并作为结构体中变量的声明:

image-20231108103042577

转换后的结果:

image-20231108101029825

应用结构体√

主窗口中

ida主窗口中,将鼠标光标放在结构体开始的地方(以此处为例):

image-20231107165717821

alt+q,选择对应的结构体:

image-20231107164615327

完成转换:

image-20231107164436743

从而将这里的数据转换为:

image-20231107165210215

伪代码中

在下图的伪代码中,我们经过分析(此类分析过程留待后续专门章节,本节不过多介绍),确定了off_143DE40C0是一个Stru_DirInfoOfResourcesInExe结构体的数组,v1是指向一个Stru_DirInfoOfResourcesInExe结构体的指针。

image-20231107171554033

因此,我们可以把v1的数据类型从char **转换成Stru_DirInfoOfResourcesInExe *

右键v1,点击Convert to struct *...

image-20231107171040296

选择相应的结构体:

image-20231107171218552

即可完成转换:

image-20231107171242144

也可以右键v1,选择Set lvar type...(或者直接按y):

image-20231107171813070

v1的声明从char **v1改为Stru_DirInfoOfResourcesInExe *v1

image-20231107171856790

image-20231107171934471

这样同样可以设置变量的数据类型。

如果不想把v1设置成结构体了,可以右键v1,并点击Reset pointer type

image-20231107171332673

即可将v1重置回万能的__int64

image-20231107171452393

导出与导入√

Local Types窗口与Structures窗口

在描述如何导出或导入结构体信息之前,我们需要首先了解一些前置知识。

为了给types提供直观且强大的接口,ida提供了两种类型的types,分别为Assembler level typesC level types

Assembler level types是在StructuresEnums窗口中定义的类型。由于我们必须手动指定成员变量的偏移量和其他属性,因此ida认为成员变量的偏移量是固定的,并且ida永远不会改变此结构体的成员变量。

C level types是在Local Types窗口中定义的类型。对于它们,ida会自动计算成员变量的偏移量,如有必要,可能会移除成员变量并更改结构体的总大小。

在我们手动编辑了C level types的type后,IDA会将该type视为Assembler level types

需要注意是是,Structures窗口中不只有Assembler level types,也含有以灰色显示的C level typesLocal Types窗口中不只有C level types,也含有以灰色显示的Assembler level types。灰色用来间接表示该type“不属于”此窗口,实际上是另一个窗口中的副本。具体显示颜色可见下表。

所属typesStructures窗口中显示Local Types窗口中显示
Assembler level types黑色灰色
C level types灰色黑色

Structures窗口中的TestStruct

image-20231107151848394

Local Types窗口中的TestStruct

image-20231107152118484

此外,需要注意的是,Structures中的结构体一定会显示在Local Types窗口中,但是Local Types窗口中的结构体不一定会显示在Structures窗口中。

推荐阅读:

导出

依次选择File -> Produce file -> Create C header file...

image-20231107125958116

image-20231107130114824

从而可以将Local Types界面中的全部type导出为一个.h头文件:

image-20231107130440125

导入
导入整个.h头文件

上一环节我们从ida中导出了全部type(不止局限于结构体)的.h文件,自然也是可以将任意的.h头文件导入到ida中的。

依次选择File -> Load file -> Parse C header file(或者直接按ctrl+f9):

image-20231107154538522

选择相应的.h头文件,即可完成导入:

image-20231107154739575

注意,这个是导入到Local Types窗口中,如果需要同步到Structures窗口中,则可以在Local Types中双击对应行并点击确定,即可同步到Structures窗口中:

image-20231107155019428

也可以在对应行(可多选)右键,并点击Synchronize to idb

image-20231107155131056

image-20231107155242731

image-20231107155310222

又或者可以在Structures窗口中按Insert,点击Add standard structure

image-20231024095235165

通过关键词搜索(ctrl+f),找到想要同步的结构体并点击确定即可同步:

image-20231107162241674

Local Types窗口中的type有没有同步显示在Structures窗口中,可以通过下图中的Sync一列来判断。Auto的就是已经同步显示的。

image-20231107155808785

导入一个或几个结构体

如果我们只需要导入一两个结构体,可以在Local Types窗口中,按Insert键,粘贴并导入我们的结构体:

image-20231107160404343

需要注意的时候,结构体内部使用的数据类型如图中QWORD,必须是ida中定义过的,否则要通过#define或typedef来定义QWORD,不然会导入失败。

同样,这样只是将结构体导入到了idaLocal Types中,还没有同步到当前IDA文件的Structures窗口中,可以按照上面介绍过的几个方法来同步。

进阶操作

示例一

结构体中的函数指针实例

示例二

关于Set call typeForce call type

变量合并
内存对齐
函数

windows平台编码方案辨析

x64dbg

字符串断点

老版本

老版本的x64dbg的条件断点想要设置字符串的话,并不是很方便,一般是通过比较QWORD, DWORD等数值来间接实现。字符串短一点还好,如果很长的话,手动转换出合适的断点条件是比较繁琐的。

于是我简单写了一个小脚本,用于将n字节的字符串转换为$\lceil\frac{n}{8}\rceil$个QWORD、$\lceil\frac{n}{4}\rceil$DWORD、$\lceil\frac{n}{2}\rceil$个WORD或$n$个BYTE。使用的时候修改path为目标字符串,block_size为1, 2, 4, 8。另外,使用时可以根据utf16(unicode), gbk(ansi), ascii等编码细调。

import struct

def str2utf16bytes(s : str) -> bytes:
    # utf-16-be     big endian no BOM
    # utf-16-le     little endian no BOM
    # utf-16        little endian + BOM
    return s.encode('utf-16-le')

def utf16bytes2str(b : bytes) -> str:
    return b.decode('utf-16-le')


def bytes2blocks(b : bytes, block_size : int) -> list:
    result = []
    block_num = len(b) // block_size
    if len(b) % block_size:
        block_num += 1
    
    for block_idx in range(block_num):
        block_bytes = b[block_idx * block_size : (block_idx+1) * block_size]
        block_bytes += b'\x00' * (block_size - len(block_bytes))

        if block_size == 1:
            mode = '<B'
        elif block_size == 2:
            mode = '<H'
        elif block_size == 4:
            mode = '<I'
        elif block_size == 8:
            mode = '<Q'
        else:
            raise Exception("Block Size must be 1, 2, 4 or 8.")
        block_data = struct.unpack(mode, block_bytes)[0]
        result.append((block_idx, block_data, block_bytes))
    return result


if __name__ == '__main__':
    #path = r'd:\Game\GujianOL/data'
    path = r'd:\Game\GujianOL/data/_index/708.idx'
    block_size = 8

    print(path)
    utf16 = str2utf16bytes(path)
    results = bytes2blocks(utf16, block_size)

    for block in results:
        idx, data, bytes = block
        print('{:<8s}{:<16}    {}'.format(hex(idx * block_size), hex(data), utf16bytes2str(bytes)))

pathd:\Game\GujianOL/data, block_size 8时,上面脚本的运行结果为:

d:\Game\GujianOL/data/_index/708.idx
0x0     0x47005c003a0064    d:\G
0x8     0x5c0065006d0061    ame\
0x10    0x69006a00750047    Guji
0x18    0x4c004f006e0061    anOL
0x20    0x7400610064002f    /dat
0x28    0x69005f002f0061    a/_i
0x30    0x7800650064006e    ndex
0x38    0x3800300037002f    /708
0x40    0x7800640069002e    .idx

因此,如果我们希望在rcx指向d:\Game\GujianOL/data/_index/708.idx时断下断点,可以选择性地设置这样的条件:判断rcx+0x20处的QWORD等于utf16编码的字符串/dat对应的小端QWORD,如下图所示。当然我们也可以根据需要来决定是否采用更多的QWORD进行匹配来避免条件断点的精确性。

image-20231029204938101

新版本

byte:[]之类

新函数

条件断点中的字符串以及一些log命令等

addr

module.base

" - ".base

CRC16

虚幻OodleLZ库

逆向分析前置

游戏文件目录简述√

官服

游戏根目录D:\Game\GujianOL下有:

目录含义
bin64包含gujianol.exe及与之运行相关的一些exe、dll文件。重点有:gujianol.exe, oo2core_6_win64.dll, browserhost.exe, crashpad_handler.exe, sentry.dll
data游戏的资源文件,从data000data116,每个文件最大为1G。另有_index文件夹存放着708.idx, 753.idx等索引文件。
ExportFace游戏内角色的捏脸数据
ExportPrefab游戏内仙府的建造方案
Interface
screenshots游戏截图
settings跟随游戏账号的设置
tools动画编辑器、音乐编辑器
.sentry-nativeThe Sentry Native SDK is an error and crash reporting client for native applications, optimized for C and C++. Sentry allows to add tags, breadcrumbs and arbitrary custom context to enrich error reports.
文件含义
GameLauncher.exe(不是真正的)游戏启动器
branch-version游戏版本号
launcher.ini启动器的配置文件,存储了APP_ID, APP_NAME, LAUNCHER_PATH等信息,以及UPGRADE, LOG, AUTH, SENTRY的网址
gamelauncher.dbjson格式,存储了一些文件的md5,比如GameLauncher.exe, launcher.ini
union.db配置文件,APPID=26ID=wangyuan
sentry.dllSentry Native SDK
crashpad_handler.execrashpad_handler是Google开发的一款用于收集和处理应用程序崩溃报告的工具
log_launcher_20230826.logLauncher启动失败时的log文件
skins.skin应该是启动器的UI皮肤文件
game.ico图标
title.pnglogo

%LocalAppData%\WangYuan\GameCenterLite目录下(%LocalAppData%C:\Users\UserName\AppData\Local)有:

目录含义
.sentry-nativeSentry Native SDK
文件含义
Launcher.exe真正的游戏启动器
Agent.exe收发一些跟流量相关的数据,并通过管道与Launcher.exe进行数据传输
wy.login_sdk_x64.dll网元账号登录的SDK
wke.dllBlzFans/wke: 3D Web UI. Web and Flash Embedded in 3D games, based on WebKit
sentry.dllSentry Native SDK
oo2core_6_win64.dllOodle的动态链接库
launcher.dbjson格式,存储了一些文件的md5,比如Agent.exe, Launcher.exe
crashpad_handler.execrashpad_handler是Google开发的一款用于收集和处理应用程序崩溃报告的工具
log_launcher_20231029.logLauncher启动失败时的log文件
error.html启动器加载网页失败时展示的页面
skins.skin应该是启动器的UI皮肤文件

steam服

基本同上,只是没有了%LocalAppData%\WangYuan\GameCenterLite这个文件夹,且游戏根目录下没有了启动器、配置信息等文件(因为登录信息是调用steam的接口)

启动游戏时的进程调用链√

官服

1. GameLauncher.exe

游戏根目录下的GameLauncher.exe实际上是一个假的启动器。

使用x32dbg动调(为避免唠叨,本文后续所有的动调都默认需要管理员权限,因为游戏本身和启动器都是管理员权限运行的),在CreateProcessACreateProcessW下断点,f9运行后断在CreateProcessW[esp+4](即args1)为D:\Game\GujianOL\crashpad_handler.exe 。检索一下,得知crashpad_handler是Google开发的一款用于收集和处理应用程序崩溃报告的工具。跟主线任务无关,直接无视。

继续f9,这次CreateProcessW的args1为C:\Users\iyzyi\AppData\Local\WangYuan\GameCenterLite\launcher.exe,栈中函数的全部参数为:

image-20231102210218265

对照CreateProcessW的函数声明,我们可以发现,lpCurrentDirectory设置为游戏的根目录。

BOOL CreateProcessW(
  [in, optional]      LPCWSTR               lpApplicationName,
  [in, out, optional] LPWSTR                lpCommandLine,
  [in, optional]      LPSECURITY_ATTRIBUTES lpProcessAttributes,
  [in, optional]      LPSECURITY_ATTRIBUTES lpThreadAttributes,
  [in]                BOOL                  bInheritHandles,
  [in]                DWORD                 dwCreationFlags,
  [in, optional]      LPVOID                lpEnvironment,
  [in, optional]      LPCWSTR               lpCurrentDirectory,
  [in]                LPSTARTUPINFOW        lpStartupInfo,
  [out]               LPPROCESS_INFORMATION lpProcessInformation
);
2. Launcher.exe

Launcher.exe是真正的游戏启动器,不在游戏根目录中,而是在%LocalAppData%\WangYuan\GameCenterLite目录下,初步推测应该适用于该游戏厂商旗下的所有游戏的启动。

但如果我们直接运行此程序,会立马闪退,并在%LocalAppData%\WangYuan\GameCenterLite目录下生成形如log_launcher_20231102.log的日志文件,内容为:

21:43:45:448 [8644:17568] [INFO] Launcher Start
21:43:45:493 [8644:17568] [ERROR] GetGamePath failed.
21:43:45:493 [8644:17568] [INFO] Launcher Exit

不应该啊,明明上一步GameLauncher.exe中通过CreatePorcessW调用此程序时,没有其他的参数,只是单纯传递了此程序的绝对路径。为什么不能成功运行呢?难道是因为直接在资源管理器中启动此程序时,没有将lpCurrentDirectory设置为游戏的根目录吗?

然而尝试使用cmd,进入游戏根目录下,再通过绝对路径的方式启动此程序,依然无法成功运行:

image-20231102214803048

也尝试使用x64dbg打开此程序(但先不运行),使用命令chd "D:\Game\GujianOL" 改变当前目录(chd - SetCurrentDirectory),在运行程序,也是无法成功运行:

image-20231103001025550

没办法,使用ida静态分析一下Lanucher.exe吧。

因为前面的报错日志中提到了GetGamePath failed,所以尝试直接搜索这个字符串,定位到sub_14000D710

image-20231102215906398

image-20231102215949854

发现了相当显眼的get_env(),这里Lanucher.exe尝试从环境变量中获取GAME_PATHGAME_ID,难道参数是通过环境变量来传递的吗?

还没来得及多加思考,紧跟其后,我们可以发现-gamepath=-gid=这两个明显是命令行参数的字符串。

回想起我们在游戏根目录下发现的union.db文件中就存储着:

[UNION]
APPID=26
ID=wangyuan

从而推测-gid=26

-gamepath=的值我们首先猜测为游戏根目录D:\Game\GujianOL

现在我们尝试通过%LocalAppData%\WangYuan\GameCenterLite\Launcher.exe -gid=26 -gamepath=D:\Game\GujianOL来运行此程序。成功运行!

接下来,我们就可以使用x64dbg动调此程序了:

image-20231102221054803

同样在CreateProcessW下断点。

第 i 次触发断点创建进程路径
1C:UsersiyzyiAppDataLocalWangYuanGameCenterLitecrashpad_handler.exe
2C:UsersiyzyiAppDataLocalWangYuanGameCenterLiteAgent.exe
null游戏启动器中会弹出登录框,需要完成账号登录,并点击开始游戏按钮,才能继续调试
3C:UsersiyzyiAppDataLocalWangYuanGameCenterLiteAgent.exe
4D:GameGujianOLbin64GujianOL.exe --fs=data:_index/708.idx:_index/708.idx

有一点我想吐槽我自己,深夜写博客,脑子不清醒了,把文件union.db中的wangyuan当成了-gamepath的值,于是在触发了第3次断点并继续运行后,被卡在更新游戏资源文件这一步,多次动调都会这样(然而又尝试正常在资源管理器中运行游戏启动器并点击开始游戏,而不通过调试器或cmd启动,是不会卡在这个界面的):

image-20231102231423986

我就纳闷了,以前不是可以通过命令行参数成功启动嘛,怎么就今天写博客的时候不行了呢?走了很多弯路,大脑也浑浑噩噩的,但其实并没有什么高深的原因,问题就出在-gamepath的参数是错误的,仅此而已。

另外,如果有读者认为,我们主要是靠猜测拼凑出的-gid=-gamepath,一点也不优雅。那我们还可以在GameLauncher.exe通过CreateProcessW启动Launcher.exe的时候,使用procexp(需管理员权限,因为目标进程也是管理员权限)查看此时GameLauncher.exe的环境变量:

image-20231103095008060

3. gujianol.exe

前面都是在真假启动器上做工作,总算分析到游戏程序本身了。

尝试命令行启动D:\Game\GujianOL\bin64\GujianOL.exe --fs=data:\_index/708.idx:\_index/708.idx,不出意料地没能成功运行,反倒运行了Launcher.exe,要求我们进行登录。这一点倒是可以预料的,毕竟命令行参数中并没有账号信息,只是简单地给出了索引文件的名称(708.idx太华山服务器对应的索引文件,后续章节会继续讨论)。

因为前面出现过通过环境变量在父子进程间传递信息,所以这里我们很容易联想到账号信息等数据,有可能是通过环境变量,从游戏启动器传递给游戏程序的。

使用ida打开Lanucher.exe,随手搜了一些字符串,比如start,就发现了Start game success,并通过交叉引用定位到sub_14001AA30

image-20231013145012540

再往上翻两下,就找到了CreateProcessW

image-20231103003742199

再往上一翻:

image-20231013145949592

很明显,这里调用wputenv_s函数,将gates, gj3_gates, gj3_ticket, gj_jates, gj_ticket等信息存入当前进程的环境变量。然后调用了CreateProcessW来启动游戏进程:

image-20231103100433003

BOOL CreateProcessW(
  [in, optional]      LPCWSTR               lpApplicationName,
  [in, out, optional] LPWSTR                lpCommandLine,
  [in, optional]      LPSECURITY_ATTRIBUTES lpProcessAttributes,
  [in, optional]      LPSECURITY_ATTRIBUTES lpThreadAttributes,
  [in]                BOOL                  bInheritHandles,
  [in]                DWORD                 dwCreationFlags,
  [in, optional]      LPVOID                lpEnvironment,
  [in, optional]      LPCWSTR               lpCurrentDirectory,
  [in]                LPSTARTUPINFOW        lpStartupInfo,
  [out]               LPPROCESS_INFORMATION lpProcessInformation
);

其中,倒数第4个参数lpEnvironment为NULL时,创建的子进程将使用父进程的环境变量。

[in, optional] lpEnvironment

A pointer to the environment block for the new process. If this parameter is NULL, the new process uses the environment of the calling process..

因而,gujianol.exe继承了Lanucher.exe设置的环境变量。

我们可以使用procexp64(同样需要管理员权限运行,因为目标进程也是管理员权限)来查看一下相关进程的环境变量:找到目标进程,右键菜单点击Properties...,在新窗口中点击Environment选项卡即可。以下截图我截去了无关的默认环境变量。

Launcher.exe登录前:

image-20231013151904624

image-20231013151930623

Launcher.exe登录后,但未启动游戏,此时环境变量没有变化:

image-20231013152210180

image-20231013152241923

启动游戏后,Launcher.exe的环境变量增加了许多诸如登录票据、服务端地址等信息:

image-20231013152325961

image-20231013152346073

接下来启动的gujianol.exe的环境变量继承了上一步的Launcher.exe的环境变量:

image-20231013152422641

image-20231013152447583

完整的进程调用链

以上我们便完成了对官服启动游戏时的进程调用链的分析,这里我们使用Procmon64记录一下全部有关进程创建的流程。

首先我们需要设置一下Procmon64的Filter,不然记录超级多,没法看。这里我们只关心进程的创建,所以可以添加一个OperationProcess Create的Filter:

image-20231103102447248

记录如下:

image-20231103103249708

steam服

steam服没有官方启动器,而是通过steam来完成信息认证。

直接运行gujianol.exe并不会立即启动游戏,而是通过steam来启动:"D:\Game\Steam\steam.exe" steam://run/1541840

image-20231103110351962

因为后续工作主要集中在官服,所以这里我们就不过多探索了。再逆下去其实就是逆steam通用的登录认证与启动游戏的流程了。

下文中如果不特别指出,均为对官服的逆向分析。

如何动调游戏进程⚪

直接附加游戏进程

传统方法,Launcher.exe登录并点击开始游戏后,直接使用x64dbg附加gujianol.exe进程,手速越快,错过的初始化逻辑越少。

image-20231103120103005

如果是老版本的x64dbg(我用的是Jul 2 2019版本),附加成功后,会自动断在此处:

image-20231103120655578

再接下来就可以正常调试后续的代码。

要想附加进程后自动断下断点,需要我们在设置中勾选附加断点这一选项:

image-20231103120850467

image-20231103120904036

但是较新版本中,比如Oct 5 2023版本的x64dbg,是没有附加断点这个选项的:

image-20231103131004437

No "Attach Breakpoint" Event Option in snapshot_2020-11-12_05-12 · Issue #2528 · x64dbg/x64dbg 这个issue中,开发者解释说:

No, that feature has been removed. The reason is that the breakpoint requires a thread to be created in the debuggee and this is a way to detect the debugger.

If you want to stop the execution for some reason, you can press the pause button. What is your use case for the attach breakpoint?

大意是x64dbg为了支持附加断点这个功能,就需要在目标进程中创建一个远程线程,这可能会导致目标进程能够检测到有调试器的存在,出于隐藏调试器的考虑,此后不再支持附加断点

我刚提了一个feature issue询问开发者是否可以考虑增加一个附加选项,在逆向人员知道开启附件断点这个功能可能导致的后果时,自行决定是否继续启用。但我估计应该不会有后续。

所以说在较新版本的x64dbg中,当目标进程启动后,不仅需要我们快速地附加到此进程,而且附加到进程后也需要我们迅速地按下F12 Pause暂停键,不让程序继续运行下去(全都运行完了我还分析啥逻辑哦)。

其实删除掉附加断点这个功能,只是强迫症(比如我)难受一点,实际上影响并不是很大。我一开始认为,如果没有在附加到进程后自动断下断点,可能会错过一些逻辑。但实际上,本来等程序运行起来后再附加到进程这个操作,哪怕手速再快,也会错过很多逻辑。现在只不过多了一步手动操作,看起来有些繁琐罢了。

补充:x64dbg的开发者回复我的issue了,并告知我可以通过编写注册了CB_ATTACH事件回调的插件来实现我的需求。有时间再写这个插件吧。

You should be able to call CreateRemoteThread from a plugin that registers the CB_ATTACH callback.

Callback Structures — x64dbg documentation

x64dbg 插件开发SDK环境配置 - lyshark - 博客园 (cnblogs.com)

【c++】x64dbg插件开发,摸索前进:第二章第一个正式插件 - 『编程语言区』 - 吾爱破解 - LCG - LSG |安卓破解|病毒分析|www.52pojie.cn

x64dbg/PluginTemplate: Plugin template for x64dbg. (github.com)

How Windows Debuggers Work | Microsoft Press Store

DebugBreakProcess function (winbase.h) - Win32 apps | Microsoft Learn

源码中搜索:cbDebugDetach

DbgChild: 自动附加子进程√

直接附加到游戏进程只能跟踪后续执行的相关逻辑,不可避免地无法跟踪一些游戏初始化的相关代码。此时我们可以考虑使用DbgChildDbgChild是一个x64dbg插件,可用于自动附加到子进程并进行调试。

初始化插件

Releases页面下载并解压后,将下列文件复制到x64dbg的根目录(即x96dbg.exe所在目录)以及对应子目录。注意不是按照插件的惯例把全部文件都放到x32\pluginsx64\plugins文件夹中。

NewProcessWatcher.exe
x64_post.unicode.txt
x64_pre.unicode.txt
x86_post.unicode.txt
x86_pre.unicode.txt

x32\plugins\DbgChild.dp32
x32\CreateProcessPatch.exe
x32\DbgChildHookDLL.dll
x32\NTDLLEntryPatch.exe
x32\CPIDS\

x64\plugins\DbgChild.dp64
x64\CreateProcessPatch.exe
x64\DbgChildHookDLL.dll
x64\NTDLLEntryPatch.exe
x64\CPIDS\
工作原理

作者在本项目的readme中只是简略地一笔带过该插件的实现思路,但其中有很多实现细节确实值得学习,因此我阅读了此插件完整的源代码,并在此介绍一下相关实现。

1. CreateProcessPatch.exe

总结CreateProcessPatch.exe填写payload的参数并将其拷贝到目标父进程,并通过跨进程修改内存数据来Hook目标父进程的ZwCreateUserProcess函数,使其跳转执行拷贝的payload。payload核心功能为加载DbgChildHookDLL.dll并调用其中自定义的ZwCreateUserProcess函数。

主体逻辑在CreateProcessPatch函数中实现。

首先通过OpenProcess函数打开了目标父进程的句柄hProcess,然后通过VirtualAllocEx函数在hProcess对应的进程中创建了一块空间,记作payload

FillPayload函数中通过memcpy函数填写完payload_ep(ep的含义是entry point)中空缺部分的数据(比如payload_dll_func_name等)。注意,payload是目标父进程中的内存空间,payload_epCreateProcessPatch.exe中的payload.asm汇编代码经过编译后标签init_payload的地址。

然后通过PatchCode函数远程修改目标父进程的内存数据。该函数在com_patch.cpp中定义,通过VirtualProtectEx, NtSuspendProcess, WriteProcessMemory, FlushInstructionCache, NtResumeProcess等函数,来实现修改远程进程中的数据。

不止一处调用PatchCode哦,比如:

// 把CreateProcessPatch.exe中的payload_ep(已填充好空缺部分)拷贝到hProcess进程中的payload中
PatchCode(hProcess, payload, payload_ep, payload_size, NULL, 0, NULL, 0);

又比如(简化代码):

// 获取系统库函数ZwCreateUserProcess的地址
void* ZwCreateUserProcess_f = (void*)GetProcAddress(GetModuleHandleW(L"ntdll.dll"), "ZwCreateUserProcess");

// 用于实现Hook时jmp跳走的汇编指令
#ifdef _WIN64
    unsigned char pushret_relative_ref[] = { 0xFF, 0x35, 0x00, 0x00, 0x00, 0x00, 0xC3,
        0x90, 0x90, 0x90 , 0x90 , 0x90 , 0x90 , 0x90 , 0x90 , 0x90 , 0x90 , 0x90,
        0x90, 0x90, 0x90 , 0x90 , 0x90 , 0x90 , 0x90 , 0x90 , 0x90 , 0x90 , 0x90
    };
    DWORD* jmp_dest = (DWORD*)&pushret_relative_ref[2];
#else
    unsigned char pushret_relative_ref[] = { 0x68, 0x00, 0x00, 0x00, 0x00, 0xC3, 
        0x90, 0x90, 0x90 , 0x90 , 0x90 , 0x90 , 0x90 , 0x90 , 0x90 , 0x90 , 0x90,
        0x90, 0x90, 0x90 , 0x90 , 0x90 , 0x90 , 0x90 , 0x90 , 0x90 , 0x90 , 0x90
    };
#endif

// 修改jmp指令跳转到的地址为payload的起始地址
#ifdef _WIN64
        PatchCode(hProcess, ntdll_dos_stub, &payload, sizeof(payload), NULL, 0, NULL, 0);
        *jmp_dest = (DWORD)(ntdll_dos_stub - (((unsigned char*)ZwCreateUserProcess_f) + (size_pushret - 1)));
#else
        memcpy(&(pushret_relative_ref[1]), &payload, sizeof(payload));
#endif

// Hook hProcess进程中的ZwCreateUserProcess函数,从而将控制流劫持到payload的起始地址
PatchCode(hProcess, ((unsigned char*)ZwCreateUserProcess_f), pushret_relative_ref, total_bytes, code_before_patch, sizeof(code_before_patch), code_after_patch, sizeof(code_after_patch));

上面代码中,根据x64和x86的不同,用于实现Hook时跳转到payload的汇编代码也不同。

在x86中,首先0x68 dword表示push payload_addr,然后0xc3表示ret,这样完成调用payload。

在x64中,因为payload的地址是8字节,无法直接跳转。所以先通过PatchCodepayload_addr存放到目标父进程的ntdll_dos_stub地址处(此处为ntdll.dll的DOS STUB,存有This program cannot be run in MS-DOS mode.),然后通过0xff 0x35 intpush [rip+int],将payload地址入栈,最后通过0xc3 ret到payload。

下图为x64中的跳转实现:

image-20231104105624452

其中push时,具体的rip+int的计算是这样的:

image-20231104111118601

另外,此前我们多次提到payload,但还没分析它是怎么使用汇编代码实现的,又实现了什么功能,现在我们一起来看一下。

实际上payload.asm的实现相当巧妙,绝对值得后续继续学习。payload.asm总体上可主要分为5大部分。

  • ① 根据不同架构,将CAX定义为RAXEAX,方便编写最后一部分x64和x86架构都通用的汇编代码。

    IFDEF RAX
    CAX EQU RAX
    CBX EQU RBX
    ELSE
    CAX EQU EAX
    CBX EQU EBX
    .486
    .model flat, C
    option casemap:none 
    ENDIF
  • ② 空缺参数部分。用于存储payload所需的一些参数,会在前面提到过的FillPayload函数中填充。

    ldr_get_procedure_address_lbl::
    ldr_get_procedure_address db    8 dup(?) ;
    
    ldr_load_dll_sym_lbl::
    ldr_load_dll_sym db    8 dup(?) ;
    
    dll_unicode_string_lbl::
    dll_unicode_string      db    16 dup(?) ;
    
    dll_ansi_string_lbl::
    dll_ansi_string      db    16 dup(?) ;
    
    dll_full_path_lbl::
    dll_full_path      db    520 dup (?) ;
    
    dll_func_name_lbl::
    dll_func_name      db    520 dup (?) ;
    
    ............
  • ③ x64 PAYLOAD。实现了x64架构下的相关逻辑。
  • ④ x86 PAYLOAD。功能同x64,只不过是x86架构的实现。
  • ⑤ 通用汇编部分。主要是编写了一些对外的函数接口,如:

    get_payload_size PROC
    PUSH CBX
    LEA CAX, OFFSET end_payload
    LEA CBX, OFFSET init_payload
    SUB CAX, CBX
    POP CBX
    RET
    get_payload_size ENDP
    
    get_payload_ep PROC
    LEA CAX, OFFSET init_payload
    RET
    get_payload_ep ENDP
    
    get_payload_dll_str PROC
    LEA CAX, OFFSET dll_full_path_lbl
    RET
    get_payload_dll_str ENDP

其中,第③部分和第④部分实现的核心逻辑是这样的:

  • 调用ldr_load_dll_sym,实际该值为ntdll.LdrLoadDll,用于加载DbgChildHookDLL.dll

    NTSYSAPI 
    NTSTATUS
    NTAPI
    LdrLoadDll(
      IN PWCHAR               PathToFile OPTIONAL,
      IN ULONG                Flags OPTIONAL,
      IN PUNICODE_STRING      ModuleFileName,
      OUT PHANDLE             ModuleHandle);
  • 调用ldr_get_procedure_address,实际该值为ntdll.LdrGetProcedureAddress,用于获取DbgChildHookDLL.dll中的ZwCreateUserProcess函数的地址。

注意,DbgChildHookDLL.dll中的ZwCreateUserProcess函数,不是系统库函数,而是自己编写的、用于Hook后跳转到payload中被调用的封装函数。

NTSYSAPI
NTSTATUS
NTAPI
LdrGetProcedureAddress(
    _In_ PVOID DllHandle,
    _In_opt_ PANSI_STRING ProcedureName,
    _In_opt_ ULONG ProcedureNumber,
    _Out_ PVOID *ProcedureAddress);

同时需要注意的是,LdrGetProcedureAddress的第二个参数,不是函数名称字符串,而是ANSI_STRING结构体的指针:

typedef struct _STRING
{
    USHORT Length;
    USHORT MaximumLength;
    _Field_size_bytes_part_opt_(MaximumLength, Length) PCHAR Buffer;
} STRING, *PSTRING, ANSI_STRING, *PANSI_STRING, OEM_STRING, *POEM_STRING;

payload.asm中,我们可以注意到第二个参数rdx的值为dll_ansi_string,不得不说,插件开发者使用的这个名称确实有误导性:

image-20231103224647385

dll_ansi_string这个值,通过get_payload_dll_ansi_string向外传出:

dll_ansi_string_lbl::
dll_ansi_string      db    16 dup(?) ;

............

get_payload_dll_ansi_string PROC
LEA CAX, OFFSET dll_ansi_string_lbl
RET
get_payload_dll_ansi_string ENDP

FillPayload函数中完成此指针指向的结构体的赋值(简化代码):

#define API_NAME_A ("ZwCreateUserProcess")

// 对应字符串为API_NAME_A
void* payload_dll_func_name = get_payload_dll_func_name();

// ANSI_STRING结构体的指针,是LdrGetProcedureAddress的第二个参数
PANSI_STRING payload_dll_ansi_string = (PANSI_STRING)get_payload_dll_ansi_string();

// 通过相对偏移,计算出的结果就是远程进程中的payload中payload_dll_func_name的地址
payload_dll_ansi_string->Buffer = (PCHAR)(remote_payload + (((unsigned char*)payload_dll_func_name) - ((unsigned char*)payload_ep)));
payload_dll_ansi_string->MaximumLength = sizeof(API_NAME_A);
payload_dll_ansi_string->Length = sizeof(API_NAME_A) - 1;
  • 将真正的ZwCreateUserProcess函数地址(实际上是trampoline的地址,通过跳转指令最终把汇编代码拼接成了完整的真正的ZwCreateUserProcess)赋值给raxeax,用于配合下一步中DbgChildHookDLL.dll中的ZwCreateUserProcess函数在函数内部,获取真正的ZwCreateUserProcess函数地址。

    ; x64
    lea rax, trampoline
    
    ; x86
    lea eax, [ebp+trampoline]
  • 调用第二步获取的函数,即DbgChildHookDLL.dll中的ZwCreateUserProcess函数。

另外,必须提及的是,我们拷贝到远程进程中的payload,只是上面分析的第②③④部分,不是全部的payload.asm中的代码。payload总体上是这样的汇编代码框架:

payload PROC

init_payload::

nop
jmp payload_ep_lbl

; 二  (空缺)参数部分

payload_ep_lbl:

IFDEF RAX

; 三  x64 PAYLOAD

ELSE

; 四  x86 PAYLOAD

ENDIF

end_payload::

payload ENDP
2. DbgChildHookDLL.dll

总结:目标父进程每次运行到ntdll.ZwCreateUserProcess时,都会跳转执行DbgChildHookDLL.dll中的自定义的ZwCreateUserProcess函数,从而将新创建子进程的pid写入CPIDS文件夹。

目标父进程运行到原生的ZwCreateUserProcess函数时,由于被Hook了,因而跳转执行payload,其中payload内部调用的第三个函数就是DbgChildHookDLL.dllZwCreateUserProcess函数(共计11个参数会在payload入口处就入栈保存,等调用这个函数时再恢复原样),这个函数并不是ntdll.ZwCreateUserProcess函数,而是插件开发者自定义的。

// 返回rax或eax
void GetCax(void) {
    return;
}

// 自定义的ZwCreateUserProcess
DBGCHILDHOOKDLL_API NTSTATUS WINAPI ZwCreateUserProcess(
    PHANDLE ProcessHandle,
    PHANDLE ThreadHandle,
    ACCESS_MASK ProcessDesiredAccess,
    ACCESS_MASK ThreadDesiredAccess,
    POBJECT_ATTRIBUTES ProcessObjectAttributes,
    POBJECT_ATTRIBUTES ThreadObjectAttributes,
    ULONG ProcessFlags,
    ULONG ThreadFlags,
    PRTL_USER_PROCESS_PARAMETERS ProcessParameters,
    PVOID CreateInfo,
    PVOID AttributeList)
{
#pragma comment(linker, "/EXPORT:" __FUNCTION__"=" __FUNCDNAME__)
    
    // payload在调用此函数前,将payload中trampoline的地址赋值给了rax或eax
    // trampoline可以看作真正的ZwCreateUserProcess函数的入口点
    ZwCreateUserProcess_f = (ZwCreateUserProcess_t)((void* (*)(void))GetCax)();
    
    // 查看payload的汇编代码,可以发现,dll_work_full_path就在trampoline地址+80
    work_path = (WCHAR*)(((unsigned char*)ZwCreateUserProcess_f) + 80);

    // 核心的自定义逻辑在此实现
    return _ZwCreateUserProcess(ProcessHandle,
                                ThreadHandle,
                                ProcessDesiredAccess,
                                ThreadDesiredAccess,
                                ProcessObjectAttributes,
                                ThreadObjectAttributes,
                                ProcessFlags,
                                ThreadFlags,
                                ProcessParameters,
                                CreateInfo,
                                AttributeList);
}

进而在_ZwCreateUserProcess函数中,通过NtQueryInformationProcess函数(args1为FileHandle,是新创建子进程的句柄,亦即ZwCreateUserProcess的第一个参数(PHANDLE)指向的句柄;args2为ProcessBasicInformation的枚举值)获取了ProcessInformation结构体,其中的UniqueProcessId就是新创建子进程的pid。然后在CPIDS文件夹中创建一个名称为此pid的文件。

3. NewProcessWatcher.exe

总结NewProcessWatcher.exe监视CPIDS文件夹中的新创建的文件,以获取刚创建的子进程的pid,然后通过命令行调用NTDLLEntryPatch.exe来patch目标子进程的ntdll.LdrInitializeThunk(即ntdll的EntryPoint入口点),间接实现暂停子进程的继续运行,然后通过命令行调用 x64dbg.exex32dbg.exe来附加子进程。

main函数中创建了两条线程,分别监视x64和x86的CPIDS文件夹中有无新文件(文件名为新创建子进程的pid),线程函数为NewProcessWatcher,线程函数的参数是CPIDS文件夹"x??\\CPIDS" (??为64或32)的绝对路径。

NewProcessWatcher线程函数中,在死循环里面,如果发现了新创建文件,则将(新创建文件名, CPIDS文件夹绝对路径)作为线程函数的参数,在新线程中运行线程函数ProcesCreated

ProcesCreated线程函数中,首先将参数拼接成新创建文件名的绝对路径,然后通过GetPreResumedCmd函数,获取所谓的PreResumedCmd命令行参数。

其中,GetPreResumedCmd函数的核心逻辑就是读取x64_pre.unicode.txtx86_pre.unicode.txt,将文件内容x64\NTDLLEntryPatch.exe 4294967295 px32\NTDLLEntryPatch.exe 4294967295 p中的4294967295替换成新创建子进程的pid。

获取PreResumedCmd命令行参数之后,使用CreateProcessW运行形如x64\NTDLLEntryPatch.exe 624 p的进程。NTDLLEntryPatch.exe通过patch目标子进程的ntdll.LdrInitializeThunk(即ntdll的EntryPoint入口点),间接实现暂停子进程的继续运行。

最后调用PostProcess函数。此函数在循环中每500ms判断一次子进程的主线程是否处于暂挂suspended状态,若否,则跳出循环。然后读取x64_post.unicode.txtx86_post.unicode.txt中,同样将x64\x64dbg.exe -a 4294967295 -e 4294967295x32\x32dbg.exe -a 4294967295 -e 4294967295中的4294967295替换成新创建子进程的pid,即为所谓的PostResumedCmd命令行参数。最后使用CreateProcessW运行形如x64\x64dbg.exe -a 624 -e 624的进程,从而将启动一个新的调试器,且附加到目标子进程上。

成功附加目标子进程后,需要手动unpatch ntdll.LdrInitializeThunk,使得子进程继续运行下去。或者勾选Auto From x32dbg|x64dbg Unpatch NTDLL Entry,当成功附加目标子进程后,插件会自动unpatch ntdll.LdrInitializeThunk

顺便一提的是,上面使用的x64dbg的命令行参数-a PID -e PID,我并没有找到明确的手册解释其含义是附加调试器到进程。文档里面只提到了 -p PID 表示附加到PID,没有写明-a-e。最后我是在源码中得知其含义:

image-20231104140005562

4. NTDLLEntryPatch.exe

就干了一件事,通过PatchCode函数,将目标子进程的ntdll.LdrInitializeThunk的前两个字节,修改成了0xEB, 0xFE0xeb表示jmp0xfe-2,这条指令长度也是2),也就是说执行完这条指令后又跳转到这条指令继续执行。除非unpatch此处,恢复原来的两个字节,否则目标子进程将“阻塞”在此处,不会继续往下运行。

关于LdrInitializeThunk

  • ntdll.dll外的其他dll的加载和连接是通过ntdll.LdrInitializeThunk实现的。在进入这个函数之前,目标EXE映像已经被映射到当前进程的用户空间,ntdll.dll的映像也已经被映射,但是并没有在EXE映像与ntdll.dll映像之间建立连接。 LdrInitializeThunkntdll.dll中不经连接就可进入的函数,实质上就是ntdll.dll的入口。
  • 用户模式下的所有线程都从 LdrInitializeThunk 开始执行。进程中的第一个线程调用LdrpInitializeProcess,其他线程都调用LdrpInitializeThread
工作流程

用visio简单画了个流程图,凑合看吧:

DbgChild工作流程

插件菜单

该插件具有以下操作:

image-20231104155954264

介绍一下使用此插件时,需要用到的几个操作:

后文编号功能作用
Hook Process Creation手动启动自动附加子进程的功能
Auto from x32dbg/x64dbg Hook Process Creation(选中后)在调试程序的时候自动启动自动附加子进程的功能。
Unpatch NTDLL Entry手动取消对目标子进程的”阻塞“
Auto From x32dbg/x64dbg Unpatch NTDLL Entry(选中后)在成功附加新创建的子进程后,自动取消对目标子进程的”阻塞“
使用说明

使用x64dbg打开或附加目标父进程后,可以设置是否选中下图中的②和④。

如果不选中②,则在启动想要跟踪的子进程之前(比如触发对应进程的CreateProcessW断点的时候),必须手动点击①,来开启自动跟踪子进程的功能。

如果不选中④,则在新创建的调试器附加到想要跟踪的子进程后,必须手动点击③,来取消子进程”阻塞“在ntdll.LdrInitializeThunk,使得子进程能够继续运行下去。

(②和④是否选中,需要根据根据实际需要来决定。比如说目标父进程中启动了多个进程,而我们只关心其中的一个,那么可以不选中②,而是在CreateProcess下断点,当断在想要继续调试的子进程时,再手动点击①。)

image-20231104175551157

手动点击①或通过②自动开启功能时,会弹出下面弹窗,点击是即可,同时在调试期间不要关闭NewProcessWatcher.exe

image-20231104180141011

完成以上配置或操作,接下来就可以正常调试父进程了,遇到子进程时会另起一个调试器并附加到子进程,当然这个时间可能有点长,大概需要几分钟才能成功附加到子进程,多耐心等待一会。(经过分析这个时间浪费在NewProcessWatcher中的GetMainTIDFromPID函数中,这个函数在成功获取到目标子进程的主线程之前不会跳出死循环。)

然而,有一点需要注意,前面在直接附加游戏进程的一小节中提到过,较老版本的x64dbg是支持附加断点的,也就是说附加到子进程后,不管有没有选中④,最终都会断在附件断点的(选了④,直接断在附加断点;没选④,手动点击③后会断在附加断点):

image-20231104182249077

如果使用的是较新版本的x64dbg,且选中了④自动移除对子进程的”阻塞“,那么会断在异常,f9就可以继续正常运行:

image-20231104184243679

如果使用的是较新版本的x64dbg,且没选中④自动移除对子进程的”阻塞“,那么不会断下,而是一直阻塞在ntdll.LdrInitializeThunk,所以界面中一直显示运行中,没有断下(因为程序在运行中没有断下,所以图中rip等寄存器都是不正确的):

image-20231104184729979

按下F12暂停程序,发现rip确实处于ntdll.LdrInitializeThunk,且第一条指令为跳转到自身,该函数的执行被”阻塞“了:

image-20231104185006887

手动点击插件的③Unpatch NTDLL entry,取消阻塞,此函数第一条指令恢复为原本的指令:

image-20231104185211166

接下来就可以正常调试子进程了。

自动跟踪本游戏进程的操作步骤

DbgChild的菜单中选中了④,没有选中②:

image-20231104191225866

首先打开游戏启动器并登录,然后使用x64dbg附加到Launcher.exe,并在CreateProcessW下断点。

然后点击启动器中的开始游戏,第一次断在CreateProcessW,子进程为C:\Users\iyzyi\AppData\Local\WangYuan\GameCenterLite\Agent.exe,不关心,继续运行程序。但注意DbgChild的菜单中一定不要选中②Auto from x64dbg Hook process creation,不然也会另起一个调试器跟踪Agent.exe这个进程。

第二次断在CreateProcessW,子进程为D:\Game\GujianOL\bin64\GujianOL.exe --fs=data:_index/708.idx:_index/708.idx。此时点击①Hook process creation

image-20231104191427281

image-20231104191439519

此时Launcher.exe被暂停了,f9继续运行下去:

image-20231104191559505

然后等待两三分钟,就启动了一个附加到子进程的新的调试器窗口:

image-20231104191803873

f9就可以正常调试游戏进程了。

在这个过程中,NewProcessWatcher.exe的log为:

image-20231104191916719

总结

上一节我们分析了启用游戏时的进程调用链,最后得知,gujianol.exe的启动需要继承父进程Launcher.exe设置的一些有关登录信息的环境变量,并不能简单地通过命令行参数来启动,所以我们无法通过在x64dbg中直接打开gujianol.exe并修改命令行参数来实现从头开始跟踪的动态调试。

gujianol.exe必须是通过Launcher.exe完成登录并启动的,因此在本节中我们介绍了两种方法动调游戏进程的方法,应用场景分别为:

  • DbgChild:当我们需要对游戏初始化的相关逻辑进行分析时(比如说初始化时磁盘中的资源文件是如何处理成一个索引、以便后续根据需要来具体加载对应的资源文件的),直接附加到游戏进程极大概率会错过此类代码,因此必须通过DbgChild插件来从头开始跟踪子进程(即游戏进程)。但是该插件的缺点也十分明显,使用这个插件,动态打开新的调试器窗口并附加子进程的这一过程相当慢,所以如无必要,还请尽可能直接附加到游戏进程。
  • 直接附加:当我们需要分析的相关操作并不在游戏初始化的阶段时(比如说实时的通信流量是如何加解密的),直接附加到游戏进程就可以了,简单粗暴。

动静结合时的地址转换问题⚪

一般在做逆向分析的时候,我都是通过ida静态分析和x64dbg动态调试相结合。

但是同一个函数,ida中显示的地址和x64dbg动调时的地址大概率是不一样的,这给我们的逆向分析(比如在ida中找到一处关键逻辑后,想要找到它在x64dbg中的地址,进而调试分析;或者在x64dbg中通过调试确定某个函数后,想要在ida中查看这个函数的交叉引用)带来一些困难。

此外,由于游戏程序每周都会更新,二进制文件会相应发生改变,

本节就针对以上两种情形,给出对应的处理思路。

基址重定位与ASLR导致动态基址

基址重定位√

向进程的虚拟内存加载PE文件(包含EXE, DLL, OCX, SYS等)时,该PE文件会被加载到PE头的ImageBase所指的地址处,如下图所示。

image-20231105203612355

但是在加载过程中,如果当前要加载的PE文件的ImageBase所指的地址处,已经在此之前加载了其他的PE文件,此时就需要通过基址重定位,更改当前要加载的PE文件加载到内存中的基址。

由于进程使用虚拟内存,且创建好进程后EXE会首先加载进内存,所以EXE本身无需考虑基址重定位问题。对于windows的系统DLL文件,微软根据不同版本分别赋予了不同的ImageBase,例如同一系统的kernel32.dlluser32.dllImageBase固定且不相同,因此也无需考虑基址重定位问题。所以,基址重定位一般发生在用户DLL中,如果有固定此模块基址的需要,可以手动修改ImageBase为所在进程中其他模块没有使用的地址(同时也需要禁用ASLR)。

因为在本项目中,我们基本没有动调时固定住dll基址的需求,所以此处就不展开深入讨论了。这部分只是为了告知读者,不只有ASLR会导致动态基址,避免被我后续有关ASLR的讨论误导。

ASLR
基本介绍

ASLR (Address Space Layout Randomization) 是一种计算机安全机制,用于增加操作系统和应用程序的安全性。通过在每次运行程序时随机化内存地址布局,减少缓冲区溢出和其他内存攻击的成功率,使攻击者难以预测和利用特定的内存地址来执行恶意代码。尽管本意是好的,但不得不说也给我们逆向人员带来了一些麻烦(废话,防的就是我们这群人)。

ASLR一般分为映像随机化、堆栈随机化、PEB与TEB随机化。本文不涉及如何编写针对特定漏洞的payload,所以这里主要关注映像随机化(指PE文件加载到虚拟内存时会采用一个随机的基址),因为其中牵扯到如何定位到目标代码。

编译时启用

ASLR机制可以在链接时添加/DYNAMICBASE选项来启用:

image-20231105145404538

如果启用了ASLR机制,则会在PE文件头中设置IMAGE_DLLCHARACTERISTICS_DYNAMIC_BASE标志,具体的位置是:

IMAGE_NT_HEADERS
    IMAGE_OPTIONAL_HEADER32/64
        DLL_CHARACTERISTICS
            IMAGE_DLLCHARACTERISTICS_DYNAMIC_BASE

在010editor中安装了exe文件的模板后,可以方便查看PE文件的格式。

32位程序:

image-20231105151920398

64位程序:

image-20231105152115695

系统设置

从Windows 10 1709、Windows 11和Windows Server 1803开始支持Exploit ProtectionExploit Protection将许多漏洞利用缓解技术应用于操作系统进程和应用(在以上版本的windows系统之前,此类防护方案由Enhanced Mitigation Experience Toolkit提供,不过现已废弃),可在Windows设置 -> 更新和安全 -> Windows安全中心 -> 应用和浏览器控制 中找到Exploit Protection设置

image-20231105222351281

在这里面可以设置windows系统是否启用ASLR:

image-20231105222559071

上图设置里面同时也支持对特定程序(可以选择匹配程序名或程序路径)设置专门的方案,以决定是否针对特定程序,来启用包括ASLR在内的各种安全方案。

上图设置中有关ASLR的防护选项有:

防护选项含义系统设置的默认值涉及到的编译标志
强制映像随机化(强制性ASLR)无视PE文件中有无/DYNAMICBASE对应的标志,强制性使用ASLR。关闭
随机化内存分配(自下而上ASLR)常规的ASLR开启/DYNAMICBASE (默认启用此标志)
高熵ASLR在常规的ASLR的基础上,利用更大的64 位地址空间来随机化映像加载的基址。此选项的生效需启用随机化内存分配(自下而上ASLR)作为前置。开启/HIGHENTROPYVA (默认为64位可执行映像启用此标志)
系统设置(随机化内存分配 自下而上ASLR)PE文件标志(IMAGE_DLLCHARACTERISTICS_DYNAMIC_BASE)结果
启用启用启用
启用关闭关闭
关闭启用
关闭关闭关闭

基本的知识介绍完了,下面我们介绍几种可以用于动静结合来逆向程序时定位到相关地址的方案。

方案1:禁用ASLR机制来固定x64dbg中的基址

禁用ASLR机制可以使得程序在运行时不再采用动态随机的基址,所以在x64dbg中动调的时候,目标程序的基址将是固定的。而且idax64dbg中的对应地址会是完成一致的,因为二者分析的映像的基址都是PE文件头中的ImageBase

接下来介绍几种可以考虑的禁用ASLR机制的方法。

法① 修改PE文件头√

将PE文件头中的IMAGE_DLLCHARACTERISTICS_DYNAMIC_BASE这1bit从1修改为0。

前面提到过在010editor中使用exe模板,就能便捷地解析PE文件格式,将下图箭头处(这个标志在PE文件中的相对位置前面也有介绍)的中的1改成0即可:

image-20231105225538844

其中关于DllCharacteristics这16bit标志位的具体含义,可参考DllCharacteristics Enum

法② 设置中关闭系统的ASLR机制

1) win7

  1. 打开注册表编辑器。可以按下 Win+R 键,输入 regedit,然后按 Enter 键来打开注册表编辑器。
  2. 导航到以下注册表键:HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\Memory Management
  3. 在右侧窗格中,查找名为 MoveImages 的项。如果不存在,可以右键单击空白处,选择新建 -> DWORD (32位)值,并将其命名为 MoveImages
  4. 双击 MoveImages,将数值数据设置为 0,然后点击确定
  5. 关闭注册表编辑器。
  6. 重新启动计算机以使更改生效。

注册表中的操作:

image-20231106105737809

以win7中默认的cmd.exe为例。关闭ASLR前:

image-20231106105549232

关闭ASLR后:

image-20231106105916544

而此程序的PE文件中,ImageBase就是上图中的4AD00000

image-20231106110011733

2) win10

直接在前面提到过的Exploit Protection设置中,将随机化内存分配设置为默认关闭,然后重启电脑

image-20231105230454788

这样一来,系统中所有的程序,如果没有单独设置程序的安全方案,都不会启用ASLR。所以说还是具有一定的风险的。

image-20231105234045986

法③ 设置中关闭特定程序的ASLR机制

image-20231106113225826

方案2:更改ida中的基址为x64dbg中的基址√

如果出于种种原因(比如反反调试),不想禁用ASLR机制时,也可以将ida中映像的基址改为x64dbg动调时映像的基址。这样两者也会保持地址的一致,便于逆向分析。

ida中依次点击Edit -> Segments -> Rebase program...

image-20231106165927881

将此处的基址,修改为映像在x64dbg中对应的基址即可:

image-20231106170031974

这个方法挺简单的,而且程序会原封不动地按照原有逻辑运行,不会有任何文件中的、内存中的、注册表中的、等等的改动,不会被目标程序中的反调试、不兼容性等原因干扰。但是每次x64dbg动调时目标程序的基址变了,都需要手动在ida中对应地改一下。

方案3:通过RVA换算ida和x64dbg中的地址√
VA与RVA

VA:PE文件被加载器映射到内存中后,并没有直接使用物理地址,而是使用虚拟地址(Virtual Address, VA)。操作系统的内存管理单元负责将VA映射到真实的物理地址,以实现进程对内存的访问。这样一来,每个进程有一个抽象出来的独立的地址空间。

RVA:尽管PE文件头中规定了程序加载到内存中的基址,但是由于基址重定位、ASLR等机制,每次运行程序时,映像加载到内存中的基址都可能不同,我们很难通过一个确定的VA来描述某处逻辑的具体位置,但是此处距离映像基址的偏移是固定不变的,这就是相对虚拟地址(Relative Virtual Address, RVA)。

即我们有:

$$ RVA = VA - ImageBase $$

举个例子,如果某个函数的地址VA为0x401000,映像的基址为0x400000,此函数地址相对于基址的偏移为0x1000,那么我们就说这个函数的RVA0x1000

按照以上思路,我们可以通过RVA来转换同一个映像在基于不同的基址时的同一逻辑的地址。具体应用的时候有以下两种做法,其实原理是完全一样的,只是具体操作上有一点点小差异而已。

法①:x64dbg中获取RVA

x64dbg中,可以直接获取某个VA对应的RVA。

在想要转换的VA处(图中为0x00007FF792AC200E),右键菜单中选择复制 -> RVA

image-20231106203726399

此时RVA = 200E就被拷贝到剪切板中了。

再手动加上ida中的映像基址,就可以轻松计算出该地址在ida中对应的地址了。

但是很可惜,原生的ida中没有直接复制对应RVA的功能。不过检索发现好像有相关的插件,毕竟实现起来也很简单,感兴趣的话可以参考:使用IDAPython开发复制RVA的插件

法②:ida和x64dbg地址转换脚本

对于同一个PE文件,不管是在ida中,还是在x64dbg中,同一逻辑所处的虚拟地址相对于模块的基址肯定是一样的。下面的python脚本就可以用于协助计算二者地址的转换。

使用时首先需要给出idax64dbg中模块的基址,然后可以使用get_x64dbg_posida中的地址转换成x64dbg中对应的地址,也可以使用get_ida_posx64dbg中的地址转换成相应的ida中的地址。

# convert_address.py

ida_base = 0x140000000
x64dbg_base = 0x00007FF7E0790000

def get_x64dbg_pos(ida_pos):
    print(hex(ida_pos - ida_base + x64dbg_base))

def get_ida_pos(x64dbg_pos):
    print(hex(x64dbg_pos - x64dbg_base + ida_base))

get_x64dbg_pos(0x1400B0AE2)
get_ida_pos(0x0000007FF7E0840AE2)

游戏更新导致二进制文件变化√

问题描述

上一小节介绍的是同一个可执行文件在ida中和在x64dbg中地址的转换问题。但是在这个项目中,我们面临着一个新的问题:由于游戏每周都会更新,更新后的游戏程序会因为源代码(细微)改动、嵌入新的Resources数据、重新进行编译等等原因,导致汇编代码、函数地址、变量布局等等信息,都与上一周的游戏程序中的有所差异。

所以我们每周都要重新使用ida分析一次本周的游戏程序,这样才能和x64dbg动调时的本周的游戏程序对应起来。不然我们拿着上一周ida的分析结果,使用x64dbg对照着这一周的游戏程序进行动调,是没法完成idax64dbg中地址的一对一转化的。

其核心原因在于二者基于的不是同一个PE文件,因此同一个函数的RVA是否相同全是玄学。试以20230817~20230823版本的游戏程序和20231012~20231018版本的游戏程序对比为例。

20230817~20230823版本的游戏程序中,main函数的地址为0x14025B950,调用memset时的地址与memset的地址之间的偏移为0x898C38+5

image-20231106212007019

20231012~20231018版本的游戏程序中,main函数的地址为0x14025B250,调用memset时的地址与memset的地址之间的偏移为0x897E58+5

image-20231107092551869

可以明显看出,两个不同时间段的游戏程序,是有略微差异的。因而我们不能通过上一节介绍的几种方法,将上一周的ida分析文件中某个逻辑的地址,转化成这一周x64dbg动调时的地址。

但是在逆向这种大型项目的过程中,我们很难在一周之内就能完整地将需要分析的逻辑都整理出来。而每周的ida分析文件中都包含了大量我们逆向时的相关记录,难道每周都要重新分析一遍曾经逆过的逻辑(至少也得是要将关键性的逻辑更新到新一周的ida分析文件中)吗?这听起来工作量太大了,有什么优雅的解决方案吗?

解决方案

在逆向工程领域,特征码,或称匹配特征,是一种用于在二进制代码中搜索和匹配特定模式或标识符的特征组合,通常用于分析和调试程序时定位到目标代码,有助于查找代码中的关键信息,标记特定函数、数据结构或算法,或者识别恶意软件特征。

我们以上面展示的main函数为例,介绍如何通过特征码,从ida分析结果中旧版本的main函数,定位到x64dbg中新版本的main函数。

我们首先从ida分析结果(下图为20231012~20231018版本)中的main函数内部找到一段合适的特征码,比如说:

image-20231107092832002

然后在x64dbg中,首先需要在反汇编窗口中转到游戏进程的主模块。

可以在内存布局窗口,右键箭头指向的这一行,并选择在反汇编中转到

image-20231106234121345

也可以在反汇编窗口中,按ctrl+g,输入gujianol.base,最后回车就可以跳转到主模块的基址处了(其中gujianol是游戏进程的主模块的名称,如果该名称中有空格,则需要使用双引号包裹模块名称,后面再跟.base)。:

image-20231106234400280

在反汇编窗口中转到游戏进程的主模块之后,我们在反汇编窗口中,右键,依次选择搜索 -> 当前模块 -> 匹配特征(或者直接快捷键ctrl+shift+b):

image-20231106234907569

上图中的当前区域是指一个模块中的一个区段,比如下图中,gujianol.exe是一个区域,.text是另一个区域,BIND又是另一个区域:

image-20231106235534188

我们在匹配特征的时候,一般不选择所有模块,因为匹配结果可能会超级多。

在弹出的窗口中填写我们刚才选取的特征码:

image-20231106233031228

搜索后,刚好只找到一处符合匹配特征的数据:

image-20231106233158381

双击过去看一下,确实是我们要找的main函数:

image-20231106233233999

而在ida当中,同样也支持匹配特征,可以在工具栏中找到。:

image-20231107195639587

也是支持用??表示模糊匹配的。

image-20231107195534260

匹配结果:

image-20231107195556679

特征码选取原则

特征码不是随便选取的,不合适的特征码可能会没有匹配结果,也可能匹配出超级多的结果,影响我们的判断。从经验上来说,我认为选取特征码的时候,需要尽可能满足以下几个条件。

  • 不要选取共性的汇编代码

共性是指不仅目标函数中会包含,而且其他很多函数中也会包含。比如说函数开头的那几条负责处理栈的汇编代码,基本上所有的函数都遵循大致相同的特征。

还是以上面的main函数为例,如果我们选取的特征码是函数开头的那三条汇编指令:

image-20231107093006588

那么将会搜索出好几处符合匹配特征的地址:

image-20231106232506654

  • 避开涉及绝对地址或相对偏移的汇编指令

没有任何一个编译器能够保证可以用相同的参数编译相同的代码两次却还能得到两份一模一样的可执行文件,更何况游戏更新时一般都会更改一些源代码、嵌入一些Resources数据。我们前面就以两个版本的main函数为例论证过这个问题了。

因此我们在选取特征码的时候,一定要避开汇编代码中涉及到绝对地址或相对偏移的指令,比如call指令、jmp指令等等。如果无法完全避开,则需要使用??进行模糊匹配,比如:

image-20231107093212114

image-20231107091532935

当然也不是绝对不能用哈。如果是函数内部的相对跳转,或许也可以选用,比如下图中的函数内部的近跳转,版本更新的时候一般也不会改变:

image-20231107093530478

密码算法扫描插件√

findcrypt-yara这个IDA插件可以用于扫描一些密码算法的魔数常量,便于我们分析程序使用了哪些密码算法。

从Github下载后,将findcrypt3.pyfindcrypt3.rules放入IDA的plugins文件夹,然后python -m pip install yara-python==4.2.3安装yara-python

使用快捷键ctrl+alt+f即可调用此插件,或者:

image-20231030103537862

需要注意的是,如果运行插件,出现下图所示的报错:

image-20231029102543585

是因为yara-python的最新版本更改了yara.StringMatch的定义,改用4.2.3版本的yara-python就可以了:

image-20231029102213230

image-20231029102448583

运行插件后可以观察到:

image-20231030104159175

image-20231030104139008

image-20231030104115995

image-20231030104025423

image-20231030104045592

这个分析结果只能作为一个参考,并不完全准确,比如说就没有正确分析出crc16的常量表。

某类函数批量重命名√

在对游戏程序进行初步逆向分析的时候,偶然间我发现在.data段和.rdata区段中,出现了很多类似这样的数据:

.data段:

image-20231102191543215

.rdata段:

image-20231102191819637

稍微分析一下,就能看出实际上每两个QWORD为一组,第一个QWORD是函数名称的字符串地址,第二个QWORD是函数的地址。这样的数据结构实际上与Lua有关,我们会在后续关于Lua的章节中详细介绍,本节暂且不过多涉及。

我们可以手动将这些函数重命名为对应的字符串,便于后续分析时能够更加清晰地查看函数的交叉引用,从而提高逻辑分析的效率。但是实在是太多了,一个一个手动重命名相当繁琐,不太现实。

我写了一个idapython脚本,可以批量将此类函数重命名。使用时需要修改.code(即.text), .data, .rdata区段的开始地址和结束地址,可在ida中按g查看:

image-20231102191324545

# rename_functions.py
# idapython scripts

code_section_begin_addr = 0x140001000
code_section_end_addr = 0x14117E000 

data_section_begin_addr = 0x143D1F000
data_section_end_addr = 0x14488B000

rdata_section_begin_addr = 0x141180E88
rdata_section_end_addr = 0x143D1F000

cnt = 0

def belong_code_section(addr):
    return code_section_begin_addr <= addr <= code_section_end_addr

def auto_rename_func_name(begin_pos, end_pos):
    pos = begin_pos
    while True:
        if pos + 16 > end_pos:
            break

        func_name_pos = struct.unpack('Q', get_bytes(pos, 8))[0]
        if not is_loaded(func_name_pos) or belong_code_section(func_name_pos):
            pos += 8
            continue
        
        func_name = get_strlit_contents(func_name_pos, -1, STRTYPE_C)
        if not func_name:
            pos += 8
            continue

        func_offset = struct.unpack('Q', get_bytes(pos+8, 8))[0]
        if not is_loaded(func_offset) or not belong_code_section(func_offset):
            pos += 8
            continue

        func_name = '%s_%s' % (func_name.decode(), hex(func_offset)[2:])
        print(hex(func_offset), func_name)
        # https://hex-rays.com/products/ida/support/idadoc/203.shtml
        success = ida_name.set_name(func_offset, func_name)
        if success:
            global cnt
            cnt += 1
        pos += 16


auto_rename_func_name(data_section_begin_addr, data_section_end_addr)
auto_rename_func_name(rdata_section_begin_addr, rdata_section_end_addr)
print('共重命名%d个函数' % cnt)

IDA Help: Alphabetical list of IDC functions (hex-rays.com)

函数功能文档
is_loaded(ea)字节是否已初始化(通俗一点就是ea是否是ida中的有效地址)click
get_strlit_contents(long ea, long len, long type)获取字符串内容(返回字符串内容或空字符串)click
set_name(long ea, string name, long flags=SN_CHECK)重命名一个地址click

脚本的原理其实很简单。分别从.data.rdata区段的开始地址开始,首先判断第一个QWORD的值是否是ida中的有效地址,若是则继续判断该值是否是一个字符串的起始地址,若是则将其记作函数名称,继续判断下一个QWORD的值是否是ida中的有效地址且位于.text区段(因为函数代码必定位于.text区段),若是,则将此值对应的函数重命名。以上判定的任何一处为否定,则指针后移8个字节,重新进行以上算法,直到到达.data.rdata区段的结束地址。

运行脚本后,可能会有弹窗:

image-20231102194216479

这是因为对应的字符串含有不支持用于函数名称的字符,比如图中的这个是因为函数名称不能以!字符开头。勾选图中的Don't display this message again再点击OK就不再弹窗了(当然这类函数也不会被重命名,如果需要可以修改前面的脚本,把字符串做进一步的动态更改,以满足ida中函数命名规范,我这里因为用不太到,所以就不修改了)。

脚本运行成功后输出一览:

image-20231102195123149

1400多个函数,还好直接上脚本了,不然手动重命名肯定烦死,而且也很难全部发现此类函数,因为这些数据不是聚在一个地方,而是分散在.data.rdata区段中。

通过脚本能够查找到对应字符串的这类sub开头的函数,都被重命名了:

.data段:

image-20231102195520209

.rdata段:

image-20231102195408184

新User多开游戏进程分析

资源文件的提取

本章主要聚焦于资源文件的提取,即通俗意义上的解包。

其实资源文件主要是指存储于磁盘当中、高达百G大小的游戏资源的打包文件(本章将之划分并称为索引文件磁盘文件),但是由于加解密逻辑的相似性,因而我把对内嵌在exe文件中的Resources文件(本章将之称为内存文件)的提取也整合在本章当中。

另外需要说明的是,由于处理资源文件的索引关系、以及将必要的资源文件加载到内存中,是在游戏初始化的时候进行的,因此本章的大多数时候,都需要我们通过使用前面介绍过的DbgChild插件,来从头开始附加到游戏进程上。由于本章动调的地方比较多,所以后续不会每次动调时都强调这一点了。

初步探索

在前面展示游戏文件目录的时候,我们就已经介绍过了该游戏的资源文件存储在data文件夹中:

1) 磁盘文件data文件夹中存放着data000 ~ data116,每个文件约1G。
2) 索引文件data/_index文件夹中存放着708.idx, 753.idx等文件,大小在8M~9M左右,初步推测是文件索引。

因为游戏在初始化的时候,必定会加载存储在磁盘中的资源文件(至少也会处理一下基本的索引关系,以便后续在游戏运行过程中,能够通过这个索引,来加载具体的文件),所以我们可以在x64dbg中,尝试在CreateFileACreateFileW下断点。

经过测试,主要是CreateFileW被大量触发,偶尔触发的CreateFileA基本上是系统DLL或第三方DLL调用的,比如:

image-20231031154533815

接下来我们在CreateFileW的断点中加上日志,以便观察操作了哪些文件:

image-20231029210442808

由于CreateFileW涉及到的不只有游戏目录下的一些文件:

image-20231029205454210

也会涉及一些系统盘中的文件,比如:

image-20231029205414985

image-20231029205703798

我们不关心的这类文件太多了,影响分析,加上一个日志条件进行过滤,这里我们简单地用第一个字节是不是Dd来排除(游戏存放在D盘):

image-20231029210600460

这次日志就清晰很多了,从启动游戏一直到进入游戏的开始界面为止,共有614条CreateFileW记录,这里只介绍和处理资源文件相关的日志。

CreateFileW: d:\Game\GujianOL\bin64/gujianol.log

CreateFileW: d:\Game\GujianOL/data/data000
CreateFileW: d:\Game\GujianOL/data/data001
CreateFileW: d:\Game\GujianOL/data/data002
CreateFileW: d:\Game\GujianOL/data/data003
CreateFileW: d:\Game\GujianOL/data/data004
......(省略的dataxxx都是一条记录)......
CreateFileW: d:\Game\GujianOL/data/data253
CreateFileW: d:\Game\GujianOL/data/data254
CreateFileW: d:\Game\GujianOL/data/data255

CreateFileW: d:\Game\GujianOL/data/data000
CreateFileW: d:\Game\GujianOL/data/data000
CreateFileW: d:\Game\GujianOL/data/data001
CreateFileW: d:\Game\GujianOL/data/data001
CreateFileW: d:\Game\GujianOL/data/data002
CreateFileW: d:\Game\GujianOL/data/data002
......(省略的dataxxx都是两条记录)......
CreateFileW: d:\Game\GujianOL/data/data115
CreateFileW: d:\Game\GujianOL/data/data115
CreateFileW: d:\Game\GujianOL/data/data116
CreateFileW: d:\Game\GujianOL/data/data116

CreateFileW: d:\Game\GujianOL/data/_index/708.idx

CreateFileW: d:\Game\GujianOL\bin64/../settings/LanguageSetting.dat
CreateFileW: d:\Game\GujianOL\bin64/../VERSION
CreateFileW: D:\Game\GujianOL\bin64\NvCamera\ShotWithGeforce518x32.rgba
CreateFileW: D:\Game\GujianOL\bin64/Log_GujianOL.htm
CreateFileW: D:\Game\GujianOL\bin64\GujianOL.exe

很明显观察到,CreateFileW函数,首先从data000data255处理了一遍,然后又从data000data116处理了两遍,最后处理了_index/708.idx

索引文件

逻辑定位

我们十分关注上述CreateFileW函数在哪里被调用(来打开dataxxx文件以及idx文件的句柄),以及之后游戏进程又进行了哪些文件操作,所以可以考虑将此断点的暂停条件设置为rcx指向的字符串等于d:\Game\GujianOL/data/_index/708.idx

为啥不选择d:\Game\GujianOL/data/data000呢?这是因为dataxxx文件总计一百多G,肯定不会全部加载到内存中,而idx文件根据名称我们初步判断是索引文件,而且只有9M大小,应该会在游戏初始化的时候,加载到内存中。(然而实际上其实我都有尝试过,写博客时觉得先写索引文件的逆向分析过程比较好,逻辑上一脉相承)

另外注意上面的这个字符串中,data前面的斜杠是/而非\,且开头的盘符是d而非D,这是我们通过上一步的日志发现的。如果我们按照习惯,设置的条件一般是D:\Game\GujianOL\data\_index\708.idx,是断不下来的。

接下来按照前面介绍过的老版本中字符串断点的实现,我们为CreateFileW的断点设置下面的条件:

d:\Game\GujianOL/data/_index/708.idx
0x0     0x47005c003a0064    d:\G
0x8     0x5c0065006d0061    ame\
0x10    0x69006a00750047    Guji
0x18    0x4c004f006e0061    anOL
0x20    0x7400610064002f    /dat
0x28    0x69005f002f0061    a/_i
0x30    0x7800650064006e    ndex
0x38    0x3800300037002f    /708
0x40    0x7800640069002e    .idx

image-20231107195156000

从而断在:

image-20231107204359066

此时堆栈为下图所示:

image-20231107204426894

其中的第一个数据,亦即CreateFileW的返回地址,为0x00007FF7D5153E30

image-20231107204535796

此地址所位于的函数,对应ida中的Func_CreateFileHandle_1400A3D00

(注意,逆向过程中,我们不可能在一开始就能分析出每个函数的功能,并重命名为一个清晰的名称。但是为了便于记录和理解,本文中涉及到的函数,如果是我曾分析并重命名的函数,均直接给出由我重命名后的函数名称。不然文中出现一堆sub开头的函数名,肯定会特别混乱)

image-20231107200420413

x64dbg中动调跳出这个函数(ctrl+f9直达函数retf8单步步过)后,会立马来到:

image-20231107205005110

此处对应ida中的Func_LoadFileHandle_1400A3BE0

image-20231107201639324

继续跳出当前函数,来到:

image-20231107205051294

此处对应ida中的Func_GetFileData_1400A9E90

image-20231107201949543

接下来我们就分析一下这三个函数,以及其内部调用的其他关键函数。

逆向分析

1 Func_CreateFileHandle_1400A3D00

总结

根据iType (args2) 决定CreateFileW的相关标志,并通过CreateFileW打开pwszFilePath (args1) 文件的句柄,最后返回Stru_FileHandle结构体(该结构体中包含文件句柄、与文件操作相关的函数数组这两个成员变量)。

伪代码

这里附上经过我分析后的伪代码,以便后续分析时可以对照着它来理解具体的实现细节。函数内部的关键函数和变量均已重命名,同时对于涉及到的结构体,已经尽可能地分析出了具体的结构。本章后续的分析我都会按照这个模式来布局。

Stru_FileHandle *__fastcall Func_CreateFileHandle_1400A3D00(WCHAR *pwszFilePath, int iType)
{
  DWORD dwFlagsAndAttributes; // ebp
  WCHAR *pwszFilePath_1; // rdi
  DWORD dwCreationDisposition; // esi
  DWORD dwDesiredAccess; // r14d
  int v7; // ebx
  int v8; // ebx
  int v9; // ebx
  DWORD FileAttributesW; // eax
  HANDLE hFile; // rbx
  Stru_FileHandle *pSFileHandle; // rax
  WCHAR wszTempPath[2048]; // [rsp+40h] [rbp-2028h] BYREF
  WCHAR wszTempFileName[2048]; // [rsp+1040h] [rbp-1028h] BYREF

  dwFlagsAndAttributes = 0;
  pwszFilePath_1 = pwszFilePath;
  dwCreationDisposition = 3;                    // OPEN_EXISTING
  dwDesiredAccess = 0xC0000000;                 // READ and WRITE
  if ( !pwszFilePath )                          // 如果参数pwszFilePath为NULL,则创建一个Temp文件
                                                // 此时需iType = 3
  {
    if ( iType != 3
      || GetTempPathW(0x800u, wszTempPath) - 1 > 0x7FF
      || !GetTempFileNameW(wszTempPath, L"_tmp_", 0, wszTempFileName) )
    {
      return 0i64;
    }
    pwszFilePath_1 = wszTempFileName;
  }
  if ( !iType )
  {
    dwDesiredAccess = 0x80000000;               // iType = 0   READ
                                                // https://learn.microsoft.com/en-us/windows/win32/secauthz/access-mask-format
    if ( (unsigned int)off_143E093C8(pwszFilePath_1) )
      goto LABEL_18;
    return 0i64;
  }
  v7 = iType - 1;
  if ( !v7 )
  {
    dwCreationDisposition = 4;                  // iType = 1
                                                // OPEN_ALWAYS
    if ( (unsigned int)off_143E093C8(pwszFilePath_1) )
    {
      sub_1400A3CC0(pwszFilePath_1);
      goto LABEL_18;
    }
    return 0i64;
  }
  v8 = v7 - 1;
  if ( v8 )
  {
    v9 = v8 - 1;
    if ( !v9 )
    {
      dwCreationDisposition = 2;                // iType = 4
                                                // CREATE_ALWAYS
      dwFlagsAndAttributes = 0x4000002;
      goto LABEL_18;
    }
    if ( v9 != 1 )
      goto LABEL_18;                            // iType = 4
  }
  dwCreationDisposition = 2;                    // iType = 2
  FileAttributesW = GetFileAttributesW(pwszFilePath_1);
  if ( FileAttributesW != -1 && (FileAttributesW & 1) != 0 )
    SetFileAttributesW(pwszFilePath_1, FileAttributesW & 0xFFFFFFFE);
LABEL_18:
  hFile = CreateFileW(pwszFilePath_1, dwDesiredAccess, 3u, 0i64, dwCreationDisposition, dwFlagsAndAttributes, 0i64);// 
                                                // 1-动调CreateFileW 708.idx
                                                // 1-动调CreateFileW 第二轮data000
  if ( hFile == (HANDLE)-1i64 )
    return 0i64;
  pSFileHandle = (Stru_FileHandle *)g_pSMemoryFuncs->Func_Wrapper_malloc_1400A6410(g_pSMemoryFuncs->qwZero, 16i64);// malloc(16)
  if ( !pSFileHandle )
  {
    CloseHandle(hFile);
    return 0i64;
  }
  pSFileHandle->hFile = hFile;
  pSFileHandle->pFuncs = &Stru_FileFuncs1_143E09410;
  return pSFileHandle;
}
Part1

根据参数iType来设置CreateFileWdwDesiredAccess, dwCreationDispositiondwFlagsAndAttributes这三个标志,然后调用CreateFileW来打开pwszFilePath的句柄。

我们其实目前并不关心这些标志分别代表什么含义,无非是诸如文件不存在时要不要创建新的文件等等,所以暂且略过。

Part2

通过封装的malloc来创建一个16字节的空间。

image-20231107214610435

上面的伪代码截图是经过我分析过后的,而ida中的原版其实是这样的,分析的时候,头一下子就大了起来:

image-20231107215118043

其实主要的难点在于off_143E093A0。经过观察,指针关系为off_143E093A0 -> off_143E094A0 -> sub_1400A6410

因此*off_143E093A0()off_143E094A0(),而off_143E094A0()其实就是call sub_1400A6410(如果感觉反直觉的话,可以仔细想想pFunc()调用的pFunc的地址,还是pFunc的值)。

off_143E093A0[3]*((*off_143E093A0) + 0x18)*(0x143E094A0 + 0x18)*(0x143E094B8),也就是0

image-20231107222031530

如果还是难以理解,可以借助此处的汇编代码来理解:

image-20231107220827807

顺便说一下,从off_143E094A0开始的32个字节,存储的是3个函数和一个0,三个函数分别封装了malloc, freefree + realloc,都通过mov rcx, rdx将传入的第一个参数丢弃掉(可见上图中的汇编代码),我们分别将之重命名为Func_Wrapper_malloc_1400A6410, Func_Wrapper_free_1400A6420Func_Wrapper_realloc_1400A6430

关系捋清了之后,为了伪代码中能够清晰地反映出调用了哪个函数,我们可以创建如下结构体Stru_MemoryFuncs

image-20231108092533946

然后将off_143E094A0开始的32字节转换为此结构体,并重命名为Stru_MemoryFuncs_143E094A0

image-20231107225217428

然后将off_143E093A0重命名为g_pSMemoryFuncs

image-20231108092121027

ida可能会进行自动类型推断,根据Stru_MemoryFuncs_143E094A0Stru_MemoryFuncs结构体,从而将g_pSMemoryFuncs自动判断为Stru_MemoryFuncs *。但是也有可能没有,这就需要我们光标选中g_pSMemoryFuncs后按y修改变量的声明:

image-20231108093618583

由于这是我们手动声明的,ida会默认这就是绝对正确的,不会再对它进行自动类型推断,因而主窗口中会显示一行注释,表明此处的变量类型:

image-20231108093917528

我们再回到伪代码中:

image-20231108094428584

可以发现这里已经变得清晰多了。如果想要伪代码更简洁一点,可以参考前面结构体一节中的函数指针来优化成:

image-20231108103802290

Part3

在刚创建的16字节空间中,前8字节为一个全局的地址0x143E09410(是另一个存储着一些函数地址的数组),后8字节存储文件的句柄:

image-20231108105001889

因此我们定义Stru_FileHandle的结构为:

image-20231107211906584

其中,Stru_FileFuncs1_143E09410处的函数有:

image-20231108105202380

可以依次分析并重命名。

Func_Wrapper_CloseHandle_1400A3450 关闭文件句柄:

image-20231108105826384

Func_Wrapper_FlushFileBuffers_1400A3490 刷新文件缓冲区:

image-20231108105757203

Func_Wrapper_ReadFile_1400A34B0 读取文件:

image-20231108105700424

Func_Wrapper_WriteFile_1400A34E0 写入文件:

image-20231108105954250

Func_Wrapper_SetFilePointerEx_1400A3510 设置文件指针:

image-20231108110022855

其中的LARGE_INTEGER其实是一个union:

typedef __int64 LONGLONG;  
typedef union _LARGE_INTEGER {
    struct {
        ULONG LowPart;
        LONG HighPart;
    };
    struct {
        ULONG LowPart;
        LONG HighPart;
    } u;
    LONGLONG QuadPart;
} LARGE_INTEGER;

Func_GetFileSizeAndTime_1400A3570 获取文件大小和时间:

image-20231108110056032

经过分析,我们可以创建一个结构体Stru_FileFuncs1方便后续伪代码中的显示:

image-20231108105514973

image-20231108105408814

同时需要将Stru_FileHandle中的pFuncs的数据类型从默认的__int64更改为Stru_FileFuncs1 *,这样才能在伪代码中显示出这样的效果:

image-20231108185100055

注意,此时Stru_FileFuncs1结构体内的成员变量,看起来像函数,但实际上的类型只是__int64。同时因为ida自动推断函数参数信息的失误,因此可能会有下面这种显示:

image-20231109152157933

而我们经过前面的分析,可以知道Func_Wrapper_free_1400A6420其实是有两个参数的,上图却只显示了一个参数。

我们可以选择在红框中的任意位置右键,并选择Set call type...

image-20231109154231295

设置正确的函数原型:

image-20231109154329008

于是就有了正确的显示:

image-20231109153735291

我们也可以先修改结构体中Func_Wrapper_free_1400A6420这个成员变量的类型为:

image-20231109152550076

然后在同样的右键菜单中,选择Force call type,这样就能够在此处强制性采用g_pSMemoryFuncs->Func_Wrapper_free_1400A6420的函数类型了,能够实现同样的效果。

但是,如果没有把g_pSMemoryFuncs->Func_Wrapper_free_1400A6420的数据类型手动修改为对应的函数原型,还是默认的__int64的话,那么同样的右键菜单中是不会出现Force call type这个子选项的。

2 Func_LoadFileHandle_1400A3BE0

总结

套壳Func_CreateFileHandle_1400A3D00

伪代码

image-20231108114112165

分析

这个函数结构上相对简单。

首先通过Func_Ansi2Utf16_1400A3010szFilePath转换为pwszFilePath。具体转换逻辑我分析了一些,但和当前的主线任务关系不大,这里就不介绍了。

然后调用Func_CreateFileHandle_1400A3D00获取pSFileHandle并返回。

3 Func_GetFileData_1400A9E90

总结
  1. 传入索引文件的路径
  2. 打开索引文件句柄Stru_FileHandle
  3. 读取索引文件前32字节,解析头部,完成crc16校验,设置解压算法
  4. 获取文件大小和文件时间
  5. 读取索引文件数据并解压
  6. 解析索引文件的具体数据,实现相关初始化操作。
伪代码
__int64 __fastcall Func_GetFileData_1400A9E90(
        __int64 a1,
        const char *szDataDirPath,
        char *szFileRelativePath,
        void (__fastcall *pfnDecompressFuncOrNull)(__int64, _QWORD, __int64 (__fastcall **)()))
{
  int v4; // r15d
  char *v6; // rbx
  const char *v7; // rdi
  unsigned int v9; // ebp
  __int64 v11; // rax
  __int64 v12; // rcx
  char *v13; // rax
  CHAR *szFilePath; // rsi
  Stru_FileHandle *pSFileHandle; // rbx
  Stru_PackFileInfoWithDecOrWithNoDec *pSPackFileInfoWithDecOrWithNoDec_1; // rax
  Stru_PackFileInfoWithDecOrWithNoDec *pSPackFileInfoWithDecOrWithNoDec; // rbx
  __int64 pFuncs; // rax
  __int64 dwPlainLen; // rsi
  void *pbFilePlain; // rdi
  Stru_PackFileInfoWithDecOrWithNoDec *v21; // rdi
  __int64 v22; // rax
  size_t dwPlainLen_1; // [rsp+50h] [rbp+8h] BYREF

  v4 = 0;
  v6 = szFileRelativePath;
  v7 = szDataDirPath;
  v9 = 1;
  if ( !a1 || *(__int64 (__fastcall ***)())(a1 + 64) != &off_143E095A0 || !szFileRelativePath )
    return 0i64;
  if ( *szFileRelativePath == '*' )
  {
    v6 = szFileRelativePath + 1;
    v4 = 1;
  }
  if ( szDataDirPath )
  {
    v11 = -1i64;
    v12 = -1i64;
    do
      ++v12;
    while ( szDataDirPath[v12] );
    do
      ++v11;
    while ( v6[v11] );
    v13 = (char *)malloc(v11 + 8 + v12);
    szFilePath = v13;
    if ( !v13 )
      return 0i64;
    if ( *v7 == '*' )
      ++v7;
    sprintf(v13, "%s/%s", v7, v6);              // 拼接出完整的文件路径
    pSFileHandle = Func_LoadFileHandle_1400A3BE0(szFilePath, 0);// 3-动调CreateFileW 708.idx
    free(szFilePath);
  }
  else
  {
    pSFileHandle = Func_LoadFileHandle_1400A3BE0(v6, 0);
  }
  if ( !pSFileHandle )
    return 0i64;
  pSPackFileInfoWithDecOrWithNoDec_1 = Func_ParsePackFileHeaderAndSetDecompressFunc_1400A8000(
                                         pSFileHandle,
                                         0i64,
                                         0i64,
                                         pfnDecompressFuncOrNull);
  pSPackFileInfoWithDecOrWithNoDec = pSPackFileInfoWithDecOrWithNoDec_1;
  if ( !pSPackFileInfoWithDecOrWithNoDec_1 )
    return 0i64;
  if ( v4 )
  {
    pFuncs = pSPackFileInfoWithDecOrWithNoDec_1->pFuncs;// 
                                                // g_pFuncs_PackFileWithNoDec_143E09620 or
                                                // g_pFuncs_PackFileWithDec_143E09558
    dwPlainLen_1 = 0i64;
    if ( (*(unsigned int (__fastcall **)(Stru_PackFileInfoWithDecOrWithNoDec *, size_t *, _QWORD))(pFuncs + 40))(
           pSPackFileInfoWithDecOrWithNoDec,
           &dwPlainLen_1,
           0i64) )                              // Func_GetFilePlainLenAndTimeWithNoDec_1400BEF00 or
                                                // Func_GetFilePlainLenAndTimeWithDec_1400A7CB0
    {
      dwPlainLen = dwPlainLen_1;
      pbFilePlain = malloc(dwPlainLen_1);
      if ( !pbFilePlain
        || (*(__int64 (__fastcall **)(Stru_PackFileInfoWithDecOrWithNoDec *, void *, __int64))(pSPackFileInfoWithDecOrWithNoDec->pFuncs
                                                                                             + 16))(// 
                                                // *(_QWORD *)pSPackFileInfoWithDecOrWithNoDec即为pFuncs
                                                // pFuncs + 16为 
                                                // Func_ReadFileDataWithNoDec_1400BEE30 or 
                                                // Func_ReadFileDataWithDec_1400A7B00
             pSPackFileInfoWithDecOrWithNoDec,
             pbFilePlain,
             dwPlainLen) != dwPlainLen
        || !(unsigned int)sub_1400AADD0(a1, (__int64)pbFilePlain, dwPlainLen) )
      {
        v9 = 0;
      }
      free(pbFilePlain);
    }
  }
  else
  {
    v21 = (Stru_PackFileInfoWithDecOrWithNoDec *)sub_1400A6F80((__int64)pSPackFileInfoWithDecOrWithNoDec_1, 0x40000u);
    v22 = pSPackFileInfoWithDecOrWithNoDec->pFuncs;
    if ( v21 )
    {
      (*(void (__fastcall **)(Stru_PackFileInfoWithDecOrWithNoDec *))v22)(pSPackFileInfoWithDecOrWithNoDec);
      pSPackFileInfoWithDecOrWithNoDec = v21;
    }
    else
    {
      (*(void (__fastcall **)(Stru_PackFileInfoWithDecOrWithNoDec *, _QWORD, _QWORD))(v22 + 32))(
        pSPackFileInfoWithDecOrWithNoDec,
        0i64,
        0i64);
    }
    sub_1400A9860(a1, pSPackFileInfoWithDecOrWithNoDec, 0i64);
  }
  if ( pSPackFileInfoWithDecOrWithNoDec )
    (*(void (__fastcall **)(Stru_PackFileInfoWithDecOrWithNoDec *))pSPackFileInfoWithDecOrWithNoDec->pFuncs)(pSPackFileInfoWithDecOrWithNoDec);
  return v9;
}
Part1

image-20231108145435208

分了两种情况,但最终都要调用Func_LoadFileHandle_1400A3BE0加载文件句柄并返回Stru_FileHandle的指针。

Part2

image-20231108145758278

将上一步获取的pSFileHandle传入Func_ParsePackFileHeaderAndSetDecompressFunc_1400A8000,解析文件32字节的头部并设置了解压算法。

需要注意的是,根据实际情况,该函数返回的指针指向的数据的类型实际上不唯一,有可能是两种结构体,Stru_PackFileInfoWithNoDec(对应无需解压的数据)和Stru_PackFileInfoWithDec(对应需要解压的数据),分别由该函数内部调用的Func_SetPackFileInfoWithNoDec_1400BEF30Func_SetPackFileInfoWithDec_1400A7CD0返回。尽管二者大部分成员变量都不同,但是+0处的成员变量都是pFuncs,我根据二者共有的成员变量,定义了共用的Stru_PackFileInfoWithDecOrWithNoDec,以便在伪代码中显示出通用的pFuncs字段。

Part3

image-20231108151920671

Func_ParsePackFileHeaderAndSetDecompressFunc_1400A8000的不同结构体指针类型的返回值、对应的+0成员变量pFuncs(一个函数数组)、以及后续伪代码中涉及到的pFuncs中的两个函数可见下表:

返回值指向的数据类型S->pFuncs*((S->pFuncs) + 40)*((S->pFuncs) + 16)
Stru_PackFileInfoWithNoDecg_pFuncs_PackFileWithNoDec_143E09620Func_GetFilePlainLenAndTimeWithNoDec_1400BEF00Func_ReadFileDataWithNoDec_1400BEE30
Stru_PackFileInfoWithDecg_pFuncs_PackFileWithDec_143E09558Func_GetFilePlainLenAndTimeWithDec_1400A7CB0Func_ReadFileDataWithDec_1400A7B00

上图伪代码中的pFuncs + 40,根据返回值指向的结构体类型的不同,将分别调用Func_GetFilePlainLenAndTimeWithNoDec_1400BEF00Func_GetFilePlainLenAndTimeWithDec_1400A7CB0。这两个函数均用于获取文件的明文长度和文件的时间。

Part4

image-20231108152239649

伪代码中的pFuncs + 16,根据返回值指向的结构体类型的不同,将分别调用Func_ReadFileDataWithNoDec_1400BEE30Func_ReadFileDataWithDec_1400A7B00。这两个函数均用于获取文件明文数据,前者直接读取文件数据并返回,后者需要读取并解压所有chunk再返回。

Part5

解压出索引文件的明文数据后,通过这个函数做最后的处理:

image-20231109195605893

我推测应该就是将索引文件的信息处理成一个关于索引的数据结构,用于检索磁盘文件。

因为和解包的主线任务关系不大,所以这个函数就不分析了。

3-1 Func_ParsePackFileHeaderAndSetDecompressFunc_1400A8000

总结

本章最最最关键的函数,应该没有之一!

  1. 读取pSFileHandle (args1) 当前文件指针处的前32字节,解析并进行crc16校验。
  2. 根据上一步解析结果中的wType,设置不同的解压函数或NULL。
  3. 根据是否设置解压函数,调用相应函数将目标文件的相关信息整合到Stru_PackFileInfoWithNoDecStru_PackFileInfoWithDec中。
伪代码
Stru_PackFileInfoWithDecOrWithNoDec *__fastcall Func_ParsePackFileHeaderAndSetDecompressFunc_1400A8000(
        Stru_FileHandle *pSFileHandle,
        __int64 liDistanceToMove,
        __int64 dwCipherLen,
        void (__fastcall *pfnDecompressFuncOrNull)(__int64, _QWORD, __int64 (__fastcall **)()))
{
  __int64 wType; // rcx
  unsigned int (__fastcall *pfnDecompressFunc)(Stru_DecompressContext *, Stru_DecompressContext *); // rcx
  Stru_PackFileInfoWithDec *v10; // rdi
  unsigned int (__fastcall *pfnDecompressFunc_1)(Stru_DecompressContext *, Stru_DecompressContext *); // [rsp+30h] [rbp-68h] BYREF
  Stru_FileStorageHeaderWhenPreprocess pSHeader; // [rsp+38h] [rbp-60h] BYREF
  char pbFileHeader32B[32]; // [rsp+58h] [rbp-40h] BYREF

  pfnDecompressFunc_1 = 0i64;
  if ( !pSFileHandle )
    return 0i64;                                // 注意,pSFileHandle结构体的pFuncs,
                                                // 我默认指向了Stru_FileFuncs1_143E09410,
                                                // 但不是百分百是这样的,
                                                // 也可能是Stru_MemFileFuncs_143E09528 等
  if ( ((__int64 (__fastcall *)(Stru_FileHandle *, __int64, _QWORD))pSFileHandle->pFuncs->Func_Wrapper_SetFilePointerEx_1400A3510)(// 此函数也可能是sub_1400A6C00
         pSFileHandle,
         liDistanceToMove,
         0i64) != liDistanceToMove
    || pSFileHandle->pFuncs->Func_Wrapper_ReadFile_1400A34B0(pSFileHandle, pbFileHeader32B, 32i64) != 32// 此函数也可能是sub_1400A6AA0
                                                // 读取PackFile开头32字节
                                                // 动调708.idx, data111都可能走这
    || !(unsigned int)Func_Crc16AndParseFileStorage_1400A7E90((__int64)pbFileHeader32B, &pSHeader)
    || dwCipherLen && dwCipherLen != pSHeader.qwCipherLen )
  {
    pSFileHandle->pFuncs->Func_Wrapper_CloseHandle_1400A3450(pSFileHandle);
    return 0i64;
  }
  wType = (unsigned __int16)pSHeader.wStorageType;
  if ( pSHeader.wStorageType )
  {
    if ( pSHeader.wStorageType == 1 || pSHeader.wStorageType == 2 )
    {
      pfnDecompressFunc = Func_DecompressType1or2_1400A7270;
      pfnDecompressFunc_1 = Func_DecompressType1or2_1400A7270;
      goto LABEL_14;
    }
    if ( pSHeader.wStorageType == 3 )
    {
      pfnDecompressFunc = Func_DecompressType3_1400A7410;
      pfnDecompressFunc_1 = Func_DecompressType3_1400A7410;
      goto LABEL_14;
    }
    if ( pfnDecompressFuncOrNull )              // 动调查看rbp即pfnDecompressFuncOrNull
                                                // 为Func_SetOodleCompressAndDecompressFunc_140227F90
                                                // wStorageType = 4, 5, 6, 7时设置Oodle解压函数
                                                // wStroageType >= 8时解压函数为NULL
    {
      LOWORD(wType) = pSHeader.wStorageType - 4;
      pfnDecompressFuncOrNull(wType, 0i64, (__int64 (__fastcall **)())&pfnDecompressFunc_1);
    }
  }
  pfnDecompressFunc = pfnDecompressFunc_1;
LABEL_14:
  if ( !pfnDecompressFunc )                     // 没有设置解压函数时执行此段代码
    return (Stru_PackFileInfoWithDecOrWithNoDec *)Func_SetPackFileInfoWithNoDec_1400BEF30(
                                                    pSFileHandle,
                                                    pSHeader.qwTime,
                                                    liDistanceToMove + 32,
                                                    pSHeader.qwPlainLen,
                                                    1);
  v10 = Func_SetPackFileInfoWithDec_1400A7CD0(  // 设置了解压函数时执行此段代码
          pSFileHandle,
          pSHeader.qwCipherLen,
          pSHeader.qwTime,
          pSHeader.qwPlainLen,
          pfnDecompressFunc,
          pSHeader.dwChunkPlainLen);
  if ( !v10 )
  {
    pSFileHandle->pFuncs->Func_Wrapper_CloseHandle_1400A3450(pSFileHandle);
    return 0i64;
  }
  return (Stru_PackFileInfoWithDecOrWithNoDec *)v10;
}
Part1

image-20231108153613114

设置文件的指针,读取文件中从liDistanceToMove处开始的32字节,然后通过Func_Crc16AndParseFileStorage_1400A7E90解析这32字节的数据并进行crc16校验。

Func_CreateFileHandle_1400A3D00中,我们定义了Stru_FileHandle结构体,但是当时还不知道其中的pFuncs成员的用处。这里就展示了调用pFuncs中的Func_Wrapper_SetFilePointerEx_1400A3510Func_Wrapper_ReadFile_1400A34B0的代码。

伪代码中的注释中说的Stru_FileHandlepFuncs不一定指向我们前面分析的Stru_FileFuncs1_143E09410,我们留待后续讨论。

Part2

image-20231108185405774

根据上一步从文件的前32字节解析而来的pSHeader中的wType,选择不同的解压函数。

wType解压函数
1~2Func_DecompressType1or2_1400A7270
3Func_DecompressType3_1400A7410
4~7Func_SetOodleCompressAndDecompressFunc_140227F90里面设置具体的解压函数
其他NULL

前两个解压函数很容易分析出来,不多赘述。

wType不为1, 2, 3时,都将执行Func_SetOodleCompressAndDecompressFunc_140227F90,但是在该函数内部,只有wType为4~7时会设置相应的解压函数。因而其他情况的wType对应的pfnDecompressFunc均为NULL。

但是问题是,Func_SetOodleCompressAndDecompressFunc_140227F90并没有出现在上图代码中,是怎么被我们发现的呢?

注意看,上图第54行,调用了当前所处函数的第四个参数pfnDecompressFuncOrNull,这个函数我们有两种方法来确定。

动调

动调到当前函数Func_ParsePackFileHeaderAndSetDecompressFunc_1400A8000

image-20231108201328816

查看第四个参数r90x00007FF604337F60

image-20231108201403362

即对应ida中的Func_SetOodleCompressAndDecompressFunc_140227F90

静态

查看当前函数Func_ParsePackFileHeaderAndSetDecompressFunc_1400A8000的交叉引用:

image-20231108200222409

来到Func_GetFileData_1400A9E90

image-20231108200312526

这里的pfnDecompressFuncOrNull同样是Func_GetFileData_1400A9E90的第四个参数,因此继续查看Func_GetFileData_1400A9E90的交叉引用:

image-20231108200559289

调用Func_GetFileData_1400A9E90的函数不多,挨个看一下。其中第三个函数,就有:

image-20231108200634398

由此可确定pfnDecompressOrNull是(至少在某种情况下会是)Func_SetOodleCompressAndDecompressFunc_140227F90

Part3

image-20231108191700940

类型调用返回
未设置解压函数Func_SetPackFileInfoWithNoDec_1400BEF30Stru_PackFileInfoWithNoDec 指针
设置了解压函数Func_SetPackFileInfoWithDec_1400A7CD0Stru_PackFileInfoWithDec 指针

由于此处返回的结构体可能有两种形态,因而我找出二者的共同含义的字段,定义了通用性的Stru_PackFileInfoWithDecOrWithNoDec

image-20231108203437378

3-1-1 Func_Crc16AndParseFileStorage_1400A7E90

总结
  1. 将传入的文件的前32个字节解析为Stru_FileStorageHeaderWhenPreprocess结构。
  2. 计算前30个字节的crc16,并与第31~32字节处的crc16校验和进行比对。
  3. args2不为NULL,则解析出的结构体通过args2这个指针传出。
伪代码
__int64 __fastcall Func_Crc16AndParseFileStorage_1400A7E90(
        __int64 pbFileHeader32B,
        Stru_FileStorageHeaderWhenPreprocess *pSHeader_1)
{
  Stru_FileStorageHeaderWhenPreprocess *pSHeader; // rax
  unsigned __int8 *pbInputAdd1; // r11
  unsigned __int16 wCalcCheckNum; // r10
  __int64 i; // rbx
  __int16 wCheckNum; // di
  unsigned __int8 v7; // dl
  unsigned __int16 v8; // r8
  __int64 v9; // rcx
  unsigned __int16 v10; // r8
  unsigned __int16 v11; // r8
  int dwChunkPlainLen; // ecx
  __int64 result; // rax
  char v14; // [rsp+0h] [rbp-48h] BYREF

  pSHeader = (Stru_FileStorageHeaderWhenPreprocess *)&v14;
  if ( pSHeader_1 )
    pSHeader = pSHeader_1;
  pSHeader->qwPlainLen = *(_QWORD *)pbFileHeader32B;
  pbInputAdd1 = (unsigned __int8 *)(pbFileHeader32B + 1);
  pSHeader->qwTime = *(_QWORD *)(pbFileHeader32B + 8);
  wCalcCheckNum = 0;
  pSHeader->qwCipherLen = *(_QWORD *)(pbFileHeader32B + 16);
  i = 5i64;
  pSHeader->dwChunkPlainLen = *(_DWORD *)(pbFileHeader32B + 24);
  pSHeader->wStorageType = *(_WORD *)(pbFileHeader32B + 28);
  wCheckNum = *(_WORD *)(pbFileHeader32B + 30);
  do
  {
    v7 = wCalcCheckNum ^ *(pbInputAdd1 - 1);
    pbInputAdd1 += 6;                           // 每次循环迭代6B,共循环5次,因而crc16共计算30B
    v8 = HIBYTE(wCalcCheckNum) ^ pwCrc16Table_143B0BD30[v7];
    v9 = (unsigned __int8)(HIBYTE(wCalcCheckNum) ^ LOBYTE(pwCrc16Table_143B0BD30[v7]) ^ *(pbInputAdd1 - 6));
    v10 = ((unsigned __int16)(HIBYTE(v8) ^ pwCrc16Table_143B0BD30[v9]) >> 8) ^ pwCrc16Table_143B0BD30[(unsigned __int8)(HIBYTE(v8) ^ LOBYTE(pwCrc16Table_143B0BD30[v9]) ^ *(pbInputAdd1 - 5))];
    v11 = ((unsigned __int16)(HIBYTE(v10) ^ pwCrc16Table_143B0BD30[(unsigned __int8)(v10 ^ *(pbInputAdd1 - 4))]) >> 8) ^ pwCrc16Table_143B0BD30[(unsigned __int8)(HIBYTE(v10) ^ LOBYTE(pwCrc16Table_143B0BD30[(unsigned __int8)(v10 ^ *(pbInputAdd1 - 4))]) ^ *(pbInputAdd1 - 3))];
    wCalcCheckNum = HIBYTE(v11) ^ pwCrc16Table_143B0BD30[(unsigned __int8)(v11 ^ *(pbInputAdd1 - 2))];
    --i;
  }
  while ( i );
  if ( wCheckNum != wCalcCheckNum )             // 比较计算出的和存储的crc16校验和是否相等
    return 0i64;
  if ( pSHeader->qwCipherLen < 32 )
    return 0i64;
  if ( pSHeader->qwPlainLen < 0 )
    return 0i64;
  dwChunkPlainLen = pSHeader->dwChunkPlainLen;
  result = 1i64;
  if ( (unsigned int)(dwChunkPlainLen - 128) > 0x1FFF80 )
    return 0i64;
  return result;
}
Part1

image-20231108170010096

如果此函数传入的参数2pSHeader_1不为空,则pSHeader指向参数2指向的空间,否则指向当前函数栈内的某处空间,此空间用于存储将文件前32个字节的数据转换成的Stru_FileStorageHeaderWhenPreprocess结构体,具体结构如下:

image-20231108170302548

Part2

image-20231108170811266

这里以6个字节为一组,进行crc16的迭代计算,共计5轮循环,因此是将文件的前30个字节进行计算。然后判断计算结果是否与文件的第31~32字节处存储的crc16的校验和相等,如果不等则校验失败。

其中pwCrc16Table_143B0BD30为:

image-20231108170952148

一直不知道这里是啥算法,直到搜索了一下这个数组里面的第二个WORD(因为第一个WORD是0),发现是crc16:

image-20231026211750535

具体有关crc16算法的相关内容,可以跳转CRC16一节查阅。

Part3

一些其他的校验,比如:

image-20231108184410418

3-1-2 Func_DecompressType1or2_1400A7270

image-20231108194148336

Func_DecompressType1or2_1400A7270的两个参数,都可定义为如下的结构体Stru_DecompressContext

image-20231108194311265

然后封装调用了sub_1400BC8E0

image-20231108194453900

这个函数很难分析,因为涉及到具体的压缩算法,如果不能通过特征识别出具体的算法名称,靠自己肉眼逆是很难逆出来的。

这里先放张截图给大家留个印象,后面写解包代码的时候我们在讨论如何处理这个函数。

3-1-3 Func_DecompressType3_1400A7410

总结

这个函数不出意外应该也是一个解压算法,参数也和Func_DecompressType1or2_1400A7270的参数完全一样,但是本项目中似乎没用到这个解压函数,因此就不分析了。放在这里只是为了行文逻辑的完整性。

伪代码

image-20231108195303355

image-20231108195328891

3-1-4 Func_SetOodleCompressAndDecompressFunc_140227F90

总结

根据wType (args1) 设置不同的OodleLZ变体算法,通过指针分别将压缩算法和解压缩算法的函数地址传给args2args3

伪代码

image-20231109175921161

参数1的wType其实是Func_ParsePackFileHeaderAndSetDecompressFunc_1400A8000中的wType - 4

image-20231108204011236

然后根据wType为0, 1, 2, 3时(所以实际对应Func_ParsePackFileHeaderAndSetDecompressFunc_1400A8000中的wType为4, 5, 6, 7),分别设置对应的压缩函数和解压缩函数(前提分别是参数2, 参数3不为NULL)。

解压函数

4种wType下的解压函数都是一样的,为Func_OodleDecompress_14069BEF0

image-20231108204340541

首先,这个函数的参数,和前面分析过的两个解压函数(Func_DecompressType1or2_1400A7270Func_DecompressType3_1400A7410)的参数完全一致,保证了解压接口的一致性:

image-20231108204445672

image-20231108204459549

其次,这里的OodleLZ_Decompress是游戏根目录下bin64/oo2core_6_win64.dll中的导出函数。可通过UnrealEngine源码查阅详细的参数说明,点此跳转Github仓库中的具体位置

image-20231108205536827

// Decompress returns raw (decompressed) len received
// Decompress returns 0 (OODLELZ_FAILED) if it detects corruption
OOFUNC1 OO_SINTa OOFUNC2 OodleLZ_Decompress(const void * compBuf,OO_SINTa compBufSize,void * rawBuf,OO_SINTa rawLen,
                                            OodleLZ_FuzzSafe fuzzSafe OODEFAULT(OodleLZ_FuzzSafe_Yes),
                                            OodleLZ_CheckCRC checkCRC OODEFAULT(OodleLZ_CheckCRC_No),
                                            OodleLZ_Verbosity verbosity OODEFAULT(OodleLZ_Verbosity_None),
                                            void * decBufBase OODEFAULT(NULL),
                                            OO_SINTa decBufSize OODEFAULT(0),
                                            OodleDecompressCallback * fpCallback OODEFAULT(NULL),
                                            void * callbackUserData OODEFAULT(NULL),
                                            void * decoderMemory OODEFAULT(NULL),
                                            OO_SINTa decoderMemorySize OODEFAULT(0),
                                            OodleLZ_Decode_ThreadPhase threadPhase OODEFAULT(OodleLZ_Decode_Unthreaded)
                                            );

具体的参数释义太长了,这里只介绍最重要的:

序号参数含义
args1const void * compBuf压缩数据的指针
args2OO_SINTa compBufSize压缩数据的可用字节数,必须大于或等于(解压缩时)消耗的字节数
args3void * rawBuf解压缩后的数据的指针
args4OO_SINTa rawLen解压缩后的数据的字节数
返回值 解压缩后输出的字节数,如果解压缩失败,则返回0

最后,我们就可以对比着分析出,Func_OodleDecompress_14069BEF0中,pSrcCtx->pbData前四个字节存储的是明文的长度,从第四个字节开始的数据为密文。伪代码中的注释已经非常详细了,就过多赘述这一点了。

另外,我要必须记录一点:在一开始刚接触当前函数的时候,如果此前没能发现Func_DecompressType1or2_1400A7270并分析其参数的数据类型,自然就不会从接口一致性的角度出发并分析出当前函数的参数和上述函数的参数是一致的。那么此时面对当前函数,其实是有一些头大的,因为初始伪代码是这样的:

image-20231109182051546

我们可以先选中a2,右键并选择Reset pointer type

image-20231109183214981

从而将a2QWORD*转换为QWORD,这样会清楚一点:

image-20231109183354055

转换前*((_QWORD *)a2 + 1),转换后*(_QWORD *)(a2 + 8)。前者是+1,是因为它是QWORD指针,每次+1对应实际地址+8。这两种写法只是计算地址时的线性因子不同而已,汇编中是一样的:

image-20231109184001964

回到正题,前3个参数其实都好分析,就是args4容易混淆。

按照常理(对照着OodleLZ_Decompress的参数表),这里的args4应该像args2一样,类似*(unsigned int *)a1才顺眼。明明密文、密文长度、明文、明文长度这四个参数两两对应,为啥实际却使用了三次a2、一次a1呢,令人迷惑。

但实际上是因为a2+8指向的QWORD指向的缓冲区,存储着一个chunk(跟密文数据有关),该chunk的第一个DWORD就是密文对应的明文长度,从第四个字节开始才是chunk的密文。这也是前两个参数中出现了+4-4的原因。

压缩函数

同样从UE的源码中找到OodleLZ_Compress的声明:

OOFUNC1 OO_SINTa OOFUNC2 OodleLZ_Compress(OodleLZ_Compressor compressor,
    const void * rawBuf,OO_SINTa rawLen,void * compBuf,
    OodleLZ_CompressionLevel level,
    const OodleLZ_CompressOptions * pOptions OODEFAULT(NULL),
    const void * dictionaryBase OODEFAULT(NULL),
    const void * lrm OODEFAULT(NULL),
    void * scratchMem OODEFAULT(NULL),
    OO_SINTa scratchSize OODEFAULT(0) );

这里同样只介绍伪代码中用到的参数:

序号参数含义
1OodleLZ_Compressor compressor表示具体不同的(OodleLZ变体)压缩算法
2const void * rawBuf要压缩的原始数据
3OO_SINTa rawLenrawBuf中要压缩的字节数
4void * compBuf压缩后的数据的指针(该缓冲区长度必须不小于OodleLZ_GetCompressedBufferSizeNeeded的返回值)
5OodleLZ_CompressionLevel level压缩等级

不同的wType对应着不同的压缩函数,从0到3依次对应:

image-20231108231131486

image-20231108231209496

image-20231108231222958

image-20231108231238866

其实只是OodleLZ_Compress的一些参数不同而已,这里整理下不同的地方。

wType压缩函数args1 compressorOodleLZ变体算法名称
0sub_14069C4808Kraken
1sub_14069C5209Mermaid
2sub_14069C5C011Selkie
3sub_14022810013Leviathan

具体的Oodle系列的压缩算法,我们不需要去逆向分析。需要的时候,直接带着相应的参数,去调用dll中的接口就行。

3-1-5 Func_SetPackFileInfoWithNoDec_1400BEF30

总结

将要目标文件的一些信息整合到Stru_PackFileInfoWithNoDec中并返回该结构体。

伪代码
Stru_PackFileInfoWithNoDec *__fastcall Func_SetPackFileInfoWithNoDec_1400BEF30(
        Stru_FileHandle *pSFileHandle,
        __int64 qwTime,
        __int64 qwFilePointer,
        __int64 qwPlainLen,
        int flag)
{
  Stru_FileFuncs1 *pFuncs; // rax
  Stru_FileFuncs1 *v10; // rdi
  __int64 v11; // rbp
  __int64 v12; // rsi
  __int64 v13; // rcx
  __int64 v14; // rax
  union _LARGE_INTEGER liFilePointer; // rax
  Stru_PackFileInfoWithNoDec *pSInfo; // rdi
  Stru_FileHandle *v18; // rax

  if ( !pSFileHandle )                          // 为NULL时最终返回NULL
  {
    liFilePointer.QuadPart = -1i64;
LABEL_20:                                       // 后面代码有机会跳转过来
    if ( liFilePointer.QuadPart == qwFilePointer )
    {
      pSInfo = (Stru_PackFileInfoWithNoDec *)g_pSMemoryFuncs->Func_Wrapper_malloc_1400A6410(
                                               g_pSMemoryFuncs->qwZero,
                                               56i64);
      if ( pSInfo )
      {
        v18 = 0i64;
        pSInfo->pFuncs = g_pFuncs_PackFileWithNoDec_143E09620;
        if ( flag )
          v18 = pSFileHandle;
        pSInfo->pSFileHandle = pSFileHandle;
        pSInfo->qwTime = qwTime;
        pSInfo->pSFileHandleOrNull = v18;
        pSInfo->qwFilePointer = qwFilePointer;
        pSInfo->qwFilePointerAddPlainLen = qwFilePointer + qwPlainLen;
        pSInfo->qwFilePointer2 = qwFilePointer;
        return pSInfo;
      }
    }
    else
    {
      pSInfo = 0i64;
    }
    if ( flag && pSFileHandle )
      pSFileHandle->pFuncs->Func_Wrapper_CloseHandle_1400A3450(pSFileHandle);
    return pSInfo;
  }
  if ( (__int64 (__fastcall **)())pSFileHandle->pFuncs != g_pFuncs_PackFileWithNoDec_143E09620
    || !flag
    || !pSFileHandle->hFile )
  {
    liFilePointer = pSFileHandle->pFuncs->Func_Wrapper_SetFilePointerEx_1400A3510(pSFileHandle, qwFilePointer, 0);
    goto LABEL_20;                              // 跳到开头
  }
  pFuncs = pSFileHandle[2].pFuncs;
  v10 = (Stru_FileFuncs1 *)((char *)pFuncs + qwFilePointer);
  v11 = (__int64)pFuncs + qwFilePointer + qwPlainLen;
  if ( (__int64)pFuncs + qwFilePointer > v11 || (__int64)v10 < (__int64)pFuncs || v11 > pSFileHandle[2].hFile )
    goto LABEL_17;
  v12 = (__int64)pSFileHandle[3].pFuncs;
  v13 = (__int64)pSFileHandle[1].pFuncs;
  if ( v12 < (__int64)v10 )
    v12 = (__int64)pFuncs + qwFilePointer;
  if ( v12 > v11 )
    v12 = (__int64)pFuncs + qwFilePointer + qwPlainLen;
  v14 = v13 ? (*(__int64 (__fastcall **)(__int64, __int64, _QWORD))(*(_QWORD *)v13 + 32i64))(v13, v12, 0i64) : -1i64;
  if ( v14 == v12 )
  {
    pSFileHandle[2].pFuncs = v10;
    pSFileHandle[2].hFile = v11;
    pSFileHandle[3].pFuncs = (Stru_FileFuncs1 *)v12;
    return (Stru_PackFileInfoWithNoDec *)pSFileHandle;
  }
  else
  {
LABEL_17:
    pSFileHandle->pFuncs->Func_Wrapper_CloseHandle_1400A3450(pSFileHandle);
    return 0i64;
  }
}
分析

这个函数的最后三分之一我没分析完,所以看起来很不对劲,请大家自行略过。特别是上面伪代码中诸如pSFileHandle[3].pFuncs这种,肯定是不对的,应该是因为实际的结构体大小大于pSFileHandle对应的结构体的大小,但是真正的结构体我又没分析出来。不过好在不影响这里我们分析主要逻辑。

首先,创建了56字节的空间:

image-20231108235135625

然后根据这里的赋值操作:

image-20231108235237961

可以定义Stru_PackFileInfoWithNoDec结构体:

image-20231108235345145

需要高度注意的是,此结构体中+0处的pFuncsg_pFuncs_PackFileWithNoDec_143E09620

image-20231108235853161

3-1-6 Func_SetPackFileInfoWithDec_1400A7CD0

总结

将要目标文件的一些信息整合到Stru_PackFileInfoWithDec中并返回该结构体,该结构体的成员变量的具体数据的来源主要包括:

  1. 当前函数传入的参数,如pSFileHandle, qwTime, qwPlainLen, pfnDecompressFunc, dwChunkPlainLen
  2. 从文件中读取的数据,如ArrayOfChunksCipherLen(存储着每个chunk的密文长度的数组)
  3. 当前函数中设置(或计算出)的,如pFuncs = g_pFuncs_PackFileWithDec_143E09558(该结构体对应的特定函数数组), dwChunkNum
  4. 等等
伪代码
Stru_PackFileInfoWithDec *__fastcall Func_SetPackFileInfoWithDec_1400A7CD0(
        Stru_FileHandle *pSFileHandle,
        __int64 qwCipherLen,
        __int64 qwTime,
        __int64 qwPlainLen,
        unsigned int (__fastcall *pfnDecompressFunc)(Stru_DecompressContext *pSDstCtx, Stru_DecompressContext *pSSrcCtx),
        unsigned int dwChunkPlainLen)
{
  unsigned __int64 dwChunkNum; // rsi
  __int64 qwBodyLen; // rbp
  unsigned __int64 qwChunkPlainLenAddChunkNumMul4; // r12
  Stru_PackFileInfoWithDec *pSInfo_1; // rax
  Stru_PackFileInfoWithDec *pSInfo; // rbx
  __int64 v13; // rdi
  __int64 v14; // r8
  unsigned int v15; // r10d
  __int64 pos; // rdx
  unsigned int v17; // eax
  __int64 i; // r9
  __int64 paChunksCipherLen; // rcx

  if ( dwChunkPlainLen - 128 > 0x1FFF80 )
    return 0i64;
  dwChunkNum = qwPlainLen / dwChunkPlainLen;
  if ( dwChunkNum > 0xFFFFFFFE )
    return 0i64;
  if ( qwPlainLen % dwChunkPlainLen )
    LODWORD(dwChunkNum) = dwChunkNum + 1;
  qwBodyLen = qwCipherLen - (unsigned int)(4 * dwChunkNum + 32);// qwCipherLen是header+body总长度
  if ( qwBodyLen < 0 )
    return 0i64;
  qwChunkPlainLenAddChunkNumMul4 = (unsigned int)(4 * dwChunkNum) + (unsigned __int64)dwChunkPlainLen;
  pSInfo_1 = (Stru_PackFileInfoWithDec *)g_pSMemoryFuncs->Func_Wrapper_malloc_1400A6410(
                                           g_pSMemoryFuncs->qwZero,
                                           qwChunkPlainLenAddChunkNumMul4 + 96);// 该buf长度为 一个chunk的长度 + 4 * chunk的数量 + 96 Byte

  pSInfo = pSInfo_1;
  if ( !pSInfo_1 )
    return 0i64;
  memset(pSInfo_1, 0, qwChunkPlainLenAddChunkNumMul4 + 96);
  pSInfo->dwChunkPlainLen = dwChunkPlainLen;
  pSInfo->pSFileHandle = pSFileHandle;
  pSInfo->dwChunkNum = dwChunkNum;
  pSInfo->pFuncs = g_pFuncs_PackFileWithDec_143E09558;
  pSInfo->dwPlainLen = qwPlainLen;
  pSInfo->pfnDecompressFunc = pfnDecompressFunc;
  pSInfo->dwLastChunkIndex = -1;
  pSInfo->qwTime = qwTime;
  pSInfo->paChunksCipherLen = &pSInfo->ArrayOfChunksCipherLen;
  v13 = 0i64;
  pSInfo->pbChunkPlainBuf = (char *)&pSInfo->ArrayOfChunksCipherLen + 4 * (unsigned int)dwChunkNum;
  pSInfo->qword28 = 0i64;
  if ( pSFileHandle->pFuncs->Func_Wrapper_ReadFile_1400A34B0(
         pSFileHandle,
         &pSInfo->ArrayOfChunksCipherLen,       // ChunksCipherLen数组
         4 * dwChunkNum) != 4 * (_DWORD)dwChunkNum )// 读取dwChunkNum个DWORD,表示dwChunkNum个chunk的dwChunkCipherLen
    goto LABEL_14;
  v14 = 0i64;
  v15 = 0;
  if ( (unsigned int)dwChunkNum >= 2 )          // 如果不止一个chunk
  {
    pos = 0i64;
    v17 = ((unsigned int)(dwChunkNum - 2) >> 1) + 1;// dwChunkNum / 2 向下取整
    i = v17;
    v15 = 2 * v17;
    do
    {
      paChunksCipherLen = pSInfo->paChunksCipherLen;
      pos += 8i64;
      v13 -= *(unsigned int *)(paChunksCipherLen + pos - 8);// 减去奇数chunk的cipher长度
      v14 -= *(unsigned int *)(paChunksCipherLen + pos - 4);// 减去偶数chunk的cipher长度
      --i;
    }
    while ( i );
  }
  if ( v15 < (unsigned int)dwChunkNum )         // 如果dwChunkNum是奇数,那这里还要减去最后一个chunk的cipher长度
    qwBodyLen -= *(unsigned int *)(pSInfo->paChunksCipherLen + 4i64 * v15);
  if ( qwBodyLen + v14 + v13 < 0 )              // 后两者为负数
                                                // 这么麻烦,其实意思是判断qwBodyLen >= ChunksCipherLen数组总和
  {
LABEL_14:
    g_pSMemoryFuncs->Func_Wrapper_free_1400A6420(g_pSMemoryFuncs->qwZero, pSInfo);
    return 0i64;
  }
  pSInfo->qwFilePointer = pSFileHandle->pFuncs->Func_Wrapper_SetFilePointerEx_1400A3510(pSFileHandle, 0i64, 1u).QuadPart;// 返回文件指针的当前值
  return pSInfo;
}
Part1

image-20231109092904221

  1. dwChunkPlainLen需要不大于0x200000
  2. 通过明文长度和每个chunk块的最大长度求出一共有多少个chunk块
  3. qwCipherLen是文件总长度,4 * dwChunkNum + 32是Header长度。
Part2

image-20231109093449395

创建了一个长度为一个chunk的长度 + 4 * chunk的数量 + 96的空间,并作为Stru_PackFileInfoWithDec结构体:

image-20231109093935222

伪代码的第53行开始,从文件中读取了dwChunkNumDWORD,并存入结构体中的ArrayOfChunksCipherLen数组当中(由于ida无法表示动态长度的数组,所以定义结构体的时候我只定义了该数组的第一个DWORD),代表每个chunk的密文长度。

需要高度注意的是,此结构体中+0处的pFuncsg_pFuncs_PackFileWithDec_143E09558

image-20231109094123239

Part3

image-20231109093554332

这段代码挺啰嗦的,总结起来就每次循环都处理两个chunk,分别在v13v14中减去各自chunk的cipher长度(v13v14都是__int64且初始值为0,所以会变成负数),最后可能会剩下一个chunk,那就单独减去这个chunk的长度。然后第78行的qwBodyLen + v14 + v13 < 0本质上就是判断qwBodyLen是否小于所有chunk的长度总和:若是返回0;若否则获取文件指针的当前值并赋值给pSInfo->qwFilePointer,然后返回Stru_PackFileInfoWithDec指针。

3-2 Func_GetFilePlainLenAndTimeWithNoDec_1400BEF00

总结

Stru_PackFileInfoWithNoDec * (args1) 的成员变量中,获取(或计算出)文件的大小和时间,分别存入pqwSize (args2) 和 pqwTime (args3)。

伪代码

image-20231109100437268

3-3 Func_GetFilePlainLenAndTimeWithDec_1400A7CB0

总结

Stru_PackFileInfoWithDec * (args1) 的成员变量中,获取(或计算出)文件的大小和时间,分别存入pqwSize (args2) 和 pqwTime (args3)。

伪代码

image-20231109101130508

3-4 Func_ReadFileDataWithNoDec_1400BEE30

总结

Stru_PackFileInfoWithNoDec * (args1) 的成员变量中获取包含文件句柄的Stru_FileHandle的指针,然后从该文件中读取长度为dwPlainLen (args3) 的数据,保存到pbFilePlain (args2) 中。

由于这个函数对应的是没有设置解压算法的情况,所以读取出来的数据就是文件的明文数据。

伪代码

image-20231109100658356

3-5 Func_ReadFileDataWithDec_1400A7B00

总结
  1. Stru_PackFileInfoWithDec * (args1) 的成员变量中获取包含文件句柄的Stru_FileHandle的指针。
  2. 调用n次Func_ReadFileChunkDataWithDec_1400A7930,每次调用都会从该文件中读取并解压缩出一个chunk的的明文数据。
  3. 最终该文件的明文数据会保存到pbFilePlain (args2) 中。
伪代码
size_t __fastcall Func_ReadFileDataWithDec_1400A7B00(
        Stru_PackFileInfoWithDec *pSInfo,
        char *pbFilePlain,
        size_t dwPlainLen)
{
  char *pbChunkPlainBuf; // r13
  unsigned __int64 dwChunkPlainLen; // r15
  __int64 field_28; // rcx
  size_t v7; // rbp
  __int64 dwPlainLen_2; // rax
  __int64 dwPlainLen_1; // r8
  unsigned __int64 dwChunkNum; // r12
  unsigned __int64 v13; // rax
  unsigned __int64 v14; // rcx
  int v15; // ebx
  size_t v16; // rsi
  size_t dwLeftPlainLen; // r14
  char *pbFilePlainPos; // rsi
  unsigned int dwChunkIndex; // ebx
  Stru_DecompressContext pSDstCtx; // [rsp+20h] [rbp-48h] BYREF
  unsigned __int64 v21; // [rsp+70h] [rbp+8h]

  pbChunkPlainBuf = (char *)pSInfo->pbChunkPlainBuf;// temp buf,用于暂存一个chunk的明文数据
  dwChunkPlainLen = (unsigned int)pSInfo->dwChunkPlainLen;
  field_28 = pSInfo->field_28;                  // 应该是0?
  v7 = dwPlainLen;
  pSDstCtx.dwLen = dwChunkPlainLen;
  pSDstCtx.pbData = (__int64)pbChunkPlainBuf;
  dwPlainLen_2 = field_28 + dwPlainLen;
  if ( field_28 < 0 )
    return 0i64;
  dwPlainLen_1 = pSInfo->dwPlainLen;
  if ( field_28 > dwPlainLen_1 )
    return 0i64;
  if ( dwPlainLen_2 > dwPlainLen_1 || dwPlainLen_2 < field_28 )
  {
    dwPlainLen_2 = pSInfo->dwPlainLen;
    v7 = dwPlainLen_1 - field_28;
  }
  if ( !v7 )
    return 0i64;
  dwChunkNum = dwPlainLen_2 / dwChunkPlainLen;  // 不含最后一个不能整除的块
  v13 = field_28 / dwChunkPlainLen;
  v14 = field_28 % dwChunkPlainLen;
  v21 = v14;
  v15 = v13;
  if ( (_DWORD)v13 != pSInfo->dwLastChunkIndex )
  {
    if ( !(unsigned int)Func_ReadFileChunkDataWithDec_1400A7930(&pSDstCtx, pSInfo, v13) )
      return 0i64;
    v14 = v21;
  }
  v16 = dwChunkPlainLen - v14;
  if ( dwChunkPlainLen - v14 >= v7 )
    v16 = v7;
  memcpy(pbFilePlain, &pbChunkPlainBuf[v14], v16);// 拷贝第一个chunk的明文数据
  dwLeftPlainLen = v7 - v16;
  if ( v7 != v16 )
  {
    pbFilePlainPos = &pbFilePlain[v16];
    dwChunkIndex = v15 + 1;
    pSDstCtx.pbData = (__int64)pbFilePlainPos;
    if ( dwChunkIndex < (unsigned int)dwChunkNum )
    {
      while ( (unsigned int)Func_ReadFileChunkDataWithDec_1400A7930(&pSDstCtx, pSInfo, dwChunkIndex) )
      {
        pbFilePlainPos += dwChunkPlainLen;
        ++dwChunkIndex;
        dwLeftPlainLen -= dwChunkPlainLen;
        pSDstCtx.pbData = (__int64)pbFilePlainPos;// 用于存储下一个chunk的明文
        if ( dwChunkIndex >= (unsigned int)dwChunkNum )// 相等时跳走
          goto LABEL_18;
      }
      return 0i64;
    }
LABEL_18:
    if ( dwLeftPlainLen )                       // 解压并拷贝最后一个chunk
    {
      pSDstCtx.pbData = (__int64)pbChunkPlainBuf;
      if ( !(unsigned int)Func_ReadFileChunkDataWithDec_1400A7930(&pSDstCtx, pSInfo, dwChunkIndex) )
        return 0i64;
      memcpy(pbFilePlainPos, pbChunkPlainBuf, dwLeftPlainLen);
    }
  }
  pSInfo->field_28 += v7;
  return v7;
}
Part1

image-20231109113425527

一些初始化相关的操作。

pbChunkPlainBuf指向了Stru_PackFileInfoWithDec结构体中的某个地址(还记得这个结构体的实际大小是一个chunk的长度 + 4 * chunk的数量 + 96吗?是的,其中的一个chunk的长度就是用在这里)。

field_28实在没分析出来,不过好在没有影响完整的逻辑分析。

pSDstCtx是后面马上就要分析的Func_ReadFileChunkDataWithDec_1400A7930的参数,用来传递明文的大小和将要存储的位置。默认情况下,解压后的明文大小为dwChunkPlainLen,解压后的数据要存储到pbChunkPlainBuf中。

Part2

image-20231109111534935

这里由于不知道field_28到底是啥,所以分析一度陷入僵局。不过综合后续的代码,field_28的值目前应该是0。因此v13v14也是0。

故而在第49行,调用Func_ReadFileChunkDataWithDec_1400A7930来读取并解密dwChunkIndex=0的chunk(姑且称作第一个chunk吧)。

然后在第56行,通过memcpy,将解密后的数据拷贝到pbFilePlain中。

Part3

image-20231109112149595

再然后就是在循环中,调用Func_ReadFileChunkDataWithDec_1400A7930来读取并解密其他chunk(不包含第一个和最后一个chunk)。

这里每次循环都将pSDstCtxpbData指向了pbFilePlainPos,因此不需要额外通过memcpy来拷贝解密后的明文。

Part4

image-20231109112415836

解压并拷贝最后一个chunk。

单独处理它,是因为最后一个chunk的长度不是dwChunkPlainLen,而是最后剩下的字节数dwLeftPlainLen = dwPlainLen - (n-1) * dwChunkPlainLen,即文件的明文长度减去除最后一个chunk外的其他所有chunk的明文长度总和。

当然这里dwLeftPlainLen 的计算其实也涉及了那个没能分析出来的field_28,不过好像总体上的逻辑大概就是这样,所以不管啦。

3-5-1 Func_ReadFileChunkDataWithDec_1400A7930

总结

读取并解压所选文件的第dwChunkIndex (args3) 个chunk,结果保存在pSDstCtx (args1) 中。

伪代码
__int64 __fastcall Func_ReadFileChunkDataWithDec_1400A7930(
        Stru_DecompressContext *pSDstCtx,
        Stru_PackFileInfoWithDec *pSInfo,
        unsigned int dwChunkIndex)
{
  __int64 paChunksCipherLen; // r11
  unsigned int dwLen; // r9d
  int dwLastChunkIndex; // eax
  char *pbData; // rdx
  __int64 v10; // rdx
  __int64 qwFilePointer; // rsi
  __int64 v12; // r9
  unsigned int v13; // r10d
  __int64 pos; // rcx
  unsigned int v15; // eax
  __int64 i; // r8
  __int64 v17; // rax
  Stru_DecompressContext SSrcCtx; // [rsp+20h] [rbp-4038h] BYREF
  char pbTempBuf[16384]; // [rsp+30h] [rbp-4028h] BYREF

  paChunksCipherLen = pSInfo->paChunksCipherLen;
  dwLen = *(_DWORD *)(paChunksCipherLen + 4i64 * dwChunkIndex);
  dwLastChunkIndex = pSInfo->dwLastChunkIndex;
  pbData = pbTempBuf;
  SSrcCtx.dwLen = dwLen;
  SSrcCtx.pbData = (__int64)pbTempBuf;
  if ( dwLastChunkIndex + 1 != dwChunkIndex )
  {
    v10 = 0i64;
    qwFilePointer = pSInfo->qwFilePointer;
    v12 = 0i64;
    v13 = 0;
    if ( dwChunkIndex >= 2 )
    {
      pos = paChunksCipherLen;
      v15 = ((dwChunkIndex - 2) >> 1) + 1;      // dwChunkIndex / 2 向下取整
      i = v15;
      v13 = 2 * v15;
      do
      {
        v17 = *(unsigned int *)pos;
        pos += 8i64;
        v10 += v17;                             // 加上奇数chunk的cipher长度
        v12 += *(unsigned int *)(pos - 4);      // 加上偶数chunk的cipher长度
        --i;
      }
      while ( i );
    }
    if ( v13 < dwChunkIndex )                   // 如果dwChunkIndex是奇数,那这里还要加上下标为(dwChunkIndex-1)的chunk的cipher长度
      qwFilePointer += *(unsigned int *)(paChunksCipherLen + 4i64 * v13);
    if ( pSInfo->pSFileHandle->pFuncs->Func_Wrapper_SetFilePointerEx_1400A3510(
           pSInfo->pSFileHandle,
           v12 + v10 + qwFilePointer,           // 上面搞来搞去,实际上就是求出File Body基址 + 前面(dwChunkIndex-1)个chunk的cipher长度总和,即当前的chunk第一个字节对应的文件指针
           0) != v12 + v10 + qwFilePointer )
      return 0i64;
    pbData = (char *)SSrcCtx.pbData;
    dwLen = SSrcCtx.dwLen;
  }
  if ( dwLen > 0x4000 )                         // 内置的pbTempBuf不够长就动态申请,这样性能好一些
  {
    pbData = (char *)g_pSMemoryFuncs->Func_Wrapper_malloc_1400A6410(g_pSMemoryFuncs->qwZero, dwLen);
    SSrcCtx.pbData = (__int64)pbData;
    if ( !pbData )
      return 0i64;
    dwLen = SSrcCtx.dwLen;
  }
  if ( pSInfo->pSFileHandle->pFuncs->Func_Wrapper_ReadFile_1400A34B0(pSInfo->pSFileHandle, pbData, dwLen) != SSrcCtx.dwLen// 读取
    || !pSInfo->pfnDecompressFunc(pSDstCtx, &SSrcCtx) )// 解压
  {
    if ( (char *)SSrcCtx.pbData != pbTempBuf )  // 如果是在本函数内部动态申请的buf,那么还要free。下同。
      g_pSMemoryFuncs->Func_Wrapper_free_1400A6420(g_pSMemoryFuncs->qwZero, (void *)SSrcCtx.pbData);
    return 0i64;
  }
  if ( (char *)SSrcCtx.pbData != pbTempBuf )
    g_pSMemoryFuncs->Func_Wrapper_free_1400A6420(g_pSMemoryFuncs->qwZero, (void *)SSrcCtx.pbData);
  pSInfo->dwLastChunkIndex = dwChunkIndex;
  return 1i64;
}
Part1

image-20231109110139671

一些初始操作。其中:

dwLen实际上是paChunksCipherLen[dwChunkIndex],伪代码中显示的不简洁。

pbData暂时默认指向了当前函数栈内的一块0x4000大小的缓冲区。

Part2

image-20231109103925071

这里的操作(每次循环处理两个chunk)和Func_SetPackFileInfoWithDec_1400A7CD0中的有些类似。

具体来说,这里每次循环都处理两个chunk,分别在v10v12中累加各自chunk的cipher长度,最后可能会剩下一个chunk,那就单独加上这个chunk的长度。共循环$\lfloor\frac{dwChunkIndex}{2}\rfloor $次,因此实际上就是处理了dwChunkIndex个chunk。

综上分析,第53行中的v12 + v10 + qwFilePointer表示的其实就是前dwChunkIndex个chunk的密文长度之和,再加上当前文件的指针位置,因而实际含义就是当前chunk在文件中的起始位置。

这个值有什么用呢?在51行的Func_Wrapper_SetFilePointerEx_1400A3510中,用来将文件指针设置到这个chunk的起始位置,以便后续读取。

Part3

image-20231109103042266

如果chunk的明文长度大于0x4000,那么当前函数内部的那个缓冲区pbTempBuf[0x4000]就不够用了,所以申请一块新的内存,并更新SSrcCtx.pbData指向新创建的空间。

然后从文件中读取这个chunk的密文数据,接着使用此前设置好的解压函数来恢复出该chunk的明文数据。

格式总结

前面我们按照动调时的先后发现顺序依次分析了三个函数,又根据这三个函数内部调用其他函数的先后顺序,依次分析了涉及到的其他函数。下面我们整理一下通过分析上述函数总结出的索引文件的文件格式。

前32字节

Func_ParsePackFileHeaderAndSetDecompressFunc_1400A8000中,读取了文件的前32字节,然后调用Func_Crc16AndParseFileStorage_1400A7E90进行解析和crc16校验。

由此可以分析出这里的结构:

struct FileHeader {
    QWORD    qwPlainLen;                // 明文长度
    QWORD    qwTime;                    // 时间戳
    QWORD    qwCipherLen;            // 整个PackFile的长度(包括Header和以密文形式存在的Body)
    DWORD    dwChunkPlainLen;        // 每个chunk的明文的最大长度
    WORD    wStorageType;            // 存储类型,对应不同的解压函数
    WORD    wCrc16;                    // crc16校验和
}
(无解压函数时) 明文数据

如果没有设置解压函数,则此后的数据都是文件的明文数据。读取操作是在Func_ReadFileDataWithNoDec_1400BEE30在进行的。

(有解压函数时) 所有的chunk的密文长度

Func_SetPackFileInfoWithDec_1400A7CD0中,接着当前文件指针的位置,继续读取了NDWORD,每个DWORD都代表一个chunk的密文长度。

(有解压函数时) 所有的chunk的密文数据

Func_ReadFileDataWithDec_1400A7B00中,接着当前文件指针的位置,调用了NFunc_ReadFileChunkDataWithDec_1400A7930,每次调用都会从该文件中读取并解压缩出一个chunk的的明文数据。

具体的解压算法可见解压函数列表

其中需要注意,wType为4~7时的OodleLZ变体算法的解压函数Func_OodleDecompress_14069BEF0中,会将chunk的前4个字节作为明文长度,chunk从第4个字节开始才是密文数据;而wType为1时的解压函数Func_DecompressType1or2_1400A7270中,chunk就是密文。

解包代码

本部分代码属于GujianOLPack

utils.py
import hashlib, struct, os, stat, re

def b2q(bytes):
    return struct.unpack('<Q', bytes)[0]
def b2d(bytes):
    return struct.unpack('<I', bytes)[0]
def b2w(bytes):
    return struct.unpack('<H', bytes)[0]
def q2b(qword):
    return struct.pack('<Q', qword)
def d2b(dword):
    return struct.pack('<I', dword)
def w2b(word):
    return struct.pack('<H', word)

def md5(bytes):
    m = hashlib.md5()
    m.update(bytes)
    return m.hexdigest()

def sha1(bytes):
    m = hashlib.sha1()
    m.update(bytes)
    return m.hexdigest()

def sha1_digest(bytes):
    m = hashlib.sha1()
    m.update(bytes)
    return m.digest()

def byte2hexbytes(bytes):
    h = b''
    for i in bytes:
        t = hex(i)[2:]
        if len(t) == 1:
            t = '0' + t
        h += t.encode()
    return h


# xxxx.yyy -> xxxx (1).yyy -> xxxx (2).yyy -> ...
def file_path_auto_numbering(file_path) -> str:
    if not os.path.exists(file_path):
        return file_path
    else:
        dir_path, full_file_name = os.path.split(file_path)
        file_name, file_ext = os.path.splitext(full_file_name)

        num_list = []
        for enum_file in os.listdir(dir_path):
            enum_file_name, enum_file_ext = os.path.splitext(enum_file)
            if enum_file_name.startswith(file_name) and enum_file_ext == file_ext:
                res = re.match(r'^ \((\d+?)\)$', enum_file_name[len(file_name):])
                if res:
                    num_list.append(int(res.group(1)))

        if len(num_list) == 0:
            next_num = 2
        else:
            next_num = max(num_list) + 1

        new_file_name = '{} ({}){}'.format(file_name, next_num, file_ext)
        return os.path.join(dir_path, new_file_name)
    

# windows下设为只读
def set_file_read(file_path):
    os.chmod(file_path, stat.S_IREAD)

# windows下取消只读
def unset_file_read(file_path):
    os.chmod(file_path, stat.S_IWRITE)


def set_all_index_file_read(index_path):
    # 官服
    if os.path.isdir(index_path):
        for file in os.listdir(index_path):
            res = re.match(r'^\d+?\.idx$', file)
            if res:
                file_path = os.path.join(index_path, file)
                set_file_read(file_path)
    # Steam服
    else:
        set_file_read(index_path)


def unset_all_index_file_read(index_path):
    # 官服
    if os.path.isdir(index_path):
        for file in os.listdir(index_path):
            res = re.match(r'^\d+?\.idx$', file)
            if res:
                file_path = os.path.join(index_path, file)
                unset_file_read(file_path)
    # Steam服
    else:
        unset_file_read(index_path)

封装了一些小功能。

oo2core.py
import os, ctypes

script_path = os.path.split(os.path.realpath(__file__))[0]
dll_path = os.path.join(script_path, 'oo2core_6_win64.dll')


def Decompress(Cipher, PlainLen):
    Lib = ctypes.CDLL(dll_path)

    BufferSize = Lib.OodleLZ_GetDecodeBufferSize(PlainLen, 1)
    ByteArray = bytearray(BufferSize)
    CBuffer = ctypes.c_ubyte * len(ByteArray)

    RealPlainLen = Lib.OodleLZ_Decompress(Cipher, len(Cipher), CBuffer.from_buffer(ByteArray), PlainLen, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
    if RealPlainLen == 0:
        raise Exception("Decompress Failed!")
    return bytes(ByteArray[:RealPlainLen])


def Compress(Plain, Format = 8, Level = 7):
    Lib = ctypes.CDLL(dll_path)

    BufferSize = Lib.OodleLZ_GetCompressedBufferSizeNeeded(len(Plain))
    ByteArray = bytearray(BufferSize)
    CBuffer = ctypes.c_ubyte * len(ByteArray)

    OutputCompressDataLen = Lib.OodleLZ_Compress(Format, Plain, len(Plain), CBuffer.from_buffer(ByteArray), Level, 0, 0, 0, 0, 0)
    if OutputCompressDataLen == 0:
        raise Exception("Compress Failed!")
    return bytes(ByteArray[:OutputCompressDataLen])


if __name__ == '__main__':
    Plain = b'1234567890' * 100
    Cipher = Compress(Plain)
    print('Cipher:\t', Cipher)

    NewPlain = Decompress(Cipher, len(Plain))
    print('Plain:\t', NewPlain)

从python中调用oo2core_6_win64.dll中的压缩、解压缩相关函数。

在调用OodleLZ_DecompressOodleLZ_Compress之前,需分别调用OodleLZ_GetDecodeBufferSizeOodleLZ_GetCompressedBufferSizeNeeded来获取所需缓冲区大小。

file.py
import time
from utils import *
from oo2core import *
import crcmod


class FileUnpack:

    def __init__(self, f, pos):
        self.f = f
        self.pos = pos
        self.f.seek(self.pos)

        self.qwPlainLen         = b2q(self.f.read(8))
        self.qwTime             = b2q(self.f.read(8))
        self.qwCipherLen        = b2q(self.f.read(8))       # 实际上是header+data区的总长度
        self.dwChunkPlainLen    = b2d(self.f.read(4))
        self.wStorageType       = b2w(self.f.read(2))
        self.wCrc16             = b2w(self.f.read(2))
        self.lChunksCipherLen = []

        self.dwChunkNum = self.qwPlainLen // self.dwChunkPlainLen
        if self.qwPlainLen % self.dwChunkPlainLen != 0:
            self.dwChunkNum += 1

        for i in range(self.dwChunkNum):
            dwChunkCipherLen = b2d(self.f.read(4))
            self.lChunksCipherLen.append(dwChunkCipherLen)


    def get(self):
        self.f.seek(self.pos + 8 + 8 + 8 + 4 + 2 + 2 + self.dwChunkNum * 4)
        file_data = bytearray()
        
        if (self.wStorageType == 0):
            file_data += self.f.read(self.qwPlainLen)

        elif (self.wStorageType == 4):
            for dwChunkCipherLen in self.lChunksCipherLen:
                dwChunkPlainLen = b2d(self.f.read(4))
                data = self.f.read(dwChunkCipherLen - 4)
                
                plain = Decompress(data, dwChunkPlainLen)
                file_data += plain
        return bytes(file_data)
    

    def show_header(self):
        print('qwPlainLen       :', self.qwPlainLen)
        print('qwTime:          :', time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(self.qwTime)))
        print('qwCipherLen      :', self.qwCipherLen)
        print('dwChunkPlainLen  :', self.dwChunkPlainLen)
        print('wStorageType     :', self.wStorageType)
        print('wCrc16           :', self.wCrc16)
        print('lChunksCipherLen :', self.lChunksCipherLen)



class FilePack:

    def __init__(self, file_data, qwTime, wStorageType):
        self.file_data          = file_data

        self.qwPlainLen         = len(file_data)
        self.qwTime             = qwTime            # os.path.getmtime(file_path)
        self.dwChunkPlainLen    = 524288 if self.qwPlainLen >= 524288 else self.qwPlainLen
        self.wStorageType       = wStorageType
        
        if wStorageType == 0:
            self.body_data          = self.file_data
            self.qwCipherLen        = 8 + 8 + 8 + 4 + 2 + 2 + len(self.body_data)
            self.wCrc16             = self._crc16()
        
        elif wStorageType == 4:
            self.dwChunkNum = self.qwPlainLen // self.dwChunkPlainLen
            if self.qwPlainLen % self.dwChunkPlainLen != 0:
                self.dwChunkNum += 1

            self.body_data          = self._get_body_data()
            self.qwCipherLen        = 8 + 8 + 8 + 4 + 2 + 2 + self.dwChunkNum * 4 + len(self.body_data)
            self.wCrc16             = self._crc16()

        else:
            raise Exception('Error Storage Type: %d', wStorageType)
        

    def get(self):
        data = bytearray()
        data += q2b(self.qwPlainLen)
        data += q2b(self.qwTime)
        data += q2b(self.qwCipherLen)
        data += d2b(self.dwChunkPlainLen)
        data += w2b(self.wStorageType)
        data += w2b(self.wCrc16)
        
        if self.wStorageType == 4:
            for i in range(len(self.lChunksCipherLen)):
                data += d2b(self.lChunksCipherLen[i])

        data += self.body_data
        return bytes(data)


    def show_header(self):
        print('qwPlainLen       :', self.qwPlainLen)
        print('qwTime:          :', time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(self.qwTime)))
        print('qwCipherLen      :', self.qwCipherLen)
        print('dwChunkPlainLen  :', self.dwChunkPlainLen)
        print('wStorageType     :', self.wStorageType)
        print('wCrc16           :', self.wCrc16)
        print('lChunksCipherLen :', self.lChunksCipherLen)


    def _get_body_data(self):
        body_data = bytearray()
        self.lChunksCipherLen = []

        for i in range(self.dwChunkNum):
            if i + 1 == self.dwChunkNum:
                dwChunkPlainLen = self.qwPlainLen - self.dwChunkPlainLen * i
            else:
                dwChunkPlainLen = self.dwChunkPlainLen

            data = self.file_data[self.dwChunkPlainLen * i : self.dwChunkPlainLen * i + dwChunkPlainLen]
            cipher = Compress(data)

            self.lChunksCipherLen.append(len(cipher) + 4)

            body_data += d2b(dwChunkPlainLen)
            body_data += cipher
        
        self.file_data = b''
        return bytes(body_data)
    
    
    def _crc16(self):
        crc16 = crcmod.predefined.Crc('crc-16')
        crc16.update(q2b(self.qwPlainLen))
        crc16.update(q2b(self.qwTime))
        crc16.update(q2b(self.qwCipherLen))
        crc16.update(d2b(self.dwChunkPlainLen))
        crc16.update(w2b(self.wStorageType))
        return crc16.crcValue


if __name__ == '__main__':
    # Unpack
    index_path = r'D:\Game\Steam\steamapps\common\古剑奇谭网络版\data\_index'
    f = open(index_path, 'rb')
    unpacker = FileUnpack(f, 0)
    unpacker.show_header()
    unpack_file_data = unpacker.get()
    print('Unpack File Len  :', len(unpack_file_data))
    f.close()
    print()

    unpack_output_path = r'temp/index_unpack.txt'
    with open(unpack_output_path, 'wb')as f:
        f.write(unpack_file_data)

    # Pack
    packer = FilePack(unpack_file_data, unpacker.qwTime, 4)
    packer.show_header()
    pack_file_data = packer.get()
    print('Pack File Len    :', len(pack_file_data))
    print()

    pack_output_path = r'temp/index_pack.bin'
    with open(pack_output_path, 'wb')as f:
        f.write(pack_file_data)

    # Compare MD5 (如果要判断MD5是否相同,记得手动固定住pack文件的时间戳)
    with open(index_path, 'rb')as f:
        m1 = md5(f.read())
        print('MD5:', m1)
    
    with open(pack_output_path, 'rb')as f:
        m2 = md5(f.read())
        print('MD5:', m2)

FileUnpack:解析打包文件格式并获取明文数据。

FilePack:将明文的文件数据转换成打包格式。

index
from file import FileUnpack, FilePack
from utils import *
import time, os, re


def merge_hash_dict(dict1, dict2):
    result = {}
    for key in dict1.keys():
        if key in dict2.keys():
            result[key] = tuple(set(dict1[key] + dict2[key]))
            # if (len(dict1[key]) > 1 or len(dict2[key]) > 1) and dict1[key] != dict2[key]:
            #     print(dict1[key], dict2[key], result[key])
        else:
            result[key] = dict1[key]
    
    for key in dict2.keys():
        if key not in dict1.keys():
            result[key] = dict2[key]
  
    return result


class Index:

    def __init__(self, index_path):
        self.index_path = index_path
        with open(index_path, 'rb')as f:
            unpacker = FileUnpack(f, 0)
            index_data = unpacker.get()

            with open(index_path + '.txt', 'wb')as f:
                f.write(index_data)

            self.index_list = []
            for line in index_data.split(b'\r\n'):
                if len(line) > 0:
                    # official:     temp: hash name len type
                    # steam:        temp: hash name
                    temp = line.split(b'\t')
                    self.index_list.append(temp)

            self.dict = {}
            for line_list in self.index_list:
                hash = line_list[0]
                name = line_list[1]

                if hash not in self.dict.keys():
                    self.dict[hash] = (name, )
                else:
                    old = self.dict[hash]
                    if name not in old:
                        self.dict[hash] = (*old, name)

            print('Index File Path : %s' % index_path)
            print('Index File:     : %d Items' % len(self.index_list))

        
    def get_names_by_hash(self, hash : bytes) -> tuple:
        if hash in self.dict.keys():
            return self.dict[hash]
        else:
            return (b'unknown/' + hash, )
        

    def get_line_by_name(self, name : bytes):
        line = -1
        for i in range(len(self.index_list)):
            if name == self.index_list[i][1]:
                return i
        return line
    

    def get_line_data_by_name(self, name : bytes):
        line = self.get_line_by_name(name)
        if line == -1:
            return None
        else:
            return self.index_list[line]
    
    
    def update(self, name : bytes, file_path):
        with open(file_path, 'rb')as f:
            file_data = f.read()

        return self._update(name, file_data)


    def _update(self, name : bytes, file_data):
        file_hash = sha1(file_data)
        file_len = len(file_data)
        self.update_by_info(name, file_hash, file_len)
        return (file_hash, file_len)
    
    
    def update_by_info(self, name : bytes, file_hash : str, file_len : int):
        line = self.get_line_by_name(name)
        if line == -1:
            print('index文件中没有找到 %s 的记录' % name)
            return
        else:
            self.index_list[line][0] = file_hash.encode()
            if len(self.index_list[line]) == 4:
                self.index_list[line][2] = str(file_len).encode()


    def save(self):
        # data = b''
        # 我去,速度提升太明显了吧
        data = bytearray()

        for line_list in self.index_list:
            data += b'\t'.join(line_list) + b'\r\n'
        data = bytes(data)

        packer = FilePack(data, int(time.time()), 4)
        data = packer.get()

        unset_file_read(self.index_path)
        with open(self.index_path, 'wb')as f:
            f.write(data)
        set_file_read(self.index_path)
        print('已将更改写入 %s' % self.index_path)
        

class OfficialIndex:

    def __init__(self, index_dir):
        # 官服的_index文件夹下不止一个index文件
        self.list_indexs = []
        self.dict = {}

        for file in os.listdir(index_dir):
            res = re.match(r'^\d+?\.idx$', file)
            if res:
                index_path = os.path.join(index_dir, file)
                index = Index(index_path)
                self.list_indexs.append(index)

                #self.dict.update(index.dict)      
                self.dict = merge_hash_dict(self.dict, index.dict) 


    def get_names_by_hash(self, hash : bytes) -> tuple:
        if hash in self.dict.keys():
            return self.dict[hash]
        else:
            return (hash, )
        

    # 对于OfficialIndex,此函数仅用作判断name对应的item是否在索引文件中存在
    def get_line_by_name(self, name : bytes):
        line = -1
        for index in self.list_indexs:
            line = index.get_line_by_name(name)
            if line != -1:
                return line
        return line
    

    def get_line_data_by_name(self, name : bytes):
        line = -1
        for index in self.list_indexs:
            line = index.get_line_by_name(name)
            if line != -1:
                return index.index_list[line]
        return None


    def update(self, name : bytes, file_path):
        with open(file_path, 'rb')as f:
            file_data = f.read()
    
        for index in self.list_indexs:
            file_hash = index._update(name, file_data)
        
        return file_hash
    
    
    def update_by_info(self, name : bytes, file_hash : str, file_len : int):
        for index in self.list_indexs:
            index.update_by_info(name, file_hash, file_len)


    def save(self):
        for index in self.list_indexs:
            index.save()


if __name__ == '__main__':
    index_dir = r'D:\Game\GujianOL\data\_index'#r'temp\_index_of'
    index = OfficialIndex(index_dir)

解包结果

磁盘文件

逻辑定位

索引文件一节一样,这里我们同样在CreateFileW下断点,暂停条件改为rcx指向d:\Game\GujianOL/data/data000

d:\Game\GujianOL/data/data000
0x0     0x47005c003a0064    d:\G
0x8     0x5c0065006d0061    ame\
0x10    0x69006a00750047    Guji
0x18    0x4c004f006e0061    anOL
0x20    0x7400610064002f    /dat
0x28    0x610064002f0061    a/da
0x30    0x30003000610074    ta00
0x38    0x30                0

image-20231110095640626

第一次断在CreateFileW

断在此处:

image-20231110095953468

和上一节同样的方法,跟踪到调用这个函数的位置:

image-20231110100006656

简单一对比,该地址所处的函数,刚好是前面我们分析过的Func_CreateFileHandle_1400A3D00

继续探索哪里调用了这个函数,可以跟踪到此处:

image-20231110100025964

对比确定当前所在函数为Func_GetDiskFileLinkedList_1400ACE20ida中部分伪代码为:

image-20231110143158733

继续跟踪哪里调用了上面那个函数,来到此处:

image-20231110100130809

对应函数为Func_LoadDiskFilesInfo_1400ACFE0ida中部分伪代码为:

image-20231110143314430

第二次断在CreateFileW

再次ctrl+f9想要跟踪哪里调用了上面那个函数时,居然又断在了CreateFileW

image-20231110100726165

同理又依次跟踪到了前面都分析过的Func_CreateFileHandle_1400A3D00Func_LoadFileHandle_1400A3BE0

然后我们跟踪到了,调用了Func_LoadFileHandle_1400A3BE0的、前面第一次断在CreateFileW时最后一步跟踪到的Func_LoadDiskFilesInfo_1400ACFE0

image-20231110101245297

此处对应ida中的部分伪代码:

image-20231110143421846

第三次断在CreateFileW

这次我们继续跟踪哪里调用了上一个函数,结果还没跟到ret,第三次断在了CreateFileW

image-20231110101725933

同样又依次跟踪到了前面都分析过的Func_CreateFileHandle_1400A3D00Func_LoadFileHandle_1400A3BE0

再接下来,然后我们跟踪到了,调用了Func_LoadFileHandle_1400A3BE0Func_LoadDiskFileHandleAndCheckZFS_1400ABB50

image-20231110102118571

此处对应ida中的部分伪代码:

image-20231110102901360

继续跟踪到调用了上个函数的、我们前面都已经跟踪到两次的Func_LoadDiskFilesInfo_1400ACFE0

image-20231110103140694

此处对应ida中的部分伪代码:

image-20231110143617610

最后跟踪到调用了Func_LoadDiskFilesInfo_1400ACFE0Func_InitDiskFilesInfoCase2_1400A9D40

image-20231110103222042

此处的伪代码为:

image-20231110151539014

小总结

三次断在CreateFileW,我们三次都跟踪到了Func_LoadDiskFilesInfo_1400ACFE0,最后跟踪到了调用了该函数的Func_InitDiskFilesInfoCase2_1400A9D40

这里Func_InitDiskFilesInfoCase2_1400A9D40函数名称中有Case2的字眼,这是因为还有Func_InitDiskFilesInfoCase1_1400AD3E0Case1这个函数我之前自己分析的时候动调跟踪到过,但是写博客的时候不知道为啥只会跟踪到Case2。不过这两个函数的总体逻辑是相似的,都调用了Func_LoadDiskFilesInfo_1400ACFE0Func_LoadZfsChunksInfoOfAllDiskFiles_1400AD320Case1的伪代码见下图),后续我们只以Func_InitDiskFilesInfoCase2_1400A9D40为例进行分析。

image-20231110150440950

接下来,我们从Func_InitDiskFilesInfoCase2_1400A9D40这个函数开始,按照其内部调用函数的先后顺序,分析相关函数。

逆向分析

0 Func_InitDiskFilesInfoCase2_1400A9D40

总结
伪代码
Part1
Part2
Part3
Part4
1 Func_LoadDiskFilesInfo_1400ACFE0

总结
  1. 依次判断游戏根目录下的data文件夹中data000~data255这些文件是否存在,并将存在的文件添加到链表中。
  2. 创建Stru_DiskFilesContext结构体,内含所有dataxxx的相关信息,包括文件句柄等,返回此结构体的指针。
  3. 其内部调用的Func_LoadDiskFileHandleAndCheckZFS_1400ABB50会检查文件开头4字节是否为"ZFS\x00"
伪代码
Stru_DiskFilesContext *__fastcall Func_LoadDiskFilesInfo_1400ACFE0(
        const char *szPathPrefix,
        int a2,
        unsigned int a3,
        __int64 a4)
{
  size_t dwPathPrefixLen; // rbx
  char v6; // sp
  __int64 v7; // rcx
  __int64 pMemoryFuncs_143E094A0; // r13
  Stru_MemoryFuncs *pMemoryFuncs_143E094A0_1; // rax
  Stru_DiskFileNode *pSDiskFileNodes; // rax
  __int64 qwDiskFilesNum; // rsi
  Stru_DiskFileNode *pSDiskFileNode; // r14
  unsigned __int16 i; // bp
  Stru_DiskFilesContext *pSDiskFilesCtx_1; // rax
  Stru_DiskFilesContext *pSDiskFilesCtx; // rdi
  void *szPathPrefixCopy; // rax
  _BYTE *v18; // rcx
  char v19; // al
  __int64 pSMemoryFuncs; // rax
  char *pszDiskFilePath; // rbx
  __int64 dwPos; // rsi
  __int64 dwDiskFilePathLen; // rdx
  __int64 pszDiskFilePathCopy; // rax
  char c; // cl
  Stru_FileHandle *pSFileHandle; // rax
  BOOL dwHasDiskFileExists; // ebx
  __int64 ctx[34]; // [rsp+30h] [rbp-148h] BYREF

  dwPathPrefixLen = -1i64;
  do
    ++dwPathPrefixLen;
  while ( szPathPrefix[dwPathPrefixLen] );
  v7 = (__int64)ctx;
  if ( ((v6 + 48) & 7) != 0 )
    v7 = (__int64)&ctx[1] - ((v6 + 48) & 7);
  pMemoryFuncs_143E094A0 = (__int64)&Stru_MemoryFuncs_143E094A0;
  ctx[5] = 0x10000i64;
  pMemoryFuncs_143E094A0_1 = &Stru_MemoryFuncs_143E094A0;
  ctx[6] = 256i64;
  if ( g_pSMemoryFuncs2 )
    pMemoryFuncs_143E094A0_1 = g_pSMemoryFuncs2;// 和前面的赋值没区别,都指向同一地址
  ctx[0] = (__int64)pMemoryFuncs_143E094A0_1;
  ctx[1] = 0i64;
  ctx[2] = v7 + 72;
  ctx[7] = 0i64;
  ctx[8] = 0i64;
  ctx[3] = v7 + 256;
  ctx[4] = v7 + 256;
  pSDiskFileNodes = Func_GetDiskFileLinkedList_1400ACE20(szPathPrefix, (__int64)ctx, a2);// 3-动调CreateFileW 第一轮data000
  qwDiskFilesNum = 0i64;
  pSDiskFileNode = pSDiskFileNodes;
  for ( i = 0; pSDiskFileNodes; ++qwDiskFilesNum )// 迭代链表,统计出节点个数
    pSDiskFileNodes = pSDiskFileNodes->pSNextDiskFileNode;
  pSDiskFilesCtx_1 = (Stru_DiskFilesContext *)g_pSMemoryFuncs->Func_Wrapper_malloc_1400A6410(
                                                g_pSMemoryFuncs->qwZero,
                                                56 * qwDiskFilesNum + 184);
  pSDiskFilesCtx = pSDiskFilesCtx_1;
  if ( pSDiskFilesCtx_1 )
  {
    memset(pSDiskFilesCtx_1, 0, 56 * qwDiskFilesNum + 184);
    pSDiskFilesCtx->field_8 = a4;
    pSDiskFilesCtx->pfn_sub_1400AB2F0 = sub_1400AB2F0;
    pSDiskFilesCtx->qwDiskFilesNum = qwDiskFilesNum;
    pSDiskFilesCtx->pfn_sub_1400AB830 = sub_1400AB830;
    pSDiskFilesCtx->pfn_sub_1400AB5C0 = sub_1400AB5C0;
    pSDiskFilesCtx->pfn_sub_1400AB9D0 = sub_1400AB9D0;
    pSDiskFilesCtx->dwFlag = a2 != 0;
    pSDiskFilesCtx->field_5C = 1;
    InitializeCriticalSection(&pSDiskFilesCtx->rtl_critical_section);
    pSDiskFilesCtx->field_60 = a3;
    if ( a3 < 0xA00000 )
      pSDiskFilesCtx->field_60 = 0xA00000;
    szPathPrefixCopy = (void *)g_pSMemoryFuncs->Func_Wrapper_malloc_1400A6410(
                                 g_pSMemoryFuncs->qwZero,
                                 dwPathPrefixLen + 2);
    pSDiskFilesCtx->szPathPrefix = szPathPrefixCopy;
    if ( dwPathPrefixLen )
    {
      memcpy(szPathPrefixCopy, szPathPrefix, dwPathPrefixLen);
      v18 = (_BYTE *)(pSDiskFilesCtx->szPathPrefix + dwPathPrefixLen);
      v19 = *(v18 - 1);
      if ( v19 != '/' && v19 != '\\' )          // 如果字符串最后一个字节不是'\\'或'/',则在其后追加一个'/'
      {
        *v18 = '/';
        ++dwPathPrefixLen;
      }
    }
    *(_BYTE *)(dwPathPrefixLen + pSDiskFilesCtx->szPathPrefix) = 0;// 字符串末尾追加'\x00'
    pSMemoryFuncs = (__int64)g_pSMemoryFuncs;
    pSDiskFilesCtx->field_88 = 96i64;
    pSDiskFilesCtx->field_90 = 682i64;
    if ( pSMemoryFuncs )
      pMemoryFuncs_143E094A0 = pSMemoryFuncs;
    pSDiskFilesCtx->field_98 = 0i64;
    pSDiskFilesCtx->field_A0 = 0i64;
    pSDiskFilesCtx->field_A8 = 0i64;
    for ( pSDiskFilesCtx->pSMemoryFuncs = (Stru_MemoryFuncs *)pMemoryFuncs_143E094A0; pSDiskFileNode; ++i )// 逐一处理每一个DiskFile节点
    {
      pszDiskFilePath = &pSDiskFileNode->szDiskFilePath;
      dwPos = 56i64 * i;
      dwDiskFilePathLen = -1i64;
      do
        ++dwDiskFilePathLen;
      while ( pszDiskFilePath[dwDiskFilePathLen] );
      pszDiskFilePathCopy = g_pSMemoryFuncs->Func_Wrapper_malloc_1400A6410(
                              g_pSMemoryFuncs->qwZero,
                              dwDiskFilePathLen + 1);
      *(__int64 *)((char *)&pSDiskFilesCtx->field_C0 + dwPos) = pszDiskFilePathCopy;// 
                                                // base + 0xB8之后的每56个字节都是一个Stru_DiskFileContext结构,一共i个
                                                // base + 0xB8 + 56 * i       pSFileHandle
                                                // base + 0xB8 + 56 * i + 8   pszDiskFilePath
      do
      {
        c = *pszDiskFilePath;
        ++pszDiskFilePathCopy;
        ++pszDiskFilePath;
        *(_BYTE *)(pszDiskFilePathCopy - 1) = c;// 拷贝字符串
      }
      while ( c );
      if ( pSDiskFilesCtx->dwFlag )
        sub_1400ABD70((__int64)&pSDiskFilesCtx->field_B8 + dwPos, 4u, pSDiskFilesCtx->field_60);
      pSFileHandle = Func_LoadFileHandle_1400A3BE0(*(CHAR **)((char *)&pSDiskFilesCtx->field_C0 + dwPos), 0);// 3-动调CreateFileW 第二轮data000
      if ( pSFileHandle )
      {
        dwHasDiskFileExists = pSFileHandle->hFile != -1i64;
        pSFileHandle->pFuncs->Func_Wrapper_CloseHandle_1400A3450(pSFileHandle);
        if ( dwHasDiskFileExists )
          Func_LoadDiskFileHandleAndCheckZFS_1400ABB50(pSDiskFilesCtx, i, a2);// 
                                                // 4-动调CreateFileW 第二轮data000的第二次
                                                // 加载磁盘文件的句柄,并比对开头4字节是否是"ZFS\x00"
      }
      pSDiskFileNode = pSDiskFileNode->pSNextDiskFileNode;// 下一个节点
    }
    sub_1400A6570(ctx);
    return pSDiskFilesCtx;
  }
  else
  {
    sub_1400A6570(ctx);
    return 0i64;
  }
}
Part1

image-20231110165009649

伪代码中的ctx实在没分析出来,只知道应该是一个上下文结构体,后续会传递给Func_GetDiskFileLinkedList_1400ACE20中的Func_CreateNewDiskFileNode_1400A6460,用于创建链表中的节点。好在没有影响分析主要逻辑。

第51行调用了Func_GetDiskFileLinkedList_1400ACE20,依次判断游戏根目录下的data文件夹中data000~data255这些文件是否存在,并将存在的文件添加到链表中并返回链表中的第一个节点的地址,数据结构为Stru_DiskFileNode

第54行,通过迭代链表中的节点,统计出了节点个数。

Part2

image-20231110165735043

创建了一个新的结构体,长度为56 * qwDiskFilesNum + 184,所以很明显其中存储了链表中每个节点对应的dataxxx文件相关的信息。

我们进而定义Stru_DiskFilesContext(这个结构体相当复杂,超级多的字段我们暂时无法确定其具体含义,哪怕结合了其他函数中涉及到的信息,也只能分析成下面这样了):

image-20231110170027575

上述结构体从+0xB8开始,每56个字节都是一个Stru_DiskFileContext子结构体。同样由于不好定义动态长度的结构体的原因,这里我设计了一些占位的字段,以免伪代码中由于我们定义的结构体大小小于理论设计上的结构体的大小,导致出现类似于pDiskFilesCtx[1].xxx这种错误显示。

Part3

image-20231110170518139

前面的Part2只是初始化了结构体中第一部分(从+0+0xB0)的相关字段。

这里的Part3,在for循环中,依次处理链表中的每个节点的相关信息,并将结果存储到结构体中。

正如第110行注释中所说,结构体基址 + 0xB8开始的每56个字节都是一个Stru_DiskFileContext结构(跟上面的Stru_DiskFilesContext名称中差一个s,来自后期的我承认这样命名确认容易混淆),我们这样定义Stru_DiskFileContext

image-20231110182508121

因此在Stru_DiskFilesContext中,结构体基址 + 0xB8 + 56 * i是第i个节点的pSFileHandle结构体基址 + 0xB8 + 56 * i + 8是第i个节点的pszDiskFilePath

然后在第124行,将结构体基址 + 0xC0 + dwPos结构体基址 + 0xB8 + 56 * i + 8传给Func_LoadFileHandle_1400A3BE0,第二次打开dataxxx文件的句柄。

打开成功之后,又关闭了文件句柄。我估计又是通过是否打开文件句柄成功来判断文件是否存在(虽然前面判断过一次了,但是谁让开发者在代码中就是这样写的呢)。

如果文件存在,则调用Func_LoadDiskFileHandleAndCheckZFS_1400ABB50(这个函数内部调用了此前日志中显示的第二轮的第二次的CreateFileW),校验磁盘文件的开头4个字节是否为"ZFS\x00"

处理完当前节点对应的dataxxx后,继续处理下一个节点的。

1-1 Func_GetDiskFileLinkedList_1400ACE20

总结

依次判断游戏根目录下的data文件夹中data000~data255这些文件是否存在,并将存在的文件添加到链表中并返回Stru_DiskFileNode *

伪代码
Stru_DiskFileNode *__fastcall Func_GetDiskFileLinkedList_1400ACE20(const char *szPathPrefix, __int64 ctx, int a3)
{
  __int64 ctx_1; // r8
  Stru_DiskFileNode *pSCurrentNode; // r12
  __int64 dwPathPrefixLen; // rax
  unsigned int dwDiskFileIndex; // esi
  unsigned __int64 qwNodeSize; // r13
  Stru_DiskFileNode *pSDiskFileNode; // r15
  Stru_FileHandle *pSFileHandle; // rbx
  WCHAR *pwszDiskFilePath; // rax
  BOOL bHasDiskFileExists; // edi
  Stru_DiskFileNode *pSFirstDiskFileNode; // [rsp+20h] [rbp-1078h] BYREF
  int v18; // [rsp+28h] [rbp-1070h]
  __int64 ctx_2; // [rsp+30h] [rbp-1068h]
  Stru_WrapperUtf16Str pSWrapperUtf16Str; // [rsp+40h] [rbp-1058h] BYREF

  ctx_2 = ctx;
  ctx_1 = ctx;
  pSFirstDiskFileNode = 0i64;
  pSCurrentNode = (Stru_DiskFileNode *)&pSFirstDiskFileNode;
  v18 = a3;
  dwPathPrefixLen = -1i64;
  while ( szPathPrefix[++dwPathPrefixLen] != 0 )
    ;
  dwDiskFileIndex = 0;
  qwNodeSize = (dwPathPrefixLen + 31) & 0xFFFFFFFFFFFFFFF8ui64;
  while ( 1 )
  {
    pSDiskFileNode = (Stru_DiskFileNode *)Func_CreateNewDiskFileNode_1400A6460(ctx_1, qwNodeSize);// 链表的节点
    memset(pSDiskFileNode, 0, qwNodeSize);
    sprintf(&pSDiskFileNode->szDiskFilePath, "%s/data%03u", szPathPrefix, dwDiskFileIndex);
    if ( !a3 )
    {
      while ( 1 )
      {
        if ( pSDiskFileNode == (Stru_DiskFileNode *)-8i64 )
        {
          pSFileHandle = Func_CreateFileHandle_1400A3D00(0i64, 0);
        }
        else
        {
          memset(&pSWrapperUtf16Str, 0, sizeof(pSWrapperUtf16Str));
          pwszDiskFilePath = Func_Ansi2Utf16_1400A3010(&pSWrapperUtf16Str, &pSDiskFileNode->szDiskFilePath);
          pSFileHandle = pwszDiskFilePath ? Func_CreateFileHandle_1400A3D00(pwszDiskFilePath, 0) : 0i64;
          if ( pSWrapperUtf16Str.pwszData )     // 2-动调CreateFileW 第一轮data000
          {
            g_pSMemoryFuncs->Func_Wrapper_free_1400A6420(g_pSMemoryFuncs->qwZero, (void *)pSWrapperUtf16Str.pwszData);
            pSWrapperUtf16Str.pwszData = 0i64;
          }
        }
        if ( pSFileHandle )
        {
          bHasDiskFileExists = pSFileHandle->hFile != -1i64;
          pSFileHandle->pFuncs->Func_Wrapper_CloseHandle_1400A3450(pSFileHandle);// 句柄刚打开又关闭了
          if ( bHasDiskFileExists )             // 所以这里的操作,应该只是用来判断磁盘文件是否存在
            break;                              // 文件存在的话,退出内层while循环;文件不存在的话,继续向下枚举,直到找个一个存在的文件
        }
        if ( ++dwDiskFileIndex >= 0x100 )       // 磁盘文件数量最大255,达到上限直接返回链表
          return pSFirstDiskFileNode;
        sprintf(&pSDiskFileNode->szDiskFilePath, "%s/data%03u", szPathPrefix, dwDiskFileIndex);
      }
      a3 = v18;
    }
    ++dwDiskFileIndex;
    pSCurrentNode->pSNextDiskFileNode = pSDiskFileNode;// 节点插入链表尾部
    pSCurrentNode = pSDiskFileNode;             // 更新当前节点
    if ( dwDiskFileIndex >= 0x100 )
      break;
    ctx_1 = ctx_2;
  }
  return pSFirstDiskFileNode;
}
Part1

image-20231110153808915

一些初始化操作。

第23行获取了szPathPrefix字符串的长度。

第26行的& 0xFFFFFFFFFFFFFFF8实际上是向下对齐到8的倍数。

Part2

image-20231110154225167

首先通过Func_CreateNewDiskFileNode_1400A6460创建一个链表中的节点(该数据结构中具体的成员变量稍后讨论)。

但是这个函数我并没有跟进去分析,因为它的args1是一个上下文结构体,由当前函数的调用者Func_LoadDiskFilesInfo_1400ACFE0构造此结构体并传入。由于我此前并没有分析出来这个ctx的数据结构,所以很难继续分析Func_CreateNewDiskFileNode_1400A6460。我只是通过当前函数的前后逻辑以及动调来推测出该函数的大概功能。

然后通过 "%s/data%03u"来拼接出dataxxx的绝对路径。

Part3

image-20231110154718616

第43行把ANSI编码的路径转换成宽字符编码,然后调用我们前面分析过的Func_CreateFileHandle_1400A3D00来打开文件句柄。但是打开文件句柄后,啥事都没干,就在第54行把句柄给关闭了。

所以我们可以推测,这里实际上是通过打开文件句柄是否成功来判断dataxxx文件是否存在(xxx是从000遍历到255),如果不存在,则继续判断序号递增的下一个dataxxx文件是否存在,直到找到一个存在的dataxxx文件时再跳出内层循环,交给外层循环处理相关后续(即Part4中的在链表中添加节点)。

这里的逻辑,刚好印证了我们在本章一开始的时候,在x64dbg日志中观察到的有关CreateFileW的前半部分操作,即先从data000data255处理一遍。

Part4

image-20231110153625071

将本轮外层循环中通过处理得到的节点,插入到链表的尾部,并将链表中的当前节点设置为该节点。

如果已经遍历完data000 ~ data255,则跳出外层循环,函数返回链表中的第一个节点。

关于链表节点的数据结构

当前函数中涉及到了用作链表中的节点的数据结构,本来应该去分析Func_CreateNewDiskFileNode_1400A6460这个函数,但是我偷懒,直接上动调:

image-20231028165511601

image-20231028165444250

image-20231110161702834

从而轻松观察到其实就两个成员变量,因此我们可以定义如下结构体Stru_DiskFileNode:、

image-20231110161137764

第一个QWORD是下一个节点的地址;从第9个字节开始的数据是dataxxx文件的绝对路径(同样由于ida没法设置动态长度的字符串/数组,所以这里我只使用了一个BYTE来表示占位)。

1-2 Func_LoadDiskFileHandleAndCheckZFS_1400ABB50

总结

打开文件句柄,读取文件开头4字节并判断是否为"ZFS\x00"

伪代码
Stru_FileHandle *__fastcall Func_LoadDiskFileHandleAndCheckZFS_1400ABB50(
        Stru_DiskFilesContext *pSDiskFilesCtx,
        unsigned __int16 i,
        int iOpenFileType)
{
  Stru_DiskFileContext *pSDiskFileCtx; // rbx
  Stru_FileHandle *result; // rax
  Stru_FileHandle *pSFileHandle; // rax
  Stru_FileHandle *pSFileHandle_1; // rcx
  int dwFirstDword; // [rsp+38h] [rbp+10h] BYREF

  pSDiskFileCtx = (Stru_DiskFileContext *)(&pSDiskFilesCtx->field_B8 + 7 * i);
  result = pSDiskFileCtx->pSFileHandle;
  if ( !pSDiskFileCtx->pSFileHandle )           // 如果为空,则创建SFileHandle
  {
    pSFileHandle = Func_LoadFileHandle_1400A3BE0(pSDiskFileCtx->pszDiskFilePath, iOpenFileType);// 3-动调CreateFileW 第二轮data000的第二次
    pSDiskFileCtx->pSFileHandle = pSFileHandle;
    if ( !pSFileHandle )                        // 如果以iOpenFileType创建失败,则以iOpenFileType=4再次创建
    {
      if ( iOpenFileType == 1 && !(unsigned int)sub_1400A5270(pSDiskFileCtx->pszDiskFilePath, 0i64, 0i64) )
        pSDiskFileCtx->pSFileHandle = Func_LoadFileHandle_1400A3BE0(pSDiskFileCtx->pszDiskFilePath, 4);
      if ( !pSDiskFileCtx->pSFileHandle )
        return pSDiskFileCtx->pSFileHandle;
    }
    if ( pSDiskFileCtx->pSFileHandle
      && pSDiskFileCtx->pSFileHandle->pFuncs->Func_Wrapper_ReadFile_1400A34B0(// 读取开头4字节
           pSDiskFileCtx->pSFileHandle,
           &dwFirstDword,
           4i64) == 4
      && dwFirstDword == aZFS )                 // 判断开头4字节是否是"ZFS\x00"
    {
      return pSDiskFileCtx->pSFileHandle;
    }
    pSFileHandle_1 = pSDiskFileCtx->pSFileHandle;
    if ( iOpenFileType )
    {
      if ( pSFileHandle_1
        && !((__int64 (__fastcall *)(Stru_FileHandle *, _QWORD, _QWORD))pSFileHandle_1->pFuncs->Func_Wrapper_SetFilePointerEx_1400A3510)(
              pSFileHandle_1,
              0i64,
              0i64)                             // 指针设置到开头
        && pSDiskFileCtx->pSFileHandle
        && pSDiskFileCtx->pSFileHandle->pFuncs->Func_Wrapper_WriteFile_1400A34E0(// 写入"ZFS\x00"
             pSDiskFileCtx->pSFileHandle,
             &aZFS,
             4i64) == 4
        && pSDiskFileCtx->pSFileHandle
        && pSDiskFileCtx->pSFileHandle->pFuncs->Func_Wrapper_FlushFileBuffers_1400A3490(pSDiskFileCtx->pSFileHandle) )
      {
        return pSDiskFileCtx->pSFileHandle;
      }
      pSFileHandle_1 = pSDiskFileCtx->pSFileHandle;
      if ( pSDiskFileCtx->pSFileHandle )
        goto LABEL_20;
    }
    else if ( pSFileHandle_1 )
    {
LABEL_20:
      pSFileHandle_1->pFuncs->Func_Wrapper_CloseHandle_1400A3450(pSFileHandle_1);
    }
    pSDiskFileCtx->pSFileHandle = 0i64;
    return pSDiskFileCtx->pSFileHandle;
  }
  return result;
}
Part1

image-20231110200925683

前半部分就是单纯的打开文件句柄,读取开头4个字节并判断是否为"ZFS\x00"

当然,由于ZFS这个字符串太短了,导致ida并不能将其识别为字符串,而是一个DWORD,所以这里得能用肉眼看出来。

image-20231110201030316

另外,伪代码第12行的&pSDiskFilesCtx->field_B8 + 7 * i,其实是(QWORD)(&pSDiskFilesCtx->field_B8) + 56 * i 7 * i是因为&pSDiskFilesCtx->field_B8QWORD *类型,+1就是QWORD类型下的+8

Part2

image-20231110201252500

后半部分没太分析出来,只知道是要在某种情况下写入"ZFS\x00"

2 Func_LoadZfsChunksInfoOfAllDiskFiles_1400AD320

总结

循环中调用NFunc_LoadZfsChunksInfo_1400AC2E0来处理N个磁盘文件(处理是解析磁盘文件的基本格式,将),最终应该会形成一个类似于索引一样的东西,以便后续可以动态加载需要的文件。

伪代码

image-20231110203132071

分析

第9行申请了大小为0x20008的空间。对照后续的分析,这个长度刚好是4 + 4 + 0x1000 * 0x20

然后在循环中,调用NFunc_LoadZfsChunksInfo_1400AC2E0来处理N个磁盘文件,最终应该会形成一个类似于索引一样的东西,以便后续可以动态加载需要的文件。

2-1 Func_LoadZfsChunksInfo_1400AC2E0

总结
伪代码
__int64 __fastcall Func_LoadZfsChunksInfo_1400AC2E0(
        Stru_DiskFilesContext *pSDiskFilesCtx,
        unsigned __int16 i,
        unsigned __int8 *pbZfsChunkHeader)
{
  unsigned __int8 *pbZfsChunkHeader_1; // rsi
  int dwChunkPos; // ebx
  __int64 dwChunkPos_2; // rax
  Stru_DiskFileContext *pSDiskFileCtx; // r12
  union _LARGE_INTEGER v8; // rbp
  union _LARGE_INTEGER qwFilePointer; // rcx
  Stru_FileItemInfo *pSFileItemInfo_1; // r13
  bool dwFlag; // zf
  Stru_ZfsChunkNode *pSZfsChunkNode; // rax
  __int64 *pSTempZfsChunkNode; // rcx
  int dwFileItemIndex; // eax
  unsigned __int16 wCalcCrc16; // r8
  char *pbHashAdd1; // r9
  __int64 dwRound; // r10
  unsigned int dwFileItemInfoPos; // ebp
  unsigned __int8 v19; // cl
  unsigned __int16 v20; // dx
  __int64 v21; // rax
  unsigned __int16 v22; // dx
  char bStorageType; // cl
  int dwFilePos; // r14d
  int dwCipherLen; // r15d
  Stru_FileItemInfo *pSFileItemInfo; // rsi
  unsigned int a; // r9d
  int b; // edx
  unsigned int c; // r8d
  unsigned int d; // r9d
  int e; // edx
  unsigned int f; // r8d
  unsigned int g; // r9d
  int h; // edx
  unsigned int ii; // r8d
  unsigned int j; // r9d
  int k; // r8d
  int l; // edx
  unsigned int m; // r8d
  unsigned int n; // r9d
  int o; // edx
  unsigned int p; // r8d
  unsigned int q; // r9d
  int r; // edx
  unsigned int dwHashValue1; // r9d
  Stru_FileItemInfoNode *pSFileItemInfoNode; // r8
  Stru_FileItemInfoNode *SFileItemInfoNode; // r8
  __int64 v48; // rdx
  Stru_FileItemInfoNode *pSFileItemInfoNode_3; // rdx
  __int64 v50; // rcx
  __int64 v51; // rdx
  Stru_FileItemInfoNode *pSFileItemInfoNode_1; // rax
  Stru_FileItemInfoNode *pSFileItemInfoNode_2; // rbx
  unsigned int A; // esi
  int B; // ecx
  unsigned int C; // r8d
  unsigned int D; // esi
  int E; // ecx
  unsigned int F; // r8d
  unsigned int G; // esi
  int H; // ecx
  unsigned int I; // r8d
  unsigned int J; // esi
  int K; // r8d
  int v65; // ecx
  int v66; // eax
  int L; // ecx
  unsigned int M; // r8d
  unsigned int N; // esi
  int O; // ecx
  unsigned int P; // r8d
  unsigned int Q; // esi
  int R; // ecx
  int dwHashValue2; // esi
  Stru_FileItemInfoNode *pSFirstFileItemInfoNode; // rax
  Stru_FileItemInfoNode *pSNewNode; // rcx
  Stru_FileItemInfoNode *v77; // rcx
  Stru_FileItemInfoNode *pSNextNode; // rax
  Stru_FileItemInfoNode *v79; // rcx
  __int64 v80; // rdx
  __int64 v81; // rcx
  __int64 v82; // rcx
  size_t v83; // rbp
  Stru_FileItemInfoNode *v84; // rax
  Stru_FileItemInfoNode *v85; // rsi
  unsigned int v86; // r11d
  __int64 v87; // r9
  __int64 v88; // r10
  __int64 v89; // rcx
  Stru_FileItemInfoNode *v90; // rcx
  int v91; // eax
  Stru_FileItemInfoNode *v92; // rax
  Stru_FileHandle *pSFileHandle; // rbx
  int v94; // r14d
  FILE *v95; // rax
  FILE *v96; // rax
  FILE *v97; // rax
  Stru_FileHandle *pSFileHandle_1; // rcx
  unsigned int dwNextChunkPosAdd4; // ebx
  unsigned int dwNextChunkPos_1; // [rsp+20h] [rbp-58h] BYREF
  int dwFileItemIndex_1; // [rsp+24h] [rbp-54h]
  char v103; // [rsp+80h] [rbp+8h] BYREF
  unsigned __int16 i_1; // [rsp+88h] [rbp+10h]
  unsigned __int8 *pbZfsChunkHeader_2; // [rsp+90h] [rbp+18h]
  int dwChunkPos_1; // [rsp+98h] [rbp+20h]

  pbZfsChunkHeader_2 = pbZfsChunkHeader;
  i_1 = i;
  pbZfsChunkHeader_1 = pbZfsChunkHeader;
  dwChunkPos = 0;
  dwChunkPos_2 = 4i64;
  pSDiskFileCtx = (Stru_DiskFileContext *)(&pSDiskFilesCtx->field_B8 + 7 * i);// 
                                                // base + 0xB8 + 56 * i       pSFileHandle
                                                // base + 0xB8 + 56 * i + 8   pszDiskFilePath
  v8.QuadPart = -1i64;
  dwNextChunkPos_1 = 4;
  while ( 1 )                                   // 依次处理每一个IX开头的chunk
  {
    if ( pSDiskFileCtx->pSFileHandle )
    {
      qwFilePointer = pSDiskFileCtx->pSFileHandle->pFuncs->Func_Wrapper_SetFilePointerEx_1400A3510(
                        pSDiskFileCtx->pSFileHandle,
                        (unsigned int)dwChunkPos_2,// 文件指针设置为文件开头+4
                        0);
      dwChunkPos_2 = dwNextChunkPos_1;
    }
    else
    {
      qwFilePointer.QuadPart = -1i64;
    }
    if ( qwFilePointer.QuadPart != dwChunkPos_2
      || !pSDiskFileCtx->pSFileHandle
      || pSDiskFileCtx->pSFileHandle->pFuncs->Func_Wrapper_ReadFile_1400A34B0(// 读取随后的0x20008字节,存储在pbZfsChunkHeader
           pSDiskFileCtx->pSFileHandle,
           pbZfsChunkHeader_1,
           0x20008i64) != 0x20008
      || *(_DWORD *)pbZfsChunkHeader_1 != *(_DWORD *)"[IX]" )// 判断chunk的开头4字节为[IX]
    {
      break;
    }
    dwChunkPos = dwNextChunkPos_1;              // 此时dwNextChunkPos_1还是当前chunk的pos
    pSFileItemInfo_1 = (Stru_FileItemInfo *)(pbZfsChunkHeader_1 + 8);// chunk基址+8开始的数据就是该chunk的第一个FileItemInfo
    dwFlag = pSDiskFilesCtx->dwFlag == 0;
    dwNextChunkPos_1 = *((_DWORD *)pbZfsChunkHeader_1 + 1);// [IX]后面的4个字节是下一个Zfs Chunk的Pos
    dwChunkPos_1 = dwChunkPos;
    if ( dwFlag )
    {
      pSDiskFileCtx->dwChunkPos = dwChunkPos;
    }
    else
    {
      pSZfsChunkNode = (Stru_ZfsChunkNode *)g_pSMemoryFuncs->Func_Wrapper_malloc_1400A6410(
                                              g_pSMemoryFuncs->qwZero,
                                              16i64);
      if ( pSZfsChunkNode )
      {
        pSZfsChunkNode->pSNextNode = 0i64;
        pSZfsChunkNode->dwChunkPos = dwChunkPos;
        pSTempZfsChunkNode = &pSDiskFileCtx->pSZfsChunkLinkedList;
        for ( pSZfsChunkNode->dwNextChunkPos = pSDiskFileCtx->dwChunkPos;
              *pSTempZfsChunkNode;              // 直到找到最后一个Node时停止循环
              pSTempZfsChunkNode = (__int64 *)*pSTempZfsChunkNode )
        {
          ;
        }
        *pSTempZfsChunkNode = (__int64)pSZfsChunkNode;// 链表末尾追加这个新创建的节点
      }
      pSDiskFileCtx->dwChunkPos = dwChunkPos;
      sub_1400ABE90(pSDiskFileCtx, dwChunkPos, dwChunkPos + 0x20008);
    }
    dwFileItemIndex = 0;
    dwFileItemIndex_1 = 0;
    while ( 1 )                                 // 依次处理此Zfs Chunk中的每一个文件记录
    {
      wCalcCrc16 = 0;
      pbHashAdd1 = &pSFileItemInfo_1->pbHash[1];
      dwRound = 7i64;
      dwFileItemInfoPos = 32 * dwFileItemIndex + dwChunkPos + 8;
      do
      {
        v19 = wCalcCrc16 ^ *(pbHashAdd1 - 1);
        pbHashAdd1 += 4;                        // 计算crc16,每次迭代4字节,共7轮,总计28字节
        v20 = HIBYTE(wCalcCrc16) ^ pwCrc16Table_143B0BD30[v19];
        v21 = (unsigned __int8)(HIBYTE(wCalcCrc16) ^ LOBYTE(pwCrc16Table_143B0BD30[v19]) ^ *(pbHashAdd1 - 4));
        v22 = ((unsigned __int16)(HIBYTE(v20) ^ pwCrc16Table_143B0BD30[v21]) >> 8) ^ pwCrc16Table_143B0BD30[(unsigned __int8)(HIBYTE(v20) ^ LOBYTE(pwCrc16Table_143B0BD30[v21]) ^ *(pbHashAdd1 - 3))];
        wCalcCrc16 = HIBYTE(v22) ^ pwCrc16Table_143B0BD30[(unsigned __int8)(v22 ^ *(pbHashAdd1 - 2))];
        --dwRound;
      }
      while ( dwRound );
      bStorageType = pSFileItemInfo_1->bStorageType;
      dwFilePos = pSFileItemInfo_1->dwFilePos;
      dwCipherLen = pSFileItemInfo_1->dwCipherLen;
      pSFileItemInfo = 0i64;
      if ( wCalcCrc16 == pSFileItemInfo_1->wCrc16 )// 比对计算出的crc16和FileItemInfo中存储的crc16
        pSFileItemInfo = pSFileItemInfo_1;
      ++pSFileItemInfo_1;                       // 该chunk中的下一个FileItemInfo
      if ( !bStorageType )                      // 用bStorageType==0来判断此chunk中的FileItemInfo都已经处理完了(原理是最后一个有效FileItemInfo后面的那个FileItemInfo的所有变量均为0)
        break;
      if ( bStorageType != 1 )                  // 不是1的好像就不是合法数据,可能是用于标注哪些数据可以在整理碎片的时候可以删除?
      {
        if ( pSDiskFilesCtx->dwFlag
          && !(unsigned int)sub_1400ABC70(pSDiskFileCtx, dwFileItemInfoPos, 1i64, dwFileItemInfoPos + 32) )
        {
          v96 = __iob_func();
          fprintf(v96 + 2, "load del index range error!\n");
        }
        goto LABEL_69;
      }
      if ( pSFileItemInfo )
      {
        a = (((unsigned __int8)pSFileItemInfo->pbHash[9]
            + (((unsigned __int8)pSFileItemInfo->pbHash[10] + ((unsigned __int8)pSFileItemInfo->pbHash[11] << 8)) << 8)) << 8)
          - 0x1124111
          + (unsigned __int8)pSFileItemInfo->pbHash[8];// a = dw8 - 0x1124111
        b = (a >> 13) ^ ((((unsigned __int8)pSFileItemInfo->pbHash[1]
                         + (((unsigned __int8)pSFileItemInfo->pbHash[2]
                           + ((unsigned __int8)pSFileItemInfo->pbHash[3] << 8)) << 8)) << 8)
                       - ((((unsigned __int8)pSFileItemInfo->pbHash[5]
                          + (((unsigned __int8)pSFileItemInfo->pbHash[6]
                            + ((unsigned __int8)pSFileItemInfo->pbHash[7] << 8)) << 8)) << 8)
                        + (unsigned __int8)pSFileItemInfo->pbHash[4])
                       - a
                       + (unsigned __int8)pSFileItemInfo->pbHash[0]);// b = (a >> 13) ^ (dw0 - dw4)
        c = (b << 8) ^ ((((unsigned __int8)pSFileItemInfo->pbHash[5]
                        + (((unsigned __int8)pSFileItemInfo->pbHash[6]
                          + ((unsigned __int8)pSFileItemInfo->pbHash[7] << 8)) << 8)) << 8)
                      - 0x61C88647              // 魔数0x61c88647与碰撞解决
                      + (unsigned __int8)pSFileItemInfo->pbHash[4]
                      - b
                      - a);                     // c = (b << 8) ^ (dw4 - 0x61C88647 - b - a)
        d = (c >> 13) ^ (a - c - b);
        e = (d >> 12) ^ (b - c - d);
        f = (e << 16) ^ (c - e - d);
        g = (f >> 5) ^ (d - f - e);
        h = (g >> 3) ^ (e - f - g);
        ii = (h << 10) ^ (f - h - g);
        j = ((ii >> 15) ^ (g - ii - h)) + 20;
        k = (unsigned __int8)pSFileItemInfo->pbHash[16]
          + ((unsigned __int8)pSFileItemInfo->pbHash[17] << 8)
          + ((unsigned __int8)pSFileItemInfo->pbHash[18] << 16)
          + ((unsigned __int8)pSFileItemInfo->pbHash[19] << 24)
          + ii;                                 // k = dw16 + i
        l = (j >> 13) ^ ((unsigned __int8)pSFileItemInfo->pbHash[12]
                       + ((unsigned __int8)pSFileItemInfo->pbHash[13] << 8)
                       + ((unsigned __int8)pSFileItemInfo->pbHash[14] << 16)
                       + ((unsigned __int8)pSFileItemInfo->pbHash[15] << 24)
                       + h
                       - k
                       - j);                    // l = (j >> 13) ^ (dw12 + h - k - j)
        m = (l << 8) ^ (k - l - j);
        n = (m >> 13) ^ (j - m - l);
        o = (n >> 12) ^ (l - m - n);
        p = (o << 16) ^ (m - o - n);
        q = (p >> 5) ^ (n - p - o);
        r = (q >> 3) ^ (o - p - q);
        dwHashValue1 = (((r << 10) ^ (p - r - q)) >> 15) ^ (q - ((r << 10) ^ (p - r - q)) - r);// 从a到这的一大串代码,虽然逆不动,但还是可以分析出应该是一种散列算法,把20B的数据散列到4B
        pSFileItemInfoNode = pSDiskFilesCtx->pSFileItemInfoLinkedList;
        if ( !pSFileItemInfoNode
          || (SFileItemInfoNode = pSFileItemInfoNode->pSNextNode,
              (v48 = *((_QWORD *)&SFileItemInfoNode->pSNextNode->pSNextNode
                     + 2 * (dwHashValue1 & (SFileItemInfoNode->field_8 - 1)))) == 0)// 判断HashMap中并没有dwHashValue1这个key?
          || (pSFileItemInfoNode_3 = (Stru_FileItemInfoNode *)(v48 - SFileItemInfoNode->field_20)) == 0i64 )
        {
LABEL_34:
          if ( pSDiskFilesCtx->dwFlag )
            sub_1400ABE90(pSDiskFileCtx, dwFilePos, dwCipherLen + dwFilePos);
          pSFileItemInfoNode_1 = (Stru_FileItemInfoNode *)sub_1400A65D0((__int64)&pSDiskFilesCtx->pSMemoryFuncs);
          pSFileItemInfoNode_2 = pSFileItemInfoNode_1;
          if ( pSFileItemInfoNode_1 )
          {
            pSFileItemInfoNode_1->dwFileItemInfoPos = dwFileItemInfoPos;// 存储文件信息(即header)的pos
            pSFileItemInfoNode_1->dwFilePos = dwFilePos;// 存储文件数据的pos
            pSFileItemInfoNode_1->dwCipherLen = dwCipherLen;
            pSFileItemInfoNode_1->wDiskFileIndex = i_1;
            *(_OWORD *)pSFileItemInfoNode_1->pbHash = *(_OWORD *)pSFileItemInfo->pbHash;
            *(_DWORD *)&pSFileItemInfoNode_1->pbHash[16] = *(_DWORD *)&pSFileItemInfo->pbHash[16];
            A = (((unsigned __int8)pSFileItemInfoNode_1->pbHash[9]
                + (((unsigned __int8)pSFileItemInfoNode_1->pbHash[10]
                  + ((unsigned __int8)pSFileItemInfoNode_1->pbHash[11] << 8)) << 8)) << 8)
              - 0x1124111
              + (unsigned __int8)pSFileItemInfoNode_1->pbHash[8];
            B = (A >> 13) ^ ((unsigned __int8)pSFileItemInfoNode_1->pbHash[0]
                           + (((unsigned __int8)pSFileItemInfoNode_1->pbHash[1]
                             + (((unsigned __int8)pSFileItemInfoNode_1->pbHash[2]
                               + ((unsigned __int8)pSFileItemInfoNode_1->pbHash[3] << 8)) << 8)) << 8)
                           - ((((unsigned __int8)pSFileItemInfoNode_1->pbHash[5]
                              + (((unsigned __int8)pSFileItemInfoNode_1->pbHash[6]
                                + ((unsigned __int8)pSFileItemInfoNode_1->pbHash[7] << 8)) << 8)) << 8)
                            + (unsigned __int8)pSFileItemInfoNode_1->pbHash[4])
                           - A);
            C = (B << 8) ^ ((((unsigned __int8)pSFileItemInfoNode_1->pbHash[5]
                            + (((unsigned __int8)pSFileItemInfoNode_1->pbHash[6]
                              + ((unsigned __int8)pSFileItemInfoNode_1->pbHash[7] << 8)) << 8)) << 8)
                          - 0x61C88647
                          + (unsigned __int8)pSFileItemInfoNode_1->pbHash[4]
                          - B
                          - A);
            D = (C >> 13) ^ (A - C - B);
            E = (D >> 12) ^ (B - C - D);
            F = (E << 16) ^ (C - E - D);
            G = (F >> 5) ^ (D - F - E);
            H = (G >> 3) ^ (E - F - G);
            I = (H << 10) ^ (F - H - G);
            J = ((I >> 15) ^ (G - I - H)) + 20;
            K = (unsigned __int8)pSFileItemInfoNode_1->pbHash[16]
              + ((unsigned __int8)pSFileItemInfoNode_1->pbHash[17] << 8)
              + ((unsigned __int8)pSFileItemInfoNode_1->pbHash[18] << 16)
              + ((unsigned __int8)pSFileItemInfoNode_1->pbHash[19] << 24)
              + I;
            v65 = ((unsigned __int8)pSFileItemInfoNode_1->pbHash[13] << 8)
                + ((unsigned __int8)pSFileItemInfoNode_1->pbHash[14] << 16)
                + ((unsigned __int8)pSFileItemInfoNode_1->pbHash[15] << 24)
                + H;
            v66 = (unsigned __int8)pSFileItemInfoNode_1->pbHash[12];
            *(_QWORD *)&pSFileItemInfoNode_2->field_28 = pSFileItemInfoNode_2->pbHash;
            pSFileItemInfoNode_2->field_30 = 20;
            L = (J >> 13) ^ (v66 + v65 - K - J);
            M = (L << 8) ^ (K - L - J);
            N = (M >> 13) ^ (J - M - L);
            O = (N >> 12) ^ (L - M - N);
            P = (O << 16) ^ (M - O - N);
            Q = (P >> 5) ^ (N - P - O);
            R = (Q >> 3) ^ (O - P - Q);
            dwHashValue2 = (((R << 10) ^ (P - R - Q)) >> 15) ^ (Q - ((R << 10) ^ (P - R - Q)) - R);
            pSFileItemInfoNode_2->dwHashValue = dwHashValue2;
            pSFirstFileItemInfoNode = pSDiskFilesCtx->pSFileItemInfoLinkedList;
            if ( pSFirstFileItemInfoNode )      // 不为空
            {
              pSNextNode = pSFirstFileItemInfoNode->pSNextNode;
              *(_QWORD *)&pSFileItemInfoNode_2->field_10 = 0i64;
              pSFileItemInfoNode_2->pSNextNode = pSNextNode;// 插入到链表首部
              *(_QWORD *)&pSFileItemInfoNode_2->field_8 = pSDiskFilesCtx->pSFileItemInfoLinkedList->pSNextNode->field_18
                                                        - pSDiskFilesCtx->pSFileItemInfoLinkedList->pSNextNode->field_20;
              *(_QWORD *)(pSDiskFilesCtx->pSFileItemInfoLinkedList->pSNextNode->field_18 + 16) = pSFileItemInfoNode_2;
              pSDiskFilesCtx->pSFileItemInfoLinkedList->pSNextNode->field_18 = (__int64)pSFileItemInfoNode_2;
            }
            else                                // 为空
            {
              *(_QWORD *)&pSFileItemInfoNode_2->field_10 = 0i64;
              *(_QWORD *)&pSFileItemInfoNode_2->field_8 = 0i64;
              pSDiskFilesCtx->pSFileItemInfoLinkedList = pSFileItemInfoNode_2;// 作为第一个节点
              pSFileItemInfoNode_2->pSNextNode = (Stru_FileItemInfoNode *)malloc(64ui64);
              pSNewNode = pSDiskFilesCtx->pSFileItemInfoLinkedList->pSNextNode;
              if ( !pSNewNode )
                exit(-1);
              pSNewNode->pSNextNode = 0i64;     // 清0
              *(_QWORD *)&pSNewNode->field_8 = 0i64;
              *(_QWORD *)&pSNewNode->field_10 = 0i64;
              pSNewNode->field_18 = 0i64;
              pSNewNode->field_20 = 0i64;
              *(_QWORD *)&pSNewNode->field_28 = 0i64;
              *(_QWORD *)&pSNewNode->field_30 = 0i64;
              *(_QWORD *)&pSNewNode->dwFileItemInfoPos = 0i64;
              pSDiskFilesCtx->pSFileItemInfoLinkedList->pSNextNode->field_18 = (__int64)pSDiskFilesCtx->pSFileItemInfoLinkedList;
              pSDiskFilesCtx->pSFileItemInfoLinkedList->pSNextNode->field_8 = 32;
              pSDiskFilesCtx->pSFileItemInfoLinkedList->pSNextNode->field_C = 5;
              pSDiskFilesCtx->pSFileItemInfoLinkedList->pSNextNode->field_20 = 0i64;
              pSDiskFilesCtx->pSFileItemInfoLinkedList->pSNextNode->pSNextNode = (Stru_FileItemInfoNode *)malloc(0x200ui64);
              v77 = pSDiskFilesCtx->pSFileItemInfoLinkedList->pSNextNode->pSNextNode;
              if ( !v77 )
                exit(-1);
              memset(v77, 0, 0x200ui64);
              pSDiskFilesCtx->pSFileItemInfoLinkedList->pSNextNode->dwFileItemInfoPos = 0xA0111FE1;
            }
            ++pSDiskFilesCtx->pSFileItemInfoLinkedList->pSNextNode->field_10;
            v79 = pSDiskFilesCtx->pSFileItemInfoLinkedList->pSNextNode;
            v80 = 2i64 * (dwHashValue2 & (unsigned int)(v79->field_8 - 1));
            ++*(&v79->pSNextNode->field_8 + 2 * v80);
            v81 = *((_QWORD *)&pSDiskFilesCtx->pSFileItemInfoLinkedList->pSNextNode->pSNextNode->pSNextNode + v80);
            pSFileItemInfoNode_2->field_18 = 0i64;
            pSFileItemInfoNode_2->field_20 = v81;
            v82 = *((_QWORD *)&pSDiskFilesCtx->pSFileItemInfoLinkedList->pSNextNode->pSNextNode->pSNextNode + v80);
            if ( v82 )
              *(_QWORD *)(v82 + 24) = pSFileItemInfoNode_2;
            *((_QWORD *)&pSDiskFilesCtx->pSFileItemInfoLinkedList->pSNextNode->pSNextNode->pSNextNode + v80) = pSFileItemInfoNode_2;
            if ( *(&pSDiskFilesCtx->pSFileItemInfoLinkedList->pSNextNode->pSNextNode->field_8 + 2 * v80) < (unsigned int)(10 * (*(&pSDiskFilesCtx->pSFileItemInfoLinkedList->pSNextNode->pSNextNode->field_C + 2 * v80) + 1))
              || pSFileItemInfoNode_2->pSNextNode->dwHashValue == 1 )
            {
              dwChunkPos = dwChunkPos_1;
            }
            else
            {
              v83 = 16i64 * (unsigned int)(2 * pSFileItemInfoNode_2->pSNextNode->field_8);
              v84 = (Stru_FileItemInfoNode *)malloc(v83);
              v85 = v84;
              if ( !v84 )
                exit(-1);
              memset(v84, 0, v83);
              v86 = 0;
              for ( *(_QWORD *)&pSFileItemInfoNode_2->pSNextNode->field_28 = (((2
                                                                              * pSFileItemInfoNode_2->pSNextNode->field_8
                                                                              - 1) & pSFileItemInfoNode_2->pSNextNode->field_10) != 0)
                                                                           + ((unsigned int)pSFileItemInfoNode_2->pSNextNode->field_10 >> (LOBYTE(pSFileItemInfoNode_2->pSNextNode->field_C) + 1));
                    v86 < pSFileItemInfoNode_2->pSNextNode->field_8;
                    ++v86 )
              {
                v87 = *((_QWORD *)&pSFileItemInfoNode_2->pSNextNode->pSNextNode->pSNextNode + 2 * v86);
                if ( v87 )
                {
                  do
                  {
                    v88 = *(_QWORD *)(v87 + 32);
                    v89 = (__int64)v85
                        + 16
                        * (*(_DWORD *)(v87 + 52) & (unsigned int)(2 * pSFileItemInfoNode_2->pSNextNode->field_8 - 1));
                    if ( ++*(_DWORD *)(v89 + 8) > pSFileItemInfoNode_2->pSNextNode->field_28 )
                    {
                      ++pSFileItemInfoNode_2->pSNextNode->field_2C;
                      *(_DWORD *)(v89 + 12) = *(_DWORD *)(v89 + 8) / pSFileItemInfoNode_2->pSNextNode->field_28;
                    }
                    *(_QWORD *)(v87 + 24) = 0i64;
                    *(_QWORD *)(v87 + 32) = *(_QWORD *)v89;
                    if ( *(_QWORD *)v89 )
                      *(_QWORD *)(*(_QWORD *)v89 + 24i64) = v87;
                    *(_QWORD *)v89 = v87;
                    v87 = v88;
                  }
                  while ( v88 );
                }
              }
              free(pSFileItemInfoNode_2->pSNextNode->pSNextNode);
              pSFileItemInfoNode_2->pSNextNode->field_8 *= 2;
              ++pSFileItemInfoNode_2->pSNextNode->field_C;
              pSFileItemInfoNode_2->pSNextNode->pSNextNode = v85;
              v90 = pSFileItemInfoNode_2->pSNextNode;
              if ( pSFileItemInfoNode_2->pSNextNode->field_2C <= (unsigned int)pSFileItemInfoNode_2->pSNextNode->field_10 >> 1 )
                v91 = 0;
              else
                v91 = v90->field_30 + 1;
              v90->field_30 = v91;
              v92 = pSFileItemInfoNode_2->pSNextNode;
              dwChunkPos = dwChunkPos_1;
              if ( v92->field_30 > 1u )
                v92->dwHashValue = 1;
            }
            goto LABEL_69;
          }
          goto LABEL_68;
        }
        while ( 1 )
        {
          if ( pSFileItemInfoNode_3->dwHashValue == dwHashValue1 && pSFileItemInfoNode_3->field_30 == 20 )
          {
            v50 = *(_QWORD *)&pSFileItemInfoNode_3->field_28;
            if ( *(_QWORD *)v50 == *(_QWORD *)pSFileItemInfo->pbHash
              && *(_QWORD *)(v50 + 8) == *(_QWORD *)&pSFileItemInfo->pbHash[8]
              && *(_DWORD *)(v50 + 16) == *(_DWORD *)&pSFileItemInfo->pbHash[16] )// 比对hash
            {
              break;                            // hash匹配成功则退出循环
            }
          }
          v51 = pSFileItemInfoNode_3->field_20;
          if ( v51 )
          {
            pSFileItemInfoNode_3 = (Stru_FileItemInfoNode *)(v51
                                                           - pSDiskFilesCtx->pSFileItemInfoLinkedList->pSNextNode->field_20);// next
            if ( pSFileItemInfoNode_3 )         // 为空则跳出这个循环,往上跳转
              continue;
          }
          goto LABEL_34;
        }
      }
      if ( pSDiskFilesCtx->dwFlag )
      {
        pSFileHandle = pSDiskFileCtx->pSFileHandle;
        v94 = pSDiskFilesCtx->field_5C;
        v103 = -1;
        if ( pSFileHandle
          && ((__int64 (__fastcall *)(Stru_FileHandle *, _QWORD, _QWORD))pSFileHandle->pFuncs->Func_Wrapper_SetFilePointerEx_1400A3510)(
               pSFileHandle,
               dwFileItemInfoPos + 31,
               0i64) == dwFileItemInfoPos + 31
          && pSFileHandle->pFuncs->Func_Wrapper_WriteFile_1400A34E0(pSFileHandle, &v103, 1i64) == 1
          && (!v94 || pSFileHandle->pFuncs->Func_Wrapper_FlushFileBuffers_1400A3490(pSFileHandle))
          && !(unsigned int)sub_1400ABC70(pSDiskFileCtx, dwFileItemInfoPos, 1i64, dwFileItemInfoPos + 32) )
        {
          v95 = __iob_func();
          fprintf(v95 + 2, "load dup index range error!\n");
        }
LABEL_68:
        dwChunkPos = dwChunkPos_1;
      }
LABEL_69:
      dwFileItemIndex = dwFileItemIndex_1 + 1;
      dwFileItemIndex_1 = dwFileItemIndex;
      if ( dwFileItemIndex >= 0x1000 )          // 每个Zfs Chunk最多0x1000个File Item
        goto LABEL_79;
    }                                           // while end for per File Item
    if ( pSDiskFilesCtx->dwFlag                 // 这段代码只在Zfs Chunk没有0x1000个File Item时执行(即break跳出while)
      && dwFileItemIndex_1 != 0x1000
      && !(unsigned int)sub_1400ABC70(
                          pSDiskFileCtx,
                          dwFileItemInfoPos,
                          (unsigned int)(0x1000 - dwFileItemIndex_1),
                          dwFileItemInfoPos + 32 * (0x1000 - dwFileItemIndex_1)) )
    {
      v97 = __iob_func();
      fprintf(v97 + 2, "load free index range error!\n");
    }
LABEL_79:
    dwChunkPos_2 = dwNextChunkPos_1;            // 为处理下一个Zfs Chunk做准备
    if ( !dwNextChunkPos_1 )
      return 1i64;
    pbZfsChunkHeader_1 = pbZfsChunkHeader_2;
    v8.QuadPart = -1i64;
  }                                             // while end for per Zfs Chunk
  if ( pSDiskFilesCtx->dwFlag && dwChunkPos )
  {
    pSFileHandle_1 = pSDiskFileCtx->pSFileHandle;
    dwNextChunkPosAdd4 = dwChunkPos + 4;
    dwNextChunkPos_1 = 0;
    if ( pSFileHandle_1 )
      v8 = pSFileHandle_1->pFuncs->Func_Wrapper_SetFilePointerEx_1400A3510(pSFileHandle_1, dwNextChunkPosAdd4, 0);// 文件指针设置为最后一个Zfs Chunk基址+4
    if ( v8.QuadPart == dwNextChunkPosAdd4
      && pSDiskFileCtx->pSFileHandle
      && pSDiskFileCtx->pSFileHandle->pFuncs->Func_Wrapper_WriteFile_1400A34E0(// 写入dword(0)
           pSDiskFileCtx->pSFileHandle,
           &dwNextChunkPos_1,
           4i64) == 4
      && pSDiskFileCtx->pSFileHandle )
    {
      pSDiskFileCtx->pSFileHandle->pFuncs->Func_Wrapper_FlushFileBuffers_1400A3490(pSDiskFileCtx->pSFileHandle);// 刷新文件写入缓冲区
    }
  }
  return 0i64;
}
Part1

image-20231112172822678

在外层循环中,依次处理当前磁盘文件中的每一个ZFS Chunk。

第123行,将文件指针设置到当前ZFS Chunk的其实位置。

第135行,读取了文件指针处开始的0x20008个字节,这个刚好是一个ZFS Chunk Header的长度。

第139行,判断读取数据的前4个字节是否为"[IX]",这个判断和"ZFS\x00"的判断一样,都是比较DWORD而非字符串。

第144行,将当前处理的ZFS Chunk中的第一个文件记录的位置为当前ZFS Chunk的基址+8。此处的pSFileItemInfo是我们自定义的结构体Stru_FileItemInfo,用于标识一个文件记录的相关信息,大小为0x20

image-20231112174241940

每个ZFS Chunk中有0x1000个这样的文件记录,这就解释了0x20008 = 4("[IX]") + 4(next zfs chunk pos) + 0x1000 * 0x20

第146行,从"[IX]"后面紧跟的4个字节中,读取了下一个ZFS Chunk的位置。

Part2

image-20231112172322002

在内层循环中,依次处理当前ZFS Chunk中的每一个文件记录。

其中do-while循环中,和Func_Crc16AndParseFileStorage_1400A7E90中的Part2一样,计算了4 * 7 = 28字节的crc16,并与当前文件记录中的第29~30字节处存储的crc16校验和进行比较。

第198行,地址后移0x20,即下一个文件记录的地址。

紧接着的第199行,通过当前文件记录的bStorageType字段是否为0,来判断当前ZFS Chunk的文件记录是否都已处理完(原理是此时文件记录对应的Stru_FileItemInfo结构体的所有字段全都为0)。

Part3

image-20231112172056896

虽然这里没有能够分析出具体的算法,但是可以看出,应该是一种将20字节的数据散列到4字节中的算法。

Part4

image-20231112180151623

这里我并没有完全分析出来,只能继续猜测是通过

Part5

格式总结

解包代码

解包结果

内存文件

逻辑定位

文件格式总结

解包代码

解包结果

010editor模板编写

Index

ZFS

资源文件的替换与MOD制作

魔改Lua引擎分析

通信协议逆向分析

通信协议的利用

通信协议的解析

Last modification:August 31st, 2024 at 02:31 pm