上次的分析中,我发现卡拉彼丘官方启动器使用自定义的 HDiffPatch 程序,拥有神秘的 -c 选项。它修改了哪些代码?到底有没有作用?会不会给解决方案带来灵感?我已经等不及了。
前情提要。
v4.5.2
卡丘至少用过两版 hpatchz,v4.5.2 和 v4.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,找到,太有发现了。
好吧我只是在装模作样地分析差异,完全不理解
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 选项时设置 v76 为 1:
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 传入不同的参数,两处的局部变量分别为 v37 和 v39。
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 的参数识别有误,v37 和 v39 在伪代码中被当作第六个参数,但它实际是第八个参数 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
还原后可以看出这些偏移分别对应 makeNewDir、copySameFile 等回调,形状上是一个函数指针表;
.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.h 和 new_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 是标准的文件重命名系统调用,我“听说”它在同卷上只更新目录项,不实际复制文件数据。
- rename, _wrename | Microsoft Learn
- MoveFileExA function (winbase.h) - Win32 apps | Microsoft Learn
- MoveFileEx(MOVEFILE_REPLACE_EXISTING) + NTFS + same volume = atomic | Microsoft Learn
听起来很有道理,但我对 Windows 一窍不通,所以也许这是错的。
结论
卡拉彼丘基于 v4.6.9 版本的 hpatchz 制作修改版,新增 -c 选项。当传入 -c,启用 CopySameFileAsMove 模式,将 defaultPatchDirlistener 中的 _copySameFile 回调替换为 hpatch_renamePath。补丁过程中,与旧版本完全相同的文件直接移动而不复制。
由于 rename 在同卷上只是元数据操作,比复制快得多,而且对于几十 GB 的 Paks/ 目录,未改动的文件可能占相当大比例,所以这个优化挺有意义的。
只可惜就算这样我的硬盘也受不了。
其他方案
上次想了三种方案,都有各自的问题,只有方案三还算值得尝试,但经此一战我对 HDiffPatch 源码产生了畏惧……
- 获取新旧版本
Paks/目录,逐文件重新制作补丁- 解析
Paks.patch,从中拆出逐文件的补丁- 修改 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
奇怪的游戏。