前言

很少接觸UE4逆向,借這個比賽的題目來學習下UE4逆向和vm虛擬機。

主要參考這兩篇文章來復現:

Dump SDK

UE4逆向的第一步基本都要先Dump SDK,否則根本無從下手。

UE4三件套

按常規方法靜態定位三件套。

GName:0x4E2EC00

image.png

GWorld:0x4F5C0D0

image.png

GObject:0x4E533AC

image.png

嘗試用ue4dumper來dump sdk,但失敗了。失敗的原因很有可能是GWorld的結構被魔改了。

image.png

用frida驗證下GNmae字符串算法是否被魔改。

根據UE4.27源碼的FNmae::ToString()函數,可以得出32位程序的字符串算法如下。

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
function getName32(GName, idx) {
var ComparisonIndex = idx;
var FNameEntryAllocator = GName.add(0x28); // 32位的FNameEntryAllocator偏移為0x28

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

// console.log("len: ", Len)
// console.log(hexdump(FNameEntry))

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

// base == libUE4.so base
getName32(base.add(0x4E2EC00), 0)
getName32(base.add(0x4E2EC00), 3)

打印出前2個字符串如下,符合預期,代表GName地址是正確的且字符串算法大概率沒有被魔改。

image.png

結構修復

通過CE觀察內存的方式來修復結構。

總結,要修改的offset如下:

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
// jni\Offsets.h

// add to: patchCustom_32()
if(isTencentGamematch2024final()) {
//Class: UObject
// UObjectToInternalIndex = 0x8;
UObjectToClassPrivate = 0x14;
UObjectToFNameIndex = 0x18;
UObjectToOuterPrivate = 0x20;

//Class: UStruct
UStructToSuperStruct = 0x40;

UStructToChildren = 0x6c;
UStructToChildProperties = 0x44;


//Class: UWorld
UWorldToPersistentLevel = 0x58;

//Class: ULevel
ULevelToAActors = 0x9C;
ULevelToAActorsCount = 0xA0;

//Class: UFunction
UFunctionToFunc = 0xA4;

//Class: UField
UFieldToNext = 0x2C;

//Class: FUObjectArray
// FUObjectArrayToTUObjectArray = 0x10;

//Class: TUObjectArray
TUObjectArrayToNumElements = 0xC;

// Global
FUObjectItemPadd = 0x4;
FUObjectItemSize = 0x14;
}

重新編譯修改Offset後的ue4dumper,再次嘗試dump sdk,這次成功了。

1
./ue4dumper_ext --sdku --newue+ --gname 0x4E2EC00 --guobj 0x4E533AC --package com.tencent.ace.gamematch2024final

image.png

基礎保護

簡單看看程序的一些保護,詳細分析可看:https://bbs.kanxue.com/thread-281464-1.htm#msg_header_h1_0

hook svc

hook openat系統調用:

image.png

hook access系統調用:檢測了root、magisk、riru、sandhook、frida等

image.png

除此之外還有CRC校驗代碼段,frida hook會改變代碼段,從而被檢測到。

bypass思路1

嘗試hook pthread_create,但檢測代碼貌似不在這。

查了查UE4的定時器,發現SetTimer函數:

1
2
3
4
5
6
7
8
9
void SetTimer
(
FTimerHandle & InOutHandle,
UserClass * InObj,
typename FTimerDelegate::TUObjectMethodDelegate_Const< UserClass >::FMethodPtr InTimerMethod,
float InRate,
bool InbLoop,
float InFirstDelay
)

在SDK裡搜,看到K2_SetTimer

image.png

根據官網對SetTimer的描述,hook K2_SetTimer並將InRate置為負數,成功讓程序在過一會後仍不會閃退。

image.png

1
2
3
4
5
Interceptor.attach(base.add(0x37AD990),{
onEnter(args){
args[3] = ptr(-1)
}
})

bypass思路2

在SDK裡可以看到start1start2函數,十分可疑。

image.png

嘗試直接patch掉,同樣不再閃退。

1
2
3
4
5
6
7
8
9
10
11
12
function patch_anti(base) {
var targetAddress2 = base.add(0x223c534);
Interceptor.replace(targetAddress2, new NativeCallback(function () {
return 0;
}, 'int', []));

var targetAddress3 = base.add(0x223c544);
Interceptor.replace(targetAddress3, new NativeCallback(function () {
return 1;
}, 'int', []));

}

初見登錄邏輯

在Dump下來的SDK裡搜尋相關的關鍵字,找到CheckPassWordInc

用frida hook該函數的中轉地址0x19f63a4,的確每次點擊登錄按鈕時都會觸發。

image.png

libUE4.so拉入IDA分析,發現具體的check邏輯在sub_19F4304裡進行,一開始先判斷了輸入的長度是否為32

image.png

繼續向下看,發現只有sub_46CFDDC這個函數被混淆得特別嚴重。

hook看看該函數的參數,args[0]是32字節的輸入,args[1]一開始是空,在函數leave時同樣保存著32字節的數據,應該是加密結果,即該函數就是具體的加密函數。

image.png

加密函數被混淆成這樣根本無從分析,接下來先用Unicorn來解混淆。

Unicorn模擬執行解混淆

主要有以下兩種混淆,都可以算是間接跳轉:

  1. it.cc + mov pc的組合。
1
2
3
4
5
6
7
8
9
.text:046D1C60                 CMP             R0, #0
.text:046D1C62 IT NE
.text:046D1C64 MOVNE R1, #8
.text:046D1C66 LDR R0, [R4,#0x14]
.text:046D1C68 MOV R2, R9
.text:046D1C6A ADD R0, R9
.text:046D1C6C LDR R0, [R0,R1]
.text:046D1C6E ADD R0, R6
.text:046D1C70 MOV PC, R0

IDA F5會像這樣:

image.png

注:可能會存在這種只有it.cc沒有mov pc, <reg>的,要注意。

1
2
3
4
5
6
.text:046D2834                 IT CC
.text:046D2836 MOVCC R0, #1
.text:046D2838 STRD.W R6, R5, [R1,#0x44]
.text:046D283C STRD.W R0, R2, [R1,#0x4C]
.text:046D2840 POP.W {R11}
.text:046D2844 POP {R4-R7,PC}
  1. blx {register}間接跳轉。
1
2
3
4
5
6
7
8
9
.text:046CFDF6                 LDRD.W          R1, R0, [R6]
.text:046CFDFA LDR R2, [R6,#(off_4D89E98 - 0x4D89E90)]
.text:046CFDFC ADD R0, R4
.text:046CFDFE MOV R9, R5
.text:046CFE00 LDR R1, [R1,R4]
.text:046CFE02 ADDS R3, R1, R5
.text:046CFE04 ADDS R1, R2, R4
.text:046CFE06 ADDS R1, #0x19
.text:046CFE08 BLX R3 ; sub_46D2DCC

IDA F5會像這樣:

image.png

解混淆思路

解混淆的思路 & 腳本來源於:https://bbs.kanxue.com/thread-281463.htm

簡單說下上面這篇文章的解混淆思路。

it.cc + mov pc <reg>這種組合可以直接轉換成b.cc <branch1> + b <branch2>這套條件分支指令,前提是要知道2個分支的具體地址,因此解混淆的目的就是想辦法獲取具體的分支地址。

  1. 先執行一次目標函數,目的是獲取這些信息:it.cc所在地址( 也就是待patch的地址 )、it.cc所在位置的context( 用於之後模擬執行另一個分支 )、mov pc <reg>執行後的地址( 第1個分支 )。
  2. 以BFS的方式遍歷上述獲取的信息,恢復到it.cc位置的狀態,然後修改CPSR標誌寄存器,從而執行另一條分支。
  3. 不斷重複第2步,盡可能覆蓋多一點路徑。

在其基礎上我添加了hook_blx_reg用來處理blx間接跳轉,具體邏輯和上面差不多:

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
def hook_blx_reg(emu: Uc, addr, size, user_data):
global do_blx_reg
global prev_blx_addr
global blx_config
global disasm_history_blx
global prev_ins_size
global prev_blx_prev_ins_size

# 限制一下, 否則每次都disasm會非常慢
if not do_blx_reg:
if addr in disasm_history_blx:
return
disasm_history_blx[addr] = 1

disasm = get_disasm(emu, addr, size)
if not disasm:
return

offset = addr - libUE4_base

# 判斷是否 blx reg, 是就代表間接跳轉, 記錄下blx的地址
if disasm.mnemonic == "blx" and disasm.operands[0].reg >= UC_ARM_REG_R0 and disasm.operands[0].reg <= UC_ARM_REG_R12:
do_blx_reg = True
prev_blx_addr = offset
# prev_ins = get_disasm(emu, addr - prev_ins_size, prev_ins_size)
# print(hex((addr - prev_ins_size - libUE4_base)) + '\t' + prev_ins.mnemonic + '\t' + prev_ins.op_str + '\t' + str(prev_ins_size))
prev_blx_prev_ins_size = prev_ins_size

elif do_blx_reg:
if prev_blx_addr in blx_config and blx_config[prev_blx_addr].blx_addr != offset:
raise Exception(f"{hex(prev_blx_addr)}: blx有多個目標地址?")

# 只接受libUE4.so裡的地址, 其他系統函數的地址不處理
if addr > libUE4_base and addr < libUE4_end:
blx_config[prev_blx_addr] = BlxConfig(prev_blx_addr, offset, prev_blx_prev_ins_size)

do_blx_reg = False
prev_blx_addr = None
prev_blx_prev_ins_size = None

prev_ins_size = size

一些不足之處

  1. 針對blx間接跳轉的解混淆,會導致第0個參數被覆蓋

原本是這樣的:

image.png

腳本Patch後是這樣的:

image.png

可以看到mov r0, r10這句被覆蓋了,而這句正是get_bit函數的第0個參數的賦值指令。

  1. 針對it.cc間接跳轉的解混淆,會導致一些上下文缺失

紅框那部份代碼除了在計算間接跳轉的地址外,還包含一些後續需要的上下文,但本腳本的patch選擇直接從藍框那裡跳轉,導致缺失了紅框中的一些上下文。

image.png

最終會影響IDA F5的分析:這個0x60應該是vm_ctx[b4]才對。

image.png

  1. IDA跳轉地址解析出錯

0x46d0792這個地址的指令理應是beq 0x46d0f16才對,但IDA解析成了另一個地址,暫時不知原因。( 就算手動Keypatch為beq 0x46d0f16也是顯示beq.w loc_47A16AA+2 )

image.png

image.png

解決方案:

1
2
3
1. 計算: 0x47A16AA + 2 - 0x46d0f16 = 0xD0796
2. 0x46d0f16 - 0xD0796 = 0x4600780
3. 改為 beq 0x4600780

然後IDA就會顯示為預期的beq 0x46d0f16

image.png

Section0:登錄邏輯分析

在解混淆以及對部份函數手動重新解析後,可以在IDA的F5視圖裡看到登錄的密文為:

image.png

1
0x3D, 0xF2, 0x2C, 0xF8, 0x8F, 0xFB, 0x47, 0x5B, 0x49, 0x04, 0x78, 0xD9, 0x4E, 0x31, 0xEF, 0x3E, 0xA1, 0xA7, 0xAA, 0x7B, 0xCF, 0x72, 0xA8, 0xBC, 0x53, 0x2B, 0x67, 0x00, 0xB2, 0xB0, 0x32, 0xFA

sub_46CFDDC這個被嚴重混淆的函數,主要做了3件事:

  1. 解密opcodes。
  2. 調用vm_init初始化vm虛擬機。
  3. 調用vm_start啟動vm虛擬機,在其中執行加密邏輯。

image.png

vm初始化

一開始先調用sub_46D2754來解密opcodes,然後初始化vm_ctx,它類似於arm32的寄存器。

vm_ctx初始化後打印看看其中的值,可以初步確定以下幾個的含義:

  • vm_ctx[0]:字符串"tencentgamesecfs",大概率就是key。
  • vm_ctx[1]:16,key的長度。
  • vm_ctx[2]:input。
  • vm_ctx[3]:一片空的內存空間,大概是用來存放output的。

image.png

sub_46D2754解密的opcodes最終會保存在vm_ctx[21]指向的地址,大小為0x323C個字節。

image.png

vm_init的leave時機dump opcodes。

swap32函數用來翻轉字節,因為在之後的vm中也是會對每個opcode執行該操作,這裡提前預處理下。

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
 function hook_vm_init(base) {
function swap32(val) {
return ((val & 0xFF) << 24)
| ((val & 0xFF00) << 8)
| ((val >> 8) & 0xFF00)
| ((val >> 24) & 0xFF);
}

function dump_opcode(addr) {
let opcodes = [];
for(let i = 0; i < 0x323C; i += 4) {
let opcode = ptr(swap32(addr.add(i).readU32()));
opcodes.push(opcode);
}
console.log(opcodes.join(","))
}

// vm_init
Interceptor.attach(base.add(0x46CFEAC).add(1),{
onEnter(args){
this.vm_ctx = args[1];
},
onLeave(retval){
let opcodes_addr = this.vm_ctx.add(4*21).readPointer()
dump_opcode(opcodes_addr)
}
})

}

vm分析

vm_start開頭的代碼如下,v22明顯是opcode,而v22*v18字節翻轉而來,v18=vm_ctx[15],即vm_ctx[15]相當於pc寄存器( 指向下一條要執行的指令 )。

*v15 += 8也能確定vm_ctx[15]就代表pc。

image.png

向下看那個大switch,每個case都對應了不同的handler,而基本上每個handler裡都有調用get_bit函數。

image.png

get_bit函數如下,其實就是在取a2a3 ~ a4位,而a2固定是opcode。

image.png

每個handler在最後都有將pc -= 4的操作,記得在上面曾將pc += 8,因此這時的pc正好指向下一條指令 ( 每個opcode占4字節 )。

image.png

接著從匯編視圖分析JUMPOUT(0x46D156A),發現當下一條指令不為0時,則會跳回bswap32上面,繼續執行下一條指令。( 跳轉指令除外 )

image.png

image.png

image.png

簡單總結下該vm虛擬機的執行流程:

1
2
3
4
5
6
1. 從pc取opcode
2. 調用bswap翻轉opcode
3. pc += 8
4. switch handler
5. on handler end: pc -= 4
6. (跳轉指令除外) 判斷pc指向的下一個opcode是否為0, 若是則return, 否則回到【1

接下來就是分析每個handler,從而還原vm。

vm handler分析

從上面的分析可知,vm_ctx[15]對應pcvm_ctx[0 ~ 3]對應r0 ~ r3

再看handler1、handler2,明顯是入棧、出棧的操作,vm_ctx[13]就是sp,同樣對應arm的r13( sp )。

由此可以推斷,每個handler都能轉換成與其等價( 或差不多 )的arm匯編指令。

image.png

handler1、handler2對應的arm匯編指令是pushpop

1
2
3
4
5
def handler1_push(opcode):
return "push {r4-r7, lr}"

def handler2_pop(opcode):
return "pop {r4-r7, pc}"

下面再簡單記錄幾個handler的分析過程( 主要是一些分析技巧和一些我很少見的arm指令 )。

handler3分析:

IDA F5的偽代碼只能作為參考,實際分析時要結合匯編來看。

先用frida stalker打印匯編執行流,記為trace.log

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Interceptor.attach(baseAddr.add(0x46CFFAC).add(1), {
onEnter: function() {
console.log("[call 46CFFAC]")
this.tid = Process.getCurrentThreadId();
Stalker.follow(this.tid, {
transform: function(iterator) {
let instruction = iterator.next();
do {
// 限制為當前模塊的指令
if(instruction.address >= baseAddr && instruction.address <= baseAddr.add(baseInfo.size)) {
console.log(`${ptr(instruction.address - baseAddr)}: ${instruction.mnemonic} ${instruction.opStr}`);
}

iterator.keep();
} while((instruction = iterator.next()) != null)
}

})
},
onLeave: function() {
Stalker.unfollow(this.tid)
}
})

trace.log一路向上跟,會發現vm_ctx_1就是vm_start的args[1],即vm_ctx

這無法從F5的偽代碼中直接看出。

image.png

找不到v12初始化的地址,但發現有個get_bit沒有接收其返回值,猜測其實它的返回值被保存在v12中。

image.png

同樣用frida stalker來驗證,直接打印寄存器( r0get_bit的返回值,r8v12 )

1
2
3
4
5
6
7
8
9
10
if(Number(instruction.address) == baseAddr.add(0x46D103E)) {
iterator.putCallout((context) => {
console.log("r8: ", context.r8)
});
}
if(Number(instruction.address) == baseAddr.add(0x46D0D72)) {
iterator.putCallout((context) => {
console.log("r0: ", context.r0)
});
}

發現兩者一致,成功驗證想法。

image.png

handler3是加法操作,對應的arm匯編指令是add,根據get_bit的不同分為寄存器、立即數、左移等情況,還原代碼如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def handler3_add(opcode):
b1 = get_bit(0, opcode, 25, 27)
out = get_bit(0, opcode, 20, 25)
if out == 15:
raise Exception("handler3_add 1")

in1 = get_bit(0, opcode, 15, 20)
if b1:
if b1 == 1:
in2 = get_bit(0, opcode, 10, 15)
asm_str = f"add r{out}, r{in1}, r{in2}"
else:
if b1 != 2:
raise Exception("handler3_add 2")
in2 = get_bit(0, opcode, 10, 15)
lsl = get_bit(0, opcode, 0, 10)
asm_str = f"add r{out}, r{in1}, r{in2}, lsl #{lsl}"
else:
in2 = get_bit(0, opcode, 0, 15)
asm_str = f"add r{out}, r{in1}, {in2}"

return asm_str

handler10分析:

vm_ctx[17~20]剛好可以對應arm的CPSR狀態寄存器的ZNCV位。

image.png

image.png

因此handler10對應的arm匯編指令就是cmp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def handler10_cmp(opcode):
v190 = get_bit(0, opcode, 26, 27)

b = get_bit(0, opcode, 21, 26)

if v190:
v143 = get_bit(0, opcode, 16, 21)

asm_str = f"cmp r{b}, r{v143}"
else:
v143 = get_bit(0, opcode, 0, 21)

asm_str = f"cmp r{v190}, {v143}"

return asm_str

handler11分析:

根據CPSR那4個標誌位進行條件跳轉。

image.png

對應arm的beqbnebcc等不同的條件跳轉指令。

注:可能還原得不太正確,但基本能用就不管了。

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
def handler11_bcc(opcode):

global branchs
global branch_maps
v182 = get_bit(0, opcode, 23, 27)

b_reg = None
b_addr = None
if get_bit(0, opcode, 22, 23):
b_reg = get_bit(0, opcode, 17, 22)

else:
b_addr = get_bit(0, opcode, 0, 22)

if b_addr and b_addr not in branch_maps:
branchs.append(b_addr)
branch_maps[b_addr] = 1

#17: CPSR_Z
#18: CPSR_N
#19: CPSR_C
#20: CPSR_V

if b_reg:
return f"bx r{b_reg}"

if v182 == 0:
asm_str = f"b {hex(b_addr)}"
elif v182 == 1:
asm_str = f"beq {hex(b_addr)}"

elif v182 == 2:
asm_str = f"bne {hex(b_addr)}"

elif v182 == 3:
asm_str = f"bcc {hex(b_addr)}"

elif v182 == 4:
asm_str = f"bcs {hex(b_addr)}"

elif v182 == 5:
asm_str = f"bcc {hex(b_addr)}"

elif v182 == 6:
asm_str = f"beq {hex(b_addr)}"

elif v182 == 7:
asm_str = f"bge {hex(b_addr)}"

elif v182 == 8:
asm_str = f"blt {hex(b_addr)}"

elif v182 == 9:
asm_str = f"bgt {hex(b_addr)}"

elif v182 == 10:
asm_str = f"ble {hex(b_addr)}"

else:
raise Exception("Error")

return asm_str

handler13分析:

等價於arm的ubfx指令。

image.png

1
2
3
4
5
6
7
8
9
10
11
12
def handler13_ubfx(opcode):
# UBFX Wd, Wn, #lsb, #width ; 32-bit general registers
# 解釋: 從Wn的第lsb位開始提取width位到Wd (https://blog.csdn.net/LQMIKU/article/details/104361219)

v182 = get_bit(0, opcode, 22, 27)
v168 = get_bit(0, opcode, 17, 22)
v134 = get_bit(0, opcode, 8, 17)
v135 = get_bit(0, opcode, 0, 8)

asm_str = f"ubfx r{v182}, r{v168}, #{v134}, #{v135}"

return asm_str

handler18分析:

等價於arm的rev指令( 與bswap32功能一樣都是字節翻轉 )。

image.png

1
2
3
4
5
6
7
8
9
10
def handler18_rev(opcode):

b1 = get_bit(0, opcode, 22, 27)
b2 = get_bit(0, opcode, 17, 22)

if b1 == 15:
raise Exception("handler18_rev TODO")
asm_str = f"rev r{b1}, r{b2}"

return asm_str

handler20:

等價於arm的rsb指令。

指令例子:rsb r0, r1, #10 == r0 = 10 - r1

image.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def handler20_rsb(opcode):
global vm_ctx

b1 = get_bit(0, opcode , 22, 27)
b2 = get_bit(0, opcode , 17, 22)
b3 = get_bit(0, opcode , 0, 17)

if b1 == 15:
raise Exception("handler20_rsb TODO")

asm_str = f"rsb r{b1}, r{b2}, #{b3}"
vm_ctx[b1] = b3 - vm_ctx[b2]

print(asm_str)

vm指令還原

分析完所有handler後,就可以將所有的opcode都還原成arm指令( 4字節的opcode → 4字節的arm指令 )。

以BFS的方式遍歷所有遇到的分支:從0xE50( opcodes[0xE50/4] )開始執行,調用start函數進行指令解析,在start裡會將遇到的所有分支入隊。

image.png

start函數如下:

image.png

注:arm沒有r16,但我的asm_str裡會出現r16,因此要將r16替換為其他不用的寄存器,這裡選擇lr

image.png

還原後的文件記為vm_code,能被IDA順利解析:

image.png

Section0:登錄算法還原

用Findcrypt插件分析,發現AES算法的特徵。

image.png

網上找的AES腳本對比,可以看出整體加密結構是一致的,但Sub_BytesShift_Rows等函數的實現就有所不同。

原版AES:

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
void Encrypt()
{
int i,j,round=0;
// Copy plaintext to state array
for (i = 0; i < 4; i++)
{
for (j = 0; j < 4; j++)
{
state[j][i] = plaintext[i * 4 + j];
}
}

// Add the first round key to the state before starting the rounds
Add_Round_Key(0);
// The first rounds-1 rounds are the same
for (round = 1; round < rounds; round++)
{
Sub_Bytes();
Shift_Rows();
Mix_Columns();
Add_Round_Key(round);
}
// Last round has no Mix_Columns()
Sub_Bytes();
Shift_Rows();
Add_Round_Key(rounds);
// Copy the state array to output
for (i = 0; i < 4; i++)
{
for(j = 0; j < 4; j++)
{
encrypted[i * 4 + j] = state[j][i];
}
}
}

vm_code IDA偽代碼:

image.png

將一些函數按原版AES重命名後:

image.png

在正式開始算法還原前,需從真實環境提取一組的input、output數據,作為之後測試算法是否正確的依據:

1
2
3
4
5
input: 01230123012301230123012301230123

res: 0 1 2 3 4 5 6 7 8 9 A B C D E F 0123456789ABCDEF
c0889100 d5 1d 78 a3 4f 12 05 88 18 a2 f8 d5 44 56 a7 d2 ..x.O.......DV..
c0889110 d5 1d 78 a3 4f 12 05 88 18 a2 f8 d5 44 56 a7 d2 ..x.O.......DV..

接下來是我進行算法還原的思路,僅供參考。

先將IDA的偽代碼扣下來,看看加密結果與真實環境中的是否一致。

image.png

不出所料,果然不一樣,原因可能是vm指令還原那部份出了錯,又或者是IDA的問題,都有可能。

image.png

到底哪裡出了錯暫時不知,先假設vm指令還原那部份沒有出錯,即vm_code的arm匯編指令是正確的,然後調試看看。

如何調試?對於vm指令還原後的程序,應該不存在一種直接調試的手段,我想到的一種思路是借助Unicorn來間接地調試。

注:這裡的調試並非傳統意義上的打斷點 → 單步執行調試,而是偏向於在某條匯編指令執行後,打印某寄存器、hexdump某內存地址,看看數據是否符合預期。

Unicorn輔助調試算法出錯的原因

先看看Unicorn模擬執行的結果是否與真實環境一致( 沿用上面解混淆的Unicorn代碼 )。

可以看到完全一樣,即模擬執行的環境沒有任何問題。

image.png

定義一些輔助函數,方便調試觀察。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def read_vm_reg(emu:Uc, idx):
if not g_vmctx:
raise Exception("g_vmctx == None")

return int.from_bytes(emu.mem_read(g_vmctx + 4 * idx, 0x4), 'little')

def hexdump(emu:Uc, addr, len):
res = emu.mem_read(addr, len)
print(f"{hex(addr)}:")
for i in range(len):
print(hex(res[i])[2:].rjust(2, '0'), end=' ')
if (i+1) % 16 == 0:
print(f" | {res[i-15: i+1]}")

def hex2str(hexstr: str):
hexstr = hexstr.replace("0x", "")
return bytearray.fromhex(hexstr).decode()[::-1]

具體調試思路如下:

  1. 添加一個UC_HOOK_CODE來hook每條執行的匯編指令,在合適的時機保存vm_ctx。 在bl bswap32_0的下一條匯編這個時機調用on_opcode,這時可以監控執行過程的每個opcode。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    g_vmctx = None
    def hook_code(emu: Uc, addr, size, user_data):
    global g_vmctx

    if addr == libUE4_base + 0x46CFFBC:
    g_vmctx = emu.reg_read(UC_ARM_REG_R10)

    if addr == libUE4_base + 0x46D002C:
    on_opcode(emu)
  2. opcode基本上都會重複,因此要調試vm_code某地址的匯編指令時,要通過2個opcode才能確定。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    count = -1 # 代表在opcodeA後的第{count}條指令時打印
    g_addr = None
    def on_opcode(emu: Uc):
    global count
    global g_addr
    opcode = emu.reg_read(UC_ARM_REG_R0)

    if count != -1:
    count -= 1

    if opcode == 0x1ac7b000:
    count = 1

    if count == 0 and opcode == 0x38d58300:
    r3 = read_vm_reg(emu, 3)
    print("r3: ", hex(r3))
    例子:目標是vm_code0x7DC這條匯編指令。 image.png 0x7DC上一條指令對應的opcode為opcodes[0x7D8/4] = 0x1ac7b0000x7DC對應的opcode為opcodes[0x7DC/4] = 0x38d58300image.png 利用0x1ac7b0000x38d58300這2個opcode就基本能確定是vm_code0x7DC

image.png

接下來就是找不同的過程,先看aes_key_schedule的結果與本地是否不同。

image.png

按上述調試思路,打印aes_key_schedule的返回值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def on_opcode(emu: Uc):
global count
global g_addr
opcode = emu.reg_read(UC_ARM_REG_R0)

if count != -1:
count -= 1

# print 0x6F0 return ( save in args[2] )
if count == 0 and opcode == 0x58000798:
count = -1
r1 = read_vm_reg(emu, 1)
if not g_addr:
g_addr = r1
if opcode == 0x40002000:
count = 1
if opcode == 0x10008800:
if g_addr:
hexdump(emu, g_addr, 0x160)

輸出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
63 6e 65 74 67 74 6e 65 73 65 6d 61 73 66 63 65  |  bytearray(b'cnetgtnesemasfce')
2e e1 56 8e 49 95 38 eb 3a f0 55 8a 49 96 36 ef | bytearray(b'.\xe1V\x8eI\x958\xeb:\xf0U\x8aI\x966\xef')
f1 da c6 89 b8 4f fe 62 82 bf ab e8 cb 29 9d 07 | bytearray(b'\xf1\xda\xc6\x89\xb8O\xfeb\x82\xbf\xab\xe8\xcb)\x9d\x07')
34 c5 63 d3 8c 8a 9d b1 0e 35 36 59 c5 1c ab 5e | bytearray(b'4\xc5c\xd3\x8c\x8a\x9d\xb1\x0e56Y\xc5\x1c\xab^')
6c 63 ff b9 e0 e9 62 08 ee dc 54 51 2b c0 ff 0f | bytearray(b'lc\xff\xb9\xe0\xe9b\x08\xee\xdcTQ+\xc0\xff\x0f')
1a 92 45 bf fa 7b 27 b7 14 a7 73 e6 3f 67 8c e9 | bytearray(b"\x1a\x92E\xbf\xfa{\'\xb7\x14\xa7s\xe6?g\x8c\xe9")
04 e7 c0 fb fe 9c e7 4c ea 3b 94 aa d5 5c 18 43 | bytearray(b'\x04\xe7\xc0\xfb\xfe\x9c\xe7L\xea;\x94\xaa\xd5\\\x18C')
1e e4 8a 16 e0 78 6d 5a 0a 43 f9 f0 df 1f e1 b3 | bytearray(b'\x1e\xe4\x8a\x16\xe0xmZ\nC\xf9\xf0\xdf\x1f\xe1\xb3')
73 7a 4a 6e 93 02 27 34 99 41 de c4 46 5e 3f 77 | bytearray(b"szJn\x93\x02\'4\x99A\xde\xc4F^?w")
86 20 12 00 15 22 35 34 8c 63 eb f0 ca 3d d4 87 | bytearray(b'\x86 \x12\x00\x15"54\x8cc\xeb\xf0\xca=\xd4\x87')
91 54 35 7e 84 76 00 4a 08 15 eb ba c2 28 3f 3d | bytearray(b'\x91T5~\x84v\x00J\x08\x15\xeb\xba\xc2(?=')
91 54 35 7e 84 76 00 4a 08 15 eb ba c2 28 3f 3d | bytearray(b'\x91T5~\x84v\x00J\x08\x15\xeb\xba\xc2(?=')
86 20 12 00 15 22 35 34 8c 63 eb f0 ca 3d d4 87 | bytearray(b'\x86 \x12\x00\x15"54\x8cc\xeb\xf0\xca=\xd4\x87')
73 7a 4a 6e 93 02 27 34 99 41 de c4 46 5e 3f 77 | bytearray(b"szJn\x93\x02\'4\x99A\xde\xc4F^?w")
1e e4 8a 16 e0 78 6d 5a 0a 43 f9 f0 df 1f e1 b3 | bytearray(b'\x1e\xe4\x8a\x16\xe0xmZ\nC\xf9\xf0\xdf\x1f\xe1\xb3')
04 e7 c0 fb fe 9c e7 4c ea 3b 94 aa d5 5c 18 43 | bytearray(b'\x04\xe7\xc0\xfb\xfe\x9c\xe7L\xea;\x94\xaa\xd5\\\x18C')
1a 92 45 bf fa 7b 27 b7 14 a7 73 e6 3f 67 8c e9 | bytearray(b"\x1a\x92E\xbf\xfa{\'\xb7\x14\xa7s\xe6?g\x8c\xe9")
6c 63 ff b9 e0 e9 62 08 ee dc 54 51 2b c0 ff 0f | bytearray(b'lc\xff\xb9\xe0\xe9b\x08\xee\xdcTQ+\xc0\xff\x0f')
34 c5 63 d3 8c 8a 9d b1 0e 35 36 59 c5 1c ab 5e | bytearray(b'4\xc5c\xd3\x8c\x8a\x9d\xb1\x0e56Y\xc5\x1c\xab^')
f1 da c6 89 b8 4f fe 62 82 bf ab e8 cb 29 9d 07 | bytearray(b'\xf1\xda\xc6\x89\xb8O\xfeb\x82\xbf\xab\xe8\xcb)\x9d\x07')
2e e1 56 8e 49 95 38 eb 3a f0 55 8a 49 96 36 ef | bytearray(b'.\xe1V\x8eI\x958\xeb:\xf0U\x8aI\x966\xef')
63 6e 65 74 67 74 6e 65 73 65 6d 61 73 66 63 65 | bytearray(b'cnetgtnesemasfce')

再看本地運算的結果,從第2行開始就不一樣了,由此確定本地的aes_key_schedule實現有問題。

image.png

然後就是漫長地重複上述的調試過程,嘗試找出那個不符合預期的地方。

看了好幾處運算的地方,本地結果與真實環境中都一樣,直到我看到HIBYTE,讓我回想起之前同樣在進行算法還原時,就是被HIBYTE坑了很久,IDA偽代碼中的HIBYTE實際上是BYTE3才對。

image.png

嘗試將所有HIBYTE都改成BYTE3,結果就正確了。

image.png

反推解密算法

上一小節中成功修復了IDA扣下來的魔改AES加密算法,以此作為根據 + 參考原版AES解密邏輯 + 問GPT很容易可以得出解密算法。

先簡單分析每個函數,看看需不需要改成逆函數用於解密。

  1. aes_key_schedule:密鑰擴展,不需要改。
    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
    int __fastcall aes_key_schedule(int a1, int a2, int* output)
    {
    int m; // [sp+0h] [bp-28h]
    int k; // [sp+4h] [bp-24h]
    int j; // [sp+8h] [bp-20h]
    int i; // [sp+Ch] [bp-1Ch]
    int* v8; // [sp+10h] [bp-18h]
    int* v9; // [sp+14h] [bp-14h]
    int* v10; // [sp+14h] [bp-14h]

    if (!a1 || !output)
    return -1;
    if (a2 != 16)
    return -1;
    v9 = output;
    v8 = output + 44;
    for (i = 0; i < 4; ++i)
    output[i] = bswap32(*(int*)(a1 + 4 * i));
    for (j = 0; j < 10; ++j)
    {
    v9[4] = *v9 ^ ((RijnDael_AES_LONG_3000[(unsigned __int8)BYTE2(v9[3])] << 24) | (RijnDael_AES_LONG_3000[BYTE1(v9[3])] << 16) | (RijnDael_AES_LONG_3000[(unsigned __int8)v9[3]] << 8) | RijnDael_AES_LONG_3000[BYTE3(v9[3])]) ^ dword_1388[j];
    v9[5] = v9[1] ^ v9[4];
    v9[6] = v9[2] ^ v9[5];
    v9[7] = v9[3] ^ v9[6];
    v9 += 4;
    }
    v10 = output + 40;
    for (k = 0; k < 11; ++k)
    {
    for (m = 0; m < 4; ++m)
    v8[m] = v10[m];
    v10 -= 4;
    v8 += 4;
    }
    return 0;
    }
  2. transpose:只是將輸入轉置一下,不需要改。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    int __fastcall transpose(int a1, _BYTE* a2)
    {
    _BYTE* v2; // r0
    int j; // [sp+0h] [bp-10h]
    int i; // [sp+4h] [bp-Ch]

    for (i = 0; i < 4; ++i)
    {
    for (j = 0; j < 4; ++j)
    {
    v2 = a2++;
    *(_BYTE*)(a1 + 4 * j + i) = *v2;
    }
    }
    return 0;
    }
  3. Add_Round_Key:input異或輪密鑰,不需要改,但傳入的輪密鑰順序要由後→前。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    int __fastcall Add_Round_Key(int a1, int a2)  // Add_Round_Key
    {
    int j; // [sp+0h] [bp-20h]
    int i; // [sp+4h] [bp-1Ch]
    _DWORD v5[4]; // [sp+8h] [bp-18h] BYREF
    int v6; // [sp+18h] [bp-8h]
    int v7; // [sp+1Ch] [bp-4h]

    v7 = a1;
    v6 = a2;
    for (i = 0; i < 4; ++i)
    {
    for (j = 0; j < 4; ++j)
    {
    *((_BYTE*)&v5[i] + j) = *(_DWORD*)(v6 + 4 * j) >> (8 * (3 - i));
    *(_BYTE*)(v7 + 4 * i + j) ^= *((_BYTE*)&v5[i] + j);
    }
    }
    return 0;
    }
  4. Sub_Bytes:加密時查詢的是逆S盒,解密時要改成S盒。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    int __fastcall Sub_Bytes(int a1)
    {
    int j; // [sp+0h] [bp-Ch]
    int i; // [sp+4h] [bp-8h]

    for (i = 0; i < 4; ++i)
    {
    for (j = 0; j < 4; ++j)
    *(_BYTE*)(a1 + 4 * i + j) = RijnDael_AES_LONG_inv_3100[*(unsigned __int8*)(j + a1 + 4 * i)];
    }
    return 0;
    }
  5. Shift_Rows__ROR4__(v3[i], 8 * i)是可逆的,解密時要改成__ROR4__(v3[i], 32 - 8 * i)__ROR4__(val, n)解析:將val右移的n位保存下來,然後在val右移後拼到val的高位。 Encrypt:__ROR4__(0xfd84a748, 8) = 0x48fd84a7。 Decrypt:__ROR4__(0x48fd84a7, 24) = 0xfd84a748
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    int __fastcall Shift_Rows(int a1)
    {
    int i; // [sp+0h] [bp-18h]
    _DWORD v3[4]; // [sp+4h] [bp-14h] BYREF
    int v4; // [sp+14h] [bp-4h]

    v4 = a1;
    for (i = 0; i < 4; ++i)
    {
    v3[i] = bswap32(*(_DWORD*)(v4 + 4 * i));
    v3[i] = __ROR4__(v3[i], 8 * i);
    *(_BYTE*)(v4 + 4 * i) = BYTE3(v3[i]); // nglog: HIBYTE -> BYTE3
    *(_BYTE*)(v4 + 4 * i + 1) = HIWORD(v3[i]);
    *(_BYTE*)(v4 + 4 * i + 2) = BYTE1(v3[i]);
    *(_BYTE*)(v4 + 4 * i + 3) = v3[i];
    }
    return 0;
    }
  6. Mix_Columns:對矩陣的列進行線性轉換,直接問GPT取得其逆函數。
    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
    int __fastcall Mix_Columns(int a1)
    {
    char v1; // r0
    int v3; // [sp+0h] [bp-48h]
    int v4; // [sp+4h] [bp-44h]
    int v5; // [sp+8h] [bp-40h]
    int m; // [sp+14h] [bp-34h]
    int k; // [sp+18h] [bp-30h]
    int j; // [sp+1Ch] [bp-2Ch]
    int i; // [sp+20h] [bp-28h]
    char v10[32]; // [sp+24h] [bp-24h] BYREF
    int v11; // [sp+44h] [bp-4h]

    v11 = a1;
    v10[0] = 2;
    v10[1] = 3;
    v10[2] = 1;
    v10[3] = 1;
    v10[4] = 1;
    v10[5] = 2;
    v10[6] = 3;
    v10[7] = 1;
    v10[8] = 1;
    v10[9] = 1;
    v10[10] = 2;
    v10[11] = 3;
    v10[12] = 3;
    v10[13] = 1;
    v10[14] = 1;
    v10[15] = 2;
    for (i = 0; i < 4; ++i)
    {
    for (j = 0; j < 4; ++j)
    v10[4 * i + 16 + j] = *(_BYTE*)(j + v11 + 4 * i);
    }
    for (k = 0; k < 4; ++k)
    {
    for (m = 0; m < 4; ++m)
    {
    v5 = sub_B64(v10[4 * k], v10[m + 16]);
    v4 = v5 ^ sub_B64(v10[4 * k + 1], v10[m + 20]);
    v3 = v4 ^ sub_B64(v10[4 * k + 2], v10[m + 24]);
    v1 = sub_B64(v10[4 * k + 3], v10[m + 28]);
    *(_BYTE*)(v11 + 4 * k + m) = v3 ^ v1;
    }
    }
    return 0;
    }

然後嘗試解密登錄的密文,得出結果為dde8cdf098e8434b93f04f86085a88f9

image.png

輸入後成功進入遊戲。

image.png

Section1:透視和自瞄

UE4的大體框架如下,實現透視、自瞄所需的變量大致都列了出來。

image.png

圖片來源:https://renyili.org/post/game_cheat2/

繪圖函數

繪圖函數用於輸出透視的結果,K2_DrawLine是UE4自帶的繪圖函數,是UCanvas的成員函數。

image.png

用ue4dumper將遊戲所有對象dump下來,記為Objects.txt,在其中搜Canvas,看到有一個DebugCanvasObject

這代表通過GObject能遍歷到該對象,然後就可以通過它來調用K2_DrawLine

image.png

只調用一次K2_DrawLine繪制的東西無法長久保留在屏幕上,因此要在一個合適的時機不斷調用K2_DrawLine,這樣繪制的東西才不會立即被刷走。

在SDK裡看到ReceiveDrawHUD函數,hook後發現它會不斷被某處調用,大概每次繪製遊戲屏幕上的信息時會觸發,在這時機調用K2_DrawLine繪制透視信息簡直再合適不過。

image.png

第一種透視思路

Objects.txt裡可以看到有兩類的YellowBall,一種是會動的,另一種是固定不動的。

image.png

image.png

Sphere4_Blueprint_C類下有Speed屬性,因此它應該就是會動的YellowBall的類。

image.png

無論哪一種,都繼承於AActor,而AActorRootComponentRelativeLocation裡保存著座標信息( 世界座標 )。

注:屬性的Offset可以參考SDK裡的,若被魔改過則需要用CE分析看看。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 獲取指定actor座標
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": x,
"y": y,
"z": z,
}
}

獲取到目標的世界座標後,要轉換為屏幕座標,第一種轉換方式是借助UE4的透視投影矩陣。

注:透視投影使用透視矩陣將3D場景中的物件轉換到2D屏幕座標系。這個矩陣是透過相機位置和朝向信息計算出來的。

這個矩陣保存在UCanvasViewProjectionMatrixFMatrix是一個4*4的矩陣。

image.png

image.png

用frida或ue4dumper獲取DebugCanvasObject的地址( 假設是0xc0402580 ),然後用CE查看其內存,切換為float類型以便觀察。

當遊戲鏡頭角度發生變化時,紅框的數據也會隨之變化,因此這裡大概就是ViewProjectionMatrix,偏移為0xC0402790 - 0xC0402580 = 0x210

image.png

知道了偏移後,就能用frida提取ViewProjectionMatrix

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 getViewProjectionMatrix() {
if (!DebugCanvasObject) return null;

let ViewProjectionMatrix = DebugCanvasObject.add(Offset.UCanvasToViewProjectionMatrix);
let InX = ViewProjectionMatrix.add(0 * 0x10);
let InX_x = InX.add(0 * 4).readFloat()
let InX_y = InX.add(1 * 4).readFloat()
let InX_z = InX.add(2 * 4).readFloat()
let InX_tran = InX.add(3 * 4).readFloat()

let InY = ViewProjectionMatrix.add(1 * 0x10);
let InY_x = InY.add(0 * 4).readFloat()
let InY_y = InY.add(1 * 4).readFloat()
let InY_z = InY.add(2 * 4).readFloat()
let InY_tran = InY.add(3 * 4).readFloat()

let InZ = ViewProjectionMatrix.add(2 * 0x10);
let InZ_x = InZ.add(0 * 4).readFloat()
let InZ_y = InZ.add(1 * 4).readFloat()
let InZ_z = InZ.add(2 * 4).readFloat()
let InZ_tran = InZ.add(3 * 4).readFloat()

let InW = ViewProjectionMatrix.add(3 * 0x10);
let InW_x = InW.add(0 * 4).readFloat()
let InW_y = InW.add(1 * 4).readFloat()
let InW_z = InW.add(2 * 4).readFloat()
let InW_tran = InW.add(3 * 4).readFloat()

return [
[InX_x, InX_y, InX_z, InX_tran],
[InY_x, InY_y, InY_z, InY_tran],
[InZ_x, InZ_y, InZ_z, InZ_tran],
[InW_x, InW_y, InW_z, InW_tran]
]

}

世界座標 → 屏幕座標:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function W2S(W) {
let viewProjectionMatrix = getViewProjectionMatrix();
if(!viewProjectionMatrix)return [];

let res = [
viewProjectionMatrix[0][0] * W.x + viewProjectionMatrix[1][0] * W.y + viewProjectionMatrix[2][0] * W.z + viewProjectionMatrix[3][0],
viewProjectionMatrix[0][1] * W.x + viewProjectionMatrix[1][1] * W.y + viewProjectionMatrix[2][1] * W.z + viewProjectionMatrix[3][1],
viewProjectionMatrix[0][2] * W.x + viewProjectionMatrix[1][2] * W.y + viewProjectionMatrix[2][2] * W.z + viewProjectionMatrix[3][2],
viewProjectionMatrix[0][3] * W.x + viewProjectionMatrix[1][3] * W.y + viewProjectionMatrix[2][3] * W.z + viewProjectionMatrix[3][3],
]

let r = res[3];
if (r > 0) {
let rhw = 1 / r;
let inOutPosition = [];
inOutPosition[0] = (((res[0] * rhw) / 2.0) + 0.5) * 1280; // 1280: 通過DrawLine測試出來的寬度
inOutPosition[1] = (0.5 - ((res[1] * rhw) / 2.0)) * 760; // 760 : 通過DrawLine測試出來的高度
inOutPosition[2] = r;

return inOutPosition;
}
return [];
}

第二種透視思路

用UE4本身的函數ProjectWorldLocationToScreen來實現世界座標 → 屏幕座標。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 第2種:世界座標 -> 屏幕座標
function W2S_2(W) {
let ProjectWorldLocationToScreen = new NativeFunction(base.add(0x39B8820), "int", ["pointer", "float", "float", "float", "pointer", "int"]);

let out = Memory.alloc(0x100);

let res = ProjectWorldLocationToScreen(PlayerController, W.x, W.y, W.z, out, 1);
if (res) {
let x = out.add(0 * 4).readFloat()
let y = out.add(1 * 4).readFloat()
let z = out.add(2 * 4).readFloat()

return [x, y, z]
}

return [];
}

這種方法轉換後的座標還需進行一些處理才能使用,具體處理參考:https://bbs.kanxue.com/thread-281464-1.htm#msg_header_h1_0

自瞄思路

原理:獲取目標座標與本地座標,利用三角函數計算出兩者間的水平夾角、垂直夾角,然後設置人物相機的ControlRotation為該值即可實現自瞄的效果。

Player對象:PlayerControllerAcknowledgedPawn( 它也是繼承於AActor )。

1
2
3
4
5
function getPlayerLocation() {
if (!PlayerController) return;
let AcknowledgedPawn = PlayerController.add(Offset.APlayerControllerToAcknowledgedPawn).readPointer();
return getActorLocation(AcknowledgedPawn);
}

Player朝向信息:PlayerControllerControlRotation

1
2
3
4
5
6
7
8
9
// 設置相機(人物)角度信息
function setCameraRotation(loc) {
let ControlRotation = PlayerController.add(Offset.AControllerToControlRotation);
console.log("setCameraRotation:", loc)
ControlRotation.add(0 * 4).writeFloat(loc[0]) // 垂直
ControlRotation.add(1 * 4).writeFloat(loc[1]) // 水平
ControlRotation.add(2 * 4).writeFloat(loc[2])

}

自瞄實現:

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
// 自瞄實現
function aimbot(targetLoc) {
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]

}

function calcTargetOffset2(targetLoc, cameraLoc) {
let dx = targetLoc.x - cameraLoc.x;
let dy = targetLoc.y - cameraLoc.y;
let dz = targetLoc.z - cameraLoc.z;

let pi_2 = Math.PI / 2;
let pitch_range = 90.0;

let pitch = Math.atan2(dz, Math.sqrt(dx * dx + dy * dy)) * (pitch_range / pi_2);
if (pitch < 0){
pitch += 360;
}

let yaw_range = 360;
let yaw = Math.atan2(dy, dx) * yaw_range / 4 / pi_2;

if (yaw < 0.0){
yaw += 360.0;
}
return [pitch, yaw, 0];
}

let playerLoc = getPlayerLoccation();
let loc = calcTargetOffset2(targetLoc, playerLoc);
setCameraRotation(loc);
}

效果圖:

show2.gif

參考資料