前言

第一次聽說這比賽是上年偶然和舍友聊天時他告訴我的,沒想到還有以遊戲安全為主的比賽,當時看到有安卓的賽道就報名了,然後比賽時就被那門卡了2天,然後就沒有然後了。

今年沒意外的話是我大學生涯的最後一年,也許也是最後一年打這個比賽了吧,下年也不知道有沒有空看看題。

以下是我的解題記錄,一部份是比賽時寫的,一部份是賽後補充的,有寫錯的還請指正。

前置準備

版本:4.27

image.png

GName:0xADF07C0

image.png

GObject:0xAE34A98

image.png

dump sdk by GObject,記為SDKO.txt

1
./ue4dumper64 --sdku --newue+ --gname 0xADF07C0 --guobj 0xAE34A98 --package com.ACE2025.Game

dump all objects,記為Objects.txt

1
2

./ue4dumper64 --objs --newue+ --gname 0xADF07C0 --guobj 0xAE34A98 --package com.ACE2025.Game

異常點分析與修復

題目說明如下,純粹的UE4逆向,無任何反調試。

image.png

速度異常

hook pthread_create,patch掉libGame.so創建唯一一個線程後,速度不再異常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
function hook_pthread() {

var pthread_create_addr = Module.findExportByName(null, 'pthread_create');
console.log("pthread_create_addr,", pthread_create_addr);

var pthread_create = new NativeFunction(pthread_create_addr, "int", ["pointer", "pointer", "pointer", "pointer"]);

Interceptor.replace(pthread_create_addr, new NativeCallback(function (parg0, parg1, parg2, parg3) {
var so_name = Process.findModuleByAddress(parg2).name;
var so_path = Process.findModuleByAddress(parg2).path;
var so_base = Module.getBaseAddress(so_name);
var offset = parg2 - so_base;
// console.log("so_name", so_name, "offset", offset, "path", so_path, "parg2", parg2);
var PC = 0;
if ((so_name.indexOf("libGame.so") > -1)) {
console.log("find thread func offset", so_name, offset);
if ((7068 === offset)) {
console.log("anti bypass");
} else {
PC = pthread_create(parg0, parg1, parg2, parg3);
console.log("ordinary sequence", PC)
}
} else {
PC = pthread_create(parg0, parg1, parg2, parg3);
// console.log("ordinary sequence", PC)
}
return PC;
}, "int", ["pointer", "pointer", "pointer", "pointer"]))

}

由此可知相關邏輯就在libGame.so創建的線程中。接下來分析它的實現原理。

用IDA動調線程回調函數sub_1B9C

image.png

進入後會看到明顯的控制流平坦化,先不管。

打斷點進入case 12623,分析後發現就是通過/proc/self/maps獲取libUE4.so的基址。

image.png

之後本想手動還原下控制流,但突然想起IDA有個D-810插件貌似能解控制流混淆,嘗試下,發現效果很好。

獲取了libUE4_base後會賦給infos[19]

image.png

然後*(_QWORD *)(libUE4_base_1 + 0xAFAC398)獲取了libUE4.so的一個全局變量,猜測是GWorld

image.png

用ue4dumper來驗證,發現能順利dump出SDK,由此可知0xAFAC398的確是GWorld

記dump出來的文件為SDKW.txt

1
./ue4dumper64 --sdkw --newue+ --gname 0xADF07C0 --gworld 0xAFAC398 --package com.ACE2025.Game

獲取GWorld後,就能通過遍歷其中的屬性定位到FirstPersonCharacter_C,具體原理如下,這是用frida實現的。

其中用了vtabs( UObject的第0個成員屬性,虛表 )的函數偏移是否等於0xA63BE28來確定是否FirstPersonCharacter_C對象,0xA63BE28大概是FirstPersonCharacter_C的一個特徵?

最終通過修改CharacterMovementComponentMaxAccelerationMaxWalkSpeed來改變人物速度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
let GWorld = base.add(0xAFAC398).readPointer(); // FirstPersonExampleMap (GWorld)
let PersistentLevel = GWorld.add(0x30).readPointer() // PersistentLevel
let StreamingLevels = PersistentLevel.add(0x98).readPointer(); // StreamingLevelsToConsider.StreamingLevels
let StreamingLevelsNum = PersistentLevel.add(0xA0).readU32();

let FirstPersonCharacter_C = null;
for(let i = 0; i < StreamingLevelsNum; i++) {
let StreamingLevel = StreamingLevels.add(i * 8).readPointer();
let vtabs = StreamingLevel.readPointer();

if (vtabs.sub(base) == 0xA63BE28) {
// console.log(i, vtabs.readPointer());
FirstPersonCharacter_C = StreamingLevel;
}
}

let CharacterMovementComponent = FirstPersonCharacter_C.add(0x288).readPointer()
CharacterMovementComponent.add(0x1a0).writeFloat(1000000000)
CharacterMovementComponent.add(0x18c).writeFloat(1000000000)

自瞄異常

SDKO.txt裡可以看到我的角色類裡有個ProjectileClass成員,而它有個OnHit成員函數

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
Class: MyProjectCharacter.Character.Pawn.Actor.Object
SkeletalMeshComponent* Mesh1P;//[Offset: 0x4b8, Size: 0x8]
SkeletalMeshComponent* FP_Gun;//[Offset: 0x4c0, Size: 0x8]
SceneComponent* FP_MuzzleLocation;//[Offset: 0x4c8, Size: 0x8] // 槍口位置
SkeletalMeshComponent* VR_Gun;//[Offset: 0x4d0, Size: 0x8]
SceneComponent* VR_MuzzleLocation;//[Offset: 0x4d8, Size: 0x8]
CameraComponent* FirstPersonCameraComponent;//[Offset: 0x4e0, Size: 0x8]
MotionControllerComponent* R_MotionController;//[Offset: 0x4e8, Size: 0x8] // for VR
MotionControllerComponent* L_MotionController;//[Offset: 0x4f0, Size: 0x8] // for VR
float BaseTurnRate;//[Offset: 0x4f8, Size: 0x4] // 左右轉向速率
float BaseLookUpRate;//[Offset: 0x4fc, Size: 0x4] // 上下轉向速率
Vector GunOffset;//[Offset: 0x500, Size: 0xc]
class MyProjectProjectile* ProjectileClass;//[Offset: 0x510, Size: 0x8]
SoundBase* FireSound;//[Offset: 0x518, Size: 0x8]
AnimMontage* FireAnimation;//[Offset: 0x520, Size: 0x8]
bool bUsingMotionControllers;//(ByteOffset: 0, ByteMask: 1, FieldMask: 1)[Offset: 0x528, Size: 0x1]
float RecoilPitch;//[Offset: 0x52c, Size: 0x4] // 後座力
float RecoilYaw;//[Offset: 0x530, Size: 0x4] // 後座偏航
float RecoilRecoverySpeed;//[Offset: 0x534, Size: 0x4] // 後座力恢復速度
float RecoilAccumulationRate;//[Offset: 0x538, Size: 0x4] // 後座力累積率

Class: MyProjectProjectile.Actor.Object
SphereComponent* CollisionComp;//[Offset: 0x220, Size: 0x8]
ProjectileMovementComponent* ProjectileMovement;//[Offset: 0x228, Size: 0x8]
void OnHit(PrimitiveComponent* HitComp, Actor* OtherActor, PrimitiveComponent* OtherComp, Vector NormalImpulse, out const HitResult Hit);// 0x67138e8

嘗試hook OnHit,分別在enter和leave時打印CameraRotation,發現兩者相等,即在enter前就已經完成自瞄,代表相關的自瞄邏輯不在這裡。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function hook_onHit() {
// void OnHit(PrimitiveComponent* HitComp, Actor* OtherActor, PrimitiveComponent* OtherComp, Vector NormalImpulse, out const HitResult Hit);// 0x67138e8
Interceptor.attach(base.add(0x6711D34), {
onEnter: function(args) {
console.log("[onHit] enter: ", JSON.stringify(getCameraRotation()))
},
onLeave: function() {
// setCameraRotation([100, 200, 0])
console.log("[onHit] leave: ", JSON.stringify(getCameraRotation()))

}
})
}

CameraRotation下硬斷( 寫 ),命中信息如下:

命中PC:0x799F6637C0

libUE4 base:0x7996b2b000

計算得Offset為0x8B387C0

image.png

IDA跳到0x8B387C0,如下:

image.png

0x8B387C0所在函數為mb_aimbot,嘗試直接patch掉mb_aimbot,發現patch後人物無法轉動視角。

1
2
3
4
5
function patch_mb_aimbot() {
Interceptor.replace(base.add(0x8B3861C), new NativeCallback(() => {
console.log("patch mb_aimbot")
}, "void", []))
}

hook mb_aimbot,打印調用棧,未點擊時,調用棧如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
[hook_aimbot] 
799fa8e604 is in libUE4.so offset: 0x8f9b604
799fa92444 is in libUE4.so offset: 0x8f9f444
799fa9a358 is in libUE4.so offset: 0x8fa7358
799fcf0b8c is in libUE4.so offset: 0x91fdb8c
799d2c1bb0 is in libUE4.so offset: 0x67cebb0
799d2c1730 is in libUE4.so offset: 0x67ce730
799d2c0e24 is in libUE4.so offset: 0x67cde24
799fcecc04 is in libUE4.so offset: 0x91f9c04
799fcea3bc is in libUE4.so offset: 0x91f73bc
799f82e760 is in libUE4.so offset: 0x8d3b760
799f6f98f0 is in libUE4.so offset: 0x8c068f0
799d93f614 is in libUE4.so offset: 0x6e4c614
799c5ee728 is in libUE4.so offset: 0x5afb728
799c5e83bc is in libUE4.so offset: 0x5af53bc
799c5e6514 is in libUE4.so offset: 0x5af3514
[hook_aimbot]
79a03df660 is in libUE4.so offset: 0x98ec660
799fcf0b8c is in libUE4.so offset: 0x91fdb8c
799d2c1bb0 is in libUE4.so offset: 0x67cebb0
799d2c1730 is in libUE4.so offset: 0x67ce730
799d2c0e24 is in libUE4.so offset: 0x67cde24
799fcecc04 is in libUE4.so offset: 0x91f9c04
799fcea3bc is in libUE4.so offset: 0x91f73bc
799f82e760 is in libUE4.so offset: 0x8d3b760
799f6f98f0 is in libUE4.so offset: 0x8c068f0
799d93f614 is in libUE4.so offset: 0x6e4c614
799c5ee728 is in libUE4.so offset: 0x5afb728
799c5e83bc is in libUE4.so offset: 0x5af53bc
799c5e6514 is in libUE4.so offset: 0x5af3514

點擊後,多了一個不同的調用棧0x670f3fc

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[hook_aimbot]
799d2f83fc is in libUE4.so offset: 0x670f3fc
7a136f0c94 is in libart.so offset: 0x2e6c94
799d2f8eb0 is in libUE4.so offset: 0x670feb0
799fe51e38 is in libUE4.so offset: 0x9268e38
799fe4fe04 is in libUE4.so offset: 0x9266e04
799fb8958c is in libUE4.so offset: 0x8fa058c
799fb886f4 is in libUE4.so offset: 0x8f9f6f4
799d3b9420 is in libUE4.so offset: 0x67d0420
799fb88374 is in libUE4.so offset: 0x8f9f374
799f49b7bc is in libUE4.so offset: 0x88b27bc
799fb90358 is in libUE4.so offset: 0x8fa7358
799fde6b8c is in libUE4.so offset: 0x91fdb8c
799d3b7bb0 is in libUE4.so offset: 0x67cebb0
799d3b7730 is in libUE4.so offset: 0x67ce730
799d3b6e24 is in libUE4.so offset: 0x67cde24
799fde2c04 is in libUE4.so offset: 0x91f9c04

嘗試patch掉0x670f3fc所在函數0x670F110,雖然點擊後不會再自動瞄到某處,但子彈射不出。

由此猜測0x670F110是射擊的回調函數,自瞄邏輯應該就在裡面。

0x670F110process_before_shoot

1
2
3
Interceptor.replace(base.add(0x670F110), new NativeCallback(() => {
return 1;
}, "int", []))

0x670F110中從調用mb_aimbot處向上分析,發現是否調用mb_aimbot邏輯是由sub_680B790(v32, "E")決定的。

image.png

hook sub_680B790,打印參數和返回值。

注:hexdump後可知是unicode編碼的字符串,因此要用readUtf16String

1
2
3
4
5
6
7
8
9
10
11
12
13
function hook_680B790() {
Interceptor.attach(base.add(0x680B790), {
onEnter: function(args) {
this.a1 = args[1];
console.log("a0: ", args[0].readUtf16String());
console.log("a1: ", args[1].readUtf16String());
},
onLeave: function(retval) {
console.log("res: ", retval);

}
})
}

輸出如下,可以看出是字符串對比函數,resa0a1第1個不相等字符的差值,若相等則為0( 不區分大小寫 )。記sub_680B790utf16_cmp

可以看到前面一直在和EditorCube8對比,明顯它就是自瞄的目標,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
a0:  BigWall
a1: EditorCube8
res: 0xfffffffd
a0: BigWall2
a1: EditorCube8
res: 0xfffffffd
a0: EditorCube10
a1: EditorCube8
res: 0xfffffff9
a0: EditorCube11
a1: EditorCube8
res: 0xfffffff9
a0: EditorCube12
a1: EditorCube8
res: 0xfffffff9
a0: EditorCube13
a1: EditorCube8
res: 0xfffffff9
a0: EditorCube14
a1: EditorCube8
res: 0xfffffff9
a0: EditorCube15
a1: EditorCube8
res: 0xfffffff9
a0: EditorCube16
a1: EditorCube8
res: 0xfffffff9
a0: EditorCube17
a1: EditorCube8
res: 0xfffffff9
a0: EditorCube18
a1: EditorCube8
res: 0xfffffff9
a0: EditorCube19
a1: EditorCube8
res: 0xfffffff9
a0: EditorCube20
a1: EditorCube8
res: 0xfffffffa
a0: EditorCube21
a1: EditorCube8
res: 0xfffffffa
a0: EditorCube8
a1: EditorCube8
res: 0x0
a0: EditorCube9
a1: EditorCube8
res: 0x1
a0: Floor_12
a1: EditorCube8
res: 0x1
a0: Wall1
a1: EditorCube8
res: 0x12
a0: Wall2_11
a1: EditorCube8
res: 0x12
a0: Wall3
a1: EditorCube8
res: 0x12
a0: Wall4
a1: EditorCube8
res: 0x12
a0: ../../../MyProject/Saved/Config/Android/Engine.ini
a1: ../../../MyProject/Saved/Config/Android/Engine.ini
res: 0x0
a0: true
a1: True
res: 0x0
a0: Android
a1: Android
res: 0x0
a0: Android
a1: Android
res: 0x0
a0: Android
a1: Android
res: 0x0

嘗試在a1EditorCube8時將返回值固定replace為一個大於0的值。

1
2
3
4
5
6
7
8
9
10
Interceptor.attach(base.add(0x680B790), {
onEnter: function(args) {
this.a1 = args[1];
},
onLeave: function(retval) {
if (this.a1.readUtf16String() == "EditorCube8") {
retval.replace(5);
}
}
})

結果是射擊時不再自動瞄到指定目標,但手槍在射完後會向上抬一下,類似後座力?不知是否屬於異常點。

下面簡單看看它的自瞄實現原理:

process_before_shoot開始看,一開始先遍歷自瞄目標。

image.png

然後調用calcTargetOffset計算自瞄值,然後根據這個值來設置CameraRotation( 人物相機的轉向,使它朝向目標以實現自瞄的效果 )。

image.png

calcTargetOffset實現大概像這樣:利用目標location與人物的location向量來計算。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
function calcTargetOffset(targetLoc, cameraLoc) {
let x = targetLoc.x - cameraLoc.x;
let y = targetLoc.y - cameraLoc.y;
let z = targetLoc.z - cameraLoc.z;

let angleX = 0;
let angleY = 0;

if (x > 0 && y == 0) angleX = 0;
if (x > 0 && y > 0) angleX = Math.abs(Math.atan(y / x)) / Math.PI * 180;

if (x == 0 && y > 0) angleX = 90;
if (x < 0 && y > 0) angleX = 90 + Math.abs(Math.atan(x / y)) / Math.PI * 180;

if (x < 0 && y == 0) angleX = 180;
if (x < 0 && y < 0) angleX = 180 + Math.abs(Math.atan(y / x)) / Math.PI * 180;

if (x == 0 && y < 0) angleX = 270;
if (x > 0 && y < 0) angleX = 270 + Math.abs(Math.atan(x / y)) / Math.PI * 180;

if (angleX < 0) {
angleX += 360;
}

if (angleX > 360) {
angleX -= 360;
}

angleY = Math.atan(z / Math.sqrt(x * x + y * y)) / Math.PI * 180;

if (angleY < 0) {
angleY += 360;
}

return [angleY, angleX, 0]

}

子彈發射位置異常

可以明顯看出子彈發射的起始位置是隨機的。

猜測可能與MyProjectCharacterGunOffset有關。

1
2
3
Class: MyProjectCharacter.Character.Pawn.Actor.Object
// ...
Vector GunOffset;//[Offset: 0x500, Size: 0xc]

GunOffset下硬斷( 讀 )。

image.png

命中如下兩處地址:

1
2
3
// libUE4 base: 6f6c74e000
1. PC: 0x6F7307EA6C (0x6930A6C) LR: 0x6F7307EA68
2. PC: 0x6F7307EA7C (0x6930A7C) LR: 0x6F7307EA68

0x6930A6C所在函數是sub_6930A3C

image.png

hook sub_6930A3C打印調用棧。

其中0x670f658位於0x670F110函數( 即process_before_shoot )。

1
2
3
4
5
6
7
6f72e75e24 is in libUE4.so offset: 0x670fe24
6f72e75658 is in libUE4.so offset: 0x670f658 (位於0x670F110)
6f72e75eb0 is in libUE4.so offset: 0x670feb0
6f72e75eb0 is in libUE4.so offset: 0x670feb0
6f759cee38 is in libUE4.so offset: 0x9268e38
6f759cce04 is in libUE4.so offset: 0x9266e04
6fe951f09c is in libart.so offset: 0x59b09c

process_before_shoot中有調用rand()生成隨機值,猜測這與槍口的隨機有關。

image.png

嘗試hook rand固定其返回值。

1
2
3
4
5
6
7
8
function hook_rand() {
Interceptor.attach(Module.findExportByName(null, "rand"), {
onLeave: function(retval) {
retval.replace(100);
console.log("[rand] res: ", retval);
}
})
}

結果是子彈發射位置固定了,但是固定在了人物的頭上偏左的位置。

繼續嘗試其他修復思路。

process_before_shoot中的a1[0x14A] & 1不為0,目的是讓執行流無法走到上述的0x6930A6C位置。

image.png

1
2
3
4
5
6
7
8
9
10
11
12
13
function hook_process_before_shoot() {
Interceptor.attach(base.add(0x670F110), {
onEnter: function(args) {
let val = args[0].add(0x528).readU8();
args[0].add(0x528).writeU8(val | 1);
console.log("[process_before_shoot] a1[0x14A] & 1: ", args[0].add(0x528).readU8() & 1)
},
onLeave: function(retval) {


}
})
}

結果同樣可以固定子彈發射的位置,但這次是在人物的下方固定向正前方發射,上下抬頭時不會改變發射方向。

以下部份是賽後的分析。

a1[0x14A] & 10,會調用about_bullt_loc1生成一些隨機值,調用about_bullt_loc2生成一個基於GunOffset等參數而來的值,最終傳入mb_process_bullet_shoot_loc做最後的處理。

image.png

而當a1[0x14A] & 11時,則會從VR_MuzzleLocation裡獲取一些參數,最終同樣傳入mb_process_bullet_shoot_loc

注:通過hook所在函數,將*((QWORD*)a1 + 155)當成UObject來打印它的名字,從而確定它是VR_MuzzleLocation對象。

image.png

image.png

VR_MuzzleLocation看名字來說是給VR設備使用的,安卓設備感覺是使用FP_MuzzleLocation才對。

因此合理懷疑這也是一個異常點。

1
2
3
4
5
6
Class: MyProjectCharacter.Character.Pawn.Actor.Object
SkeletalMeshComponent* Mesh1P;//[Offset: 0x4b8, Size: 0x8]
SkeletalMeshComponent* FP_Gun;//[Offset: 0x4c0, Size: 0x8]
SceneComponent* FP_MuzzleLocation;//[Offset: 0x4c8, Size: 0x8] // 槍口位置
SkeletalMeshComponent* VR_Gun;//[Offset: 0x4d0, Size: 0x8]
SceneComponent* VR_MuzzleLocation;//[Offset: 0x4d8, Size: 0x8]

hook process_before_shoot,從IDA裡可知args[0].add(0x4D8)保存著VR_MuzzleLocation對象的地址,嘗試將它改為FP_MuzzleLocation的地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function hook_test() {

Interceptor.attach(base.add(0x670F110), {
onEnter: function(args) {
console.log("[process_before_shoot]");
let VR_MuzzleLocation = args[0].add(0x4D8)
VR_MuzzleLocation.writePointer(All_Objects["MuzzleLocation"])
printName(VR_MuzzleLocation.readPointer());

},
onLeave: function(retval) {


}
})
}

結果是子彈終於會隨著槍口的變化而變化,但卻是從槍口往左發射的,正常應該是往前才對。

接下來嘗試修改FP_MuzzleLocation裡的一些參數,看看能否改變發射方向。

FP_MuzzleLocation屬於USceneComponent類,其中有以下這兩個屬性:

1
2
3
4
Class: SceneComponent.ActorComponent.Object
//...
Vector RelativeLocation;//[Offset: 0x11c, Size: 0xc]
Rotator RelativeRotation;//[Offset: 0x128, Size: 0xc]

利用K2_SetRelativeLocationK2_SetRelativeRotation函數來修改。通過不斷嘗試,發現只需要將Rotation設置為[0, 90, 0]即可,相當於旋轉了90度。完整修複代碼如下:

注:不能直接通過內存來修改,要用API來修改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
function fix_MuzzleLocation() {
Interceptor.attach(base.add(0x670F110), {
onEnter: function(args) {
console.log("[process_before_shoot]");
/* API */
let K2_SetRelativeLocation = new NativeFunction(base.add(0x8AE6D70), "void", ["pointer", "float", "float", "float", "bool", "pointer", "bool"]);
let K2_SetRelativeRotation = new NativeFunction(base.add(0x8AE6F00), "void", ["pointer", "float", "float", "float", "bool", "pointer", "bool"]);

// K2_SetRelativeLocation(All_Objects["MuzzleLocation"], 0, 0, 0, 0, ptr(0), 0);
K2_SetRelativeRotation(All_Objects["MuzzleLocation"], 0, 90, 0, 0, ptr(0), 0);

/* Prop */
let RelativeLocation = All_Objects["MuzzleLocation"].add(0x11c);
let RelativeRotation = All_Objects["MuzzleLocation"].add(0x128);

console.log("location: ", JSON.stringify(readVector(RelativeLocation)))
console.log("rotation: ", JSON.stringify(readVector(RelativeRotation)))

// Replace
let MuzzleLocation = args[0].add(0x4D8)
MuzzleLocation.writePointer(All_Objects["MuzzleLocation"])

},
onLeave: function(retval) {
}
})
}

透視分析

Objects.txt裡可以看到FirstPersonCharacter_CThirdPersonCharacter_C,它們分別是我控制的人物和假人。

1
2
3
4
5
6
7
8
9
10
11
[0x3c05]:
Name: FirstPersonCharacter_C
Class: FirstPersonCharacter_C
ObjectPtr: 0x71694840c0
ClassPtr: 0x7169582700

[0x3c53]:
Name: ThirdPersonCharacter
Class: ThirdPersonCharacter_C
ObjectPtr: 0x7168662630
ClassPtr: 0x716958c100

嘗試一:替換Material。( 沒效果 )

1
2
3
4
5
6
7
8
9
10
11
12
let ThirdPersonCharacter_Mesh = ThirdPersonCharacter_obj.add(Offset.ACharacterToUSkeletalMeshComponent).readPointer();

let SetMaterial = new NativeFunction(ThirdPersonCharacter_Mesh.readPointer().add(0x598).readPointer(), "void", ["pointer", "int", "pointer"]);

SetMaterial(ThirdPersonCharacter_Mesh, 0, All_Objects["BaseMaterial"]);
// SetMaterial(ThirdPersonCharacter_Mesh, 0, All_Objects["DefaultTextMaterialOpaque"]);
// SetMaterial(ThirdPersonCharacter_Mesh, 0, All_Objects["m_SimpleVolumetricCloud"]);
// SetMaterial(ThirdPersonCharacter_Mesh, 0, All_Objects["DefaultSpriteMaterial"]);
// SetMaterial(ThirdPersonCharacter_Mesh, 0, All_Objects["PokeAHoleMaterial"]);
// SetMaterial(ThirdPersonCharacter_Mesh, 0, All_Objects["OculusMR_ChromaKey"]);
// SetMaterial(ThirdPersonCharacter_Mesh, 0, All_Objects["DebugMeshMaterial"]);
// SetMaterial(ThirdPersonCharacter_Mesh, 0, All_Objects["EmissiveMeshMaterial"]);

嘗試二:設置bDisableDepthTest0。( 沒效果 )

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let ThirdPersonCharacter_Mesh = ThirdPersonCharacter_obj.add(Offset.ACharacterToUSkeletalMeshComponent).readPointer();

let GetNumMaterials = new NativeFunction(ThirdPersonCharacter_Mesh.readPointer().add(0x6e8).readPointer(), "int", ["pointer"]);
let GetMaterial = new NativeFunction(ThirdPersonCharacter_Mesh.readPointer().add(0x590).readPointer(), "pointer", ["pointer", "int"]);

let NumMaterials = GetNumMaterials(ThirdPersonCharacter_Mesh)
console.log("NumMaterials: ", NumMaterials);
for(let i = 0; i < NumMaterials; i++) {
let material = GetMaterial(ThirdPersonCharacter_Mesh, i)
console.log(`material[${i}]: `, material, getName64(material.add(Offset.UObjectToFNameIndex).readU32()))
let bDisableDepthTest = material.add(0x1f8);
bDisableDepthTest.writeU8(bDisableDepthTest.readU8() & (~1));
console.log("bDisableDepthTest: ", bDisableDepthTest.readU8() & 1)
}

嘗試三:利用SetTexture隨便設置一個Texture。( 沒效果 )

1
2
3
4
5
6
7
8
9
10
11
12
13
let ThirdPersonCharacter_Mesh = ThirdPersonCharacter_obj.add(Offset.ACharacterToUSkeletalMeshComponent).readPointer();

/* API */
// void SetTexture(Texture* InTexture);// 0x95efb5c
let SetTexture = new NativeFunction(base.add(0x8B2C4CC), "void", ["pointer", "pointer"]);
// Texture* GetTexture();// 0x95efa98
let GetTexture = new NativeFunction(ThirdPersonCharacter_Mesh.readPointer().add(0x1F8).readPointer(), "pointer", ["pointer"]);

/* Prop */
let ThirdPersonCharacter_Mesh = ThirdPersonCharacter_Mesh.add(Offset.USkeletalMeshComponentToSkeletalMesh).readPointer();

SetTexture(ThirdPersonCharacter_Mesh, All_Objects["T_ML_Rubber_Blue_01_N"]);

以下部份是賽後的分析。

找資料時發現類似這遊戲的人物透視效果基本上有以下兩種實現思路:

  1. 通過Disable Depth Test ( 參考 )。
  2. 通過Custom Depth ( 參考 )。

但嘗試後發現遊戲似乎不是用上述方法實現透視效果的( 不太確定,也有可能是我修改的地方不對 )?

找了很久都沒有什麼思路,最終只好退而求其次,用一種「掩耳盜鈴」的方式來修復,具體思路如下:

  • 調用KismetSystemLibrary的靜態函數LineTraceSingle來獲取FirstPersonCharacter_CThirdPersonCharacter_C之間的HitResult
  • 分析HitResult,會發現ThirdPersonCharacter_C沒有被遮擋時HitResult.Distance0,否則為二者之間的距離。
  • 因此可以根據HitResult.Distance是否為0來設置ThirdPersonCharacter_C是否渲染到MainPass。

具體調用LineTraceSingle、獲取HitResult.Distance的代碼如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function getFirstPersonThirdPersonDistance() {
// static bool LineTraceSingle(const Object* WorldContextObject, const Vector Start, const Vector End, byte TraceChannel, bool bTraceComplex, out const Actor*[] ActorsToIgnore, byte DrawDebugType, out HitResult OutHit, bool bIgnoreSelf, LinearColor TraceColor, LinearColor TraceHitColor, float DrawTime);// 0x9471770
let LineTraceSingle = new NativeFunction(base.add(0x8D1AA78), "bool", ["pointer", "float", "float", "float", "float", "float", "float", "uint8", "bool", "pointer", "uint8", "pointer", "bool", "float", "float", "float", "float", "float", "float", "float", "float", "float"]);

let buf1 = Memory.alloc(0x1000);
let HitResult = Memory.alloc(0x1000);

let start_loc = getActorLocation(FirstPersonCharacter_C_obj);
let end_loc = getActorLocation(ThirdPersonCharacter_obj);

let r = LineTraceSingle(FirstPersonCharacter_C_obj, start_loc[0], start_loc[1], start_loc[2], end_loc[0], end_loc[1], end_loc[2], 0, 0, buf1, 1, HitResult, 0, 255, 0, 0, 0, 0, 255, 0, 0, 10000);

let HitResult_Distance = HitResult.add(0x8).readFloat();

return HitResult_Distance;
}

時機選擇在Actor類的ReceiveTick函數,在其中判斷是否渲染:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function bypassWallhack() {
let SetRenderInMainPass = new NativeFunction(base.add(0x8AB9E58), "void", ["pointer", "bool"]);
let ThirdPersonCharacter_Mesh = ThirdPersonCharacter_obj.add(Offset.ACharacterToUSkeletalMeshComponent).readPointer();

// void ReceiveTick(float DeltaSeconds);// 0x6c50500
Interceptor.attach(base.add(0x6c50500), {
onEnter: function(args) {
if (getFirstPersonThirdPersonDistance() == 0) {
console.log("can see ThirdPerson")
SetRenderInMainPass(ThirdPersonCharacter_Mesh, 1);

} else {
console.log("can not see ThirdPerson")
SetRenderInMainPass(ThirdPersonCharacter_Mesh, 0);

}
}
})
}

當然這肯定不是正解,而且效果也非常一般,之後看看有沒有其他大佬分析下正解吧。

白色的Cube碰撞異常

子彈射在白色的Cube上不會反彈。

白色的Cube應是就是EditorCubeN

1
2
3
4
5
6
7
8
9
10
11
12
[0x3c3e]:
Name: EditorCube8
Class: StaticMeshActor
ObjectPtr: 0x7170c09f40
ClassPtr: 0x717e7c0100

[0x3c26]:
Name: EditorCube10
Class: StaticMeshActor
ObjectPtr: 0x7170c0ba40
ClassPtr: 0x717e7c0100

子彈射在EditorCubeN上會瞬間消失,但EditorCubeN是有被擊退的效果,而子彈射在黑色的Cube上能正常被反彈。

嘗試一:看看是否因為物理模擬未啟用。( 沒效果 )

1
2
3
4
5
6
7
let StaticMeshComponent = All_Objects["EditorCube8"].add(0x220).readPointer();

let SetSimulatePhysics = new NativeFunction(StaticMeshComponent.readPointer().add(0x5D0).readPointer(), "void", ["pointer", "bool"]);
let SetNotifyRigidBodyCollision = new NativeFunction(StaticMeshComponent.readPointer().add(0x658).readPointer(), "void", ["pointer", "bool"]);

SetSimulatePhysics(StaticMeshComponent, 1);
SetNotifyRigidBodyCollision(StaticMeshComponent, 1)

嘗試二:設置物理材質的反彈系數為1。( 沒效果 )

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let StaticMeshComponent = All_Objects["EditorCube8"].add(0x220).readPointer();

/* API */
let GetPhysicalMaterial = new NativeFunction(StaticMeshComponent.readPointer().add(0x2D0).readPointer(), "pointer", ["pointer"]);
let GetNumMaterials = new NativeFunction(StaticMeshComponent.readPointer().add(0x6e8).readPointer(), "int", ["pointer"]);
let GetMaterial = new NativeFunction(StaticMeshComponent.readPointer().add(0x590).readPointer(), "pointer", ["pointer", "int"]);

/* Prop */
let NumMaterials = GetNumMaterials(StaticMeshComponent)
console.log("NumMaterials: ", NumMaterials);
for(let i = 0; i < NumMaterials; i++) {
let material = GetMaterial(StaticMeshComponent, i)
let PhysicalMaterial = GetPhysicalMaterial(material);
let Restitution = PhysicalMaterial.add(0x34);
Restitution.writeFloat(1)

}

嘗試三:設置與所有物體的碰撞響應都為Block( 具體值是2 )。( 沒效果 )

1
2
3
4
5
let StaticMeshComponent = All_Objects["EditorCube8"].add(0x220).readPointer();

let SetCollisionResponseToAllChannels = new NativeFunction(StaticMeshComponent.readPointer().add(0x850).readPointer(), "void", ["pointer", "uint8"]);

SetCollisionResponseToAllChannels(StaticMeshComponent, 2); // 設置為0,1,3後, Cube會掉到地底

經過上述嘗試,可知子彈消失大概率與Collision無關。

猜測子彈是在擊中EditorCubeN時執行了一段Destroy邏輯。

hook MyProjectProjectileOnHit,打印調用棧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[onHit] enter:
6f70d20aac is in libUE4.so offset: 0x6713aac
6f71110b7c is in libUE4.so offset: 0x6b03b7c
6f7125e9ac is in libUE4.so offset: 0x6c519ac
6f7125e7d0 is in libUE4.so offset: 0x6c517d0
6f70139f24 is in libUE4.so offset: 0x5b2cf24 // 1
6f72eb6a8c is in libUE4.so offset: 0x88a9a8c // 1
6f7356ff04 is in libUE4.so offset: 0x8f62f04 // 0
6f72eb6bc4 is in libUE4.so offset: 0x88a9bc4
6f730c087c is in libUE4.so offset: 0x8ab387c
6f738ee130 is in libUE4.so offset: 0x92e1130
6f71110b7c is in libUE4.so offset: 0x6b03b7c
6f73907350 is in libUE4.so offset: 0x92fa350
6f71110b7c is in libUE4.so offset: 0x6b03b7c
6f71262774 is in libUE4.so offset: 0x6c55774
6f712629f0 is in libUE4.so offset: 0x6c559f0
6f7125d7ec is in libUE4.so offset: 0x6c507ec
[onHit] leave: this.HitComp 0x6f74e76428

Destroy邏輯可能就在其中,但沒時間看了。。

以下部份是賽後的分析。

OnHit最後調會return sub_88A8D2C((__int64)v4, 0, 1),而sub_88A8D2C函數如下。

about_ActorDestroying中有"ActorDestroying"字符串,猜測會不會就是那段Destroy邏輯。

image.png

嘗試直接patch掉該函數,使其固定返回1

1
2
3
4
Interceptor.replace(base.add(0x88A8D2C), new NativeCallback(() => {
console.log("call 88A8D2C");
return 1;
}, "int", []));

結果是射到EditorCubeN時也會正常反彈,成功修復該異常點。

其他異常

在測試過程中還發現以下一些不確定算不算異常點的:

  • 子彈要射到角色的腳底才會與角色發生碰撞,射在其他位置會直接穿過。
  • 有時候射著射著人物就飛高高了。

完整代碼

frida -U -f com.ACE2025.Game -l final.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288

let GWorld = null;
let GName = null;
let GObject = null;

let Offset = {
//Class: UWorld
UWorldToPersistentLevel: 0x58,

// Class: ULevel
ULevelToActors: 0xa0,

// Class: FNamePool
GNamesToFNamePool: 0x38,
FNamePoolToCurrentBlock: 0x0,

//Class: UObject
UObjectToClassPrivate: 0x10,
UObjectToFNameIndex: 0x18,
UObjectToOuterPrivate: 0x20,

//Class: FUObjectArray
FUObjectArrayToTUObjectArray: 0x10,

//Class: TUObjectArray
TUObjectArrayToNumElements: 0x14,

// Global
FUObjectItemPadd: 0x0,
FUObjectItemSize: 0x18,

// Class: AActor
AActorToRootComponent: 0x130,

// Class: USceneComponent
USceneComponentToRelativeLocation: 0x11c,

// Class: ACharacter
ACharacterToUSkeletalMeshComponent: 0x280,

// Class: USkeletalMeshComponent
USkeletalMeshComponentToSkeletalMesh: 0x478,
USkeletalMeshComponentToUMaterialInterface: 0x448,

// class: USkeletalMesh
USkeletalMeshToFSkeletalMaterial: 0xd8
}

function startUE4(base) {
console.log("UE4.base: ", base);

/* Utils area */
// 設置三件套
function setupUE4() {
GWorld = base.add(0xAFAC398).readPointer();
GName = base.add(0xADF07C0);
GObject = base.add(0xAE34A98);
}


function getName64(idx) {
var ComparisonIndex = idx;

var FNameEntryAllocator = GName.add(0x38); // 64位的FNameEntryAllocator偏移為0x38

var FNameBlockOffsetBits = 16
var FNameBlockOffsets = 65536

var Block = ComparisonIndex >> FNameBlockOffsetBits
var Offset = ComparisonIndex & (FNameBlockOffsets - 1)

var Blocks_Offset = 0x8
var Blocks = FNameEntryAllocator.add(Blocks_Offset)

var FNameEntry = Blocks.add(Block * Process.pointerSize).readPointer().add(Offset * 2)

var FNameEntryHeader = FNameEntry.readU16()

// console.log("FNameEntry: ", hexdump(FNameEntry));

var isWide = FNameEntryHeader & 1
var Len = FNameEntryHeader >> 6

// if (0 == isWide) {
// console.log(`\x1b[32m[+] ${FNameEntry.add(2).readCString(Len)}\x1b[0m`)
// }
return FNameEntry.add(2).readCString(Len);
}

let ThirdPersonCharacter_obj = null;
let FirstPersonCharacter_C_obj = null;

let All_Objects = {}

// 遍歷UObjectArray
function travUObjectArray() {
let TUObjectArray = GObject.add(Offset.FUObjectArrayToTUObjectArray);
let Objects = TUObjectArray.readPointer().readPointer();

for(let i = 0;; i++) {
try {
let objectItem = Objects.add(i * Offset.FUObjectItemSize).add(Offset.FUObjectItemPadd);
let obj = objectItem.readPointer();
let objNameIdx = obj.add(Offset.UObjectToFNameIndex).readU32();
let objName = getName64(objNameIdx);

All_Objects[objName] = obj;
if (objName == "ThirdPersonCharacter") {
ThirdPersonCharacter_obj = obj;
// console.log("ThirdPersonCharacter_obj: ", ptr(obj));
}

if (objName == "FirstPersonCharacter_C") {
FirstPersonCharacter_C_obj = obj;
// console.log("FirstPersonCharacter_C_obj: ", ptr(obj));
}



} catch (error) {
console.log(error);
break
}
}

}

function getActorLocation(actor) {
let RootComponent = ptr(actor).add(Offset.AActorToRootComponent).readPointer();
let RelativeLocation = RootComponent.add(Offset.USceneComponentToRelativeLocation);
let x = RelativeLocation.add(0 * 4).readFloat()
let y = RelativeLocation.add(1 * 4).readFloat()
let z = RelativeLocation.add(2 * 4).readFloat()

return [x, y, z]
}

function getFirstPersonThirdPersonDistance() {
// static bool LineTraceSingle(const Object* WorldContextObject, const Vector Start, const Vector End, byte TraceChannel, bool bTraceComplex, out const Actor*[] ActorsToIgnore, byte DrawDebugType, out HitResult OutHit, bool bIgnoreSelf, LinearColor TraceColor, LinearColor TraceHitColor, float DrawTime);// 0x9471770
let LineTraceSingle = new NativeFunction(base.add(0x8D1AA78), "bool", ["pointer", "float", "float", "float", "float", "float", "float", "uint8", "bool", "pointer", "uint8", "pointer", "bool", "float", "float", "float", "float", "float", "float", "float", "float", "float"]);

let buf1 = Memory.alloc(0x1000);
let HitResult = Memory.alloc(0x1000);

let start_loc = getActorLocation(FirstPersonCharacter_C_obj);
let end_loc = getActorLocation(ThirdPersonCharacter_obj);

let r = LineTraceSingle(FirstPersonCharacter_C_obj, start_loc[0], start_loc[1], start_loc[2], end_loc[0], end_loc[1], end_loc[2], 0, 0, buf1, 1, HitResult, 0, 255, 0, 0, 0, 0, 255, 0, 0, 10000);
let HitResult_Distance = HitResult.add(0x8).readFloat();

return HitResult_Distance;
}

function hook_utf16_cmp() {
Interceptor.attach(base.add(0x680B790), {
onEnter: function(args) {
this.a1 = args[1];
// console.log("a0: ", args[0].readUtf16String());
// console.log("a1: ", args[1].readUtf16String());
},
onLeave: function(retval) {
if (this.a1.readUtf16String() == "EditorCube8") {
retval.replace(5);
}
// console.log("res: ", retval);

}
})
}

function hook_process_before_shoot() {
Interceptor.attach(base.add(0x670F110), {
onEnter: function(args) {
let val = args[0].add(0x528).readU8();
args[0].add(0x528).writeU8(val | 1);
console.log("[process_before_shoot] a1[0x14A] & 1: ", args[0].add(0x528).readU8() & 1)
},
onLeave: function(retval) {


}
})
}

function fix_MuzzleLocation() {

Interceptor.attach(base.add(0x670F110), {
onEnter: function(args) {
console.log("[process_before_shoot]");

/* API */
// void K2_SetRelativeRotation(Rotator NewRotation, bool bSweep, out HitResult SweepHitResult, bool bTeleport);// 0x9597380
let K2_SetRelativeRotation = new NativeFunction(base.add(0x8AE6F00), "void", ["pointer", "float", "float", "float", "bool", "pointer", "bool"]);

K2_SetRelativeRotation(All_Objects["MuzzleLocation"], 0, 90, 0, 0, ptr(0), 0);

/* Prop */
let RelativeLocation = All_Objects["MuzzleLocation"].add(0x11c);
let RelativeRotation = All_Objects["MuzzleLocation"].add(0x128);

// console.log("location: ", JSON.stringify(readVector(RelativeLocation)))
// console.log("rotation: ", JSON.stringify(readVector(RelativeRotation)))

// Replace
let MuzzleLocation = args[0].add(0x4D8)
MuzzleLocation.writePointer(All_Objects["MuzzleLocation"])

},
onLeave: function(retval) {


}
})
}

function fix_EditorCubeN_bullet_problem() {
Interceptor.replace(base.add(0x88A8D2C), new NativeCallback(() => {
return 1;
}, "int", []));
}

function bypassWallhack() {
let SetRenderInMainPass = new NativeFunction(base.add(0x8AB9E58), "void", ["pointer", "bool"]);
let ThirdPersonCharacter_Mesh = ThirdPersonCharacter_obj.add(Offset.ACharacterToUSkeletalMeshComponent).readPointer();

Interceptor.attach(base.add(0x6c50500), {
onEnter: function(args) {
if (getFirstPersonThirdPersonDistance() == 0) {
console.log("can see ThirdPerson")
SetRenderInMainPass(ThirdPersonCharacter_Mesh, 1);

} else {
console.log("can not see ThirdPerson")
SetRenderInMainPass(ThirdPersonCharacter_Mesh, 0);

}
}
})
}

/* call area */
setupUE4();
travUObjectArray();

hook_utf16_cmp(); // bypass: aimbot
hook_process_before_shoot(); // bypass: rand bullet shoot location (fixed )
fix_EditorCubeN_bullet_problem(); // bypass: EditorCubeN problem
fix_MuzzleLocation(); // bypass: fix and replace the right MuzzleLocation
bypassWallhack(); // bypass: fix wallhack in a different way

}
function hook_pthread() {
var pthread_create_addr = Module.findExportByName(null, 'pthread_create');
console.log("pthread_create_addr,", pthread_create_addr);

var pthread_create = new NativeFunction(pthread_create_addr, "int", ["pointer", "pointer", "pointer", "pointer"]);

Interceptor.replace(pthread_create_addr, new NativeCallback(function (parg0, parg1, parg2, parg3) {
var so_name = Process.findModuleByAddress(parg2).name;
var so_path = Process.findModuleByAddress(parg2).path;
var so_base = Module.getBaseAddress(so_name);
var offset = parg2 - so_base;
// console.log("so_name", so_name, "offset", offset, "path", so_path, "parg2", parg2);
var PC = 0;
if ((so_name.indexOf("libGame.so") > -1)) {
console.log("find thread func offset", so_name, offset);
if ((7068 === offset)) {
console.log("anti bypass");
} else {
PC = pthread_create(parg0, parg1, parg2, parg3);
console.log("ordinary sequence", PC)
}
} else {
PC = pthread_create(parg0, parg1, parg2, parg3);
// console.log("ordinary sequence", PC)
}
return PC;
}, "int", ["pointer", "pointer", "pointer", "pointer"]))

}
function main() {

hook_pthread();
setTimeout(() => {
startUE4(Module.findBaseAddress("libUE4.so"));
}, 3500);
}
setImmediate(main)

結語

今年跟上年一樣是UE4的題型,猜到了會出UE4,賽前本想找些遊戲來練練手,但一直沒找到合適的,只能說可惜了。這也導致了比賽前2天基本都在熟悉UE4,直到最後也沒有完整地修復幾個異常點。

本以為決賽無望的,沒想到運氣挺好居然進了,算是圓了上一年的遺憾吧。