Unreal Engine4引擎逆向基础
本文以 2024 第九届腾讯游戏安全竞赛安卓初赛题目(com.tencent.ace.match2024)为例,完整记录一次 UE4 手游的逆向过程。内容从 GUObjectArray、GWorld、GName 三大核心结构体的源码分析讲起,便于对 UE 引擎对象系统不熟悉的逆向入门者学习
ps:小白初次写文章,大佬们多多指教
使用工具:IDA 9.4Pro、Frida 17、ue4dumper
环境:Android 64 位,UE4.27
0.关键结构体基本认识
GUObjectArray(FUObjectArray)
UE中所有东西都继承自UObject,引擎会维护一个全局对象数组GUObjectArray,里面存储了所有UObject对象的指针,通过这个数组可以遍历引擎中的每一个类,可以读取类的属性以及在内存中的偏移
我们从理解源代码开始
class FUObjectArray
{
private:
typedef FChunkedFixedUObjectArray TUObjectArray;
int32 ObjFirstGCIndex;
int32 ObjLastNonGCIndex;
int32 MaxObjectsNotConsideredByGC;
bool OpenForDisregardForGC;
TUObjectArray ObjObjects;
};
class FChunkedFixedUObjectArray
{
enum
{
NumElementsPerChunk = 64 * 1024,
};
FUObjectItem** Objects;
FUObjectItem* PreAllocatedObjects;
int32 MaxElements;
int32 NumElements;
int32 MaxChunks;
int32 NumChunks;
}
struct FUObjectItem
{
UObjectBase* Object;
int32 Flags;
int32 ClusterRootIndex;
int32 SerialNumber;
};
class COREUOBJECT_API UObjectBase
{
private:
EObjectFlags ObjectFlags;
int32 InternalIndex;
UClass* ClassPrivate;
FName NamePrivate;
virtual ~UObjectBase();
};
class CORE_API FName
{
private:
FNameEntryId ComparisonIndex;
#if WITH_CASE_PRESERVING_NAME
FNameEntryId DisplayIndex;
#endif
uint32 Number;
}
struct FNameEntryId
{
private:
uint32 Value;
CORE_API static FNameEntryId FromValidEName(EName Ename);
};
class FNameEntry
{
private:
#if WITH_CASE_PRESERVING_NAME
FNameEntryId ComparisonId;
#endif
FNameEntryHeader Header;
union
{
ANSICHAR AnsiName[NAME_SIZE];
WIDECHAR WideName[NAME_SIZE];
};
}
GWorld(UWorld)
UE引擎中有一个全局变量GWorld,代表当前游戏的世界状态,通过GWorld可以访问到当前游戏中的所有关卡、角色、物品等信息,获取运行时数据实例,并且是一个指针变量,指向Uworld
一样的看源代码
class UWorld : public UObject, public FNetworkNotify
{
ULevel* PersistentLevel;
AGameModeBase* AuthorityGameMode;
AGameStateBase* GameState;
TArray<ULevel*> Levels;
FSceneInterface* Scene;
TArray<TWeakObjectPtr<AController>> ControllerList;
TArray<TWeakObjectPtr<APlayerController>> PlayerControllerList;
};
UCLASS(MinimalAPI)
class ULevel : public UObject, public IInterface_AssetUserData
{
GENERATED_BODY()
public:
FURL URL;
TArray<AActor*> Actors;
TArray<AActor*> ActorsForGC;
static TMap<FName, TWeakObjectPtr<UWorld> > StreamedLevelsOwningWorld;
UPROPERTY(Transient)
UWorld* OwningWorld;
UPROPERTY()
class UModel* Model;
UPROPERTY()
TArray<class UModelComponent*> ModelComponents;
UPROPERTY(Transient, DuplicateTransient, NonTransactional)
ULevelActorContainer* ActorCluster;
};
class TArray
{
protected:
ElementAllocatorType AllocatorInstance;
SizeType ArrayNum;
SizeType ArrayMax;
}
GName(FNamePool)
UE 不会在每个对象中存储字符串,而是将所有的字符串存储在一个全局的字符串池中,并给每个字符串分配一个唯一的索引 id,有了 GName,就可以将在内存中看到一个个整数映射回字符串,是FNamePool类型的全局唯一实例对象
确认源代码
class FNamePool
{
private:
enum { MaxENames = 512 };
FNameEntryAllocator Entries;
#if WITH_CASE_PRESERVING_NAME
FNamePoolShard<ENameCase::CaseSensitive> DisplayShards[FNamePoolShards];
#endif
FNamePoolShard<ENameCase::IgnoreCase> ComparisonShards[FNamePoolShards];
alignas(PLATFORM_CACHE_LINE_SIZE) FNameEntryId ENameToEntry[NAME_MaxHardcodedNameIndex] = {};
uint32 LargestEnameUnstableId;
TMap<FNameEntryId, EName, TInlineSetAllocator<MaxENames>> EntryToEName;
};
class FNameEntryAllocator
{
private:
mutable FRWLock Lock;
uint32 CurrentBlock = 0;
uint32 CurrentByteCursor = 0;
uint8* Blocks[FNameMaxBlocks] = {};
}
class CORE_API FName
{
private:
FNameEntryId ComparisonIndex;
#if WITH_CASE_PRESERVING_NAME
FNameEntryId DisplayIndex;
#endif
uint32 Number;
}
struct FNameEntryId
{
private:
uint32 Value;
CORE_API static FNameEntryId FromValidEName(EName Ename);
};
inline FNameEntryHandle Allocate(uint32 Bytes)
{
uint32 ByteOffset = CurrentByteCursor;
uint32 Step = Align(Bytes, alignof(FNameEntry));
CurrentByteCursor += Step;
return FNameEntryHandle(CurrentBlock, ByteOffset / Stride);
}
class FNameEntry
{
private:
#if WITH_CASE_PRESERVING_NAME
FNameEntryId ComparisonId;
#endif
FNameEntryHeader Header;
union
{
ANSICHAR AnsiName[NAME_SIZE];
WIDECHAR WideName[NAME_SIZE];
};
}
1.关于UE的SDK dump流程
我使用的工具是ue4dumper,这个工具需要Gworld,GUObjectArray,GName的地址
推荐一下这位师傅的文章7beK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6%4N6%4N6Q4x3X3g2U0L8X3u0D9L8$3N6K6i4K6u0W2j5$3!0E0i4K6u0r3M7X3g2$3k6i4u0U0j5#2)9J5c8Y4m8Q4x3V1j5I4y4K6j5@1x3e0R3#2y4g2)9J5k6h3S2@1L8h3H3`.
网上也多有此类教程,大多写的很好,不再赘述
2.关于拥有SDK时反查指定运行对象地址
学习了上述结构体的基础布局之后
我们很容易知道两种思路(附件中均有实现,可以配合注释慢慢学习,分别是GetAddrByName,FindAddrInGWorld)
1 遍历 GUObjectArray
通过完全遍历 GUObjectArray,依次对比当前 Object 的名字与所需名字,若成立则返回 Object。
范围:全引擎所有 UObject(Actor、UClass、UFunction、UPackage、CDO 等)
优点:通用,什么都能找
缺点:慢;同名对象多(CDO 和实例),可能命中错的
适用:找非 Actor 对象(UClass、UFunction 等),或不确定目标在哪时全局搜索。
2 GWorld → PersistentLevel → Actors
获取 GWorld 后,沿 GWorld -> PersistentLevel -> ActorList 这条链,遍历 Actors 数组,依次比对名字,命中则返回地址。
范围:当前关卡的 Actor(几百个)
优点:快,全是实例,不会命中 CDO
缺点:只能找 Actor,找不到 UClass、UFunction 这类非 Actor 对象
适用:找场景里的实际物体(玩家、敌人、墙、道具),操作运行时状态
2024第九届游戏安全竞赛-初赛-安卓客户端安全
初步学习完关键结构体,我们取2024第九届游戏安全竞赛-初赛-安卓客户端安全为例子,一步步深入,附件在最后
Section0
初步游玩可以发现一碰墙就生命值归零,在dump出的SDK中寻找生命值字样

在此可以找出偏移0x510
这时我们需要根据给定字符串找到运行时实例对象地址
我上述的第一思路是:
遍历GUObjectArray,根据上述结构体知识查出每个Object的名字,再匹配检查
后面能发现不太对,因为找出来的对象不处于Uworld中
故我们需要寻找当前Uworld下的
那么思路是寻找Gworld->PersistentLevel->ActorList->AActor(又已知这个继承于UObject)
实现看附件FindAddrInGWorld.js,具体原理是差不多的
且该脚本验证了Actor偏移问题
获取了之后接上setInterval即可
var player = FindAddrInGWorld("FirstPersonCharacter_C");
setInterval(function () {
if (!player || player.isNull()) return;
try { player.add(0x510).writeFloat(999999.0); } catch (e) {}
}, 10);
Section0其他解法1
不与门触碰,所以我们需要说穿墙
首先我们可以看见Class: FirstPersonCharacter_C继承于Character.Pawn.Actor.Object
696K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6*7K9s2g2S2L8X3I4S2L8W2)9J5k6i4A6Z5K9h3S2#2i4K6u0W2j5$3!0E0i4K6u0r3M7q4)9J5c8U0p5^5z5e0x3&6y4U0f1H3x3o6M7&6y4K6l9H3y4K6f1@1y4o6R3`.

而CapsuleComponent又继承于PrimitiveComponent,PrimitiveComponent内部的结构体BodyInstance

可以发现这里有我们角色的碰撞处理点,写frida脚本处理掉却发现人物卡死了,原因是地面其他点也被该位置控制
转换思路变成改掉Wall的碰撞处理点即可
19dK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6S2M7$3E0Q4x3X3g2U0M7$3c8F1i4K6u0W2L8X3g2@1i4K6u0r3M7i4g2W2M7%4c8A6L8$3&6K6i4K6u0r3z5o6x3%4x3o6j5^5z5l9`.`.
看这篇文章启发我们可以去hookSetCollisionEnabled
SDK中搜索得到偏移0x98edb3c
在ida中查找其指向的真实函数
return (*(__int64 (__fastcall **)(__int64, _QWORD))(*(_QWORD *)a1 + 0x660LL))(a1, a2: v6[0]);
发现是虚函数,那么首先查找SetCollisionEnabled的父类
那么我们要获取wall的实际类名
var cls = wall.add(0x10).readPointer();
var ci = cls.add(0x18).readU32();
var blockIndex = (ci >>> 16) & 0x1FFF;
var entry = GNames.add(0x40).add(blockIndex * 8).readPointer()
.add((ci & 0xFFFF) * 2);
var header = entry.readU16();
var len = header >>> 6;
console.log("wall 的类:", entry.add(0x02).readCString(len));
输出:StaticMeshActor
那么去SDK中查询StaticMeshActor追踪其StaticMeshComponent
发现其的确继承了PrimitiveComponent
那我们尝试hook这个函数
具体实现看Hook_SetCollisionEnabled.js
Section0其他解法2
我们可以采取瞬移的方法过门,上网查询发现SetActorLocation该函数
在SDK中查询找到对应点,再在ida中查询

咨询ai得知 v6 v8 v9 即为坐标 那么我们直接内存读出即可,实现请看Hook_SetActorLocation.js
当然碰到墙还是会死哈
Section1
ai问一下使得组件不可见的手段有哪些,注意到了SetActorHiddenInGame和SetVisibility
采取处理Actor的SetActorHiddenInGame不起作用,转而尝试处理SetVisibility,这是因为Setvisibility是组件级
SetActorHiddenInGame 改的是 Actor 的 bHidden,而渲染器实际看的是组件的 bVisible,要bHidden+bVisible都到位了才能看到
确认SDK中Setvisibility的位置,ida发现真函数地址,且需要传入SceneComponent作为pointer,先回头查看Shape_Pipe_Flag是StaticMeshActor类,SDK中查找确认继承于Actor.Object,在Actor中查找SceneComponent确认偏移是0x130,由此我们得知Shape_Pipe_Flag+0x130即为需要传入的ptr
实现如下:
var libUE4 = Process.findModuleByName("libUE4.so");
var fn = new NativeFunction(
libUE4.base.add(0x8E619BC),
'void',
['pointer', 'bool', 'int']
);
var shapes = FindActorsContains("Shape_Pipe_Flag");
for (var i = 0; i < shapes.length; i++) {
var rootComp = shapes[i].addr.add(0x130).readPointer();
if (rootComp.isNull()) continue;
fn(rootComp, 1, 2);
}
解出Part1为8939
Section2
目标是使得立方体不可穿透,那不就是前文所提到的穿墙的逆反处理,不过要先找到对象,我们可以把人物和立方体重合,然后所有Actor查询坐标,发现是这个Cube2
小改Hook_SetCollisionEnabled.js即可获取第二段答案
Part2=008
Section3
老方法,直接人走过去看看到底是谁,是个叫Actor的,不知道什么玩意
采取frida脚本试探一下:
var target = FindAddrInGWorld("Actor");
console.log("Actor =", target);
console.log("类:", getClassName(target));
还是不确定,直接把碰撞关了试一下:
var libUE4 = Process.findModuleByName("libUE4.so");
var fn = new NativeFunction(
libUE4.base.add(0x8C21320),
'void',
['pointer', 'bool']
);
fn(target, 0);
console.log("MyActor 碰撞已关");
确实是这个,上SDK一查得到
Class: MyActor.Actor.Object
bool getlastflag();// 0x6a91fec
上ida找一找,追入sub_6A91A40

打开libplay.so稍微逆一下:
发现逻辑并不难,就是一个换表base64,处理好xor即可:
给出脚本:
raw_key = bytes([
0xD8, 0x98, 0x54, 0xC1, 0x64, 0x93, 0x56, 0x84,
0x38, 0x4F, 0x60, 0xBB, 0xA9, 0xA4, 0xCC, 0x88,
0x8D, 0x9F
])
key_xors = [
0xD2, 0x94, 0x5A, 0xC1, 0x35, 0x85, 0x71, 0xBC,
0x71, 0x55, 0x5B, 0xE7, 0x84, 0xEA, 0xA3, 0x72,
0x71, 0x61
]
raw_target = bytes([
0x9D, 0x43, 0xB0, 0xD7, 0xD4, 0x53, 0x1C, 0x7D,
0xB4, 0xB6, 0xF6, 0x37, 0x23, 0x66, 0xDB, 0x92,
0x19, 0xDF, 0xCF, 0xF9, 0x9A, 0x92, 0xF2, 0x3C
])
target_xors = [
0xC8, 0x17, 0x81, 0xB1, 0xB7, 0x63, 0x7B, 0x34,
0xED, 0xF2, 0xB7, 0x45, 0x47, 0x1C, 0xE3, 0xA2,
0x43, 0xEF, 0x97, 0x9C, 0xF7, 0xA6, 0xC4, 0x76
]
raw_alphabet = bytes([
0x31, 0xBB, 0x87, 0x09, 0xF8, 0xE4, 0xE7, 0x90,
0xF4, 0x99, 0xCC, 0x69, 0x5F, 0x04, 0x46, 0x89,
0x75, 0x5C, 0xF0, 0xCC, 0xBD, 0x2E, 0xA3, 0x68,
0x0F, 0xD6, 0xDC, 0x4E, 0x7A, 0x4D, 0x63, 0xD0,
0x60, 0x24, 0x2D, 0x75, 0x3C, 0x16, 0xFC, 0x41,
0x1D, 0x6E, 0xDF, 0xA4, 0x0D, 0xD3, 0xA6, 0x9D,
0xB9, 0x58, 0x88, 0xB2, 0xBB, 0x8D, 0x9F, 0x25,
0x1B, 0x11, 0xB0, 0x41, 0x2F, 0xCD, 0x10, 0xB6, 0x84
])
alphabet_xors = [0] * 65
alphabet_xors[0] = 0x70
alphabet_xors[1] = 0xF8
alphabet_xors[2] = 0xC2
alphabet_xors[3] = 0x39
alphabet_xors[4] = 0xBA
alphabet_xors[5] = 0xA0
alphabet_xors[6] = 0xA1
alphabet_xors[7] = 0xD7
alphabet_xors[8] = 0xBC
alphabet_xors[9] = 0xD0
alphabet_xors[10] = 0x86
alphabet_xors[11] = 0x22
alphabet_xors[12] = 0x13
alphabet_xors[13] = 0x49
alphabet_xors[14] = 0x08
alphabet_xors[15] = 0xC6
alphabet_xors[16] = 0x25
alphabet_xors[17] = 0x0D
alphabet_xors[18] = 0xA2
alphabet_xors[19] = 0x9F
alphabet_xors[20] = 0xE9
alphabet_xors[21] = 0x7B
alphabet_xors[22] = 0xF5
alphabet_xors[23] = 0x3F
alphabet_xors[24] = 0x57
alphabet_xors[25] = 0x8F
alphabet_xors[26] = 0x86
alphabet_xors[27] = 0x2F
alphabet_xors[28] = 0x18
alphabet_xors[29] = 0x2E
alphabet_xors[30] = 0x07
alphabet_xors[31] = 0xB5
alphabet_xors[32] = 0x06
alphabet_xors[33] = 0x43
alphabet_xors[34] = 0x45
alphabet_xors[35] = 0x1C
alphabet_xors[36] = 0x56
alphabet_xors[37] = 0x7D
alphabet_xors[38] = 0x90
alphabet_xors[39] = 0x2C
alphabet_xors[40] = 0x73
alphabet_xors[41] = 0x01
alphabet_xors[42] = 0xAF
alphabet_xors[43] = 0xD5
alphabet_xors[44] = 0x7F
alphabet_xors[45] = 0xA0
alphabet_xors[46] = 0xD2
alphabet_xors[47] = 0xE8
alphabet_xors[48] = 0xCF
alphabet_xors[49] = 0x2F
alphabet_xors[50] = 0xF0
alphabet_xors[51] = 0xCB
alphabet_xors[52] = 0xC1
alphabet_xors[53] = 0xBC
alphabet_xors[54] = 0xAD
alphabet_xors[55] = 0x16
alphabet_xors[56] = 0x2F
alphabet_xors[57] = 0x24
alphabet_xors[58] = 0x86
alphabet_xors[59] = 0x76
alphabet_xors[60] = 0x17
alphabet_xors[61] = 0xF4
alphabet_xors[62] = 0x3B
alphabet_xors[63] = 0x99
alphabet_xors[64] = 0x84
def xor_bytes(data, keys):
return bytes(a ^ b for a, b in zip(data, keys))
alphabet = xor_bytes(raw_alphabet, alphabet_xors)[:64]
print("[+] Custom base64 alphabet:")
print(" ", alphabet.decode())
print()
target = xor_bytes(raw_target, target_xors)
print("[+] Decrypted target (byte_3E30):")
print(" ", target.decode())
print()
rev = {c: i for i, c in enumerate(alphabet)}
def custom_b64decode(data: bytes) -> bytes:
res = bytearray()
for i in range(0, len(data), 4):
n = (rev[data[i]] << 18) | (rev[data[i+1]] << 12) | \
(rev[data[i+2]] << 6) | rev[data[i+3]]
res.append((n >> 16) & 0xff)
res.append((n >> 8) & 0xff)
res.append(n & 0xff)
return bytes(res)
intermediate = custom_b64decode(target)
print("[+] Intermediate after custom base64 decode:")
print(" ", intermediate.hex())
print()
a1 = xor_bytes(raw_key, key_xors)
print("[+] Decrypted key data (a1):")
print(" ", a1.hex())
print()
flag = xor_bytes(intermediate, a1)
print("[+] Recovered flag (XOR key):")
print(" ", flag.decode())
print()
print("=" * 50)
print("Flag:", flag.decode())
print("=" * 50)
得到Part3=_Anti_Cheat_Expert
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。
最后于 1天前
被dayDReam_10编辑
,原因: