本文是逆向学习的记录笔记
一、从源码分析内存结构
Unreal的对象继承结构是UObject继承UObjectBaseUtility继承UObjectBase。
//UObjectBaseUtility.h
FORCEINLINE FString GetName() const
{
return GetFName().ToString();
}
//UObjectBase.h
FORCEINLINE FName GetFName() const
{
return NamePrivate;
}
//对象布局:
EObjectFlags ObjectFlags;//4Bytes
int32 InternalIndex;//4Bytes
ObjectPtr_Private::TNonAccessTrackedObjectPtr<UClass> ClassPrivate;//指针8Bytes
FName NamePrivate;
可以看到上述UObjectBase中,FName
在16字节偏移处,但UObjectBase有虚函数,对象最前面还会有8字节的虚函数表指针,所以是24字节偏移,写成16进制就是0x18
。
FName
转换FString
(删了一些检查代码):
FString FName::ToString() const
{
return GetDisplayNameEntry()->GetPlainNameString();
}
const FNameEntry* FName::GetDisplayNameEntry() const
{
return ResolveEntryRecursive(GetDisplayIndexInternal());
}
FORCEINLINE FNameEntryId FName::GetDisplayIndexInternal() const
{
#if WITH_CASE_PRESERVING_NAME
return DisplayIndex;
#else // WITH_CASE_PRESERVING_NAME
return ComparisonIndex;
#endif // WITH_CASE_PRESERVING_NAME
}
const FNameEntry* FName::ResolveEntry(FNameEntryId LookupId)
{
return &GetNamePool().Resolve(LookupId);
}
const FNameEntry* FName::ResolveEntryRecursive(FNameEntryId LookupId)
{
const FNameEntry* Entry = ResolveEntry(LookupId);
#if UE_FNAME_OUTLINE_NUMBER
if (Entry->Header.Len == 0)
{
return ResolveEntry(Entry->GetNumberedName().Id); // Should only ever recurse one level
}
else
#endif
{
return Entry;
}
}
class FName
{
//...
private:
FNameEntryId ComparisonIndex;
#if !UE_FNAME_OUTLINE_NUMBER
uint32 Number;
#endif// ! //UE_FNAME_OUTLINE_NUMBER
#if WITH_CASE_PRESERVING_NAME
FNameEntryId DisplayIndex;
#endif // WITH_CASE_PRESERVING_NAME
可以发现,上述代码中,FName本质是存储一个ID,到全局的NamePool(GName)中取得FNameEntry再转换成FString。
class FNamePool
{
public:
FNameEntry& Resolve(FNameEntryHandle Handle) const { return Entries.Resolve(Handle); }
private:
FNameEntryAllocator Entries;
}
class FNameEntryAllocator
{
public:
FNameEntry& Resolve(FNameEntryHandle Handle) const
{
return *reinterpret_cast<FNameEntry*>(Blocks[Handle.Block] + Stride * Handle.Offset);
}
static constexpr uint32 FNameBlockOffsetBits = 16;
static constexpr uint32 FNameBlockOffsets = 1 << FNameBlockOffsetBits;
struct FNameEntryHandle
{
uint32 Block = 0;
uint32 Offset = 0;
FNameEntryHandle(FNameEntryId Id)
: Block(Id.ToUnstableInt() >> FNameBlockOffsetBits)
, Offset(Id.ToUnstableInt() & (FNameBlockOffsets - 1))
{
}
}
struct FNameEntryId
{
private:
uint32 Value;
}
上述分析发现,FNameEntryId本质是一个uint32,上述resolve时,高16Byte视为Block,低16Byte视为Offset,到FNamePool中的FNameEntryAllocator中的Blocks取FNameEntry。
有一点坑要注意,Resolve时用到的Stride
,定义如下:
enum { Stride = alignof(FNameEntry) };
在IDE中看可能是4,但是看定义如下:
struct FNameEntry
{
private:
#if WITH_CASE_PRESERVING_NAME
FNameEntryId ComparisonId;
#endif
FNameEntryHeader Header;
// Unaligned to reduce alignment waste for non-numbered entries
struct FNumberedData
{
#if UE_FNAME_OUTLINE_NUMBER
#if WITH_CASE_PRESERVING_NAME // ComparisonId is 4B-aligned, 4B-align Id/Number by 2B pad after 2B Header
uint8 Pad[sizeof(Header) % alignof(decltype(ComparisonId))];
#endif
uint8 Id[sizeof(FNameEntryId)];
uint8 Number[sizeof(uint32)];
#endif // UE_FNAME_OUTLINE_NUMBER
};
union
{
ANSICHAR AnsiName[NAME_SIZE];
WIDECHAR WideName[NAME_SIZE];
FNumberedData NumberedName;
};
其中WITH_CASE_PRESERVING_NAME
宏只在编辑器生效,因此实际的Stride
值应该是2。
二、逆向
1. 找到FNamePool的全局静态变量地址
1.1 IDA静态查找方法
逆向过程中,先用ida找到FNamePool的构造函数调用,找到FNamePool的全局静态变量地址:
static bool bNamePoolInitialized;
alignas(FNamePool) static uint8 NamePoolData[sizeof(FNamePool)];
static FNamePool& GetNamePool()
{
if (bNamePoolInitialized)
{
return *(FNamePool*)NamePoolData;
}
FNamePool* Singleton = nullptr;
UE_AUTORTFM_OPEN
{
Singleton = new (NamePoolData) FNamePool;
bNamePoolInitialized = true;
LLM(FLowLevelMemTracker::Get().FinishInitialise());
};
return *Singleton;
}
如何找FNamePool构造函数地址,可以用Shift+F12或Names窗口,搜索ByteProperty
,这个是构造函数中默认添加的硬编码Name:
FNamePool::FNamePool()
{
// Register all hardcoded names
#define REGISTER_NAME(num, name) ENameToEntry[num] = Store(FNameStringView(#name, FCStringAnsi::Strlen(#name)));
#include "UObject/UnrealNames.inl"
#undef REGISTER_NAME
// UnrealNames.inl
// Special zero value, meaning no name.
REGISTER_NAME(0,None)
// Class property types (name indices are significant for serialization).
REGISTER_NAME(1,ByteProperty)
REGISTER_NAME(2,IntProperty)
REGISTER_NAME(3,BoolProperty)
REGISTER_NAME(4,FloatProperty)
//...
找到后选择ByteProperty的全局静态变量,按X,用xrefs找到调用这个变量的地方,既是FNamePool的构造函数,可以用F5将汇编转为C代码。
对这个构造函数按X,用xrefs查找构造函数调用处(或者View>Open subviews>Functions),可能结果有多个,可以随意进入一个,能发现类似这样的代码:
if ( byte_144A3AA64 )
{
v8 = &stru_144A56400;
}
else
{
v8 = (RTL_SRWLOCK *)sub_141055BD0((__int64)&stru_144A56400);
byte_144A3AA64 = 1;
}
对比UE代码能猜测到,这是一个只初始化一次的写法,在上述例子中,byte_144A3AA64
是bNamePoolInitialized
,用来判断是否已初始化;sub_141055BD0
是构造函数;stru_144A56400
就是全局静态内存的位置,既alignas(FNamePool) static uint8 NamePoolData[sizeof(FNamePool)];
。
还需要计算这个地址相对于程序集的偏移,用IDA的Edit>Segments>Rebase program可以获得基址:
计算
0x144A56400-0x140000000=0x4A56400
。可以用CE验证一下,因为我换了个游戏版本,假设上述获取的偏移地址为
0x431BAC0
,添加地址"exe名称"+0x431BAC0
,这个地址是上面刚算的偏移,获取的是FNamePool,也就是GName的地址,将其数值加10(16进制),写成"exe名称"+0x431BAD0
,即可偏移到Blocks的内容,因为FNamePool首个成员就是FNameEntryAllocator对象Entries。FNameEntryAllocator需要偏移16字节(0x10)获得Blocks的地址:
class FNameEntryAllocator
{
mutable FRWLock Lock;//8
uint32 CurrentBlock = 0;//4
uint32 CurrentByteCursor = 0;//4
uint8* Blocks[FNameMaxBlocks] = {};
获取地址注意要用8字节,默认4字节获得的地址是错误的:
用内存视图可以右键Display Type改成8byte,然后找到加0x10后的内存,右键值,点击Follow。
即可看到熟悉的内容,这个Blocks中,第一个Block的字符串池:
None前面的两字节搞不懂的话,可以看
FNameEntry
的定义,用位域的语法,用16bit存了bIsWide
、LowercaseProbeHash
、Len
三个变量。Block里存的不是字符串而是FNameEntry
,这个不要搞混了。
1.2 CheatEngine动态查找方法:
上一节是用CE验证方法的正确性,这一节是直接用CE找NamePool的地址
CE附加游戏,全局搜索ByteProperty
,类型字符串,可能搜到好几十个。对每个右键>Browse this memory region(浏览相关内存区域),观察MemoryView中,内存是否像上文中静态查找的内存区域,比如前面是否有None
字符串,后面是否有IntProperty
等。
找到后,根据上一节的经验,找到*.None(要从None往前两个字节)右键goto address获取当前地址,得到地址后,全局搜索8Bytes的这个地址
261F9F30000
,于是得到"exe名称"+431BAD0
,这个结果和上一节相同,431BAD0
是Blocks的地址,将其减0x10就是NamePool的地址。
2. 查找GWorld的方法
引擎中代码如下:
UWorld* UEngine::GetWorldFromContextObject(const UObject* Object, EGetWorldErrorMode ErrorMode) const
{
if (Object == nullptr)
{
switch (ErrorMode)
{
case EGetWorldErrorMode::Assert:
check(Object);
break;
case EGetWorldErrorMode::LogAndReturnNull:
FFrame::KismetExecutionMessage(TEXT("A null object was passed as a world context object to UEngine::GetWorldFromContextObject()."), ELogVerbosity::Warning);
//UE_LOG(LogEngine, Warning, TEXT("UEngine::GetWorldFromContextObject() passed a nullptr"));
break;
case EGetWorldErrorMode::ReturnNull:
break;
}
return nullptr;
}
bool bSupported = true;
UWorld* World = (ErrorMode == EGetWorldErrorMode::Assert) ? Object->GetWorldChecked(/*out*/ bSupported) : Object->GetWorld();
if (bSupported && (World == nullptr) && (ErrorMode == EGetWorldErrorMode::LogAndReturnNull))
{
FFrame::KismetExecutionMessage(*FString::Printf(TEXT("No world was found for object (%s) passed in to UEngine::GetWorldFromContextObject()."), *GetPathNameSafe(Object)), ELogVerbosity::Warning);
}
return (bSupported ? World : GWorld);
}
在Names视图中搜索ANullObjectWas
可定位到变量,或者在Strings视图搜索,不过要注意,A null object was……
字符串被TEXT宏包围,意味着是宽字符,需要在Strings中任意右键Setup,在string types中勾选Unicode C-style (16-bits)
。
用xrefs定位引用到:
__int64 __fastcall sub_1427F1530(__int64 a1, __int64 a2, int a3)
{
__int64 v4; // rsi
__int64 v6; // rax
__int64 v7; // rdi
const wchar_t *v8; // rbx
const wchar_t *v9; // r8
__int64 v10; // rdx
const wchar_t *v11; // [rsp+20h] [rbp-28h] BYREF
int v12; // [rsp+28h] [rbp-20h]
const wchar_t *v13; // [rsp+30h] [rbp-18h] BYREF
int v14; // [rsp+38h] [rbp-10h]
__int64 v15; // [rsp+58h] [rbp+10h] BYREF
__int64 v16; // [rsp+68h] [rbp+20h]
v4 = a2;
if ( a2 )
{
LOBYTE(v15) = 1;
if ( a3 == 2 )
v6 = sub_140F63220(a2, &v15);
else
v6 = (*(__int64 (__fastcall **)(__int64))(*(_QWORD *)a2 + 344i64))(a2);
v7 = v6;
if ( !(_BYTE)v15 )
return qword_14446D5E0;
if ( !v6 && a3 == 1 )
{
v16 = 0i64;
sub_140FBBA70(v4, &v13, 0i64);
v8 = &String;
v9 = &String;
if ( v14 != (_DWORD)v7 )
v9 = v13;
sub_140C9D710(&v11, L"No world was found for object (%s) passed in to UEngine::GetWorldFromContextObject().", v9);
LOBYTE(v10) = 3;
if ( v12 != (_DWORD)v7 )
v8 = v11;
sub_140FA1A50(v8, v10, v7);
if ( v11 )
sub_140CDC2B0();
if ( v13 )
sub_140CDC2B0();
}
if ( !(_BYTE)v15 )
return qword_14446D5E0;
return v7;
}
else
{
if ( a3 == 1 )
{
v15 = 0i64;
LOBYTE(a2) = 3;
sub_140FA1A50(
L"A null object was passed as a world context object to UEngine::GetWorldFromContextObject().",
a2,
0i64);
}
return 0i64;
}
}
对比代码,上述的qword_14446D5E0
就是GWorld
,用GName
同样方法减去基址就能得到偏移hex(0x14446D5E0-0x140000000)=0x446d5e0
。
2.1 DumpSDK
利用工具将引擎中的数据结构dump出来帮助分析,可以用Dumper-7,编译dll,注入UE游戏中,然后就能在C:\Dumper-7\<游戏目录>\CppSDK
找到dump出的文件了。
例如能看到ULevel* PersistentLevel
在UWorld
中的偏移在0x30处:
// Class Engine.World
// 0x0760 (0x0788 - 0x0028)
class UWorld final : public UObject
{
public:
uint8 Pad_28[0x8]; // 0x0028(0x0008)
class ULevel* PersistentLevel; // 0x0030(0x0008)
AActor的数组在0x98
的位置:
// Class Engine.Level
// 0x0270 (0x0298 - 0x0028)
class ULevel final : public UObject
{
public:
uint8 Pad_28[0x70]; // 0x0028(0x0070)
class TArray<class AActor*> Actors; // 0x0098(0x0010)
TArray
的结构是指针、当前大小、总体容量:
template<typename ArrayElementType>
class TArray
{
protected:
ArrayElementType* Data;
int32 NumElements;
int32 MaxElements;
在下一节中,我们利用CE来验证这个结果。
2.2 CE分析
CE附加到游戏中,添加地址"游戏.exe"+0x446d5e0
,值是UWorld*
,所以设定值为8字节,展示为16进制。
将指针指向的值复制出来,到MemoryView中,使用Tools>Dissect data/structures分析结构和数据,Group中拷贝刚刚的值,Sturctures>Define new structure填入4096。
注:有可能无法自动分析出结构,需要自己在指定偏移处右键ChangeType。
上图可以看到,在UWorld偏移0x30处得到ULevel,再通过0x98得到TArray<AActor>,可以看到在这个开始页面,AActor有49个,容量最高是88。
根据SDK,AActor的
0x130
的偏移是USceneComponent*
,可获取到偏移数据FTransform,但实际上Dumper未分析出来这个数据的偏移是多少。我们可以先定位到AActor的首地址,按空格在Memory Browse中跳转位置,然后在Memory Browse中将显示值的类型改为Float(注:新的UE版本可能是Double类型的),查看哪里数据比较像FTransform:
struct FTransform
{
public:
struct FQuat Rotation; // 0x0000(0x0010)
struct FVector Translation; // 0x0010(0x000C)
uint8 Pad_1C[0x4]; // 0x001C(0x0004)
struct FVector Scale3D; // 0x0020(0x000C)
uint8 Pad_2C[0x4]; // 0x002C(0x0004)
};
可以看到前四个字节是四元数,16字节偏移处是世界坐标,根据UnrealEngine的尺度,单位是1cm,因此很容易出现成千上万的高数值,32字节偏移处是缩放,一般都是1,1,1,根据这个特征,在我的游戏中能看到FTransform偏移是在0x1C0
处。
SDK中我可以手动修复结构:
//uint8 Pad_152[0xA6]; // 0x0152(0x00A6)(Fixing Struct Size After Last Property [ Dumper-7 ])
uint8 Pad_152[0x6E]; // 0x0152(0x006E)
FTransform ComponentToWorld; // 0x01C0(0x0030)
uint8 Pad_1F0[0x8]; // 0x01F0(0x0008)
三、程序获取
3.1 用上述获取的信息编写方法:
#include "engine.h"
#include "SDK/Engine_classes.hpp"//Dumper-7导出的文件
#include <Windows.h>
namespace Engine
{
uint8_t* GameBase = (uint8_t*)GetModuleHandleA(0);
uint8_t** FNamePool = (uint8_t**)(GameBase + 0x431BAC0);
SDK::UWorld** GWorld = (SDK::UWorld**)(GameBase + 0x446d5e0);
struct FNameEntry {
uint16_t bIsWide : 1;
uint16_t LowercaseProbeHash : 5;
uint16_t Len : 10;
union {
char AnsiName[1024];
wchar_t WideName[1024];
};
};
};
std::string GetName(uint32_t Id) {
uint32_t Block = Id >> 16;
uint32_t Offset = Id & 65535;
// "2 + "是因为GName是uint8_t*的数组,一个偏移8Byte,两个偏移0x10
FNameEntry* Info = (FNameEntry*)(FNamePool[2 + Block] + 2 * Offset);
return std::string(Info->AnsiName, Info->Len);
}
SDK::UWorld* GetWorld( ) {
return *GWorld;
}
}
3.2 用IMGUI显示
推荐找一个IMGUI的通用hook框架,我用的是UniversalHookX。
menu.cpp
的Menu::Render
方法主要用来编写显示逻辑。
获取UWorld:
void Render( ) {
if (!bShowMenu)
return;
if (ImGui::Begin("逆向工具 - AActor 查看器", &bShowMenu)) {
// 显示基本信息
SDK::UWorld* World = Engine::GetWorld( );
if (World == nullptr || World->PersistentLevel == nullptr) {
ImGui::Text("无法获取 World 或 PersistentLevel");
ImGui::End( );
return;
}
添加一些选项:
SDK::TArray<SDK::AActor*>& Actors = World->PersistentLevel->Actors;
ImGui::Text("当前场景中的 Actor 数量: %d", Actors.Num( ));
// 添加一些控制选项
static bool bShowOnlyValidActors = true;
ImGui::Checkbox("仅显示有效 Actor", &bShowOnlyValidActors);
static bool bShowRelativeTransforms = true;
ImGui::Checkbox("显示相对变换", &bShowRelativeTransforms);
static bool bShowWorldTransforms = true;
ImGui::Checkbox("显示世界变换", &bShowWorldTransforms);
遍历AActor列表:
if (ImGui::BeginChild("ActorList", ImVec2(0, 0), true)) {
for (size_t i = 0; i < Actors.Num( ); ++i) {
SDK::AActor* actor = Actors[i];
显示Actor名称:
// 获取 Actor 名称
SDK::FName fname = actor->Name;
std::string actorName = Engine::GetName(fname.ComparisonIndex);
// 显示折叠菜单
if (ImGui::TreeNodeEx((void*)actor, 0, "[%d] %s", i, actorName.c_str( )))
{
显示Transform信息:
if (bShowWorldTransforms) {
SDK::FTransform& worldTransform = actor->RootComponent->ComponentToWorld;
SDK::FVector worldLocation = worldTransform.Translation;
SDK::FRotator worldRotation = QuatToEulerDeg(worldTransform.Rotation);
SDK::FVector worldScale = worldTransform.Scale3D;
ImGui::Separator( );
ImGui::TextColored(ImVec4(0.8f, 1.0f, 0.8f, 1.0f), "世界变换:");
ImGui::Text("位置: X: %.2f, Y: %.2f, Z: %.2f",
worldLocation.X, worldLocation.Y, worldLocation.Z);
ImGui::Text("旋转: Pitch: %.2f, Yaw: %.2f, Roll: %.2f",
worldRotation.Pitch, worldRotation.Yaw, worldRotation.Roll);
ImGui::Text("缩放: X: %.2f, Y: %.2f, Z: %.2f",
worldScale.X, worldScale.Y, worldScale.Z);
}
上述UI逻辑编写完后,将其编译成dll注入到游戏中,即可看到UI菜单: