前言
很少接觸UE4逆向,借這個比賽的題目來學習下UE4逆向和vm虛擬機。
主要參考這兩篇文章來復現:
Dump SDK
UE4逆向的第一步基本都要先Dump SDK,否則根本無從下手。
UE4三件套
按常規方法靜態定位三件套。
GName:0x4E2EC00

GWorld:0x4F5C0D0

GObject:0x4E533AC

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

用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);
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()
var isWide = FNameEntryHeader & 1
var Len = FNameEntryHeader >> 6
if (0 == isWide) {
console. log (`\x1b[32m[+] ${FNameEntry.add(2).readCString(Len)}\x1b[0m`)
}
}
getName32(base.add(0x4E2EC00), 0)
getName32(base.add(0x4E2EC00), 3)
|
打印出前2個字符串如下,符合預期,代表GName
地址是正確的且字符串算法大概率沒有被魔改。

結構修復
通過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 | if (isTencentGamematch2024final()) {
UObjectToClassPrivate = 0x14;
UObjectToFNameIndex = 0x18;
UObjectToOuterPrivate = 0x20;
UStructToSuperStruct = 0x40;
UStructToChildren = 0x6c;
UStructToChildProperties = 0x44;
UWorldToPersistentLevel = 0x58;
ULevelToAActors = 0x9C;
ULevelToAActorsCount = 0xA0;
UFunctionToFunc = 0xA4;
UFieldToNext = 0x2C;
TUObjectArrayToNumElements = 0xC;
FUObjectItemPadd = 0x4;
FUObjectItemSize = 0x14;
}
|
重新編譯修改Offset後的ue4dumper
,再次嘗試dump sdk,這次成功了。
1 | . /ue4dumper_ext --sdku --newue+ --gname 0x4E2EC00 --guobj 0x4E533AC --package com.tencent.ace.gamematch2024final
|

基礎保護
簡單看看程序的一些保護,詳細分析可看:https://bbs.kanxue.com/thread-281464-1.htm#msg_header_h1_0
hook svc
hook openat
系統調用:

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

除此之外還有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
。

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

1 2 3 4 5 | Interceptor.attach(base.add(0x37AD990),{
onEnter(args){
args[3] = ptr(-1)
}
})
|
bypass思路2
在SDK裡可以看到start1
、start2
函數,十分可疑。

嘗試直接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
,的確每次點擊登錄按鈕時都會觸發。

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

繼續向下看,發現只有sub_46CFDDC
這個函數被混淆得特別嚴重。
hook看看該函數的參數,args[0]
是32字節的輸入,args[1]
一開始是空,在函數leave時同樣保存著32字節的數據,應該是加密結果,即該函數就是具體的加密函數。

加密函數被混淆成這樣根本無從分析,接下來先用Unicorn來解混淆。
Unicorn模擬執行解混淆
主要有以下兩種混淆,都可以算是間接跳轉:
1.it.cc
+ mov pc
的組合。
.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會像這樣:

注:可能會存在這種只有it.cc
沒有mov pc, <reg>
的,要注意。
.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}
2.blx {register}
間接跳轉。
.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會像這樣:

解混淆思路
解混淆的思路 & 腳本來源於:https://bbs.kanxue.com/thread-281463.htm。
簡單說下上面這篇文章的解混淆思路。
it.cc
+ mov pc <reg>
這種組合可以直接轉換成b.cc <branch1>
+ b <branch2>
這套條件分支指令,前提是要知道2個分支的具體地址,因此解混淆的目的就是想辦法獲取具體的分支地址。
- 先執行一次目標函數,目的是獲取這些信息:
it.cc
所在地址( 也就是待patch的地址 )、it.cc
所在位置的context( 用於之後模擬執行另一個分支 )、mov pc <reg>
執行後的地址( 第1個分支 )。
- 以BFS的方式遍歷上述獲取的信息,恢復到
it.cc
位置的狀態,然後修改CPSR標誌寄存器,從而執行另一條分支。
- 不斷重複第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
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
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_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有多個目標地址?" )
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個參數被覆蓋
原本是這樣的:

腳本Patch後是這樣的:

可以看到mov r0, r10
這句被覆蓋了,而這句正是get_bit
函數的第0個參數的賦值指令。
2.針對it.cc
間接跳轉的解混淆,會導致一些上下文缺失
紅框那部份代碼除了在計算間接跳轉的地址外,還包含一些後續需要的上下文,但本腳本的patch選擇直接從藍框那裡跳轉,導致缺失了紅框中的一些上下文。

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

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


解決方案:
1 2 3 | 1. 計算: 0x47A16AA + 2 - 0x46d0f16 = 0xD0796
2. 0x46d0f16 - 0xD0796 = 0x4600780
3. 改為 beq 0x4600780
|
然後IDA就會顯示為預期的beq 0x46d0f16

綜上不足之處,IDA的F5偽代碼只能作為參考,分析時一定要結合( 原so的 )匯編來一起看。
Section0:登錄邏輯分析
在解混淆以及對部份函數手動重新解析後,可以在IDA的F5視圖裡看到登錄的密文為:

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件事:
- 解密opcodes。
- 調用
vm_init
初始化vm虛擬機。
- 調用
vm_start
啟動vm虛擬機,在其中執行加密邏輯。

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的。

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

在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( "," ))
}
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。

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

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

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

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



簡單總結下該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]
對應pc
,vm_ctx[0 ~ 3]
對應r0 ~ r3
。
再看handler1、handler2,明顯是入棧、出棧的操作,vm_ctx[13]
就是sp
,同樣對應arm的r13
( sp )。
由此可以推斷,每個handler都能轉換成與其等價( 或差不多 )的arm匯編指令。

handler1、handler2對應的arm匯編指令是push
、pop
。
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的偽代碼中直接看出。

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

同樣用frida stalker來驗證,直接打印寄存器( r0
是get_bit
的返回值,r8
是v12
)
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)
});
}
|
發現兩者一致,成功驗證想法。

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狀態寄存器的Z
、N
、C
、V
位。


因此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個標誌位進行條件跳轉。

對應arm的beq
、bne
、bcc
等不同的條件跳轉指令。
注:可能還原得不太正確,但基本能用就不管了。
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
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
指令。

1 2 3 4 5 6 7 8 9 10 11 12 | def handler13_ubfx(opcode):
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
功能一樣都是字節翻轉 )。

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

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
裡會將遇到的所有分支入隊。

start
函數如下:

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

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

Section0:登錄算法還原
用Findcrypt插件分析,發現AES算法的特徵。

與網上找的AES腳本對比,可以看出整體加密結構是一致的,但Sub_Bytes
、Shift_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;
for (i = 0; i < 4; i++)
{
for (j = 0; j < 4; j++)
{
state[j][i] = plaintext[i * 4 + j];
}
}
Add_Round_Key(0);
for (round = 1; round < rounds; round++)
{
Sub_Bytes();
Shift_Rows();
Mix_Columns();
Add_Round_Key(round);
}
Sub_Bytes();
Shift_Rows();
Add_Round_Key(rounds);
for (i = 0; i < 4; i++)
{
for (j = 0; j < 4; j++)
{
encrypted[i * 4 + j] = state[j][i];
}
}
}
|
vm_code
IDA偽代碼:

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

在正式開始算法還原前,需從真實環境提取一組的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的偽代碼扣下來,看看加密結果與真實環境中的是否一致。

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

到底哪裡出了錯暫時不知,先假設vm指令還原那部份沒有出錯,即vm_code
的arm匯編指令是正確的,然後調試看看。
如何調試?對於vm指令還原後的程序,應該不存在一種直接調試的手段,我想到的一種思路是借助Unicorn來間接地調試。
注:這裡的調試並非傳統意義上的打斷點 → 單步執行調試,而是偏向於在某條匯編指令執行後,打印某寄存器、hexdump某內存地址,看看數據是否符合預期。
Unicorn輔助調試算法出錯的原因
先看看Unicorn模擬執行的結果是否與真實環境一致( 沿用上面解混淆的Unicorn代碼 )。
可以看到完全一樣,即模擬執行的環境沒有任何問題。

定義一些輔助函數,方便調試觀察。
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 ]
|
具體調試思路如下:
添加一個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)
|
opcode基本上都會重複,因此要調試vm_code
某地址的匯編指令時,要通過2個opcode才能確定。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | count = - 1
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_code
裡0x7DC
這條匯編指令。

0x7DC
上一條指令對應的opcode為opcodes[0x7D8/4]
= 0x1ac7b000
。
0x7DC
對應的opcode為opcodes[0x7DC/4]
= 0x38d58300
。

利用0x1ac7b000
、0x38d58300
這2個opcode就基本能確定是vm_code
的0x7DC
。

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

按上述調試思路,打印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
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
實現有問題。

然後就是漫長地重複上述的調試過程,嘗試找出那個不符合預期的地方。
看了好幾處運算的地方,本地結果與真實環境中都一樣,直到我看到HIBYTE
,讓我回想起之前同樣在進行算法還原時,就是被HIBYTE
坑了很久,IDA偽代碼中的HIBYTE
實際上是BYTE3
才對。

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

反推解密算法
上一小節中成功修復了IDA扣下來的魔改AES加密算法,以此作為根據 + 參考原版AES解密邏輯 + 問GPT很容易可以得出解密算法。
先簡單分析每個函數,看看需不需要改成逆函數用於解密。
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;
int k;
int j;
int i;
int * v8;
int * v9;
int * v10;
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;
}
|
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;
int j;
int i;
for (i = 0; i < 4; ++i)
{
for (j = 0; j < 4; ++j)
{
v2 = a2++;
*(_BYTE*)(a1 + 4 * j + i) = *v2;
}
}
return 0;
}
|
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)
{
int j;
int i;
_DWORD v5[4];
int v6;
int v7;
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;
}
|
Sub_Bytes
:加密時查詢的是逆S盒,解密時要改成S盒。
1 2 3 4 5 6 7 8 9 10 11 12 | int __fastcall Sub_Bytes( int a1)
{
int j;
int i;
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;
}
|
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;
_DWORD v3[4];
int v4;
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]);
*(_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;
}
|
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 | int __fastcall Mix_Columns( int a1)
{
char v1;
int v3;
int v4;
int v5;
int m;
int k;
int j;
int i;
char v10[32];
int v11;
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
。

輸入後成功進入遊戲。

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

圖片來源:2fdK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6J5k6h3&6&6K9h3I4A6i4K6u0W2L8%4u0Y4i4K6u0r3M7r3!0K6N6q4)9J5c8X3N6S2L8h3g2Q4y4h3k6U0K9r3g2S2N6o6u0Q4x3V1j5`.
繪圖函數
繪圖函數用於輸出透視的結果,K2_DrawLine
是UE4自帶的繪圖函數,是UCanvas
的成員函數。

用ue4dumper將遊戲所有對象dump下來,記為Objects.txt
,在其中搜Canvas,看到有一個DebugCanvasObject
。
這代表通過GObject能遍歷到該對象,然後就可以通過它來調用K2_DrawLine
。

只調用一次K2_DrawLine
繪制的東西無法長久保留在屏幕上,因此要在一個合適的時機不斷調用K2_DrawLine
,這樣繪制的東西才不會立即被刷走。
在SDK裡看到ReceiveDrawHUD
函數,hook後發現它會不斷被某處調用,大概每次繪製遊戲屏幕上的信息時會觸發,在這時機調用K2_DrawLine
繪制透視信息簡直再合適不過。

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


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

無論哪一種,都繼承於AActor
,而AActor
→ RootComponent
→ RelativeLocation
裡保存著座標信息( 世界座標 )。
注:屬性的Offset可以參考SDK裡的,若被魔改過則需要用CE分析看看。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | 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屏幕座標系。這個矩陣是透過相機位置和朝向信息計算出來的。
這個矩陣保存在UCanvas
的ViewProjectionMatrix
,FMatrix
是一個4*4的矩陣。


用frida或ue4dumper獲取DebugCanvasObject
的地址( 假設是0xc0402580 ),然後用CE查看其內存,切換為float類型以便觀察。
當遊戲鏡頭角度發生變化時,紅框的數據也會隨之變化,因此這裡大概就是ViewProjectionMatrix
,偏移為0xC0402790 - 0xC0402580 = 0x210

知道了偏移後,就能用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;
inOutPosition[1] = (0.5 - ((res[1] * rhw) / 2.0)) * 760;
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 | 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對象:PlayerController
→ AcknowledgedPawn
( 它也是繼承於AActor )。
1 2 3 4 5 | function getPlayerLocation() {
if (!PlayerController) return ;
let AcknowledgedPawn = PlayerController.add(Offset.APlayerControllerToAcknowledgedPawn).readPointer();
return getActorLocation(AcknowledgedPawn);
}
|
Player朝向信息:PlayerController
→ ControlRotation
。
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);
}
|
效果圖:

參考資料
- https://bbs.kanxue.com/thread-281464-1.htm#msg_header_h1_0
- https://bbs.kanxue.com/thread-281463.htm
- https://bbs.kanxue.com/thread-282857.htm
[培训]内核驱动高级班,冲击BAT一流互联网大厂工作,每周日13:00-18:00直播授课
最后于 2025-1-27 17:52
被ngiokweng编辑
,原因: 代碼框太丑