源码仓库: https://github.com/awe0w0/Palworld-UE5-Mechanics-Lab

幻兽帕鲁外挂程序

最近整理了一套 PalworldAttackLab v4 的架构资料。它表面上是一个单机机制测试器,底层实际跨过了 .NET 桌面端、Windows 进程内存、UE5 反射系统、原生桥 DLL、命名管道和游戏线程执行几个层级。

这篇不准备逐页介绍 UI,也不把它写成一份“偏移合集”。我更想记录的是:面对一个固定 Shipping 构建,怎样先证明对象身份,再选择最小的修改路径,并且让每次修改都能回读、恢复和解释。

先划清边界。本文对应 Steam build 24575825,引擎标识为 5.1.1-0+++UE5+Release-5.1-Pal,只讨论指定 EXE 哈希下的本地单机进程。程序会主动拒绝多人和 PvP 环境,也没有网络访问、反作弊绕过、驱动、常驻服务或资源文件修改。游戏更新后,文中的 RVA、对象索引和字段布局全部应视为失效。

先说结论:Palworld 没有抛弃 UE5 的骨架

分析过程中最重要的发现,不是某个字段的偏移,而是 Palworld 的 Shipping 包仍然保留了相当完整的 UE5 元对象关系:

FUObjectArray / GObjects
  -> UObject
     -> UClass / UStruct
        -> UFunction
           -> UObject::ProcessEvent(Object, Function, Params)

GWorld
  -> UGameInstance
     -> ULocalPlayer
        -> APlayerController
           -> Character / Pawn

Palworld 的业务对象是在这套骨架上继续生长的。UPalOptionSubsystemUPalCharacterMovementComponentUPalIndividualCharacterParameterUPalTechnologyDataUPalDebugSettingUPalCheatManager 等类型,都仍然遵循 UObject、UClass、Outer、CDO 和 UFunction 的基本关系。

这让程序可以把“一个看起来像地址的数字”逐级证明成“当前世界里、属于预期类、仍然有效的那个对象”,而不是拿着一次 dump 的地址盲写。

架构被刻意拆成两条路径

整个程序没有把所有功能都塞进同一种技术里。它根据修改是否依赖 UE 线程语义,分成两条路径:

WinForms / CLI
      |
      v
AttackSession --------------------------+
      |                                 |
      v                                 v
只读反射扫描器                   BridgeClient / Pipe
      |                                 |
      | 单个已验证 float                 | 固定协议请求
      v                                 v
短期外部写句柄                 PalworldMechanicsBridge.dll
                                        |
                                        v
                              游戏线程 ProcessEvent
                                        |
                                        v
                              Pal / UE 业务对象

攻击倍率是一个例外。它位于活动 Option 子系统中的单个 float,在路径、哈希、UClass、Outer 世界链、单机标志和字段合理性全部通过后,外部程序才短暂打开 VmWrite | VmOperation,写入四字节,立即关闭写句柄,再用只读句柄逐位回读。

其余机制会触发 Setter、Getter、RPC 包装、OnRep、容器逻辑或 World Context,默认都依赖游戏线程。这部分不从外部直接调用裸函数地址,而是交给临时加载的原生桥,在游戏线程通过 UE 反射执行。

这种分法看起来麻烦,但职责很干净:能被严格证明为单字段事务的,就保持外部写入最小化;需要引擎语义的,就回到引擎自己的调用路径。

GObjects 不是地址簿,而是一套身份校验链

当前构建的 GObjects 是分块 TUObjectArray。每块最多 0x10000FUObjectItem,每项大小 0x18。索引解析本身并不复杂:

chunkIndex = objectIndex / 0x10000
inChunk    = objectIndex % 0x10000
item       = Objects[chunkIndex] + inChunk * 0x18
uobject    = item->Object

真正关键的是解析之后的验证。一个活动 UObject 至少要通过这些检查:

  1. 地址和虚表位于合理的用户态与目标模块范围。
  2. UObject::Index 没有越过 GObjects.NumElements
  3. GObjects[Index].Object 能反向解析到同一个地址。
  4. UObject::Class 等于预期 UClass,或沿 SuperStruct 继承于预期类。
  5. CDO 同时满足 UClass::ClassDefaultObject、Class 回指和 RF_ClassDefaultObject 标志。
  6. UFunction 的 Outer 精确等于拥有它的 UClass,原生入口仍落在主模块内。
  7. 活动业务对象的 Outer 链能够回到当前 GWorld
bool IsExpectedObject(void* object, void* expectedClass)
{
    return IsReadable(object)
        && IsModuleVTable(*(void**)object)
        && GObjects[object->Index].Object == object
        && IsA(object->Class, expectedClass)
        && OuterChainContainsCurrentWorld(object);
}

上面是概念化后的代码,实际实现还会检查结构大小、字段范围、CDO 标志和进程会话身份。这里的设计倾向很明确:索引只是候选入口,结构关系才是证据。

ASLR 之后,稳定的是相对关系

程序里保存的是“主模块基址 + 固定构建 RVA”,而不是某次运行时看到的绝对地址:

absolute = MainModule.BaseAddress + RVA

GObjectsGWorldProcessEvent 都按这个方式定位。进程每次启动后,模块基址会随 ASLR 改变;dump 里那些 0x000002... 形式的地址只属于生成 dump 的那次快照。

同理,活动 BP_PalOptionSubsystem_C 也不会永久绑定某个实例地址。程序先尝试固定构建的对象索引种子,失败后再按块扫描 GObjects,寻找精确 UClass 的非 CDO 实例,并要求它的 Outer 链包含当前 World、设置字段处于合理范围、多人/PvP 标志可读。

这不是让旧偏移“自动兼容新版本”,而是在同一固定构建里抵抗对象装载顺序和实例地址变化。版本边界依然不能省略。

为什么反射调用必须回到游戏线程

一次 UE 反射调用可以抽象成三个输入:

ProcessEvent(TargetObject, UFunctionObject, ParametersBuffer);

成员函数的 Target 是真实实例,静态 Blueprint 函数通常使用所属类的 CDO;UFunction 从已验证的 GObjects 项取得;参数缓冲必须与 dump 中的字段、返回值和 padding 精确一致。

例如移动倍率 Setter 的参数只有一个 FName 和一个 float:

struct SetMultiplierParams
{
    uint64_t FlagName; // ComparisonIndex + Number
    float Value;
};

但“参数拼对了”不等于“可以在任意线程调用”。UObject 操作、RPC、OnRep、World Context 和部分容器更新都默认依赖游戏线程。桥因此没有让管道工作线程直接执行命令,而是建立了一个小型状态机:

idle -> pending -> processing -> complete -> idle

工作线程只负责接收请求并设为 pending。临时 ProcessEvent 钩子在原始反射分发正常返回后,确认当前调用发生在目标游戏线程,再消费请求。桥自己的反射调用通过 thread-local 标记避免递归消费下一条请求,执行结果写入响应后再唤醒管道线程。

这条路径的本质不是“Hook 方便调用函数”,而是借用游戏本来持续发生的反射分发,把外部请求安全地搭到正确线程上。

桥协议尽量无聊,反而更可靠

外部 C# 与原生 C++ 之间使用固定大小的命名管道协议 v3:

  • Magic 为小端 PLMB
  • Request 固定 44 字节;
  • Response 固定 200 字节;
  • C# 使用显式布局,C++ 使用 Pack=1 和静态断言;
  • 每个包都检查 Magic、Version 和 Size;
  • 外部扫描得到的 World、Controller、Movement 指针必须与桥返回结果一致。

协议没有引入复杂序列化库,代价是结构升级必须同步修改两端;好处是 ABI 是否一致可以在程序启动时直接证明。对于一个只服务固定桥版本的小协议,“无聊”比灵活更重要。

桥的完整生命周期也被纳入会话管理:验证 DLL 路径、加载模块、连接按 PID 命名的管道、Ping、执行、恢复、卸钩,最后才允许卸载 DLL。任何一环失败,UI 都不会把会话标成已安全连接。

Shipping 里有 UFunction,不代表功能还活着

分析 UPalCheatManager 时遇到一个很有代表性的坑:反射表中能找到函数名,也能确认 Owner 和 UFunction,但 Shipping 原生实现可能已经被裁成桩。

CaptureSuccessAlways() 的实现是立即返回,IsCaptureSuccessAlways() 则是恒真返回。二者都保留了反射外壳,却不能组成一个真实可控的“捕获必中”状态。

因此程序没有因为函数名看起来正确就调用它,而是继续追踪捕获结果的真实分发路径。当前实现只在 UFunction 指针精确等于 APalCaptureJudgeObject::CaptureResult_ToALL 时,在原始 ProcessEvent 分发前修改对应参数结构。

if (captureOverrideEnabled && function == CaptureResultToAllFunction)
{
    params->Result.IsSuccess = true;
    params->Result.TestSuccessCount = 3;
    params->Result.FailedCaptureType = 0;
}

这里的重点仍然是证据边界:函数指针和参数布局已经验证,开关与拦截结构可以工作;验证期间没有真正投球,所以不能把“实际捕获事件命中”写成已完成的端到端结论。

可逆不是一个按钮,而是一套事务模型

这套程序最花心思的不是“改进去”,而是“怎么回来”。不同状态的恢复方式并不相同:

  • 攻击倍率保存连接时原 float,关闭短期写句柄后再回读。
  • 飞行保存连接时的 MovementMode,恢复时调用对应 Start/End UFunction。
  • 移动倍率使用桥自己的 FName 键,恢复为中性值 1.0,不触碰其他系统的倍率键。
  • 摔落伤害、属性上限和全局时间倍率在首次修改前捕获原值。
  • 属性点和科技点属于可能进入存档的进度数据,由外部会话保存原值,默认在退出前写回并触发 OnRep。
  • 捕获覆盖在卸钩后自然失效,同时仍保存桥内部开关的初始状态。

“临时机制”和“进度数据”必须区分。前者往往随 DLL 卸载或反射恢复消失,后者可能在游戏保存时持久化。如果进程在恢复前异常退出,不能假装事务一定完成。

Shutdown 的优先级因此是先尽力恢复,再保证能够到达卸钩路径。即使世界正在切换、玩家对象暂时不可达,也要先保护 ProcessEvent 原始代码不被遗留补丁占住。直接卸钩能恢复代码完整性,但不能替代业务字段的成功回写,这两种“恢复”需要分别报告。

验证结果也分等级

这次文档把结论分成三种状态:运行时已验证、反射/静态已验证、未验证。没有触发条件的功能不会因为代码写完就被标成成功。

2026-08-22 的固定构建验证中:

  • C# 与原生构建通过,0 警告、0 错误;
  • Request 44、Response 200 的协议 ABI 通过;
  • 只读扫描取得 578184 个 GObjects,并验证 40 个关键 UFunction 的 Owner/UClass;
  • 单机守卫确认 bIsMultiplay=falsebIsPvP=false
  • 跳跃/移动、飞行、摔落伤害、属性点、属性上限、科技点、全局时间倍率均完成“修改 -> 独立回读 -> 恢复 -> 再回读”;
  • 每轮结束后桥 DLL 均已卸载,游戏进程保持响应;
  • 选中帕鲁复活缺少有效槽位,真实行为未验证;
  • 捕获覆盖计数仍为 0,不能声称真实投球事件已经命中。

这种写法可能不如“全部功能完成”好看,但技术文档最重要的不是气势,而是读者能分辨:什么已经在真实进程闭环,什么只验证了结构,什么还没有证据。

版本升级时,旧配置应该直接判死

固定构建工具最危险的错觉,是把“同一版本内能动态找实例”误解为“游戏更新后还能自动适配”。正确的升级流程应该从新证据开始:

  1. 重新记录 EXE 哈希和 Steam build。
  2. 重新生成 GObjects、属性 dump、CppSDK、usmap 和反汇编映射。
  3. 重新定位 GObjects、GWorld、ProcessEvent,并复核 UObject/UClass/UFunction 基础布局。
  4. 重找 Pal 类、CDO、UFunction 索引与参数尺寸。
  5. 重新检查活动世界链、业务字段和 ProcessEvent 入口。
  6. 先只读 probe,再做最小幅度的可逆写入闭环。
  7. 最后检查原始代码字节、桥模块卸载状态和游戏响应。

任何一步不一致都应 fail closed。猜一个“附近看起来差不多”的偏移,也许能让 UI 显示成功,却会把整套身份验证设计变得毫无意义。

暂时的收尾

从结果看,PalworldAttackLab 不是一个把 WriteProcessMemory 包上 WinForms 的简单工具。它更像一个小型 UE5 运行时实验室:外部扫描器负责证明对象和版本,桥负责把必要操作送回游戏线程,协议负责固定边界,会话层负责恢复和交叉验证。

其中最值得留下的经验并不是某个具体 RVA,而是几个原则:地址要有身份链,反射要尊重线程语义,Shipping 函数要检查真实实现,修改要有独立回读,恢复要按数据类型设计,未触发的行为永远不能算验证成功。

这些原则以后换到别的 UE5 固定构建里仍然有用。真正需要重做的,是每一次版本更新后的全部证据。