428 lines
13 KiB
Ruby
Executable File
428 lines
13 KiB
Ruby
Executable File
#!/usr/bin/env ruby
|
|
|
|
##
|
|
# This module requires Metasploit: https://metasploit.com/download
|
|
# Current source: https://github.com/rapid7/metasploit-framework
|
|
##
|
|
|
|
#
|
|
# ELM327 and STN1100 MCU interface to the Metasploit HWBridge
|
|
#
|
|
|
|
#
|
|
# 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 formatting
|
|
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("ATCAF1") # Turn on ISO-TP formatting
|
|
send_cmd("ATR1") # Turn on responses
|
|
send_cmd("ATSH#{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 containing 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
|
|
|