作者:沈沉舟
公眾號:青衣十三樓飛花堂

某IoT設備,已經設法搞掂了調試環境,擁有后臺root shell及gdb server。這是個ARM/Linux,上傳ARM版全功能busybox以改善調試環境。netstat看到某個進程(some)偵聽了一堆TCP、UDP端口,顯然該程序是該IoT設備的主程序。

定位UDP端口的處理流程,一般這種會調用recvfrom(),分析后發現some!recvfrom()實際調用libuClibc!recvfrom()。

ssize_t recvfrom
(
    int                 sockfd,
    void               *buf,
    size_t              len,
    int                 flags,
    struct sockaddr    *src_addr,
    socklen_t          *addrlen
);

在IDA中靜態分析libuClibc!recvfrom(),在其尾部尋找適當位置,確保有寄存器或棧變量對應形參buf、src_addr及返回值n,確保此時UDP數據已在buf中。然后在此位置設置條件斷點,比如,當源端口等于0x1314、源IP等于x.x.x.x時顯示讀取的報文:

b *0xhhhhhhhh
commands $bpnum
    silent
    get_big_endian_2 *(char**)($sp+0x28)+2
    set $sport=$ret
    get_big_endian_4 *(char**)($sp+0x28)+4
    set $src=$ret
    if (($src==0xhhhhhhhh)&&($sport==0x1314))
        set scheduler-locking on
        display
        i r r7 r8 r10 r4
        x/2wx $sp+0x28
        db $r8 $r4
    else
        c
    end
end

(示例,勿照搬)

為什么不直接攔截libuClibc!recvfrom()?因為入口無法知道源IP、源端口,也不知道UDP數據,出口知道,可以在出口設條件斷點。為什么不在some!recvfrom()的RetAddr處設斷?因為有很多地方調用some!recvfrom(),調試目的就是找出哪個地方處理所關心的UDP端口。如果能直接找到創建套接字、綁定端口的地方,無需上述方案。這里介紹的是通用調試思路,在靜態分析、交叉引用等手段無法直接定位的情況下仍然有效。

如果some是單線程進程,可以直接攔截libuClibc!recvfrom(),記錄相應形參,設法繼續執行到RetAddr,檢查數據后做出相應動作。欲達此目的,可能比你想像的復雜,參看:

《2.15 GDB斷點后處理commands中finish/until/tb帶來的問題》

找不到單篇的,看這里:

《Unix編程/應用問答中文版》

此次some是多線程進程,有幾十個線程,其中很多會調用libuClibc!recvfrom(),無法使用上述調試技巧。有人可能想,攔截libuClibc!recvfrom(),然后"set scheduler-locking on",然后使用上述調試技巧。這不可行,因為多線程,事先不知道哪個線程處理哪個端口,如果提前鎖定在某一線程,而該線程并不處理所關心的端口,條件斷點可能這輩子都不會命中,即使命中也不是我們關心的命中。

在libuClibc!recvfrom()出口設置條件斷點,稍費點勁的地方是確定哪些寄存器、棧變量保有入口形參的值,肯定不是原來的r0-r3之流的。一旦條件滿足,立即鎖定在當前線程中(set scheduler-locking on),否則單步必飛,線程太多了。

recvmsg()也可以用于讀取UDP報文:

ssize_t recvmsg
(
    int             sockfd,
    struct msghdr  *msg,
    int             flags
);

struct iovec
{
    void           *iov_base;       /* Starting address */
    size_t          iov_len;        /* Number of bytes to transfer */
};

struct msghdr
{
    void           *msg_name;       /* optional address */
    socklen_t       msg_namelen;    /* size of address */
    struct iovec   *msg_iov;        /* scatter/gather array */
    size_t          msg_iovlen;     /* # elements in msg_iov */
    void           *msg_control;    /* ancillary data, see below */
    size_t          msg_controllen; /* ancillary data buffer len */
    int             msg_flags;      /* flags on received message */
};

struct cmsghdr
{
    size_t          cmsg_len;       /* Data byte count, including header */
    int             cmsg_level;     /* Originating protocol */
    int             cmsg_type;      /* Protocol-specific type */
    unsigned char   cmsg_data[1];
};

b *0xhhhhhhhh
commands $bpnum
    silent
    get_big_endian_2 *(char**)$r6+2
    set $sport=$ret
    get_big_endian_4 *(char**)$r6+4
    set $src=$ret
    if (($src==0xhhhhhhhh)&&($sport==0x1314))
        set scheduler-locking on
        display
        i r r7 r6 r5
        db *(*(char***)($r6+8)) $r5
    else
        c
    end
end

(示例,勿照搬)

定位TCP端口的處理流程,一般這種會調用recv():

ssize_t recv
(
    int     sockfd,
    void   *buf,
    size_t  len,
    int     flags
);

不同于recvfrom()、recvmsg(),recv()得不到源IP、源端口,斷點條件需要做些改動,比如,當TCP數據區長度等于11,前4字節等于指定值時顯示讀取的報文:

b *0xhhhhhhhh
commands $bpnum
    silent
    if ($r0==0xb)
        get_big_endian_4 $r4
        set $magic=$ret
        if ($magic==0xhhhhhhhh)
            set scheduler-locking on
            display
            i r r5 r4 r7 r0
            db $r4 $r0
        else
            c
        end
    else
        c
    end
end

(示例,勿照搬)

$ nsfocus_scan -q tcpdata -k 0x1314 -p 1984 -x "scz@nsfocus" -y "\xff\xff\0\0" -b x.x.x.x -t 3600

向1984/TCP發送"scz@nsfocus",源端口0x1314,等待響應報文,讀超時1h。如果前述條件斷點命中,單步回到父函數即可定位1984/TCP的處理流程。發送觸發報文時設置一個較大的讀超時是必要的,因為TCP不像UDP,后者發出去就算完事了,TCP得保持連接不斷,否則在服務端的交互式調試不長久。

read()也可以用于讀取TCP報文:

ssize_t read
(
    int     fd,
    void   *buf,
    size_t  count
);

在IDA中分析這些讀取函數時,可能碰上Linux系統調用。參看syscall(2),講了各種CPU架構如何傳遞系統調用號、參數。比如我碰上的這個,"svc 0"相當于x86的"int 0x80",r7對應系統調用號,r0-r6用于傳遞參數。

前面針對recv()的條件斷點有坑,當時假設服務器會一次性讀取來自客戶端的TCP數據,對讀取到的字節數做了不恰當的約束。312/TCP端口的處理流程是先讀4字節長度域,對長度域進行某些判斷之后再繼續讀取后續數據,結果前述recv()條件斷點不會命中,因為recv()出口TCP數據區長度等于4,不等于11,我發的觸發報文不會觸發條件斷點。后修改條件斷點如下:

b *0xhhhhhhhh
commands $bpnum
    silent
    if ($r0>=4)
        get_big_endian_4 $r4
        set $magic=$ret
        if ($magic==0xhhhhhhhh)
            set scheduler-locking on
            display
            i r r5 r4 r7 r0
            db $r4 $r0
        else
            c
        end
    else
        c
    end
end

(示例,勿照搬)

本來在recv()出口對TCP數據區長度進行相等檢查,是為了增強約束條件,減少誤命中,適用于1984/TCP,卻不適用于312/TCP。起初我忽略了這種可能性,以至于無法定位312/TCP的處理流程,還很奇怪,難道有什么其他系統調用可以讀取網絡報文?

修改過的條件斷點只是針對312/TCP,如果某TCP端口先讀1字節、2字節、3字節等等,就需要做出相應修改。很難弄一個普適的條件斷點,只能基于對系統的理解、長期積累的經驗,在某種條件斷點未能如愿命中時做出合理猜測并調整約束條件。

直接攔截對網絡報文的讀取設置條件斷點是一種比較直白的調試方案,一旦有效命中,其附近代碼馬上就是對客戶端可控數據的協議解碼,如果某端口在處理未久經考驗的私有協議,就開始挖吧。

可以在some中尋找socket()、bind()、listen()、accept()。

對于bind(),由形參可知綁定的端口號。動態攔截bind()有時并不適用,比如因各種原因只能Attch,不能從some啟動之初開始調試,而Attach時已經bind()結束,攔截讀函數不存在這種限制。可以嘗試靜態分析bind()的主調函數,直接確定綁定的端口號。即使這樣,也不能完全取代攔截讀函數,考慮那些復雜網絡服務框架,bind()與讀操作相差十萬八千里。

對于TCP,動態攔截accept()是一種選擇,無論some是否fork(),TCP的accept()是必經的。假設主調者給accpet()第2、3形參傳NULL,此時無法對源IP、源端口設約束條件。但是,在一段時間內除了你主動發起的TCP連接很可能沒有其他新的TCP連接出現,此時命中accept()本身就具有排他性。

動態攔截網絡函數的另一個坑是,假設同時存在liba!read()、libb!read(),來幾層封裝、動態加載啥的,很可能只注意到liba!read(),而沒有意識到libb!read()的存在。在用戶態使用gdb調試,想直接攔截所有的__NR_read就算了吧。這種時候不要懷疑人生,不要假設靈異事件存在,要堅信libb!read()的存在,但如何找出它,沒有固定套路,見招拆招吧。


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