本文首发于
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
协议,然后Handle
是0x002b
这种非标准实现。然后数据内容的话对比一下前后几个数据包就能发现前两字节应该是消息序号,后面才是消息内容。
但是第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_ON
和SWITCH_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
地址当成了Map
的K
,那么咱关注Map
的V
就行,不过这个Map
的put
有俩地方,不确定是哪个。
那就只能都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/V
给put
进去了,那就看看调用链,结果发现有两处。
那就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权限当然可以,但是在作为中间人时,咱们同样也没什么办法去获取。
最后
- 米家BLE协议在实现上充分考虑了防重放,在使用
AES-CCM-128
加密时,使用了消息序号相关的nonce
,并且在消息中包含了消息序号,使得重放攻击变得十分困难。(除非未做消息序号合法性验证) - 米家BLE协议在实现上充分考虑了防中间人窃听,使用了
ECDH-secp256r1
这类非对称密钥算法,使得解密消息依赖于不公开的私钥;使用了HKDF
这类密钥推导算法,使得密钥复杂度得以提升。因此想要解密截获的消息也变得十分困难。 - 米家App在编写时有意识地使用了反逆向手段,如包名、类名、方法名、成员名的混淆,并且混淆程度较高,充分利用了Java的多态性,但是对于一些不必要的信息却并没有做混淆,如关键字符串、引用包中的报错信息,这些还是可以帮助逆向者猜测其代码行为。
- 米家App的React Native代码并没有做太多的混淆,看着似乎只是使用了少了的混淆手段(感觉是打包时自动的),许多关键信息都直接暴露在逆向者面前。
但是实际上在逆向分析的过程中,我每次打开米家App都会有一个toast
气泡提示我的手机已被root
,需要注意系统安全。但它并没有采取任何对抗的措施,比如金融类App十分常见的卡屏、闪退等。它这样似乎表明了一个态度:开放和自信——你大可以来看我的代码,我的协议没有缺陷。
总结一句话,米家在协议设计上可以看出来其一流的专业水平,除非其依赖的一些算法被爆出重要漏洞,否则无解。
Comments 1 条评论
博主 Nope
Hi. I have a question about this post.
What is “miot-mesh-login-salt” and “miot-mesh-login-info”? Is it a constant or a parameter?
However, your post is awesome. Got a lot of help. Thank you.