From 28c3dd57395967e4c76c49365d1eeb48a264953e Mon Sep 17 00:00:00 2001 From: PazFi Date: Tue, 19 Jul 2022 17:02:35 +0300 Subject: [PATCH 1/8] A SCADA scanner module for BACnet protocol. The scanner discovers BACnet devices on the network by broadcasting Who-is packets, extracts model name, software version, firmware revision and description from the discovered devices by sending specific read-property packets. After parsing the data the module saves it to a local xml file. Because devices can be nested, every address can have multiple devices. --- .../auxiliary/scanner/scada/bacnet_l3.md | 67 +++ modules/auxiliary/scanner/scada/bacnet_l3.rb | 412 ++++++++++++++++++ 2 files changed, 479 insertions(+) create mode 100644 documentation/modules/auxiliary/scanner/scada/bacnet_l3.md create mode 100644 modules/auxiliary/scanner/scada/bacnet_l3.rb diff --git a/documentation/modules/auxiliary/scanner/scada/bacnet_l3.md b/documentation/modules/auxiliary/scanner/scada/bacnet_l3.md new file mode 100644 index 0000000000..c92e051656 --- /dev/null +++ b/documentation/modules/auxiliary/scanner/scada/bacnet_l3.md @@ -0,0 +1,67 @@ +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. + +## User 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 3 yes The socket connect timeout in seconds + +``` + +## Usage + +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 +``` diff --git a/modules/auxiliary/scanner/scada/bacnet_l3.rb b/modules/auxiliary/scanner/scada/bacnet_l3.rb new file mode 100644 index 0000000000..5175250590 --- /dev/null +++ b/modules/auxiliary/scanner/scada/bacnet_l3.rb @@ -0,0 +1,412 @@ +require 'packetfu' + +class MetasploitModule < Msf::Auxiliary + include Msf::Auxiliary::Report + + FILE_NAME = 'bacnet-discovery'.freeze + DEFAULT_SERVER_TIMEOUT = 3 + DEFAULT_SEND_COUNT = 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 + + # Global variables initialization + $iface + $timeout + $packet_count + $port + + 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', Array[true, 'The socket connect timeout in seconds', DEFAULT_SERVER_TIMEOUT]), + OptInt.new('COUNT', Array[true, 'The number of times to send each packet', DEFAULT_SEND_COUNT]), + OptPort.new('PORT', Array[true, 'BACnet/IP UDP port to scan (usually between 47808-47817)', DEFAULT_BACNET_PORT]), + OptString.new('INTERFACE', Array[true, 'The interface to scan from', 'eth1']) + ], self.class + ) + end + + def hex_to_bin(s) + s.scan(/../).map { |x| x.hex.chr }.join + end + + def bin_to_hex(s) + s.each_byte.map { |b| b.to_s(16).rjust(2, '0') }.join + end + + def to_ip_address(s) + s = s.unpack('C*') + "#{s[0]}.#{s[1]}.#{s[2]}.#{s[3]}" + end + + # Create UDP packet with the l2 and l3 details. + def create_packet_base + udp_pkt = PacketFu::UDPPacket.new + udp_pkt.ip_daddr = '255.255.255.255' + udp_pkt.eth_daddr = 'ff:ff:ff:ff:ff:ff' + udp_pkt.udp_dport = $port + udp_pkt.udp_sport = rand(0xffff - 1024) + 1024 + udp_pkt.ip_ttl = 64 + + begin + udp_pkt.ip_saddr = `ip a s #{$iface} | grep "inet "`.scan(%r{inet (.*)/}).flatten[0] # Get interface's ip address + udp_pkt.eth_saddr = `ip a s #{$iface} | grep ether`.scan(%r{link/ether (.*) brd}).flatten[0] # Get interface's mac address + rescue StandardError + # Output is displayed via calling terminal command. + raise ArgumentError, "Device \"#{$iface}\" does not exist." + end + udp_pkt + end + + # Check if device is nested and extract relevant data + def parse_npdu(data) + is_nested = false + 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 + 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*')] + idx += 3 + sadr_len.unpack1('C*') + is_nested = true + end + idx += 1 if dst_specifier # increase index if both specifiers exist + # if no network address specified - set as broadcast network address + src_net_id ||= '\x00' + rescue StandardError => e + raise e + end + 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(udp_pkt) + capture = PacketFu::Capture.new(iface: $iface, start: true, filter: "udp and src port #{$port}") + $packet_count.times { udp_pkt.to_w($iface) } + sleep($timeout) + capture.save + capture + end + + # Analyze I-am packets,and prepare read-property messages for each. + def analyze_i_am_devices(capture) + devices_data = {} + instance_numbers = [] + capture.array.each do |packet| + mac = "#{bin_to_hex(packet[6])}:#{bin_to_hex(packet[7])}:#{bin_to_hex(packet[8])}:#{bin_to_hex(packet[9])}:#{bin_to_hex(packet[10])}:#{bin_to_hex(packet[11])}" + ip = to_ip_address(packet[26..29]) + next unless packet[42] == BACNETIP_CONSTANT # If packet is not a bacnet/ip + + data = packet[46..] + index = data.index(BACNET_UNCOFIRMED_REQ_I_AM_OBJ_DEVICE_PREFIX) + next unless index # If packet 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, mac, ip]] = data unless devices_data[[instance_number, mac, 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.array.each do |packet| + data = packet[46..] + 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 + begin + 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 + rescue StandardError => e + raise e + end + end + asset_data + end + + # Gets properties from devices and returns a hash with the details of each device. + def get_properties_from_devices(messages, udp_pkt) + devices_by_ip = {} + messages.each do |key, message_block| + instance_number = key[0].to_i(16) + mac = key[1] + ip = key[2] + + capture = send_read_properties(message_block, udp_pkt, mac, 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, udp_pkt, mac, ip, instance_number) + udp_pkt.eth_daddr = mac + udp_pkt.ip_daddr = ip + + capture = PacketFu::Capture.new(iface: $iface, start: true, + filter: "ip src #{ip} and ip dst #{udp_pkt.ip_saddr}") + print_status("Querying device number #{instance_number} in ip #{ip}") + messages.each do |message| + udp_pkt.payload = message + udp_pkt.recalc + $packet_count.times { udp_pkt.to_w($iface) } + end + sleep($timeout) + capture.save + capture + 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']} + " + end + print_good "for asset number #{asset['instance-number']}: + model name: #{asset['model-name']} + firmware revision: #{asset['firmware-revision']} + application software version: #{asset['application-software-version']} + description: #{asset['description']} + #{sadr}" + 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} " + devices.each do |device| + sadr = '' + if device['sadr'] + sadr = " + #{device['sadr']} " + end + chunk = "#{chunk} + + #{device['instance-number']} + #{device['model-name']} + #{device['application-software-version']} + #{device['firmware-revision']} + #{device['description']} #{sadr} + " + end + chunk += ' + + ' + data += chunk + end + data + end + + def run + # Get user input + $iface = datastore['INTERFACE'].freeze + $timeout = datastore['TIMEOUT'].freeze + $packet_count = datastore['COUNT'].freeze + $port = datastore['PORT'].freeze + + raise Msf::OptionValidateError, ['TIMEOUT'] if $timeout.negative? + raise Msf::OptionValidateError, ['COUNT'] if $packet_count < 1 + raise Msf::OptionValidateError, ['INTERFACE'] if $iface.empty? + + begin + # Create Base packet + udp_pkt = create_packet_base + + # Add Who-is payload + udp_pkt.payload = DISCOVERY_MESSAGE_L3 + udp_pkt.recalc + + # Prevent ICMP retransmission + socket = UDPSocket.new + socket.bind(udp_pkt.ip_saddr, udp_pkt.udp_sport) + + # Broadcast who-is and create request-property messages for detected devices. + print_status "Broadcasting Who-is via #{$iface}" + capture = broadcast_who_is(udp_pkt) + 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" + devices_by_ip = get_properties_from_devices(messages, udp_pkt) + print_status 'Done collecting data' + output_results(devices_by_ip) + else + print_status('No devices found. Exiting.') + return + end + rescue ArgumentError + return + rescue StandardError => e + print_bad(e.message) + return + end + socket.close + begin + data = parse_data_to_xml(devices_by_ip) + store_local('bacnet.devices.info'.dup, 'text/xml', data, FILE_NAME) + print_good("Successfully saved data to local store named #{FILE_NAME}.xml") + print_status('Done.') + rescue StandardError => e + print_bad(e.message) + end + end +end From a6bdc5ea290ac9b9567d1ba1d9331bce701ce52a Mon Sep 17 00:00:00 2001 From: PazFi Date: Sun, 24 Jul 2022 18:51:53 +0300 Subject: [PATCH 2/8] -Validating md file with msftidy_docs. -Removing global variables, and calling data stored in datastore when required. -Calling methods or variables instead of calling terminal commands. -Some indentations. -Using heredocs when handling multiple strings. -Handling the case where LHOST does not contain IP address. --- .../auxiliary/scanner/scada/bacnet_l3.md | 16 ++- modules/auxiliary/scanner/scada/bacnet_l3.rb | 124 +++++++++--------- 2 files changed, 73 insertions(+), 67 deletions(-) diff --git a/documentation/modules/auxiliary/scanner/scada/bacnet_l3.md b/documentation/modules/auxiliary/scanner/scada/bacnet_l3.md index c92e051656..c634ae4beb 100644 --- a/documentation/modules/auxiliary/scanner/scada/bacnet_l3.md +++ b/documentation/modules/auxiliary/scanner/scada/bacnet_l3.md @@ -1,3 +1,4 @@ +## 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, @@ -13,13 +14,21 @@ Each bacnet device responds with this data: - Application software version. - Firmware revision. - Device description. +## Verification Steps -## User Options + 1. Start msfconsole. + 2. Do: `use auxiliary/scanner/scada/bacnet_l3`. + 3. Do: `set INTERFACE`. + 4. Do: `set LHOST` and choose the chosen interface's IP. + 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. +The user can always check these options via the `show options` command. ``` msf auxiliary(profinet_siemens) > show options @@ -32,10 +41,11 @@ 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 3 yes The socket connect timeout in seconds +LHOST yes The local IP of selected interface ``` -## Usage +## Scenarios The following demonstrates a basic scenario, we "detect" two devices: diff --git a/modules/auxiliary/scanner/scada/bacnet_l3.rb b/modules/auxiliary/scanner/scada/bacnet_l3.rb index 5175250590..6ee8d23c88 100644 --- a/modules/auxiliary/scanner/scada/bacnet_l3.rb +++ b/modules/auxiliary/scanner/scada/bacnet_l3.rb @@ -2,6 +2,7 @@ require 'packetfu' class MetasploitModule < Msf::Auxiliary include Msf::Auxiliary::Report + include Msf::Exploit::Capture FILE_NAME = 'bacnet-discovery'.freeze DEFAULT_SERVER_TIMEOUT = 3 @@ -45,12 +46,6 @@ class MetasploitModule < Msf::Auxiliary "\u0001${dest_net_id}{dadr_len}{dadr}\xFF\u0002\u0002\u0002\f\f\u0002{object_identifier}#{READ_DESCRIPTION_PROP}" ].freeze - # Global variables initialization - $iface - $timeout - $packet_count - $port - def initialize super( 'Name' => 'BACnet Scanner', @@ -73,9 +68,11 @@ class MetasploitModule < Msf::Auxiliary OptInt.new('TIMEOUT', Array[true, 'The socket connect timeout in seconds', DEFAULT_SERVER_TIMEOUT]), OptInt.new('COUNT', Array[true, 'The number of times to send each packet', DEFAULT_SEND_COUNT]), OptPort.new('PORT', Array[true, 'BACnet/IP UDP port to scan (usually between 47808-47817)', DEFAULT_BACNET_PORT]), - OptString.new('INTERFACE', Array[true, 'The interface to scan from', 'eth1']) + OptString.new('INTERFACE', Array[true, 'The interface to scan from', 'eth1']), + OptAddressLocal.new('LHOST', [true, 'The local listener hostname']) ], self.class ) + deregister_options('RHOSTS', 'FILTER', 'PCAPFILE') end def hex_to_bin(s) @@ -86,26 +83,20 @@ class MetasploitModule < Msf::Auxiliary s.each_byte.map { |b| b.to_s(16).rjust(2, '0') }.join end - def to_ip_address(s) - s = s.unpack('C*') - "#{s[0]}.#{s[1]}.#{s[2]}.#{s[3]}" - end - # Create UDP packet with the l2 and l3 details. def create_packet_base udp_pkt = PacketFu::UDPPacket.new udp_pkt.ip_daddr = '255.255.255.255' udp_pkt.eth_daddr = 'ff:ff:ff:ff:ff:ff' - udp_pkt.udp_dport = $port + udp_pkt.udp_dport = datastore['PORT'] udp_pkt.udp_sport = rand(0xffff - 1024) + 1024 udp_pkt.ip_ttl = 64 begin - udp_pkt.ip_saddr = `ip a s #{$iface} | grep "inet "`.scan(%r{inet (.*)/}).flatten[0] # Get interface's ip address - udp_pkt.eth_saddr = `ip a s #{$iface} | grep ether`.scan(%r{link/ether (.*) brd}).flatten[0] # Get interface's mac address - rescue StandardError - # Output is displayed via calling terminal command. - raise ArgumentError, "Device \"#{$iface}\" does not exist." + udp_pkt.ip_saddr = datastore['LHOST'] # Get user-input ip address + udp_pkt.eth_saddr = get_mac(datastore['INTERFACE']) # Get interface's mac address + rescue StandardError => e + raise e end udp_pkt end @@ -163,9 +154,9 @@ class MetasploitModule < Msf::Auxiliary # Broadcasting Who-is and returns a capture with the responses. def broadcast_who_is(udp_pkt) - capture = PacketFu::Capture.new(iface: $iface, start: true, filter: "udp and src port #{$port}") - $packet_count.times { udp_pkt.to_w($iface) } - sleep($timeout) + capture = PacketFu::Capture.new(iface: datastore['INTERFACE'], start: true, filter: "udp and src port #{datastore['PORT']}") + datastore['COUNT'].times { udp_pkt.to_w(datastore['INTERFACE']) } + sleep(datastore['TIMEOUT']) capture.save capture end @@ -176,7 +167,7 @@ class MetasploitModule < Msf::Auxiliary instance_numbers = [] capture.array.each do |packet| mac = "#{bin_to_hex(packet[6])}:#{bin_to_hex(packet[7])}:#{bin_to_hex(packet[8])}:#{bin_to_hex(packet[9])}:#{bin_to_hex(packet[10])}:#{bin_to_hex(packet[11])}" - ip = to_ip_address(packet[26..29]) + ip = Rex::Socket.addr_ntoa(packet[26..29]) next unless packet[42] == BACNETIP_CONSTANT # If packet is not a bacnet/ip data = packet[46..] @@ -211,9 +202,11 @@ class MetasploitModule < Msf::Auxiliary 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] - ) + 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) @@ -293,15 +286,15 @@ class MetasploitModule < Msf::Auxiliary udp_pkt.eth_daddr = mac udp_pkt.ip_daddr = ip - capture = PacketFu::Capture.new(iface: $iface, start: true, + capture = PacketFu::Capture.new(iface: datastore['INTERFACE'], start: true, filter: "ip src #{ip} and ip dst #{udp_pkt.ip_saddr}") print_status("Querying device number #{instance_number} in ip #{ip}") messages.each do |message| udp_pkt.payload = message udp_pkt.recalc - $packet_count.times { udp_pkt.to_w($iface) } + datastore['COUNT'].times { udp_pkt.to_w(datastore['INTERFACE']) } end - sleep($timeout) + sleep(datastore['TIMEOUT']) capture.save capture end @@ -312,15 +305,16 @@ class MetasploitModule < Msf::Auxiliary ip_group.each do |asset| sadr = '' if asset['sadr'] - sadr = "sadr: #{asset['sadr']} - " + sadr = "sadr: #{asset['sadr']}\n" end - print_good "for asset number #{asset['instance-number']}: - model name: #{asset['model-name']} - firmware revision: #{asset['firmware-revision']} - application software version: #{asset['application-software-version']} - description: #{asset['description']} - #{sadr}" + 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 @@ -329,41 +323,41 @@ class MetasploitModule < Msf::Auxiliary def parse_data_to_xml(raw_data) data = '' raw_data.each do |ip, devices| - chunk = " - #{ip} " + chunk = <<~IP.chomp + + #{ip} + IP devices.each do |device| sadr = '' if device['sadr'] sadr = " #{device['sadr']} " end - chunk = "#{chunk} - - #{device['instance-number']} - #{device['model-name']} - #{device['application-software-version']} - #{device['firmware-revision']} - #{device['description']} #{sadr} - " + chunk = <<~XML.chomp + #{chunk} + + #{device['instance-number']} + #{device['model-name']} + #{device['application-software-version']} + #{device['firmware-revision']} + #{device['description']} #{sadr} + + XML end - chunk += ' - - ' + chunk += <<~IP + + + IP data += chunk end data end def run - # Get user input - $iface = datastore['INTERFACE'].freeze - $timeout = datastore['TIMEOUT'].freeze - $packet_count = datastore['COUNT'].freeze - $port = datastore['PORT'].freeze - - raise Msf::OptionValidateError, ['TIMEOUT'] if $timeout.negative? - raise Msf::OptionValidateError, ['COUNT'] if $packet_count < 1 - raise Msf::OptionValidateError, ['INTERFACE'] if $iface.empty? + # 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 # Create Base packet @@ -373,12 +367,16 @@ class MetasploitModule < Msf::Auxiliary udp_pkt.payload = DISCOVERY_MESSAGE_L3 udp_pkt.recalc - # Prevent ICMP retransmission - socket = UDPSocket.new - socket.bind(udp_pkt.ip_saddr, udp_pkt.udp_sport) + begin + # Prevent ICMP retransmission + socket = UDPSocket.new + socket.bind(udp_pkt.ip_saddr, udp_pkt.udp_sport) + rescue StandardError + raise StandardError, "Could not open a socket. Is '#{datastore['LHOST']}' a correct local IP?" + end # Broadcast who-is and create request-property messages for detected devices. - print_status "Broadcasting Who-is via #{$iface}" + print_status "Broadcasting Who-is via #{datastore['INTERFACE']}" capture = broadcast_who_is(udp_pkt) devices_data = analyze_i_am_devices(capture) messages = create_messages_for_devices(devices_data) @@ -393,8 +391,6 @@ class MetasploitModule < Msf::Auxiliary print_status('No devices found. Exiting.') return end - rescue ArgumentError - return rescue StandardError => e print_bad(e.message) return From 665bde7f602c5bac15af766462430d13be867f66 Mon Sep 17 00:00:00 2001 From: PazFi Date: Mon, 25 Jul 2022 08:17:39 +0300 Subject: [PATCH 3/8] Enforcing regex input validation on local IP. --- modules/auxiliary/scanner/scada/bacnet_l3.rb | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/modules/auxiliary/scanner/scada/bacnet_l3.rb b/modules/auxiliary/scanner/scada/bacnet_l3.rb index 6ee8d23c88..c1ba47d5a3 100644 --- a/modules/auxiliary/scanner/scada/bacnet_l3.rb +++ b/modules/auxiliary/scanner/scada/bacnet_l3.rb @@ -69,7 +69,7 @@ class MetasploitModule < Msf::Auxiliary OptInt.new('COUNT', Array[true, 'The number of times to send each packet', DEFAULT_SEND_COUNT]), OptPort.new('PORT', Array[true, 'BACnet/IP UDP port to scan (usually between 47808-47817)', DEFAULT_BACNET_PORT]), OptString.new('INTERFACE', Array[true, 'The interface to scan from', 'eth1']), - OptAddressLocal.new('LHOST', [true, 'The local listener hostname']) + OptAddressLocal.new('LHOST', [true, 'The local IP of selected interface'], regex:/^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)(\.(?!$)|$)){4}$/) ], self.class ) deregister_options('RHOSTS', 'FILTER', 'PCAPFILE') @@ -202,7 +202,7 @@ class MetasploitModule < Msf::Auxiliary def create_nested_messages(instance_number, items) nested_messages = [] GET_PROPERTY_MESSAGES_L3_NESTED.each do |msg_base| - msg = msg_base + msg = msg_base .sub('{object_identifier}', instance_number) .sub('{dest_net_id}', items[1]) .sub('{dadr_len}', items[2]) @@ -314,7 +314,7 @@ class MetasploitModule < Msf::Auxiliary \tapplication software version: #{asset['application-software-version']} \tdescription: #{asset['description']} \t#{sadr} - OUTPUT + OUTPUT end end end @@ -368,12 +368,12 @@ class MetasploitModule < Msf::Auxiliary udp_pkt.recalc begin - # Prevent ICMP retransmission - socket = UDPSocket.new - socket.bind(udp_pkt.ip_saddr, udp_pkt.udp_sport) - rescue StandardError - raise StandardError, "Could not open a socket. Is '#{datastore['LHOST']}' a correct local IP?" - end + # Prevent ICMP retransmission + socket = UDPSocket.new + socket.bind(udp_pkt.ip_saddr, udp_pkt.udp_sport) + rescue + raise StandardError.new "Could not open a socket. Is '#{datastore['LHOST']}' a correct local IP?" + end # Broadcast who-is and create request-property messages for detected devices. print_status "Broadcasting Who-is via #{datastore['INTERFACE']}" From 362318c95bed4080c10a7397bc9a1044a4b10a5d Mon Sep 17 00:00:00 2001 From: PazFi Date: Sun, 31 Jul 2022 08:44:40 +0300 Subject: [PATCH 4/8] Fixing rubocop issues. --- modules/auxiliary/scanner/scada/bacnet_l3.rb | 26 ++++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/modules/auxiliary/scanner/scada/bacnet_l3.rb b/modules/auxiliary/scanner/scada/bacnet_l3.rb index c1ba47d5a3..fc53c98ecb 100644 --- a/modules/auxiliary/scanner/scada/bacnet_l3.rb +++ b/modules/auxiliary/scanner/scada/bacnet_l3.rb @@ -69,18 +69,18 @@ class MetasploitModule < Msf::Auxiliary OptInt.new('COUNT', Array[true, 'The number of times to send each packet', DEFAULT_SEND_COUNT]), OptPort.new('PORT', Array[true, 'BACnet/IP UDP port to scan (usually between 47808-47817)', DEFAULT_BACNET_PORT]), OptString.new('INTERFACE', Array[true, 'The interface to scan from', 'eth1']), - OptAddressLocal.new('LHOST', [true, 'The local IP of selected interface'], regex:/^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)(\.(?!$)|$)){4}$/) + OptAddressLocal.new('LHOST', [true, 'The local IP of selected interface'], regex: /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)(\.(?!$)|$)){4}$/) ], self.class ) deregister_options('RHOSTS', 'FILTER', 'PCAPFILE') end - def hex_to_bin(s) - s.scan(/../).map { |x| x.hex.chr }.join + def hex_to_bin(str) + str.scan(/../).map { |x| x.hex.chr }.join end - def bin_to_hex(s) - s.each_byte.map { |b| b.to_s(16).rjust(2, '0') }.join + def bin_to_hex(str) + str.each_byte.map { |b| b.to_s(16).rjust(2, '0') }.join end # Create UDP packet with the l2 and l3 details. @@ -202,7 +202,7 @@ class MetasploitModule < Msf::Auxiliary def create_nested_messages(instance_number, items) nested_messages = [] GET_PROPERTY_MESSAGES_L3_NESTED.each do |msg_base| - msg = msg_base + msg = msg_base .sub('{object_identifier}', instance_number) .sub('{dest_net_id}', items[1]) .sub('{dadr_len}', items[2]) @@ -314,7 +314,7 @@ class MetasploitModule < Msf::Auxiliary \tapplication software version: #{asset['application-software-version']} \tdescription: #{asset['description']} \t#{sadr} - OUTPUT + OUTPUT end end end @@ -368,12 +368,12 @@ class MetasploitModule < Msf::Auxiliary udp_pkt.recalc begin - # Prevent ICMP retransmission - socket = UDPSocket.new - socket.bind(udp_pkt.ip_saddr, udp_pkt.udp_sport) - rescue - raise StandardError.new "Could not open a socket. Is '#{datastore['LHOST']}' a correct local IP?" - end + # Prevent ICMP retransmission + socket = UDPSocket.new + socket.bind(udp_pkt.ip_saddr, udp_pkt.udp_sport) + rescue StandardError + raise StandardError, "Could not open a socket. Is '#{datastore['LHOST']}' a correct local IP?" + end # Broadcast who-is and create request-property messages for detected devices. print_status "Broadcasting Who-is via #{datastore['INTERFACE']}" From baa686f5e0a096bbf5ec805b916384eee1c0c21b Mon Sep 17 00:00:00 2001 From: PazFi Date: Sun, 31 Jul 2022 16:50:52 +0300 Subject: [PATCH 5/8] Using Rex::Socket::Udp instead of packetfu. Adding report_note in case user does not have privileges to write to file. Added sleeping time between outputs. Removed LHOST from options, since it is not needed. Replaced print_bad with fail_with. --- modules/auxiliary/scanner/scada/bacnet_l3.rb | 220 +++++++++---------- 1 file changed, 109 insertions(+), 111 deletions(-) diff --git a/modules/auxiliary/scanner/scada/bacnet_l3.rb b/modules/auxiliary/scanner/scada/bacnet_l3.rb index fc53c98ecb..ccf3512ee5 100644 --- a/modules/auxiliary/scanner/scada/bacnet_l3.rb +++ b/modules/auxiliary/scanner/scada/bacnet_l3.rb @@ -1,12 +1,12 @@ -require 'packetfu' - class MetasploitModule < Msf::Auxiliary include Msf::Auxiliary::Report include Msf::Exploit::Capture + include Rex::Socket::Udp FILE_NAME = 'bacnet-discovery'.freeze - DEFAULT_SERVER_TIMEOUT = 3 + DEFAULT_SERVER_TIMEOUT = 1 DEFAULT_SEND_COUNT = 1 + DEFAULT_SLEEP = 1 BACNET_ASHARE_STANDARD = "\x01".freeze BACNETIP_CONSTANT = "\x81".freeze @@ -65,14 +65,13 @@ class MetasploitModule < Msf::Auxiliary register_options( [ - OptInt.new('TIMEOUT', Array[true, 'The socket connect timeout in seconds', DEFAULT_SERVER_TIMEOUT]), - OptInt.new('COUNT', Array[true, 'The number of times to send each packet', DEFAULT_SEND_COUNT]), - OptPort.new('PORT', Array[true, 'BACnet/IP UDP port to scan (usually between 47808-47817)', DEFAULT_BACNET_PORT]), - OptString.new('INTERFACE', Array[true, 'The interface to scan from', 'eth1']), - OptAddressLocal.new('LHOST', [true, 'The local IP of selected interface'], regex: /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)(\.(?!$)|$)){4}$/) + 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') + deregister_options('RHOSTS', 'FILTER', 'PCAPFILE', 'LHOST') end def hex_to_bin(str) @@ -83,50 +82,29 @@ class MetasploitModule < Msf::Auxiliary str.each_byte.map { |b| b.to_s(16).rjust(2, '0') }.join end - # Create UDP packet with the l2 and l3 details. - def create_packet_base - udp_pkt = PacketFu::UDPPacket.new - udp_pkt.ip_daddr = '255.255.255.255' - udp_pkt.eth_daddr = 'ff:ff:ff:ff:ff:ff' - udp_pkt.udp_dport = datastore['PORT'] - udp_pkt.udp_sport = rand(0xffff - 1024) + 1024 - udp_pkt.ip_ttl = 64 - - begin - udp_pkt.ip_saddr = datastore['LHOST'] # Get user-input ip address - udp_pkt.eth_saddr = get_mac(datastore['INTERFACE']) # Get interface's mac address - rescue StandardError => e - raise e - end - udp_pkt - end - # Check if device is nested and extract relevant data def parse_npdu(data) is_nested = false 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 - 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*')] - idx += 3 + sadr_len.unpack1('C*') - is_nested = true - end - idx += 1 if dst_specifier # increase index if both specifiers exist - # if no network address specified - set as broadcast network address - src_net_id ||= '\x00' - rescue StandardError => e - raise e + 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*')] + idx += 3 + sadr_len.unpack1('C*') + is_nested = true + end + idx += 1 if dst_specifier # increase index if both specifiers exist + # if no network address specified - set as broadcast network address + src_net_id ||= '\x00' end [is_nested, src_net_id, sadr_len, sadr] end @@ -153,32 +131,47 @@ class MetasploitModule < Msf::Auxiliary end # Broadcasting Who-is and returns a capture with the responses. - def broadcast_who_is(udp_pkt) - capture = PacketFu::Capture.new(iface: datastore['INTERFACE'], start: true, filter: "udp and src port #{datastore['PORT']}") - datastore['COUNT'].times { udp_pkt.to_w(datastore['INTERFACE']) } - sleep(datastore['TIMEOUT']) - capture.save - capture + def broadcast_who_is + begin + broadcast_addr = get_ipv4_broadcast(datastore['INTERFACE']) + rescue StandardError + raise StandardError, "Interface #{datastore['INTERFACE']} is down" + end + cap = [] + lsocket = Rex::Socket::Udp.create({ + 'LocalHost' => broadcast_addr, + 'LocalPort' => datastore['PORT'], + 'Context' => { 'Msf' => framework, 'MsfExploit' => self } + }) + datastore['COUNT'].times { lsocket.sendto(DISCOVERY_MESSAGE_L3, '255.255.255.255', datastore['PORT'], 0) } + loop do + data, host, port = lsocket.recvfrom(65535, datastore['TIMEOUT']) + break if host.nil? + + cap << [data, host, port] + 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.array.each do |packet| - mac = "#{bin_to_hex(packet[6])}:#{bin_to_hex(packet[7])}:#{bin_to_hex(packet[8])}:#{bin_to_hex(packet[9])}:#{bin_to_hex(packet[10])}:#{bin_to_hex(packet[11])}" - ip = Rex::Socket.addr_ntoa(packet[26..29]) - next unless packet[42] == BACNETIP_CONSTANT # If packet is not a bacnet/ip + capture.each do |cap| + data = cap[0] + ip = cap[1] + next unless data[0] == BACNETIP_CONSTANT # If communication is not a bacnet/ip - data = packet[46..] + data = data[4..] index = data.index(BACNET_UNCOFIRMED_REQ_I_AM_OBJ_DEVICE_PREFIX) - next unless index # If packet has no I-am object + 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, mac, ip]] = data unless devices_data[[instance_number, mac, ip]] + devices_data[[instance_number, ip]] = data unless devices_data[[instance_number, ip]] end devices_data end @@ -227,8 +220,8 @@ class MetasploitModule < Msf::Auxiliary # Loop on recorded packets and extract data from read-property messages def extract_data(capture) asset_data = {} - capture.array.each do |packet| - data = packet[46..] + 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 @@ -246,27 +239,22 @@ class MetasploitModule < Msf::Auxiliary else raise "undefined attribute for property number #{bin_to_hex(type)}." end - begin - 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 - rescue StandardError => e - raise e - 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, udp_pkt) + def get_properties_from_devices(messages) devices_by_ip = {} messages.each do |key, message_block| instance_number = key[0].to_i(16) - mac = key[1] - ip = key[2] + ip = key[1] - capture = send_read_properties(message_block, udp_pkt, mac, ip, instance_number) + capture = send_read_properties(message_block, ip, instance_number) begin device = extract_data(capture) raise StandardError if device.empty? @@ -282,21 +270,25 @@ class MetasploitModule < Msf::Auxiliary end # Sending read-property packets and returns a pcap with the responses. - def send_read_properties(messages, udp_pkt, mac, ip, instance_number) - udp_pkt.eth_daddr = mac - udp_pkt.ip_daddr = ip - - capture = PacketFu::Capture.new(iface: datastore['INTERFACE'], start: true, - filter: "ip src #{ip} and ip dst #{udp_pkt.ip_saddr}") + 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| - udp_pkt.payload = message - udp_pkt.recalc - datastore['COUNT'].times { udp_pkt.to_w(datastore['INTERFACE']) } + 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 - sleep(datastore['TIMEOUT']) - capture.save - capture + ssocket.close + cap end # Iterates over all the devices and prints the details to the user. @@ -353,6 +345,17 @@ class MetasploitModule < Msf::Auxiliary 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? @@ -360,49 +363,44 @@ class MetasploitModule < Msf::Auxiliary raise Msf::OptionValidateError, ['INTERFACE'] if datastore['INTERFACE'].empty? begin - # Create Base packet - udp_pkt = create_packet_base - - # Add Who-is payload - udp_pkt.payload = DISCOVERY_MESSAGE_L3 - udp_pkt.recalc - - begin - # Prevent ICMP retransmission - socket = UDPSocket.new - socket.bind(udp_pkt.ip_saddr, udp_pkt.udp_sport) - rescue StandardError - raise StandardError, "Could not open a socket. Is '#{datastore['LHOST']}' a correct local IP?" - end - # Broadcast who-is and create request-property messages for detected devices. print_status "Broadcasting Who-is via #{datastore['INTERFACE']}" - capture = broadcast_who_is(udp_pkt) + 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" - devices_by_ip = get_properties_from_devices(messages, udp_pkt) + 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 - print_status('No devices found. Exiting.') - return + fail_with(Failure::NotFound, 'No devices found. Exiting.') end rescue StandardError => e - print_bad(e.message) + fail_with(Failure::Unknown, e.message) return end - socket.close begin data = parse_data_to_xml(devices_by_ip) - store_local('bacnet.devices.info'.dup, 'text/xml', data, FILE_NAME) - print_good("Successfully saved data to local store named #{FILE_NAME}.xml") + 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 - print_bad(e.message) + fail_with(Failure::Unknown, e.message) end end end From 1f7b3319a93592ca5a9c38350e2db3e41de4a76a Mon Sep 17 00:00:00 2001 From: PazFi Date: Mon, 1 Aug 2022 13:43:26 +0300 Subject: [PATCH 6/8] Changing readme file accordingly. --- documentation/modules/auxiliary/scanner/scada/bacnet_l3.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/documentation/modules/auxiliary/scanner/scada/bacnet_l3.md b/documentation/modules/auxiliary/scanner/scada/bacnet_l3.md index c634ae4beb..6a6a3659d3 100644 --- a/documentation/modules/auxiliary/scanner/scada/bacnet_l3.md +++ b/documentation/modules/auxiliary/scanner/scada/bacnet_l3.md @@ -19,7 +19,6 @@ Each bacnet device responds with this data: 1. Start msfconsole. 2. Do: `use auxiliary/scanner/scada/bacnet_l3`. 3. Do: `set INTERFACE`. - 4. Do: `set LHOST` and choose the chosen interface's IP. 5. Do: `run`. 6. Devices running the BACnet protocol should respond with data. @@ -40,9 +39,7 @@ 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 3 yes The socket connect timeout in seconds -LHOST yes The local IP of selected interface - +TIMEOUT 1 yes The socket connect timeout in seconds ``` ## Scenarios From f2a70c43cba49f7107aec867c78239950852b217 Mon Sep 17 00:00:00 2001 From: PazFi Date: Mon, 1 Aug 2022 13:55:38 +0300 Subject: [PATCH 7/8] Removing unnecessary lines of code. --- modules/auxiliary/scanner/scada/bacnet_l3.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/modules/auxiliary/scanner/scada/bacnet_l3.rb b/modules/auxiliary/scanner/scada/bacnet_l3.rb index ccf3512ee5..7907e6c188 100644 --- a/modules/auxiliary/scanner/scada/bacnet_l3.rb +++ b/modules/auxiliary/scanner/scada/bacnet_l3.rb @@ -99,10 +99,9 @@ class MetasploitModule < Msf::Auxiliary src_net_id = data[idx..idx + 1] sadr_len = data[idx + 2] sadr = data[idx + 3..idx + 2 + sadr_len.unpack1('C*')] - idx += 3 + sadr_len.unpack1('C*') is_nested = true end - idx += 1 if dst_specifier # increase index if both specifiers exist + # if no network address specified - set as broadcast network address src_net_id ||= '\x00' end From a727ebbf5ec9bc5f34374b07b3901ac536d3c31e Mon Sep 17 00:00:00 2001 From: PazFi Date: Mon, 1 Aug 2022 15:11:57 +0300 Subject: [PATCH 8/8] Adding detection of I-AM responses sent in unicast form. --- modules/auxiliary/scanner/scada/bacnet_l3.rb | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/modules/auxiliary/scanner/scada/bacnet_l3.rb b/modules/auxiliary/scanner/scada/bacnet_l3.rb index 7907e6c188..5d145f81d0 100644 --- a/modules/auxiliary/scanner/scada/bacnet_l3.rb +++ b/modules/auxiliary/scanner/scada/bacnet_l3.rb @@ -133,21 +133,33 @@ class MetasploitModule < Msf::Auxiliary 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']) - break if host.nil? + data2, host2, port2 = ssocket.recvfrom(65535, datastore['TIMEOUT']) + break if (host.nil? && host2.nil?) - cap << [data, host, port] + cap << [data, host, port] if host + cap << [data2, host2, port2] if host2 end lsocket.close cap