对某智能家居软件协议的一些分析
@(目录)> 因为家里装了一些“全屋智能”的设备,也是想试试看逆向一个真实的app是什么感觉,而且这个app想来总是比微信支付宝好下手一点,所以就来试试啦。最后的效果是可以自己写python脚本来实现家中一些设备的控制。下文的所有数据都是假的。
# 从Login开始
一开始是想从Manifest.xml里看到的com.lumiunited.aqara开始分析的。 又恰巧有com.lumiunited.aqara.login.loginpage.LoginPageActivity这个类,我理所当然的认为这里就是相关的login逻辑了。但是在逆向的时候我想调试代码,断点这个activity上并不能断住,因此怀疑这里并不是最开始部分的login逻辑。 于是通过adb shell中运行`dumpsys activity activities | grep mResumedActivity`找到当前正在活动的activity:com.lumi.module.login.ui.activity.LoginWrapperActivity。 所以真正的login部分其实在这里。
![在这里插入图片描述](https://img-blog.csdnimg.cn/f75d90723ec5421ab82e87beec6f0de9.png)
通过查询一些资料(https://www.geeksforgeeks.org/mvvm-model-view-viewmodel-architecture-pattern-in-android)找到登录的核心逻辑在model.repository.SigninRepository里。 其中用密码登录的逻辑如下:
```java
@NotNull
public final l a(@NotNull SignInWithPwdBody signInWithPwdBody0) {
Log.f(signInWithPwdBody0, "signInWithPwdBody");
return this.createRequest(new s.b3.v.l(signInWithPwdBody0) {
public final SignInRepository a;
public final SignInWithPwdBody b;
{
this.a = signInRepository0;
this.b = signInWithPwdBody0;
super(1);
}
public final void a(@NotNull n n0) {
Log.f(n0, "it");
new NetworkResource(/*ERROR_MISSING_ARG_2*/) {
@NotNull
public SignInResult a(@NotNull SignInResult signInResult0) {
Log.f(signInResult0, "result");
signInResult0.setAccount(n0.b.getAccount());
n0.a.b().a(signInResult0);
n0.a.a().a();
return signInResult0;
}
@Override// com.lumi.external.http.NetworkResource
@NotNull
public k0 getRemoteData() {
String s = n0.b.getPassword();
if(s != null) {
String s1 = RSA.encrypt(s);
n0.b.setPassword(s1);
}
return k.u.j.g.f.d.b.b.a(((b)k.u.b.b.e.b.a(n0.a, b.class)), n0.b, false, 2, null);
}
@Override// com.lumi.external.http.NetworkResource
public void onFetchFailed(@nullable String s, @Nullable Integer integer0) {
if(integer0 == null || ((int)integer0) != 801) {
super.onFetchFailed(s, integer0);
}
}
};
}
@Override// s.b3.v.l
public Object invoke(Object object0) {
this.a(((n)object0));
return j2.a;
}
});
}
```
我们可以在k.u.j.g.f.d.b 类里看到大量的网络调用接口。用jeb进行调试的话很容易发现我们的password先经过了md5哈希,然后再使用了apk文件里带着的一个证书进行rsa公钥加密并转成base64编码,它的代码如下:
```java
package com.lumi.external.utils;
import android.content.res.Resources;
import android.util.Base64;
import com.lumi.external.utils.log.Logs;
import java.io.InputStream;
import java.nio.charset.Charset;
import java.security.PublicKey;
import java.security.cert.Certificate;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.interfaces.RSAPublicKey;
import javax.crypto.Cipher;
import k.u.b.f.d.e;
import org.jetbrains.annotations.NotNull;
import s.b3.k;
import s.b3.w.Log;
import s.h0;
import s.i3.f;
@h0(bv = {1, 0, 3}, d1 = {"\u00000\n\u0002\u0018\u0002\n\u0002\u0010\u0000\n\u0002\b\u0002\n\u0002\u0010\u000E\n\u0002\b\u0003\n\u0002\u0010\b\n\u0002\b\u0003\n\u0002\u0018\u0002\n\u0000\n\u0002\u0018\u0002\n\u0000\n\u0002\u0010\u0011\n\u0002\b\u0004\bÆ\u0002\u0018\u00002\u00020\u0001B\u0007\b\u0002¢\u0006\u0002\u0010\u0002J\u0010\u0010\t\u001A\u00020\u00042\u0006\u0010\n\u001A\u00020\u0004H\u0007J\u0010\u0010\u000B\u001A\u00020\f2\u0006\u0010\r\u001A\u00020\u000EH\u0002J%\u0010\u000F\u001A\n\u0012\u0006\u0012\u0004\u0018\u00010\u00040\u00102\u0006\u0010\u0011\u001A\u00020\u00042\u0006\u0010\u0012\u001A\u00020\bH\u0002¢\u0006\u0002\u0010\u0013R\u000E\u0010\u0003\u001A\u00020\u0004X\u0082T¢\u0006\u0002\n\u0000R\u000E\u0010\u0005\u001A\u00020\u0004X\u0082T¢\u0006\u0002\n\u0000R\u000E\u0010\u0006\u001A\u00020\u0004X\u0082T¢\u0006\u0002\n\u0000R\u000E\u0010\u0007\u001A\u00020\bX\u0082\u000E¢\u0006\u0002\n\u0000¨\u0006\u0014"}, d2 = {"Lcom/lumi/external/utils/RSA;", "", "()V", "CERT_TYPE", "", "CER_NAME", "ECB_PKCS1_PADDING", "keyLength", "", "encrypt", "str", "getCert", "Ljava/security/cert/Certificate;", "inputStream", "Ljava/io/InputStream;", "splitString", "", "string", "len", "(Ljava/lang/String;I)[Ljava/lang/String;", "externalCore_release"}, k = 1, mv = {1, 4, 1})
public final class RSA {
public static final String CERT_TYPE = "X.509";
public static final String CER_NAME = "lumiunited.cer";
public static final String ECB_PKCS1_PADDING = "RSA/ECB/PKCS1Padding";
@NotNull
public static final RSA INSTANCE;
public static int keyLength;
public static {
RSA.INSTANCE = new RSA();
}
@NotNull
@k
public static final String encrypt(@NotNull String s) throws Exception {
byte[] arr_b1;
Log.e(s, "str");
Logs.d(new Object[]{"encrypt content " + s});
byte[] arr_b = new byte;
try {
Cipher cipher0 = Cipher.getInstance("RSA/ECB/PKCS1Padding");
Resources resources0 = e.f.a().d().getResources();
Log.d(resources0, "AppManager.instance.getA…cationContext().resources");
InputStream inputStream0 = resources0.getAssets().open("lumiunited.cer");
Log.d(inputStream0, "AppManager.instance.getA…ces.assets.open(CER_NAME)");
cipher0.init(1, RSA.INSTANCE.getCert(inputStream0));
String[] arr_s = RSA.INSTANCE.splitString(s, RSA.keyLength - 11);
int v = 0;
int v1 = 0;
while(v < arr_s.length) {
String s1 = arr_s;
if(s1 != null) {
Charset charset0 = f.a;
if(s1 != null) {
arr_b1 = s1.getBytes(charset0);
Log.d(arr_b1, "(this as java.lang.String).getBytes(charset)");
goto label_62;
}
throw new NullPointerException("null cannot be cast to non-null type java.lang.String");
}
arr_b1 = null;
label_62:
byte[] arr_b2 = cipher0.doFinal(arr_b1);
int v2 = 0;
while(v2 < arr_b2.length) {
arr_b = arr_b2;
++v2;
++v1;
}
++v;
}
String s2 = Base64.encodeToString(arr_b, 0, v1, 0);
Logs.d(new Object[]{"after encrypt " + s2});
Log.d(s2, "afterEncrypt");
return s2;
}
catch(Exception exception0) {
exception0.printStackTrace();
Logs.e(new Object[]{"RSA.Encrypt", exception0.getMessage()});
throw exception0;
}
}
private final Certificate getCert(InputStream inputStream0) throws CertificateException {
Certificate certificate0 = CertificateFactory.getInstance("X.509").generateCertificate(inputStream0);
Log.d(certificate0, "cert");
PublicKey publicKey0 = certificate0.getPublicKey();
if(publicKey0 != null) {
RSA.keyLength = ((RSAPublicKey)publicKey0).getModulus().bitLength() / 16;
return certificate0;
}
throw new NullPointerException("null cannot be cast to non-null type java.security.interfaces.RSAPublicKey");
}
private final String[] splitString(String s, int v) {
String s1;
int v1 = s.length() % v;
int v2 = 0;
int v3 = s.length() / v + (v1 == 0 ? 0 : 1);
String[] arr_s = new String;
while(v2 < v3) {
if(v2 == v3 - 1 && v1 != 0) {
int v4 = v2 * v;
int v5 = v4 + v1;
if(s != null) {
s1 = s.substring(v4, v5);
Log.d(s1, "(this as java.lang.Strin…ing(startIndex, endIndex)");
goto label_35;
}
throw new NullPointerException("null cannot be cast to non-null type java.lang.String");
}
else {
int v6 = v2 * v;
int v7 = v6 + v;
if(s == null) {
throw new NullPointerException("null cannot be cast to non-null type java.lang.String");
}
s1 = s.substring(v6, v7);
Log.d(s1, "(this as java.lang.Strin…ing(startIndex, endIndex)");
}
label_35:
arr_s = s1;
++v2;
}
return arr_s;
}
}
```
但是到这一步我们还是不知道具体的一些发包的逻辑究竟在哪,因此需要利用一下app抓包来给我们更多的信息
# app交互流量抓取
抓取环境搭建细节就不说了,我用的是root过的手机加上lsposed的Just Trust me 和太极阳模块。通过抓包能得到下面这样的结构图
![在这里插入图片描述](https://img-blog.csdnimg.cn/3ad2345bc24d48e49224bd14e8660ad5.png)
其中login的requests部分的headers和body分别是下面这个样子:
![在这里插入图片描述](https://img-blog.csdnimg.cn/ff4bbcaf72c24b06bcd9b39635d48d48.png)
![在这里插入图片描述](https://img-blog.csdnimg.cn/40c6657411f64f9da4d91d7503f1e406.png)
我们可以看到password的部分确实是一个base64的结构,而且长度也符合前文的加密算法。但是headers里面出现了nonce和sign字段,他们大概率是用于防止modify和replay攻击,但是我实际尝试了一下,发现它replay好像是一点没防,而modify的话在login时似乎也并没有校验这两个head,只要sign字段非空就可以过校验,其实这里是它写的有一些问题我们下文会提。
总之,通过抓包我们可以在整个工程里搜索sign的字段,找到k.u.g.d.d.c下有关于请求发送的逻辑,其中最关键的sign字段的签名逻辑如下:
```java
public static String a(HashMap hashMap0) {
try {
String s = (String)hashMap0.get("Appid");
String s1 = (String)hashMap0.get("Appkey");
String s2 = (String)hashMap0.get("Requestid");
String s3 = (String)hashMap0.get("Userid");
String s4 = (String)hashMap0.get("Token");
String s5 = (String)hashMap0.get("Time");
String s6 = (String)hashMap0.get("RequestBody");
if(s2 == null) {
s2 = "";
}
String s7 = TextUtils.isEmpty(s2) ? "" : k.u.g.d.j.l.a(s2);
hashMap0.put("Nonce", s7);
return LumiDevSDK.getSignHead((s == null ? "" : s), s7, (s5 == null ? "" : s5), (s4 == null ? "" : s4), (s6 == null ? "" : s6), (s1 == null ? "" : s1));
}
catch(Exception unused_ex) {
return "";
}
}
```
而其中的LumiDevSDK.getSignHead方法又是native的,其逻辑在apk的lib文件夹下的liblumidevsdk.so中。结合frida hook逆向出来的逻辑是这样的, 其中data_text是请求的内容。
```python
from hashlib import md5
sign_payload= 'Appid={}&Nonce={}&Time={}{}&{}&{}'.format(appid,nonce,time,"&Token="+token if token!='' else '',data_text,AppKey)
md5(sign_payload.encode()).hexdigest()
```
```javascript
var native_func_addr = Module.findBaseAddress("liblumidevsdk.so");
var md5_update_addr = native_func_addr.add(ptr(0x31238));
var md5_final_addr = native_func_addr.add(ptr(0x310C8));
function jstring2Str(jstring) {
var ret;
var String = Java.use("java.lang.String");
ret = Java.cast(jstring, String);
return ret;
}
Interceptor.attach(Module.getExportByName('liblumidevsdk.so', 'Java_com_lumi_lumidevsdk_LumiDevSDK_getSignHead'), {
onEnter: function(args) {
// console.log(jstring2Str(args),jstring2Str(args),jstring2Str(args),jstring2Str(args),jstring2Str(args),jstring2Str(args));
},
onLeave: function(retval) {
console.log("return:",jstring2Str(retval));
}
});
Interceptor.attach(md5_update_addr, {
onEnter: function (args) {
console.log(args.readCString(args>>>0));
},
onLeave: function (return_val) {}
});
```
而AppKey,appid可以通过搜索找到是一个定值,nonce=md5(requestid).hexdigest().upper()得到,而requestid是一个随机生成的uuid。至于token则是第一次login成功后服务器返回给你的字段,记录下来即可。
![在这里插入图片描述](https://img-blog.csdnimg.cn/8f2cee1aa79742ab8e6ea536cf45c634.png)
因此这时候我们已经可以较为轻松的写出login的登录脚本了。
# 一个软件协议上的bug
但是上文提到的计算sign字段的python代码,它在login时所计算出来的值其实是错的,不过在除了login以外的其他所有请求里却都是正确的。这像极了上文提到的这个软件对于这个sign字段的校验:**除了login没有检验sign,其他的发包都进行了校验**。因此我对这个现象十分感兴趣,想知道我自己为什么错了。同样我也怀疑,软件开发者会不会和我有同样的错误,所以导致了它无法写出正确的对于login部分的校验逻辑。
通过frida hook 出校验库文件里签名时所用到的md5Update和md5Final函数结果,和正确的openssl里md5产生的结果做比较,惊讶地发现他们是完全一样的。这也就是说实际上是我python这边的代码错了,然后我把c语言输入的字节码提取出来在python中转成bytes形式和python的bytes做了比较,终于发现了问题所在:
```python
# c语言转化到python的bytes
b'Appid=94542648475678b220992a70&Nonce=FDC0A1842BA521912BB687C8B1F1D7D1&Time=1666525713724&{"account":"11234567890","encryptType":2,"password":"OyuOGZn0xZeQ1DbP8mc+CBDSi0OPvrQpfPdp\\/a7FB2squqXWRFNGodcJ79qDNGv05pmViz8gsON9\\n3JlQgQAAXi9F78gmacvvTWX4K2v96tkKae1tTIA97tYZD\\/fZNjHcsWCjmj7hjnoxqxGhUGDWUPX7\\nBS8LB\\/1KaQJfk9wYNRQ=\\n"}&Jaaz01kIORDYrBaaGYgpUXKBnIHfW8E3'
# python生成的bytes
b'Appid=94542648475678b220992a70&Nonce=FDC0A1842BA521912BB687C8B1F1D7D1&Time=1666525713724&{"account":"11234567890","encryptType":2,"password":"OyuOGZn0xZeQ1DbP8mc+CBDSi0OPvrQpfPdp\\/a7FB2squqXWRFNGodcJ79qDNGv05pmViz8gsON9\n3JlQgQAAXi9F78gmacvvTWX4K2v96tkKae1tTIA97tYZD\\/fZNjHcsWCjmj7hjnoxqxGhUGDWUPX7\nBS8LB\\/1KaQJfk9wYNRQ=\n"}&Jaaz01kIORDYrBaaGYgpUXKBnIHfW8E3'
```
我们可以发现两者最大的区别在于对\n的转义处理,在c语言里是直接把他们看成两个字符的,而我自己写的python程序就把他们转义成了一个回车符。故我猜测是由于开发者也对于这块的处理有一些模糊,因此没法写出合适的校验逻辑,导致了问题的产生。而这部分为什么会产生回车呢? 这其实也是上文中rsa加密那块代码里有一些小的问题,在加密完成后使用base64时,它使用了Base64.encodeToString(arr_b, 0, v1, 0)这段代码, 开发者使用了default模式进行编码因此导致会有换行,google后发现字符串过长(一般超过76)时会自动在中间加一个换行符。而使用NO_WRAP参数就不会产生这个问题。
所以,其实并非是login的问题,而是\n导致的错误,我猜测开发者可能遇到了和我一样的问题,导致其在login部分无法完成sign字段的校验,因此就不校验了。所以攻击者可以对login的数据包进行modify的攻击。正确的sign脚本格式如下:
```python
from hashlib import md5
sign_payload= 'Appid={}&Nonce={}&Time={}{}&{}&{}'.format(appid,nonce,time,"&Token="+token if token!='' else '',data_text,AppKey)
md5(sign_payload.encode().replace(b'\n',b'\\n')).hexdigest()
```
# 写一个python脚本实现对一些iot设备的控制
在了解了nonce和sign两个字段的算法以后,其实大多数的设备已经可以被我们为所欲为了,只要在抓到的数据包里面找一些请求格式然后再去找对应的逻辑即可明白大多数功能。不过我最想做到的电脑查看摄像机部分似乎逻辑很复杂,而且在root的手机上无法加载视频,可能是做了一些保护措施。但是抛开这个设备,其他的电灯按钮之类的设备已经完全可以做到脚本控制了。
以最简单的开关灯为例,首先我们需要查询一些设备的id,可以点一下app中的总览按钮,然后刷新一下,发现数据包发出了对/app/v1.0/lumi/app/view/statistics/block/query 的请求,因此我们可以抄下来请求包的数据(本质上应该是用subjectId去查询一个真正的id)然后自己加一个sign字段,去获取各个设备的id
![请求包](https://img-blog.csdnimg.cn/080841b5d50a4e979b889e6f11ac1c87.png)
![响应包](https://img-blog.csdnimg.cn/1be2a09c101045a6bb587e6c3d3cc4e5.png)
然后拿到了设备的id以后其实如果为了以后方便可以自己在本地维护一张map这样的话以后就不用查询了。然后我们想要知道怎么控制灯的开关,可以在手机上点一下开关,发现app对https://aiot-rpc.aqara.cn/app/v1.0/lumi/app/view/write发送了请求包,内容是
![在这里插入图片描述](https://img-blog.csdnimg.cn/382cf881b6134db6b88293f9c97eea9d.png)
到这时我们就可以轻松的写一个脚本来实现对于设备的控制了。
# 脚本附录
```python
import requests,json,time,math,uuid,rsa
from hashlib import md5
import base64
AppKey = "Fake123456789BzqGY123XKBnI34H8E3"
user = '12345678901'
password = 'password'
appid = '1234590848747ab2209qwert'
class Mysession():
def __init__(self, usr:str, pwd:str):
self.usr = usr
self.pwd = pwd
self.userId = ""
self.token= ""
self.device_map = None
self.login(usr, pwd)
def login(self, usr:str, pwd:str):
data = {"account":usr,"encryptType":2,"password":self.encpwd(password)}
data_text = json.dumps(data,separators=(',',':'))
headers = self.get_headers(self.token,data)
r = requests.post('https://aiot-rpc.aqara.cn/app/v1.0/lumi/user/login', verify=False,data=data_text,headers = headers)
response = json.loads(r.text)
print(response)
self.userId = response['result']['userId']
self.token = response['result']['token']
def list_overall(self):
data = {"dataList":[{"options":"","version":"1.2","subjectId":"lumi.fds"},{"options":"","version":"1.2","subjectId":"lumi.trt"},{"options":"","version":"1.2","subjectId":"lumi.fgf"},{"options":"","version":"1.0","subjectId":"ir.df"},{"options":"2","version":"1.0","subjectId":"ir.gf"},{"options":"","version":"1.6","subjectId":"lumi.fds"},{"options":"","version":"1.0","subjectId":"lumi.fd"},{"options":"","version":"1.0","subjectId":"lumi.sdf"},{"options":"","version":"1.0","subjectId":"lumi.sfvs"},{"options":"","version":"1.0","subjectId":"lumi.fdsfc"},{"options":"","version":"1.0","subjectId":"lumi.fdsfx"},{"options":"","version":"1.0","subjectId":"lumi.d"},{"options":"","version":"1.0","subjectId":"lumi.gf"},{"options":"","version":"1.0","subjectId":"lumi.jk"},{"options":"","version":"1.0","subjectId":"lumi.jh"},{"options":"","version":"1.0","subjectId":"lumi.g"},{"options":"","version":"1.0","subjectId":"lumi.hg"},{"options":"","version":"1.0","subjectId":"lumi.dff"},{"options":"","version":"1.0","subjectId":"lumi.yt"},{"options":"","version":"1.0","subjectId":"lumi.uy"},{"options":"","version":"1.0","subjectId":"lumi.hg"},{"options":"","version":"1.0","subjectId":"lumi.tr"},{"options":"","version":"1.0","subjectId":"lumi.er"},{"options":"","version":"1.0","subjectId":"lumi.er"},{"options":"","version":"1.0","subjectId":"lumi.tr"},{"options":"","version":"1.0","subjectId":"lumi.tr"},{"options":"","version":"1.0","subjectId":"lumi.er"},{"options":"","version":"1.0","subjectId":"lumi.fgd"},{"options":"","version":"1.1","subjectId":"lumi.gf"},{"options":"","version":"1.1","subjectId":"lumi.gfd"},{"options":"","version":"1.0","subjectId":"lumi.342"},{"options":"","version":"1.0","subjectId":"lumi.21"},{"options":"","version":"1.0","subjectId":"lumi.23"},{"options":"","version":"1.0","subjectId":"lumi.27"},{"options":"","version":"1.0","subjectId":"lumi1.12"}],"panelId":"real1.123","subscribe":1}
headers = self.get_headers(self.token,data)
r = requests.post('https://aiot-rpc.aqara.cn/app/v1.0/lumi/app/view/statistics/block/query', verify=False,data=json.dumps(data,separators=(',',':')),headers = headers)
r.encoding = 'utf-8'
self.device_map = json.loads(r.content)
with open('devices.json','wb') as f: #记录一下设备id 以后想看的话再写个map
f.write(r.content)
def ctrl_dev(self, id:str, value:str, subjectId:str):
data = {"data":{id:value},"options":"","subjectId":subjectId,"version":"1.1","viewId":"control"}
headers = self.get_headers(self.token,data)
r = requests.post('https://aiot-rpc.aqara.cn/app/v1.0/lumi/app/view/write', verify=False,data=json.dumps(data,separators=(',',':')),headers = headers)
r.encoding = 'utf-8'
print(r.text)
def get_headers(self,token:str,data:dict)->dict:
head = {
"lang": "zh",
"app-version": "2.4.6",
"phone-model": "Mi MIX 2##Mobile",
"time": str(math.ceil(time.time()*1000)),
"sys-type": "1",
"nonce": 'D55B1E753BF3101C3EA5D75F406041E3', # md5(Requestid).hexdigest() Requestid = UUID.randomUUID().toString()
"area": "CN",
"appid": appid,
"clientid": "gfdsgssdfert",
"sign": 'bb032515809af1e115f7d84c3f79b03d', # 'dad3cba93cb2ed2e4745ee44b0fa213a', # Appid nonceTimeTokenRequestBody AppKey
"user-agent": "okhttp/4.8.1",
"content-type": "application/json; charset=utf-8"
}
head['userId'] = self.userId
head['token']= self.token
nonce = md5(str(uuid.uuid4()).encode()).hexdigest().upper()
data_text = json.dumps(data,separators=(',',':'))
sign_payload= 'Appid={}&Nonce={}&Time={}{}&{}&{}'.format(appid, nonce,head['time'],"&Token="+self.token if self.token!='' else '',data_text,AppKey)
head['nonce'] = nonce
head['sign']= md5(sign_payload.encode().replace(b'\n',b'\\n')).hexdigest()
print(sign_payload, head['sign'] )
return head
def encpwd(self,pwd:str) -> str:
with open('pwd.pem', mode='rb') as f:
keydata = f.read()
pubkey = rsa.PublicKey.load_pkcs1_openssl_pem(keydata)
res =rsa.encrypt(md5(pwd.encode()).hexdigest().encode(),pubkey)
res = base64.b64encode(res).decode()
print(res)
return res
ctrl = Mysession(user,password)
ctrl.ctrl_dev('4.335','0','lumi.5238')
``` 绿米呀,绿米有官方开放的api接口,直接按照官方文档开发就可以了:lol 感谢楼主分析,但我想知道像某花某米他们这些智能家居软件里是否留有后门,像我这种普通人日常使用是否有隐私泄露风险? 谢谢楼主分享哦哦哦 谢谢楼主分享,很有用的分析 学习了,谢谢!! 像这种app服务是部署在本地然后向运营商申请公网ip吗 控制部分大部分应该用双向协议的,websocket或者mqtt居多 受教了,非常感谢 受教了,非常感谢。