Code Auditing Guidelines: Detecting and Fixing Insecure Deserialization in legacy session handling in Your Ruby Monolith
Identifying Legacy Session Handling Mechanisms
Many legacy Ruby monoliths, particularly those built on older versions of Rails or custom frameworks, often rely on serialized session data stored in formats like YAML or Marshal. The primary vulnerability here stems from the deserialization process itself. If an attacker can control the serialized data that gets deserialized, they can inject arbitrary Ruby code, leading to Remote Code Execution (RCE).
The first step in auditing is to locate where session data is being serialized and deserialized. In Rails applications, this typically involves looking at the `config/initializers/session_store.rb` file and how session data is handled within controllers. For custom applications, you’ll need to trace the session management logic, often found in base controller classes or dedicated session management modules.
Analyzing Serialization Formats and Vulnerabilities
The most common culprits for insecure deserialization in Ruby are:
- Marshal: Ruby’s built-in serialization format. It’s notoriously insecure when deserializing untrusted data. The `Marshal.load` method is the primary vector.
- YAML: While safer than Marshal by default in recent Ruby versions, older versions or specific configurations can still be vulnerable, especially when using `YAML.load` without proper type restrictions.
- JSON: Generally considered safe for deserialization as it’s a data-interchange format. However, if the application then *interprets* the deserialized JSON data in an unsafe way (e.g., using `eval` on string values), vulnerabilities can still arise.
Let’s examine a hypothetical vulnerable session handling snippet using Marshal:
Vulnerable Marshal Deserialization Example
Consider a controller action that loads session data directly from a cookie, assuming it’s safely marshalled:
Controller Snippet (Vulnerable)
# app/controllers/application_controller.rb (simplified)
class ApplicationController < ActionController::Base
before_action :load_session_data
private
def load_session_data
if cookies[:user_session].present?
# !!! DANGER: Unsafe deserialization of untrusted cookie data !!!
session_data = Marshal.load(Base64.decode64(cookies[:user_session]))
@current_user = User.find_by(id: session_data['user_id'])
else
@current_user = nil
end
end
end
In this example, the `Marshal.load` call directly processes data from a cookie. An attacker can craft a malicious cookie containing a Base64 encoded string that, when unmarshalled, executes arbitrary Ruby code. A common payload involves creating an object that executes code in its `initialize` or `_load` method.
Crafting Exploits: The Marshal Payload
A typical exploit payload for `Marshal.load` leverages Ruby’s object instantiation and method calls during deserialization. The `_load` method is particularly interesting, as it’s called by `Marshal.load` for certain object types.
Example Exploit Payload Generation
# exploit_generator.rb
require 'base64'
require 'yaml' # Often used to define the malicious object structure
# A simple class that executes a command when its _load method is called.
# This is a simplified example; real-world exploits might be more complex.
class Exploit
def initialize(cmd)
@cmd = cmd
end
def _load(*)
system(@cmd)
end
end
# Command to execute (e.g., list directory contents)
command_to_run = "ls -la /"
# Create an instance of the Exploit class
malicious_object = Exploit.new(command_to_run)
# Marshal the object
marshalled_data = Marshal.dump(malicious_object)
# Base64 encode it for cookie transmission
encoded_payload = Base64.encode64(marshalled_data)
puts "Generated malicious cookie value:"
puts encoded_payload
When this `encoded_payload` is set as the `user_session` cookie, and the vulnerable `load_session_data` method is executed, the `system(“ls -la /”)` command will run on the server. This demonstrates a critical RCE vulnerability.
Mitigation Strategies: Secure Deserialization
The most robust solution is to avoid deserializing untrusted data altogether. If session data *must* be stored client-side and is sensitive, consider alternatives:
1. Server-Side Sessions with Signed Cookies
This is the standard and recommended approach in modern web frameworks like Rails. Session data is stored on the server (e.g., in a database or Redis), and the client only receives a session ID cookie. This cookie is signed to prevent tampering. The application then retrieves the session data from the server using the signed ID.
In Rails, this is configured by default. If you’ve moved away from it, revert to:
# config/initializers/session_store.rb (Rails default) Rails.application.config.session_store :cookie_store, key: '_your_app_session', secure: Rails.env.production?, httponly: true, secret: ENV['SESSION_SECRET']
Ensure `secret` is set to a strong, unique value (e.g., from an environment variable) and that `secure` and `httponly` are enabled for production environments.
2. Encrypted and Signed Data (If Client-Side Storage is Unavoidable)
If you absolutely must store session-like data client-side and cannot use server-side sessions, use a format that is both encrypted and signed. Libraries like `ActiveSupport::MessageEncryptor` (in Rails) or `crypt-token` can be used. The data is encrypted, so its contents are unreadable, and then signed to ensure integrity. The server decrypts and verifies the signature before using the data.
Example using ActiveSupport::MessageEncryptor
# app/controllers/application_controller.rb (mitigated)
require 'active_support/message_encryptor'
class ApplicationController < ActionController::Base
before_action :load_session_data
private
def load_session_data
if cookies[:user_session].present?
# Use a strong secret key, ideally from environment variables
secret = ENV.fetch('SESSION_ENCRYPTION_KEY')
encryptor = ActiveSupport::MessageEncryptor.new(secret)
begin
# Decrypt and verify the data
decrypted_data = encryptor.decrypt_and_verify(cookies[:user_session])
session_data = JSON.parse(decrypted_data) # Assuming data was JSON encoded before encrypting
@current_user = User.find_by(id: session_data['user_id'])
rescue ActiveSupport::MessageEncryptor::InvalidSignature, JSON::ParserError => e
Rails.logger.error "Session data tampered or invalid: #{e.message}"
# Handle error: clear cookie, redirect, etc.
cookies.delete(:user_session)
@current_user = nil
end
else
@current_user = nil
end
end
def set_session_data(user_id)
secret = ENV.fetch('SESSION_ENCRYPTION_KEY')
encryptor = ActiveSupport::MessageEncryptor.new(secret)
session_payload = { user_id: user_id }.to_json
encrypted_data = encryptor.encrypt_and_sign(session_payload)
cookies[:user_session] = {
value: encrypted_data,
secure: Rails.env.production?,
httponly: true,
expires: 1.hour.from_now # Example expiration
}
end
end
In this mitigated version, `encryptor.decrypt_and_verify` handles both decryption and signature validation. If the signature is invalid or decryption fails, an exception is raised, preventing malicious data from being processed. The data itself is also JSON encoded before encryption, which is a safer intermediate format.
3. Input Validation and Whitelisting (Less Ideal)
If migrating away from Marshal/YAML deserialization is not immediately feasible, and you’re stuck with it for specific legacy components, rigorous input validation and whitelisting are critical. This involves:
- Strictly validating the structure and types of deserialized data. For example, if you expect a hash with an integer `user_id`, ensure it is indeed an integer and not a string that could be interpreted as code.
- Avoiding deserializing arbitrary objects. If possible, deserialize into simple data structures like hashes or arrays.
- Using safer deserialization methods where available. For YAML, `YAML.safe_load` is preferred over `YAML.load` in newer Ruby versions, as it restricts the types of objects that can be instantiated.
Example using YAML.safe_load
# app/controllers/application_controller.rb (mitigated with YAML)
require 'yaml'
class ApplicationController < ActionController::Base
before_action :load_session_data
private
def load_session_data
if cookies[:user_session].present?
begin
# Use safe_load with explicit type restrictions if possible
# For complex structures, consider a schema validation library
session_data = YAML.safe_load(Base64.decode64(cookies[:user_session]), permitted_classes: [Hash, Array, String, Integer, NilClass], aliases: false)
# Further validation of expected keys and types
if session_data.is_a?(Hash) && session_data['user_id'].is_a?(Integer)
@current_user = User.find_by(id: session_data['user_id'])
else
Rails.logger.warn "Invalid session data structure received."
cookies.delete(:user_session)
@current_user = nil
end
rescue Psych::SyntaxError, ArgumentError => e
Rails.logger.error "Failed to parse session data: #{e.message}"
cookies.delete(:user_session)
@current_user = nil
end
else
@current_user = nil
end
end
end
Note that `YAML.safe_load` still requires careful configuration. The `permitted_classes` and `aliases: false` arguments are crucial for preventing known bypasses. Even with `safe_load`, it’s best to perform explicit type checks on the resulting data structure.
Auditing Checklist and Remediation Workflow
- Locate all session handling logic: Search for `Marshal.load`, `YAML.load`, `YAML.safe_load`, and any custom deserialization routines.
- Identify data sources: Determine where the data being deserialized originates from (cookies, database fields, external APIs). Prioritize data from untrusted sources like cookies.
- Analyze serialization format: Confirm if Marshal, YAML, or another potentially unsafe format is in use.
- Test for vulnerabilities: Manually craft or use tools (like Burp Suite) to send malicious serialized payloads to identified endpoints.
- Prioritize remediation:
- High Priority: Replace all instances of `Marshal.load` on untrusted input with server-side sessions or encrypted/signed data.
- Medium Priority: For YAML, migrate to `YAML.safe_load` with strict `permitted_classes` and perform explicit type validation.
- Low Priority: If using JSON, ensure no further unsafe interpretation (like `eval`) occurs on the deserialized data.
- Implement automated checks: Integrate static analysis tools (SAST) that can detect insecure deserialization patterns.
- Regular security reviews: Schedule periodic code reviews specifically focused on security, including deserialization vulnerabilities.
By systematically auditing your legacy session handling and implementing these secure deserialization patterns, you can significantly reduce the attack surface of your Ruby monolith.