Lucene search
K

BACnet Scanner

🗓️ 29 Aug 2022 18:02:21Reported by Paz <Paz @ SCADAfence>Type 
metasploit
 metasploit
🔗 www.rapid7.com👁 172 Views

BACnet Scanner for Discovering Devices and Polling Propertie

Code
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