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
Data
Build on a solid foundation with Vulners data
We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data
Api
Power your application with Vulners API
The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access
App
Assess and manage vulnerabilities with Vulners tools
Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation