banner
raye~

Raye's Journey

且趁闲身未老,尽放我、些子疏狂。
medium
tg_channel
twitter
github
email
nintendo switch
playstation
steam_profiles

Unicorn 模擬執行工具使用

unicorn 模擬

認識模擬執行#

我們很多時候都喜歡用真機來調試,因為真機環境是最好的,但有時候我們找不到一個真機環境,或者環境很難復現(比如路由器分析等),此時自然而然就會想到模擬器,類似 Windows 上的夜神模擬器,Linux 的 qemu 模擬器。

但是模擬器還是存在一定的局限性,比如夜神模擬器其實是只能運行 x86_64 的指令集,此時模擬執行框架就應運而出了。

Unicorn 就是一款非常優秀的跨平台模擬執行框架,該框架可以跨平台執行 Arm, Arm64 (Armv8), M68K, Mips, Sparc, & X86 (include X86_64) 等指令集的原生程序。

使用 unicorn#

我比較喜歡開門見山地直接演示代碼執行,安裝的話比較簡單,pip install 就好。

那麼在使用 unicorn 的過程中,需要有幾個步驟:

  • step1: mu = Uc(UC_ARCH_ARM, UC_MODE_THUMB) 初始化一個虛擬機,選擇你的指令集和架構
  • step2: mu.mem_map(ADDRESS, 2 * 0x10000) 映射一段內存區域
  • step3: mu.mem_write(ADDRESS, ARM_CODE) 往內存區域中寫入一段代碼
  • step4: mu.reg_write 設置寄存器的值,即運行代碼上下文的環境
  • step5: mu.emu_start 開機模擬
  • step6: 監控運行過程中的狀態,比如單條指令執行結果,寄存器值的變化,類似 gdb 調試一樣

了解了上述過程,我想模擬執行的大致流程就出來了,如下是實際運行一段代碼

from unicorn import *
from unicorn.arm_const import *
ARM_CODE   = b"\x37\x00\xa0\xe3\x03\x10\x42\xe0"
# mov r0, #0x37;
# sub r1, r2, r3
# Test ARM
 
# callback for tracing instructions
def hook_code(uc, address, size, user_data):
    print(">>> Tracing instruction at 0x%x, instruction size = 0x%x" %(address, size))
 
def test_arm():
    print("Emulate ARM code")
    try:
        # Initialize emulator in ARM mode
        mu = Uc(UC_ARCH_ARM, UC_MODE_THUMB)
 
        # map 2MB memory for this emulation
        # uc_mem_map 映射的內存 address和size都要和0x1000對齊
        ADDRESS = 0x10000
        mu.mem_map(ADDRESS, 2 * 0x10000)
        mu.mem_write(ADDRESS, ARM_CODE)
 
        mu.reg_write(UC_ARM_REG_R0, 0x1234)
        mu.reg_write(UC_ARM_REG_R2, 0x6789)
        mu.reg_write(UC_ARM_REG_R3, 0x3333)

        # hook UC_HOOK_CODE 是指令級別的hook
        mu.hook_add(UC_HOOK_CODE, hook_code, begin=ADDRESS, end=ADDRESS)
        # emulate machine code in infinite time
        mu.emu_start(ADDRESS, ADDRESS + len(ARM_CODE))
        r0 = mu.reg_read(UC_ARM_REG_R0)
        r1 = mu.reg_read(UC_ARM_REG_R1)
        print(">>> R0 = 0x%x" % r0)
        print(">>> R1 = 0x%x" % r1)
    except UcError as e:
        print("ERROR: %s" % e)

test_arm()

如上代碼,我們想要模擬執行一段 arm 的代碼,當然代碼很簡單,你可以一眼就看出運行結果

mov r0, #0x37
sub r1, r2, r3

此時我們要做的就是按照 step1~step6 的過程來編寫代碼,然後這裡重點講一下是如何監控調試的。

Unicorn 提供了指令級別的 hook,只需要編寫回調的函數,就能監控上下文。

# callback for tracing instructions
def hook_code(uc, address, size, user_data):
    print(">>> Tracing instruction at 0x%x, instruction size = 0x%x" %(address, size))
         

... 省略代碼
		# hook UC_HOOK_CODE 是指令級別的hook
        mu.hook_add(UC_HOOK_CODE, hook_code, begin=ADDRESS, end=ADDRESS)

最後我們讀取執行完這段代碼後的寄存器結果如下:

❯ python unicorn_t2.py
Emulate ARM code
>>> Tracing instruction at 0x10000, instruction size = 0x4
>>> R0 = 0x37
>>> R1 = 0x3456

unicorn 能做什麼?#

既然用了如此強大的模擬執行框架,那麼我們就可以用來… 寫匯編!

笑死,以前我想模擬跑一個 x86 或者 arm 匯編的時候,還會為環境模擬而頭疼,此時有了 unicorn,那就可以很輕鬆地寫任何語言的匯編了嘛!

比如我們用 arm 來寫一個計算斐波那契數列的匯編,學習一下 arm 的指令。

三個寄存器就夠了,互相做加法,最後的代碼:

.global main
main:
    MOV R0, #10          // 設置斐波那契數列的長度為10
    MOV R1, #0           // 初始化第一個斐波那契數為0
    MOV R2, #1           // 初始化第二個斐波那契數為1
loop:
    CMP R0, #0           // 檢查計數器是否為0
    BEQ end              // 如果計數器為0,則跳轉到end
    ADD R3, R1, R2       // 計算下個斐波那契數
    MOV R1, R2           // 更新第一個斐波那契數為當前第二個斐波那契數
    MOV R2, R3           // 更新第二個斐波那契數為剛計算出的數
    SUB R0, R0, #1       // 計數器減1
    B loop               // 跳轉回循環開始處
end:
    // 結束程序

arm 匯編指令學習#

記錄下常見的 arm 匯編指令如下:

  1. MOV: 數據傳輸指令。用於將立即數或另一個寄存器的值移動到一個寄存器。例如,MOV R0, #10 將 10 移動到寄存器 R0。
  2. CMP: 比較指令。用於比較兩個寄存器的值。結果不存儲,但影響狀態寄存器(設置條件標誌)。例如,CMP R0, #0 比較 R0 和 0。
  3. BEQ: 條件分支指令。如果最近的 CMP 結果為相等,則跳轉到標籤。例如,BEQ end 如果條件滿足(相等)則跳轉到 end
  4. ADD: 加法指令。將兩個寄存器的值相加並存儲到另一寄存器。例如,ADD R3, R1, R2 把 R1 和 R2 的值相加,結果存儲在 R3。
  5. SUB: 減法指令。從一個寄存器的值中減去另一個寄存器的值或立即數。例如,SUB R0, R0, #1 是將 R0 的值減 1。
  6. B: 無條件跳轉指令。跳轉到指定的標籤。例如,B loop 無條件跳轉回 loop 標籤。
  7. LDR/STR (Load/Store): 用於從內存加載數據到寄存器或將數據從寄存器存儲到內存。
    • 示例:LDR R3, [R1] 從由 R1 指定的內存地址加載數據到 R3。
    • 示例:STR R3, [R1] 將 R3 的數據存儲到由 R1 指定的內存地址。
  8. BL/BLX (Branch with Link/Branch with Link and Exchange): 用於函數調用,保存返回地址到鏈接寄存器(LR)。
  • 示例:BL function_name 調用名為 function_name 的函數,並將返回地址保存在 LR 中。
  1. PUSH/POP (Stack Push/Stack Pop): 用於操作堆棧,通常在函數調用中保存和恢復寄存器。
    • 示例:PUSH {R0, R1, LR} 將 R0, R1, 和鏈接寄存器 LR 壓棧。
    • 示例:POP {R0, R1, LR} 從堆棧中彈出數據到 R0, R1, 和 LR。
  2. BNE, BGT, BLE, etc. (Branch if Not Equal, Branch if Greater Than, Branch if Less or Equal, etc.): 條件分支指令,基於 CMP 指令設置的標誌進行分支。
  • 示例:BNE somewhere 如果最後一次比較結果不相等,則跳轉到 somewhere

模擬執行#

這裡因為我們是直接寫的 arm 匯編代碼,機器其實是不認識的,因此還需要借助 keystone 工具來將其轉為機器碼,轉為機器碼之後就可以模擬執行了。

from unicorn import *
from unicorn.arm_const import *
from keystone import *

# ARM匯編代碼
arm_code = """
.global main
main:
    MOV R0, #10          // 設置斐波那契數列的長度為10
    MOV R1, #0           // 初始化第一個斐波那契數為0
    MOV R2, #1           // 初始化第二個斐波那契數為1
loop:
    CMP R0, #0           // 檢查計數器是否為0
    BEQ end              // 如果計數器為0,則跳轉到end
    ADD R3, R1, R2       // 計算下個斐波那契數
    MOV R1, R2           // 更新第一個斐波那契數為當前第二個斐波那契數
    MOV R2, R3           // 更新第二個斐波那契數為剛計算出的數
    SUB R0, R0, #1       // 計數器減1
    B loop               // 跳轉回循環開始處
end:
    // 結束程序
"""

# 初始化Keystone引擎
ks = Ks(KS_ARCH_ARM, KS_MODE_ARM)

# 將ARM匯編代碼編譯為二進制代碼
arm_code_binary, _ = ks.asm(arm_code.encode())

# 設置模擬器
mu = Uc(UC_ARCH_ARM, UC_MODE_ARM)

# 分配內存空間並將ARM代碼加載到內存中
ADDRESS = 0x1000000
mu.mem_map(ADDRESS, 0x1000)
mu.mem_write(ADDRESS, bytes(arm_code_binary))

# 設置寄存器初始值
mu.reg_write(UC_ARM_REG_SP, 0x7fffffff)

# 定義hook函數,在每條指令執行後輸出寄存器的值
def hook_code(uc, address, size, user_data):
    print(f"Instruction at 0x{address:x} executed")
    # 寄存器名稱映射
    reg_names = {
        UC_ARM_REG_R0: 'R0',
        UC_ARM_REG_R1: 'R1',
        UC_ARM_REG_R2: 'R2',
        UC_ARM_REG_R3: 'R3',
    }
    for reg in [UC_ARM_REG_R0, UC_ARM_REG_R1, UC_ARM_REG_R2, UC_ARM_REG_R3]:
        reg_value = mu.reg_read(reg)
        reg_name = reg_names.get(reg, 'Unknown')
        print(f"{reg_name}: {reg_value}")
    print()

# 添加hook函數,在每條指令執行後觸發
mu.hook_add(UC_HOOK_CODE, hook_code)

# 開始模擬執行
try:
    mu.emu_start(ADDRESS, ADDRESS + len(arm_code_binary))
except UcError as e:
    print(f"Error: {e}")

# 輸出寄存器值
print("R1:", mu.reg_read(UC_ARM_REG_R1))
print("R2:", mu.reg_read(UC_ARM_REG_R2))
print("最終結果 R3:", mu.reg_read(UC_ARM_REG_R3))

解題#

網上能找到的比較有意思的題目應該就是這道 100mazes 了,參考:  例題:MTCTF2021 100mazes 

題目的要求是: 有 100 個迷宮,需要給出每個迷宮的路線,最後提交這些路線的 md5 結果作為 flag。

那肯定是不能直接手動求解啊,於是觀察每個迷宮的代碼:

企業微信截圖_4c99ea50-1a32-4a7d-bb22-af38a9bd407c

然後每個函數其實都是讀取迷宮數據,再根據你的輸入路線判斷能否到達終點:

DraggedImage

那麼就可以編寫代碼,提取到迷宮數據,然後再通過 dfs 搜索能否走到迷宮了(其實不用模擬執行也不是不行?畢竟你也可以通過寫腳本比如 idapython 提取迷宮數據,然後再編寫代碼走迷宮)。

這裡就重點分析下他是如何用 unicorn 去模擬執行代碼的,雖然 unicorn 是能直接模擬指令層面的,但是對於類似 printf 這類函數就無能為力了,因此需要做額外處理。

另外就是這裡除了寄存器,還需要模擬一個堆棧。

關鍵代碼就是這個,

def hook_code(uc, address, size, user_data):
    global map_data, str_map, ans_map, ans, all_input
    # print('>>> Tracing instruction at 0x%x, instruction size = 0x%x' % (address, size))
    assert isinstance(uc, Uc)
    code = uc.mem_read(address, 4)
    if code == b"\x48\x0F\xC7\xF0":
        uc.reg_write(UC_X86_REG_RIP, address + 4)        #遇見rdrand rax直接跳過

    if address == 0x640:                                 #遇見printf ret
        rsp = uc.reg_read(UC_X86_REG_RSP)
        retn_addr = u64(uc.mem_read(rsp, 8))
        uc.reg_write(UC_X86_REG_RIP, retn_addr)
    elif address == 0x650:                               #遇見getchar  讀取迷宮
        rbp = uc.reg_read(UC_X86_REG_RBP)
        maze_data = uc.mem_read(rbp - 0xC6A, 0x625)      #迷宮數據
        step_data = uc.mem_read(rbp - 0x9F9, 4).decode() #方向數據
        xor_data = uc.mem_read(rbp - 0x9D0, 0x9C4)       #異或數據
        lr_val = u32(uc.mem_read(rbp - 0x9F4, 4))        #起點x
        ur_val = u32(uc.mem_read(rbp - 0x9F0, 4))        #起點y

        maze_data = list(maze_data)                      #異或
        for i in range(0, 0x9C4, 4):
            maze_data[i // 4] ^= u32(xor_data[i: i + 4])

        for i in range(25):                              #合成最終的迷宮
            line_data = ""
            for j in range(25):
                line_data += chr(maze_data[i * 25 + j])
            # print(line_data)

        map_data = maze_data
        str_map = step_data
        ans = ""
        assert dfs(0, -1, -1, lr_val, ur_val)           #深搜
        # print(ans)
        all_input += ans

                                                       # leave;ret
        rbp = uc.reg_read(UC_X86_REG_RBP)
        new_rbp = u64(uc.mem_read(rbp, 8))
        retn_addr = u64(uc.mem_read(rbp + 8, 8))
        uc.reg_write(UC_X86_REG_RBP, new_rbp)
        uc.reg_write(UC_X86_REG_RSP, rbp + 0x18)
        uc.reg_write(UC_X86_REG_RIP, retn_addr)
載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。