2025腾讯游戏安全竞赛Andorid初赛
2025腾讯游戏安全竞赛Andorid初赛
发现了穿墙透视,子弹射速有问题,可能移动速度也有问题
准备工作
寻找Gname
搜索ByteProperty
GName 对应的就是全局变量 wlock_,地址 0xADF07C0。
注意,这题里的 UE4 不是老式一个 GNames 数组指针的形态FNamePool,NamePoolData。也就是说ByteProperty 等硬编码名字,是被 sub_6A21FA4 调 sub_6A23DA8 塞进这个池里的
寻找GUObject
dword_AE34A98 GUObjectArray
寻找Gworld
qword_AFAC398,我这里的字符串识别错误了
DumpSDK
1 | ./ue4dumper64 --strings --newue+ --gname 0xADF07C0 --package com.ACE2025.Game --output /data/local/tmp |
1 | ./ue4dumper64 --package com.ACE2025.Game --actors --gname 0xADF07C0 --gworld 0xAFAC398 --output /data/local/tmp --newue+ |
异常点1-3: 速度 加速度 后座
init_array自动执行了一些东西用 sub_27F0,sub_27F0 创建线程,线程入口是 sub_1B9C
sub_1B9C 先解密出这些字符串并读 /proc/self/maps,libUE4.so
可以解密出一些字符串
sub_30A0(dst, src) 是一个一次性异或解密器,unk_890 里放的是两段拼在一起的密文数据,前 0x1A 字节,也就是 26 字节,当作 key,后 10 字节是被加密的目标字符串,逐字节执行dst[i] = src[26 + i] ^ src[i % 26];,总共解出 10 个字节,写到 unk_B658
此外
- sub_29A4(dst, unk_7C1):解出 “/proc/self/maps\0”
- sub_2B6C(dst, unk_7FB):解出 “/proc/%d/maps\0”
- sub_2D1C(dst, unk_833):解出 “r\0”
- sub_2ED8(dst, unk_865):解出 “-\0”
- sub_30A0(dst, unk_890):解出 “libUE4.so\0”
然后 sub_B80(-1, “libUE4.so”) 取 libUE4.so 基址,sub_B80 的直线逻辑是这样的:
- 0x1260 比较第一个参数 a1 是否小于 0。
- 如果 a1 < 0,走 0x1294 -> 0x12A0,调用 sub_29A4 解出 “/proc/self/maps”。
- 如果 a1 >= 0,走 0x1314 -> 0x1324,调用 sub_2B6C 解出 “/proc/%d/maps”。
- sub_19E8 在 0x1B44 实际调用的是 __vsnprintf_chk,所以它就是个格式化字符串封装。
- 负数参数时,格式化出 /proc/self/maps,非负参数时,格式化出 /proc/
/maps
/proc/self/maps:看当前这个进程自己的 maps。
/proc/1234/maps:看 PID 为 1234 的那个进程的 maps。
然后它继续做:
- 0x1374 -> 0x1380:sub_2D1C 解出 “r”
- 0x1390 -> 0x139C:fopen(filename, “r”)
- 0x16B0 -> 0x16C0:fgets(line, …, fp),循环读每一行
- 0x1784 -> 0x1790:strstr(line, module_name),检查这一行里有没有目标模块名
- 0x17E4 -> 0x17F0:sub_2ED8 解出 “-“
- 0x1800 -> 0x1810:strtok(line, “-“)
- 0x1858 -> 0x1868:strtoul(token, NULL, 16)
- 0x193C -> 0x1944:fclose(fp)
- 0x19A8:返回解析出来的那个数
它打开 maps 文件,逐行读,找包含libUE4.so的那一行,然后把这一行按 - 切开,取前半段十六进制地址,再转成整数返回。Linux/Android 的 maps 行长这样:7b12340000-7b1f000000 r-xp … /data/app/…/libUE4.so
拿到 libUE4.so 基址后,继续
- 0x224C:ue4_base + 0xAFAC398,后面一串是对象遍历
- 0x2588 -> 0x2598:检查 candidate->vtable - ue4_base == 0xA63BE28
- 0xA63BE28 是之前在 libUE4.so 里确认过的 AMyProjectCharacter vtable,所以这里是在筛主角对象
找到后,写这几处:
- 0x2608 / 0x2628:Actor + 0x538 = 0.0f
- 0x2644:取 Actor + 0x288,也就是 CharacterMovement
- 0x26A0 / 0x26DC:CharacterMovement + 0x1A0 = 1e9f
- 0x2708:CharacterMovement + 0x18C = 1e9f
- 0x2714:写标志,避免重复执行
它遍历 UE4 运行时对象,筛选条件是:
*(qword *)Actor - UE4Base == 0xA63BE28
0xA63BE28 正好就是我们之前在 libUE4.so 里看到的 AMyProjectCharacter vtable
它改的是“运行时内存里的角色对象”,不是改磁盘文件。
当前确认到的 3 处实际 gameplay 写入是:
- 把 AMyProjectCharacter::RecoilAccumulationRate 写成 0.0f
- sub_1B9C 里0x2608 先算出 selected_actor + 0x538
- 0x2628 执行写入 0,这个偏移在 SDK 里对应 RecoilAccumulationRate,见 SDKw.txt:8338
- 把 CharacterMovement::MaxAcceleration 写成 1e9f
- 0x2644 先取 selected_actor + 0x288,这是 CharacterMovement 指针,见 SDKw.txt:6448
- 0x26A0 再算 CharacterMovement + 0x1A0
- 0x26DC 写入常量 0x4E6E6B28
- 这个偏移在 SDK 里对应 MaxAcceleration,见 SDKw.txt:6561
- 把 CharacterMovement::MaxWalkSpeed 写成 1e9f
- 0x26E0 后继续拿着同一个 CharacterMovement
- 0x2708 写 CharacterMovement + 0x18C
- 这个偏移在 SDK 里对应 MaxWalkSpeed,见 SDKw.txt:6556
先在 libGame.so 里找写了哪里例如 0x2708 是
1
2 MOV W8, #0x4E6E6B28
STR W8, [X9,#0x18C]这表示往某个对象偏移 0x18C 的位置写一个 32 位值 0x4E6E6B28。
这题里为什么能断言是 CharacterMovementComponent + 0x1A0,看 sub_1B9C 这一段
1
2
3
4
5
6
7
8
9
10 263c LDR X8, [X19,#0x38]
2640 LDR X8, [X8]
2644 LDR X8, [X8,#0x288]
264c STR X8, [X10]
...
2698 LDR X8, [X19,#0x48]
269c LDR X8, [X8]
26a0 ADD X8, X8, #0x1A0
26dc STR W8, [X10]
2708 STR W8, [X9,#0x18C]这段的含义是:
1. 先拿到当前选中的角色对象 actor,为什么是actor? 2. 从 actor + 0x288 取出一个指针 3. 再对这个取出来的指针加 0x1A0 4. 最后写值先在libUE4.so找到MyProjectCharacter的类注册函数sub_67125B8 里直接出现了类名字符串
这说明 sub_67125B8 是在注册/获取 MyProjectCharacter 这个类。这个注册函数把 sub_67125A8 当成构造入口传进去
sub_67125B8 里有这句:
也就是说,sub_67125A8 被塞进了 MyProjectCharacter 的类注册描述里,而 sub_67125A8 本身只有两条指令
1
2 67125a8 LDR X0, [X0]
67125ac B sub_670E1B4这就是一个标准 constructor thunk。它把对象指针取出来,然后直接跳到 sub_670E1B4。 所以到这里已经能下结论sub_670E1B4 == MyProjectCharacter 的实际构造函数.在这个构造函数开头,确实把 0xA63BE28 写进了对象首地址
sub_670E1B4 开头这几条最关键:
1
2
3
4 670e1f4 ADRP X8, #off_AC71358@PAGE
670e1f8 LDR X8, [X8,#off_AC71358@PAGEOFF] ; unk_A63BE18
670e208 ADD X10, X8, #0x10
670e210 STR X10, [X19]意思是
1
2
3 X8 = 0xA63BE18
X10 = X8 + 0x10 = 0xA63BE28
*(uint64_t *)this = 0xA63BE28而在构造函数里把值写到对象首地址 (qword)this”就是典型的 设置 primary vptr。
所以这里不是像 vtable,而是:
1
2
3 MyProjectCharacter::ctor(this) {
*(uint64_t *)this = 0xA63BE28;
}这就已经把 0xA63BE28 == MyProjectCharacter 的主 vtable 地址点 证死了。
为什么不是 0xA63BE18,而是 0xA63BE28,这是 C++ ABI 的正常现象。我把 0xA63BE18 开始的字节也读出来了:
1
2
3
4
5 0xA63BE18: 00 00 00 00 00 00 00 00
0xA63BE20: 00 00 00 00 00 00 00 00
0xA63BE28: 60 b5 b2 05 00 00 00 00
0xA63BE30: 48 00 71 06 00 00 00 00
...也就是说0xA63BE18 到 0xA63BE27 前 16 字节不是函数指针,真正函数指针表从 0xA63BE28 开始
这正好对应编译器在构造函数里做的 +0x10。所以
- 0xA63BE18 是 vtable group 的起点附近
- 0xA63BE28 是对象里实际保存的 address point
- libGame.so 读对象首 qword 读到的也正是这个地址点
这也解释了为什么直接 xrefs_to 0xA63BE28 可能是空的代码引用的是 0xA63BE18,然后运行时再 +0x10。
因为 SDK 已经把 Character 类布局写出来了,这里明确写着Class: Character.Pawn.Actor.Object
CharacterMovementComponent* CharacterMovement; // [Offset: 0x288, Size: 0x8]
而当前对象又不是普通 Actor,它已经被前面的 vtable 判断缩到 AMyProjectCharacter 了。MyProjectCharacter 继承自 Character,见
SDKw.txt:8319,所以actor 是 MyProjectCharacter*,actor + 0x288 继承自 Character 的 CharacterMovement 指针
所以,按效果这个 so 目前明确做了三件事:
- 去掉后坐力累计
- 把最大加速度拉到极端值
- 把最大移动速度拉到极端值
异常点4: 自改朝向
AMyProjectCharacter
按输入绑定 -> 开火函数 -> 字段偏移 -> 行为验证 -> 排除误判这条链推。
先校对类布局。
先用导出的 SDK 对齐 AMyProjectCharacter 的字段偏移,确认 FP_MuzzleLocation=0x4c8、ProjectileClass=0x510、FireSound=0x518、FireAnimation=0x520、RecoilPitch/Yaw/Recovery=0x52c~0x538。
用字符串和 xref 找到角色构造和输入绑定。
MuzzleLocation 的 xref 落到 sub_670E1B4,这就是 AMyProjectCharacter 构造。然后我对 Fire、MoveForward、MoveRight、TurnRate、LookUpRate 做 xref,全部汇到 sub_670EB7C。这个函数连续绑定 action/axis,很明显就是 SetupPlayerInputComponent。
这是 UE 角色类里一个很常见的函数,作用就是把按键绑定到函数,把摇杆/鼠标轴绑定到函数比如:
- 按空格 -> Jump
- 按开火 -> OnFire
- W/S -> MoveForward
- A/D -> MoveRight
输入绑定表
- “Jump” 绑定到跳跃
- “Fire” 绑定到 sub_670F110
- “ResetVR” 绑定到一个函数
- “MoveForward” 绑定到 sub_670FA44
- “MoveRight” 绑定到 sub_670FAAC
- “TurnRate” 绑定到 sub_670FB14
- “LookUpRate” 绑定到 sub_670FB60
在输入绑定里确定开火函数。sub_670EB7C 里 Fire直接绑定到了 sub_670F110,所以 sub_670F110 就是开火回调。
反编译 sub_670F110,把它拆成几段。前半段先更新 recoil 相关临时量。中间一段关键:
调 sub_95EBDEC(),这个函数反编译后明确返回 AStaticMeshActor::StaticClass(),然后用 world + 这个 class 枚举场景里的 StaticMeshActor
sub_95EBDEC() 先返回一个类
sub_8C30D58(World, Class, OutArray) 再用这个类去找场景对象
recoil 就是后坐力,这里能看出来有这几个字段:
- RecoilPitch
- RecoilYaw
- RecoilRecoverySpeed
- RecoilAccumulationRate
FP = First Person Muzzle = 枪口 所以 FP_MuzzleLocation 就“第一人称枪口位置
SceneComponent* FP_MuzzleLocation;//[Offset: 0x4c8, Size: 0x8]
遍历时取对象名,和 E 做比较,命中后取目标位置与 FP_MuzzleLocation 位置做差,归一化,转成旋转,再通过 Pawn.Controller 指针调一个写旋转的方法
它是在改朝向
这里访问的 a1+0x258 按 SDK 正好是 Pawn.Controller,后面那个调用只吃一个 Rotator 形态的数据,调用形态和 SetControlRotation 非常吻合。
同时我专门去查了 ClientSetLocation、K2_SetActorLocation、K2_SetActorLocationAndRotation 的 xref,没有发现这条 0x670F110 开火链直接调用它们。
所以这不是把角色坐标改了,而是把控制朝向硬拉到了某个目标上。表现出来就会像开火时自动偏到某个地方。
AStaticMeshActor::StaticClass() 是 UE 的类系统概念。AStaticMeshActor 是静态网格体 actor”这个类,比如墙.地板,方块 场景装饰物
为什么说目标是静态网格锚点,不是敌人。因为 sub_95EBDEC() 明确是 StaticMeshActor::StaticClass(),所以这段自瞄逻辑本质上是在遍历场景静态网格 -> 按名字筛一个锚点 -> 把视角/开火方向拉过去。
一个容易混淆的点。sub_671096C 会把 recoil 的两个累积值缓慢插值回 0,这只是后坐力恢复,不是自瞄。
异常点5:开火随机
子弹乱飞
sub_670F110 是开火函数
在 0x670F644 它先调了 sub_670FBAC,拿发射旋转;
在 0x670F654 又调了 sub_670FCEC,拿发射位置;
然后 0x670F6B8 / 0x670F7A4 调 sub_8D2ED80 去 SpawnActor。
真正的异常点在 sub_670FBAC,这函数里有两次明确的 rand()
第二次随机值会被算成:((rand() & 0xFFFFFF) / 16777000.0) * 60.0 - 30.0 也就是大约 [-30, +30] 度的随机偏移。
这个随机偏移最后被直接加进发射旋转。在 sub_670FBAC 末尾有三条关键指令:
0x670FCBC FADD S8, S8, S1
0x670FCC0 FADD S9, S9, S0
0x670FCC4 FADD S10, S10, S12
这就是把前面算出来的随机量混到最终返回的 rotator 里。
4. sub_670FCEC 只是算枪口位置,不是随机源。它会用 FP_MuzzleLocation 和 GunOffset 算 spawn location,没看到 rand()。
似乎子弹发射位置也有问题是从背后射出来的
OnFire 在 libUE4+0x670F644 会调用 sub_670FBAC。这一步会算一份带随机散布的 Rotation,里面有 rand(),所以子弹方向会乱飘。
丢弃原函数算出来的发射角,在真正 SpawnActor 前,直接覆盖它要用的旋转参数:
- 旋转覆盖函数
- 从 character + 0x258 取 Controller
- 再从 controller + 0x288 读 ControlRotation
把这 3 个 float 直接写回 rotPtr
OnFire 到 SpawnActor 时,寄存器约定是:
- x2 = 发射位置
- x3 = 发射旋转
SpawnActor 是 UE 里生成一个 Actor 实例的函数,开火时它就是拿来生成子弹对象的
muzzle 就是枪口,FP_MuzzleLocation,第一人称,VR = VR 模式,MuzzleLocation = 枪口位置组件
原来的 x2 是 sub_670FCEC 算出来的,它会基于:
- FP_MuzzleLocation @ 0x4C8
- VR_MuzzleLocation @ 0x4D8
- GunOffset @ 0x500
计算,现在要把位置也接管了:
- 直接从 FP_MuzzleLocation 或 VR_MuzzleLocation 这个 SceneComponent 上读世界坐标
- 读的是组件的 0x1D0/0x1D4/0x1D8 三个 float
- 然后直接写回 locPtr,也就是 x2
合并入口如下,它会同时做两件事:
1 | function overwriteProjectileSpawn(character, locPtr, rotPtr, useVrMuzzle) { |
- overwriteSpawnLocationFromMuzzle(…)
- overwriteSpawnRotationFromController(…)
异常点6: 透视 (为解决)
参考了比较多的博客还是没解决
最终脚本
1 | const LIB_GAME = 'libGame.so'; |





















