小米IoT私有协议的漏洞挖掘尝试(十一)

尝试用发包的方式来让客户端进入新的逻辑,节省函数指针的间接引用所需要的大量静态分析时间。

于是,我修改补了一段畸形的http报文:

截图_2026-11-01_22-44-16.png

日志在开到最大的情况下,可以看到http_in_close函数,这是以前从未见过的,可以猜测得知这大概率是之前那几个结构体里的某个函数指针,在http_resp_body_recving进行了调用,进入了这段逻辑:

截图_2026-11-01_22-46-02.png

事实证明,httpdns成功接收了包。

但是光发包还不行,还得尝试让它有意义,而这里的jsmi_parse_start与一个名为cjson的库有关,这里恰好与我最初处理的json结构体联系起来,其恰好就是cjson的核心结构体jsmntok_t,其作用就是用扁平数组的方式来存储一个json字段。

截图_2026-11-01_22-50-06.png

截图_2026-11-01_22-50-16.png

大概逆向通读了一下http_in_close这个函数,发现它主要是在检查和接收几个键值对,于是尝试构造一段符合要求的json文本:

截图_2026-11-01_23-00-43.png

成功触发了http_in_close的校验逻辑,证明这种伪造是有效的,但是报错了enable disable,但是enable明明已经拼进了json字段中,猜测可能需要嵌套进去:

截图_2026-11-01_23-03-35.png

修改了enable的位置,这次没有字段缺失报错,显示了jsmi parse err,找到报错位置:

截图_2026-11-01_23-05-25.png

问题就出在jsmn_parse的返回值上,jsmn_parse同样是cjson的库函数,其作用是把第二个参数的字符串指针指向的json文本转化为tokens的形式(也就是之前所见的扁平数组)。

显然,jsmn_parse的返回值是一个负数错误码,进而引发了后续的问题,所以这里看一下具体是哪个状态码:

截图_2026-11-01_23-35-05.png

返回-2,表示的是“token数不足”,所以应该尝试减少json字段的嵌套深度,或者键值对数量,来强行把数据合法地塞进去。

小米IoT私有协议的漏洞挖掘尝试(十)

socat实在是太好用了。

在目录里挂了一个名为gslb的文件后,成功返回了200状态码:

QQ20261017-222617.png

但实际上,它还是出现在了[E]日志里,仔细比对后,发现http.server返回的HTTP的版本号是1.0,而miio_client希望接收的协议版本号是1.1,所以直接把包丢弃了:

QQ20261017-221014.png

考虑到为此专门部署一个httpd或者用http.server模块实现一个简易的问答模型实在没必要,我干脆用socat转发stdout来模拟一次回包。

写一个简易脚本向stdout输出固定格式的http报文:

截图_2026-10-20_15-32-59.png

敲下socat -v TCP-LISTEN:53000,reuseaddr,fork EXEC:./res.sh,实现监听与转发,这样,脚本就会在tcp连接建立2秒后发出报文。

(这里反复尝试了好几次,ARP的欺骗效果不太稳定,不知道是不是缓存残留,miio_client多次试图连接原本的小米云。)

截图_2026-10-20_15-31-56.png

socat的-v能把双方的报文输出到stderr,这里成功完成了双向通信,同时wireshark抓包正常:

截图_2026-10-20_20-36-59.png

dbg顺便连上去,尝试抓取该程序在真正得到200状态码的正确报文后的逻辑:

截图_2026-10-20_15-35-35.png

然后就是机械的下断点,跟踪。

最后可以确定的是,程序完整地走完了整个http_disconnecting部分,进入了http_resp_body_recving,最后在对v195->field_30进行状态判断时卡住,进入循环,并反复报错:tls_shake failed,即TLS握手失败,但好在它有一个if-else,如果v196变量校验失败,就会尝试用http降级接收,后续可以考虑给v196设置一个有效值,暂时绕过TLS,理清大的状态:

截图_2026-10-20_22-45-42.png

小米IoT私有协议的漏洞挖掘尝试(九)

nft指令快要忘光了,今天用之前又现复习了一小时。

发现我一直在给同一个结构体改名字,在http_resp_frame_helper和http_req_frame_helper之间游移不定,但其实应该还是前者更合理一些。

QQ20261017-161622.png

目前已知的是,当它开始第一次连接时,它会先走httpc_connecting,然后走httpc_req_head_sending和httpc_req_body_sending发一个get请求,随即进入httpc_resp_head_recving,并根据某字段进入了一个分支,在该分支下,如果收到的请求行是“HTTP/1.1 200 OK”,它就先用httpc_disconnecting把连接关掉,关起门来判断读取的报文和目前的缓冲区指针哪个大,然后根据结果去判断继续读还是File done。

但关键问题在于,它具体是如何解析的,是由某结构体里的函数指针所指向的函数进行处理的,正常走根本抓不到:

QQ20261017-120129.png

恢复结构体彻底陷入了瓶颈,所以我不得不尝试脱离纯粹的静态分析,抓一些除了400响应码以外的其他报文。

这时候之前搭建的局域网就派上用场了。

虽然它是硬编码了ip,但事实上,如果我通过ARP广播进行欺骗,伪装成一台网关,让它把报文发到这个网关,并且利用nft修改转发规则,就可以让明文的http流量经过攻击机,并到达除了小米云以外的服务器,这时我就可以用那台服务器起一个http服务,自己伪造响应报文观察它的反应。

使用一台kali虚拟机来完成这一过程:

QQ20261017-133314.png

首先启动bettercap中间人工具,通过probe和show证明两台虚拟机之间相互可达。
,随后设置为双工模式,开始发起ARP广播。

QQ20261017-135010.png

QQ20261017-135040.png

在小米设备的虚拟机上通过ip neigh查看ARP的缓存情况,发现192.168.122.1(网关)的Mac地址已经和192.168.122.251(kali)一样,证明ARP欺骗生效。

用nft建立一个表,分别在prerouting钩子挂一个dnat链,在postrouting钩子挂一个snat链,前者负责把受攻击设备的数据包的dst改成个人服务器的ip,以此保证路径被篡改;后者负责把数据包的src改成kali自己的ip,负责保证回来的报文可以正常根据NAT规则表返回。

QQ20261017-152534.png

成功劫持:

QQ20261017-155821.png

随后我在自己的服务器上开了一个python的http.server:

QQ20261017-160206.png

QQ20261017-160225.png

成功了,不过返回去的是404 Not Found,所以有必要放一个get请求的同名文件,看看能否返回200。

小米IoT私有协议的漏洞挖掘尝试(八)

无符号的程序,还是尽量从日志下手。*

gdbserver的效果不太理想,哪怕是一台qemu-system虚拟机,也完全不支持硬件watch调试。

我又传了一个静态编译的strace进去,同样收效甚微。

最后还是靠着观察日志,终于找到了这个状态机的收发函数。下面是较为完善的状态机:

while循环
{
    switch(handle_method)
    {
    case 0: 确定某个指针是否存在,存在就令handle_method为1,否则为7
    case 1: httpc_connecting
    case 2: httpc_req_head_sending
    case 3: httpc_req_body_sending
    case 4: httpc_resp_head_recving
    case 5: httpc_resp_body_recving
    case 6: httpc_disconnecting
    }
}

对http_connecting进行处理:

QQ20261017-094017.png

观察到它主要根据field20这一int类型的变量来决定是否走tls_open,所以dbg在此下断点:

QQ20261017-094007.png

然后直接把这个x0寄存器改成1,观察它内部发生变化的几个变量:

QQ20261017-095314.png

QQ20261017-095351.png

QQ20261017-095416.png

结果发现,field18这个变量被赋值了一个指针,位于rodata区域,IDA里跳转过去,发现正好是之前研究过的那份证书。

但是tls_open内部是一个比现在这个http处理更为复杂的状态机,并且几乎没有任何日志字符串可供提示,逆向会花费更多的精力,不过还是可以依靠它恢复一些结构体。

小米IoT私有协议的漏洞挖掘尝试(七)

定位了GET请求发包时的拼接点:

QQ20261010-095742.png

QQ20261010-095805.png

QQ20261010-095822.png

QQ20261010-101739.png

目标是定位到这段参数是在何时被存储进结构体的,由此,我就需要捕获这个结构体的内存被修改的时机。

在正常情况下,这可以通过dbg下硬件断点,通过watch监视来解决,不过qemu-user的gdb_stub并不支持这一功能,同时也出于需要组建局域网进一步验证的考量,我选择使用更为全能的qemu-system来解决问题,可以静态编译一个gdbserver实现监视。

手动配置网桥会比较麻烦,所以我使用了virt-manager工具来辅助组网。

该路由器基于Openwrt二次开发,先去官网下载一个适配qemu的通用内核:

QQ20261010-105550.png

通过dd和mkfs制作ext4文件,并通过mount -o loop,将该文件模拟成一个loop设备进行挂载,将解包的根目录复制进去:

QQ20261010-110013.png

下一步是配置虚拟机:

QQ20261010-130247.png

QQ20261010-130329.png

多余的外设统统删掉,只保留串口,控制台和网卡:

QQ20261010-131102.png

成功登入:

QQ20261010-131127.png

这里我在后面又补了一个串口便于操作,一开始是想通过ssh登录的,但openwrt实现ssh的组件是dropbear,同时这个路由器使用的组件比较老,在加密上有诸多版本匹配缺陷,最后还是选择了双串口。

串口是一个字符设备,用于进行常规的控制台通信,ttyAMA的一系列串口就主要服务于aarch64架构。

但事实上,如果仅仅只是通过手动添加硬件设备来增加串口是不够的,哪怕内核引导参数里同时写了console=ttyAMA0和console=ttyAMA1,但因为启动时只有一个控制台被启用,所以它拿到了/bin/sh,而另一个其实是空置的状态,所以需要人为地在启动脚本中mknod出新的字符设备,让它能通过open /dev/ttyAMA1这种方式来与接口通信,并通过sh 0</dev/ttyAMA1 /dev/ttyAMA1>1 2>&1来绑定交互式shell:

QQ20261010-193401.png

除此之外,我顺便在启动脚本里写了两条网络配置:

ip addr add 192.168.122.2/24 dev eth0 配置静态ip

ip route add default via 192.168.122.1 添加路由规则,让网络包顺利走网卡

在VMware,virt-manager这类虚拟机管理器中,一个NAT模式的虚拟局域网内的某个ip往往遵循这样的链路:

客户机自身 -> 虚拟交换机 -> 虚拟路由器(NAT)-> 宿主机网卡

其中,这个虚拟路由器其实算是一个网络接口,通过iptables/nft的的NAT转换规则模拟一台路由器,将数据包根据POSTROUTING规则执行地址转换,并发给物理网卡。

QQ20261010-153458.png

成功发包。

花了一些时间找gdbserver,用musl库的工具链编译,传进虚拟机:

QQ20261010-215138.png