d4e5cb7993
Adds detection for ELM327 chips reporting CAN ERROR when vehicle is off. Addes some enhanced UDS Error codes. Cleaned up reporting from getvinfo if the vehicle is off or not connected.
426 lines
13 KiB
Ruby
Executable File
426 lines
13 KiB
Ruby
Executable File
#!/usr/bin/env ruby
|
|
|
|
# ELM327 and STN1100 MCU interface to the Metasploit HWBridge
|
|
|
|
##
|
|
# This module requires Metasploit: http://metasploit.com/download
|
|
# Current source: https://github.com/rapid7/metasploit-framework
|
|
##
|
|
|
|
####
|
|
# This module requires a connected ELM327 or STN1100 is connected to
|
|
# the machines serial. Sets up a basic RESTful web server to communicate
|
|
#
|
|
# Requires MSF and the serialport gem to be installed.
|
|
# - `gem install serialport`
|
|
# - or, if using rvm: `rvm gemset install serialport`
|
|
####
|
|
|
|
|
|
### Non-typical gem ###
|
|
begin
|
|
require 'serialport'
|
|
rescue LoadError => e
|
|
gem = e.message.split.last
|
|
abort "#{gem} gem is not installed. Please install with `gem install #{gem}' or, if using rvm, `rvm gemset install #{gem}' and try again."
|
|
end
|
|
|
|
#
|
|
# Load our MSF API
|
|
#
|
|
|
|
msfbase = __FILE__
|
|
while File.symlink?(msfbase)
|
|
msfbase = File.expand_path(File.readlink(msfbase), File.dirname(msfbase))
|
|
end
|
|
$:.unshift(File.expand_path(File.join(File.dirname(msfbase), '..', '..', 'lib')))
|
|
require 'msfenv'
|
|
require 'rex'
|
|
require 'msf/core'
|
|
require 'optparse'
|
|
|
|
# Prints with [*] that represents the message is a status
|
|
#
|
|
# @param msg [String] The message to print
|
|
# @return [void]
|
|
def print_status(msg='')
|
|
$stdout.puts "[*] #{msg}"
|
|
end
|
|
|
|
# Prints with [-] that represents the message is an error
|
|
#
|
|
# @param msg [String] The message to print
|
|
# @return [void]
|
|
def print_error(msg='')
|
|
$stdout.puts "[-] #{msg}"
|
|
end
|
|
|
|
# Base ELM327 Class for the Realy
|
|
module ELM327HWBridgeRelay
|
|
|
|
class ELM327Relay < Msf::Auxiliary
|
|
|
|
include Msf::Exploit::Remote::HttpServer::HTML
|
|
|
|
# @!attribute serial_port
|
|
# @return [String] The serial port device name
|
|
attr_accessor :serial_port
|
|
|
|
# @!attribute serial_baud
|
|
# @return [Integer] Baud rate of serial device
|
|
attr_accessor :serial_baud
|
|
|
|
# @!attribute serial_bits
|
|
# @return [Integer] Number of serial data bits
|
|
attr_accessor :serial_bits
|
|
|
|
# @!attribute serial_stop_bits
|
|
# @return [Integer] Stop bit
|
|
attr_accessor :serial_stop_bits
|
|
|
|
# @!attribute server_port
|
|
# @return [Integer] HTTP Relay server port
|
|
attr_accessor :server_port
|
|
|
|
def initialize(info={})
|
|
# Set some defaults
|
|
self.serial_port = "/dev/ttyUSB0"
|
|
self.serial_baud = 115200
|
|
begin
|
|
@opts = OptsConsole.parse(ARGV)
|
|
rescue OptionParser::InvalidOption, OptionParser::MissingArgument => e
|
|
print_error("#{e.message} (please see -h)")
|
|
exit
|
|
end
|
|
|
|
if @opts.has_key? :server_port
|
|
self.server_port = @opts[:server_port]
|
|
else
|
|
self.server_port = 8080
|
|
end
|
|
|
|
super(update_info(info,
|
|
'Name' => 'ELM327/STN1100 HWBridge Relay Server',
|
|
'Description' => %q{
|
|
This module sets up a web server to bridge communications between
|
|
Metasploit and the EML327 or STN1100 chipset.
|
|
},
|
|
'Author' => [ 'Craig Smith' ],
|
|
'License' => MSF_LICENSE,
|
|
'Actions' =>
|
|
[
|
|
[ 'WebServer' ]
|
|
],
|
|
'PassiveActions' =>
|
|
[
|
|
'WebServer'
|
|
],
|
|
'DefaultAction' => 'WebServer',
|
|
'DefaultOptions' =>
|
|
{
|
|
'SRVPORT' => self.server_port,
|
|
'URIPATH' => "/"
|
|
}))
|
|
self.serial_port = @opts[:serial] if @opts.has_key? :serial
|
|
self.serial_baud = @opts[:baud].to_i if @opts.has_key? :baud
|
|
self.serial_bits = 8
|
|
self.serial_stop_bits = 1
|
|
@operational_status = 0
|
|
@ser = nil # Serial Interface
|
|
@device_name = ""
|
|
@packets_sent = 0
|
|
@last_sent = 0
|
|
@starttime = Time.now()
|
|
@supported_buses = [ { "bus_name" => "can0" } ]
|
|
end
|
|
|
|
# Sends a serial command to the ELM327. Automatically appends \r\n
|
|
#
|
|
# @param cmd [String] Serial AT command for ELM327
|
|
# @return [String] Response between command and '>' prompt
|
|
def send_cmd(cmd)
|
|
@ser.write(cmd + "\r\n")
|
|
resp = @ser.readline(">")
|
|
resp = resp[0, resp.length - 2]
|
|
resp.chomp!
|
|
resp
|
|
end
|
|
|
|
# Connects to the ELM327, resets paramters, gets device version and sets up general comms.
|
|
# Serial params are set via command options or during initialization
|
|
#
|
|
# @return [SerialPort] SerialPort object for communications. Also available as @ser
|
|
def connect_to_device()
|
|
begin
|
|
@ser = SerialPort.new(self.serial_port, self.serial_baud, self.serial_bits, self.serial_stop_bits, SerialPort::NONE)
|
|
rescue
|
|
$stdout.puts "Unable to connect to serial port. See -h for help"
|
|
exit -2
|
|
end
|
|
resp = send_cmd("ATZ") # Turn off ECHO
|
|
if resp =~ /ELM327/
|
|
send_cmd("ATE0") # Turn off ECHO
|
|
send_cmd("ATL0") # Disble linefeeds
|
|
@device_name = send_cmd("ATI")
|
|
send_cmd("ATH1") # Show Headers
|
|
@operational_status = 1
|
|
$stdout.puts("Connected. Relay is up and running...")
|
|
else
|
|
$stdout.puts("Connected but invalid ELM response: #{resp.inspect}")
|
|
@operational_status = 2
|
|
# Down the road we may make a way to re-init via the hwbridge but for now just exit
|
|
$stdout.puts("The device may not have been fully initialized, try reconnecting")
|
|
exit(-1)
|
|
end
|
|
@ser
|
|
end
|
|
|
|
# HWBridge Status call
|
|
#
|
|
# @return [Hash] Status return hash
|
|
def get_status()
|
|
status = Hash.new
|
|
status["operational"] = @operational_status
|
|
status["hw_specialty"] = { "automotive" => true }
|
|
status["hw_capabilities"] = { "can" => true}
|
|
status["last_10_errors"] = @last_errors # NOTE: no support for this yet
|
|
status["api_version"] = "0.0.1"
|
|
status["fw_version"] = "not supported"
|
|
status["hw_version"] = "not supported"
|
|
status["device_name"] = @device_name
|
|
status
|
|
end
|
|
|
|
# HWBridge Statistics Call
|
|
#
|
|
# @return [Hash] Statistics return hash
|
|
def get_statistics()
|
|
stats = Hash.new
|
|
stats["uptime"] = Time.now - @starttime
|
|
stats["packet_stats"] = @packets_sent
|
|
stats["last_request"] = @last_sent
|
|
volt = send_cmd("ATRV")
|
|
stats["voltage"] = volt.gsub(/V/,'')
|
|
stats
|
|
end
|
|
|
|
# HWBRidge DateTime Call
|
|
#
|
|
# @return [Hash] System DateTime Hash
|
|
def get_datetime()
|
|
{ "sytem_datetime" => Time.now() }
|
|
end
|
|
|
|
# HWBridge Timezone Call
|
|
#
|
|
# @return [Hash] System Timezone as String
|
|
def get_timezone()
|
|
{ "system_timezone" => Time.now.getlocal.zone }
|
|
end
|
|
|
|
# Returns supported buses. Can0 is always available
|
|
# TODO: Use custom methods to force non-standard buses such as kline
|
|
#
|
|
# @return [Hash] Hash of supported_buses
|
|
def get_supported_buses()
|
|
@supported_buses
|
|
end
|
|
|
|
# Sends CAN packet
|
|
#
|
|
# @param id [String] ID as a hex string
|
|
# @param data [String] String of HEX bytes to send
|
|
# @return [Hash] Success Hash
|
|
def cansend(id, data)
|
|
result = {}
|
|
result["success"] = false
|
|
id = "%03X" % id.to_i(16)
|
|
resp = send_cmd("ATSH#{id}")
|
|
if resp == "OK"
|
|
send_cmd("ATR0") # Disable response checks
|
|
send_cmd("ATCAF0") # Turn off ISO-TP formating
|
|
else
|
|
return result
|
|
end
|
|
if data.scan(/../).size > 8
|
|
$stdout.puts("Error: Data size > 8 bytes")
|
|
return result
|
|
end
|
|
send_cmd(data)
|
|
@packets_sent += 1
|
|
@last_sent = Time.now()
|
|
if resp == "CAN ERROR"
|
|
result["success"] = false
|
|
return result
|
|
end
|
|
result["success"] = true
|
|
result
|
|
end
|
|
|
|
# Sends ISO-TP Packets
|
|
#
|
|
# @param srcid [String] Sender ID as hex string
|
|
# @param dstid [String] Responder ID as hex string
|
|
# @param data [String] Hex String of data to send
|
|
# @param timeout [Integer] Millisecond timeout, currently not implemented
|
|
# @param maxpkts [Integer] Max number of packets in response, currently not implemented
|
|
def isotpsend_and_wait(srcid, dstid, data, timeout, maxpkts)
|
|
result = {}
|
|
result["success"] = false
|
|
srcid = "%03X" % srcid.to_i(16)
|
|
dstid = "%03X" % dstid.to_i(16)
|
|
send_cmd("ATMCAF1") # Turn on ISO-TP formatting
|
|
send_cmd("ATR1") # Turn on responses
|
|
send_cmd("ATSTH#{srcid}") # Src Header
|
|
send_cmd("ATCRA#{dstid}") # Resp Header
|
|
send_cmd("ATCFC1") # Enable flow control
|
|
resp = send_cmd(data)
|
|
@packets_sent += 1
|
|
@last_sent = Time.now()
|
|
if resp == "CAN ERROR"
|
|
result["success"] = false
|
|
return result
|
|
end
|
|
result["Packets"] = []
|
|
resp.split(/\r/).each do |line|
|
|
pkt = {}
|
|
if line=~/^(\w+) (.+)/
|
|
pkt["ID"] = $1
|
|
pkt["DATA"] = $2.split
|
|
end
|
|
result["Packets"] << pkt
|
|
end
|
|
result["success"] = true
|
|
result
|
|
end
|
|
|
|
# Generic Not supported call
|
|
#
|
|
# @return [Hash] Status not supported
|
|
def not_supported()
|
|
{ "status" => "not supported" }
|
|
end
|
|
|
|
# Handles incomming URI requests and calls their respective API functions
|
|
#
|
|
# @param cli [Socket] Socket for the browser
|
|
# @param request [Rex::Proto::Http::Request] HTTP Request sent by the browser
|
|
def on_request_uri(cli, request)
|
|
if request.uri =~ /status$/i
|
|
send_response_html(cli, get_status().to_json(), { 'Content-Type' => 'application/json' })
|
|
elsif request.uri =~ /statistics$/i
|
|
send_response_html(cli, get_stats().to_json(), { 'Content-Type' => 'applicaiton/json' })
|
|
elsif request.uri =~/settings\/datetime$/i
|
|
send_response_html(cli, get_datetime().to_json(), { 'Content-Type' => 'application/json' })
|
|
elsif request.uri =~/settings\/timezone$/i
|
|
send_response_html(cli, get_timezone().to_json(), { 'Content-Type' => 'application/json' })
|
|
# elsif request.uri =~/custom_methods$/i
|
|
# send_response_html(cli, get_custom_methods().to_json(), { 'Content-Type' => 'application/json' })
|
|
elsif request.uri =~/automotive/i
|
|
if request.uri =~/automotive\/supported_buses/i
|
|
send_response_html(cli, get_supported_buses().to_json(), { 'Content-Type' => 'application/json' })
|
|
elsif request.uri =~/automotive\/can0\/cansend/
|
|
params = CGI.parse(URI(request.uri).query)
|
|
if params.has_key? "id" and params.has_key? "data"
|
|
send_response_html(cli, cansend(params["id"][0], params["data"][0]).to_json(), { 'Content-Type' => 'application/json' })
|
|
else
|
|
send_response_html(cli, not_supported().to_json(), { 'Content-Type' => 'application/json' })
|
|
end
|
|
elsif request.uri =~/automotive\/can0\/isotpsend_and_wait/
|
|
params = CGI.parse(URI(request.uri).query)
|
|
if params.has_key? "srcid" and params.has_key? "dstid" and params.has_key? "data"
|
|
timeout = 1500
|
|
maxpkts = 3
|
|
timeout = params["timeout"][0] if params.has_key? "timeout"
|
|
maxpkts = params["maxpkts"][0] if params.has_key? "maxpkts"
|
|
send_response_html(cli, isotpsend_and_wait(params["srcid"][0], params["dstid"][0], params["data"][0], timeout, maxpkts).to_json(), { 'Content-Type' => 'application/json' })
|
|
else
|
|
send_response_html(cli, not_supported().to_json(), { 'Content-Type' => 'application/json' })
|
|
end
|
|
else
|
|
send_response_html(cli, not_supported().to_json(), { 'Content-Type' => 'application/json' })
|
|
end
|
|
else
|
|
send_response_html(cli, not_supported().to_json(), { 'Content-Type' => 'application/json' })
|
|
end
|
|
end
|
|
|
|
# Main run operation. Connects to device then runs the server
|
|
def run
|
|
connect_to_device()
|
|
exploit()
|
|
end
|
|
|
|
end
|
|
|
|
# This class parses the user-supplied options (inputs)
|
|
class OptsConsole
|
|
|
|
DEFAULT_BAUD = 115200
|
|
DEFAULT_SERIAL = "/dev/ttyUSB0"
|
|
|
|
# Returns the normalized user inputs
|
|
#
|
|
# @param args [Array] This should be Ruby's ARGV
|
|
# @raise [OptionParser::MissingArgument] Missing arguments
|
|
# @return [Hash] The normalized options
|
|
def self.parse(args)
|
|
parser, options = get_parsed_options
|
|
|
|
# Now let's parse it
|
|
# This may raise OptionParser::InvalidOption
|
|
parser.parse!(args)
|
|
|
|
options
|
|
end
|
|
|
|
# Returns the parsed options from ARGV
|
|
#
|
|
# raise [OptionParser::InvalidOption] Invalid option found
|
|
# @return [OptionParser, Hash] The OptionParser object and an hash containg the options
|
|
def self.get_parsed_options
|
|
options = {}
|
|
parser = OptionParser.new do |opt|
|
|
opt.banner = "Usage: #{__FILE__} [options]"
|
|
opt.separator ''
|
|
opt.separator 'Specific options:'
|
|
|
|
opt.on('-b', '--baud <serial_baud>',
|
|
"(Optional) Sets the baud speed for the serial device (Default=#{DEFAULT_BAUD})") do |v|
|
|
options[:baud] = v
|
|
end
|
|
|
|
opt.on('-s', '--serial <serial_device>',
|
|
"(Optional) Sets the serial device (Default=#{DEFAULT_SERIAL})") do |v|
|
|
options[:serial] = v
|
|
end
|
|
|
|
opt.on('-p', '--port <server_port>',
|
|
"(Optional) Sets the listening HTTP server port (Default=8080)") do |v|
|
|
options[:server_port] = v
|
|
end
|
|
|
|
opt.on_tail('-h', '--help', 'Show this message') do
|
|
$stdout.puts opt
|
|
exit
|
|
end
|
|
end
|
|
return parser, options
|
|
end
|
|
end
|
|
end
|
|
|
|
|
|
|
|
#
|
|
# Main
|
|
#
|
|
if __FILE__ == $PROGRAM_NAME
|
|
begin
|
|
bridge = ELM327HWBridgeRelay::ELM327Relay.new
|
|
bridge.run
|
|
rescue Interrupt
|
|
$stdout.puts("Shutting down")
|
|
end
|
|
end
|