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]}'")), unsafefind_by_sql, and race conditions in uniqueness validation. - Views (
app/views/) — the UI templates (.erb,.haml,.slim). Look here for XSS viaraw,html_safeon user-controlled data,sanitizewith attacker-friendly allowlists, and improperlink_tousage that letsjavascript:URLs through. - Controllers (
app/controllers/) — the request handlers. Look here for authorization bypasses (missingbefore_action), insecure direct object references (IDOR), and mass assignment viapermit!.
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
endThe 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_idclaims. - Decryption of any
cookies.signed[:x]orcookies.encrypted[:x]value the app stores client-side. - RCE if the app uses a
:marshalor:hybridcookie 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])
endThe 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 -Abundler-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 --updateBoth 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#
- Rails Security Guide — the canonical reference, maintained by the Rails core team.
- OWASP Ruby on Rails Cheatsheet — defensive checklist organized by category.
- Brakeman Scanner — Rails static analysis.
- Ruby Advisory Database — backing dataset for bundler-audit.
- Egor Homakov on the 2012 GitHub mass-assignment incident — the bug that finally killed default mass-assignment in Rails 4.
- CVE-2013-0156 writeups — the Marshal/XML deserialization vulnerability that defined the Rails 3 era.