腾讯游戏安全大赛 2024 安卓初赛题解
寻找关键结构体
和 23 年的不同,24 年的题不是 unity 引擎的游戏了,而是 UE 引擎,该引擎使用 C++ 实现,有极强的反射机制。
逆向 UE 需要几个关键的结构体 GName、GUObjectArray、GWorld
GName:为了优化性能,UE 不会把在每个对象中存储字符串,而是将所有的字符串存储在一个全局的字符串池中,并给每个字符串分配一个唯一的索引
id,有了GName,就可以将在内存中看到一个个整数映射回字符串。
GUObjectArray: UE 中所有东西都继承自
UObject,引擎会维护一个全局对象数组GUObjectArray,里面存储了所有UObject对象的指针,通过这个数组可以遍历引擎中的每一个类,可以读取类的属性以及在内存中的偏移。
GWorld: UE 引擎中有一个全局变量
GWorld,代表当前游戏的世界状态,通过GWorld可以访问到当前游戏中的所有关卡、角色、物品等信息,获取运行时数据实例。
因为不同版本引擎中的结构体定义以及偏移可能不同,因此需要的先确定游戏使用的 UE 版本,才方便后续源码比对。
这个题目应该是做些了隐藏,直接在 AndroidManifest.xml 里看不到真实版本号

在 libUE4.so 里直接字符串搜索也找不到,后面是在 Strings 窗口右键选择 Setup…,并且勾选 Unicode C-style 之后再次搜索才看到了真实的版本号是 4.27

github 上找到 ue4.27 的源码进行分析:
GWorld
找到 GWorld 定义位置

在 GWorld 被赋值为 LoadedWorld 处往上翻有个 GWorld初始化为 nullptr

往下翻有个字符串 SeamlessTravel FlushLevelStreaming

据此在 ida 中寻找 Gworld 结构体,通过对比可以注意到 qword_B32D8A8,即 Gworld 偏移为 0xB32D8A8
1 | |
GUObjectArray
找到 GUObjectArray 并且附近有字符串 CloseDisregardForGC

在 ida 中搜索字符串定位到相同位置,可知 GUObjectArray 偏移为 B1B5F98
1 | |
GName
UE 4.23 及之后的版本,Epic 重构了名称存储系统,引入了 FNamePool,现在的 GName 本质上就是指向 FNamePool 实例的指针或引用,通过
FNamePool 的构造函数会初始化一些 Name,例如 None,ByteProperty,IntProperty,构造函数通过宏 #include "UObject/UnrealNames.inl" 加载了引擎预定义的硬编码名称

字符串搜索 ByteProperty,交叉引用定位到所在函数,该函数即为 FNamePool 构造函数
1 | |
在对这个函数进行交叉引用查找,传入的参数即为 GName
1 | |
1 | |
可知 GName 偏移为 0xB171CC0
Dump
Gworld:0xB32D8A8
GUObjectArray:0xB1B5F98
GName:0xB171CC0
找到关键结构体的偏移之后,使用 UE4Dumper dump 出需要的信息,方便后续分析
ue4dumper build 要先有 android ndk 环境,找到 ndk-build.cmd 拖进从 github 上拉去下来的 UE4Dumper 路径下,生成的 ue4dumper 在 lib 目录,再 adb push 到手机端使用
SDK Dump With GObjectArray Args
1 | |
SDK Dump With GWorld Args
1 | |
Dump Objects Args
1 | |
Show ActorList With GWorld Args
1 | |
section0
需要成功离开房间,但是一碰到墙生命值就会归零。
Method 1
想到的第一种方式是找到修改生命值,把它改成一个极大值
在 dump 出的 SDK 中搜索找到生命值,一般是 float 类型,再由内存对齐可知,生命值偏移为 0x510

然后需要获取玩家的地址,在 dump 出的 Actor 列表里面可以找到找到玩家角色 FirstPersonCharacter_C 的地址 7915f3e040
1 | |
根据这个地址写 frida 脚本锁血
1 | |
但是这样的话每次游戏重新开始地址都会变化,需要重新获取地址,不够方便,所以下面就把获取地址的逻辑和锁血逻辑结合起来写在一个 frida 脚本里面
要获得玩家地址,需要先获取 GWorld,然后通过 GWorld 获取 PersistentLevel,再通过 PersistentLevel 获取 ActorList,最后遍历 ActorList 找到玩家角色 FirstPersonCharacter_C
脚本中的几个偏移需要通过分析 ue 4.27 源码得到
ue4.27 源码:https://github.com/EpicGames/UnrealEngine/tree/4.27
- Level Offset(相对于 GWorld):
0x30
UnrealEngine/Engine/Source/Runtime/Engine/Classes/Engine/World.h
1 | |
public UObject 是 ue 的核心基类,成员包括:虚函数表指针(8)、ObjectFlags(4)、InternalIndex(4)、ClassPrivate(8)、NamePrivate(8)、OuterPrivate(8),一共占用 0x28 字节
public FNetworkNotify 是一个接口,在 C++ 中,继承一个接口会增加一个接口虚函数表指针(vptr),指针在 64 位下占 0x08 字节
此时累加 0x28 + 0x8 = 0x30 字节,所以 UWorld 自己的成员变量将从 0x30 开始摆放,而 PersistentLevel 是 UWorld 内部定义的第一个有效成员变量,故 PersistentLevel 偏移为 0x30
在 dump 出的 SDK 中也能验证到
1 | |
- ActorList Offset(相对于 Level):
0x98
UnrealEngine/Engine/Source/Runtime/Engine/Classes/Engine/Level.h
1 | |
UObject:上文已经分析过,占用 0x28 字节
IInterface_AssetUserData :一个接口类,占用 0x08 字节
参考 ue4 源码的 URL.h,一个 FURL 结构体包含以下成员:
FString Protocol (16 字节)
FString Host (16 字节)
int32 Port (4 字节)
int32 Valid (4 字节)
FString Map (16 字节)
FString RedirectURL (16 字节)
TArray<FString> Op (16 字节)
FString Portal (16 字节)
一共 104 即 0x68 字节
所以到 Actors 偏移刚好是 0x28 + 0x8 + 0x68 = 0x98 字节
而对于 GName 的解析,可以参考这两篇文章,已经写得足够详细
执行锁血完整脚本
1 | |
1 | |
Method 2
另一种思路是直接 hook 掉扣血的函数
找到了碰到墙之后会触发的函数
void ReceiveHit(PrimitiveComponent* bpp__MyComp__pf, Actor* bpp__Other__pf, PrimitiveComponent* bpp__OtherComp__pf, bool bpp__bSelfMoved__pf, Vector bpp__HitLocation__pf, Vector bpp__HitNormal__pf, Vector bpp__NormalImpulse__pf, out const HitResult bpp__Hit__pf__const);// 0x5e93094
把函数的前几个指令改成 ret 就可以实现不扣血了
1 | |
后面了解到 UE4 的脚本包装函数,为了让蓝图能够调用原生 C++ 函数,引擎会自动生成一个包装层,函数名开头有 exec,而真正的函数是通过这个函数最后 return 时候进行虚表调用的
dump 下来的 SDK 中函数地址是包装后的函数地址,所以在反编译器中国根据地址找到的函数其实是包装函数,而不是真正的原生函数
根据上面的地址找到对应的函数 sub_5E93094
1 | |
注意到最后 return 的 a1 + 0x8B0,写 frida 脚本找到没有 exec 包裹的函数地址
1 | |

找到偏移地址对应的函数 sub_5E843D4
1 | |
进入最后 return 的函数 sub_5E840C8
1 | |
从 -100.0 可以发现这里就是真正的扣血逻辑所在函数
1 | |
可以直接 nop 掉扣血处的指令
1 | |
Method 3
修改位置坐标,但是只成功实现了瞬间的传送,很快又会被拉回原始位置
在 SDK 中找到获取玩家位置的偏移
1 | |
在 ida 中找到偏移地址处函数
sub_965DDF8
1 | |
sub_965DC3C
1 | |
它对应的源码中的真实函数是 return 的 sub_8C3181C
sub_8C3181C
1 | |
1 | |
逃出房间
最终成功出去

section1
空中有部分 flag 字段被隐藏了

猜测是透明度的原因
在 sdk 的三个不同类中看到了和透明度相关的函数
1 | |
其中 SceneComponent::SetVisibility 是用于控制游戏世界中 3D 物体是否被渲染的函数,它的参数 bNewVisibility 为 true 时,组件可见,参数 bPropagateToChildren 是一个层级传播参数,为 true 时不仅改变当前组件的可见性,还会递归地将此设置应用到所有挂载在它下面的子组件
Widget::SetVisibility 是用于控制 2D UI 元素 的显示、布局占用以及交互能力的函数
MovieSceneLevelVisibilitySection::SetVisibility 是用于在 过场动画时间轴中,控制整个关卡的加载或显示状态
所以选择 hook SceneComponent::SetVisibility 来实现透明度的修改,再把之前锁血的功能也结合在一起
1 | |

可知 section1 为 8939
section2
提示说让立方体变得不可穿透,先问 ai 了解到 SetCollisionEnabled
在 SDK 中找到该函数
1 | |
ida 中找到该地址对应的函数
1 | |
这里和上面一样,依旧需要获取 return 的函数
但是只 hook 这个函数没有成功,又去看了碰撞相关的其他函数
1 | |
sub_98EB590
1 | |
1 | |
1 | |

所以 section2 是 008
section3

在 Objects.txt 搜索 flag 看到了 getlastflag
1 | |
1 | |
在 ida 中找到对应函数
1 | |
在 libplay.so 中找到 get_last_flag 函数
1 | |
上面有字符串异或和间接跳转
用 bn 看,把 data 段设为只读,发现已经分析出来了
1 | |
其中的 sub_40192c 里面是一个异或, sub_4019bc 里面是一个变表 base64
sub_40192c 处跳进去
1 | |
sub_4019bc 处跳进去
1 | |
最终解密
1 | |
所以 section3 是 _Anti_Cheat_Expert
三个部分连在一起的最终 flag 为 flag{8939008_Anti_Cheat_Expert}
参考文章: