Safeguard
Security Guides

Ruby Security Best Practices: Deserialization, Injection, and the Gem Supply Chain

Rails is safe by default — until a developer reaches for YAML.load, Kernel#open, or a raw-string query. Here are the Ruby footguns and the gem hygiene that keep them closed.

Daniel Osei
Security Researcher
6 min read

Ruby, and Rails in particular, is built to be safe by default: parameterized queries through Active Record, automatic HTML escaping in ERB, CSRF tokens wired into every form. That safety is real, which is exactly why almost every serious Ruby vulnerability comes from a developer stepping around a default rather than a gap in the framework. A handful of methods — YAML.load, Marshal.load, Kernel#open, and raw-string where — convert ordinary convenience into remote code execution, and the gem supply chain underneath carries its own CVEs no matter how careful your first-party code is. In 2022 the Rails team shipped CVE-2022-32224, where a YAML-serialized Active Record column deserialized through YAML.unsafe_load, so an attacker who could influence that stored value could escalate to remote code execution. In March 2025, CVE-2025-27610 let a crafted, percent-encoded request read files outside the intended public directory served by Rack::Static. Neither needed exotic tooling. This guide walks the Ruby footguns worth grepping for and the supply-chain hygiene that keeps the gems beneath you honest.

Why is deserialization the most dangerous method call in Ruby?

Because Ruby's native serializers will happily instantiate arbitrary objects, and object instantiation in Ruby can trigger code. Marshal.load and the historical YAML.load can be chained into RCE the same way Java's ObjectInputStream can. CVE-2022-32224 was exactly this: a YAML-serialized attribute became a gadget sink.

# DANGEROUS -- full object deserialization of untrusted input
data = YAML.load(request.body.read)      # can instantiate arbitrary objects
obj  = Marshal.load(cookies[:session])   # classic RCE gadget sink

# SAFE -- restrict to primitive types only
data = YAML.safe_load(request.body.read, permitted_classes: [Symbol, Date])

Never Marshal.load anything that crossed a trust boundary — Marshal has no safe mode — and use YAML.safe_load with an explicit permitted_classes allowlist everywhere else. On Rails, keep the framework patched: the fix for CVE-2022-32224 shipped in 5.2.8.1, 6.0.5.1, 6.1.6.1, and 7.0.3.1, which switched the default serialized-column loader to YAML.safe_load.

How does command injection sneak into Ruby code?

Through the methods that hand a string to a shell. Backticks, %x[], and system("... ") with an interpolated string all invoke /bin/sh, so any user input can break out with a semicolon or pipe. Ruby adds a second, less obvious sink: Kernel#open treats a filename beginning with a pipe character as a command to execute.

# DANGEROUS
system("convert #{params[:file]} out.png")   # shell metacharacters win
open(params[:path])                           # a leading "|" runs a shell

# SAFE -- array form bypasses the shell entirely
system("convert", params[:file], "out.png")
File.open(params[:path])                       # never Kernel#open on user input

The array form of system, exec, and IO.popen passes arguments straight to the OS without a shell, which removes the metacharacter class of bug outright.

What still causes SQL injection when Active Record exists?

Raw string fragments. Active Record is safe when you pass conditions as a hash or a parameterized array, and unsafe the moment you interpolate into a string.

# DANGEROUS
User.where("name = '#{params[:name]}'")

# SAFE
User.where(name: params[:name])
User.where("name = ?", params[:name])

The same rule extends to order, pluck, group, and find_by_sql — any place a string reaches SQL, a bound parameter or an allowlist belongs there instead. Mass assignment is the sibling risk: always filter incoming attributes with strong parameters, such as params.require(:user).permit(:name, :email), so a crafted request cannot flip admin to true on a model that exposes it.

Where does the gem supply chain bite?

In the transitive dependencies you never chose. A Rails app resolves dozens of gems through Gemfile.lock, and a vulnerable rack, nokogiri, or devise deep in that tree is exploitable no matter how clean your controllers are. CVE-2025-27610 in Rack::Static disclosed sensitive files through path traversal; it was fixed in rack 2.2.13, 3.0.14, and 3.1.12. The community ruby-advisory-db and the bundler-audit gem give you a baseline scan:

gem install bundler-audit
bundler-audit check --update

Commit Gemfile.lock, run the audit in CI as a failing gate, and pin gems to patched versions rather than floating on loose constraints that a compromised release could satisfy.

Ruby security checklist

PracticeWhy it matters
Replace YAML.load/Marshal.load with safe_load + allowlistNative deserialization is an RCE sink
Use array-form system/exec; never Kernel#open on inputRemoves shell and pipe-command injection
Pass Active Record conditions as hashes or bound paramsEliminates SQL injection
Filter attributes with strong parametersBlocks mass-assignment privilege escalation
Keep Rails and Rack patched to current releasesCloses CVE-2022-32224 and CVE-2025-27610
Run bundler-audit in CI and commit Gemfile.lockCatches vulnerable transitive gems

How Safeguard Helps

Safeguard's software composition analysis resolves your entire Gemfile.lock — including the transitive rack, nokogiri, and devise versions you did not add directly — and applies call-path reachability so a gem CVE your code can actually trigger is separated from one that sits dormant in the tree. Griffin, our AI analysis engine, inspects new gem releases for the behavioral signatures of a hijacked package before a build pulls them in. When a patched version exists, auto-fix opens a tested pull request that bumps the gem for you, and developers can run the same scan locally with the Safeguard CLI before they ever push. The framework discipline — safe deserialization, bound queries, strong parameters — stays yours; Safeguard secures the gem supply chain beneath it.

Bring your Ruby supply chain under control — start for free or read the documentation.

Frequently Asked Questions

Is YAML.load safe in modern Ruby?

Recent Psych versions made the bare YAML.load safer than it once was, but relying on that is fragile across versions and across gems that call it internally. The durable practice is to call YAML.safe_load explicitly with a permitted_classes allowlist, and to never pass untrusted data to Marshal.load at all, because Marshal has no safe mode.

Does Rails protect me from SQL injection automatically?

Only when you use Active Record the intended way — hash conditions or parameterized arrays. The moment you interpolate user input into a string fragment inside where, order, or find_by_sql, you are back to raw SQL and fully exposed. Treat every string that reaches the query builder as a place a bound parameter belongs.

How do I find vulnerable gems in my project?

Run bundler-audit, which checks your Gemfile.lock against the community ruby-advisory-db, and wire it into CI as a failing gate. For transitive depth and reachability — knowing whether a flagged gem is actually called — a reachability-aware SCA tool narrows the list to the CVEs that matter in your code.

What is the risk with Kernel#open specifically?

Kernel#open interprets a string starting with a pipe character as a shell command to execute. If user input can control the start of the path, an attacker supplies a piped command and runs code. Use File.open for files and a dedicated HTTP client for URLs, and never route user-controlled paths through Kernel#open.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.