UE4FPS透视

Joliph - 2023-10-21 - 技术分享 / 安全 / CPP / UE
2023-10-21|最后更新: 2023-10-21|
type
status
date
slug
summary
tags
category
icon
password

背景

UE4透视分析文章迁移

Tips

  1. UE不同版本的引擎之间关键结构实现差异巨大,请注意当前文章基于UE4.26.2 & windows平台
  1. 目标游戏为自己编译的FPS DEMO,UE官方的ShooterGame,如需复现请在UE4.26.2编译该DEMO

在UE4的FPS游戏中实现透视需要什么

想要实现透视主要需解决三大问题
  1. 需要拿到所有目标透视物体的对象世界坐标
  1. 需要将该世界坐标转换成屏幕坐标
  1. 需要将该屏幕坐标通过一定的方式画在屏幕上
下面来拆解以上述三个问题
  1. 如何获取目标透视物体的对象世界坐标呢
    1. UE4引擎定义了一个UWorldProxy类型的全局变量GWorld,在内存中找到他
    2. 拿到后续访问链条上各个字段的偏移从而遍历GWorld->World->PersistentLevel->Actors.Data[Index]拿到所有AActors的地址
    3. 如何读取各个actor对应的名字
  1. 需要将该世界坐标转换成屏幕坐标
    1. 拿到后续访问链条上各个字段的偏移从而访问GWorld->World->OwningGameInstance->LocalPlayers.Data[0]->PlayerController->PlayerCameraManager->CameraCachePrivate.POV拿到相机参数
    2. 通过系统API获取到游戏窗口大小,并将相机参数 & 窗口大小和1中获取到的敌人的世界坐标传入计算出屏幕坐标
    3. 上述计算的算法实现
  1. 需要将该屏幕坐标通过一定的方式画在屏幕上
    1. 这里我使用了imgui库,需要对这个库的使用方式有一定的了解,建议先写点demo试试
问题拆解的比较细致了,后续就是根据问题导向的详细分析环节

1.a如何在内存中找到GWorld结构体的位置

这里扩展一下研究的问题,UE4分析中需主要关注三个结构体的位置,GWorld为其中之一,另外两个为NamePoolData和GUObjectArray。如何找到他们的位置,用源码分析是最合适的,下载UE4对应版本的源码后搜索查找哪里有对目标对象的引用,并关注引用代码附近是否存在明显的特征,如特定字符串。也可以使用ida,编译出shipping shootergame的发布版本后用ida加载对应的文件和pdb然后查找关键数据的引用和观察附近是否存在特征,相关特征非常多,方法就是上面提到的方式
UE4的NamePoolData
注意:从4.23开始,至少作者看到的4.23以及4.26版本中GName的定义实际上已经是FNamePool,其定义在Engine\Source\Runtime\Core\Private\UObject\UnrealNames.cpp
关键源码参考
alignas(FNamePool) static uint8 NamePoolData[sizeof(FNamePool)]; static FNamePool& GetNamePool() { if (bNamePoolInitialized) { return *(FNamePool*)NamePoolData; } FNamePool* Singleton = new (NamePoolData) FNamePool; bNamePoolInitialized = true; return *Singleton; }
  1. 用开发模式编译然后用ida加载pdb看的话一堆对FNamePool各个函数的引用,然后实际去看的话都是发行版不会编译的宏定义导致的,切换到发行版编译查看更方便一些
  1. 对FNamePool的构造函数进行x查找引用并且排除掉FNamePool内部的调用后可以看到只有一个匿名函数的调用,考虑到外部只有GetNamePool调用其构造函数(但很奇怪pdb没有这个函数)
notion image
  1. 通过分析确定这个FNamePool构造时的this指针指向的就是实际NamePoolData的地址
notion image
  1. 查找对上述函数的引用发现实际上GetNamePool被内联了,模式很明显标好符号名后如下
notion image
但如果没有pdb怎么查找呢
  1. 使用刚刚定位到的NamePoolData通过引用分析查看调用,如果无pdb分析也只能从上述地址找到,发现44个引用
  1. 44个引用中FName::成员函数中的共有34个,均为FName::GetDisplayNameEntry→GetNamePool这条调用链路内敛产生的引用,均无明显特征排除之
  1. 找到了一种定位模式: 需要文件包含AOnlineBeacon::AOnlineBeacon函数
    1. 通过字符串”BeaconDriver”定位到AOnlineBeacon::AOnlineBeacon函数,函数的最后一个调用为FNameEntryId::FromValidEName
    2. FNameEntryId::FromValidEName函数中有引用到全局变量NamePoolData
  1. 另一种模式:上一个模式找不到时可用
    1. 通过字符串“BaseEyeHeight %f”找到函数APawn::DisplayDebug
    2. 向上找到第一个call即为FNameEntryId::FromValidEName
    3. FNameEntryId::FromValidEName函数中有引用到全局变量NamePoolData
UE4的GUObject
GUObjectArray在源码UE_4.26\Engine\Source\Runtime\CoreUObject\Private\UObject\UObjectHash.cpp中定义
// Global UObject array instance FUObjectArray GUObjectArray;
结合源码和IDA找到一个查找方式
  1. 其代码如下,即字符串引用向下找第一个全局变量即为GUObjectArray
// Setup GC optimizations if (bDisableDisregardForGC) { SCOPED_BOOT_TIMING("DisableDisregardForGC"); GUObjectArray.DisableDisregardForGC(); }
UE4的GWorld
  1. 在C:\Program Files\Epic Games\UE_4.26\Engine\Source\Runtime\Engine\Classes\Engine\World.h中存在关键代码
/** Global UWorld pointer. Use of this pointer should be avoided whenever possible. */ extern __declspec(dllimport) class UWorldProxy GWorld;
  1. 阐明了GWorld = Global UWorld pointer
  1. 至少在PC端的编译构建包中找GWorld不是一件很容易的事情, 不同与移动端
  1. 找到一种可用的GWorld定位点
    1. 字符串“WorldTickMisc”可以用来定位UWorld::Tick函数
    2. UWorld::Tick函数查找引用向上回溯会有两处调用函数:FUMGViewportClient::Tick & UGameEngine::Tick
    3. FUMGViewportClient::Tick大概的形式如下图所示
    4. notion image
notion image

1.b如何拿到后续访问链条上各个字段的偏移

解析pdb或者使用windbg的dt命令解析出结构体信息
dt ShooterGame_Win64_Shipping!FUObjectArray   +0x000 ObjFirstGCIndex : Int4B   +0x004 ObjLastNonGCIndex : Int4B   +0x008 MaxObjectsNotConsideredByGC : Int4B   +0x00c OpenForDisregardForGC : Bool   +0x010 ObjObjects       : FChunkedFixedUObjectArray   +0x030 ObjObjectsCritical : FWindowsCriticalSection   +0x058 ObjAvailableList : TLockFreePointerListUnordered<int,64>   +0x0e0 UObjectCreateListeners : TArray<FUObjectArray::FUObjectCreateListener *,TSizedDefaultAllocator<32> >   +0x0f0 UObjectDeleteListeners : TArray<FUObjectArray::FUObjectDeleteListener *,TSizedDefaultAllocator<32> >   +0x100 UObjectDeleteListenersCritical : FWindowsCriticalSection   +0x128 MasterSerialNumber : FThreadSafeCounter dt ShooterGame_Win64_Shipping!FChunkedFixedUObjectArray   +0x000 Objects         : Ptr64 Ptr64 FUObjectItem   +0x008 PreAllocatedObjects : Ptr64 FUObjectItem   +0x010 MaxElements     : Int4B   +0x014 NumElements     : Int4B   +0x018 MaxChunks       : Int4B   +0x01c NumChunks       : Int4B dt ShooterGame_Win64_Shipping!FUObjectItem   +0x000 Object           : Ptr64 UObjectBase   +0x008 Flags           : Int4B   +0x00c ClusterRootIndex : Int4B   +0x010 SerialNumber     : Int4B dt ShooterGame_Win64_Shipping!UObjectBase +0x000 __VFN_table : Ptr64 +0x008 ObjectFlags : EObjectFlags +0x00c InternalIndex : Int4B +0x010 ClassPrivate : Ptr64 UClass +0x018 NamePrivate : FName +0x020 OuterPrivate : Ptr64 UObject
数据太多,不一一展示,得知这里的结构后完全可以定义一个伪struct方便后续的代码编写,参考如下,完整实现在开源项目中
struct UObjectBase { int* virtualPointer; uint32_t ObjectFlags; uint32_t InternalIndex; UObjectBase* ClassPrivate; FName NamePrivate; UObjectBase* OuterPrivate; }; struct FUObjectItem { UObjectBase* Object; int32_t Flags; int32_t ClusterRootIndex; int32_t SerialNumber; }; struct FChunkedFixedUObjectArray { FUObjectItem** Objects; FUObjectItem* PreAllocatedObjects; int32_t MaxElements; int32_t NumElements; int32_t MaxChunks; int32_t NumChunks; }; struct FUObjectArray { int32_t ObjFirstGCIndex; int32_t ObjLastNonGCIndex; int32_t MaxObjectsNotConsideredByGC; bool OpenForDisregardForGC; FChunkedFixedUObjectArray ObjObjects; //... };
1.c如何读取各个actor对应的名字
actor的类型为AActor,AActor继承了UObjectBase,名字的获取与UObjectBase有关,即每个UObjectBase均可知道其名称字符串
根据UObjectBase获取到名称的代码如下
FString ObjName = Obj->GetFName().ToString(); FString ObjClassName = Obj->GetClass()->GetName(); UE_LOG(LogTemp, Display, TEXT("Object Name: %s, Class Name: %s"), *ObjName, *ObjClassName);
分析UE4官方代码的上述函数实现流程,抽样化简如下
Obj->GetClass(){ return Obj.ClassPrivate; } Obj->GetFName(){ return Obj.NamePrivate; } //那NamePrivate是如何从FName类型调用ToString变成字符串类型的呢 FString FName::ToString() { if (Obj.NamePrivate.Number == 0) { // Avoids some extra allocations in non-number case return GetDisplayNameEntry()->GetPlainNameString(); } FString Out; ToString(Out); return Out; } //在非数字模式(name+id)下FString = GetDisplayNameEntry()->GetPlainNameString();对链路上的函数合并且去除冗余部分后化简如下 const FNameEntry* FName::GetDisplayNameEntry() const { uint32 block = ComparisonIndex.Value >> 16; uint32 Offset = ComparisonIndex.Value & 0xFFFF; return *(FNameEntry*)(NamePoolData.Entries.Blocks + 8 * block + Stride * Offset); } FString FNameEntry::GetPlainNameString() const { return Header.bIsWide ? FString(Header.Len, WideName) : FString(Header.Len, AnsiName); } //获取到NamePoolData并调用
再结合IDA定位到关键函数的汇编实现处分析并注释如下
.text:0000000140F8D640 mov [rsp-18h+arg_10], rbx .text:0000000140F8D645 push rbp .text:0000000140F8D646 push rsi .text:0000000140F8D647 push rdi .text:0000000140F8D648 mov rbp, rsp .text:0000000140F8D64B sub rsp, 30h .text:0000000140F8D64F mov eax, [rcx] ; mov eax,[this] // eax == FName.ComparisonIndex.Value .text:0000000140F8D651 mov rsi, rcx .text:0000000140F8D654 mov r9d, [rcx+4] .text:0000000140F8D658 mov edi, eax ; edi = FName.ComparisonIndex.Value; .text:0000000140F8D65A shr edi, 10h ; edi = FName.ComparisonIndex.Value >> 16 = block; .text:0000000140F8D65D mov rbx, rdx .text:0000000140F8D660 movzx ecx, ax ; ecx = FName.ComparisonIndex.Value & 0xFFFF = Offset; .text:0000000140F8D663 mov [rbp+24h], ecx ; Offset; .text:0000000140F8D666 mov [rbp+20h], edi ; block; .text:0000000140F8D669 test r9d, r9d .text:0000000140F8D66C jnz short loc_140F8D6BE .text:0000000140F8D66E cmp cs:byte_14454DB24, r9b .text:0000000140F8D675 jz short loc_140F8D680 .text:0000000140F8D677 lea r8, NamePoolData .text:0000000140F8D67E jmp short loc_140F8D696 .text:0000000140F8D680 lea rcx, NamePoolData ; this .text:0000000140F8D687 call ??0FNamePool@@QEAA@XZ ; FNamePool::FNamePool(void) .text:0000000140F8D68C mov r8, rax .text:0000000140F8D68F mov cs:byte_14454DB24, 1 .text:0000000140F8D696 mov rax, [rbp+20h] .text:0000000140F8D69A mov rdx, rbx .text:0000000140F8D69D shr rax, 20h ; rax = offset; .text:0000000140F8D6A1 lea ecx, [rax+rax] ; ecx = 2 * offset .text:0000000140F8D6A4 add rcx, [r8+rdi*8+10h] ; rcx = NamePoolData.Entries.Blocks[block] + 2 * offset; .text:0000000140F8D6A9 call ?GetPlainNameString@FNameEntry@@QEBA?AVFString@@XZ ; FNameEntry::GetPlainNameString(void) .text:0000000140F8D6AE mov rax, rbx .text:0000000140F8D6B1 mov rbx, [rsp+96] .text:0000000140F8D6B6 add rsp, 30h .text:0000000140F8D6BA pop rdi .text:0000000140F8D6BB pop rsi .text:0000000140F8D6BC pop rbp .text:0000000140F8D6BD retn
上述二进制分析与源码分析中包含两个注意点
  1. MSVC提示Stride=4,但实际上用windbg看编译出的结构体对齐Stride=2,核心原因是导出的VS工程看提示的话会默认认为WITH_CASE_PRESERVING_NAME宏定义是存在的,但实际上该宏定义引擎默认是在编辑器中会开启,游戏环境中会关闭,而Stride = alignof(FNameEntry),在关闭上述宏定义后FNameEntry中将不再包含成员变量FNameEntryId ComparisonId,其大小为4字节,扩大了对齐大小
  1. 注意上述二进制代码的0x140F8D663和0x140F8D666地址处,两处向同一个栈格子写入了,最后在0x140F8D696中被同时读出,排布为:rbp + 20h[block|offset],在0x140F8D696处读出后才会右移32位获取到真正的offset(注意端序问题)
  1. 比如block=1,offset=0x45按照小端序存储后为[01 00 00 00 | 45 00 00 00],读取到rax = 0x4500000001,所以再右移32位把block去掉即为offset
Class FNamePool{ ... private: FNameEntryAllocator Entries; ... }; class FNameEntryAllocator{ ... private: mutable FRWLock Lock; uint32 CurrentBlock = 0; uint32 CurrentByteCursor = 0; uint8* Blocks[FNameMaxBlocks] = {}; };
FNamePool的第一个对象为Entries,而Entries的FRWLock Lock占8字节,两个uint32合起来占8字节,所以Blocks到FNamePool的偏移为0x10

参考

二进制明文字符串加密:实现原理LLVM PASS虚函数保护