Attacker Value
Unknown
(1 user assessed)
Exploitability
Unknown
(1 user assessed)
User Interaction
Unknown
Privileges Required
Unknown
Attack Vector
Unknown
0

Ruby on Rails DoubleTap Development Mode secret_key_base Vulnerability

Disclosure Date: March 27, 2019 Last updated February 13, 2020
Add MITRE ATT&CK tactics and techniques that apply to this CVE.

Description

Ruby on Rails versions including 5.2.2.1 and prior are vulnerable to a predicatble secret_key_base in development mode, which could be used to recreated a signed message, such as a serialized object, and gain remote code execution.

Add Assessment

1
Technical Analysis

Background

Ruby on Rails is a server-side web application framework written in Ruby. It is a model-view-controller (MVC) archtecture, 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.

Ruby on Rails versions including 5.2.2.1 and prior are vulnerable to a deserialization attack, because the Rails application by default uses its own name as the secret_key_base in development mode. This can be easily extracted by visiting an invalid resource for a route, which as a result allows a remote user to create and deliver a signed serialized payload, load it by the application, and gain remote code execution.

Please note that this is not the same as the “DoubleTap” vulnerability. The other one is a directory traversal attack that in theory could be chained to aid remote code execution.

In this documentation, I will go over:

  • The setup I used to test the vulnerable environment.
  • My analysis on the vulnerability.
  • Some information about patching.

Vulnerable Setup

In order to set up a vulnerable box for testing, do the following on a Linux (Ubuntu) machine, assuming rvm is already 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_controller.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:

rails s -b 0.0.0.0

By default, the application should be using its name as the secret key that is hashed in MD5.

Vulnerability Analysis

The best way to understand the vulnerabilty is by looking at Rails’ application.rb source code. Most importantly, the vulnerability comes from the secret_key_base method in the Application class (see railties/lib/rails/application.rb):

def secret_key_base
  if Rails.env.test? || Rails.env.development?
    secrets.secret_key_base || Digest::MD5.hexdigest(self.class.name)
  else
    validate_secret_key_base(
      ENV["SECRET_KEY_BASE"] || credentials.secret_key_base || secrets.secret_key_base
      )
  end
end

We see that in order to be vulnerable, we either need to be in test mode, or development mode. That way, the application will wither rely on a secret_key_base from somewhere (explained later), or it computes its own based on the application name.

Rails 5.2 (2017)

Before we move on with the analysis, it is interesting to point out that the vulnerable code we are looking at right now was actually meant to improve Rails security with encrypted credentials, which was introduced in Aug 3rd, 2017. Although meant for better security, it did not start off safe, but in fact, worse:

def secret_key_base
  if Rails.env.test? || Rails.env.development?
    Digest::MD5.hexdigest self.class.name
    # Code omitted below

You can see the pull request here.

Before this, Rails used to rely on config/secrets.yml, which wasnt protected by encryption like 5.2’s credentials.yml.enc file.

Where is the Secret?

According to the vulnerable code, we know that by default, Rails would try to load the secret_key_base somewhre, otherwise it relies on the application name. On a newly installed Rails app, it seems it just defaults back to the application name, but let us take a look at the first condition anyway:

secrets.secret_key_base

Here we know that the secret_key_base comes from secrets. If we look around a little, we know that is actually a method:

def secrets
  @secrets ||= begin
    secrets = ActiveSupport::OrderedOptions.new
    files = config.paths["config/secrets"].existent
    files = files.reject { |path| path.end_with?(".enc") } unless config.read_encrypted_secrets
    secrets.merge! Rails::Secrets.parse(files, env: Rails.env)

    # Fallback to config.secret_key_base if secrets.secret_key_base isn't set
    secrets.secret_key_base ||= config.secret_key_base
    # Fallback to config.secret_token if secrets.secret_token isn't set
    secrets.secret_token ||= config.secret_token

    if secrets.secret_token.present?
      ActiveSupport::Deprecation.warn(
        "`secrets.secret_token` is deprecated in favor of `secret_key_base` and will be removed in Rails 6.0."
        )
    end

    secrets
  end
end

And very quickly, we see that the secret comes from config/secrets.*, encrypted or not. Well, by default, a Rails app does not actually have this file, so it makes perfect sense we always fall back to the application name (in MD5) as the secret_key_base by default.

Notice the above function basically means the secret is loaded from a file, and it is parsed. It does not actually tell us how that secret is parsed, so naturally this line has my curiosity:

secrets.merge! Rails::Secrets.parse(files, env: Rails.env)

And Rails::Secrets.parse comes from the secrets.rb file:

def parse(paths, env:)
  paths.each_with_object(Hash.new) do |path, all_secrets|
    require "erb"

    secrets = YAML.load(ERB.new(preprocess(path)).result) || {}
    all_secrets.merge!(secrets["shared"].deep_symbolize_keys) if secrets["shared"]
    all_secrets.merge!(secrets[env].deep_symbolize_keys) if secrets[env]
  end

This tells a couple of things:

  • The secrets file should be a ERB template, and it is serialized.
  • Before it is loaded, the content is spit out by a method called preprocess

If the secrets file is encrypted (ends with .enc), then this decryption routine should run before it is deserialized:

def _decrypt(encrypted_message, purpose)
  cipher = new_cipher
  encrypted_data, iv, auth_tag = encrypted_message.strip.split("--".freeze).map { |v| ::Base64.strict_decode64(v) }

  # Currently the OpenSSL bindings do not raise an error if auth_tag is
  # truncated, which would allow an attacker to easily forge it. See
  # https://github.com/ruby/openssl/issues/63
  raise InvalidMessage if aead_mode? && (auth_tag.nil? || auth_tag.bytes.length != 16)

  cipher.decrypt
  cipher.key = @secret
  cipher.iv  = iv
  if aead_mode?
    cipher.auth_tag = auth_tag
    cipher.auth_data = ""
  end

  decrypted_data = cipher.update(encrypted_data)
  decrypted_data << cipher.final

  message = Messages::Metadata.verify(decrypted_data, purpose)
  @serializer.load(message) if message
rescue OpenSSLCipherError, TypeError, ArgumentError
  raise InvalidMessage
end

Basically, the code is expecting the input to be in this format, and is split by --:

string1--string2--string3

The first string is the actual encrypted data. The second string is the IV. The third is the auth tag. Each substring is Base64 encoded, so after splitting the string, they need to be decoded. What happens next depends on the code using the decryption method, beause it needs to specity what cipher to use. In the case of secrets.yml.enc, we are expecting AES-128-GCM, because it is specified in secrets.rb:

@cipher = "aes-128-gcm"

After that, our decrypted string is deserialized, and loaded as the secret. Technically, if a user has access to secrets.yml, they could backdoor this too to gain remote code execution.

However, like I previously said, all this does not even happen by default, because there is no secrets.yml by default. Using the application name as the key is definitely a much bigger concern for Rails users.

Patching

Since vulnerable version relies on the application name to create the secret_key_base, it is easy to mitigate this. Originally, this was the vulnerable code:

def secret_key_base
  if Rails.env.test? || Rails.env.development?
    secrets.secret_key_base || Digest::MD5.hexdigest(self.class.name)
    # Code omitted below

And that becomes the following the patched version:

def secret_key_base
  if Rails.env.development? || Rails.env.test?
    secrets.secret_key_base ||= generate_development_secret
    # Code omitted below

In method generate_development_secret, the key is completely randomized using SecureRandom:

def generate_development_secret
  if secrets.secret_key_base.nil?
    key_file = Rails.root.join("tmp/development_secret.txt")

    if !File.exist?(key_file)
      random_key = SecureRandom.hex(64)
      File.binwrite(key_file, random_key)
    end

    secrets.secret_key_base = File.binread(key_file)
  end

  secrets.secret_key_base
end

Looks like everything a-okay.

General Information

Additional Info

Technical Analysis