diff --git a/lib/metasploit/framework/ldap/client.rb b/lib/metasploit/framework/ldap/client.rb index 57d9a5696478..63a8ed3296de 100644 --- a/lib/metasploit/framework/ldap/client.rb +++ b/lib/metasploit/framework/ldap/client.rb @@ -132,7 +132,9 @@ def ldap_auth_opts_schannel(opts, ssl) ) pkcs12_results = pkcs12_storage.pkcs12( username: opts[:username], - realm: opts[:domain] + realm: opts[:domain], + tls_auth: true, + status: 'active' ) if pkcs12_results.empty? raise Msf::ValidationError, "Pkcs12 for #{opts[:username]}@#{opts[:domain]} not found in the database" diff --git a/lib/msf/core/db_manager/cred.rb b/lib/msf/core/db_manager/cred.rb index 8796c8df2ddb..ce39eac03309 100644 --- a/lib/msf/core/db_manager/cred.rb +++ b/lib/msf/core/db_manager/cred.rb @@ -246,7 +246,7 @@ def update_credential(opts) if opts[:public][:id] public_id = opts[:public].delete(:id) public = Metasploit::Credential::Public.find(public_id) - public.update_attributes(opts[:public]) + public.update(opts[:public]) else public = Metasploit::Credential::Public.where(opts[:public]).first_or_initialize end @@ -256,7 +256,7 @@ def update_credential(opts) if opts[:private][:id] private_id = opts[:private].delete(:id) private = Metasploit::Credential::Private.find(private_id) - private.update_attributes(opts[:private]) + private.update(opts[:private]) else private = Metasploit::Credential::Private.where(opts[:private]).first_or_initialize end @@ -266,7 +266,7 @@ def update_credential(opts) if opts[:origin][:id] origin_id = opts[:origin].delete(:id) origin = Metasploit::Credential::Origin.find(origin_id) - origin.update_attributes(opts[:origin]) + origin.update(opts[:origin]) else origin = Metasploit::Credential::Origin.where(opts[:origin]).first_or_initialize 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 be0bb91d3c5c..9744ff20261a 100644 --- a/lib/msf/core/exploit/remote/kerberos/service_authenticator/base.rb +++ b/lib/msf/core/exploit/remote/kerberos/service_authenticator/base.rb @@ -257,7 +257,8 @@ def authenticate(options = {}) pkcs12_results = pkcs12_storage.pkcs12( workspace: workspace, username: @username, - realm: @realm + realm: @realm, + status: 'active' ) if pkcs12_results.any? stored_pkcs12 = pkcs12_results.first diff --git a/lib/msf/core/exploit/remote/pkcs12/storage.rb b/lib/msf/core/exploit/remote/pkcs12/storage.rb index c89931100402..8bad9e326085 100644 --- a/lib/msf/core/exploit/remote/pkcs12/storage.rb +++ b/lib/msf/core/exploit/remote/pkcs12/storage.rb @@ -52,15 +52,33 @@ def filter_pkcs12(options) type: 'Metasploit::Credential::Pkcs12', **filter ).select do |cred| - cred.private.type == 'Metasploit::Credential::Pkcs12' - end + # this is needed since if a filter is provided (e.g. `id:`) framework.db.creds will ignore the type: + next false unless cred.private.type == 'Metasploit::Credential::Pkcs12' + if options[:status].present? + # If status is not set on the credential, considere it is `active` + status = cred.private.status || 'active' + next false if status != options[:status] + end + + cert = cred.private.openssl_pkcs12.certificate + unless Time.now.between?(cert.not_before, cert.not_after) + ilog("[filter_pkcs12] Found a matching certificate but it has expired") + next false + end + + if options[:tls_auth] + eku = cert.extensions.select { |c| c.oid == 'extendedKeyUsage' }.first + unless eku&.value == 'TLS Web Client Authentication' + ilog("[filter_pkcs12] Found a matching certificate but it doesn't have the 'TLS Web Client Authentication' EKU") + next false + end + end - creds.each do |stored_cred| - block.call(stored_cred) if block_given? + true end end - def delete_pkcs12(options = {}) + def delete(options = {}) if options.keys == [:ids] # skip calling #filter_pkcs12 which issues a query when the IDs are specified ids = options[:ids] @@ -82,5 +100,45 @@ def workspace end end + # Mark Pkcs12(s) as inactive + # + # @param [Array] ids The list of pkcs12 IDs. + # @return [Array] + def deactivate(ids:) + set_status(ids: ids, status: 'inactive') + end + + # Mark Pkcs12(s) as active + # + # @param [Array] ids The list of pkcs12 IDs. + # @return [Array] + def activate(ids:) + set_status(ids: ids, status: 'active') + end + + private + + # @param [Array] ids List of pkcs12 IDs to update + # @param [String] status The status to set for the pkcs12 + # @return [Array] + def set_status(ids:, status:) + updated_pkcs12 = [] + ids.each do |id| + pkcs12 = filter_pkcs12({ id: id }) + if pkcs12.blank? + print_warning("Pkcs12 with id: #{id} was not found in the database") + next + end + private = pkcs12.first.private + private.metadata.merge!({ 'status' => status } ) + updated_pkcs12 << framework.db.update_credential({ id: id, private: { id: private.id, metadata: private.metadata }}) + # I know this looks weird but the local db returns a single loot object, remote db returns an array of them + #updated_certs << Array.wrap(framework.db.update_loot({ id: id, info: updated_pkcs12_status })).first + end + updated_pkcs12.map do |stored_pkcs12| + StoredPkcs12.new(stored_pkcs12) + 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 index d2c70ca01b69..d117abbac713 100644 --- a/lib/msf/core/exploit/remote/pkcs12/stored_pkcs12.rb +++ b/lib/msf/core/exploit/remote/pkcs12/stored_pkcs12.rb @@ -13,8 +13,8 @@ def openssl_pkcs12 private_cred.openssl_pkcs12 end - def ca - private_cred.ca + def adcs_ca + private_cred.adcs_ca end def adcs_template @@ -32,7 +32,16 @@ def username def realm @pkcs12.realm.value end - end + def status + private_cred.status + end + + # @return [TrueClass, FalseClass] True if the certificate is valid within the not_before/not_after, false otherwise + def expired?(now = Time.now) + cert = openssl_pkcs12.certificate + !now.between?(cert.not_before, cert.not_after) + end + end end diff --git a/lib/msf/ui/console/command_dispatcher/db/certs.rb b/lib/msf/ui/console/command_dispatcher/db/certs.rb index adb99db2eb3d..bc15fc876d71 100644 --- a/lib/msf/ui/console/command_dispatcher/db/certs.rb +++ b/lib/msf/ui/console/command_dispatcher/db/certs.rb @@ -8,9 +8,20 @@ module Msf::Ui::Console::CommandDispatcher::Db::Certs # @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) } + tabs = [] + + case words.length + when 1 + tabs = @@certs_opts.option_keys.select { |opt| opt.start_with?(str) } + when 2 + tabs = if words[1] == '-e' || words[1] == '--export' + tab_complete_filenames(str, words) + else + [] + end end + + tabs end def cmd_certs_help @@ -26,6 +37,9 @@ def cmd_certs_help ['-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`'], + ['-a', '--activate'] => [false, 'Activates *all* matching pkcs12 entries'], + ['-A', '--deactivate'] => [false, 'Deactivates *all* matching pkcs12 entries'], + ['-e', '--export'] => [true, 'The file path where to export the matching pkcs12 entry'] ) def cmd_certs(*args) @@ -36,6 +50,7 @@ def cmd_certs(*args) id_search = [] username = nil verbose = false + export_path = nil @@certs_opts.parse(args) do |opt, _idx, val| case opt when '-h', '--help' @@ -47,6 +62,12 @@ def cmd_certs(*args) 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" + when '-a', '--activate' + mode = :activate + when '-A', '--deactivate' + mode = :deactivate + when '-e', '--export' + export_path = val else # Anything that wasn't an option is a username to search for username = val @@ -59,10 +80,30 @@ def cmd_certs(*args) print_line('======') if mode == :delete - result = pkcs12_storage.delete_pkcs12(ids: pkcs12_results.map(&:id)) + result = pkcs12_storage.delete(ids: pkcs12_results.map(&:id)) entries_affected = result.size end + if mode == :activate || mode == :deactivate + pkcs12_results = set_pkcs12_status(mode, pkcs12_results) + entries_affected = pkcs12_results.size + end + + if export_path + if pkcs12_results.empty? + print_error('No mathing Pkcs12 entry to export') + return + end + if pkcs12_results.size > 1 + print_error('More than one mathing Pkcs12 entry found. Filter with `-i` and/or provide a username') + return + end + + raw_data = Base64.strict_decode64(pkcs12_results.first.private_cred.data) + ::File.binwrite(::File.expand_path(export_path), raw_data) + return + end + if pkcs12_results.empty? print_line('No Pkcs12') print_line @@ -79,7 +120,7 @@ def cmd_certs(*args) else tbl = Rex::Text::Table.new( { - 'Columns' => ['id', 'username', 'realm', 'subject', 'issuer', 'CA', 'ADCS Template'], + 'Columns' => ['id', 'username', 'realm', 'subject', 'issuer', 'ADCS CA', 'ADCS Template', 'status'], 'SortIndex' => -1, 'WordWrap' => false, 'Rows' => pkcs12_results.map do |pkcs12| @@ -89,8 +130,9 @@ def cmd_certs(*args) pkcs12.realm, pkcs12.openssl_pkcs12.certificate.subject.to_s, pkcs12.openssl_pkcs12.certificate.issuer.to_s, - pkcs12.ca, - pkcs12.adcs_template + pkcs12.adcs_ca, + pkcs12.adcs_template, + pkcs12_status(pkcs12) ] end } @@ -98,8 +140,13 @@ def cmd_certs(*args) print_line(tbl.to_s) end - if mode == :delete + case mode + when :delete print_status("Deleted #{entries_affected} #{entries_affected > 1 ? 'entries' : 'entry'}") if entries_affected > 0 + when :activate + print_status("Activated #{entries_affected} #{entries_affected > 1 ? 'entries' : 'entry'}") if entries_affected > 0 + when :deactivate + print_status("Deactivated #{entries_affected} #{entries_affected > 1 ? 'entries' : 'entry'}") if entries_affected > 0 end end @@ -146,9 +193,38 @@ def pkcs12_search(username: nil, id_search: nil, workspace: framework.db.workspa end end + + private + # @return [Msf::Exploit::Remote::Kerberos::Ticket::Storage::ReadWrite] def pkcs12_storage @pkcs12_storage ||= Msf::Exploit::Remote::Pkcs12::Storage.new(framework: framework) end + # Gets the status of a Pkcs12 + # + # @param [Msf::Exploit::Remote::Pkcs12::Storage] + # @return [String] Status of the Pkcs12 + def pkcs12_status(pkcs12) + if pkcs12.expired? + '>>expired<<' + elsif pkcs12.status.blank? + 'active' + else + pkcs12.status + end + end + + # Sets the status of the Pkcs12 + # + # @param [Symbol] mode The status (:activate or :deactivate) to apply to the Pkcs12(s) + # @param [Array] tickets The Pkcs12 which statuses are to be updated + # @return [Array] + def set_pkcs12_status(mode, pkcs12) + if mode == :activate + pkcs12_storage.activate(ids: pkcs12.map(&:id)) + elsif mode == :deactivate + pkcs12_storage.deactivate(ids: pkcs12.map(&:id)) + end + end end diff --git a/modules/auxiliary/scanner/ldap/ldap_login.rb b/modules/auxiliary/scanner/ldap/ldap_login.rb index 47c6dac14db9..204a2abfc8ab 100644 --- a/modules/auxiliary/scanner/ldap/ldap_login.rb +++ b/modules/auxiliary/scanner/ldap/ldap_login.rb @@ -160,9 +160,10 @@ def run_host(ip) successful_logins << result if opts[:ldap_auth] == Msf::Exploit::Remote::AuthOption::SCHANNEL # Schannel auth has no meaningful credential information to store in the DB - print_brute level: :good, ip: ip, msg: "Success: 'Cert File #{opts[:ldap_cert_file]}'" + msg = opts[:ldap_cert_file].nil? ? 'Using stored certificate' : "Cert File #{opts[:ldap_cert_file]}" + print_brute level: :good, ip: ip, msg: "Success: '#{msg}'" else - create_credential_and_login(credential_data) + create_credential_and_login(credential_data) if result.credential.private print_brute level: :good, ip: ip, msg: "Success: '#{result.credential}'" end successful_sessions << create_session(result, ip) if create_session? diff --git a/spec/lib/msf/core/exploit/remote/pkcs12/storage_spec.rb b/spec/lib/msf/core/exploit/remote/pkcs12/storage_spec.rb new file mode 100644 index 000000000000..8e5eeb4728dd --- /dev/null +++ b/spec/lib/msf/core/exploit/remote/pkcs12/storage_spec.rb @@ -0,0 +1,296 @@ +RSpec.describe Msf::Exploit::Remote::Pkcs12::Storage do + + if ENV['REMOTE_DB'] + before {skip('Not supported for remote DB')} + end + + include_context 'Msf::DBManager' + + let(:workspace) { FactoryBot.create(:mdm_workspace) } + let(:origin) do + FactoryBot.create( + :metasploit_credential_origin_service, + service: FactoryBot.create( + :mdm_service, + host: FactoryBot.create(:mdm_host, workspace: workspace) + ) + ) + end + let(:username) { 'n00tmeg' } + let(:realm) { 'test_realm' } + let!(:creds) do + creds = 2.times.map do + FactoryBot.create( + :metasploit_credential_core, + private: FactoryBot.create(:metasploit_credential_pkcs12), + origin: origin, + workspace: workspace + ) + end + # Add a core credential with specific username and realm + creds << FactoryBot.create( + :metasploit_credential_core, + public: FactoryBot.create(:metasploit_credential_username, username: username), + realm: FactoryBot.create(:metasploit_credential_realm, value: realm), + private: FactoryBot.create(:metasploit_credential_pkcs12), + origin: origin, + workspace: workspace + ) + creds + end + + subject(:storage) { described_class.new(framework: framework) } + + before :each do + framework.db.workspace = workspace + end + + describe '#pkcs12' do + it 'returns an Array of StoredPkcs12' do + expect(storage.pkcs12).to be_a(Array) + expect(storage.pkcs12.all? {|e| e.is_a?(Msf::Exploit::Remote::Pkcs12::StoredPkcs12)}).to be true + end + + it 'returns all the StoredPkcs12 by default' do + expect(storage.pkcs12.size).to eq(creds.size) + expect(storage.pkcs12.map(&:id).sort).to eq(creds.map(&:id).sort) + end + + it 'yields each StoredPkcs12 by default' do + expect { |b| storage.pkcs12({}, &b) }.to yield_control.exactly(creds.size).times + results = creds.map(&:id) + storage.pkcs12 do |stored_pkcs12| + results.delete(stored_pkcs12.id) + end + expect(results).to be_empty + end + end + + describe '#filter_pkcs12' do + it 'returns an Array of Metasploit::Credential::Core' do + expect(storage.filter_pkcs12({})).to be_a(Array) + expect(storage.filter_pkcs12({}).all? {|e| e.is_a?(Metasploit::Credential::Core)}).to be true + end + + it 'returns all the credentials' do + expect(storage.filter_pkcs12({}).size).to eq(creds.size) + expect(storage.filter_pkcs12({}).map(&:id).sort).to eq(creds.map(&:id).sort) + end + + context 'with options to match an id' do + it 'returns the correct StoredPkcs12' do + cred = creds.sample + results = storage.filter_pkcs12(id: cred.id) + expect(results.size).to eq(1) + expect(results.first.id).to eq(cred.id) + end + end + + context 'with options to match an non-existing id' do + it 'raises an ActiveRecord::RecordNotFound exception' do + expect { storage.filter_pkcs12({ id: (creds.map(&:id).max + 1) }) }.to raise_error(ActiveRecord::RecordNotFound) + end + end + + context 'with options to match a username' do + it 'returns the correct StoredPkcs12' do + results = storage.filter_pkcs12(username: username) + expect(results.size).to eq(1) + expect(results.first.public.username).to eq(username) + end + end + + context 'with options to match a realm' do + it 'returns the correct StoredPkcs12' do + results = storage.filter_pkcs12(realm: realm) + expect(results.size).to eq(1) + expect(results.first.realm.value).to eq(realm) + end + end + + context 'with options to match a workspace' do + before :each do + workspace = FactoryBot.create(:mdm_workspace, name: 'test_workspace') + origin = FactoryBot.create( + :metasploit_credential_origin_service, + service: FactoryBot.create( + :mdm_service, + host: FactoryBot.create(:mdm_host, workspace: workspace) + ) + ) + creds << FactoryBot.create( + :metasploit_credential_core, + private: FactoryBot.create(:metasploit_credential_pkcs12), + origin: origin, + workspace: workspace + ) + end + it 'returns the correct StoredPkcs12' do + results = storage.filter_pkcs12(workspace: 'test_workspace') + expect(results.size).to eq(1) + expect(results.first.workspace.name).to eq('test_workspace') + end + end + + context 'with a Metasploit::Credential::Password type credential' do + before :each do + creds << FactoryBot.create( + :metasploit_credential_core, + private: FactoryBot.create(:metasploit_credential_password), + origin: origin, + workspace: workspace + ) + end + it 'only returns the Metasploit::Credential::Pkcs12 credentials' do + expect(storage.filter_pkcs12({}).size).to eq(creds.size - 1) + expect(storage.filter_pkcs12({}).map(&:id).sort).to eq(creds[0..-2].map(&:id).sort) + end + + context 'and using the option to macth this credential\'s ID' do + it 'returns an empty Array' do + expect(storage.filter_pkcs12({id: creds.last.id})).to be_empty + end + end + end + + context 'with an option to match the Pkcs12 status' do + + context 'when the status is not set on any credentials' do + it 'returns all the credentials with an option to match active status' do + expect(storage.filter_pkcs12({status: 'active'}).size).to eq(creds.size) + expect(storage.filter_pkcs12({status: 'active'}).map(&:id).sort).to eq(creds.map(&:id).sort) + end + + it 'returns no credentials with an option to match inactive status' do + expect(storage.filter_pkcs12({status: 'inactive'})).to be_empty + end + end + + context 'when the status is set on some credentials' do + before :each do + creds << FactoryBot.create( + :metasploit_credential_core, + private: FactoryBot.create(:metasploit_credential_pkcs12_with_status, status: 'active'), + origin: origin, + workspace: workspace + ) + creds << FactoryBot.create( + :metasploit_credential_core, + private: FactoryBot.create(:metasploit_credential_pkcs12_with_status, status: 'inactive'), + origin: origin, + workspace: workspace + ) + end + + it 'returns all the credentials with no status set and the active credentials with an option to match active status' do + results = storage.filter_pkcs12({status: 'active'}) + expect(results.size).to eq(creds.size - 1) + expect(results.all? {|cred| cred.private.status.nil? || cred.private.status == 'active'}).to be true + end + + it 'returns the inactive credentials with an option to match inactive status' do + results = storage.filter_pkcs12({status: 'inactive'}) + expect(results.size).to eq(1) + expect(results.first.private.status).to eq('inactive') + end + end + + end + + context 'when a certificate `not_after` date is in the past (expired)' do + before :each do + priv = FactoryBot.create( + :metasploit_credential_pkcs12, + not_before: Time.now - 2.hours, + not_after: Time.now - 1.hour + ) + creds << FactoryBot.create( + :metasploit_credential_core, + private: priv, + origin: origin, + workspace: workspace + ) + end + + it "returns all the credentials but this one" do + expect(storage.filter_pkcs12({}).size).to eq(creds.size - 1) + expect(storage.filter_pkcs12({}).map(&:id).sort).to eq(creds[0..-2].map(&:id).sort) + end + end + + context 'when the certificate not_before date is in the future' do + before :each do + priv = FactoryBot.create( + :metasploit_credential_pkcs12, + not_before: Time.now + 1.hour, + not_after: Time.now + 2.hours + ) + creds << FactoryBot.create( + :metasploit_credential_core, + private: priv, + origin: origin, + workspace: workspace + ) + end + + it "returns all the credentials but this one" do + expect(storage.filter_pkcs12({}).size).to eq(creds.size - 1) + expect(storage.filter_pkcs12({}).map(&:id).sort).to eq(creds[0..-2].map(&:id).sort) + end + end + end + + describe '#delete' do + it 'returns an Array of StoredPkcs12' do + results = storage.delete({}) + expect(results).to be_a(Array) + expect(results.all? {|e| e.is_a?(Msf::Exploit::Remote::Pkcs12::StoredPkcs12)}).to be true + end + + it 'deletes all the credentials and returns the deleted StoredPkcs12 objects' do + results = storage.delete({}) + expect(Msf::Exploit::Remote::Pkcs12::Storage.new(framework: framework).pkcs12).to be_empty + expect(results.size).to eq(creds.size) + expect(results.map(&:id).sort).to eq(creds.map(&:id).sort) + end + + context 'with options to match an id' do + it 'deletes the matching credential and return the deleted StoredPkcs12 objects' do + cred = creds.sample + results = storage.delete(id: cred.id) + remaining_pkcs12 = Msf::Exploit::Remote::Pkcs12::Storage.new(framework: framework).pkcs12 + expect(remaining_pkcs12.map(&:id).sort).to eq((creds.map(&:id) - [cred.id]).sort) + expect(results.size).to eq(1) + expect(results.first.id).to eq(cred.id) + end + end + end + + describe '#deactivate' do + it 'deactivates the Pkcs12 that matches the provided ID and returns the updated StoredPkcs12' do + cred = creds.sample + results = storage.deactivate(ids: [cred.id]) + expect(results.first.id).to eq(cred.id) + expect(results.first.status).to eq('inactive') + expect(framework.db.creds(id: cred.id).first.private.status).to eq('inactive') + end + end + + describe '#activate' do + before :each do + creds << FactoryBot.create( + :metasploit_credential_core, + private: FactoryBot.create(:metasploit_credential_pkcs12_with_status, status: 'inactive'), + origin: origin, + workspace: workspace + ) + end + + it 'activates the Pkcs12 that matches the provided ID and returns the updated StoredPkcs12' do + results = storage.activate(ids: [creds.last.id]) + expect(results.first.id).to eq(creds.last.id) + expect(results.first.status).to eq('active') + expect(framework.db.creds(id: creds.last.id).first.private.status).to eq('active') + end + end +end diff --git a/spec/lib/msf/core/exploit/remote/pkcs12/stored_pkcs12_spec.rb b/spec/lib/msf/core/exploit/remote/pkcs12/stored_pkcs12_spec.rb new file mode 100644 index 000000000000..cc92d9da26e6 --- /dev/null +++ b/spec/lib/msf/core/exploit/remote/pkcs12/stored_pkcs12_spec.rb @@ -0,0 +1,137 @@ +RSpec.describe Msf::Exploit::Remote::Pkcs12::StoredPkcs12 do + let(:private) { + FactoryBot.build( + :metasploit_credential_pkcs12, + id: rand(1..1000) + ) + } + let(:pkcs12) { + FactoryBot.build( + :metasploit_credential_core, + private: private + ) + } + + subject(:stored_pkcs12) { described_class.new(pkcs12) } + + describe '#id' do + it 'returns the expected ID value' do + expect(stored_pkcs12.id).to eq(pkcs12.id) + end + end + + describe '#openssl_pkcs12' do + it 'returns an OpenSSL::PKCS12 object' do + expect(stored_pkcs12.openssl_pkcs12).to be_a(OpenSSL::PKCS12) + end + + it 'returns the expected Pkcs12' do + raw_pkcs12 = Base64.strict_decode64(pkcs12.private.data) + expect(stored_pkcs12.openssl_pkcs12.to_der).to eq(raw_pkcs12) + end + end + + describe '#private_cred' do + it 'returns the expected private credential value' do + expect(stored_pkcs12.private_cred).to eq(private) + end + end + + describe '#username' do + it 'returns the expected username value' do + expect(stored_pkcs12.username).to eq(pkcs12.public.username) + end + end + + describe '#realm' do + it 'returns the expected realm value' do + expect(stored_pkcs12.realm).to eq(pkcs12.realm.value) + end + end + + context 'with metadata' do + let(:adcs_ca) { 'test_CA' } + let(:adcs_template) { 'test_template' } + let(:status) { 'inactive' } + let(:metadata) { + { + adcs_ca: adcs_ca, + adcs_template: adcs_template, + status: status + } + } + let(:private) { + FactoryBot.build( + :metasploit_credential_pkcs12, + metadata: metadata + ) + } + + describe '#adcs_ca' do + it 'returns the expected ADCS CA value' do + expect(stored_pkcs12.adcs_ca).to eq(adcs_ca) + end + end + + describe '#adcs_template' do + it 'returns the expected ADCS template value' do + expect(stored_pkcs12.adcs_template).to eq(adcs_template) + end + end + + describe '#status' do + it 'returns the expected status value' do + expect(stored_pkcs12.status).to eq(status) + end + end + end + + describe '#expired?' do + context 'when the certificate is valid within the not_before/not_after' do + it 'returns false' do + expect(stored_pkcs12.expired?).to be false + end + + context 'with a password-protected Pkcs12' do + let(:passwd) { 'test_password' } + let(:private) { + FactoryBot.build( + :metasploit_credential_pkcs12_with_pkcs12_password, + pkcs12_password: passwd + ) + } + + it 'returns false' do + expect(stored_pkcs12.expired?).to be false + end + end + end + + context 'when the certificate not_after date is in the past' do + let(:private) { + FactoryBot.build( + :metasploit_credential_pkcs12, + not_before: Time.now - 2.hours, + not_after: Time.now - 1.hour + ) + } + it 'returns true' do + expect(stored_pkcs12.expired?).to be true + end + end + + context 'when the certificate not_before date is in the future' do + let(:private) { + FactoryBot.build( + :metasploit_credential_pkcs12, + not_before: Time.now + 1.hour, + not_after: Time.now + 2.hours + ) + } + it 'returns true' do + expect(stored_pkcs12.expired?).to be true + end + end + end +end + diff --git a/spec/lib/msf/ui/console/command_dispatcher/db/certs_spec.rb b/spec/lib/msf/ui/console/command_dispatcher/db/certs_spec.rb new file mode 100644 index 000000000000..b98183f954cd --- /dev/null +++ b/spec/lib/msf/ui/console/command_dispatcher/db/certs_spec.rb @@ -0,0 +1,425 @@ +RSpec.describe Msf::Ui::Console::CommandDispatcher::Db::Certs do + + if ENV['REMOTE_DB'] + before {skip('Not supported for remote DB')} + end + + include_context 'Msf::DBManager' + include_context 'Msf::UIDriver' + + # Replace table entry ids with `[id]` for matching simplicity + # Also corrects spacing between columns to remove variation from different length ids + def table_without_ids(table) + output = table.dup + output.gsub!(/^--\s+--------/, '-- --------') + output.gsub!(/^id\s+username/, 'id username') + output.gsub!(/^\d+\s+/, '[id] ') + end + + subject do + described_class = self.described_class + instance = Class.new do + include Msf::Ui::Console::CommandDispatcher + include Msf::Ui::Console::CommandDispatcher::Common + include Msf::Ui::Console::CommandDispatcher::Db::Common + include described_class + end.new(driver) + instance + end + + describe '#cmd_certs' do + context 'when the -h option is provided' do + it 'should show a help message' do + subject.cmd_certs('-h') + expect(@output.join("\n")).to match_table <<~TABLE + List Pkcs12 certificate bundles in the database + Usage: certs [options] [username[@domain_upn_format]] + + OPTIONS: + + -a, --activate Activates *all* matching pkcs12 entries + -A, --deactivate Deactivates *all* matching pkcs12 entries + -d, --delete Delete *all* matching pkcs12 entries + -e, --export The file path where to export the matching pkcs12 entry + -h, --help Help banner + -i, --index Pkcs12 entry ID(s) to search for, e.g. `-i 1` or `-i 1,2,3` or `-i 1 -i 2 -i 3` + -v, --verbose Verbose output + TABLE + end + end + + context 'when there are no Pkcs12 certs' do + context 'when no options are provided' do + it 'should show no Pkcs12' do + subject.cmd_certs + expect(@output.join("\n")).to match_table <<~TABLE + Pkcs12 + ====== + No Pkcs12 + TABLE + end + end + + context 'when the -v option is provided' do + it 'should show no Pkcs12' do + subject.cmd_certs('-v') + expect(@output.join("\n")).to match_table <<~TABLE + Pkcs12 + ====== + No Pkcs12 + TABLE + end + end + + context 'when the -i option is provided' do + it 'should show no Pkcs12 and missing id warning' do + subject.cmd_certs('-i', '0') # Can't have an id of 0 + expect(@combined_output.join("\n")).to match_table <<~TABLE + Not all records with the ids: ["0"] could be found. + Please ensure all ids specified are available. + Pkcs12 + ====== + No Pkcs12 + TABLE + end + end + end + + context 'when there are Pkcs12 certs' do + let(:username1) { 'n00tmeg' } + let(:realm1) { 'test_realm1' } + let(:username2) { 'msfuser' } + let(:realm2) { 'test_realm2' } + let(:username3) { 'msftest' } + let(:realm3) { 'test_realm3' } + let(:origin) do + FactoryBot.create( + :metasploit_credential_origin_service, + service: FactoryBot.create( + :mdm_service, + host: FactoryBot.create(:mdm_host, workspace: framework.db.default_workspace) + ) + ) + end + let!(:creds) do + [ + FactoryBot.create( + :metasploit_credential_core, + public: FactoryBot.create(:metasploit_credential_username, username: username1), + realm: FactoryBot.create(:metasploit_credential_realm, value: realm1), + private: FactoryBot.create(:metasploit_credential_pkcs12), + origin: origin + ), + FactoryBot.create( + :metasploit_credential_core, + public: FactoryBot.create(:metasploit_credential_username, username: username2), + realm: FactoryBot.create(:metasploit_credential_realm, value: realm2), + private: FactoryBot.create(:metasploit_credential_pkcs12), + origin: origin + ) + ] + end + + context 'when no options are provided' do + it 'should show Pkcs12 certs' do + subject.cmd_certs + expect(table_without_ids(@output.join("\n"))).to match_table <<~TABLE + Pkcs12 + ====== + id username realm subject issuer ADCS CA ADCS Template status + -- -------- ----- ------- ------ ------- ------------- ------ + [id] n00tmeg test_realm1 /C=BE/O=Test/OU=Test/CN=Test /C=BE/O=Test/OU=Test/CN=Test active + [id] msfuser test_realm2 /C=BE/O=Test/OU=Test/CN=Test /C=BE/O=Test/OU=Test/CN=Test active + TABLE + end + end + + context 'when a username is specified' do + it 'should show the matching username' do + subject.cmd_certs('n00tmeg') + expect(table_without_ids(@output.join("\n"))).to match_table <<~TABLE + Pkcs12 + ====== + id username realm subject issuer ADCS CA ADCS Template status + -- -------- ----- ------- ------ ------- ------------- ------ + [id] n00tmeg test_realm1 /C=BE/O=Test/OU=Test/CN=Test /C=BE/O=Test/OU=Test/CN=Test active + TABLE + end + end + + context 'with the -i option twice and two different IDs' do + it 'should show both matching Pkcs12' do + subject.cmd_certs('-i', "#{creds[0].id}", '-i', "#{creds[1].id}") + expect(table_without_ids(@output.join("\n"))).to match_table <<~TABLE + Pkcs12 + ====== + id username realm subject issuer ADCS CA ADCS Template status + -- -------- ----- ------- ------ ------- ------------- ------ + [id] n00tmeg test_realm1 /C=BE/O=Test/OU=Test/CN=Test /C=BE/O=Test/OU=Test/CN=Test active + [id] msfuser test_realm2 /C=BE/O=Test/OU=Test/CN=Test /C=BE/O=Test/OU=Test/CN=Test active + TABLE + end + end + + context 'when the -i option is provided with 2 valid ids (quoted and space separated)' do + it 'should show both matching Pkcs12' do + subject.cmd_certs('-i', "#{creds[0].id} #{creds[1].id}") + expect(table_without_ids(@output.join("\n"))).to match_table <<~TABLE + Pkcs12 + ====== + id username realm subject issuer ADCS CA ADCS Template status + -- -------- ----- ------- ------ ------- ------------- ------ + [id] n00tmeg test_realm1 /C=BE/O=Test/OU=Test/CN=Test /C=BE/O=Test/OU=Test/CN=Test active + [id] msfuser test_realm2 /C=BE/O=Test/OU=Test/CN=Test /C=BE/O=Test/OU=Test/CN=Test active + TABLE + end + end + + context 'when the -i option is provided with 2 valid ids (quoted and comma + space separated)' do + it 'should show both matching Pkcs12' do + subject.cmd_certs('-i', "#{creds[0].id}, #{creds[1].id}") + expect(table_without_ids(@output.join("\n"))).to match_table <<~TABLE + Pkcs12 + ====== + id username realm subject issuer ADCS CA ADCS Template status + -- -------- ----- ------- ------ ------- ------------- ------ + [id] n00tmeg test_realm1 /C=BE/O=Test/OU=Test/CN=Test /C=BE/O=Test/OU=Test/CN=Test active + [id] msfuser test_realm2 /C=BE/O=Test/OU=Test/CN=Test /C=BE/O=Test/OU=Test/CN=Test active + TABLE + end + end + + context 'when the -i option is provided an invalid ID' do + it 'should show a warning and an empty list' do + subject.cmd_certs('-i', "#{creds.last.id + 1}") + expect(@combined_output.join("\n")).to match_table <<~TABLE + Not all records with the ids: ["#{creds.last.id + 1}"] could be found. + Please ensure all ids specified are available. + Pkcs12 + ====== + No Pkcs12 + TABLE + end + end + + context 'when the -v option is provided' do + let(:pkcs12_1) { OpenSSL::PKCS12.new(Base64.strict_decode64(creds[0].private.data), '') } + let(:pkcs12_2) { OpenSSL::PKCS12.new(Base64.strict_decode64(creds[1].private.data), '') } + + it 'should show the output given by OpenSSL::Pkcs12 for every Pkcs12' do + expected_cert1_output = "#{pkcs12_1.certificate.to_s.chomp}\n#{pkcs12_1.certificate.to_text.chomp}" + expected_cert2_output = "#{pkcs12_2.certificate.to_s.chomp}\n#{pkcs12_2.certificate.to_text.chomp}" + + subject.cmd_certs '-v' + expect(@output.join("\n")).to match_table <<~TABLE + Pkcs12 + ====== + Certificate[0]: + #{expected_cert1_output} + Certificate[1]: + #{expected_cert2_output} + TABLE + end + + context 'with a username' do + it 'should show the output given by OpenSSL::Pkcs12 for the matching Pkcs12' do + expected_cert1_output = "#{pkcs12_1.certificate.to_s.chomp}\n#{pkcs12_1.certificate.to_text.chomp}" + + subject.cmd_certs('-v', 'n00tmeg') + expect(@output.join("\n")).to match_table <<~TABLE + Pkcs12 + ====== + Certificate[0]: + #{expected_cert1_output} + TABLE + end + end + + context 'with the -i option and an ID' do + it 'should show the output given by OpenSSL::Pkcs12 for the matching Pkcs12' do + expected_cert2_output = "#{pkcs12_2.certificate.to_s.chomp}\n#{pkcs12_2.certificate.to_text.chomp}" + + subject.cmd_certs('-v', '-i', "#{creds[1].id}") + expect(@output.join("\n")).to match_table <<~TABLE + Pkcs12 + ====== + Certificate[0]: + #{expected_cert2_output} + TABLE + end + end + end + + context 'when the -d flag is provided' do + it 'should delete all the Pkcs12 and show the deleted entries' do + subject.cmd_certs('-d') + expect(table_without_ids(@output.join("\n"))).to match_table <<~TABLE + Pkcs12 + ====== + id username realm subject issuer ADCS CA ADCS Template status + -- -------- ----- ------- ------ ------- ------------- ------ + [id] n00tmeg test_realm1 /C=BE/O=Test/OU=Test/CN=Test /C=BE/O=Test/OU=Test/CN=Test active + [id] msfuser test_realm2 /C=BE/O=Test/OU=Test/CN=Test /C=BE/O=Test/OU=Test/CN=Test active + Deleted 2 entries + TABLE + expect(Msf::Exploit::Remote::Pkcs12::Storage.new(framework: framework).pkcs12).to be_empty + end + + context 'with a username' do + it 'should delete the matching Pkcs12 and show the single entry' do + subject.cmd_certs('-d', 'n00tmeg') + expect(table_without_ids(@output.join("\n"))).to match_table <<~TABLE + Pkcs12 + ====== + id username realm subject issuer ADCS CA ADCS Template status + -- -------- ----- ------- ------ ------- ------------- ------ + [id] n00tmeg test_realm1 /C=BE/O=Test/OU=Test/CN=Test /C=BE/O=Test/OU=Test/CN=Test active + Deleted 1 entry + TABLE + expect(Msf::Exploit::Remote::Pkcs12::Storage.new(framework: framework).pkcs12.size).to eq(creds.size - 1) + end + end + + context 'with the -i option and an ID' do + it 'should delete the matching Pkcs12 and show the single entry' do + subject.cmd_certs('-d', '-i', "#{creds[1].id}") + expect(table_without_ids(@output.join("\n"))).to match_table <<~TABLE + Pkcs12 + ====== + id username realm subject issuer ADCS CA ADCS Template status + -- -------- ----- ------- ------ ------- ------------- ------ + [id] msfuser test_realm2 /C=BE/O=Test/OU=Test/CN=Test /C=BE/O=Test/OU=Test/CN=Test active + Deleted 1 entry + TABLE + expect(Msf::Exploit::Remote::Pkcs12::Storage.new(framework: framework).pkcs12.size).to eq(creds.size - 1) + end + end + end + + context 'when the -A option is provided' do + it 'should deactivate all the Pkcs12 and show the deactivated entries' do + subject.cmd_certs('-A') + expect(table_without_ids(@output.join("\n"))).to match_table <<~TABLE + Pkcs12 + ====== + id username realm subject issuer ADCS CA ADCS Template status + -- -------- ----- ------- ------ ------- ------------- ------ + [id] n00tmeg test_realm1 /C=BE/O=Test/OU=Test/CN=Test /C=BE/O=Test/OU=Test/CN=Test inactive + [id] msfuser test_realm2 /C=BE/O=Test/OU=Test/CN=Test /C=BE/O=Test/OU=Test/CN=Test inactive + Deactivated 2 entries + TABLE + end + + context 'with a username' do + it 'should deactivate the matching Pkcs12 and show a single deactivated entry' do + subject.cmd_certs('-A', 'n00tmeg') + expect(table_without_ids(@output.join("\n"))).to match_table <<~TABLE + Pkcs12 + ====== + id username realm subject issuer ADCS CA ADCS Template status + -- -------- ----- ------- ------ ------- ------------- ------ + [id] n00tmeg test_realm1 /C=BE/O=Test/OU=Test/CN=Test /C=BE/O=Test/OU=Test/CN=Test inactive + Deactivated 1 entry + TABLE + end + end + + context 'with the -i option and an ID' do + it 'should deactivate the matching Pkcs12 and show a single deactivated entry' do + subject.cmd_certs('-A', '-i', "#{creds[1].id}") + expect(table_without_ids(@output.join("\n"))).to match_table <<~TABLE + Pkcs12 + ====== + id username realm subject issuer ADCS CA ADCS Template status + -- -------- ----- ------- ------ ------- ------------- ------ + [id] msfuser test_realm2 /C=BE/O=Test/OU=Test/CN=Test /C=BE/O=Test/OU=Test/CN=Test inactive + Deactivated 1 entry + TABLE + end + end + end + + context 'with a deactivated Pkcs12' do + before :each do + creds << FactoryBot.create( + :metasploit_credential_core, + public: FactoryBot.create(:metasploit_credential_username, username: username3), + realm: FactoryBot.create(:metasploit_credential_realm, value: realm3), + private: FactoryBot.create(:metasploit_credential_pkcs12_with_status, status: 'inactive'), + origin: origin + ) + end + + context 'when the -a option is provided' do + it 'should activate the deactivated Pkcs12 and show all the activated entries' do + subject.cmd_certs('-a') + expect(table_without_ids(@output.join("\n"))).to match_table <<~TABLE + Pkcs12 + ====== + id username realm subject issuer ADCS CA ADCS Template status + -- -------- ----- ------- ------ ------- ------------- ------ + [id] n00tmeg test_realm1 /C=BE/O=Test/OU=Test/CN=Test /C=BE/O=Test/OU=Test/CN=Test active + [id] msfuser test_realm2 /C=BE/O=Test/OU=Test/CN=Test /C=BE/O=Test/OU=Test/CN=Test active + [id] msftest test_realm3 /C=BE/O=Test/OU=Test/CN=Test /C=BE/O=Test/OU=Test/CN=Test active + Activated 3 entries + TABLE + end + end + + context 'with a username' do + it 'should activate the deactivated Pkcs12 and show the activated entry' do + subject.cmd_certs('-a', 'msftest') + expect(table_without_ids(@output.join("\n"))).to match_table <<~TABLE + Pkcs12 + ====== + id username realm subject issuer ADCS CA ADCS Template status + -- -------- ----- ------- ------ ------- ------------- ------ + [id] msftest test_realm3 /C=BE/O=Test/OU=Test/CN=Test /C=BE/O=Test/OU=Test/CN=Test active + Activated 1 entry + TABLE + end + end + + context 'with the -i option and an ID' do + it 'should activate the deactivated Pkcs12 and show the activated entry' do + subject.cmd_certs('-a', '-i', "#{creds.last.id}") + expect(table_without_ids(@output.join("\n"))).to match_table <<~TABLE + Pkcs12 + ====== + id username realm subject issuer ADCS CA ADCS Template status + -- -------- ----- ------- ------ ------- ------------- ------ + [id] msftest test_realm3 /C=BE/O=Test/OU=Test/CN=Test /C=BE/O=Test/OU=Test/CN=Test active + Activated 1 entry + TABLE + end + end + end + + context 'when the -e option is provided' do + context 'with a username that doesn\'t match any Pkcs12' do + it 'should return an error message' do + subject.cmd_certs('-e', 'path', 'non-existing-user') + expect(@error.join("\n")).to eq('No mathing Pkcs12 entry to export') + end + end + + context 'with more than one matching Pkcs12' do + it 'should return an error message' do + subject.cmd_certs('-e', 'path') + expect(@error.join("\n")).to eq('More than one mathing Pkcs12 entry found. Filter with `-i` and/or provide a username') + end + end + + context 'with one matching Pkcs12' do + it 'should export the matching Pkcs12 to the provided path' do + ::Tempfile.create do |file| + subject.cmd_certs('-e', file.path, 'n00tmeg') + expect(::File.binread(file.path)).to eq(Base64.strict_decode64(creds[0].private.data)) + end + end + end + end + + end + end +end