站点图标 Wankko Ree's Blog

从私有BLE协议看米家安全

本文首发于IOTsec-Zone

前言

众所周知,在家庭物联网领域,国内几乎算是米家一统天下,那么其又是靠什么能在众多安全人员的研究下屹立不倒呢?

固件提取?

既然要分析协议,那总得有设备来测试吧,于是就买了个米家智能墙壁插座(zimi.plug.zncz01),这玩意支持BLE,而且功能就只有电源的开和关,正好适合用来分析。

拿到设备,先是上米家App绑定,接着发现有个固件更新的提示。于是按耐住直接点更新的作死想法,先开了下抓包,成功获取到固件的下载地址:

https://cdn.cnbj0.******.com/miio_fw/signed_fefb8b9387f435c280d2779a12b3bbd0_upd_zimi.plug.zncz01.bin?Expires=1721564712000&GalaxyAccessKeyId=5721718224520&Signature=woxFqZFoD+sYHGBgk67FITgsHg8=

不过看文件名的upd字样,应该是增量包,不是全量包,所以没抱太大希望。尝试用binwalk解包提取,但只识别出了两个pem证书,可能是用了米家自家的独特打包方法导致没识别出来?

不过本来也没对固件这块抱有太大希望,先去抓BLE协议看看流量内容吧。

私有协议

通过多次发送开机/关机指令,可以确定指令的数据包是走的Bluetooth Attribute Protocol协议,然后Handle0x002b这种非标准实现。然后数据内容的话对比一下前后几个数据包就能发现前两字节应该是消息序号,后面才是消息内容。

但是第0x0001条的关机指令内容和第0x0003条并不一样,猜测可能有结合序号进行加密达到防重放的目的。

然后再重新连接一次抓个包,发现同样序号的同样操作,其指令内容也不一样。

应该是握手时交换了加密用的密钥?但单纯这样子看加密后的流量,肯定是没办法分析出太多有用的信息的,于是准备从Android端找找突破口。

从米家 App 入手

先看看管理界面的Activity类是啥吧,方便定位逻辑层的代码。

于是跟到com.xiaomi.smarthome.framework.plugin.rn.PluginRNActivityPlugin1看看,结果发现是空的,啥也没有。

结合类名的RN可以知道界面应该是React Native画的,那么主要的逻辑应该是JavaScript写的。

但是在米家App的安装包中并没有找到这个插座相关的东西,猜测应该是热加载的。于是上网逛了一圈,发现一个叫Xiaomi Miot For HomeAssistant的项目:al-one / hass-xiaomi-miot,他们收集了米家绝大多数的设备的热加载插件包,正好是咱们需要的东西:zimi.plug.zncz01

压缩包里面的main.bundle大概率就是React Native的界面层和逻辑层的代码了。

React Native 逆向

打开发现开头有一堆二进制数据,还以为被加密了,但是往下一翻发现就是明文的JavaScript,所以先不管开头那部分,找找发送指令的逻辑在哪吧。

按照现在前端的思路,按钮这类组件大概率是动态注册的,所以找按钮的显示值应该就能在附近找到按钮的事件。

于是搜一下关机这个关键词(因为米家App上的指令按钮显示的文字就是这个),发现有个i18n的字典。

那就看看哪里用到了card_turn_off这个值吧。

果然和猜想的一样,毕竟是react写的,不动态注册组件都说不过去,所以下面的onPress属性里的setSwitchStatus函数很明显就是按钮的点击事件了,那就接着找这个函数的定义在哪。

这个函数定义的方式比较奇怪,看起来似乎是为了xxx.get("setSwitchStatus")(...)这么调用的,不过无所谓,和咱们没啥关系。

里面有一堆的在线情况的逻辑判断,这些也和我们没关系,咱们只需要看在线情况下怎么发送的指令就行。

所以对于connectStatus的判断,咱们看最后else部分就成。

先是打了下日志,然后定义了好几个回调,最后setState那边才有一个处理函数,判断了下连接方式是走的米家网关还是蓝牙直连,咱们现在分析的是蓝牙直连的协议,所以网关那个也先不管,那么sendBleRequest这个函数调用(js确实有这种看起来很离谱的函数调用写法)应该就是发送指令的地方了,因为其他的都咋看咋不像。

然后函数第一个参数应该是指令内容,后面的参数应该都是回调之类的,那么咱们只需要关注第一个参数即可,也就是上面的SWITCH_ONSWITCH_OFF常量,找了一下这些常量,发现定义及其简单:

那就去看sendBleRequest函数吧,和第一个参数有关的也就encryptMessage函数,应该是加密然后返回结果由writeWithoutResponse函数去发送。

但是没找到这个encryptMessage函数的定义在哪。

看来应该是在米家App那边了,那就先去米家App自己的JavaScript层找找,应该不至于直接跳到Java层去。

/assets/plugin_rn_sdk/sdk.bundle里找到了这个函数:

所以接着找encryptMessageXiaoMiBLE这个函数呗,不过看这个对象名字_native,应该是要去Java层了,在JavaScript层确实也没找到相关的定义。

回到米家 App

果不其然在Java层找到了:

看着像是通过iyh.O00000Oo方法处理然后接着给securityChipEncrypt去加密。

那就先看看iyh.O00000Oo方法:

看着应该是hexstr->byte[],所以没啥值得关注的,继续跟securityChipEncrypt去。

结果发现是个抽象定义,那就只能全局搜一下看看具体定义在哪了。

第二个是抽象定义,那就第一个是了,接着跟。

可以发现把指令数据存到Bundle里去了,key是extra.byte.array,接着看O000000o方法。

那么就看callBluetoothApi方法吧,结果跟进去是个接口类。

那就搜一下具体实现定义在哪咯。

有点多,那就写个hook咱自己调用一遍看看O00000Oo()返回的类是哪个吧。

package com.example.hooktest.hook

import com.highcapable.yukihookapi.YukiHookAPI
import com.highcapable.yukihookapi.annotation.xposed.InjectYukiHookWithXposed
import com.highcapable.yukihookapi.hook.log.loggerD
import com.highcapable.yukihookapi.hook.type.android.BundleClass
import com.highcapable.yukihookapi.hook.type.java.IntType
import com.highcapable.yukihookapi.hook.type.java.StringType
import com.highcapable.yukihookapi.hook.xposed.proxy.IYukiHookXposedInit

@InjectYukiHookWithXposed
class Main : IYukiHookXposedInit {
    override fun onInit() = YukiHookAPI.configs {
        debugLog {
            tag = "mijia_hook"
        }
        isDebug = false
    }
    override fun onHook() = YukiHookAPI.encase {
        loadApp("com.xiaomi.smarthome") {
            "com.xiaomi.smarthome.frame.core.CoreApi".hook {

                injectMember {
                    method {
                        name("O000000o")
                        param(StringType, IntType, BundleClass, "com.xiaomi.smarthome.core.server.bluetooth.IBleResponse")
                    }
                    beforeHook {
                        loggerD(msg=method {
                            name("O00000Oo")
                            paramCount(0)
                        }.get(instance).call()!!.javaClass.name)
                    }
                }

            }
        }
    }
}

可以看到输出的是com.xiaomi.smarthome.core.server.ICoreApi$Stub$Proxy这个类。

跟过去看看。

看起来还是走了com.xiaomi.smarthome.core.server.CoreApiStub.callBluetoothApi,那就接着跟。

结果O000000o(String str, int i, Bundle bundle, IBleResponse iBleResponse)这个方法又是接口类里面的,不过幸好就一处实现,直接过去就行。

这回可以看出来,之前传进来的43是用来走分支的,咱去43分支看看逻辑。

可以看出来之前存进Bundle的指令内容(key是extra.byte.array)又被拿出来然后传给gsr.O000000o了,接着跟。

然后是gzk.O000000o方法,再跟。

可以看到数据去了hbu.O000000o方法,不过额外传进来了另外两组数据,不过可以看出来,数据来源都是gwn.O0000oo(str)。那就先写个hook看看hbu.O000000o的输入输出数据是啥吧。

            "_m_j.hbu".hook {
                injectMember {
                    method {
                        name("O000000o")
                        param("byte[]", "byte[]", "byte[]")
                    }
                    beforeHook {
                        loggerD(msg="${instanceClass.name}.${method.name} i: ${(args[0] as ByteArray).joinToString("") { "%02x".format(it) }}, ${(args[1] as ByteArray).joinToString("") { "%02x".format(it) }}, ${(args[2] as ByteArray).joinToString("") { "%02x".format(it) }}")
                    }
                    afterHook {
                        loggerD(msg="${instanceClass.name}.${method.name} o: ${(result as ByteArray).joinToString("") { "%02x".format(it) }}")
                    }
                }
            }

那第一个应该是固定的,至少现在还没变,然后第二个是后半截在累加,前面也没动,第三个就咱的指令。然后返回的结果看起来就是要发送的加密后的数据了。

进去看看加密逻辑吧,那俩数据怎么来的待会再看。

可以看到大大的AES,然后第一个参数当key,再全部传给了O000000o方法,那就接着跟。

第二个参数应该是另一个加密参数,具体是啥参数还得看具体的加密算法,然后传给O000000o方法得到一个hbd对象,然后调用它的O000000o方法进行加密,结果通过O00000Oo方法获取。

那就先看O000000o方法,可以看到一个英文报错,看来应该是哪个标准库里的。

在这CCMBlockCipher.java找到一个差不多的函数,看来应该是用的AES-CCM,所以第二个参数应该就是nonce,然后key长度是16字节,那应该是AES-CCM-128了。

找了个在线工具测试了一下,发现确实是。

密文+4字节tag就是加密结果,确实和之前的hook结果一样。

那么现在加密算法知道了,咱就剩下那俩参数是如何生成的不知道了。

回头看

那么gwn.O0000oo肯定是逃不掉被hook的命运了。

            "_m_j.gwn".hook {
                injectMember {
                    method {
                        name("O0000oo")
                        param(StringType)
                    }
                    beforeHook {
                        loggerD(msg="${instanceClass.name}.${method.name} i: ${args[0]}")
                    }
                    afterHook {
                        loggerD(msg="${instanceClass.name}.${method.name} o: ${(result as ByteArray).joinToString("") { "%02x".format(it) }}")
                    }
                }
            }

就是传入了插座的mac,然后能传出来64字节的数据,其中[16:32]作为了加密的key使用,[36:40]作为nonce的前4字节,然后按照先前的nonce拼接逻辑可以知道,中间4字节一直是空的,最后4字节应该是序号。

但是这个6字节的mac地址生成64字节的数据,这信息熵不太对啊,总不能是一堆无意义填充吧,那就跟进去看看。

然后是O0000oo0方法。得到结果后交给jzi.O000000o处理,那看看jzi.O000000o

看起来还是个hexstr->byte[]的方法,那主要就是O0000oo0方法了,进去看看。

这开个Map去存东西的方式,感觉是类似于缓存一样的操作,那就看put的内容就行,既然这边把mac地址当成了MapK,那么咱关注MapV就行,不过这个Mapput有俩地方,不确定是哪个。

那就只能都hook一下看看哪处被调用之后能有值吧。

            "_m_j.gwn".hook {
                injectMember {
                    method {
                        name("O0000oo0")
                        param(StringType)
                    }
                    afterHook {
                        (field {
                            name("O000000o")
                        }.get().any() as Map<String, String>)["68:AB:BC:53:72:21"]?.let { loggerD(msg="${instanceClass.name}.${method.name} $it") }
                    }
                }
            }

            "_m_j.gwn".hook {
                injectMember {
                    method {
                        name("O0000Ooo")
                        param(StringType, StringType)
                    }
                    afterHook {
                        (field {
                            name("O000000o")
                        }.get().any() as Map<String, String>)["68:AB:BC:53:72:21"]?.let { loggerD(msg="${instanceClass.name}.${method.name} $it") }
                    }
                }
            }

可以看见是gwn.O0000Ooo函数调用后才有值,那就跟过去看看。

直接第一行就是把传入的两个参数当成K/Vput进去了,那就看看调用链,结果发现有两处。

那就hook一下看看堆栈吧。

            "_m_j.gwn".hook {
                injectMember {
                    method {
                        name("O0000Ooo")
                        param(StringType, StringType)
                    }
                    beforeHook {
                        loggerD( msg = "${instanceClass.name}.${method.name} i: ${args[0]}, ${args[1]}")

                        val stackElements = Throwable().stackTrace
                        for (i in stackElements.indices) {
                            val element = stackElements[i]
                            loggerD(msg = "at ${element.className}.${element.methodName}(${element.fileName}:${element.lineNumber})")
                        }
                    }
                }
            }

可以看到带有值的那次调用是来自_m_j.gwn.O00000o方法,跟!

有个jzi.O00000o0方法,跟!

这回是个byte[]->hexstr方法,这转来转去不累的嘛...

那就回去看O00000o的第二个参数怎么来的吧。

又是有好多来源,那还是打调用堆栈呗。

            "_m_j.gwn".hook {
                injectMember {
                    method {
                        name("O00000o")
                        param(StringType, "byte[]")
                    }
                    beforeHook {
                        loggerD( msg = "${instanceClass.name}.${method.name} i: ${args[0]}, ${(args[1] as ByteArray).joinToString("") { "%02x".format(it) }}")

                        val stackElements = Throwable().stackTrace
                        for (i in stackElements.indices) {
                            val element = stackElements[i]
                            loggerD(msg = "at ${element.className}.${element.methodName}(${element.fileName}:${element.lineNumber})")
                        }
                    }
                }
            }

可以看到是来自_m_j.gyv.O000000o,跟!

数据来源又是上一个调用的Bundle里的session_key,跟!

还是上一个调用来的,只能接着跟了,不过调用这个函数的有好多。

那就看一下之前的调用堆栈吧,可以知道是来自com.xiaomi.smarthome.core.server.internal.bluetooth.security.BleSecurityConnector.O00000Oo,跟!

是个类静态变量O00000oO,那有得看是之前啥时候赋值的了。

session_key相关的put有4个,然后还有个putAll也有可能...

那还是写个对putByteArray的hook看看调用堆栈吧。

            BundleClass.hook {
                injectMember {
                    method {
                        name("putByteArray")
                    }
                    beforeHook {
                        if ((args[0] as String) == "session_key") {
                            loggerD( msg = "${instanceClass.name}.${method.name} i: ${args[0]}, ${(args[1] as ByteArray).joinToString("") { "%02x".format(it) }}")

                            val stackElements = Throwable().stackTrace
                            for (i in stackElements.indices) {
                                val element = stackElements[i]
                                loggerD(msg = "at ${element.className}.${element.methodName}(${element.fileName}:${element.lineNumber})")
                            }
                        }
                    }
                }
            }

_m_j.gyw.O000000o这一个有调用,跟!

又是类静态变量,看看赋值情况。

就一处赋值,跟!

数据来源是O000000o方法处理过的一个拼接byte[],拼接情况是前32字节看起来是什么私钥,后32字节是上一级传进来的第二个参数。

看了下O000000o方法,是个hmac相关的东西,最后获取结果是O000000o.O000000o方法。

进去看一下发现是个hkdf算法,那就是hkdf(拼接byte, "miot-mesh-login-salt", "miot-mesh-login-info", 64)

那就回去看那个私钥吧,其调用的对象又是类的静态变量O0000o00,看看赋值。

hbu.O000000o生成的KeyPair,跟!

emm...椭圆曲线的EC-secp256r1算法生成的密钥对,彳亍。

那又回去,上面使用私钥的时候,还套了个hbu.O000000o方法,传进来第一个参数是公钥套上同样方法的结果,然后公钥处理用的又是这次传的第一个参数在开头拼上1字节的0x04。那么就看hbu.O000000o方法了。

然后就发现两次的方法并不一样,这个是私钥的:

明显的ECDH算法。

公钥的是:

也是明显的EC算法。

那就回头看看之前传进来的两个参数是哪来的吧。

第一个是上一层继续传的,第二个是其他地方事先准备好的类静态变量。

接着追上一层调用。

发现是个监听事件,那大概率是插座传过来的了,所以这个应该是插座的公钥。

那再看看另一个参数,赋值就一处。

还是接着跟。

看这命名情况,应该是绑定之后就固定的一个值,然后两个调用来源分别是首次绑定和已绑定的情况。

那就回到_m_j.gyw.O000000o方法去hook一下看看传参,然后抓包看看猜测对不对。

第一个参数确实是插座发过来的。

然后多次尝试连接,看看hook日志。

第二个参数确实是固定的。

所以整个协议的大致流程是这样子的。

那么实际上分析到这里,懂密码学的已经明白,在这种用了ECDH的情况下,我们作为中间人如果去截获米家BLE协议,那么只能拿到插座在握手时发来的插座公钥和米家App发过去的米家App公钥。而咱们如果需要解密米家App发过去的消息,就需要知道插座的私钥,但是私钥直接在插座内存里,想要获取几乎不可能;如果需要解密插座发过来的消息,就需要知道米家App的私钥,这个我们如果能拿到手机的root权限当然可以,但是在作为中间人时,咱们同样也没什么办法去获取。

最后

  1. 米家BLE协议在实现上充分考虑了防重放,在使用AES-CCM-128加密时,使用了消息序号相关的nonce,并且在消息中包含了消息序号,使得重放攻击变得十分困难。(除非未做消息序号合法性验证)
  2. 米家BLE协议在实现上充分考虑了防中间人窃听,使用了ECDH-secp256r1这类非对称密钥算法,使得解密消息依赖于不公开的私钥;使用了HKDF这类密钥推导算法,使得密钥复杂度得以提升。因此想要解密截获的消息也变得十分困难。
  3. 米家App在编写时有意识地使用了反逆向手段,如包名、类名、方法名、成员名的混淆,并且混淆程度较高,充分利用了Java的多态性,但是对于一些不必要的信息却并没有做混淆,如关键字符串、引用包中的报错信息,这些还是可以帮助逆向者猜测其代码行为。
  4. 米家App的React Native代码并没有做太多的混淆,看着似乎只是使用了少了的混淆手段(感觉是打包时自动的),许多关键信息都直接暴露在逆向者面前。

但是实际上在逆向分析的过程中,我每次打开米家App都会有一个toast气泡提示我的手机已被root,需要注意系统安全。但它并没有采取任何对抗的措施,比如金融类App十分常见的卡屏、闪退等。它这样似乎表明了一个态度:开放和自信——你大可以来看我的代码,我的协议没有缺陷。

总结一句话,米家在协议设计上可以看出来其一流的专业水平,除非其依赖的一些算法被爆出重要漏洞,否则无解。


The End
退出移动版