作者:沈沉舟
公众号:青衣十三楼飞花堂

某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()的存在,但如何找出它,没有固定套路,见招拆招吧。


源链接

Hacking more

...