背景
?? Unicorn 是一款基于 QEMU 的快速 CPU 模擬器框架,可以模擬多種體系結構的指令集,包括 ARM、MIPS、PowerPC、SPARC 和 x86 等。Unicorn使我們可以更好地關注 CPU 操作, 忽略機器設備的差異。它能夠在虛擬內存中加載和運行二進制代碼,并提供對模擬器狀態的完全控制,包括內存、寄存器和標志位等。該項目最初是作為一個 QEMU 插件而啟動的,但隨著時間的推移,它已經成長為一款獨立的模擬器框架。現在 Unicorn 在許多領域都有應用,如二進制代碼分析、系統仿真、漏洞測試等。
unicorn基礎
??? unicorn安裝:
?
pip3 install?unicorn
?
????手動編譯方式如下:
?
wget https://github.com/unicorn-engine/unicorn/archive/2.0.1.zip unzip 2.0.1.zip cd?unicorn-2.0.1/bingings/python sudo make?install
?
????在unicorn/bindings/python目錄下,下面有官方提供的example腳本可以供我們學習。
?
from?unicorn import?* # 在使用Unicorn前導入unicorn模塊. 樣例中使用了一些x86寄存器常量, 所以也需要導入unicorn.x86_const模塊 from?unicorn.x86_const import?* # 需要模擬的二進制機器碼, 需要使用十六進制表示, 代表的匯編指令是: "INC ecx" 和 "DEC edx",即ecx+=1,edx-=1 X86_CODE32 = b"x41x4a"?# INC ecx; DEC edx # 我們將模擬執行上述指令的所在虛擬地址 ADDRESS = 0x80000 print("Emulate i386 code") try: ??# 使用Uc類初始化Unicorn, 該類接受2個參數: 硬件架構和32/64位(模式),在這里我們需要模擬執行x86架構的32位代碼, 并使用變量mu來接受返回值。 ??mu = Uc(UC_ARCH_X86, UC_MODE_32) ??# 使用mem_map函數根據ADDRESS映射2MB用于模擬執行的內存空間。所有進程中的CPU操作都應該只訪問該內存區域,映射的內存具有默認的讀,寫和執行權限。 ??mu.mem_map(ADDRESS, 2?* 1024?* 1024) ??# 將需要模擬執行的代碼寫入我們剛剛映射的內存中。mem_write函數2個參數: 要寫入的內存地址和需要寫入內存的代碼。 ??mu.mem_write(ADDRESS, X86_CODE32) ??# 使用reg_write函數設置ECX和EDX寄存器的值 ??mu.reg_write(UC_X86_REG_ECX, 0x1234) ??mu.reg_write(UC_X86_REG_EDX, 0x7890) ??# 使用emu_start方法開始模擬執行, 該函數接受4個參數: 要模擬執行的代碼地址, 模擬執行停止的內存地址(這里是X86_CODE32的最后1字節處), 模擬執行的時間和需要執行的指令數目。如果我們忽略后兩個參數, Unicorn將會默認以無窮時間和無窮指令數目的條件來模擬執行代碼。 ??mu.emu_start(ADDRESS, ADDRESS + len(X86_CODE32)) ??# 我們使用reg_read函數來讀取寄存器中的值,打印輸出ECX和EDX寄存器的值。 ??print("Emulation done. Below is the CPU context") ??r_ecx = mu.reg_read(UC_X86_REG_ECX) ??r_edx = mu.reg_read(UC_X86_REG_EDX) ??print(">>> ECX = 0x%x"?%r_ecx) ??print(">>> EDX = 0x%x"?%r_edx) except?UcError as?e: ??print("ERROR: %s"?% e)
?
????上面的代碼大致過程設置虛擬地址并初始化unicorn引擎,并設置內存映射空間,隨后將要模擬執行代碼寫入內存虛擬空間中。程序執行前給ecx寄存器賦值為0x1234,edx寄存器賦值為0x7890,當執行emu_start函數時,程序從要模擬代碼的開始進行執行。此時運行仿真代碼為"INC ecx; DEC edx"。即,對ecx進行加一操作,對edx進行減一操作。運行后打印結果如下:
????我們可以從項目中所有的example案例中可以提取出unicorn模板腳本:
?
from?unicorn import?* from?unicorn.x86_const import?* # 相應架構的常量信息: # arch:UC_ARCH_ARM、UC_ARCH_ARM64、UC_ARCH_M68K、UC_ARCH_MAX、UC_ARCH_MIPS、UC_ARCH_PPC、UC_ARCH_SPARC、UC_ARCH_X86 # mode:UC_MODE_16、UC_MODE_32、UC_MODE_64、UC_MODE_ARM、UC_MODE_BIG_ENDIAN、UC_MODE_LITTLE_ENDIAN、UC_MODE_MCLASS、UC_MODE_MICRO、UC_MODE_MIPS3、UC_MODE_MIPS32、UC_MODE_MIPS32R6、UC_MODE_MIPS64、UC_MODE_PPC32、UC_MODE_PPC64、UC_MODE_QPX、UC_MODE_SPARC32、UC_MODE_SPARC64、UC_MODE_THUMB、UC_MODE_V8、UC_MODE_V9 # 該模板中的UC_ARCH_X86可替換成為其他架構的常量,且相應寄存器常量名稱也要相應改變。 # 定義要執行的指令 CODE = b"xXX" # 指定內存地址 BASE_ADDRESS = 0x100000 # 定義hook函數 def?hook_code(uc, address, size, user_data): ??# 輸出寄存器值和內存內容 ??print("[+] RIP=0x%x RAX=0x%x RBX=0x%x RCX=0x%x RDX=0x%x"?% (uc.reg_read(UC_X86_REG_RIP), uc.reg_read(UC_X86_REG_RAX), uc.reg_read(UC_X86_REG_RBX), uc.reg_read(UC_X86_REG_RCX), uc.reg_read(UC_X86_REG_RDX))) ??print("[+] Memory:") ??for?i in?range(0x1000): ??????if?uc.mem_read(BASE_ADDRESS+i, 1) != b'x00': ??????????print("0x%x: %s"?% (BASE_ADDRESS+i, uc.mem_read(BASE_ADDRESS+i, 16).hex())) # 初始化 Unicorn 引擎和內存空間 mu = Uc(UC_ARCH_X86, UC_MODE_64) mu.mem_map(BASE_ADDRESS, 0x10000) mu.mem_write(BASE_ADDRESS, CODE) # 設置 RIP 和 RSP mu.reg_write(UC_X86_REG_RIP, BASE_ADDRESS) mu.reg_write(UC_X86_REG_RSP, BASE_ADDRESS + 0x10000) # 注冊hook函數 mu.hook_add(UC_HOOK_CODE, hook_code) # 開始模擬執行 mu.emu_start(BASE_ADDRESS, BASE_ADDRESS + len(CODE))
?
????注:函數(或鉤子函數)是一種用戶自定義函數,用于在模擬執行指令時對特定事件進行處理。當程序執行到某個地址時,引擎會調用已注冊的 Hook 函數,并將當前的 CPU 狀態、指令地址和指令大小等信息傳遞給函數。這樣,用戶就可以利用 Hook 函數來監測程序的執行狀態、修改寄存器/內存值,或者實現其他自定義功能。
unicorn實例
????以ctf題目為例,下載題目附件后,拖入IDA進行分析。進行main函數分析發現整個程序執行完畢后就會輸出flag的值,不考慮指令集架構及運行程序的情況下,正常逆向思路便是逆向程序邏輯以及函數代碼并編寫相應解密程序進行運行獲取flag。
??? sub_400670函數為加密函數,如果我們基礎不夠或者并不會逆向,這里便可以使用unicorn仿真執行程序。那么這里unicorn仿真的整體流程就是仿真執行整個main函數,main函數地址為0x4004E0~0x400475。
????根據以上我們得出的信息,對模板腳本進行修改:
?
from?unicorn import?* from?unicorn.x86_const import?* # 定義要執行的指令 def?read(name): ??with?open(name,"rb") as?f: ??????return?f.read() # 指定內存地址 BASE_ADDRESS = 0x400000 STACK_ADDR = 0x0 STACK_SIZE = 1024*1024 # 定義hook函數 def?hook_code(uc, address, size, user_data): ??print('>>> Tracing instruction at 0x%x, instruction size = 0x%x'?%(address, size)) # 初始化 Unicorn 引擎和內存空間 mu = Uc (UC_ARCH_X86, UC_MODE_64) mu.mem_map(BASE_ADDRESS, 1024*1024) mu.mem_map(STACK_ADDR, STACK_SIZE) # 設置 RIP 和 RSP mu.mem_write(BASE_ADDRESS, read("./test")) mu.reg_write(UC_X86_REG_RSP, STACK_ADDR + STACK_SIZE - 1) # 注冊hook函數 mu.hook_add(UC_HOOK_CODE, hook_code) # 開始模擬執行 mu.emu_start(0x00000000004004E0, 0x0000000000400575)
?
????運行發現在地址0x4004ef處的指令在運行時報錯了。unicorn顯示Invalid memory read,猜測為地址讀取問題。
????逆向代碼發現,報錯處的指令為將mov rdi, cs:stdout,原因是由于沒有設置cs寄存器以及stdout stream地址導致無法訪問。但是這條指令對我們仿真結果沒有影響,我們可以手動對程序報錯地址0x4004ef處的指令進行patch,使其跳過。patch好以后,再次運行發現又報錯了。
????逆向發現,此處和上面形成原因類似,訪問了沒有設置bss段地址,并且也對仿真結果沒有影響。循環往復patch并運行后發現在0x4004EF,0x4004F6,0x400502,0x40054F 地址處都會報錯。
????針對于以上遇到的問題的出現并沒有對結果產生影響,我們可以在代碼中手動過濾這些地址,使其跳過。隨后在程序執行最后put函數,我們可以取出打印結果。腳本中添加代碼如下:
?
nop_address = [0x00000000004004EF, 0x00000000004004F6, 0x0000000000400502, 0x000000000040054F] def?hook_code(mu, address, size, user_data):?? ??if?address in?nop_address: ??????mu.reg_write(UC_X86_REG_RIP, address+size) ??elif?address == 0x400560: ??????c = mu.reg_read(UC_X86_REG_RDI) ??????print(chr(c)) ??????mu.reg_write(UC_X86_REG_RIP, address+size)
?
????運行腳本后,我們已經可以讓程序在運行解密過程了。但是速度極慢,5分鐘打印3個字符。
????在調試過程中我們發現在運行sub_400670函數時,程序內部條件分支會不斷調用函數自身,從而進入遞歸狀態,導致解密時間非常長。我們想到在參數一致的情況下,重復執行sub_400670函數非常占用資源和時間,這里對其進行優化。思路如下,我們可以使用棧空間來保存一個不同輸入參數以及對應計算結果的字典來避免重復計算。具體可分為參數保存和返回值取出倆個步驟:
????參數保存步驟為:當程序運行到 sub_400670 函數時,會讀取函數的兩個輸入參數(x86_64架構中倆個參數分別保存在rdi和rsi寄存器中),將(arg0, arg1)保存一下。然后,我們檢查字典中是否包含這個元組作為鍵的條目。如果存在,說明之前已經計算過這個函數,可以直接從字典中取出對應的計算結果并執行ret進行返回。如果不存在,則說明對應參數的函數還沒有被計算過,程序需要進行函數運算。返回值取出的步驟為:當我們將輸入參數壓入一個棧中,程序執行完成后,會執行到到函數的結尾處(ret),我們可以在此處取出函數的返回值,并將其存儲在 (ret_rax, ret_ref) 中(ret_rax 是函數返回值,ret_ref 是保存返回值的地址)。然后,我們將這個元組作為值,將 (arg0, arg1) 作為鍵,將其存儲到字典 d 中。這樣,下一次計算相同參數的sub_400670函數時,就可以直接從字典中取出對應的計算結果,而無需再次進行計算。
????在原來的腳本之上,我們添加的代碼如下:
?
from?pwn import?* stack = [] direct = {} ENTRY = [0x0000000000400670] END = [0x00000000004006F1, 0x0000000000400709] def?hook_code(mu, address, size, user_data): ??if?address in?ENTRY: ??????arg0 = mu.reg_read(UC_X86_REG_RDI) ??????r_rsi = mu.reg_read(UC_X86_REG_RSI) ??????arg1 = u32(mu.mem_read(r_rsi, 4)) ??????if?(arg0,arg1) in?direct: ??????????(ret_rax, ret_ref) = direct[(arg0,arg1)] ??????????mu.reg_write(UC_X86_REG_RAX, ret_rax) ??????????mu.mem_write(r_rsi, p32(ret_ref)) ??????????mu.reg_write(UC_X86_REG_RIP, 0x400582) ??????else: ??????????stack.append((arg0,arg1,r_rsi)) ??????? ??elif?address in?END: ??????(arg0, arg1, r_rsi) = stack.pop() ??????ret_rax = mu.reg_read(UC_X86_REG_RAX) ??????ret_ref = u32(mu.mem_read(r_rsi,4)) ??????direct[(arg0, arg1)]=(ret_rax, ret_ref)
?
????此時再次運行腳本,爆破速度提升,且運行結果已經完全顯示。
????unicorn也固件解密中也發揮了重要作用,在文章(https://www.shielder.com/blog/2022/03/reversing-embedded-device-bootloader-u-boot-p.2)中,作者通過對某華設備固件進行逆向分析以及unicorn仿真執行解密出了kernel文件。大致思路如下,作者通過binwalk提取固件,發現固件已經被加密,并對提取出來的部分進行分析后發現uboot.bin具有可利用信息。
????對uboot.bin進行逆向分析后,通過開源uboot代碼恢復符號表,定位出了uboot解密kernel時的對應加密函數。
????在解密算法時發現uboot載入kernel.img并對其進行AES解密。解密共有倆種方法,一種方法為逆向解密,需要一定的逆向技術才可完成,較復雜。另一種方式便是使用unicorn仿真執行解密函數,該種方法較為簡單便捷。這里選取了第二種方式來解密,核心代碼如下,代碼使用 unicorn 待解密文件加載到虛擬內存并執行模擬執行解密代碼 ,并使用disas_single函數打印出此時正在執行的匯編指令來便于我們調試。
????執行后便解密出了vmlinux前512字節,完善腳本后便可解密整個vmlinux文件。隨后,我們可以使用vmlinux-to-elf工具對vmlinux恢復函數符號表。一般情況下,linux下對固件升級和固件加密都是放在內核完成的,接下來我們對kernel文件進行逆向分析就可能得出rootfs的解密流程。
??? unicorn除了在ctf和固件解密方向有實質性作用,在漏洞挖掘的fuzz方向也具有一定研究價值,但是基于unicorn的fuzzer較為復雜且難度較高。與此同時,qiling框架的出現使得仿真fuzz變得較為簡單。qiling是一個基于unicorn引擎開發的高級框架,它可以利用unicorn來模擬CPU指令,但是它同樣可以理解操作系統上下文,它集成了可執行文件格式加載器、動態鏈接、系統調用和I/O處理器。更重要的是,qiling可以在不需要原生操作系統的環境下運行可執行文件源碼。現階段來看qiling框架更加適合安全研究人員,這也是我們后面需要學習的內容。
總結
????這一小節,我們學習了unicorn框架的使用基礎,并通過一道ctf題目仿真并解出了flag。同時學習了unicorn在固件解密方向的思路,使我們更加了解unicorn框架。
審核編輯:劉清
評論
查看更多