From c4d21eb465ea56a816003636c2f3fa2ebae840a3 Mon Sep 17 00:00:00 2001 From: Christophe De La Fuente Date: Fri, 20 Dec 2024 18:34:09 +0100 Subject: [PATCH] Add certs command & use pkinit if kerberos tickets are not available in cache --- lib/metasploit/framework/ldap/client.rb | 33 +++- .../framework/login_scanner/ldap.rb | 4 +- .../kerberos/service_authenticator/base.rb | 11 ++ lib/msf/core/exploit/remote/ms_icpr.rb | 7 +- lib/msf/core/exploit/remote/pkcs12/storage.rb | 86 ++++++++++ .../exploit/remote/pkcs12/stored_pkcs12.rb | 38 +++++ lib/msf/ui/console/command_dispatcher/db.rb | 2 + .../ui/console/command_dispatcher/db/certs.rb | 154 ++++++++++++++++++ modules/auxiliary/scanner/ldap/ldap_login.rb | 8 + modules/auxiliary/scanner/smb/smb_login.rb | 13 +- 10 files changed, 344 insertions(+), 12 deletions(-) create mode 100644 lib/msf/core/exploit/remote/pkcs12/storage.rb create mode 100644 lib/msf/core/exploit/remote/pkcs12/stored_pkcs12.rb create mode 100644 lib/msf/ui/console/command_dispatcher/db/certs.rb diff --git a/lib/metasploit/framework/ldap/client.rb b/lib/metasploit/framework/ldap/client.rb index 1c1c038c0d94..57d9a5696478 100644 --- a/lib/metasploit/framework/ldap/client.rb +++ b/lib/metasploit/framework/ldap/client.rb @@ -50,6 +50,7 @@ def ldap_auth_opts_kerberos(opts, ssl) auth_opts = {} raise Msf::ValidationError, 'The LDAP::Rhostname option is required when using Kerberos authentication.' if opts[:ldap_rhostname].blank? raise Msf::ValidationError, 'The DOMAIN option is required when using Kerberos authentication.' if opts[:domain].blank? + raise Msf::ValidationError, 'The DomainControllerRhost is required when using Kerberos authentication.' if opts[:domain_controller_rhost].blank? offered_etypes = Msf::Exploit::Remote::AuthOption.as_default_offered_etypes(opts[:ldap_krb_offered_enc_types]) raise Msf::ValidationError, 'At least one encryption type is required when using Kerberos authentication.' if offered_etypes.empty? @@ -112,17 +113,33 @@ def ldap_auth_opts_schannel(opts, ssl) auth_opts = {} pfx_path = opts[:ldap_cert_file] raise Msf::ValidationError, 'The SSL option must be enabled when using Schannel authentication.' unless ssl - raise Msf::ValidationError, 'The LDAP::CertFile option is required when using Schannel authentication.' if pfx_path.blank? raise Msf::ValidationError, 'Can not sign and seal when using Schannel authentication.' if opts.fetch(:sign_and_seal, false) - unless ::File.file?(pfx_path) && ::File.readable?(pfx_path) - raise Msf::ValidationError, 'Failed to load the PFX certificate file. The path was not a readable file.' - end + if pfx_path.present? + unless ::File.file?(pfx_path) && ::File.readable?(pfx_path) + raise Msf::ValidationError, 'Failed to load the PFX certificate file. The path was not a readable file.' + end + + begin + pkcs = OpenSSL::PKCS12.new(File.binread(pfx_path), '') + rescue StandardError => e + raise Msf::ValidationError, "Failed to load the PFX file (#{e})" + end + else + pkcs12_storage = Msf::Exploit::Remote::Pkcs12::Storage.new( + framework: opts[:framework], + framework_module: opts[:framework_module] + ) + pkcs12_results = pkcs12_storage.pkcs12( + username: opts[:username], + realm: opts[:domain] + ) + if pkcs12_results.empty? + raise Msf::ValidationError, "Pkcs12 for #{opts[:username]}@#{opts[:domain]} not found in the database" + end - begin - pkcs = OpenSSL::PKCS12.new(File.binread(pfx_path), '') - rescue StandardError => e - raise Msf::ValidationError, "Failed to load the PFX file (#{e})" + elog("Using stored certificate for #{opts[:username]}@#{opts[:domain]}") + pkcs = pkcs12_results.first.openssl_pkcs12 end auth_opts[:auth] = { diff --git a/lib/metasploit/framework/login_scanner/ldap.rb b/lib/metasploit/framework/login_scanner/ldap.rb index ef0ae8d63076..38872f7c74e5 100644 --- a/lib/metasploit/framework/login_scanner/ldap.rb +++ b/lib/metasploit/framework/login_scanner/ldap.rb @@ -86,8 +86,8 @@ def each_credential credential.private = nil elsif opts[:ldap_auth] == Msf::Exploit::Remote::AuthOption::SCHANNEL # If we're using kerberos auth with schannel then the user/password is irrelevant - # Remove it from the credential so we don't store it - credential.public = nil + # Remove the password from the credential so we don't store it + # Note that the username is kept since it is needed for the certificate lookup. credential.private = nil end diff --git a/lib/msf/core/exploit/remote/kerberos/service_authenticator/base.rb b/lib/msf/core/exploit/remote/kerberos/service_authenticator/base.rb index daef70e9fb63..be2d74efa1eb 100644 --- a/lib/msf/core/exploit/remote/kerberos/service_authenticator/base.rb +++ b/lib/msf/core/exploit/remote/kerberos/service_authenticator/base.rb @@ -253,6 +253,17 @@ def authenticate(options = {}) elsif options[:credential] auth_context = authenticate_via_krb5_ccache_credential_tgs(options[:credential], options) else + pkcs12_storage = Msf::Exploit::Remote::Pkcs12::Storage.new(framework: framework, framework_module: framework_module) + pkcs12_results = pkcs12_storage.pkcs12( + workspace: workspace, + username: @username, + realm: @realm + ) + if pkcs12_results.any? + stored_pkcs12 = pkcs12_results.first + options[:pfx] = stored_pkcs12.openssl_pkcs12 + print_status("Using stored certificate for #{stored_pkcs12.username}@#{stored_pkcs12.realm}") + end auth_context = authenticate_via_kdc(options) auth_context = authenticate_via_krb5_ccache_credential_tgt(auth_context[:credential], options) end diff --git a/lib/msf/core/exploit/remote/ms_icpr.rb b/lib/msf/core/exploit/remote/ms_icpr.rb index ae4fbcf80b3f..f6daacf02898 100644 --- a/lib/msf/core/exploit/remote/ms_icpr.rb +++ b/lib/msf/core/exploit/remote/ms_icpr.rb @@ -221,6 +221,9 @@ def do_request_cert(icpr, opts) pkcs12 = OpenSSL::PKCS12.create('', '', private_key, response[:certificate]) # see: https://pki-tutorial.readthedocs.io/en/latest/mime.html#mime-types info = "#{simple.client.default_domain}\\#{datastore['SMBUser']} Certificate" + # TODO: I was under the impression a single certificate can only have one UPN associated with it. + # But here, `upn` can be an array of UPN's. This will need to be sorted out. + upn_username, upn_domain = upn&.first&.split('@') service_data = icpr_service_data credential_data = { @@ -230,7 +233,7 @@ def do_request_cert(icpr, opts) protocol: service_data[:proto], service_name: service_data[:name], workspace_id: myworkspace_id, - username: upn || datastore['SMBUser'], + username: upn_username || datastore['SMBUser'], private_type: :pkcs12, private_data: Metasploit::Credential::Pkcs12.build_data( # pkcs12 is a binary format, but for persisting we Base64 encode it @@ -238,6 +241,8 @@ def do_request_cert(icpr, opts) ca: datastore['CA'], adcs_template: cert_template ), + realm_key: Metasploit::Model::Realm::Key::ACTIVE_DIRECTORY_DOMAIN, + realm_value: upn_domain || simple.client.default_domain, origin_type: :service, module_fullname: fullname } diff --git a/lib/msf/core/exploit/remote/pkcs12/storage.rb b/lib/msf/core/exploit/remote/pkcs12/storage.rb new file mode 100644 index 000000000000..c89931100402 --- /dev/null +++ b/lib/msf/core/exploit/remote/pkcs12/storage.rb @@ -0,0 +1,86 @@ +module Msf::Exploit::Remote::Pkcs12 + + class Storage + include Msf::Auxiliary::Report + + # @!attribute [r] framework + # @return [Msf::Framework] the Metasploit framework instance + attr_reader :framework + + # @!attribute [r] framework_module + # @return [Msf::Module] the Metasploit framework module that is associated with the authentication instance + attr_reader :framework_module + + def initialize(framework: nil, framework_module: nil) + @framework = framework || framework_module&.framework + @framework_module = framework_module + end + + # Get stored pkcs12 matching the options query. + # + # @param [Hash] options The options for matching pkcs12's. + # @option options [Integer, Array] :id The identifier of the pkcs12 (optional) + # @option options [String] :realm The realm of the pkcs12 (optional) + # @option options [String] :username The username of the pkcs12 (optional) + # @return [Array] + def pkcs12(options = {}, &block) + stored_pkcs12_array = filter_pkcs12(options).map do |pkcs12_entry| + StoredPkcs12.new(pkcs12_entry) + end + + stored_pkcs12_array.each do |stored_pkcs12| + block.call(stored_pkcs12) if block_given? + end + + stored_pkcs12_array + end + + # Return the raw stored pkcs12. + # + # @param [Hash] options See the options hash description in {#pkcs12}. + # @return [Array] + def filter_pkcs12(options) + return [] unless active_db? + + filter = {} + filter[:id] = options[:id] if options[:id].present? + filter[:user] = options[:username] if options[:username].present? + filter[:realm] = options[:realm] if options[:realm].present? + + creds = framework.db.creds( + workspace: options.fetch(:workspace) { workspace }, + type: 'Metasploit::Credential::Pkcs12', + **filter + ).select do |cred| + cred.private.type == 'Metasploit::Credential::Pkcs12' + end + + creds.each do |stored_cred| + block.call(stored_cred) if block_given? + end + end + + def delete_pkcs12(options = {}) + if options.keys == [:ids] + # skip calling #filter_pkcs12 which issues a query when the IDs are specified + ids = options[:ids] + else + ids = filter_pkcs12(options).map(&:id) + end + + framework.db.delete_credentials(ids: ids).map do |stored_pkcs12| + StoredPkcs12.new(stored_pkcs12) + end + end + + # @return [String] The name of the workspace in which to operate. + def workspace + if @framework_module + return @framework_module.workspace + elsif @framework&.db&.active + return @framework.db.workspace&.name + end + end + + end +end diff --git a/lib/msf/core/exploit/remote/pkcs12/stored_pkcs12.rb b/lib/msf/core/exploit/remote/pkcs12/stored_pkcs12.rb new file mode 100644 index 000000000000..d2c70ca01b69 --- /dev/null +++ b/lib/msf/core/exploit/remote/pkcs12/stored_pkcs12.rb @@ -0,0 +1,38 @@ +module Msf::Exploit::Remote::Pkcs12 + + class StoredPkcs12 + def initialize(pkcs12) + @pkcs12 = pkcs12 + end + + def id + @pkcs12.id + end + + def openssl_pkcs12 + private_cred.openssl_pkcs12 + end + + def ca + private_cred.ca + end + + def adcs_template + private_cred.adcs_template + end + + def private_cred + @pkcs12.private + end + + def username + @pkcs12.public.username + end + + def realm + @pkcs12.realm.value + end + end + +end + diff --git a/lib/msf/ui/console/command_dispatcher/db.rb b/lib/msf/ui/console/command_dispatcher/db.rb index 711cae3c8cec..8cf44e11e9aa 100644 --- a/lib/msf/ui/console/command_dispatcher/db.rb +++ b/lib/msf/ui/console/command_dispatcher/db.rb @@ -19,6 +19,7 @@ class Db include Msf::Ui::Console::CommandDispatcher::Db::Common include Msf::Ui::Console::CommandDispatcher::Db::Analyze include Msf::Ui::Console::CommandDispatcher::Db::Klist + include Msf::Ui::Console::CommandDispatcher::Db::Certs DB_CONFIG_PATH = 'framework/database' @@ -49,6 +50,7 @@ def commands "notes" => "List all notes in the database", "loot" => "List all loot in the database", "klist" => "List Kerberos tickets in the database", + "certs" => "List Pkcs12 certificate bundles in the database", "db_import" => "Import a scan result file (filetype will be auto-detected)", "db_export" => "Export a file containing the contents of the database", "db_nmap" => "Executes nmap and records the output automatically", diff --git a/lib/msf/ui/console/command_dispatcher/db/certs.rb b/lib/msf/ui/console/command_dispatcher/db/certs.rb new file mode 100644 index 000000000000..adb99db2eb3d --- /dev/null +++ b/lib/msf/ui/console/command_dispatcher/db/certs.rb @@ -0,0 +1,154 @@ +# -*- coding: binary -*- + +module Msf::Ui::Console::CommandDispatcher::Db::Certs + # + # Tab completion for the certs command + # + # @param str [String] the string currently being typed before tab was hit + # @param words [Array] the previously completed words on the command line. words is always + # at least 1 when tab completion has reached this stage since the command itself has been completed + def cmd_certs_tabs(str, words) + if words.length == 1 + @@certs_opts.option_keys.select { |opt| opt.start_with?(str) } + end + end + + def cmd_certs_help + print_line 'List Pkcs12 certificate bundles in the database' + print_line 'Usage: certs [options] [username[@domain_upn_format]]' + print_line + print @@certs_opts.usage + print_line + end + + @@certs_opts = Rex::Parser::Arguments.new( + ['-v', '--verbose'] => [false, 'Verbose output'], + ['-d', '--delete'] => [ false, 'Delete *all* matching pkcs12 entries'], + ['-h', '--help'] => [false, 'Help banner'], + ['-i', '--index'] => [true, 'Pkcs12 entry ID(s) to search for, e.g. `-i 1` or `-i 1,2,3` or `-i 1 -i 2 -i 3`'], + ) + + def cmd_certs(*args) + return unless active? + + entries_affected = 0 + mode = :list + id_search = [] + username = nil + verbose = false + @@certs_opts.parse(args) do |opt, _idx, val| + case opt + when '-h', '--help' + cmd_certs_help + return + when '-v', '--verbose' + verbose = true + when '-d', '--delete' + mode = :delete + when '-i', '--id' + id_search = (id_search + val.split(/,\s*|\s+/)).uniq # allows 1 or 1,2,3 or "1 2 3" or "1, 2, 3" + else + # Anything that wasn't an option is a username to search for + username = val + end + end + + pkcs12_results = pkcs12_search(username: username, id_search: id_search) + + print_line('Pkcs12') + print_line('======') + + if mode == :delete + result = pkcs12_storage.delete_pkcs12(ids: pkcs12_results.map(&:id)) + entries_affected = result.size + end + + if pkcs12_results.empty? + print_line('No Pkcs12') + print_line + return + end + + if verbose + pkcs12_results.each.with_index do |pkcs12_result, index| + print_line "Certificate[#{index}]:" + print_line pkcs12_result.openssl_pkcs12.certificate.to_s + print_line pkcs12_result.openssl_pkcs12.certificate.to_text + print_line + end + else + tbl = Rex::Text::Table.new( + { + 'Columns' => ['id', 'username', 'realm', 'subject', 'issuer', 'CA', 'ADCS Template'], + 'SortIndex' => -1, + 'WordWrap' => false, + 'Rows' => pkcs12_results.map do |pkcs12| + [ + pkcs12.id, + pkcs12.username, + pkcs12.realm, + pkcs12.openssl_pkcs12.certificate.subject.to_s, + pkcs12.openssl_pkcs12.certificate.issuer.to_s, + pkcs12.ca, + pkcs12.adcs_template + ] + end + } + ) + print_line(tbl.to_s) + end + + if mode == :delete + print_status("Deleted #{entries_affected} #{entries_affected > 1 ? 'entries' : 'entry'}") if entries_affected > 0 + end + end + + + # @param [String, nil] username Search for pkcs12 associated with this username + # @param [Array, nil] id_search List of pkcs12 IDs to search for + # @param [Workspace] workspace to search against + # @option [Symbol] :workspace The framework.db.workspace to search against (optional) + # @return [Array<>] + def pkcs12_search(username: nil, id_search: nil, workspace: framework.db.workspace) + pkcs12_results = [] + + if id_search.present? + begin + pkcs12_results += id_search.flat_map do |id| + pkcs12_storage.pkcs12( + workspace: workspace, + id: id + ) + end + rescue ActiveRecord::RecordNotFound => e + wlog("Record Not Found: #{e.message}") + print_warning("Not all records with the ids: #{id_search} could be found.") + print_warning('Please ensure all ids specified are available.') + end + elsif username.present? + realm = nil + if username.include?('@') + username, realm = username.split('@', 2) + end + pkcs12_results += pkcs12_storage.pkcs12( + workspace: workspace, + username: username, + realm: realm + ) + else + pkcs12_results += pkcs12_storage.pkcs12( + workspace: workspace + ) + end + + pkcs12_results.sort_by do |pkcs12| + [pkcs12.realm, pkcs12.username] + end + end + + # @return [Msf::Exploit::Remote::Kerberos::Ticket::Storage::ReadWrite] + def pkcs12_storage + @pkcs12_storage ||= Msf::Exploit::Remote::Pkcs12::Storage.new(framework: framework) + end + +end diff --git a/modules/auxiliary/scanner/ldap/ldap_login.rb b/modules/auxiliary/scanner/ldap/ldap_login.rb index 7c5efbabf439..e73aa03d6c14 100644 --- a/modules/auxiliary/scanner/ldap/ldap_login.rb +++ b/modules/auxiliary/scanner/ldap/ldap_login.rb @@ -115,6 +115,14 @@ def run_host(ip) realm_key = nil if opts[:ldap_auth] == Msf::Exploit::Remote::AuthOption::KERBEROS realm_key = Metasploit::Model::Realm::Key::ACTIVE_DIRECTORY_DOMAIN + if datastore['CreateSession'] + # If kerberos auth is used and session creation is requested, we want to be able to read the cached tickets + # TODO: once the password issue (https://github.com/rapid7/metasploit-framework/issues/19743) is fixed + # we might prefer to check if the `password` option is set instead of `CreateSession`. If the user + # sets the password with Kerberos auth, it means he wants to test if it's valid, so we should not reuse + # a ticket from the cache. + opts[:kerberos_ticket_storage] = kerberos_ticket_storage({ read: true, write: true }) + end end scanner = Metasploit::Framework::LoginScanner::LDAP.new( diff --git a/modules/auxiliary/scanner/smb/smb_login.rb b/modules/auxiliary/scanner/smb/smb_login.rb index 0519b29657f9..f8eb49416b72 100644 --- a/modules/auxiliary/scanner/smb/smb_login.rb +++ b/modules/auxiliary/scanner/smb/smb_login.rb @@ -116,6 +116,17 @@ def run_host(ip) fail_with(Msf::Exploit::Failure::BadConfig, 'The SMBDomain option is required when using Kerberos authentication.') if datastore['SMBDomain'].blank? fail_with(Msf::Exploit::Failure::BadConfig, 'The DomainControllerRhost is required when using Kerberos authentication.') if datastore['DomainControllerRhost'].blank? + if datastore['CreateSession'] + # If kerberos auth is used and session creation is requested, we want to be able to read the cached tickets + # TODO: once the password issue (https://github.com/rapid7/metasploit-framework/issues/19743) is fixed + # we might prefer to check if the `password` option is set instead of `CreateSession`. If the user + # sets the password with Kerberos auth, it means he wants to test if it's valid, so we should not reuse + # a ticket from the cache. + krb_ticket_storage = kerberos_ticket_storage({ read: true, write: true }) + else + # Write only cache so we keep all gathered tickets but don't reuse them for auth while running the module + krb_ticket_storage = kerberos_ticket_storage({ read: false, write: true }) + end kerberos_authenticator_factory = lambda do |username, password, realm| Msf::Exploit::Remote::Kerberos::ServiceAuthenticator::SMB.new( host: datastore['DomainControllerRhost'], @@ -128,7 +139,7 @@ def run_host(ip) framework_module: self, cache_file: datastore['Smb::Krb5Ccname'].blank? ? nil : datastore['Smb::Krb5Ccname'], # Write only cache so we keep all gathered tickets but don't reuse them for auth while running the module - ticket_storage: kerberos_ticket_storage({ read: false, write: true }) + ticket_storage: krb_ticket_storage ) end end