From 1c8c3dd675e2a25784a291cf9e5923de583088d0 Mon Sep 17 00:00:00 2001 From: Spencer McIntyre Date: Tue, 4 Aug 2020 18:12:09 -0400 Subject: [PATCH 1/7] Add a Jupyter notebook / lab login scanner --- .../framework/community_string_collection.rb | 2 +- .../framework/credential_collection.rb | 463 ++++++++++-------- .../framework/login_scanner/jupyter.rb | 60 +++ .../auxiliary/scanner/http/jupyter_login.rb | 112 +++++ 4 files changed, 434 insertions(+), 203 deletions(-) create mode 100644 lib/metasploit/framework/login_scanner/jupyter.rb create mode 100644 modules/auxiliary/scanner/http/jupyter_login.rb diff --git a/lib/metasploit/framework/community_string_collection.rb b/lib/metasploit/framework/community_string_collection.rb index 071fbc92f7..eb27e6d35b 100644 --- a/lib/metasploit/framework/community_string_collection.rb +++ b/lib/metasploit/framework/community_string_collection.rb @@ -6,7 +6,7 @@ module Metasploit # This class is responsible for taking datastore options from the snmp_login module # and yielding appropriate {Metasploit::Framework::Credential}s to the {Metasploit::Framework::LoginScanner::SNMP}. # This one has to be different from credentialCollection as it will only have a {Metasploit::Framework::Credential#public} - # It may be slightly confusing that the attribues are called password and pass_file, because this is what the legacy + # It may be slightly confusing that the attributes are called password and pass_file, because this is what the legacy # module used. However, community Strings are now considered more to be public credentials than private ones. class CommunityStringCollection # @!attribute pass_file diff --git a/lib/metasploit/framework/credential_collection.rb b/lib/metasploit/framework/credential_collection.rb index 1ddbdd2d73..cdd63fe3d9 100644 --- a/lib/metasploit/framework/credential_collection.rb +++ b/lib/metasploit/framework/credential_collection.rb @@ -1,233 +1,292 @@ require 'metasploit/framework/credential' -class Metasploit::Framework::CredentialCollection +module Metasploit::Framework - # @!attribute additional_privates - # Additional privates to be combined - # - # @return [Array] - attr_accessor :additional_privates + class PrivateCredentialCollection + # @!attribute additional_privates + # Additional privates to be combined + # + # @return [Array] + attr_accessor :additional_privates - # @!attribute additional_publics - # Additional public to be combined - # - # @return [Array] - attr_accessor :additional_publics + # @!attribute blank_passwords + # Whether each username should be tried with a blank password + # @return [Boolean] + attr_accessor :blank_passwords - # @!attribute blank_passwords - # Whether each username should be tried with a blank password - # @return [Boolean] - attr_accessor :blank_passwords + # @!attribute pass_file + # Path to a file containing passwords, one per line + # @return [String] + attr_accessor :pass_file - # @!attribute pass_file - # Path to a file containing passwords, one per line - # @return [String] - attr_accessor :pass_file + # @!attribute password + # @return [String] + attr_accessor :password - # @!attribute password - # @return [String] - attr_accessor :password + # @!attribute prepended_creds + # List of credentials to be tried before any others + # + # @see #prepend_cred + # @return [Array] + attr_accessor :prepended_creds - # @!attribute prepended_creds - # List of credentials to be tried before any others - # - # @see #prepend_cred - # @return [Array] - attr_accessor :prepended_creds + # @!attribute realm + # @return [String] + attr_accessor :realm - # @!attribute realm - # @return [String] - attr_accessor :realm - - # @!attribute user_as_pass - # Whether each username should be tried as a password for that user - # @return [Boolean] - attr_accessor :user_as_pass - - # @!attribute user_file - # Path to a file containing usernames, one per line - # @return [String] - attr_accessor :user_file - - # @!attribute username - # @return [String] - attr_accessor :username - - # @!attribute userpass_file - # Path to a file containing usernames and passwords separated by a space, - # one pair per line - # @return [String] - attr_accessor :userpass_file - - # @option opts [Boolean] :blank_passwords See {#blank_passwords} - # @option opts [String] :pass_file See {#pass_file} - # @option opts [String] :password See {#password} - # @option opts [Array] :prepended_creds ([]) See {#prepended_creds} - # @option opts [Boolean] :user_as_pass See {#user_as_pass} - # @option opts [String] :user_file See {#user_file} - # @option opts [String] :username See {#username} - # @option opts [String] :userpass_file See {#userpass_file} - def initialize(opts = {}) - opts.each do |attribute, value| - public_send("#{attribute}=", value) - end - self.prepended_creds ||= [] - self.additional_privates ||= [] - self.additional_publics ||= [] - end - - # Adds a string as an addition private credential - # to be combined in the collection. - # - # @param [String] private_str the string to use as a private - # @return [void] - def add_private(private_str='') - additional_privates << private_str - end - - # Adds a string as an addition public credential - # to be combined in the collection. - # - # @param [String] public_str the string to use as a public - # @return [void] - def add_public(public_str='') - additional_publics << public_str - end - - # Add {Credential credentials} that will be yielded by {#each} - # - # @see prepended_creds - # @param cred [Credential] - # @return [self] - def prepend_cred(cred) - prepended_creds.unshift cred - self - end - - # Combines all the provided credential sources into a stream of {Credential} - # objects, yielding them one at a time - # - # @yieldparam credential [Metasploit::Framework::Credential] - # @return [void] - def each - if pass_file.present? - pass_fd = File.open(pass_file, 'r:binary') - end - - prepended_creds.each { |c| yield c } - - if username.present? - if password.present? - yield Metasploit::Framework::Credential.new(public: username, private: password, realm: realm, private_type: private_type(password)) - end - if user_as_pass - yield Metasploit::Framework::Credential.new(public: username, private: username, realm: realm, private_type: :password) - end - if blank_passwords - yield Metasploit::Framework::Credential.new(public: username, private: "", realm: realm, private_type: :password) - end - if pass_fd - pass_fd.each_line do |pass_from_file| - pass_from_file.chomp! - yield Metasploit::Framework::Credential.new(public: username, private: pass_from_file, realm: realm, private_type: private_type(pass_from_file)) + # @option opts [Boolean] :blank_passwords See {#blank_passwords} + # @option opts [String] :pass_file See {#pass_file} + # @option opts [String] :password See {#password} + # @option opts [Array] :prepended_creds ([]) See {#prepended_creds} + # @option opts [Boolean] :user_as_pass See {#user_as_pass} + # @option opts [String] :user_file See {#user_file} + # @option opts [String] :username See {#username} + # @option opts [String] :userpass_file See {#userpass_file} + def initialize(opts = {}) + opts.each do |attribute, value| + public_send("#{attribute}=", value) end - pass_fd.seek(0) + self.prepended_creds ||= [] + self.additional_privates ||= [] end - additional_privates.each do |add_private| - yield Metasploit::Framework::Credential.new(public: username, private: add_private, realm: realm, private_type: private_type(add_private)) + + # Adds a string as an addition private credential + # to be combined in the collection. + # + # @param [String] private_str the string to use as a private + # @return [void] + def add_private(private_str='') + additional_privates << private_str + end + + # Add {Credential credentials} that will be yielded by {#each} + # + # @see prepended_creds + # @param cred [Credential] + # @return [self] + def prepend_cred(cred) + prepended_creds.unshift cred + self + end + + # Combines all the provided credential sources into a stream of {Credential} + # objects, yielding them one at a time + # + # @yieldparam credential [Metasploit::Framework::Credential] + # @return [void] + def each + if pass_file.present? + pass_fd = File.open(pass_file, 'r:binary') + end + + prepended_creds.each { |c| yield c } + + if password.present? + yield Metasploit::Framework::Credential.new(private: password, realm: realm, private_type: private_type(password)) + end + if blank_passwords + yield Metasploit::Framework::Credential.new(private: "", realm: realm, private_type: :password) + end + if pass_fd + pass_fd.each_line do |pass_from_file| + pass_from_file.chomp! + yield Metasploit::Framework::Credential.new(private: pass_from_file, realm: realm, private_type: private_type(pass_from_file)) + end + pass_fd.seek(0) + end + additional_privates.each do |add_private| + yield Metasploit::Framework::Credential.new(private: add_private, realm: realm, private_type: private_type(add_private)) + end + + ensure + pass_fd.close if pass_fd && !pass_fd.closed? + end + + # Returns true when #each will have no results to iterate + def empty? + prepended_creds.empty? && !has_privates? + end + + def has_privates? + password.present? || pass_file.present? || !additional_privates.empty? || blank_passwords + end + + protected + + def private_type(private) + if private =~ /[0-9a-f]{32}:[0-9a-f]{32}/ + :ntlm_hash + elsif private =~ /^md5([a-f0-9]{32})$/ + :postgres_md5 + else + :password + end end end - if user_file.present? - File.open(user_file, 'r:binary') do |user_fd| - user_fd.each_line do |user_from_file| - user_from_file.chomp! - if password.present? - yield Metasploit::Framework::Credential.new(public: user_from_file, private: password, realm: realm, private_type: private_type(password) ) + class CredentialCollection < PrivateCredentialCollection + + # @!attribute additional_publics + # Additional public to be combined + # + # @return [Array] + attr_accessor :additional_publics + + # @!attribute user_as_pass + # Whether each username should be tried as a password for that user + # @return [Boolean] + attr_accessor :user_as_pass + + # @!attribute user_file + # Path to a file containing usernames, one per line + # @return [String] + attr_accessor :user_file + + # @!attribute username + # @return [String] + attr_accessor :username + + # @!attribute userpass_file + # Path to a file containing usernames and passwords separated by a space, + # one pair per line + # @return [String] + attr_accessor :userpass_file + + # @option opts [Boolean] :blank_passwords See {#blank_passwords} + # @option opts [String] :pass_file See {#pass_file} + # @option opts [String] :password See {#password} + # @option opts [Array] :prepended_creds ([]) See {#prepended_creds} + # @option opts [Boolean] :user_as_pass See {#user_as_pass} + # @option opts [String] :user_file See {#user_file} + # @option opts [String] :username See {#username} + # @option opts [String] :userpass_file See {#userpass_file} + def initialize(opts = {}) + super + self.additional_publics ||= [] + end + + # Adds a string as an addition public credential + # to be combined in the collection. + # + # @param [String] public_str the string to use as a public + # @return [void] + def add_public(public_str='') + additional_publics << public_str + end + + # Combines all the provided credential sources into a stream of {Credential} + # objects, yielding them one at a time + # + # @yieldparam credential [Metasploit::Framework::Credential] + # @return [void] + def each + if pass_file.present? + pass_fd = File.open(pass_file, 'r:binary') + end + + prepended_creds.each { |c| yield c } + + if username.present? + if password.present? + yield Metasploit::Framework::Credential.new(public: username, private: password, realm: realm, private_type: private_type(password)) + end + if user_as_pass + yield Metasploit::Framework::Credential.new(public: username, private: username, realm: realm, private_type: :password) + end + if blank_passwords + yield Metasploit::Framework::Credential.new(public: username, private: "", realm: realm, private_type: :password) + end + if pass_fd + pass_fd.each_line do |pass_from_file| + pass_from_file.chomp! + yield Metasploit::Framework::Credential.new(public: username, private: pass_from_file, realm: realm, private_type: private_type(pass_from_file)) end - if user_as_pass - yield Metasploit::Framework::Credential.new(public: user_from_file, private: user_from_file, realm: realm, private_type: :password) - end - if blank_passwords - yield Metasploit::Framework::Credential.new(public: user_from_file, private: "", realm: realm, private_type: :password) - end - if pass_fd - pass_fd.each_line do |pass_from_file| - pass_from_file.chomp! - yield Metasploit::Framework::Credential.new(public: user_from_file, private: pass_from_file, realm: realm, private_type: private_type(pass_from_file)) + pass_fd.seek(0) + end + additional_privates.each do |add_private| + yield Metasploit::Framework::Credential.new(public: username, private: add_private, realm: realm, private_type: private_type(add_private)) + end + end + + if user_file.present? + File.open(user_file, 'r:binary') do |user_fd| + user_fd.each_line do |user_from_file| + user_from_file.chomp! + if password.present? + yield Metasploit::Framework::Credential.new(public: user_from_file, private: password, realm: realm, private_type: private_type(password) ) + end + if user_as_pass + yield Metasploit::Framework::Credential.new(public: user_from_file, private: user_from_file, realm: realm, private_type: :password) + end + if blank_passwords + yield Metasploit::Framework::Credential.new(public: user_from_file, private: "", realm: realm, private_type: :password) + end + if pass_fd + pass_fd.each_line do |pass_from_file| + pass_from_file.chomp! + yield Metasploit::Framework::Credential.new(public: user_from_file, private: pass_from_file, realm: realm, private_type: private_type(pass_from_file)) + end + pass_fd.seek(0) + end + additional_privates.each do |add_private| + yield Metasploit::Framework::Credential.new(public: user_from_file, private: add_private, realm: realm, private_type: private_type(add_private)) end - pass_fd.seek(0) - end - additional_privates.each do |add_private| - yield Metasploit::Framework::Credential.new(public: user_from_file, private: add_private, realm: realm, private_type: private_type(add_private)) end end end - end - if userpass_file.present? - File.open(userpass_file, 'r:binary') do |userpass_fd| - userpass_fd.each_line do |line| - user, pass = line.split(" ", 2) - if pass.blank? - pass = '' - else - pass.chomp! + if userpass_file.present? + File.open(userpass_file, 'r:binary') do |userpass_fd| + userpass_fd.each_line do |line| + user, pass = line.split(" ", 2) + if pass.blank? + pass = '' + else + pass.chomp! + end + yield Metasploit::Framework::Credential.new(public: user, private: pass, realm: realm) end - yield Metasploit::Framework::Credential.new(public: user, private: pass, realm: realm) end end - end - additional_publics.each do |add_public| - if password.present? - yield Metasploit::Framework::Credential.new(public: add_public, private: password, realm: realm, private_type: private_type(password) ) - end - if user_as_pass - yield Metasploit::Framework::Credential.new(public: add_public, private: user_from_file, realm: realm, private_type: :password) - end - if blank_passwords - yield Metasploit::Framework::Credential.new(public: add_public, private: "", realm: realm, private_type: :password) - end - if pass_fd - pass_fd.each_line do |pass_from_file| - pass_from_file.chomp! - yield Metasploit::Framework::Credential.new(public: add_public, private: pass_from_file, realm: realm, private_type: private_type(pass_from_file)) + additional_publics.each do |add_public| + if password.present? + yield Metasploit::Framework::Credential.new(public: add_public, private: password, realm: realm, private_type: private_type(password) ) + end + if user_as_pass + yield Metasploit::Framework::Credential.new(public: add_public, private: user_from_file, realm: realm, private_type: :password) + end + if blank_passwords + yield Metasploit::Framework::Credential.new(public: add_public, private: "", realm: realm, private_type: :password) + end + if pass_fd + pass_fd.each_line do |pass_from_file| + pass_from_file.chomp! + yield Metasploit::Framework::Credential.new(public: add_public, private: pass_from_file, realm: realm, private_type: private_type(pass_from_file)) + end + pass_fd.seek(0) + end + additional_privates.each do |add_private| + yield Metasploit::Framework::Credential.new(public: add_public, private: add_private, realm: realm, private_type: private_type(add_private)) end - pass_fd.seek(0) - end - additional_privates.each do |add_private| - yield Metasploit::Framework::Credential.new(public: add_public, private: add_private, realm: realm, private_type: private_type(add_private)) end + + ensure + pass_fd.close if pass_fd && !pass_fd.closed? end - ensure - pass_fd.close if pass_fd && !pass_fd.closed? - end - - # Returns true when #each will have no results to iterate - def empty? - prepended_creds.empty? && !has_users? || (has_users? && !has_privates?) - end - - def has_users? - username.present? || user_file.present? || userpass_file.present? || !additional_publics.empty? - end - - def has_privates? - password.present? || pass_file.present? || userpass_file.present? || !additional_privates.empty? || blank_passwords || user_as_pass - end - - private - - def private_type(private) - if private =~ /[0-9a-f]{32}:[0-9a-f]{32}/ - :ntlm_hash - elsif private =~ /^md5([a-f0-9]{32})$/ - :postgres_md5 - else - :password + # Returns true when #each will have no results to iterate + def empty? + prepended_creds.empty? && !has_users? || (has_users? && !has_privates?) end - end + def has_users? + username.present? || user_file.present? || userpass_file.present? || !additional_publics.empty? + end + + def has_privates? + super || userpass_file.present? || user_as_pass + end + + end end diff --git a/lib/metasploit/framework/login_scanner/jupyter.rb b/lib/metasploit/framework/login_scanner/jupyter.rb new file mode 100644 index 0000000000..18ad394512 --- /dev/null +++ b/lib/metasploit/framework/login_scanner/jupyter.rb @@ -0,0 +1,60 @@ +require 'metasploit/framework/login_scanner/http' + +module Metasploit + module Framework + module LoginScanner + + # Jupyter login scanner + class Jupyter < HTTP + + # Inherit LIKELY_PORTS,LIKELY_SERVICE_NAMES, and REALM_KEY from HTTP + CAN_GET_SESSION = true + DEFAULT_PORT = 8888 + PRIVATE_TYPES = [ :password ] + + # (see Base#set_sane_defaults) + def set_sane_defaults + self.uri = '/login' if self.uri.nil? + self.method = 'POST' if self.method.nil? + + super + end + + def attempt_login(credential) + result_opts = { + credential: credential, + host: host, + port: port, + protocol: 'tcp', + service_name: ssl ? 'https' : 'http' + } + + begin + res = send_request({'method'=> 'GET', 'uri' => uri}) + vars_post = {'password' => credential.private } + unless (node = res.get_html_document.xpath('//form//input[@name="_xsrf"]')).empty? + # versions < 4.3.1 do not use this field + vars_post['_xsrf'] = node.first['value'] + end + + res = send_request({ + 'method' => 'POST', + 'uri' => uri, + 'cookie' => res.get_cookies, + 'vars_post' => vars_post + }) + + if res&.code == 302 && res.headers['Location'] + result_opts.merge!(status: Metasploit::Model::Login::Status::SUCCESSFUL, proof: res.headers) + else + result_opts.merge!(status: Metasploit::Model::Login::Status::INCORRECT, proof: res) + end + rescue ::EOFError, Errno::ETIMEDOUT ,Errno::ECONNRESET, Rex::ConnectionError, OpenSSL::SSL::SSLError, ::Timeout::Error => e + result_opts.merge!(status: Metasploit::Model::Login::Status::UNABLE_TO_CONNECT, proof: e) + end + Result.new(result_opts) + end + end + end + end +end diff --git a/modules/auxiliary/scanner/http/jupyter_login.rb b/modules/auxiliary/scanner/http/jupyter_login.rb new file mode 100644 index 0000000000..d10db839e9 --- /dev/null +++ b/modules/auxiliary/scanner/http/jupyter_login.rb @@ -0,0 +1,112 @@ +## +# This module requires Metasploit: https://metasploit.com/download +# Current source: https://github.com/rapid7/metasploit-framework +## + +require 'metasploit/framework/credential_collection' +require 'metasploit/framework/login_scanner/jupyter' + +class MetasploitModule < Msf::Auxiliary + include Msf::Auxiliary::Scanner + include Msf::Exploit::Remote::HttpClient + include Msf::Auxiliary::Report + include Msf::Auxiliary::AuthBrute + + def initialize + super( + 'Name' => 'Jupyter Login Utility', + 'Description' => %q{ + This module checks if authentication is required on a Jupyter Lab or Notebook server. If it is, this module will + bruteforce the password. Jupyter only requires a password to authenticate, usernames are not used. This module + is compatible with versions 4.3.0 (released 2016-12-08) and newer. + }, + 'Author' => [ 'Spencer McIntyre' ], + 'License' => MSF_LICENSE + ) + + register_options( + [ + OptString.new('TARGETURI', [ true, 'The path to the Jupyter application', '/' ]), + Opt::RPORT(8888) + ]) + + deregister_options('PASSWORD_SPRAY') + deregister_options('DB_ALL_CREDS', 'DB_ALL_USERS', 'HttpUsername', 'STOP_ON_SUCCESS', 'USERNAME', 'USERPASS_FILE', 'USER_AS_PASS', 'USER_FILE') + + register_autofilter_ports([ 80, 443, 8888 ]) + end + + def requires_password?(ip) + res = send_request_cgi({ + 'method' => 'GET', + 'uri' => normalize_uri(target_uri.path, 'tree') + }) + + return false if res&.code == 200 + destination = res.headers['Location'].split('?', 2)[0] + return true if destination.ends_with?(normalize_uri(target_uri.path, 'login')) + fail_with(Failure::UnexpectedReply, "#{peer} - The server responded with a redirect that did not match a known fingerprint") + end + + def run_host(ip) + res = send_request_cgi({ + 'method' => 'GET', + 'uri' => normalize_uri(target_uri.path, 'api') + }) + version = res.get_json_document.dig('version') + if version.nil? + print_error "#{peer} - The server does not appear to be running Jupyter (failed to fetch the API version)" + return + end + vprint_status "#{peer} - The server responded that it is running Jupyter version: #{version}" + + unless requires_password?(ip) + print_good "#{peer} - No password is required." + report_vuln( + :host => ip, + :port => rport, + :proto => 'tcp', + :sname => (ssl ? 'https' : 'http'), + :name => "Unauthenticated Jupyter Access", + :info => "Module #{self.fullname} confirmed access to the Jupyter application with no authentication" + ) + return + end + + cred_collection = Metasploit::Framework::PrivateCredentialCollection.new( + blank_passwords: datastore['BLANK_PASSWORDS'], + pass_file: datastore['PASS_FILE'], + password: datastore['PASSWORD'] + ) + + scanner = Metasploit::Framework::LoginScanner::Jupyter.new( + configure_http_login_scanner( + uri: normalize_uri(target_uri.path, 'login'), + cred_details: cred_collection, + bruteforce_speed: datastore['BRUTEFORCE_SPEED'], + connection_timeout: 10, + http_password: datastore['HttpPassword'], + # there is only one password and no username, so don't bother continuing + stop_on_success: true + ) + ) + + scanner.scan! do |result| + credential_data = result.to_h + credential_data.merge!( + module_fullname: fullname, + workspace_id: myworkspace_id + ) + if result.success? + credential_core = create_credential(credential_data) + credential_data[:core] = credential_core + create_credential_login(credential_data) + + print_good "#{peer} - Login Successful: #{result.credential}" + else + invalidate_login(credential_data) + vprint_error "#{peer} - LOGIN FAILED: #{result.credential} (#{result.status})" + end + end + end +end From 0e5dceb9225c4726a75c4574df8ef33604967c7f Mon Sep 17 00:00:00 2001 From: Spencer McIntyre Date: Tue, 4 Aug 2020 18:12:50 -0400 Subject: [PATCH 2/7] Add documentation for the Jupyter login scanner --- .../auxiliary/scanner/http/jupyter_login.md | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 documentation/modules/auxiliary/scanner/http/jupyter_login.md diff --git a/documentation/modules/auxiliary/scanner/http/jupyter_login.md b/documentation/modules/auxiliary/scanner/http/jupyter_login.md new file mode 100644 index 0000000000..478eccdc75 --- /dev/null +++ b/documentation/modules/auxiliary/scanner/http/jupyter_login.md @@ -0,0 +1,52 @@ +## Vulnerable Application + +This module checks if authentication is required on a Jupyter Lab or Notebook server. If it is, this module will +bruteforce the password. Jupyter only requires a password to authenticate, usernames are not used. This module is +compatible with versions 4.3.0 (released 2016-12-08) and newer. [Version 4.3.0][1] is the first version in which +authentication is required by default. + +A note on names, "Jupyter Lab" is the next-generation interface for "Jupyter Notebooks" which was the successor of the +original IPython Notebook system. This module is compatible with both standard Jupyter Notebook and Jupyter Lab servers. + +### Installation + +1. Install the latest version of Jupyter from PyPi using pip: `pip install notebook`. The "notebook" package is the core + application and is the one whose version number is referenced. +1. Start Jupyter using `jupyter notebook`, new installs will randomly generate an authentication token and open the + browser with it +1. As of [version 5.3][2], the user will be prompted to set a password the first time they open the UI +1. With the password set, the module can be tested + +## Verification Steps + +1. Install the application +1. Start msfconsole +1. Do: `use auxiliary/scanner/http/jupyter_login` +1. Set the `RHOSTS` option + * With no other options set, this will only check if authentication is required +1. Do: `run` +1. You should see login attempts + +## Options + +## Scenarios + +### Jupyte Notebook 4.3.0 + +``` +msf5 > use auxiliary/scanner/http/jupyter_login +msf5 auxiliary(scanner/http/jupyter_login) > set RHOSTS 192.168.159.128 +RHOSTS => 192.168.159.128 +msf5 auxiliary(scanner/http/jupyter_login) > set PASS_FILE /tmp/passwords.txt +PASS_FILE => /tmp/passwords.txt +msf5 auxiliary(scanner/http/jupyter_login) > run + +[*] 192.168.159.128:8888 - The server responded that it is running Jupyter version: 4.3.0 +[+] 192.168.159.128:8888 - No password is required. +[*] Scanned 1 of 1 hosts (100% complete) +[*] Auxiliary module execution completed +msf5 auxiliary(scanner/http/jupyter_login) > +``` + +[1]: https://jupyter-notebook.readthedocs.io/en/stable/changelog.html#release-4-3 +[2]: https://jupyter-notebook.readthedocs.io/en/stable/public_server.html#automatic-password-setup From 5e5922a1c4a995d7eef0e04ad36dcf9891ed2d49 Mon Sep 17 00:00:00 2001 From: Spencer McIntyre Date: Fri, 7 Aug 2020 09:02:32 -0400 Subject: [PATCH 3/7] Fix an overly indented block in credential_collection.rb --- .../framework/credential_collection.rb | 210 +++++++++--------- 1 file changed, 105 insertions(+), 105 deletions(-) diff --git a/lib/metasploit/framework/credential_collection.rb b/lib/metasploit/framework/credential_collection.rb index cdd63fe3d9..c6666d117d 100644 --- a/lib/metasploit/framework/credential_collection.rb +++ b/lib/metasploit/framework/credential_collection.rb @@ -2,127 +2,127 @@ require 'metasploit/framework/credential' module Metasploit::Framework - class PrivateCredentialCollection - # @!attribute additional_privates - # Additional privates to be combined - # - # @return [Array] - attr_accessor :additional_privates + class PrivateCredentialCollection + # @!attribute additional_privates + # Additional privates to be combined + # + # @return [Array] + attr_accessor :additional_privates - # @!attribute blank_passwords - # Whether each username should be tried with a blank password - # @return [Boolean] - attr_accessor :blank_passwords + # @!attribute blank_passwords + # Whether each username should be tried with a blank password + # @return [Boolean] + attr_accessor :blank_passwords - # @!attribute pass_file - # Path to a file containing passwords, one per line - # @return [String] - attr_accessor :pass_file + # @!attribute pass_file + # Path to a file containing passwords, one per line + # @return [String] + attr_accessor :pass_file - # @!attribute password - # @return [String] - attr_accessor :password + # @!attribute password + # @return [String] + attr_accessor :password - # @!attribute prepended_creds - # List of credentials to be tried before any others - # - # @see #prepend_cred - # @return [Array] - attr_accessor :prepended_creds + # @!attribute prepended_creds + # List of credentials to be tried before any others + # + # @see #prepend_cred + # @return [Array] + attr_accessor :prepended_creds - # @!attribute realm - # @return [String] - attr_accessor :realm + # @!attribute realm + # @return [String] + attr_accessor :realm - # @option opts [Boolean] :blank_passwords See {#blank_passwords} - # @option opts [String] :pass_file See {#pass_file} - # @option opts [String] :password See {#password} - # @option opts [Array] :prepended_creds ([]) See {#prepended_creds} - # @option opts [Boolean] :user_as_pass See {#user_as_pass} - # @option opts [String] :user_file See {#user_file} - # @option opts [String] :username See {#username} - # @option opts [String] :userpass_file See {#userpass_file} - def initialize(opts = {}) - opts.each do |attribute, value| - public_send("#{attribute}=", value) - end - self.prepended_creds ||= [] - self.additional_privates ||= [] + # @option opts [Boolean] :blank_passwords See {#blank_passwords} + # @option opts [String] :pass_file See {#pass_file} + # @option opts [String] :password See {#password} + # @option opts [Array] :prepended_creds ([]) See {#prepended_creds} + # @option opts [Boolean] :user_as_pass See {#user_as_pass} + # @option opts [String] :user_file See {#user_file} + # @option opts [String] :username See {#username} + # @option opts [String] :userpass_file See {#userpass_file} + def initialize(opts = {}) + opts.each do |attribute, value| + public_send("#{attribute}=", value) + end + self.prepended_creds ||= [] + self.additional_privates ||= [] + end + + # Adds a string as an addition private credential + # to be combined in the collection. + # + # @param [String] private_str the string to use as a private + # @return [void] + def add_private(private_str='') + additional_privates << private_str + end + + # Add {Credential credentials} that will be yielded by {#each} + # + # @see prepended_creds + # @param cred [Credential] + # @return [self] + def prepend_cred(cred) + prepended_creds.unshift cred + self + end + + # Combines all the provided credential sources into a stream of {Credential} + # objects, yielding them one at a time + # + # @yieldparam credential [Metasploit::Framework::Credential] + # @return [void] + def each + if pass_file.present? + pass_fd = File.open(pass_file, 'r:binary') end - # Adds a string as an addition private credential - # to be combined in the collection. - # - # @param [String] private_str the string to use as a private - # @return [void] - def add_private(private_str='') - additional_privates << private_str + prepended_creds.each { |c| yield c } + + if password.present? + yield Metasploit::Framework::Credential.new(private: password, realm: realm, private_type: private_type(password)) + end + if blank_passwords + yield Metasploit::Framework::Credential.new(private: "", realm: realm, private_type: :password) + end + if pass_fd + pass_fd.each_line do |pass_from_file| + pass_from_file.chomp! + yield Metasploit::Framework::Credential.new(private: pass_from_file, realm: realm, private_type: private_type(pass_from_file)) + end + pass_fd.seek(0) + end + additional_privates.each do |add_private| + yield Metasploit::Framework::Credential.new(private: add_private, realm: realm, private_type: private_type(add_private)) end - # Add {Credential credentials} that will be yielded by {#each} - # - # @see prepended_creds - # @param cred [Credential] - # @return [self] - def prepend_cred(cred) - prepended_creds.unshift cred - self - end + ensure + pass_fd.close if pass_fd && !pass_fd.closed? + end - # Combines all the provided credential sources into a stream of {Credential} - # objects, yielding them one at a time - # - # @yieldparam credential [Metasploit::Framework::Credential] - # @return [void] - def each - if pass_file.present? - pass_fd = File.open(pass_file, 'r:binary') - end + # Returns true when #each will have no results to iterate + def empty? + prepended_creds.empty? && !has_privates? + end - prepended_creds.each { |c| yield c } + def has_privates? + password.present? || pass_file.present? || !additional_privates.empty? || blank_passwords + end - if password.present? - yield Metasploit::Framework::Credential.new(private: password, realm: realm, private_type: private_type(password)) - end - if blank_passwords - yield Metasploit::Framework::Credential.new(private: "", realm: realm, private_type: :password) - end - if pass_fd - pass_fd.each_line do |pass_from_file| - pass_from_file.chomp! - yield Metasploit::Framework::Credential.new(private: pass_from_file, realm: realm, private_type: private_type(pass_from_file)) - end - pass_fd.seek(0) - end - additional_privates.each do |add_private| - yield Metasploit::Framework::Credential.new(private: add_private, realm: realm, private_type: private_type(add_private)) - end + protected - ensure - pass_fd.close if pass_fd && !pass_fd.closed? - end - - # Returns true when #each will have no results to iterate - def empty? - prepended_creds.empty? && !has_privates? - end - - def has_privates? - password.present? || pass_file.present? || !additional_privates.empty? || blank_passwords - end - - protected - - def private_type(private) - if private =~ /[0-9a-f]{32}:[0-9a-f]{32}/ - :ntlm_hash - elsif private =~ /^md5([a-f0-9]{32})$/ - :postgres_md5 - else - :password - end + def private_type(private) + if private =~ /[0-9a-f]{32}:[0-9a-f]{32}/ + :ntlm_hash + elsif private =~ /^md5([a-f0-9]{32})$/ + :postgres_md5 + else + :password end end + end class CredentialCollection < PrivateCredentialCollection From c57391501a9b79c174a298e510fbba37d9128377 Mon Sep 17 00:00:00 2001 From: Spencer McIntyre Date: Mon, 10 Aug 2020 09:47:59 -0400 Subject: [PATCH 4/7] Fix typos and clarify documentation for jupyter_login --- .../auxiliary/scanner/http/jupyter_login.md | 30 ++++++++++++---- .../framework/login_scanner/jupyter.rb | 5 +-- .../auxiliary/scanner/http/jupyter_login.rb | 35 ++++++++++--------- 3 files changed, 46 insertions(+), 24 deletions(-) diff --git a/documentation/modules/auxiliary/scanner/http/jupyter_login.md b/documentation/modules/auxiliary/scanner/http/jupyter_login.md index 478eccdc75..7f03f76b07 100644 --- a/documentation/modules/auxiliary/scanner/http/jupyter_login.md +++ b/documentation/modules/auxiliary/scanner/http/jupyter_login.md @@ -11,10 +11,10 @@ original IPython Notebook system. This module is compatible with both standard J ### Installation 1. Install the latest version of Jupyter from PyPi using pip: `pip install notebook`. The "notebook" package is the core - application and is the one whose version number is referenced. -1. Start Jupyter using `jupyter notebook`, new installs will randomly generate an authentication token and open the - browser with it -1. As of [version 5.3][2], the user will be prompted to set a password the first time they open the UI + application and is the one whose version number is used as the Jupyter version number referred to in this document. +1. Start Jupyter using `jupyter notebook` + * New installs will randomly generate an authentication token and open the browser with it + * As of [version 5.3][2], the user will be prompted to set a password the first time they open the UI 1. With the password set, the module can be tested ## Verification Steps @@ -25,13 +25,15 @@ original IPython Notebook system. This module is compatible with both standard J 1. Set the `RHOSTS` option * With no other options set, this will only check if authentication is required 1. Do: `run` -1. You should see login attempts +1. You should the server version +1. If password options (such as `PASS_FILE`) where specified, and the server requires authentication then you should see + login attempts ## Options ## Scenarios -### Jupyte Notebook 4.3.0 +### Jupyter Notebook 4.3.0 With No Authentication Requirement ``` msf5 > use auxiliary/scanner/http/jupyter_login @@ -48,5 +50,21 @@ msf5 auxiliary(scanner/http/jupyter_login) > run msf5 auxiliary(scanner/http/jupyter_login) > ``` +### Jupyter Notebook 6.0.2 With A Password Set + +``` +msf5 > use auxiliary/scanner/http/jupyter_login +msf5 auxiliary(scanner/http/jupyter_login) > set PASS_FILE /tmp/passwords.txt +PASS_FILE => /tmp/passwords.txt +msf5 auxiliary(scanner/http/jupyter_login) > run + +[*] 192.168.159.128:8888 - The server responded that it is running Jupyter version: 6.0.2 +[-] 192.168.159.128:8888 - LOGIN FAILED: :Password (Incorrect) +[+] 192.168.159.128:8888 - Login Successful: :Password1 +[*] Scanned 1 of 1 hosts (100% complete) +[*] Auxiliary module execution completed +msf5 auxiliary(scanner/http/jupyter_login) > +``` + [1]: https://jupyter-notebook.readthedocs.io/en/stable/changelog.html#release-4-3 [2]: https://jupyter-notebook.readthedocs.io/en/stable/public_server.html#automatic-password-setup diff --git a/lib/metasploit/framework/login_scanner/jupyter.rb b/lib/metasploit/framework/login_scanner/jupyter.rb index 18ad394512..163bf4c2a9 100644 --- a/lib/metasploit/framework/login_scanner/jupyter.rb +++ b/lib/metasploit/framework/login_scanner/jupyter.rb @@ -32,8 +32,9 @@ module Metasploit begin res = send_request({'method'=> 'GET', 'uri' => uri}) vars_post = {'password' => credential.private } + + # versions < 4.3.1 do not use this field unless (node = res.get_html_document.xpath('//form//input[@name="_xsrf"]')).empty? - # versions < 4.3.1 do not use this field vars_post['_xsrf'] = node.first['value'] end @@ -49,7 +50,7 @@ module Metasploit else result_opts.merge!(status: Metasploit::Model::Login::Status::INCORRECT, proof: res) end - rescue ::EOFError, Errno::ETIMEDOUT ,Errno::ECONNRESET, Rex::ConnectionError, OpenSSL::SSL::SSLError, ::Timeout::Error => e + rescue ::EOFError, Errno::ETIMEDOUT, Errno::ECONNRESET, Rex::ConnectionError, OpenSSL::SSL::SSLError, ::Timeout::Error => e result_opts.merge!(status: Metasploit::Model::Login::Status::UNABLE_TO_CONNECT, proof: e) end Result.new(result_opts) diff --git a/modules/auxiliary/scanner/http/jupyter_login.rb b/modules/auxiliary/scanner/http/jupyter_login.rb index d10db839e9..9aec5b30b0 100644 --- a/modules/auxiliary/scanner/http/jupyter_login.rb +++ b/modules/auxiliary/scanner/http/jupyter_login.rb @@ -14,21 +14,22 @@ class MetasploitModule < Msf::Auxiliary def initialize super( - 'Name' => 'Jupyter Login Utility', - 'Description' => %q{ + 'Name' => 'Jupyter Login Utility', + 'Description' => %q{ This module checks if authentication is required on a Jupyter Lab or Notebook server. If it is, this module will bruteforce the password. Jupyter only requires a password to authenticate, usernames are not used. This module is compatible with versions 4.3.0 (released 2016-12-08) and newer. }, - 'Author' => [ 'Spencer McIntyre' ], - 'License' => MSF_LICENSE + 'Author' => [ 'Spencer McIntyre' ], + 'License' => MSF_LICENSE ) register_options( [ - OptString.new('TARGETURI', [ true, 'The path to the Jupyter application', '/' ]), + OptString.new('TARGETURI', [ true, 'The path to the Jupyter application', '/' ]), Opt::RPORT(8888) - ]) + ] + ) deregister_options('PASSWORD_SPRAY') deregister_options('DB_ALL_CREDS', 'DB_ALL_USERS', 'HttpUsername', 'STOP_ON_SUCCESS', 'USERNAME', 'USERPASS_FILE', 'USER_AS_PASS', 'USER_FILE') @@ -36,15 +37,17 @@ class MetasploitModule < Msf::Auxiliary register_autofilter_ports([ 80, 443, 8888 ]) end - def requires_password?(ip) + def requires_password?(_ip) res = send_request_cgi({ 'method' => 'GET', 'uri' => normalize_uri(target_uri.path, 'tree') }) return false if res&.code == 200 + destination = res.headers['Location'].split('?', 2)[0] - return true if destination.ends_with?(normalize_uri(target_uri.path, 'login')) + return true if destination.end_with?(normalize_uri(target_uri.path, 'login')) + fail_with(Failure::UnexpectedReply, "#{peer} - The server responded with a redirect that did not match a known fingerprint") end @@ -63,12 +66,12 @@ class MetasploitModule < Msf::Auxiliary unless requires_password?(ip) print_good "#{peer} - No password is required." report_vuln( - :host => ip, - :port => rport, - :proto => 'tcp', - :sname => (ssl ? 'https' : 'http'), - :name => "Unauthenticated Jupyter Access", - :info => "Module #{self.fullname} confirmed access to the Jupyter application with no authentication" + host: ip, + port: rport, + proto: 'tcp', + sname: (ssl ? 'https' : 'http'), + name: 'Unauthenticated Jupyter Access', + info: "Module #{fullname} confirmed unauthenticated access to the Jupyter application" ) return end @@ -94,8 +97,8 @@ class MetasploitModule < Msf::Auxiliary scanner.scan! do |result| credential_data = result.to_h credential_data.merge!( - module_fullname: fullname, - workspace_id: myworkspace_id + module_fullname: fullname, + workspace_id: myworkspace_id ) if result.success? credential_core = create_credential(credential_data) From 3a6280e55607db695c62aed31f47c4143c6e3bca Mon Sep 17 00:00:00 2001 From: Spencer McIntyre Date: Mon, 10 Aug 2020 15:25:01 -0400 Subject: [PATCH 5/7] Add the missing set RHOSTS to the documentation example output --- documentation/modules/auxiliary/scanner/http/jupyter_login.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/documentation/modules/auxiliary/scanner/http/jupyter_login.md b/documentation/modules/auxiliary/scanner/http/jupyter_login.md index 7f03f76b07..0548fc2635 100644 --- a/documentation/modules/auxiliary/scanner/http/jupyter_login.md +++ b/documentation/modules/auxiliary/scanner/http/jupyter_login.md @@ -54,6 +54,8 @@ msf5 auxiliary(scanner/http/jupyter_login) > ``` msf5 > use auxiliary/scanner/http/jupyter_login +msf5 auxiliary(scanner/http/jupyter_login) > set RHOSTS 192.168.159.128 +RHOSTS => 192.168.159.128 msf5 auxiliary(scanner/http/jupyter_login) > set PASS_FILE /tmp/passwords.txt PASS_FILE => /tmp/passwords.txt msf5 auxiliary(scanner/http/jupyter_login) > run From a7cbdddbb104ae578498b2305329e4e533f9f66c Mon Sep 17 00:00:00 2001 From: Spencer McIntyre Date: Tue, 11 Aug 2020 16:49:41 -0400 Subject: [PATCH 6/7] Update a bunch of documentation for the credential collection lib --- .../framework/credential_collection.rb | 37 ++++++++++++++----- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/lib/metasploit/framework/credential_collection.rb b/lib/metasploit/framework/credential_collection.rb index c6666d117d..865886b215 100644 --- a/lib/metasploit/framework/credential_collection.rb +++ b/lib/metasploit/framework/credential_collection.rb @@ -4,8 +4,7 @@ module Metasploit::Framework class PrivateCredentialCollection # @!attribute additional_privates - # Additional privates to be combined - # + # Additional private values that should be tried # @return [Array] attr_accessor :additional_privates @@ -20,6 +19,7 @@ module Metasploit::Framework attr_accessor :pass_file # @!attribute password + # The password that should be tried # @return [String] attr_accessor :password @@ -31,6 +31,7 @@ module Metasploit::Framework attr_accessor :prepended_creds # @!attribute realm + # The authentication realm associated with this password # @return [String] attr_accessor :realm @@ -50,10 +51,10 @@ module Metasploit::Framework self.additional_privates ||= [] end - # Adds a string as an addition private credential + # Adds a string as an additional private credential # to be combined in the collection. # - # @param [String] private_str the string to use as a private + # @param [String] private_str The string to use as a private credential # @return [void] def add_private(private_str='') additional_privates << private_str @@ -62,7 +63,7 @@ module Metasploit::Framework # Add {Credential credentials} that will be yielded by {#each} # # @see prepended_creds - # @param cred [Credential] + # @param [Credential] cred # @return [self] def prepend_cred(cred) prepended_creds.unshift cred @@ -92,7 +93,6 @@ module Metasploit::Framework pass_from_file.chomp! yield Metasploit::Framework::Credential.new(private: pass_from_file, realm: realm, private_type: private_type(pass_from_file)) end - pass_fd.seek(0) end additional_privates.each do |add_private| yield Metasploit::Framework::Credential.new(private: add_private, realm: realm, private_type: private_type(add_private)) @@ -103,16 +103,25 @@ module Metasploit::Framework end # Returns true when #each will have no results to iterate + # + # @return [Boolean] def empty? prepended_creds.empty? && !has_privates? end + # Returns true when there are any private values set + # + # @return [Boolean] def has_privates? password.present? || pass_file.present? || !additional_privates.empty? || blank_passwords end protected + # Analyze a private value to determine its type by checking it against a known list of regular expressions + # + # @param [String] private The string to analyze + # @return [Symbol] def private_type(private) if private =~ /[0-9a-f]{32}:[0-9a-f]{32}/ :ntlm_hash @@ -127,7 +136,7 @@ module Metasploit::Framework class CredentialCollection < PrivateCredentialCollection # @!attribute additional_publics - # Additional public to be combined + # Additional public values that should be tried # # @return [Array] attr_accessor :additional_publics @@ -143,6 +152,7 @@ module Metasploit::Framework attr_accessor :user_file # @!attribute username + # The username that should be tried # @return [String] attr_accessor :username @@ -165,10 +175,10 @@ module Metasploit::Framework self.additional_publics ||= [] end - # Adds a string as an addition public credential + # Adds a string as an additional public credential # to be combined in the collection. # - # @param [String] public_str the string to use as a public + # @param [String] public_str The string to use as a public credential # @return [void] def add_public(public_str='') additional_publics << public_str @@ -201,7 +211,6 @@ module Metasploit::Framework pass_from_file.chomp! yield Metasploit::Framework::Credential.new(public: username, private: pass_from_file, realm: realm, private_type: private_type(pass_from_file)) end - pass_fd.seek(0) end additional_privates.each do |add_private| yield Metasploit::Framework::Credential.new(public: username, private: add_private, realm: realm, private_type: private_type(add_private)) @@ -276,14 +285,22 @@ module Metasploit::Framework end # Returns true when #each will have no results to iterate + # + # @return [Boolean] def empty? prepended_creds.empty? && !has_users? || (has_users? && !has_privates?) end + # Returns true when there are any user values set + # + # @return [Boolean] def has_users? username.present? || user_file.present? || userpass_file.present? || !additional_publics.empty? end + # Returns true when there are any private values set + # + # @return [Boolean] def has_privates? super || userpass_file.present? || user_as_pass end From da390159411756862bc06bff86fc3b5789c1f422 Mon Sep 17 00:00:00 2001 From: gwillcox-r7 Date: Wed, 12 Aug 2020 13:20:31 -0500 Subject: [PATCH 7/7] Ninja commit edits to documentation to explain how to fix some setup issues that may occur --- .../modules/auxiliary/scanner/http/jupyter_login.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/documentation/modules/auxiliary/scanner/http/jupyter_login.md b/documentation/modules/auxiliary/scanner/http/jupyter_login.md index 0548fc2635..864e674346 100644 --- a/documentation/modules/auxiliary/scanner/http/jupyter_login.md +++ b/documentation/modules/auxiliary/scanner/http/jupyter_login.md @@ -12,9 +12,12 @@ original IPython Notebook system. This module is compatible with both standard J 1. Install the latest version of Jupyter from PyPi using pip: `pip install notebook`. The "notebook" package is the core application and is the one whose version number is used as the Jupyter version number referred to in this document. -1. Start Jupyter using `jupyter notebook` +1. Start Jupyter using `jupyter notebook --ip='*'` to start Jupyter listening on all IP addresses. * New installs will randomly generate an authentication token and open the browser with it * As of [version 5.3][2], the user will be prompted to set a password the first time they open the UI + * Note that you may need to restart Jupyter after changing the password in order for Jupyter to start using the new password. + * If you can't reset the password, it may be because you need to create the directory `.jupyter` in the directory + you are running the `jupyter notebook --ip='*'` command from. 1. With the password set, the module can be tested ## Verification Steps