前言 很少接觸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模擬執行解混淆 主要有以下兩種混淆,都可以算是間接跳轉:
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會像這樣:
注:可能會存在這種只有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}
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會像這樣:
解混淆思路 解混淆的思路 & 腳本來源於: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
一些不足之處
針對blx
間接跳轉的解混淆,會導致第0個參數被覆蓋
原本是這樣的:
腳本Patch後是這樣的:
可以看到mov r0, r10
這句被覆蓋了,而這句正是get_bit
函數的第0個參數的賦值指令。
針對it.cc
間接跳轉的解混淆,會導致一些上下文缺失
紅框那部份代碼除了在計算間接跳轉的地址外,還包含一些後續需要的上下文,但本腳本的patch選擇直接從藍框那裡跳轉,導致缺失了紅框中的一些上下文。
最終會影響IDA F5的分析:這個0x60
應該是vm_ctx[b4]
才對。
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
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取opcode2. 調用bswap翻轉opcode3. pc += 8 4. switch handler5. 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 0123456789 ABCDEF c0889100 d5 1 d 78 a3 4f 12 05 88 18 a2 f8 d5 44 56 a7 d2 ..x.O.......DV.. c0889110 d5 1 d 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 49 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的大體框架如下,實現透視、自瞄所需的變量大致都列了出來。
圖片來源:https://renyili.org/post/game_cheat2/
繪圖函數 繪圖函數用於輸出透視的結果,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); }
效果圖:
參考資料