WARNING您的浏览器窗口宽度不足,我们建议您使用屏幕宽度大于1600px的设备来获取更好的浏览体验。
- 报告日期:2026-09-09
- 分析对象:游戏客户端 09-08 更新(构建时间 2026-09-04)
- 样本:
TOTClient-Win64-Shipping.exe(旧版,PE 2026-07-25)/TOTClient-Win64-Shipping.exe(PE 2026-09-04) - 总体结论:本次更新在 pak 挂载路径前新增了一道文件名硬编码白名单,只放行
OPP-WindowsClient.pak这一个名字;所有第三方*_P.pak(MOD)一律被拒并记录日志。改文件名无法绕过(白名单是编译期写死的精确全等比较)。
报告分两个独立部分,可按身份选读:
- 第一部分(面向玩家):不需要技术背景,说明”为什么 MOD 没法继续用了”。
- 第二部分(技术分析):完整判断链路、硬编码来源的证据推导,供复查与二次开发。
为什么 MOD 没法用了
1. MOD 在《The Outlast Trials》里是怎么工作的
这个游戏使用 Unreal Engine 开发,游戏本体内容是一个大型数据包 Paks/OPP-WindowsClient.pak。
MOD 的常见做法是:把自制内容打包成 .pak 文件放进同一个 Paks 目录,文件名带 _P 后缀(如 Public_P.pak)。游戏启动时,引擎除了挂载官方包,还会把这些包一起挂载进去,用 MOD 里的资源覆盖或补充官方内容。
在 2026-09-08 更新之前,这个流程是正常的——旧版客户端对 Paks 目录里的 pak 文件来者不拒,MOD 作者和玩家都依赖这一点。
2. 9 月 8 日更新后发生了什么
更新后的客户端在挂载每个 pak 之前,先做了一道**“实名核对”**:
只允许挂载文件名恰好为
OPP-WindowsClient.pak的文件;其它任何名字,打印一条警告日志并跳过挂载。
你的游戏日志(OPP.log)里的记录就是这道核对在逐一点名拒绝:
LogPakFile: Warning: Refusing to mount unexpected pak file: ...Public_P.pak
LogPakFile: Warning: Refusing to mount unexpected pak file: ...CustomUI_Plus_Hawk_P.pak
LogPakFile: Warning: Refusing to mount unexpected pak file: ...Quiet Psychosis_P.pak
LogPakFile: Warning: Refusing to mount unexpected pak file: ...Anti MotionSickness+Psychosis_P.pak注意前缀是 LogPakFile —— 这是游戏引擎自己的日志,不是反作弊(EasyAntiCheat)在拦你。游戏本身正常运行,只是多了个”门卫”。
打个比方:以前进小区不看名字;现在门口保安拿着一张只写了一个名字的名单,只有叫 OPP-WindowsClient.pak 的才能进,其它一律挡在门外并记一笔。
3. 为什么”改文件名”绕不过去
这是你问过、也是很多人第一反应会想到的办法,结论是:行不通,名单不是”看规律”,是”背死名字”。保安(程序)不是检查”名字像不像官方”(比如是不是 OPP- 开头),而是拿你的完整文件名和一个程序里写死的名字做逐字对比,必须一模一样。你的 MOD 改成 OPP-Test.pak、OPP-ABC.pak 都不在名单上,照样被拒。
所以结论是:只要不修改游戏程序本身,MOD 就进不去。这与 MOD 内容好坏、版本新旧完全无关——门卫换人了,不是你的东西坏了。
4. 想继续用 MOD,目前只有一条路
把”门卫”换掉:程序里那道核对只有 4 个字节,我们已经定位并制作了补丁版客户端,打上后跳过这道核对,MOD 恢复加载。
但使用前必须清楚三条限制:
- 只建议在”免 EasyAntiCheat(EAC)“的方式下使用。EAC 会校验游戏程序本身,改动过的 exe 走在线联机(Steam →
start_protected_game.exe)可能无法启动,且绕过反作弊有账号风险。你之前用旧版能打 MOD 的启动方式(如果是直接运行 exe、离线自娱),继续用那种方式即可。 - 每次游戏更新后需要重新打补丁。官方一更新,exe 会被还原/替换,需要把补丁重放一遍。
5. 常见误区
| 想法 | 实际情况 |
|---|---|
| ”等 MOD 作者更新 MOD 版本” | 没用。MOD 本身没坏,是游戏程序换了门卫,MOD 作者也绕不过 |
| ”删掉官方 pak 就能让 MOD 顶上” | 不行。官方包是游戏全部内容,删了游戏就没东西可玩了 |
| ”是不是我被封了 / EAC 在搞我” | 不是。这是游戏引擎日志,与 EAC 无关,单机行为 |
技术分析
样本与方法
| 项 | 值 |
|---|---|
| 旧版样本 | TOTClient-Win64-Shipping.exe,PE 时间戳 2026-07-25 |
| 新版样本 | TOTClient-Win64-Shipping.exe,PE 时间戳 2026-09-04 |
| 分析环境 | Ghidra 12.0.2 与 capstone 5.0.7 自建反汇编/扫描脚本完成 |
| 映像基址 | 0x140000000 |
| 文件偏移换算(.text) | RVA = fileoff + 0xC00(fileoff 0x400 ↔ RVA 0x1000) |
核对基准(新版 exe 全文引用地址均为 RVA;补丁字节用 fileoff):
- 原版 SHA256:
c8f7a4558bd509f7bf53b54f36d9cbad3fd1a4f71c2877f03b198b747689e6b0
结论
更新在 pak 挂载入口(函数 RVA 0x261B410)新增”期望文件名核对”:用格式串常量构造唯一期望绝对路径 <Root>/Paks/OPP-WindowsClient.pak,与待挂载 pak 的完整路径做 FString 精确全等比较(0x9F7D50);不相等则打印 Refusing to mount unexpected pak file: %s(Warning 级)并拒绝挂载。期望名两段常量(OPP、WindowsClient)均为编译期字面量,全镜像不存在第二个期望模板,故任何改名都不能命中白名单。修改比较结果标志(2 处指令共 4 字节,RVA 0x261B56D/0x261B56F)可使任意 pak 绕过核对进入挂载路径。
判断链路
链路总览
日志行 "Refusing to mount unexpected pak file: %s"
│ ①
▼
RVA 0x66A833A(拒绝串,新版独有)
│ ②
▼
RVA 0x1732560(日志桩:fmt=0x66A833A, verbosity=3=Warning, call 0x31D420)
│ ③
▼
RVA 0x261C00F(位于函数 0x261B410 内"拒绝打印块")
│ ④
▼
RVA 0x261B56D test al,al / 0x261B56F jne ──(不等)──► 拒绝块 0x261BD10 → 日志桩 → bl=0 → 返回失败
│ (相等 → 放行 bl=1)
│ ⑤
▼
比较右操作数(期望路径)来自:
RVA 0x261B4EE lea rdx → 0x673A1F2 "%sPaks/%s-%s.pak"(新版独有)
RVA 0x261B4F5 lea r9 → 0x6C9FFB0 "OPP"(UTF-16 字面量)
RVA 0x261B4C1 rbx ← [obj+0x108] ← "WindowsClient"(ANSI 常量池 0x6487A90)
经 0x1A250D0 (Printf) + 0x5D0890 (路径解析) → 唯一期望名
│ ⑥
▼
⑦ 上游调用关系确认闸门地位:0x550EE2 / 0x47A7C27 / 0x47A84F3 / 0x47A96A8
(0x47A7B10 遍历 0x38 字节条目数组 = 枚举候选 pak;0x47A8480 wrapper 按返回值登记)
▼
⑧ 最小语义补丁点:bl 标志 → 4 字节等长替换(RVA 0x261B56D / 0x261B56F)步骤 ①
对新旧两个 exe 做全量字符串提取与差异比对:
| 字符串 | 旧 | 新 |
|---|---|---|
Refusing to mount unexpected pak file: %s(RVA 0x66A833A / fileoff 0x66A773A) | 无 | 有 |
%sPaks/%s-%s.pak(RVA 0x673A1F2) | 无 | 有 |
Pak chunk signing mismatch 等签名错误串 | 有 | 有(非新增) |
→ 拒绝逻辑整体是 7/25→9/4 之间源码新增,旧版不存在这道核对。
步骤 ②
全镜像 xref(字节模式 48 8D 05 xx xx xx xx + 位移反算)命中唯一引用点,位于 RVA 0x1732560(fileoff 0x1731960)。该区域是典型 UE_LOG 编译产物——冷路径日志桩:
0x1732560 sub rsp, 0x38
mov [rsp+0x28], rcx ; 参数 1:被拒 pak 路径
lea rax, [0x66A833A] ; fmt = "Refusing to mount unexpected pak file: %s"
mov [rsp+0x20], rax
lea r8, [LogCategory] ; LogPakFile
xor ecx, ecx ; 无文件名行号信息
xor edx, edx
mov r9b, 3 ; verbosity = Warning
call 0x31D420 ; 统一日志输出
retmov r9b, 3(Warning)与日志前缀 LogPakFile: Warning: 完全对应。该类桩是 UE_LOG 宏在 Shipping 构建下将”低概率分支”冷分离的结果。
步骤 ③
唯一调用点 RVA 0x261C00F(call 0x1732560),位于函数 0x261B410 内的拒绝打印块(0x261BD10–0x261C014)。桩调用前后无其它路径可达 → 该函数即本次新增核对的宿主。
步骤 ④
关键区段反汇编(地址为 RVA):
0x261B4C1 mov rbx, [rsi+0x108] ; 取 "WindowsClient" 常量数据指针(见 §4)
0x261B4D0 call 0x93CBF0 ; 取根目录字符串 → [rsp+0x70](0x715F50 取全局)
0x261B4D5 cmp [rsp+0x78], 0 ; 根目录为空?
0x261B4E2 lea r8, [0x69BAC10] ; 空 → 用空串回退常量
0x261B4EE lea rdx, [0x673A1F2] ; fmt = "%sPaks/%s-%s.pak"
0x261B4F5 lea r9, [0x6C9FFB0] ; 常量 "OPP"
0x261B504 call 0x1A250D0 ; FString::Printf → 期望串(相对形式)
0x261B514 call 0x5D0890 ; 结合全局根 → 规范化为绝对路径 → [rsp+0x80]
0x261B546 lea rcx, [rsp+0xA0]
0x261B556 call 0x8EEE0 ; 拷贝构造:实际 pak 路径 → [rsp+0xA0]
0x261B566 call 0x9F7D50 ; FString 全等比较(实际, 期望) → eax
0x261B56B mov ebx, eax ; bl = 比较结果(== 的“放行标志”)
0x261B56D test al, al ; ┐
0x261B56F jne 0x261B57E ; ┴ 相等 → 直接跳汇合点(放行, bl=1)
0x261B571 cmp [verbosity 全局], 3 ; 不相等时:
0x261B578 jae 0x261BD10 ; LogPakFile 启用 Warning 级 → 打印拒绝日志
0x261B57E ; —— 汇合点(不放行时 bl 保持 0)——
mov rcx, [rsp+0xA0] ; 释放 [rsp+0xA0] 拷贝
mov rcx, [rsp+0x80] ; 释放期望串
mov rcx, [rsp+0x60] ; 释放入参路径
0x261B5B1 test bl, bl
0x261B5B3 je 0x261BADF ; bl=0 → 失败出口(函数返回 0,拒绝)
; —— bl=1:真实挂载路径 ——
0x261B5B9 ... call [rax+0xC0] ; 执行挂载(分配 0x18/0x298 对象,
0x261B629 call 0x261C400 ; 调用构造 0x261C400,检查 [rbx+0x249]==1)拒绝打印块(冷块,落在函数末尾):
0x261BD10 cmp [rsp+0xA8], 0 ; 实际路径串长度是否为 0
0x261BD18 je 0x261C008 ; 空 → rcx = 空串常量 0x69BAC10
0x261BD1E mov rcx, [rsp+0xA0] ; 否则 rcx = 被拒 pak 路径
0x261C00F call 0x1732560 ; 日志桩(唯一调用者)
0x261C014 jmp 0x261B57E ; 汇合 → bl=0 → 拒绝返回语义结论:
- 该函数对每个候选 pak 调用一次,先构造”期望路径”再全等比较;
- 相等 → 放行进入真实挂载路径;不相等 → (按日志级别可选地)打印拒绝行 → 返回失败;
- 函数返回值(al)即”是否允许挂载”,由 bl(= 比较结果)一路携带。
步骤 ⑤
0x9F7D50 完整反汇编(out\full_9f7d50.txt):按 FString 标准布局(+0 数据指针 / +8 长度 / +0xC 容量)以 0x8EEE0(FString 拷贝构造)复制两个操作数后逐 UTF-16 字符比较,sete al 返回相等性;无子串、前缀、通配、忽略大小写等逻辑 → 比较是字典级精确全等。这与 %sPaks/%s-%s.pak 构造出的单一期望串配合,构成名符其实的”白名单”而非”命名模式”。
步骤 ⑥
- UTF-16 字面量
OPP全镜像扫描:唯一命中 RVA0x6C9FFB0(即 Printf 实参),不存在任何OPP-*之类的附加模板; - 期望模板格式串(
%sPaks/%s-%s.pak)全镜像唯一(0x673A1F2); - 亦无读配置文件 / 注册表 / 目录枚举拼白名单的迹象(期望路径仅在 §4 一处构造)。
→ 放行名单只有 <Root>/Paks/OPP-WindowsClient.pak 一个名字,且名字三段中两段是编译期常量。改名无法绕过由此获得直接证据:改成任何 OPP-X.pak 都不等于期望串;改成 OPP-WindowsClient.pak 则与官方 59 GB 主包撞名,且撞上的老签名校验。
步骤 ⑦
0x261B410 共 4 个调用点(均检查返回 al):0x550EE2、0x47A7C27、0x47A84F3、0x47A96A8。反汇编显示:
0x47A7B10区域存在遍历 0x38 字节/条目的数组循环,0x47A7C27为该循环内调用点 → 典型的”枚举目录候选 pak → 逐个过闸门”结构;0x47A8480为包装函数,按返回值决定是否把结果压入挂载列表(out\full_47a7c.txt、full_47a84.txt)。
→ 与日志现象一致:候选 pak 被逐一核对,命中的(官方包)进列表,未命中的被拒。
排除项
Pak chunk signing mismatch、Master signature table has changed since initialization!、Corrupt pak index detected 在新旧版本中均存在 → chunk 级签名检查是引擎既有机制,不是 9/8 新增。
硬编码来源
Printf 调用现场(RVA 0x261B4D0–0x261B504)参数布局:
| 参数 | 来源 | 性质 | 证据 |
|---|---|---|---|
%sPaks/%s-%s.pak | RVA 0x673A1F2 | 编译期字面量(新版独有) | 格式串模板 |
实参 1(%s,Root) | 0x93CBF0 构造的全局根目录串;为空时回退 0x69BAC10 空串常量 | 唯一运行期成分 | 0x715F50 读取全局(进程环境相关) |
实参 2(%s) | RVA 0x6C9FFB0 OPP | 编译期 UTF-16 字面量 | 全镜像唯一 OPP 命中 |
实参 3(%s) | 0x944E00 装载 ANSI 常量 WindowsClient(RVA 0x6487A90),取其数据指针(对象 +0x108) | 编译期字面量 | 见 4.2 |
源码形态
// FPakPlatformFile 挂载入口(9/8 新增核对段,语义等价还原)
// 期望串 = FString::Printf(TEXT("%sPaks/%s-%s.pak"), *RootDir, TEXT("OPP"), TEXT("WindowsClient"))
FString ExpectedPak = NormalizeToAbsolute( // 0x5D0890
FString::Printf(TEXT("%sPaks/%s-%s.pak"), // fmt = 0x673A1F2
RootDir.IsEmpty() ? TEXT("") : *RootDir, // 0x93CBF0 / 回退 0x69BAC10
TEXT("OPP"), // 0x6C9FFB0
TEXT("WindowsClient"))); // 0x6487A90 → 0x944E00
if (CandidatePakPath == ExpectedPak) // 0x9F7D50 精确全等
{
// 真实挂载(0x261B5B9+,对象分配 + 构造 + 状态检查 [..+0x249]==1)
return true;
}
if (LogPakFile_Verbosity >= Warning) // 0x261B571(冷路径)
UE_LOG(LogPakFile, Warning, TEXT("Refusing to mount unexpected pak file: %s"),
*CandidatePakPath); // fmt=0x66A833A,桩 0x1732560
return false; // 0x261BADF说明:无符号二进制无法还原到逐字源码,但”构造唯一期望串 → 全等比较 → 拒绝返回”的语义已被控制流与常量证据锁定,等价性无歧义。
修补方案
bl(来自 mov ebx, eax)在汇合点 0x261B57E 直接决定放行与否(test bl,bl; je 失败出口)。因此只需让”不相等”分支也携带 bl=1 进入汇合点:
| 位置(fileoff / RVA) | 原字节 | 原指令 | 新字节 | 新指令 |
|---|---|---|---|---|
| 0x261A96D / 0x261B56D | 84 C0 | test al, al | B3 01 | mov bl, 1(强制置位放行标志) |
| 0x261A96F / 0x261B56F | 75 0D | jne +0x0D | EB 0D | jmp +0x0D(无条件进入挂载路径) |
- 等长 4 字节替换,无重定位/位移修正负担;原
cmp [verbosity],3 / jae两条指令成为不可达死代码(语义上等价于”永不打印拒绝日志”); - 成功路径原有状态检查(
[rbx+0x249]==1、e0–ec 偏移量 or 校验)保留不动——补丁只拆除白名单,不触碰挂载自身的合法性校验(避免引入新的不稳定因素);
使用限制
- 补丁版 exe 改变了文件内容,EAC(EasyAntiCheat)在线保护下可能拒绝启动;仅在免 EAC / 离线自娱场景使用,联机前请换回原版。绕过反作弊用于在线模式违反服务条款并有封号风险。
- 官方每次更新后需重放补丁。
局限与未决项
0x261B410成功路径上另有_P.pak后缀判断(0x717070= FString::EndsWith,RVA 0x261B6DE,后缀串 0x673A256)与若干子状态分支,属于挂载后的分类/登记逻辑,与白名单拒绝无关,语义未逐一还原——不影响本文结论。- EAC 对补丁 exe 的具体行为(拒启/报错形态)未做运行时验证,需由使用者在受控环境确认。
附录:关键地址速查表(新 exe,RVA;.text 段 fileoff = RVA − 0xC00)
| 对象 | RVA(fileoff) | 说明 |
|---|---|---|
| 拒绝日志字符串 | 0x66A833A(0x66A773A) | Refusing to mount unexpected pak file: %s,新版独有 |
| 期望格式串 | 0x673A1F2 | %sPaks/%s-%s.pak,新版独有 |
常量 OPP | 0x6C9FFB0 | UTF-16,全镜像唯一 |
常量 WindowsClient | 0x6487A90 | ANSI,元数据串池 |
常量 _P.pak | 0x673A256 | 成功路径 EndsWith 用 |
| 日志桩 | 0x1732560(0x1731960) | UE_LOG Warning 冷路径,fmt 指针 LEA 于 0x1732569 |
| 宿主(闸门)函数 | 0x261B410 | 白名单核对 + 挂载 |
| Printf 期望串 | 0x261B4EE–0x261B514 | 1A250D0 + 5D0890 |
| FString 拷贝/全等 | 0x8EEE0 / 0x9F7D50 | 拷贝构造 / 精确全等比较 |
| 拒绝打印块 | 0x261BD10–0x261C014 | 空串回退 0x69BAC10 |
| 挂载成功路径 | 0x261B5B9+ | vtable call [rax+0xC0]、构造 0x261C400、[+0x249]==1 |
| 失败出口 | 0x261BADF | 返回 0 |
| 补丁点 1 | 0x261B56D(0x261A96D) | 84 C0 → B3 01 |
| 补丁点 2 | 0x261B56F(0x261A96F) | 75 0D → EB 0D |
| 上游调用者 | 0x550EE2、0x47A7C27、0x47A84F3、0x47A96A8 | 均检查返回 al;0x47A7B10 为枚举循环 |