Module: Msf::Exploit::Remote::LDAP::Queries

Defined in:
lib/msf/core/exploit/remote/ldap/queries.rb

Constant Summary collapse

FLAG_DISALLOW_DELETE =
0x80000000
FLAG_CONFIG_ALLOW_RENAME =
0x40000000
FLAG_CONFIG_ALLOW_MOVE =
0x20000000
FLAG_CONFIG_ALLOW_LIMITED_MOVE =
0x10000000
FLAG_DOMAIN_DISALLOW_RENAME =
0x8000000
FLAG_DOMAIN_DISALLOW_MOVE =
0x4000000
FLAG_DISALLOW_MOVE_ON_DELETE =
0x2000000
FLAG_ATTR_IS_RDN =
0x20
FLAG_SCHEMA_BASE_OBJECT =
0x10
FLAG_ATTR_IS_OPERATIONAL =
0x8
FLAG_ATTR_IS_CONSTRUCTED =
0x4
FLAG_ATTR_REQ_PARTIAL_SET_MEMBER =
0x2
FLAG_NOT_REPLICATED =
0x1

Instance Method Summary collapse

Instance Method Details

#convert_nt_timestamp_to_time_string(nt_timestamp) ⇒ Object



88
89
90
# File 'lib/msf/core/exploit/remote/ldap/queries.rb', line 88

def convert_nt_timestamp_to_time_string(nt_timestamp)
  Time.at((nt_timestamp.to_i - 116444736000000000) / 10000000).utc.to_s
end

#convert_pwd_age_to_time_string(timestamp) ⇒ Object



92
93
94
95
96
97
98
99
# File 'lib/msf/core/exploit/remote/ldap/queries.rb', line 92

def convert_pwd_age_to_time_string(timestamp)
  seconds = (timestamp.to_i / -1) / 10000000 # Convert always negative number to positive then convert to seconds from tick count.
  days = seconds / 86400
  hours = (seconds % 86400) / 3600
  minutes = ((seconds % 86400) % 3600) / 60
  real_seconds = (((seconds % 86400) % 3600) % 60)
  return "#{days}:#{hours.to_s.rjust(2, '0')}:#{minutes.to_s.rjust(2, '0')}:#{real_seconds.to_s.rjust(2, '0')}"
end

#convert_system_flags_to_string(flags) ⇒ Object



132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
# File 'lib/msf/core/exploit/remote/ldap/queries.rb', line 132

def convert_system_flags_to_string(flags)
  flags_converted = flags.to_i
  flag_string = ''
  flag_string << 'FLAG_DISALLOW_DELETE | ' if flags_converted & FLAG_DISALLOW_DELETE > 0
  flag_string << 'FLAG_CONFIG_ALLOW_RENAME | ' if flags_converted & FLAG_CONFIG_ALLOW_RENAME > 0
  flag_string << 'FLAG_CONFIG_ALLOW_MOVE | ' if flags_converted & FLAG_CONFIG_ALLOW_MOVE > 0
  flag_string << 'FLAG_CONFIG_ALLOW_LIMITED_MOVE | ' if flags_converted & FLAG_CONFIG_ALLOW_LIMITED_MOVE > 0
  flag_string << 'FLAG_DOMAIN_DISALLOW_RENAME | ' if flags_converted & FLAG_DOMAIN_DISALLOW_RENAME > 0
  flag_string << 'FLAG_DOMAIN_DISALLOW_MOVE | ' if flags_converted & FLAG_DOMAIN_DISALLOW_MOVE > 0
  flag_string << 'FLAG_DISALLOW_MOVE_ON_DELETE | ' if flags_converted & FLAG_DISALLOW_MOVE_ON_DELETE > 0
  flag_string << 'FLAG_ATTR_IS_RDN | ' if flags_converted & FLAG_ATTR_IS_RDN > 0
  flag_string << 'FLAG_SCHEMA_BASE_OBJECT | ' if flags_converted & FLAG_SCHEMA_BASE_OBJECT > 0
  flag_string << 'FLAG_ATTR_IS_OPERATIONAL | ' if flags_converted & FLAG_ATTR_IS_OPERATIONAL > 0
  flag_string << 'FLAG_ATTR_IS_CONSTRUCTED | ' if flags_converted & FLAG_ATTR_IS_CONSTRUCTED > 0
  flag_string << 'FLAG_ATTR_REQ_PARTIAL_SET_MEMBER | ' if flags_converted & FLAG_ATTR_REQ_PARTIAL_SET_MEMBER > 0
  flag_string << 'FLAG_NOT_REPLICATED | ' if flags_converted & FLAG_NOT_REPLICATED > 0
  flag_string.strip.gsub!(/ \|$/, '')
end

#find_schema_dn(ldap, base) ⇒ Object



168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
# File 'lib/msf/core/exploit/remote/ldap/queries.rb', line 168

def find_schema_dn(ldap, base)
  results = ldap.search(attributes: ['objectCategory'], base: base, filter: '(objectClass=*)', scope: Net::LDAP::SearchScope_BaseObject)
  validate_query_result!(ldap.get_operation_result.table)
  if results.blank?
    fail_with(Msf::Module::Failure::UnexpectedReply, "LDAP server didn't respond to our request to find the root DN!")
  end

  # Double check that the entry has an instancetype attribute.
  unless results[0].to_h.key?(:objectcategory)
    fail_with(Failure::UnexpectedReply, "LDAP server didn't respond to the root DN request with the objectcategory attribute field!")
  end

  object_category_raw = results[0][:objectcategory][0]
  schema_dn = object_category_raw.gsub(/CN=[A-Za-z0-9-]+,/, '')
  print_good("#{peer} Discovered schema DN: #{schema_dn}")

  schema_dn
end

#find_schema_naming_context(ldap) ⇒ Object



187
188
189
190
191
192
193
# File 'lib/msf/core/exploit/remote/ldap/queries.rb', line 187

def find_schema_naming_context(ldap)
  result = ldap.search(scope: 0, base: '', attributes: [:schemanamingcontext])
  if result.first && result.first[:schemanamingcontext]
    return result.first[:schemanamingcontext].first
  end
  ''
end

#generate_rex_tables(entry, format) ⇒ Object



53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
# File 'lib/msf/core/exploit/remote/ldap/queries.rb', line 53

def generate_rex_tables(entry, format)
  tbl = Rex::Text::Table.new(
    'Header' => entry[:dn].first,
    'Indent' => 1,
    'Columns' => %w[Name Attributes],
    'ColProps' => { 'Name' => { 'Strip' => false } },
    'SortIndex' => -1,
    'WordWrap' => false
  )

  entry.keys.sort.each do |attr|
    if format == 'table'
      next if attr == :dn # Skip over DN entries for tables since DN information is shown in header.

      tbl << [attr, entry[attr].first]
      if entry[attr].length > 1
        entry[attr][1...].each do |additional_attr|
          tbl << [ '  \\_', additional_attr]
        end
      end
    else
      tbl << [attr, entry[attr].join(' || ')] # DN information is not shown in CSV output as a header so keep DN entries in.
    end
  end

  case format
  when 'table'
    print_line(tbl.to_s)
  when 'csv'
    print_line(tbl.to_csv)
  else
    fail_with(Msf::Module::Failure::BadConfig, "Invalid format #{format} passed to generate_rex_tables!")
  end
end

#normalize_entry(entry, attribute_properties) ⇒ Object



224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
# File 'lib/msf/core/exploit/remote/ldap/queries.rb', line 224

def normalize_entry(entry, attribute_properties)
  # Convert to a hash so we get the raw data we need from within the Net::LDAP::Entry object
  entry = entry.to_h
  normalized_entry = { dn: entry[:dn] }
  entry.each_key do |attribute_name|
    next if attribute_name == :dn # Skip the DN case as there will be no attributes_properties entry for it.

    normalized_attribute = entry[attribute_name].map { |v| Rex::Text.to_hex_ascii(v) }
    attribute_property = attribute_properties[attribute_name]
    unless attribute_property
      normalized_entry[attribute_name] = normalized_attribute
      next
    end

    case attribute_property[:omsyntax]
    when 1 # Boolean
      normalized_attribute[0] = entry[attribute_name][0] != 0
    when 2 # Integer
      if attribute_name == :systemflags
        flags = entry[attribute_name][0]
        converted_flags_string = convert_system_flags_to_string(flags)
        normalized_attribute[0] = converted_flags_string
      end
    when 4 # OctetString or SID String
      if attribute_property[:attributesyntax] == '2.5.5.17' # SID String
        # Advice taken from https://ldapwiki.com/wiki/ObjectSID
        object_sid_raw = entry[attribute_name][0]
        begin
          sid_data = Rex::Proto::MsDtyp::MsDtypSid.read(object_sid_raw)
          sid_string = sid_data.to_s
        rescue IOError => e
          fail_with(Msf::Module::Failure::UnexpectedReply, "Failed to read SID. Error was #{e.message}")
        end
        normalized_attribute[0] = sid_string
      elsif attribute_property[:attributesyntax] == '2.5.5.10' # OctetString
        if attribute_name.to_s.match(/guid$/i)
          # Get the entry[attribute_name] object will be an array containing a single string entry,
          # so reach in and extract that string, which will contain binary data.
          bin_guid = entry[attribute_name][0]
          if bin_guid.length == 16 # Length of binary data in bytes since this is what .length uses. In bits its 128 bits.
            begin
              decoded_guid = Rex::Proto::MsDtyp::MsDtypGuid.read(bin_guid)
              decoded_guid_string = decoded_guid.get
            rescue IOError => e
              fail_with(Msf::Module::Failure::UnexpectedReply, "Failed to read GUID. Error was #{e.message}")
            end
            normalized_attribute[0] = decoded_guid_string
          end
        elsif attribute_name == :cacertificate || attribute_name == :usercertificate
          normalized_attribute = entry[attribute_name].map do |raw_key_data|
            _certificate_file, read_data = read_der_certificate_file(raw_key_data)

            read_data
          end
        end
      end
    when 6 # String (Object-Identifier)
    when 10 # Enumeration
    when 18 # NumbericString
    when 19 # PrintableString
    when 20 # Case-Ignore String
    when 22 # IA5String
    when 23 # GeneralizedTime String (UTC-Time)
    when 24 # GeneralizedTime String (GeneralizedTime)
    when 27 # Case Sensitive String
    when 64 # DirectoryString String(Unicode)
    when 65 # LargeInteger
      if attribute_name == :creationtime || attribute_name.to_s.match(/lastlog(?:on|off)/)
        timestamp = entry[attribute_name][0]
        time_string = convert_nt_timestamp_to_time_string(timestamp)
      elsif attribute_name.to_s.match(/lockoutduration$/i) || attribute_name.to_s.match(/pwdage$/)
        timestamp = entry[attribute_name][0]
        time_string = convert_pwd_age_to_time_string(timestamp)
      end
      normalized_attribute[0] = time_string
    when 66 # String (Nt Security Descriptor)
    when 127 # Object
    else
      print_error("Unknown oMSyntax entry: #{attribute_property[:omsyntax]}")
    end
    normalized_entry[attribute_name] = normalized_attribute
  end

  normalized_entry
end

#output_data_csv(entry) ⇒ Object



164
165
166
# File 'lib/msf/core/exploit/remote/ldap/queries.rb', line 164

def output_data_csv(entry)
  generate_rex_tables(entry, 'csv')
end

#output_data_table(entry) ⇒ Object



160
161
162
# File 'lib/msf/core/exploit/remote/ldap/queries.rb', line 160

def output_data_table(entry)
  generate_rex_tables(entry, 'table')
end

#output_json_data(entry) ⇒ Object



151
152
153
154
155
156
157
158
# File 'lib/msf/core/exploit/remote/ldap/queries.rb', line 151

def output_json_data(entry)
  data = {}
  entry.each_key do |attr|
    data[attr] = entry[attr].length == 1 ? entry[attr][0] : entry[attr]
  end
  print_status(entry[:dn][0].split(',').join(' '))
  print_line(JSON.pretty_generate(data))
end

#perform_ldap_query(ldap, filter, attributes, base, schema_dn, scope: nil) ⇒ Object



23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# File 'lib/msf/core/exploit/remote/ldap/queries.rb', line 23

def perform_ldap_query(ldap, filter, attributes, base, schema_dn, scope: nil)
  results = []
  perform_ldap_query_streaming(ldap, filter, attributes, base, schema_dn, scope: scope) do |result|
    results << result
  end

  query_result_table = ldap.get_operation_result.table
  validate_query_result!(query_result_table, filter)

  if results.nil? || results.empty?
    print_error("No results found for #{filter}.")
    return nil
  end

  results
end

#perform_ldap_query_streaming(ldap, filter, attributes, base, schema_dn, scope: nil) ⇒ Object



40
41
42
43
44
45
46
47
48
49
50
51
# File 'lib/msf/core/exploit/remote/ldap/queries.rb', line 40

def perform_ldap_query_streaming(ldap, filter, attributes, base, schema_dn, scope: nil)
  attribute_properties = query_attributes_data(ldap, attributes.map(&:to_sym), schema_dn)

  scope ||= Net::LDAP::SearchScope_WholeSubtree
  result_count = 0
  ldap.search(base: base, filter: filter, attributes: attributes, scope: scope, return_result: false) do |result|
    result_count += 1
    yield result, attribute_properties if block_given?
  end

  result_count
end

#query_attributes_data(ldap, attributes, schema_dn) ⇒ Object



195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
# File 'lib/msf/core/exploit/remote/ldap/queries.rb', line 195

def query_attributes_data(ldap, attributes, schema_dn)
  attribute_properties = {}

  filter = '(|'
  attributes.each do |key|
    next if attribute_properties.key?(key) # Skip if we already have this one
    next if key == :dn # Skip DN as it will never have a schema entry

    filter += "(LDAPDisplayName=#{key})"
  end
  filter += ')'
  return unless filter.include?('LDAPDisplayName=')

  attributes_data = ldap.search(base: schema_dn, filter: filter, attributes: %i[LDAPDisplayName isSingleValued oMSyntax attributeSyntax])
  query_result_table = ldap.get_operation_result.table
  validate_query_result!(query_result_table)

  attributes_data.each do |entry|
    ldap_display_name = entry[:ldapdisplayname][0].to_s.downcase.to_sym
    attribute_properties[ldap_display_name] = {
      issinglevalued: entry[:issinglevalued][0] == 'TRUE',
      omsyntax: entry[:omsyntax][0].to_i,
      attributesyntax: entry[:attributesyntax][0]
    }
  end

  attribute_properties
end

#read_der_certificate_file(cert) ⇒ Object

Read in a DER formatted certificate file and transform it into a OpenSSL::X509::Certificate object before then using that object to read the properties of the certificate and return this info as a string.



104
105
106
107
108
109
110
111
112
113
114
# File 'lib/msf/core/exploit/remote/ldap/queries.rb', line 104

def read_der_certificate_file(cert)
  openssl_certificate = OpenSSL::X509::Certificate.new(cert)
  version = openssl_certificate.version
  subject = openssl_certificate.subject
  issuer = openssl_certificate.issuer
  algorithm = openssl_certificate.signature_algorithm
  extensions = openssl_certificate.extensions.join(' | ')
  extensions.strip!
  extensions.gsub!(/ \|$/, '') # Strip whitespace and then strip trailing | from end of string.
  [openssl_certificate, "Version: 0x#{version}, Subject: #{subject}, Issuer: #{issuer}, Signature Algorithm: #{algorithm}, Extensions: #{extensions}"]
end

#run_queries_from_file(ldap, queries, base_dn, schema_dn, output_format) ⇒ Object



323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
# File 'lib/msf/core/exploit/remote/ldap/queries.rb', line 323

def run_queries_from_file(ldap, queries, base_dn, schema_dn, output_format)
  queries.each do |query|
    unless query['action'] && query['filter'] && query['attributes']
      fail_with(Msf::Module::Failure::BadConfig, "Each query in the query file must at least contain a 'action', 'filter' and 'attributes' attribute!")
    end
    attributes = query['attributes']
    if attributes.nil? || attributes.empty?
      print_warning('At least one attribute needs to be specified per query in the query file for entries to work!')
      break
    end
    filter = Net::LDAP::Filter.construct(query['filter'])
    print_status("Running #{query['action']}...")
    query_base = query['base_dn_prefix'] ? [query['base_dn_prefix'], base_dn].join(',') : base_dn

    result_count = perform_ldap_query_streaming(ldap, filter, attributes, query_base, schema_dn) do |result, attribute_properties|
      show_output(normalize_entry(result, attribute_properties), output_format)
    end

    print_warning("Query #{query['filter']} from #{query['action']} didn't return any results!") if result_count == 0
  end
end

#safe_load_queries(filename) ⇒ Object



10
11
12
13
14
15
16
17
18
19
20
21
# File 'lib/msf/core/exploit/remote/ldap/queries.rb', line 10

def safe_load_queries(filename)
  begin
    settings = YAML.safe_load(File.binread(filename))
  rescue StandardError => e
    elog("Couldn't parse #{filename}", error: e)
    return
  end

  return unless settings['queries'].is_a? Array

  settings['queries']
end

#show_output(entry, output_format) ⇒ Object



310
311
312
313
314
315
316
317
318
319
320
321
# File 'lib/msf/core/exploit/remote/ldap/queries.rb', line 310

def show_output(entry, output_format)
  case output_format
  when 'csv'
    output_data_csv(entry)
  when 'table'
    output_data_table(entry)
  when 'json'
    output_json_data(entry)
  else
    fail_with(Msf::Module::Failure::BadConfig, 'Supported OUTPUT_FORMAT values are csv, table and json')
  end
end