diff --git a/Gemfile.lock b/Gemfile.lock index 8c708c0467..24c9e87b7f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,61 +1,61 @@ GEM remote: https://rubygems.org/ specs: - activemodel (3.2.14) - activesupport (= 3.2.14) + activemodel (3.2.17) + activesupport (= 3.2.17) builder (~> 3.0.0) - activerecord (3.2.14) - activemodel (= 3.2.14) - activesupport (= 3.2.14) + activerecord (3.2.17) + activemodel (= 3.2.17) + activesupport (= 3.2.17) arel (~> 3.0.2) tzinfo (~> 0.3.29) - activesupport (3.2.14) + activesupport (3.2.17) i18n (~> 0.6, >= 0.6.4) multi_json (~> 1.0) - arel (3.0.2) + arel (3.0.3) bcrypt (3.1.7) builder (3.0.4) - database_cleaner (1.1.1) - diff-lcs (1.2.4) - factory_girl (4.2.0) + database_cleaner (1.2.0) + diff-lcs (1.2.5) + factory_girl (4.4.0) activesupport (>= 3.0.0) fivemat (1.2.1) - i18n (0.6.5) - json (1.8.0) + i18n (0.6.9) + json (1.8.1) metasploit_data_models (0.17.0) activerecord (>= 3.2.13) activesupport pg - mini_portile (0.5.1) - msgpack (0.5.5) + mini_portile (0.5.3) + msgpack (0.5.8) multi_json (1.0.4) network_interface (0.0.1) - nokogiri (1.6.0) + nokogiri (1.6.1) mini_portile (~> 0.5.0) packetfu (1.1.9) pcaprub (0.11.3) - pg (0.16.0) - rake (10.1.0) - redcarpet (3.0.0) + pg (0.17.1) + rake (10.3.1) + redcarpet (3.1.1) rkelly-remix (0.0.6) robots (0.10.1) rspec (2.14.1) rspec-core (~> 2.14.0) rspec-expectations (~> 2.14.0) rspec-mocks (~> 2.14.0) - rspec-core (2.14.5) - rspec-expectations (2.14.2) + rspec-core (2.14.8) + rspec-expectations (2.14.5) diff-lcs (>= 1.1.3, < 2.0) - rspec-mocks (2.14.3) - shoulda-matchers (2.3.0) + rspec-mocks (2.14.6) + shoulda-matchers (2.6.0) activesupport (>= 3.0.0) simplecov (0.5.4) multi_json (~> 1.0.3) simplecov-html (~> 0.5.3) simplecov-html (0.5.3) - timecop (0.6.3) - tzinfo (0.3.37) - yard (0.8.7) + timecop (0.7.1) + tzinfo (0.3.39) + yard (0.8.7.4) PLATFORMS ruby diff --git a/lib/metasploit/framework/login_scanner/ssh.rb b/lib/metasploit/framework/login_scanner/ssh.rb index 695243a118..e86855c5d3 100644 --- a/lib/metasploit/framework/login_scanner/ssh.rb +++ b/lib/metasploit/framework/login_scanner/ssh.rb @@ -38,6 +38,9 @@ module Metasploit # @!attribute port # @return [Fixnum] The port to connect to attr_accessor :port + # @!attribute proxies + # @return [String] The proxy directive to use for the socket + attr_accessor :proxies # @!attribute ssh_socket # @return [Net::SSH::Connection::Session] The current SSH connection attr_accessor :ssh_socket @@ -103,7 +106,8 @@ module Metasploit :disable_agent => true, :password => credential.private, :config => false, - :verbose => verbosity + :verbose => verbosity, + :proxies => proxies } result_options = { diff --git a/lib/metasploit/framework/login_scanner/ssh_key.rb b/lib/metasploit/framework/login_scanner/ssh_key.rb new file mode 100644 index 0000000000..8f867baed8 --- /dev/null +++ b/lib/metasploit/framework/login_scanner/ssh_key.rb @@ -0,0 +1,247 @@ +require 'net/ssh' +require 'metasploit/framework/login_scanner' + +module Metasploit + module Framework + module LoginScanner + + # This is the LoginScanner class for dealing with the Secure Shell protocol and PKI. + # It is responsible for taking a single target, and a list of credentials + # and attempting them. It then saves the results. In this case it is expecting + # SSH private keys for the private credential. + class SSHKey + include ActiveModel::Validations + + # + # CONSTANTS + # + + VERBOSITIES = [ + :debug, + :info, + :warn, + :error, + :fatal + ] + + # @!attribute connection_timeout + # @return [Fixnum] The timeout in seconds for a single SSH connection + attr_accessor :connection_timeout + # @!attribute cred_details + # @return [Array] An array of Credential objects + attr_accessor :cred_details + # @!attribute successes + # @return [Array] Array of of result objects that failed + attr_accessor :failures + # @!attribute host + # @return [String] The IP address or hostname to connect to + attr_accessor :host + # @!attribute port + # @return [Fixnum] The port to connect to + attr_accessor :port + # @!attribute proxies + # @return [String] The proxy directive to use for the socket + attr_accessor :proxies + # @!attribute ssh_socket + # @return [Net::SSH::Connection::Session] The current SSH connection + attr_accessor :ssh_socket + # @!attribute stop_on_success + # @return [Boolean] Whether the scanner should stop when it has found one working Credential + attr_accessor :stop_on_success + # @!attribute successes + # @return [Array] Array of results that successfully logged in + attr_accessor :successes + # @!attribute verbosity + # The verbosity level for the SSH client. + # + # @return [Symbol] An element of {VERBOSITIES}. + attr_accessor :verbosity + + validates :connection_timeout, + presence: true, + numericality: { + only_integer: true, + greater_than_or_equal_to: 1 + } + + validates :cred_details, presence: true + + validates :host, presence: true + + validates :port, + presence: true, + numericality: { + only_integer: true, + greater_than_or_equal_to: 1, + less_than_or_equal_to: 65535 + } + + validates :stop_on_success, + inclusion: { in: [true, false] } + + validates :verbosity, + presence: true, + inclusion: { in: VERBOSITIES } + + validate :host_address_must_be_valid + + validate :validate_cred_details + + # @param attributes [Hash{Symbol => String,nil}] + def initialize(attributes={}) + attributes.each do |attribute, value| + public_send("#{attribute}=", value) + end + self.successes= [] + self.failures=[] + end + + # This method attempts a single login with a single credential against the target + # @param credential [Credential] The credential object to attmpt to login with + # @return [Metasploit::Framework::LoginScanner::Result] The LoginScanner Result object + def attempt_login(credential) + ssh_socket = nil + opt_hash = { + :auth_methods => ['publickey'], + :port => port, + :disable_agent => true, + :key_data => credential.private, + :config => false, + :verbose => verbosity, + :proxies => proxies + } + + result_options = { + private: credential.private, + public: credential.public, + realm: nil + } + begin + ::Timeout.timeout(connection_timeout) do + ssh_socket = Net::SSH.start( + host, + credential.public, + opt_hash + ) + end + rescue ::EOFError, Net::SSH::Disconnect, Rex::AddressInUse, Rex::ConnectionError, ::Timeout::Error + result_options.merge!( proof: nil, status: :connection_error) + rescue Net::SSH::Exception + result_options.merge!( proof: nil, status: :failed) + end + + unless result_options.has_key? :status + if ssh_socket + proof = gather_proof + result_options.merge!( proof: proof, status: :success) + else + result_options.merge!( proof: nil, status: :failed) + end + end + + ::Metasploit::Framework::LoginScanner::Result.new(result_options) + + end + + # This method runs all the login attempts against the target. + # It calls {attempt_login} once for each credential. + # Results are stored in {successes} and {failures} + # @return [void] There is no valid return value for this method + # @yield [result] + # @yieldparam result [Metasploit::Framework::LoginScanner::Result] The LoginScanner Result object for the attempt + # @yieldreturn [void] + def scan! + valid! + cred_details.each do |credential| + result = attempt_login(credential) + result.freeze + + yield result if block_given? + + if result.success? + successes << result + break if stop_on_success + else + failures << result + end + end + end + + # @raise [Metasploit::Framework::LoginScanner::Invalid] if the attributes are not valid on the scanner + def valid! + unless valid? + raise Metasploit::Framework::LoginScanner::Invalid.new(self) + end + end + + private + + # This method attempts to gather proof that we successfuly logged in. + # @return [String] The proof of a connection, May be empty. + def gather_proof + proof = '' + begin + Timeout.timeout(5) do + proof = ssh_socket.exec!("id\n").to_s + if(proof =~ /id=/) + proof << ssh_socket.exec!("uname -a\n").to_s + else + # Cisco IOS + if proof =~ /Unknown command or computer name/ + proof = ssh_socket.exec!("ver\n").to_s + else + proof << ssh_socket.exec!("help\n?\n\n\n").to_s + end + end + end + rescue ::Exception + end + proof + end + + # This method validates that the host address is both + # of a valid type and is resolveable. + # @return [void] + def host_address_must_be_valid + unless host.kind_of? String + errors.add(:host, "must be a string") + end + begin + resolved_host = ::Rex::Socket.getaddress(host, true) + if host =~ /^\d{1,3}(\.\d{1,3}){1,3}$/ + unless host =~ Rex::Socket::MATCH_IPV4 + errors.add(:host, "could not be resolved") + end + end + host = resolved_host + rescue + errors.add(:host, "could not be resolved") + end + end + + # This method validates that the credentials supplied + # are all valid. + # @return [void] + def validate_cred_details + if cred_details.kind_of? Array + cred_details.each do |detail| + unless detail.kind_of? Metasploit::Framework::LoginScanner::Credential + errors.add(:cred_details, "has invalid element #{detail.inspect}") + next + end + unless detail.valid? + errors.add(:cred_details, "has invalid element #{detail.inspect}") + end + end + else + errors.add(:cred_details, "must be an array") + end + end + + + + end + + end + end +end diff --git a/spec/lib/metasploit/framework/login_scanner/ssh_key_spec.rb b/spec/lib/metasploit/framework/login_scanner/ssh_key_spec.rb new file mode 100644 index 0000000000..fdccd4c2ce --- /dev/null +++ b/spec/lib/metasploit/framework/login_scanner/ssh_key_spec.rb @@ -0,0 +1,437 @@ +require 'spec_helper' +require 'metasploit/framework/login_scanner/ssh_key' + +describe Metasploit::Framework::LoginScanner::SSHKey do + let(:public) { 'root' } + let(:private) { OpenSSL::PKey::RSA.generate(2048).to_s } + + let(:pub_pri) { + Metasploit::Framework::LoginScanner::Credential.new( + paired: true, + public: public, + private: private + ) + } + + let(:invalid_detail) { + Metasploit::Framework::LoginScanner::Credential.new( + paired: true, + public: nil, + private: nil + ) + } + + let(:detail_group) { + [ pub_pri] + } + + subject(:ssh_scanner) { + described_class.new + } + + it { should respond_to :port } + it { should respond_to :host } + it { should respond_to :cred_details } + it { should respond_to :connection_timeout } + it { should respond_to :verbosity } + it { should respond_to :stop_on_success } + it { should respond_to :valid! } + it { should respond_to :scan! } + it { should respond_to :proxies } + + + context 'validations' do + context 'port' do + + it 'is not valid for not set' do + expect(ssh_scanner).to_not be_valid + expect(ssh_scanner.errors[:port]).to include "is not a number" + end + + it 'is not valid for a non-number' do + ssh_scanner.port = "a" + expect(ssh_scanner).to_not be_valid + expect(ssh_scanner.errors[:port]).to include "is not a number" + end + + it 'is not valid for a floating point' do + ssh_scanner.port = 5.76 + expect(ssh_scanner).to_not be_valid + expect(ssh_scanner.errors[:port]).to include "must be an integer" + end + + it 'is not valid for a negative number' do + ssh_scanner.port = -8 + expect(ssh_scanner).to_not be_valid + expect(ssh_scanner.errors[:port]).to include "must be greater than or equal to 1" + end + + it 'is not valid for 0' do + ssh_scanner.port = 0 + expect(ssh_scanner).to_not be_valid + expect(ssh_scanner.errors[:port]).to include "must be greater than or equal to 1" + end + + it 'is not valid for a number greater than 65535' do + ssh_scanner.port = rand(1000) + 65535 + expect(ssh_scanner).to_not be_valid + expect(ssh_scanner.errors[:port]).to include "must be less than or equal to 65535" + end + + it 'is valid for a legitimate port number' do + ssh_scanner.port = rand(65534) + 1 + expect(ssh_scanner.errors[:port]).to be_empty + end + end + + context 'host' do + + it 'is not valid for not set' do + expect(ssh_scanner).to_not be_valid + expect(ssh_scanner.errors[:host]).to include "can't be blank" + end + + it 'is not valid for a non-string input' do + ssh_scanner.host = 5 + expect(ssh_scanner).to_not be_valid + expect(ssh_scanner.errors[:host]).to include "must be a string" + end + + it 'is not valid for an improper IP address' do + ssh_scanner.host = '192.168.1.1.5' + expect(ssh_scanner).to_not be_valid + expect(ssh_scanner.errors[:host]).to include "could not be resolved" + end + + it 'is not valid for an incomplete IP address' do + ssh_scanner.host = '192.168' + expect(ssh_scanner).to_not be_valid + expect(ssh_scanner.errors[:host]).to include "could not be resolved" + end + + it 'is not valid for an invalid IP address' do + ssh_scanner.host = '192.300.675.123' + expect(ssh_scanner).to_not be_valid + expect(ssh_scanner.errors[:host]).to include "could not be resolved" + end + + it 'is not valid for DNS name that cannot be resolved' do + ssh_scanner.host = 'nosuchplace.metasploit.com' + expect(ssh_scanner).to_not be_valid + expect(ssh_scanner.errors[:host]).to include "could not be resolved" + end + + it 'is valid for a valid IP address' do + ssh_scanner.host = '127.0.0.1' + expect(ssh_scanner.errors[:host]).to be_empty + end + + it 'is valid for a DNS name it can resolve' do + ssh_scanner.host = 'localhost' + expect(ssh_scanner.errors[:host]).to be_empty + end + end + + context 'cred_details' do + it 'is not valid for not set' do + expect(ssh_scanner).to_not be_valid + expect(ssh_scanner.errors[:cred_details]).to include "can't be blank" + end + + it 'is not valid for a non-array input' do + ssh_scanner.cred_details = rand(10) + expect(ssh_scanner).to_not be_valid + expect(ssh_scanner.errors[:cred_details]).to include "must be an array" + end + + it 'is not valid if any of the elements are not a Credential' do + ssh_scanner.cred_details = [1,2] + expect(ssh_scanner).to_not be_valid + expect(ssh_scanner.errors[:cred_details]).to include "has invalid element 1" + end + + it 'is not valid if any of the CredDetails are invalid' do + ssh_scanner.cred_details = [pub_pri, invalid_detail] + expect(ssh_scanner).to_not be_valid + end + + it 'is valid if all of the elements are valid' do + ssh_scanner.cred_details = detail_group + expect(ssh_scanner.errors[:cred_details]).to be_empty + end + end + + context 'connection_timeout' do + + it 'is not valid for not set' do + expect(ssh_scanner).to_not be_valid + expect(ssh_scanner.errors[:connection_timeout]).to include "is not a number" + end + + it 'is not valid for a non-number' do + ssh_scanner.connection_timeout = "a" + expect(ssh_scanner).to_not be_valid + expect(ssh_scanner.errors[:connection_timeout]).to include "is not a number" + end + + it 'is not valid for a floating point' do + ssh_scanner.connection_timeout = 5.76 + expect(ssh_scanner).to_not be_valid + expect(ssh_scanner.errors[:connection_timeout]).to include "must be an integer" + end + + it 'is not valid for a negative number' do + ssh_scanner.connection_timeout = -8 + expect(ssh_scanner).to_not be_valid + expect(ssh_scanner.errors[:connection_timeout]).to include "must be greater than or equal to 1" + end + + it 'is not valid for 0' do + ssh_scanner.connection_timeout = 0 + expect(ssh_scanner).to_not be_valid + expect(ssh_scanner.errors[:connection_timeout]).to include "must be greater than or equal to 1" + end + + it 'is valid for a legitimate number' do + ssh_scanner.port = rand(1000) + 1 + expect(ssh_scanner.errors[:connection_timeout]).to be_empty + end + end + + context 'verbosity' do + + it 'is valid with :debug' do + ssh_scanner.verbosity = :debug + expect(ssh_scanner.errors[:verbosity]).to be_empty + end + + it 'is valid with :info' do + ssh_scanner.verbosity = :info + expect(ssh_scanner.errors[:verbosity]).to be_empty + end + + it 'is valid with :warn' do + ssh_scanner.verbosity = :warn + expect(ssh_scanner.errors[:verbosity]).to be_empty + end + + it 'is valid with :error' do + ssh_scanner.verbosity = :error + expect(ssh_scanner.errors[:verbosity]).to be_empty + end + + it 'is valid with :fatal' do + ssh_scanner.verbosity = :fatal + expect(ssh_scanner.errors[:verbosity]).to be_empty + end + + it 'is invalid with a random symbol' do + ssh_scanner.verbosity = :foobar + expect(ssh_scanner).to_not be_valid + expect(ssh_scanner.errors[:verbosity]).to include 'is not included in the list' + end + + it 'is invalid with a string' do + ssh_scanner.verbosity = 'debug' + expect(ssh_scanner).to_not be_valid + expect(ssh_scanner.errors[:verbosity]).to include 'is not included in the list' + end + end + + context 'stop_on_success' do + + it 'is not valid for not set' do + expect(ssh_scanner).to_not be_valid + expect(ssh_scanner.errors[:stop_on_success]).to include 'is not included in the list' + end + + it 'is not valid for the string true' do + ssh_scanner.stop_on_success = 'true' + expect(ssh_scanner).to_not be_valid + expect(ssh_scanner.errors[:stop_on_success]).to include 'is not included in the list' + end + + it 'is not valid for the string false' do + ssh_scanner.stop_on_success = 'false' + expect(ssh_scanner).to_not be_valid + expect(ssh_scanner.errors[:stop_on_success]).to include 'is not included in the list' + end + + it 'is valid for true class' do + ssh_scanner.stop_on_success = true + expect(ssh_scanner.errors[:stop_on_success]).to be_empty + end + + it 'is valid for false class' do + ssh_scanner.stop_on_success = false + expect(ssh_scanner.errors[:stop_on_success]).to be_empty + end + end + + context '#valid!' do + it 'raises a Metasploit::Framework::LoginScanner::Invalid when validations fail' do + expect{ssh_scanner.valid!}.to raise_error Metasploit::Framework::LoginScanner::Invalid + end + end + end + + context '#attempt_login' do + before(:each) do + ssh_scanner.host = '127.0.0.1' + ssh_scanner.port = 22 + ssh_scanner.connection_timeout = 30 + ssh_scanner.verbosity = :fatal + ssh_scanner.stop_on_success = true + ssh_scanner.cred_details = detail_group + end + + it 'creates a Timeout based on the connection_timeout' do + ::Timeout.should_receive(:timeout).with(ssh_scanner.connection_timeout) + ssh_scanner.attempt_login(pub_pri) + end + + it 'calls Net::SSH with the correct arguments' do + opt_hash = { + :auth_methods => ['publickey'], + :port => ssh_scanner.port, + :disable_agent => true, + :key_data => private, + :config => false, + :verbose => ssh_scanner.verbosity, + :proxies => nil + } + Net::SSH.should_receive(:start).with( + ssh_scanner.host, + public, + opt_hash + ) + ssh_scanner.attempt_login(pub_pri) + end + + context 'when it fails' do + + it 'returns :connection_error for a Rex::ConnectionError' do + Net::SSH.should_receive(:start) { raise Rex::ConnectionError } + expect(ssh_scanner.attempt_login(pub_pri).status).to eq :connection_error + end + + it 'returns :connection_error for a Rex::AddressInUse' do + Net::SSH.should_receive(:start) { raise Rex::AddressInUse } + expect(ssh_scanner.attempt_login(pub_pri).status).to eq :connection_error + end + + it 'returns :connection_disconnect for a Net::SSH::Disconnect' do + Net::SSH.should_receive(:start) { raise Net::SSH::Disconnect } + expect(ssh_scanner.attempt_login(pub_pri).status).to eq :connection_error + end + + it 'returns :connection_disconnect for a ::EOFError' do + Net::SSH.should_receive(:start) { raise ::EOFError } + expect(ssh_scanner.attempt_login(pub_pri).status).to eq :connection_error + end + + it 'returns :connection_disconnect for a ::Timeout::Error' do + Net::SSH.should_receive(:start) { raise ::Timeout::Error } + expect(ssh_scanner.attempt_login(pub_pri).status).to eq :connection_error + end + + it 'returns [:fail,nil] for a Net::SSH::Exception' do + Net::SSH.should_receive(:start) { raise Net::SSH::Exception } + expect(ssh_scanner.attempt_login(pub_pri).status).to eq :failed + end + + it 'returns [:fail,nil] if no socket returned' do + Net::SSH.should_receive(:start).and_return nil + expect(ssh_scanner.attempt_login(pub_pri).status).to eq :failed + end + end + + context 'when it succeeds' do + + it 'gathers proof of the connections' do + Net::SSH.should_receive(:start) {"fake_socket"} + my_scanner = ssh_scanner + my_scanner.should_receive(:gather_proof) + my_scanner.attempt_login(pub_pri) + end + + it 'returns a success code and proof' do + Net::SSH.should_receive(:start) {"fake_socket"} + my_scanner = ssh_scanner + my_scanner.should_receive(:gather_proof).and_return(public) + expect(my_scanner.attempt_login(pub_pri).status).to eq :success + end + end + end + + context '#scan!' do + let(:success) { + ::Metasploit::Framework::LoginScanner::Result.new( + private: public, + proof: '', + public: private, + realm: nil, + status: :success + ) + } + + let(:failure) { + ::Metasploit::Framework::LoginScanner::Result.new( + private: private, + proof: nil, + public: public, + realm: nil, + status: :failed + ) + } + + before(:each) do + ssh_scanner.host = '127.0.0.1' + ssh_scanner.port = 22 + ssh_scanner.connection_timeout = 30 + ssh_scanner.verbosity = :fatal + ssh_scanner.stop_on_success = false + ssh_scanner.cred_details = detail_group + end + + it 'calls valid! before running' do + my_scanner = ssh_scanner + my_scanner.should_receive(:scan!).and_call_original + my_scanner.scan! + end + + it 'call attempt_login once for each cred_detail' do + my_scanner = ssh_scanner + my_scanner.should_receive(:attempt_login).once.with(pub_pri).and_call_original + my_scanner.scan! + end + + it 'adds the failed results to the failures attribute' do + my_scanner = ssh_scanner + my_scanner.should_receive(:attempt_login).once.with(pub_pri).and_return failure + my_scanner.scan! + expect(my_scanner.failures).to include failure + end + + it 'adds the success results to the successes attribute' do + my_scanner = ssh_scanner + my_scanner.should_receive(:attempt_login).once.with(pub_pri).and_return success + my_scanner.scan! + expect(my_scanner.successes).to include success + end + + context 'when stop_on_success is true' do + before(:each) do + ssh_scanner.host = '127.0.0.1' + ssh_scanner.port = 22 + ssh_scanner.connection_timeout = 30 + ssh_scanner.verbosity = :fatal + ssh_scanner.stop_on_success = true + ssh_scanner.cred_details = detail_group + end + + end + + end + +end \ No newline at end of file diff --git a/spec/lib/metasploit/framework/login_scanner/ssh_spec.rb b/spec/lib/metasploit/framework/login_scanner/ssh_spec.rb index 2aa7327b9b..3a38fe8dc4 100644 --- a/spec/lib/metasploit/framework/login_scanner/ssh_spec.rb +++ b/spec/lib/metasploit/framework/login_scanner/ssh_spec.rb @@ -53,6 +53,7 @@ describe Metasploit::Framework::LoginScanner::SSH do it { should respond_to :stop_on_success } it { should respond_to :valid! } it { should respond_to :scan! } + it { should respond_to :proxies } context 'validations' do @@ -312,7 +313,8 @@ describe Metasploit::Framework::LoginScanner::SSH do :disable_agent => true, :password => private, :config => false, - :verbose => ssh_scanner.verbosity + :verbose => ssh_scanner.verbosity, + :proxies => nil } Net::SSH.should_receive(:start).with( ssh_scanner.host,