Skip to main content
  1. Posts/

Rails for Red Teamers: Building and Breaking the Web

··1473 words·7 mins·
Table of Contents
Ruby - This article is part of a series.
Part 2: This Article

Rails is famous for its “convention over configuration” philosophy. For an operator, “convention” means predictable file paths, standardized class names, and a small set of common pitfalls. That predictability is a recon advantage — knowing where to look for the bugs cuts hours off an assessment.

This post is a short tour of the parts of Rails that matter for security: the MVC layout (where vulnerabilities tend to cluster), strong parameters and the permit! shortcut, the SECRET_KEY_BASE-to-RCE chain (which works very differently on Rails 3 versus modern Rails), the raw/html_safe XSS surface, IDOR patterns, and the static-analysis tools you should run first. This is the second post in the Ruby series; the first covered the language basics.


MVC: where the bugs live
#

Rails follows the Model–View–Controller (MVC) pattern. The directory layout maps directly onto attack surface:

  • Models (app/models/) — data and business logic. Look here for weak validation, SQL injection in custom scopes (User.where("name LIKE '#{params[:name]}'")), unsafe find_by_sql, and race conditions in uniqueness validation.
  • Views (app/views/) — the UI templates (.erb, .haml, .slim). Look here for XSS via raw, html_safe on user-controlled data, sanitize with attacker-friendly allowlists, and improper link_to usage that lets javascript: URLs through.
  • Controllers (app/controllers/) — the request handlers. Look here for authorization bypasses (missing before_action), insecure direct object references (IDOR), and mass assignment via permit!.

A grep tour of an unfamiliar Rails app starts in app/controllers/ to map routes-to-handlers, then app/models/ for the validation logic, then config/routes.rb to confirm there isn’t an undocumented back-door route nobody mentioned in the kickoff call.


Mass assignment and strong parameters
#

In the Rails 2/3 era, you could update a model by handing it the whole params hash — User.update_attributes(params[:user]) — and any attribute the model exposed was settable. An attacker who knew or guessed that User#admin existed could send user[admin]=true in the POST body and gain a privilege.

This is exactly the bug Egor Homakov demonstrated against GitHub on March 4, 2012. The SSH-key form on GitHub (then a Rails app) accepted a hidden public_key[user_id] field, which let an attacker associate their own public key with another user’s account. Homakov set the user_id to one of the Rails maintainers’ and then pushed a commit to rails/rails as proof. The incident pushed Rails 4 to make strong parameters the default and effectively killed the old mass-assignment pattern in the framework.

Modern Rails requires the controller to whitelist fields via permit:

# app/controllers/users_controller.rb
class UsersController < ApplicationController
  def update
    @user = User.find(params[:id])
    if @user.update(user_params)
      redirect_to @user
    end
  end

  private

  def user_params
    params.require(:user).permit(:username, :email, :password)
  end
end

The footgun: params.permit! (with the exclamation mark) re-introduces the original vulnerability by allowing every field through. grep -rn "permit!" app/ should be the first command you run in a white-box review. Related patterns worth checking: permit(*params.keys), direct assign_attributes(params[:user]) without filtering, and merge(params[:user]) slipped into an otherwise safe permit chain.


SECRET_KEY_BASE and the cookie-deserialization chain#

Every Rails app has a SECRET_KEY_BASE used to sign and encrypt cookies, signed IDs, and similar tokens. On older Rails (pre-4.1) the equivalent was secret_token. If you have the key, you can forge anything Rails would sign — but what that buys you depends heavily on the Rails version.

Rails 2.x / 3.x. The default session serializer was Marshal, which means the session cookie was a Marshal-serialized Ruby object. With secret_token in hand, an attacker built a payload using a known Ruby deserialization gadget chain (the classic ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy, or later Gem::Requirement-based chains), signed it with the leaked secret, and got RCE the moment the server deserialized the session. Metasploit’s exploit/multi/http/rails_secret_deserialization automates this — and it targets both secret_token (Rails 2/3) and secret_key_base (Rails 4+) deployments that still use the Marshal serializer. CVE-2013-0156 expanded the surface to the XML parser via YAML/Symbol deserialization, but the cookie path was the one that mattered for stolen-secret scenarios.

Rails 4.1 and later. New apps generated by Rails 4.1+ use JSON as the cookie serializer via the generator-emitted initializer, and JSON deserialization doesn’t have the same gadget surface. (Apps upgraded from Rails 3.x keep :marshal unless the team explicitly flipped it — worth checking config/initializers/cookies_serializer.rb against any Rails app you’re assessing.) On a JSON-serialized session, SECRET_KEY_BASE alone no longer gives you a one-shot RCE. It still gives you:

  • Forged session cookies — full account takeover for any user whose ID you know.
  • Forged signed IDs, signed global IDs, and signed_id claims.
  • Decryption of any cookies.signed[:x] or cookies.encrypted[:x] value the app stores client-side.
  • RCE if the app uses a :marshal or :hybrid cookie serializer (some legacy upgrades never flipped this), stores Marshal-serialized data in signed/encrypted cookies elsewhere, or pulls Marshal payloads out of Rails.cache / Sidekiq with attacker-controlled keys.

Where the key tends to leak: .env files checked into the repo, config/secrets.yml left in the deployment archive, master.key committed alongside credentials.yml.enc, environment variables visible in debug pages, error backtraces that include the loaded environment. GitHub code search and the Wayback Machine are both worth twenty minutes before any active testing. Once you have the key, Justin Weiss’s cookie_decryptor gem handles inspection, the Metasploit module above handles the forging path for Marshal-serialized targets, and custom scripts cover the modern JSON / signed-ID forgery paths.


XSS via raw and html_safe
#

Rails auto-escapes string interpolation in ERB templates, which defaults users out of the most common XSS class. Developers turn the escaping off in three ways:

<%# Bypasses escaping completely %>
<%= @user.bio.html_safe %>
<%= raw @user.bio %>
<%= sanitize(@user.bio, tags: %w[a b i strong]) %>

html_safe flags the string as already-safe without doing anything to it. raw is the same function in standalone form. sanitize runs a real HTML sanitizer, but the moment the allowlist includes a and the attacker controls the href, javascript: URLs go right through unless the developer also restricted attributes.

Search the view layer for all three keywords during a review:

grep -rn "html_safe\|raw(\| sanitize(" app/views/ app/helpers/

If user input flows into any of them without an upstream sanitizer the developer remembered to call, you have XSS.


IDOR
#

The Rails ergonomics make IDOR easy to ship by accident. The two patterns:

# Vulnerable: any logged-in user can read any document by ID
def show
  @document = Document.find(params[:id])
end

# Scoped to the current user's association
def show
  @document = current_user.documents.find(params[:id])
end

The fix is to go through an association the current user actually owns. From a tester’s seat, the routine is: log in as user A, find a resource you own, change the integer in the URL to one outside your set, see if user B’s data shows up. UUIDs slow this down but don’t fix it — an unguessable identifier is still unauthorized data if the controller doesn’t check ownership.

A second variant: filtering by integer ID but not by ownership in a search endpoint, e.g. Document.where(id: params[:id]) outside any user-scoped relation. Same bug, same fix.


Tools that should run before any manual review
#

Two things to run before grep-touring the source.

Brakeman is a static analysis security scanner for Rails. It parses the source (no need to run the app) and reports SQLi, XSS, mass assignment, unsafe redirects, weak crypto, default credentials, and dozens of other categories. Configure it in CI for any Rails app you ship; against a target you’ve cloned, just run it.

gem install brakeman
brakeman -A

bundler-audit checks Gemfile.lock against the Ruby Advisory Database for known CVEs in dependencies. Rails apps accumulate dependency debt fast, and a single known-vulnerable gem version is often the cheapest finding in an assessment.

gem install bundler-audit
bundle audit check --update

Both tools have decent false-positive rates worth knowing about. Brakeman flags every html_safe whether or not the upstream is sanitized, and bundler-audit flags advisories regardless of whether the vulnerable code path is reachable. Triage accordingly.


Wrap-up
#

Rails is opinionated, which is good for security in the abstract — the secure path is the default path — and bad in practice when developers shortcut around the defaults. The recurring patterns: permit! instead of explicit field allowlists; html_safe on attacker-controlled strings; Model.find(params[:id]) without scoping; secrets in .env files that ended up in the repo; dependency lock files that haven’t seen bundle update in two years.

First-pass routine on a Rails target: run Brakeman, run bundler-audit, grep for the dangerous-method patterns above, check for leaked SECRET_KEY_BASE (GitHub, Wayback Machine, exposed .env endpoints), and confirm the Rails version — half the public exploits depend on whether you’re against pre-4.1 or modern Rails.


References
#

UncleSp1d3r
Author
UncleSp1d3r
As a computer security professional, I’m passionate about building secure systems and exploring new technologies to enhance threat detection and response capabilities. My experience with Rails development has enabled me to create efficient and scalable web applications. At the same time, my passion for learning Rust has allowed me to develop more secure and high-performance software. I’m also interested in Nim and love creating custom security tools.
Ruby - This article is part of a series.
Part 2: This Article