海外优质VPS
高性价比免备案主机推荐

对宝马车载apps协议的逆向分析研究


对宝马车载apps协议的逆向分析研究
本文为看雪论坛优秀文章
看雪论坛作者ID:0xbird
2016年,我开始研究宝马,对IDrive和BMW Connected Apps有了基本认识,通过一些蓝牙协议,我手机上的Spotify会被添加到桌面上的音乐列表中。

单击条目会显示一个丰富的用户界面,提供了很多浏览选项以选择播放列表并根据当前歌曲启动广播电台,这比蓝牙音乐控件更具交互性。
 
对宝马车载apps协议的逆向分析研究


0x01 分析


 
这激发了我对宝马app分析的好奇心,制造商添加应用程序可以自动升级汽车,不必局限于制造商在车上加载了哪些应用程序。

也不需要从口袋里拿出手机并看着手机屏幕来切换音乐,可以使用带有信息娱乐屏幕的触觉控制器旋钮来安全地控制任何应用程序!
 
对宝马车载apps协议的逆向分析研究
 
但是,与蓝牙一样,这种体验并不十分流畅。有时其他蓝牙应用程序协议无法连接,有时个别应用程序本身也不会响应。

在Spotify论坛上寻求帮助的呼声也被忽略了,BMW Connected应用程序的评论糟透了,没有任何修复的迹象。


0x02 apps分析


 

此外,与iPhone上可用的应用程序相比,适用于Android的BMW Connected仅共享了非常有限的应用程序选择:仅Spotify和iHeartRadio,以及基本的日历应用程序。

由于我喜欢其他音乐应用程序,因此我致电BMW支持,询问是否可以访问BMW Ready SDKhttps://www.bmwblog.com/2012/07/10/bmw-ready-sdk-for-third-party-apps-android-apps-coming/),以便可以构建自己的应用程序,他们拒绝了这种做法。
 
因此,我决定找出这个BMW Apps协议,并在没有他们帮助的情况下将自己的音乐应用程序添加到系统中。


0x03 蓝牙嗅探


 

蓝牙是一种标准协议,我只需要学习对汽车说些什么,Android内置了Bluetooth Capture日志记录!我只记录电话应用程序对汽车说的内容,然后看看发现了什么:
 
对宝马车载apps协议的逆向分析研究
 
这个SPP协议似乎有很多有用的信息:我看到了一些X509证书,一些XML数据,一些看起来像歌曲元数据的字符串,还有很多东西!


0x04 BLC复用


 

我开始注意到大多数数据包的前一个字节中的模式:第0个字节为0,第一个字节为1或6,第二个字节为0,第3个字节通常很低,接下来的字节几乎总是0x0FA4。

我发现接下来的2个字节是剩余数据的长度,接下来的4个字节的数据几乎总是0xDEADBEEF。
 
我编写(https://github.com/hufman/wireshark_bmw_bcl/commit/d5dc9a8ebcb206a84b21571c8ab4df8982ebc6b6)Wireshark Lua插件来帮助我理解数据,将第一个字节解析为名为Val1,Val2,Val3和Length的4个16位值,然后输出剩余的数据字节。

@@ -0,0 +1,158 @@print("hello world!") local debug_level = {    DISABLED = 0,    LEVEL_1  = 1,    LEVEL_2  = 2}local DEBUG = debug_level.LEVEL_1 local dprint = function() endlocal dprint2 = function() endlocal function resetDebugLevel()    current_debug_level = 2    if current_debug_level > debug_level.DISABLED then        dprint = function(...)            info(table.concat({"Lua: ", ...}," "))        end         if current_debug_level > debug_level.LEVEL_1 then            dprint2 = dprint        end    else        dprint = function() end        dprint2 = dprint    endend-- call it nowresetDebugLevel()  local bmw_proto = Proto("bmw", "BMW BCL") local hdr_fields ={    val1 = ProtoField.uint16 ("bmw.val1", "Val1", base.HEX),    val2 = ProtoField.uint16 ("bmw.val2", "Val2", base.HEX),    val3 = ProtoField.uint16 ("bmw.val3", "Val3", base.HEX),    len = ProtoField.uint16 ("bmw.len", "Length", base.DEC)}bmw_proto.fields = hdr_fieldsdprint2("bmw_proto ProtoFields registered") local dissect_data = Dissector.get("data") function bmw_proto.init()end local BMW_MSG_HDR_LEN = 8 -- mention future helper methodslocal check_bmw_length function bmw_proto.dissector(tvbuf, pktinfo, root)    dprint2("bmw_proto.dissector called")     -- check packet length    local pktlen = tvbuf:len()    local bytes_consumed = 0     while bytes_consumed < pktlen do        -- call dissect_packet for this single packet        -- it will return a positive number for the amount of bytes consumed        -- or a negative number for a request for more bytes        -- or 0 for an error        local result = dissect_packet(tvbuf, pktinfo, root, bytes_consumed)         if result > 0 then            -- successfully parsed packet            bytes_consumed = bytes_consumed + result        elseif result == 0 then            -- not a valid packet            return 0        else            -- need more data to finish parsing            pktinfo.desegment_offset = bytes_consumed            pktinfo.desegment_len = 0 - result            return pktlen        end    end    return bytes_consumedend local function heuristic(tvbuf, pktinfo, root)    ETCH_MAGIC = ByteArray.new("de ad be ef")     if tvbuf:len() < BMW_MSG_HDR_LEN + 4 then        return false    end     local etch_magic = tvbuf:range(BMW_MSG_HDR_LEN, 4):bytes()    return etch_magic == ETCH_MAGICend function dissect_packet(tvbuf, pktinfo, root, offset)    dprint2("dissect_packet function called")    local length_val, length_tvbf = check_bmw_length(tvbuf, offset)     if length_val <= 0 then        -- not enough data to get the header        return length_val    end     -- update the packet list info    pktinfo.cols.protocol:set("BMW BCL")    if string.find(tostring(pktinfo.cols.info), "^BMW") == nil then        pktinfo.cols.info:set("BMW BCL")    end     -- create the protocol tree field    local tree = root:add(bmw_proto, tvbuf:range(offset, BMW_MSG_HDR_LEN + length_val))     -- get the vals    tree:add(hdr_fields.val1, tvbuf:range(offset, 2))    tree:add(hdr_fields.val2, tvbuf:range(offset+2, 2))    tree:add(hdr_fields.val3, tvbuf:range(offset+4, 2))    tree:add(hdr_fields.len, length_tvbf)     remaining_tvb = tvbuf(offset + BMW_MSG_HDR_LEN, length_val):tvb()    dissect_data:call(remaining_tvb, pktinfo, root)     return BMW_MSG_HDR_LEN + length_valend function check_bmw_length(tvbuf, offset)    -- remaining bytes in the packet to look through    local msglen = tvbuf:len() - offset     -- check if capture was only capturing partial packet size    if msglen ~= tvbuf:reported_length_remaining(offset) then        -- captured packets are being sliced/cut-off, so don't try to desegment/reassemble        dprint2("Captured packet was shorter than original, can't reassemble")        return 0    end     if msglen < BMW_MSG_HDR_LEN then        -- we need more bytes to parse the header        dprint2("Need more bytes to look at the header")        return -DESEGMENT_ONE_MORE_SEGMENT    end     -- we have enough to parse the length from the header    local length_tvbr = tvbuf:range(offset+6, 2)    local length_val = length_tvbr:uint()     -- check if we have the whole packet somewhere    if msglen < BMW_MSG_HDR_LEN + length_val then        dprint2("Need more bytes to desegment full packet")        return -(BMW_MSG_HDR_LEN + length_val - msglen)    end    return length_val, length_tvbrend function enable_dissector()    DissectorTable.get("btrfcomm.dlci"):add_for_decode_as(bmw_proto)endenable_dissector() --bmw_proto:register_heuristic("btspp", heuristic)

似乎该协议用于将连接多路复用到单个蓝牙串行套接字,而Val2是不同的连接ID。在Wireshark中解析了该字段之后,我可以使用显示过滤器来遵循单个的通信流程。


0x05 Apache Etch


 

经过一番研究,我发现了一篇文章(https://www.bmw-carit.de/open-source/etch.php),解释了宝马将Apache Etch用作“用于BMW Apps的基本通信协议”。

速浏览Apache Etch文档(https://etch.apache.org/documentation.html)可确认这0xDEADBEEF是每个Etch RPC调用开始时的魔术标识符。

因此,这意味着我只需要将每个BCL流解码为Apache Etch数据包,Wireshark的内置Etch解析就会解析数据!


0x06 Apatch Etch符号名


 
除了Apache Etch,每个函数名称和任何其他符号名称都编译为32位哈希值。

Wireshark可以使用Etch调试组件将哈希值替换为适合的名称,但是我首先需要弄清楚这些名称并自己对它们进行哈希处理。

该散列算法https://svn.apache.org/repos/asf/etch/trunk/util/src/main/java/org/apache/etch/util/Hash.java)是公开的,所以我写了如下代码(https://github.com/hufman/etch-hash-rs),以帮助手动生成此调试名。

extern crate etch_hash; use etch_hash::*;use std::hash::Hasher; #[test]fn null() {    assert_eq!(5381, hash("".as_bytes()));}#[test]fn single_letter() {    assert_eq!(0x150a2c9e, hash("c".as_bytes()));    assert_eq!(352988316, hash("a".as_bytes()));}#[test]fn all_letters() {    assert_eq!(352988316, hash("a".as_bytes()));    assert_eq!(1511848646, hash("ab".as_bytes()));    assert_eq!(669497117, hash("abc".as_bytes()));    assert_eq!(2300776583, hash("abcd".as_bytes()));    assert_eq!(3492286878, hash("abcde".as_bytes()));    assert_eq!(1266308680, hash("abcdef".as_bytes()));    assert_eq!(3915594783, hash("abcdefg".as_bytes()));    assert_eq!(2878000137, hash("abcdefgh".as_bytes()));    assert_eq!(53556896, hash("abcdefghi".as_bytes()));    assert_eq!(4290539978, hash("abcdefghij".as_bytes()));}#[test]fn long_names() {    assert_eq!(0x28e34aa1, hash("org.apache.etch.example.binary.binaryExample.f".as_bytes()));    assert_eq!(0x0972201e, hash("org.apache.etch.example.binary.binaryExample._result_f".as_bytes()));    assert_eq!(0x28e34a7c, hash("org.apache.etch.example.binary.binaryExample.A".as_bytes()));}#[test]fn long_names_iterative() {    let result = hash("org.apache.etch.example.binary.binaryExample".as_bytes());    assert_eq!(0x28e34aa1, hash_more(result, ".f".as_bytes()));    assert_eq!(0x0972201e, hash_more(result, "._result_f".as_bytes()));    assert_eq!(0x28e34a7c, hash_more(result, ".A".as_bytes()));} #[test]fn obj_null() {    let mut hasher = EtchHash::new();    hasher.write("".as_bytes());    assert_eq!(5381, hasher.finish());}#[test]fn obj_single_letter() {    let mut hasher = EtchHash::new();    hasher.write("c".as_bytes());    assert_eq!(0x150a2c9e, hasher.finish());    hasher = EtchHash::new();    hasher.write("a".as_bytes());    assert_eq!(352988316, hasher.finish());}#[test]fn obj_long_names_iterative() {    let mut hasher = EtchHash::new();    hasher.write("org.apache.etch.example.binary.binaryExample".as_bytes());    let mut sub_hasher;    sub_hasher = hasher.clone();    sub_hasher.write(".f".as_bytes());    assert_eq!(0x28e34aa1, sub_hasher.finish());    sub_hasher = hasher.clone();    sub_hasher.write("._result_f".as_bytes());    assert_eq!(0x0972201e, sub_hasher.finish());    sub_hasher = hasher.clone();    sub_hasher.write(".A".as_bytes());    assert_eq!(0x28e34a7c, sub_hasher.finish());    sub_hasher = EtchHash::new_with_state(hasher.finish());    sub_hasher.write(".A".as_bytes());    assert_eq!(0x28e34a7c, sub_hasher.finish());}

但是,在哪里获得名称?

事实证明,JVM字节码很容易反编译。变量名称有些模糊,但是Etch生成的类包含所有可用Etch符号的确切列表:

ValueFactoryBMWRemoting.java:     private static void a() {        a = (Type)hn.get("de.bmw.idrive.BMWRemoting.VersionInfo");        b = (Type)hn.get("de.bmw.idrive.BMWRemoting.SecurityException");        c = (Type)hn.get("de.bmw.idrive.BMWRemoting.ServiceException");        d = (Type)hn.get("de.bmw.idrive.BMWRemoting.IllegalArgumentException");        e = (Type)hn.get("de.bmw.idrive.BMWRemoting.IllegalStateException");        f = (Type)hn.get("de.bmw.idrive.BMWRemoting.ver_getVersion");        g = (Type)hn.get("de.bmw.idrive.BMWRemoting._result_ver_getVersion");        h = (Type)hn.get("de.bmw.idrive.BMWRemoting.info_getSystemInfo");        i = (Type)hn.get("de.bmw.idrive.BMWRemoting._result_info_getSystemInfo");        j = (Type)hn.get("de.bmw.idrive.BMWRemoting.sas_crl");        k = (Type)hn.get("de.bmw.idrive.BMWRemoting._result_sas_crl");        l = (Type)hn.get("de.bmw.idrive.BMWRemoting.sas_certificate");        m = (Type)hn.get("de.bmw.idrive.BMWRemoting._result_sas_certificate");        n = (Type)hn.get("de.bmw.idrive.BMWRemoting.sas_login");        o = (Type)hn.get("de.bmw.idrive.BMWRemoting._result_sas_login");...

因此,只是通过自定义哈希运行此名称列表,然后将结果文件提供给Wireshark,就会获得协议转储文件!
 
对宝马车载apps协议的逆向分析研究



0x07 TCP连接


 
在对代码进行反编译的同时,我对协议连接方法进行了一些研究,发现BMW Connected应用程序会运行TCP localhost服务器,该服务器通过此BCL复用连接代理所有连接。

这意味着任何root call都可以运行tcpdump并记录每个应用程序与汽车的通信,而无需运行蓝牙嗅探。
 
另外,这意味着我不需要重写主要的BMW Connected应用程序,只需要打开与手机上的TCP端口的TCP连接即可直接与汽车建立连接,并可以扮演任何其他BMW角色启用的应用。
 
如何找出代理运行在哪个端口?BMW Connected应用程序广播系统范围内的Android Intent,其中包含连接细节,以便在汽车连接时使用。此外,它已硬编码到特定端口,因此我可以尝试手动连接到该端口。
 
这完全改变了我的方法:我要做的就是学会这种高级RPC协议!


0x08 重建Etch IDL



使用从应用程序生成的Etch RPC类,可以使用所需的位来重构Etch IDL(https://github.com/hufman/IDriveConnectKit/blob/master/etch/BMWRemoting.etch)文件。

Etch编译器返回了一些代理对象,我有了自己的一套Etch RPC类!


0x09 攻击欺骗汽车


 
我刚开始实现一个Etch RPC的服务器端,然后将自己的连接通知发送到官方应用程序。

Android Intent是应用程序组件之间通过松散耦合彼此通信的标准方式,并且完全未经身份验证。

这欺骗了官方应用程序模块,使其无法在我的控制下连接到我自己的Etch服务器,而不是连接到汽车的BCL代理。
 
Etch对象带有NotImplementedExceptions,因此很容易看到官方应用程序发出了哪些调用。

在填写完我需要使官方应用程序满意的Fake Car资料后,该应用程序(在模拟器中运行)使用提供的VIN号下载了从未与之实际连接的汽车的图像:
 
对宝马车载apps协议的逆向分析研究



0x10 汽车认证


 
从应用程序到汽车建立新的连接时,它所做的第一件事就是向汽车发送PKCS7证书。汽车以随机数作为响应,应用程序应以一些身份验证数据作为响应。
 
该证书由BMW CA签署,因此没有真正的解决方法。但是,仅从应用APK中提取文件即可轻松获得这些证书,而主要的联网应用大约包含10个。
 
接下来,如何找出正确的随机数响应?它看起来足够长,可以成为RSA4096签名,因此尝试破解。
 
事实证明,JVM字节码很容易反编译:将整个应用程序反编译为混淆的Java文件后,可以将整个程序加载到Android Studio中,并使用其强大的代码搜索和重构工具来帮助导航代码。可以找到线索com.bmwgroup.connected.internal.security.CarSecurityManager?
 
实际上,代码说明了它如何使用Android Binder RPC连接到CarSecurityService对象,以及如何将质询随机数交换为质询响应。此外,该服务已导出,可供手机上的任何应用使用。
 
对宝马车载apps协议的逆向分析研究
 
一些快速测试代码验证了此服务所返回的答案与Wireshark捕获的答案相同。服务实现是围绕本机库的一个小的JNI包装,该库包含一些OpenSSL符号。


0x11 首次连接


 
掌握了这些知识后,我迅速构建了一个测试应用程序,试图使我第一次与汽车建立联系!官方应用程序从查找数据包捕获中所做的第一件事之一就是调用rhmi_getCapabilities以获取汽车支持的功能标志列表。
 
这涉及实现一个BroadcastReceiver来侦听汽车的连接通知,充当SecurityService的客户端以准备好挑战随机数,并实际使用适当的连接详细信息实例化Etch代理对象。
 
在台式机和车库之间经过数次尝试之后,我成功建立了连接!
 
对宝马车载apps协议的逆向分析研究


0x12 RHMI资源


 
这个项目的主要目标是向汽车添加更多音乐应用程序,因此我自然而然地将注意力集中到RHMI调用命名空间,并使用诸如rhmi_setData和名称rhmi_onActionEvent。

应用程序最先调用的一个是rhmi_setResource,用于发送XML小部件布局和一些zip文件。这些资源可在任何BMW应用程序的APK中轻松获得,从而便于检查。
 
第一个也是最重要的是XML的layout(https://github.com/hufman/AndroidAutoIdrive/blob/master/app/src/main/assets/carapplications/basecoreOnlineServices/rhmi/ui_description.xml)。它包含组织成窗口的组件列表,组件中显示的模型列表以及链接到小部件的动作列表。

这些模型中的某些模型可以保存来自电话应用程序的任意数据,而另一些模型可以包含指向zip文件中资源的数字ID。
 
压缩后的资源很简单:一个图形包,其中的每个文件都用数字ID命名,或者是一个翻译文本包,其中的字符串由ID键入。

在应用程序中环顾四周,有适用于BMW或Mini品牌的不同资源包。


0x13 First App


 
因此,在我第一次尝试在汽车中创建应用程序时,我复制了初始化调用以发送远程UI资源,然后测试了该rhmi_setData调用以尝试将图像加载到汽车中:
 
对宝马车载apps协议的逆向分析研究
 
弄清楚动作事件系统非常容易:在调用rhmi_addActionEventHandler信号通知汽车发送输入事件后,汽车将开始调用rhmi_onActionEvent有关触发了哪个动作的详细信息,并将该动作链接回原始组件。


0x14 RHMI白名单


 
我的下一个实验是尝试编辑发送到汽车的Layout。但是失败了,汽车拒绝了上传。但是,上传原始工件仍然非常有效。
 
我在身份验证证书中找到了SHA256校验的列表,原始资源与这些校验和匹配。这意味着我无法更改任何窗口小部件布局或图形包。

这主要是<entryButton>组件的问题,该组件被硬编码到图形包中的特定图标,因此,基本上任何创建RHMI应用程序的应用程序都将显示属于原始身份验证证书的应用程序图标。
 
对宝马车载apps协议的逆向分析研究


0x15 组件属性


 
最初,我很失望:如果我被锁定在原始的小部件布局中,那么我将无法构建一个自定义的应用程序。但是,有时rhmi_setProperty在Wireshark捕获中看到了正确的调用,并且小部件布局中的某些组件<properties>定义了一个集合,但是属性是由数字ID定义的。
 
事实证明,Java字节码确实很容易反编译,我发现此RhmiPropertyType枚举包含整个列表。
 
对宝马车载apps协议的逆向分析研究
 
因此,即使无法更改窗口小部件配置,也可以设置窗口小部件属性,包括可见性,位置和大小,这仍然具有很大的灵活性。


0x16 Meida Browser Service API


 
有了这些基本构建块,我便开始为汽车构建自己的音乐应用程序!除了我的手机上已经有几个出色的音乐应用程序外,我将这些应用程序添加到汽车中,而不是编写自己的应用程序。

有什么方法可以代替我控制现有的音乐应用程序吗?
 
事实证明,Android音乐应用程序实现称为MediaBrowserService的协议。通过实现此单一API,该应用程序可以通过Android Auto,Android Wear和蓝牙堆栈自动使用。

我可以充当该接口的另一个客户端,并在此API之上将汽车实现为前端。


0x17 上传自己的apps



这种策略使实施相对容易:我只需要为此MediaBrowserService构建一个客户端,每当发生任何元数据回调时,都使用适当的信息更新汽车的标签。汽车上的按钮回调可以针对音乐应用运行命令。
 
然后经过几个月的测试,我有了一个具有完整浏览和搜索支持的音乐应用程序(https://github.com/hufman/AndroidAutoIdrive),它可以控制任何MediaBrowserService音乐应用程序:
 
对宝马车载apps协议的逆向分析研究 对宝马车载apps协议的逆向分析研究



0x18 安全分析总结


 
此Connected Apps协议仅在通过USB或蓝牙从手机到(行驶中的)汽车的本地连接上起作用。还有许多其他安全限制:

1. 手机应用必须使用BMW签名的证书登录。但是,只需提取任何现有应用程序,即可轻松访问这些证书。实际上,官方配套应用程序(例如Spotify)为官方应用程序托管一个ContentProvider,以获取特定于应用程序的证书并代表它们登录汽车。该ContentProvider为手机上的任何应用提供对证书的读取访问权限。

2. 此身份验证证书具有许可白名单:可以身份验证的汽车品牌,解锁的功能,可以访问的汽车数据服务的哪些部分,是否可以创建应用程序以及使用哪些资产。但是,也许为了便于开发,官方证书已经开放很多,只将资产锁定在一些很好的通用布局中。

3. 汽车将登录随机数交换为客户端应用进行签名。在2018年,宝马确实取消了出口服务,该服务允许任何应用程序要求对此的答案。尽管他们无法从Connected Classic应用中删除它,因为Play商店不允许他们在不更新目标Android API级别的情况下对其进行更新,但大多数用户将可以安全地运行新的Connected应用。但是,不道德的攻击者可能只包括JNI库来生成自己的挑战响应。

4. 这些应用已沙箱化。这些应用只能访问(只读)汽车数据服务中的信息(https://hufman.github.io/BMWConnectedAnalysis/cds/),而不能直接访问canbus。新的应用程序布局隐藏在菜单中某个应用程序的进入按钮后面,并且任何全局状态都只能通过特定的功能进行修改:设置音乐元数据,触发导航,发起电话等等。

5. 该协议已被弃用并消失。宝马的新Live Cockpithttps://www.pocket-lint.com/cars/news/bmw/146565-bmw-live-cockpit-bmw-operating-system-7-0-infotainment)系统不支持任何远程应用协议,而是最终在其现有的Apple Carplay支持中添加了Android Auto支持。将有一些应用程序嵌入到汽车中,但手机中的应用程序将无法以相同的方式进行集成。

为了进一步减少攻击,宝马应该利用自己的沙盒经验,并像通用汽车一样,启动一个开发人员计划,让用户表达自己的创造力并构建自己的应用程序以为应用程序商店做贡献。

对宝马车载apps协议的逆向分析研究

对宝马车载apps协议的逆向分析研究
– End –


对宝马车载apps协议的逆向分析研究



看雪ID:0xbird

https://bbs.pediy.com/user-823237.htm 

*本文由看雪论坛  0xbird  翻译,转载请注明来自看雪社区


推荐文章++++

对宝马车载apps协议的逆向分析研究

* Dex文件结构学习

* Bambook把玩心得

* PHPStudy后门事件分析

* CVE-2012-0003漏洞分析

* X86指令混淆之函数分析和代码块粉碎


好书推荐对宝马车载apps协议的逆向分析研究






对宝马车载apps协议的逆向分析研究
公众号ID:ikanxue
官方微博:看雪安全
商务合作:wsc@kanxue.com



对宝马车载apps协议的逆向分析研究
戳“阅读原文”一起来充电吧!

《对宝马车载apps协议的逆向分析研究》原文地址:http://mp.weixin.qq.com/s?__biz=MjM5NTc2MDYxMw==&mid=2458303493&idx=1&sn=072a250e780ab340aac1a571ad713329&chksm=b1818c8f86f60599d34eb4b7e7867ebfc78d15d35180d91c33dfdb91a6226f1abfbdedd1deff

赞(0)
本站所刊载内容均为网络上收集整理,本站不保证其真实性和准确性,所有内容仅供大家娱乐参考。如有异议,请与本站联系,会尽快处理争议内容。VPS主机推荐 » 对宝马车载apps协议的逆向分析研究

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址