Files
metasploit-gs/tools/hardware/elm327_relay.rb
T
Craig Smith d4e5cb7993 Fixes #8022
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.
2017-02-27 21:09:57 -08:00

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