卡拉彼丘 hpatchz 的 -c 选项

上次的分析中,我发现卡拉彼丘官方启动器使用自定义的 HDiffPatch 程序,拥有神秘的 -c 选项。它修改了哪些代码?到底有没有作用?会不会给解决方案带来灵感?我已经等不及了。

前情提要

v4.5.2

卡丘至少用过两版 hpatchz,v4.5.2v4.6.9,其中 v4.5.2 没有 -c 选项,所以说这次的重点是后者。

但在那之前,我还想弄清楚另一件事:v4.5.2 是原版吗?

用了 GitHub Release 的二进制还是使用官方源码自行编译的?会不会偷偷插入了某些惊喜?

也许以后不要总是好奇心作祟,在奇怪的事情上花费太多时间助长拖延症,但——“那是以后的事情”。

首先,它们的校验值不同:

88a94525ed0653a82a4f653456c4c2465b6e57976ff737c49f30021902d33e9b  hpatchz_calabiyau_v4.5.2.exe
1df11062b25b5b8619012f520e782262ab62d001800abf2e1e473691300bbad6  hpatchz_github_v4.5.2.exe

但它们的编译环境非常相似:

Calabiyau
PE64
    Linker: Microsoft Linker(14.29.30147)
    Compiler: Microsoft Visual C/C++(19.29.30147)[LTCG/C]
    Tool: Microsoft Visual Studio(2019, 16.11)
    Sign tool: Windows Authenticode(2.0)[PKCS #7]
    Debug data: Records[pogo]
GitHub Release
PE64
    Linker: Microsoft Linker(14.29.30147)
    Compiler: Microsoft Visual C/C++(19.29.30147)[LTCG/C]
    Tool: Microsoft Visual Studio(2019, 16.11)
    Debug data: Records[pogo]

一般来说就算刻意使用类似的环境,在编译工具的小版本上也不会完全相同,更何况没有理由刻意模仿编译环境。一个非常渺茫的可能性是:GitHub Release 是 CI 自动编译出的,卡丘在同时期使用相同的 CI。同时期很重要,因为就算不更改工作流配置,runner 镜像版本也会改变,相关工具链也就跟着改变了。

我选择忽略这种可能性。

diec 的结果看,卡丘只是为 GitHub Release 的二进制添加了一个签名。

它们去除签名后大小相同,校验值却仍然不同:

$ osslsigncode remove-signature -in hpatchz_calabiyau_v4.5.2.exe -out hpatchz_calabiyau_v4.5.2_unsigned.exe
PE checksum   : 0007309E
Succeeded

$ wc -c hpatchz_calabiyau_v4.5.2_unsigned.exe hpatchz_github_v4.5.2.exe
418304 hpatchz_calabiyau_v4.5.2_unsigned.exe
418304 hpatchz_github_v4.5.2.exe

$ sha256sum hpatchz_calabiyau_v4.5.2_unsigned.exe
506c68942e38184953bb9cd7a50da0f7b76af2e70d54a6af7a02620ccfc79638  hpatchz_calabiyau_v4.5.2_unsigned.exe

使用 radiff2 分析发现 0x00000168 位置有 3 字节差异:

0x00000168 79d606 => 000000 0x00000168

OK,找到,太有发现了。

shimejisim_43.webp
图1  原来是这样✍🏻哦哦原来如此受教了✍🏻哦哦哦太妙了✍🏻简直是真理。 《蘑菇拟态日常》贴纸包

好吧我只是在装模作样地分析差异,完全不理解 0x00000168 含义。

不过以修改字节这么少且偏移值这么低来看,大概是某种元信息。

使用 readpe 查看 PE 文件结构信息并比较差异,可以发现那个信息是 Checksum。签名工具在签名时计算填入了 0x06d679,而原版编译时默认留 0x000000

56c56
<     Checksum:                        0x6d679
---
>     Checksum:                        0

所以两个文件除签名和 Checksum 之外完全一致,完结,从兔子洞钻出来。

v4.6.9

BinDiff

这次的编译工具版本不同,说明为了添加自定义功能他们自行编译了源码:

Calabiyau
PE32
    Linker: Microsoft Linker(14.29.30152)
    Compiler: Microsoft Visual C/C++(2017, 15.5-6, by EP)
    Tool: Microsoft Visual Studio(2019, 16.11)
    Sign tool: Windows Authenticode(2.0)[PKCS #7]
    Debug data: Records[pogo]
GitHub Release
PE32
    Linker: Microsoft Linker(14.37.32825)
    Compiler: Microsoft Visual C/C++(2017, 15.5-6, by EP)
    Tool: Microsoft Visual Studio(2022, 17.7)
    Debug data: Records[pogo]

radiff2 显示的差异比预期中更大,肯定远超实际代码更改,这是因为编译环境不同产生了噪音:

INFO: File size differs 398640 vs 377344
similarity: 0.842
distance: 122890

我尝试在 IDA 中用 BinDiff 分析,结果差不多。

如果控制所有的编译工具版本,重新编译 v4.6.9 源码再进行 BinDiff,也许情况会改善一些。但这意味着我要在特定系统的虚拟机中下载一堆特定版本的 MicroSlop 软件,包括庞大的 Visual Studio。(好恶心……)

靠比较二进制差异来逆向看来是不行了。

Reverse

幸运的是可以对照 HDiffPatch v4.6.9 源码分析反编译代码,不至于那么困难;不幸的是 HDiffPatch 的代码用了太多的宏,实际上还是很头痛。

卡丘的入口函数与原版相同,没作任何改动。main 不含主要逻辑,只负责收集命令行参数后转交 sub_409B50 (hpatch_cmd_line) 处理:

Decompiled
 1int __cdecl main(int argc, const char **argv, const char **envp)
 2{
 3  unsigned int v3; // edi
 4  CHAR *v4; // esi
 5  int v5; // eax
 6  int v7; // ebx
 7  _DWORD v8[3072]; // [esp+10h] [ebp-3000h] BYREF
 8  _UNKNOWN *retaddr; // [esp+3010h] [ebp+0h] BYREF
 9
10  v3 = 0;
11  v4 = (CHAR *)&v8[argc];
12  if ( argc )
13  {
14    while ( v4 < (CHAR *)&retaddr )
15    {
16      v5 = WideCharToMultiByte(0xFDE9u, 0, (LPCWCH)argv[v3], -1, v4, (char *)&retaddr - v4, nullptr, nullptr);
17      if ( v5 <= 0 )
18      {
19        v7 = 4 * (GetLastError() != 122) + 38;
20        *_errno() = v7;
21        return 1;
22      }
23      v8[v3++] = v4;
24      v4 += v5;
25      if ( v3 >= argc )
26        goto LABEL_5;
27    }
28    *_errno() = 38;
29    return 1;
30  }
31  else
32  {
33LABEL_5:
34    setlocale(2, Locale);
35    return sub_409B50(argc, v8);
36  }
37}
Source
321#if (_IS_NEED_MAIN)
322int hpatch_cmd_line(int argc, const char * argv[]);
323#   if (_IS_USED_WIN32_UTF8_WAPI)
324int wmain(int argc,wchar_t* argv_w[]){
325    char* argv_utf8[hpatch_kPathMaxSize*3/sizeof(char*)];
326    if (!_wFileNames_to_utf8((const wchar_t**)argv_w,argc,argv_utf8,sizeof(argv_utf8)))
327        return HPATCH_OPTIONS_ERROR;
328    SetDefaultStringLocale();
329    return hpatch_cmd_line(argc,(const char**)argv_utf8);
330}
331#   else
332int main(int argc, const char * argv[]){
333    return hpatch_cmd_line(argc,argv);
334}
335#   endif
336#endif

搜索 -c 相关字符串可以找到卡丘新增的参数处理分支,在解析到 -c 选项时设置 v761

311case 'c':
312  if ( v76 != -1 || *(_BYTE *)(v7 + 2) )
313  {
314    v54 = "options -c ERROR!\n\n";
315    goto LABEL_235;
316  }
317  v76 = 1;
318  break;

v76 有三个交叉引用,第一个是用来输出功能启用状态的,这段输出在上篇文章中已经见到过:

671v32 = "Yes";
672if ( !v76 )
673  v32 = "No";
674sub_402500("\nDirPatch CopySameFileAsMove : %s\n", v32);

另外两处用到 v76 的地方都是卡丘插入的新代码,它们有着类似的模式:根据 v76 是否被设置,向 sub_40BE40 传入不同的参数,两处的局部变量分别为 v37v39

Decompiled
713if ( !v77 )
714{
715  if ( !v90 )
716    return sub_40B3C0(v31, v68, v73, v72, HIDWORD(v72), v74, HIDWORD(v74), v85[0]);
717  v37 = &unk_45AADC;
718  if ( !v76 )
719    v37 = &unk_45AB7C;
720  return sub_40BE40(v31, v68, v73, v75, &v83, v37, v72, HIDWORD(v72), v74, HIDWORD(v74));
721}
Source
669        if (!isSamePath){ // out new file or new dir
670#if (_IS_NEED_DIR_DIFF_PATCH)
671            if (dirDiffInfo.isDirDiff){
672                return hpatch_dir(oldPath,diffFileName,outNewPath,isLoadOldAll,patchCacheSize,kMaxOpenFileNumber,
673                                  &checksumSet,&defaultPatchDirlistener,diffDataOffert,diffDataSize);
674            }else
675#endif
Decompiled
767if ( v90 )
768{
769  v39 = &unk_45AADC;
770  if ( !v76 )
771    v39 = &unk_45AB7C;
772  v40 = sub_40BE40(v93, v68, v73, v75, &v83, v39, v72, HIDWORD(v72), v74, HIDWORD(v74));
773}
Source
698#if (_IS_NEED_DIR_DIFF_PATCH)
699            if (dirDiffInfo.isDirDiff){
700                result=hpatch_dir(oldPath,diffFileName,newTempName,isLoadOldAll,patchCacheSize,
701                                  kMaxOpenFileNumber,&checksumSet,&defaultPatchDirlistener,
702                                  diffDataOffert,diffDataSize);
703            }else
704#endif

这两处分别对应 hpatch_cmd_line 中对 hpatch_dir 的两次调用:一次在输出路径与输入路径不同时直接写入目标,另一次在原地修补时先写入临时文件、成功后再重命名覆盖。两条路径都需要向 hpatch_dir 传入 hlistener

if (!isSamePath) {
    if (dirDiffInfo.isDirDiff)
        return hpatch_dir(oldPath, diffFileName, outNewPath, isLoadOldAll,
                          patchCacheSize, kMaxOpenFileNumber, &checksumSet,
                          &defaultPatchDirlistener, diffDataOffert, diffDataSize);
    ...
} else if (!isOutDir) { // isSamePath, out to temp file then rename
    if (dirDiffInfo.isDirDiff)
        result = hpatch_dir(oldPath, diffFileName, newTempName, isLoadOldAll,
                            patchCacheSize, kMaxOpenFileNumber, &checksumSet,
                            &defaultPatchDirlistener, diffDataOffert, diffDataSize);
    ...
}

hpatch_dir 函数的定义如下:

1284int hpatch_dir(const char* oldPath,const char* diffFileName,const char* outNewPath,
1285               hpatch_BOOL isLoadOldAll,size_t patchCacheSize,size_t kMaxOpenFileNumber,
1286               TDirPatchChecksumSet* checksumSet,IHPatchDirListener* hlistener,
1287               hpatch_StreamPos_t diffDataOffert,hpatch_StreamPos_t diffDataSize){

从函数签名可以看出,IDA 的参数识别有误,v37v39 在伪代码中被当作第六个参数,但它实际是第八个参数 IHPatchDirListener* hlistener。我将每个函数与源码对照手动还原了一部分符号,还原后的伪代码是这样:

767defaultPatchDirlistener = &unk_45AADC;
768if ( !v76 )
769  defaultPatchDirlistener = &unk_45AB7C;
770return hpatch_dir(
771         oldPath,
772         (int)diffFileName,
773         outNewPath,
774         isLoadOldAll,
775         patchCacheSize,
776         kMaxOpenFileNumber,
777         &checksumSet,
778         (int)defaultPatchDirlistener,
779         diffDataOffert,
780         diffDataSize);

&unk_45AADC&unk_45AB7C 两个地址均指向 .data 段中的数据。先看 -c 未设置时的默认值 unk_45AB7C

.data:0045AB7C unk_45AB7C      db    0                 ; DATA XREF: sub_409B50+944↑o
.data:0045AB7C                                         ; sub_409B50+A3A↑o
.data:0045AB7D                 db    0
.data:0045AB7E                 db    0
.data:0045AB7F                 db    0
.data:0045AB80                 dd offset sub_402740
.data:0045AB84                 dd offset sub_407380
.data:0045AB88                 dd offset sub_4027A0
.data:0045AB8C                 dd offset sub_4027D0
.data:0045AB90                 db    0
.data:0045AB91                 db    0
.data:0045AB92                 db    0
.data:0045AB93                 db    0
.data:0045AB94                 dd offset sub_402AD0
.data:0045AB98                 dd offset sub_4073E0
.data:0045AB9C                 align 10h

还原后可以看出这些偏移分别对应 makeNewDircopySameFile 等回调,形状上是一个函数指针表;

.data:0045AB7C unk_45AB7C      db    0                 ; DATA XREF: hpatch_cmd_line+944↑o
.data:0045AB7C                                         ; hpatch_cmd_line+A3A↑o
.data:0045AB7D                 db    0
.data:0045AB7E                 db    0
.data:0045AB7F                 db    0
.data:0045AB80                 dd offset _makeNewDir
.data:0045AB84                 dd offset _copySameFile
.data:0045AB88                 dd offset _openNewFile
.data:0045AB8C                 dd offset _closeNewFile
.data:0045AB90                 db    0
.data:0045AB91                 db    0
.data:0045AB92                 db    0
.data:0045AB93                 db    0
.data:0045AB94                 dd offset _dirPatchBegin
.data:0045AB98                 dd offset _dirPatchFinish
.data:0045AB9C                 align 10h

对照类型才能完全理解每个偏移的语义,它定义在 hpatch_dir_listener.hnew_dir_output.h 中:

38typedef struct IHPatchDirListener {
39    IDirPatchListener base;
40    void*       listenerImport;
41    hpatch_BOOL (*patchBegin) (struct IHPatchDirListener* listener,TDirPatcher* dirPatcher);
42    hpatch_BOOL (*patchFinish)(struct IHPatchDirListener* listener,hpatch_BOOL isPatchSuccess);
43    hpatch_FileError_t fileError;
44} IHPatchDirListener;
54typedef struct IDirPatchListener{
55    void*       listenerImport;
56    hpatch_BOOL   (*makeNewDir)(struct IDirPatchListener* listener,const char* newDir);
57    hpatch_BOOL (*copySameFile)(struct IDirPatchListener* listener,const char* oldFileName,
58                                const char* newFileName,hpatch_ICopyDataListener* copyListener);
59    hpatch_BOOL  (*openNewFile)(struct IDirPatchListener* listener,struct hpatch_TFileStreamOutput*  out_curNewFile,
60                                const char* newFileName,hpatch_StreamPos_t newFileSize);
61    hpatch_BOOL (*closeNewFile)(struct IDirPatchListener* listener,struct hpatch_TFileStreamOutput* curNewFile);
62} IDirPatchListener;

所以反编译分析得出实际的代码定义可能是这样:

static IHPatchDirListener _defaultPatchDirlistener = {
    {                  /* IDirPatchListener */
        NULL,            /* listenerImport */
        _makeNewDir,
        _copySameFile,
        _openNewFile,
        _closeNewFile,
    },
    NULL,              /* listenerImport */
    _dirPatchBegin,
    _dirPatchFinish,
    {0},               /* fileError */
};

也确实是这样,默认值在 HDiffPatch 源码中有写:

98static IHPatchDirListener defaultPatchDirlistener={{0,_makeNewDir,_copySameFile,_openNewFile,_closeNewFile},
99                                                    0,_dirPatchBegin,_dirPatchFinish,0};

设置了 v76 之后的 hlistener 参数是 unk_45AADC,与默认值相比,唯一的差异在于 copySameFile 一项,从 _copySameFile 替换为了 sub_4073C0

.data:0045AADC unk_45AADC      db    0                 ; DATA XREF: hpatch_cmd_line+959↑o
.data:0045AADC                                         ; hpatch_cmd_line+A4F↑o
.data:0045AADD                 db    0
.data:0045AADE                 db    0
.data:0045AADF                 db    0
.data:0045AAE0                 dd offset _makeNewDir
.data:0045AAE4                 dd offset sub_4073C0
.data:0045AAE8                 dd offset _openNewFile
.data:0045AAEC                 dd offset _closeNewFile
.data:0045AAF0                 db    0
.data:0045AAF1                 db    0
.data:0045AAF2                 db    0
.data:0045AAF3                 db    0
.data:0045AAF4                 dd offset _dirPatchBegin
.data:0045AAF8                 dd offset _dirPatchFinish
.data:0045AAFC                 align 10h

sub_4073C0 不执行任何复制操作,直接将文件从旧路径重命名(移动)到新路径,返回 1 表示成功。它是对原版函数 hpatch_renamePath 的简单包装:

int __cdecl sub_4073C0(int a1, int a2, int a3)
{
  sub_406370(a2, a3); // hpatch_renamePath
  return 1;
}

hpatch_renamePath 的定义如下:

150hpatch_BOOL hpatch_renamePath(const char* oldPath_utf8,const char* newPath_utf8){
151    _path_noEndDirSeparator(oldPath,oldPath_utf8); {
152        _path_noEndDirSeparator(newPath,newPath_utf8); {
153#if (_IS_USED_WIN32_UTF8_WAPI)
154            int     wsize;
155            wchar_t oldPath_w[hpatch_kPathMaxSize];
156            wchar_t newPath_w[hpatch_kPathMaxSize];
157            wsize=_utf8FileName_to_w(oldPath,oldPath_w,hpatch_kPathMaxSize);
158            if (wsize<=0) return hpatch_FALSE;
159            wsize=_utf8FileName_to_w(newPath,newPath_w,hpatch_kPathMaxSize);
160            if (wsize<=0) return hpatch_FALSE;
161            return 0==_wrename(oldPath_w,newPath_w);
162#else
163            return 0==rename(oldPath,newPath);
164#endif
165        }
166    }
167}

rename / _wrename 是标准的文件重命名系统调用,我“听说”它在同卷上只更新目录项,不实际复制文件数据。

听起来很有道理,但我对 Windows 一窍不通,所以也许这是错的。

结论

卡拉彼丘基于 v4.6.9 版本的 hpatchz 制作修改版,新增 -c 选项。当传入 -c,启用 CopySameFileAsMove 模式,将 defaultPatchDirlistener 中的 _copySameFile 回调替换为 hpatch_renamePath。补丁过程中,与旧版本完全相同的文件直接移动而不复制。

由于 rename 在同卷上只是元数据操作,比复制快得多,而且对于几十 GB 的 Paks/ 目录,未改动的文件可能占相当大比例,所以这个优化挺有意义的。

只可惜就算这样我的硬盘也受不了。

其他方案

上次想了三种方案,都有各自的问题,只有方案三还算值得尝试,但经此一战我对 HDiffPatch 源码产生了畏惧……

  1. 获取新旧版本 Paks/ 目录,逐文件重新制作补丁
  2. 解析 Paks.patch,从中拆出逐文件的补丁
  3. 修改 HDiffPatch 源码,改变目录补丁行为

回看那篇文章时,我发现还漏了一个「整包替换」方案。也就是删掉过时的 .pak 下载新的 .pak

旧版本 Paks/ 目录共占 51941 MB:

curl -s 'https://klbqcp-client-cdn.***.cn/Client/Win/GameDepot/Game_Release/1.16.1.4/manifest.txt' \
    | rg 'PM/Content/Paks/' \
    | awk -F'|' '{ size += $3 } END { print size/1024/1024, "MB" }'
# 51941 MB

新版本 Paks/ 目录共占 51939 MB:

curl -s 'https://klbqcp-client-cdn.***.cn/Client/Win/GameDepot/Game_Release/1.16.3.6/manifest.txt' \
    | rg 'PM/Content/Paks/' \
    | awk -F'|' '{ size += $3 } END { print size/1024/1024, "MB" }'
# 51939 MB

两者 Paks/ 目录下不同的文件共 17206.9 MB:

diff \
    (curl -s 'https://klbqcp-client-cdn.***.cn/Client/Win/GameDepot/Game_Release/1.16.3.6/manifest.txt' | psub) \
    (curl -s 'https://klbqcp-client-cdn.***.cn/Client/Win/GameDepot/Game_Release/1.16.1.4/manifest.txt' | psub) \
    | rg '^<' | rg 'PM/Content/Paks/' \
    | awk -F'|' '{ size += $3} END { print size/1024/1024, "MB" }'
# 17206.9 MB

不同文件中最大的为 3467.67 MB:

diff \
    (curl -s 'https://klbqcp-client-cdn.***.cn/Client/Win/GameDepot/Game_Release/1.16.3.6/manifest.txt' | psub) \
    (curl -s 'https://klbqcp-client-cdn.***.cn/Client/Win/GameDepot/Game_Release/1.16.1.4/manifest.txt' | psub) \
    | rg '^<' | rg 'PM/Content/Paks/' \
    | awk -F'|' '{ print $3 }' | sort -rn \
    | head -1 \
    | awk '{ print $1/1024/1024, "MB" }'
# 3467.67 MB

所以替换 .pak 文件的方案,需要网络传输 17.2 GB, 磁盘读写 17.2 GB,磁盘空间峰值额外占用 3.4 GB。

也……能接受吧?不清楚。(到底是有多好玩,都这样了还要玩。)

还有一个小发现,很早以前的启动器使用另一个域名下的文件服务,它的内容很旧,启动器只到 0.9.1.555 版本,游戏只到 1.9.1.33 版本。

https://down.klbq.**.com/Client/Win/GameDepot/Launcher_Release/manifest.txt
https://down.klbq.**.com/Client/Win/GameDepot/Game_Release/manifest.txt

奇怪的游戏。

Copyright © 2024 13m0n4de · CC BY-NC 4.0