Land #16788, SCADA scanner module for BACnet protocol
This commit is contained in:
commit
fd2b325e44
|
@ -0,0 +1,74 @@
|
|||
## Vulnerable Application
|
||||
BACnet is a Data Communication Protocol for Building Automation and Control Networks.
|
||||
Developed under the auspices of the American Society of Heating,
|
||||
Refrigerating and Air-Conditioning Engineers (ASHRAE), BACnet is an American national standard,
|
||||
a European standard, a national standard in more than 30 countries, and an ISO global standard.
|
||||
The protocol is supported and maintained by ASHRAE Standing Standard Project Committee 135
|
||||
|
||||
This script polls bacnet devices with a l3 broadcast Who-is message
|
||||
and for each reply communicates further to discover more data and saves the data into metasploit.
|
||||
Each bacnet device responds with this data:
|
||||
- It's IP address, and BACnet/IP address (if the device is nested).
|
||||
- It's device number.
|
||||
- Model name.
|
||||
- Application software version.
|
||||
- Firmware revision.
|
||||
- Device description.
|
||||
## Verification Steps
|
||||
|
||||
1. Start msfconsole.
|
||||
2. Do: `use auxiliary/scanner/scada/bacnet_l3`.
|
||||
3. Do: `set INTERFACE`.
|
||||
5. Do: `run`.
|
||||
6. Devices running the BACnet protocol should respond with data.
|
||||
|
||||
## Options
|
||||
A user can choose between the interfaces of his host (e.g. eth1, ens192...),
|
||||
the number of Who-is packets to send - for reliability purposes, the time (in seconds) to wait for packets to arrive
|
||||
and the UDP port, the default is 47808.
|
||||
|
||||
The user can always check these options via the `show options` command.
|
||||
|
||||
```
|
||||
msf auxiliary(profinet_siemens) > show options
|
||||
|
||||
Module options (auxiliary/scanner/scada/bacnet_l3):
|
||||
|
||||
Name Current Setting Required Description
|
||||
---- --------------- -------- -----------
|
||||
COUNT 1 yes The number of times to send each packet
|
||||
INTERFACE eth1 yes The interface to scan from
|
||||
PORT 47808 yes BACnet/IP UDP port to scan (usually between 47808-47817)
|
||||
TIMEOUT 1 yes The socket connect timeout in seconds
|
||||
```
|
||||
|
||||
## Scenarios
|
||||
|
||||
The following demonstrates a basic scenario, we "detect" two devices:
|
||||
|
||||
```
|
||||
|
||||
msf > use auxiliary/scanner/scada/bacnet_l3
|
||||
msf auxiliary(auxiliary/scanner/scada/bacnet_l3) > run
|
||||
|
||||
[*] Broadcasting Who-is via eth1
|
||||
[*] found 2 devices
|
||||
[*] Querying device number 826001 in ip 192.168.13.11
|
||||
[*] Querying device number 4194303 in ip 192.168.13.12
|
||||
[*] Done scanning
|
||||
[+] for asset number 826001:
|
||||
model name: iSMA-B-4U4A-H-IP
|
||||
firmware revision: 6.2
|
||||
application software version: GC5 6.2
|
||||
description: BACnet iSMA-B-4U4A-H-IP Module
|
||||
|
||||
[+] for asset number 4194303:
|
||||
model name: PXG3.L-1
|
||||
firmware revision: FW=01.21.30.38;WPC=1.4.131;SVS-300:SBC=13.21;
|
||||
application software version:
|
||||
description: BacnetRouter
|
||||
|
||||
[+] Successfully saved data to local store named bacnet-discovery.xml
|
||||
[*] Done.
|
||||
[*] Auxiliary module execution completed
|
||||
```
|
|
@ -0,0 +1,417 @@
|
|||
class MetasploitModule < Msf::Auxiliary
|
||||
include Msf::Auxiliary::Report
|
||||
include Msf::Exploit::Capture
|
||||
include Rex::Socket::Udp
|
||||
|
||||
FILE_NAME = 'bacnet-discovery'.freeze
|
||||
DEFAULT_SERVER_TIMEOUT = 1
|
||||
DEFAULT_SEND_COUNT = 1
|
||||
DEFAULT_SLEEP = 1
|
||||
|
||||
BACNET_ASHARE_STANDARD = "\x01".freeze
|
||||
BACNETIP_CONSTANT = "\x81".freeze
|
||||
BACNET_LLC = "\x82\x82\x03".freeze
|
||||
BACNET_BVLC = "\x81\x0b\x00\x0c".freeze
|
||||
BACNET_BVLC_LEN = BACNET_BVLC.length
|
||||
|
||||
BACNET_WHOIS_APDU_NPDU = "\x01\x20\xff\xff\x00\xff\x10\x08".freeze
|
||||
|
||||
# Building Automation and Control Network APDU
|
||||
# 0001 .... = APDU Type: Unconfirmed-REQ (1)
|
||||
# Unconfirmed Service Choice: i-Am (0)
|
||||
# ObjectIdentifier: device
|
||||
BACNET_UNCOFIRMED_REQ_I_AM_OBJ_DEVICE_PREFIX = "\x10\x00\xc4\x02".freeze
|
||||
DEFAULT_BACNET_PORT = 47808
|
||||
DISCOVERY_MESSAGE_L3 = BACNET_BVLC + BACNET_WHOIS_APDU_NPDU
|
||||
DISCOVERY_MESSAGE_L2 = BACNET_LLC + BACNET_WHOIS_APDU_NPDU
|
||||
DISCOVERY_MESSAGE_L2_LEN = Array[DISCOVERY_MESSAGE_L2.length].pack('n')
|
||||
|
||||
READ_MULTIPLE_DEVICES_PROP = "\x1e\x09\x08\x1f".freeze
|
||||
READ_MODEL_NAME_PROP = "\x19\x46".freeze
|
||||
READ_FIRMWARE_VERSION_PROP = "\x19\x2c".freeze
|
||||
READ_APP_SOFT_VERSION_PROP = "\x19\x0c".freeze
|
||||
READ_DESCRIPTION_PROP = "\x19\x1c".freeze
|
||||
|
||||
GET_PROPERTY_MESSAGES_L3_SIMPLE = [
|
||||
"\x81\n\u0000\u0011\u0001\u0004\u0002\u0002\u0000\f\f\u0002{object_identifier}#{READ_MODEL_NAME_PROP}", # model-name
|
||||
"\x81\n\u0000\u0011\u0001\u0004\u0002\u0002\u0000\f\f\u0002{object_identifier}#{READ_FIRMWARE_VERSION_PROP}", # firmware-revision
|
||||
"\x81\n\u0000\u0011\u0001\u0004\u0002\u0002\u0000\f\f\u0002{object_identifier}#{READ_APP_SOFT_VERSION_PROP}", # application-software-version
|
||||
"\x81\n\u0000\u0011\u0001\u0004\u0002\u0002\u0000\f\f\u0002{object_identifier}#{READ_DESCRIPTION_PROP}"
|
||||
].freeze # description
|
||||
|
||||
GET_PROPERTY_MESSAGES_L3_NESTED = [
|
||||
"\u0001${dest_net_id}{dadr_len}{dadr}\xFF\u0002\u0002\u0002\f\f\u0002{object_identifier}#{READ_MODEL_NAME_PROP}",
|
||||
"\u0001${dest_net_id}{dadr_len}{dadr}\xFF\u0002\u0002\u0002\f\f\u0002{object_identifier}#{READ_FIRMWARE_VERSION_PROP}",
|
||||
"\u0001${dest_net_id}{dadr_len}{dadr}\xFF\u0002\u0002\u0002\f\f\u0002{object_identifier}#{READ_APP_SOFT_VERSION_PROP}",
|
||||
"\u0001${dest_net_id}{dadr_len}{dadr}\xFF\u0002\u0002\u0002\f\f\u0002{object_identifier}#{READ_DESCRIPTION_PROP}"
|
||||
].freeze
|
||||
|
||||
def initialize
|
||||
super(
|
||||
'Name' => 'BACnet Scanner',
|
||||
'Description' => '
|
||||
Discover BACnet devices by broadcasting Who-is message, then poll
|
||||
discovered devices for properties including model name,
|
||||
software version, firmware revision and description.
|
||||
',
|
||||
'Author' => ['Paz @ SCADAfence'],
|
||||
'License' => MSF_LICENSE,
|
||||
'Notes' => {
|
||||
'Stability' => [CRASH_SAFE],
|
||||
'Reliability' => [UNRELIABLE_SESSION],
|
||||
'SideEffects' => [SCREEN_EFFECTS]
|
||||
}
|
||||
)
|
||||
|
||||
register_options(
|
||||
[
|
||||
OptInt.new('TIMEOUT', [true, 'The socket connect timeout in seconds', DEFAULT_SERVER_TIMEOUT]),
|
||||
OptInt.new('COUNT', [true, 'The number of times to send each packet', DEFAULT_SEND_COUNT]),
|
||||
OptPort.new('PORT', [true, 'BACnet/IP UDP port to scan (usually between 47808-47817)', DEFAULT_BACNET_PORT]),
|
||||
OptString.new('INTERFACE', [true, 'The interface to scan from', 'eth1'])
|
||||
], self.class
|
||||
)
|
||||
deregister_options('RHOSTS', 'FILTER', 'PCAPFILE', 'LHOST')
|
||||
end
|
||||
|
||||
def hex_to_bin(str)
|
||||
str.scan(/../).map { |x| x.hex.chr }.join
|
||||
end
|
||||
|
||||
def bin_to_hex(str)
|
||||
str.each_byte.map { |b| b.to_s(16).rjust(2, '0') }.join
|
||||
end
|
||||
|
||||
# Check if device is nested and extract relevant data
|
||||
def parse_npdu(data)
|
||||
is_nested = false
|
||||
if data.start_with? BACNET_ASHARE_STANDARD
|
||||
control = data[1].unpack1('C*')
|
||||
src_specifier = control & (1 << 3) != 0 # check if 4th bit is set
|
||||
dst_specifier = control & (1 << 5) != 0 # check if 6th bit is set
|
||||
|
||||
idx = 2
|
||||
if dst_specifier
|
||||
dst_len = data[idx + 2].ord
|
||||
idx += 3 + dst_len
|
||||
end
|
||||
if src_specifier
|
||||
src_net_id = data[idx..idx + 1]
|
||||
sadr_len = data[idx + 2]
|
||||
sadr = data[idx + 3..idx + 2 + sadr_len.unpack1('C*')]
|
||||
is_nested = true
|
||||
end
|
||||
|
||||
# if no network address specified - set as broadcast network address
|
||||
src_net_id ||= '\x00'
|
||||
end
|
||||
[is_nested, src_net_id, sadr_len, sadr]
|
||||
end
|
||||
|
||||
# Extracting index to start handling the data from
|
||||
def extract_index(data)
|
||||
if data.start_with? BACNET_ASHARE_STANDARD
|
||||
begin
|
||||
control = data[1].unpack1('C*')
|
||||
src_specifier = control & (1 << 3) != 0 # check if 4th bit is set
|
||||
dst_specifier = control & (1 << 5) != 0 # check if 6th bit is set
|
||||
idx = 2
|
||||
if dst_specifier
|
||||
idx += 3 + dst_len
|
||||
end
|
||||
if src_specifier
|
||||
sadr_len = data[idx + 2]
|
||||
idx += 3 + sadr_len.unpack1('C*')
|
||||
end
|
||||
idx += 1 if dst_specifier # increase index if both specifiers exist
|
||||
idx
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Broadcasting Who-is and returns a capture with the responses.
|
||||
def broadcast_who_is
|
||||
begin
|
||||
broadcast_addr = get_ipv4_broadcast(datastore['INTERFACE'])
|
||||
interface_addr = get_ipv4_addr(datastore['INTERFACE'])
|
||||
rescue StandardError
|
||||
raise StandardError, "Interface #{datastore['INTERFACE']} is down"
|
||||
end
|
||||
cap = []
|
||||
|
||||
# Create a socket for broadcast response and a socket for unicast response.
|
||||
lsocket = Rex::Socket::Udp.create({
|
||||
'LocalHost' => broadcast_addr,
|
||||
'LocalPort' => datastore['PORT'],
|
||||
'Context' => { 'Msf' => framework, 'MsfExploit' => self }
|
||||
})
|
||||
ssocket = Rex::Socket::Udp.create({
|
||||
'LocalHost' => interface_addr,
|
||||
'LocalPort' => datastore['PORT'],
|
||||
'Context' => { 'Msf' => framework, 'MsfExploit' => self }
|
||||
})
|
||||
datastore['COUNT'].times { lsocket.sendto(DISCOVERY_MESSAGE_L3, '255.255.255.255', datastore['PORT'], 0) }
|
||||
|
||||
# Collect responses with unicast or broadcast destination.
|
||||
loop do
|
||||
data, host, port = lsocket.recvfrom(65535, datastore['TIMEOUT'])
|
||||
data2, host2, port2 = ssocket.recvfrom(65535, datastore['TIMEOUT'])
|
||||
break if (host.nil? && host2.nil?)
|
||||
|
||||
cap << [data, host, port] if host
|
||||
cap << [data2, host2, port2] if host2
|
||||
end
|
||||
lsocket.close
|
||||
cap
|
||||
end
|
||||
|
||||
# Analyze I-am packets,and prepare read-property messages for each.
|
||||
def analyze_i_am_devices(capture)
|
||||
devices_data = {}
|
||||
instance_numbers = []
|
||||
capture.each do |cap|
|
||||
data = cap[0]
|
||||
ip = cap[1]
|
||||
next unless data[0] == BACNETIP_CONSTANT # If communication is not a bacnet/ip
|
||||
|
||||
data = data[4..]
|
||||
index = data.index(BACNET_UNCOFIRMED_REQ_I_AM_OBJ_DEVICE_PREFIX)
|
||||
next unless index # If cap has no I-am object
|
||||
|
||||
raw_instance_number = bin_to_hex(data[(index + BACNET_UNCOFIRMED_REQ_I_AM_OBJ_DEVICE_PREFIX.length)..(index + BACNET_UNCOFIRMED_REQ_I_AM_OBJ_DEVICE_PREFIX.length + 2)]).to_i(16) & 0x3fffff
|
||||
instance_number = raw_instance_number.to_s(16).rjust(6, '0')
|
||||
next if instance_numbers.include? instance_number # Pass if we already analysed this instance number
|
||||
|
||||
devices_data[[instance_number, ip]] = data unless devices_data[[instance_number, ip]]
|
||||
end
|
||||
devices_data
|
||||
end
|
||||
|
||||
def create_messages_for_devices(devices_data)
|
||||
messages = {}
|
||||
devices_data.each do |key, data|
|
||||
instance_number = hex_to_bin(key[0])
|
||||
items = parse_npdu(data) # Get specifier data
|
||||
# Check if device is nested and create messages accordingly
|
||||
if items[0] == true
|
||||
messages[key] = create_nested_messages(instance_number, items)
|
||||
else
|
||||
messages[key] = create_simple_messages(instance_number)
|
||||
end
|
||||
end
|
||||
messages
|
||||
end
|
||||
|
||||
# Create messages for nested device and return them in array.
|
||||
def create_nested_messages(instance_number, items)
|
||||
nested_messages = []
|
||||
GET_PROPERTY_MESSAGES_L3_NESTED.each do |msg_base|
|
||||
msg = msg_base
|
||||
.sub('{object_identifier}', instance_number)
|
||||
.sub('{dest_net_id}', items[1])
|
||||
.sub('{dadr_len}', items[2])
|
||||
.sub('{dadr}', items[3])
|
||||
length = Array(msg.length + BACNET_BVLC_LEN).pack('n*')
|
||||
msg = "\x81\n#{length}#{msg}"
|
||||
nested_messages.append(msg)
|
||||
end
|
||||
nested_messages
|
||||
end
|
||||
|
||||
# Create messages for non-nested device and return them in array.
|
||||
def create_simple_messages(instance_number)
|
||||
simple_messages = []
|
||||
GET_PROPERTY_MESSAGES_L3_SIMPLE.each do |msg_base|
|
||||
msg = msg_base.sub('{object_identifier}', instance_number)
|
||||
simple_messages.append(msg)
|
||||
end
|
||||
simple_messages
|
||||
end
|
||||
|
||||
# Loop on recorded packets and extract data from read-property messages
|
||||
def extract_data(capture)
|
||||
asset_data = {}
|
||||
capture.each do |packet|
|
||||
data = packet[0][4..]
|
||||
items = parse_npdu(data)
|
||||
index = extract_index(data)
|
||||
asset_data['sadr'] = bin_to_hex(items[3]) if items[0] == true
|
||||
type = data[index + 8..index + 9]
|
||||
attribute = ''
|
||||
case type
|
||||
when READ_MODEL_NAME_PROP
|
||||
attribute = 'model-name'
|
||||
when READ_DESCRIPTION_PROP
|
||||
attribute = 'description'
|
||||
when READ_APP_SOFT_VERSION_PROP
|
||||
attribute = 'application-software-version'
|
||||
when READ_FIRMWARE_VERSION_PROP
|
||||
attribute = 'firmware-revision'
|
||||
else
|
||||
raise "undefined attribute for property number #{bin_to_hex(type)}."
|
||||
end
|
||||
value = bin_to_hex(data[index + 9..])[/3e(.*?)3f/m, 1]
|
||||
value = hex_to_bin(value)
|
||||
value = (value[value.index(hex_to_bin('00')) + 1..]).force_encoding('UTF-8') # parsing the needed text
|
||||
asset_data[attribute] = value
|
||||
end
|
||||
asset_data
|
||||
end
|
||||
|
||||
# Gets properties from devices and returns a hash with the details of each device.
|
||||
def get_properties_from_devices(messages)
|
||||
devices_by_ip = {}
|
||||
messages.each do |key, message_block|
|
||||
instance_number = key[0].to_i(16)
|
||||
ip = key[1]
|
||||
|
||||
capture = send_read_properties(message_block, ip, instance_number)
|
||||
begin
|
||||
device = extract_data(capture)
|
||||
raise StandardError if device.empty?
|
||||
|
||||
device['instance-number'] = instance_number.to_s
|
||||
devices_by_ip[ip] = [] unless devices_by_ip[ip]
|
||||
devices_by_ip[ip].append(device)
|
||||
rescue StandardError
|
||||
print_bad("Couldn't collect data for asset number #{instance_number}.")
|
||||
end
|
||||
end
|
||||
devices_by_ip
|
||||
end
|
||||
|
||||
# Sending read-property packets and returns a pcap with the responses.
|
||||
def send_read_properties(messages, ip, instance_number)
|
||||
cap = []
|
||||
ssocket = Rex::Socket::Udp.create({
|
||||
'PeerHost' => ip,
|
||||
'PeerPort' => datastore['PORT'],
|
||||
'Context' => { 'Msf' => framework, 'MsfExploit' => self }
|
||||
})
|
||||
print_status("Querying device number #{instance_number} in ip #{ip}")
|
||||
messages.each do |message|
|
||||
ssocket.sendto(message, ip, datastore['PORT'], 0)
|
||||
loop do
|
||||
data, host, port = ssocket.recvfrom(65535, datastore['TIMEOUT'])
|
||||
break if host.nil?
|
||||
|
||||
cap << [data, host, port]
|
||||
end
|
||||
end
|
||||
ssocket.close
|
||||
cap
|
||||
end
|
||||
|
||||
# Iterates over all the devices and prints the details to the user.
|
||||
def output_results(devices_by_ip)
|
||||
devices_by_ip.each_value do |ip_group|
|
||||
ip_group.each do |asset|
|
||||
sadr = ''
|
||||
if asset['sadr']
|
||||
sadr = "sadr: #{asset['sadr']}\n"
|
||||
end
|
||||
print_good(<<~OUTPUT)
|
||||
for asset number #{asset['instance-number']}:
|
||||
\tmodel name: #{asset['model-name']}
|
||||
\tfirmware revision: #{asset['firmware-revision']}
|
||||
\tapplication software version: #{asset['application-software-version']}
|
||||
\tdescription: #{asset['description']}
|
||||
\t#{sadr}
|
||||
OUTPUT
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Convert data values to xml format.
|
||||
def parse_data_to_xml(raw_data)
|
||||
data = ''
|
||||
raw_data.each do |ip, devices|
|
||||
chunk = <<~IP.chomp
|
||||
<ip>
|
||||
<value> #{ip} </value>
|
||||
IP
|
||||
devices.each do |device|
|
||||
sadr = ''
|
||||
if device['sadr']
|
||||
sadr = "
|
||||
<sadr> #{device['sadr']} </sadr>"
|
||||
end
|
||||
chunk = <<~XML.chomp
|
||||
#{chunk}
|
||||
<asset>
|
||||
<instance-number> #{device['instance-number']} </instance-number>
|
||||
<model-name> #{device['model-name']} </model-name>
|
||||
<application-software-version> #{device['application-software-version']} </application-software-version>
|
||||
<firmware-revision> #{device['firmware-revision']} </firmware-revision>
|
||||
<description> #{device['description']} </description>#{sadr}
|
||||
</asset>
|
||||
XML
|
||||
end
|
||||
chunk += <<~IP
|
||||
|
||||
</ip>
|
||||
IP
|
||||
data += chunk
|
||||
end
|
||||
data
|
||||
end
|
||||
|
||||
def get_device_array(devices_by_ip)
|
||||
devices = []
|
||||
devices_by_ip.each do |ip, batch|
|
||||
batch.each do |device|
|
||||
device['ip'] = ip
|
||||
devices << device
|
||||
end
|
||||
end
|
||||
devices
|
||||
end
|
||||
|
||||
def run
|
||||
# Validate user input
|
||||
raise Msf::OptionValidateError, ['TIMEOUT'] if datastore['TIMEOUT'].negative?
|
||||
raise Msf::OptionValidateError, ['COUNT'] if datastore['COUNT'] < 1
|
||||
raise Msf::OptionValidateError, ['INTERFACE'] if datastore['INTERFACE'].empty?
|
||||
|
||||
begin
|
||||
# Broadcast who-is and create request-property messages for detected devices.
|
||||
print_status "Broadcasting Who-is via #{datastore['INTERFACE']}"
|
||||
capture = broadcast_who_is
|
||||
devices_data = analyze_i_am_devices(capture)
|
||||
messages = create_messages_for_devices(devices_data)
|
||||
|
||||
# If there are messages to send
|
||||
if !messages.empty?
|
||||
print_status "found #{messages.length} devices"
|
||||
sleep(DEFAULT_SLEEP)
|
||||
devices_by_ip = get_properties_from_devices(messages)
|
||||
print_status 'Done collecting data'
|
||||
sleep(DEFAULT_SLEEP)
|
||||
output_results(devices_by_ip)
|
||||
else
|
||||
fail_with(Failure::NotFound, 'No devices found. Exiting.')
|
||||
end
|
||||
rescue StandardError => e
|
||||
fail_with(Failure::Unknown, e.message)
|
||||
return
|
||||
end
|
||||
begin
|
||||
data = parse_data_to_xml(devices_by_ip)
|
||||
begin
|
||||
store_local('bacnet.devices.info'.dup, 'text/xml', data, FILE_NAME)
|
||||
print_good("Successfully saved data to local store named #{FILE_NAME}.xml")
|
||||
rescue StandardError # If there are no privileges to save a file
|
||||
devices = get_device_array(devices_by_ip)
|
||||
report_note(
|
||||
ips: devices_by_ip.keys,
|
||||
devices: devices,
|
||||
proto: 'udp'
|
||||
)
|
||||
print_good('Successfully reported data')
|
||||
end
|
||||
print_status('Done.')
|
||||
rescue StandardError => e
|
||||
fail_with(Failure::Unknown, e.message)
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Reference in New Issue