Software Securityhome
Fix a findingSearch
Fix card · A08:2025 Software or Data Integrity Failures

Fix insecure deserialization in Ruby on Rails

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Do not rebuild objects from the token: Base64-decode it and JSON.parse the bytes, then copy only the fields you expect into a fixed shape; Marshal.load runs code chosen by the bytes. For YAML use YAML.safe_load, never YAML.unsafe_load. Blocklisting class or tag names does not work: the next gadget is a class you did not list.

Stack
  • Language Ruby
  • Runtime Ruby 4.0.7
  • Framework Ruby on Rails 8.1.4 (API controller) with Marshal, Psych (YAML) and JSON
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks6 cards
Download the project ZIP, 23 KB
On this page

Steps

  1. Find every Marshal.load, YAML.load, YAML.unsafe_load and Oj.load(mode: :object) whose input is request data: a cookie, a token, a body or a header.
  2. Change the wire format to data, not objects: send the profile as JSON and read it with JSON.parse. JSON.parse only ever produces strings, numbers, arrays, hashes, booleans and nil.
  3. Validate the parsed data into a fixed shape: copy only the fields you expect, each checked for its type, and reject anything else.
  4. For YAML, use YAML.safe_load; it permits only basic types and raises Psych::DisallowedClass on a !ruby/object tag. Add permitted_classes only for specific, safe classes (Date, Time) you actually need.
  5. Never Marshal.load bytes a client can influence, and do not try to make it safe with a blocklist of class names; Marshal has no allowlist hook.
  6. Add regression tests that send a gadget whose only effect is a marker (a file written during restore) and require that the marker never appears.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerableapp/deserialize/deserialize/vulnerable.rbRuby
def self.restore_profile(token)
  # Marshal.load rebuilds whatever object the bytes describe, calling its restore hooks.
  Marshal.load(Base64.strict_decode64(token)).to_h
rescue ArgumentError, TypeError
  raise ArgumentError, "invalid token"
end

With IMPL=vulnerable, 2 of 7 tests fail (2 exploit tests).

The fix: JSON into a fixed shape

Fixedapp/deserialize/deserialize/fixed.rbRuby
def self.restore_profile(token)
  data = JSON.parse(Base64.strict_decode64(token))
  raise ArgumentError, "profile must be an object" unless data.is_a?(Hash)

  ALLOWED_PROFILE_FIELDS.each_with_object({}) do |field, profile|
    value = data[field]
    raise ArgumentError, "profile.#{field} must be a string" unless value.is_a?(String)

    profile[field] = value
  end
rescue ArgumentError, JSON::ParserError => e
  raise ArgumentError, e.is_a?(JSON::ParserError) ? "invalid token" : e.message
end

With IMPL=fixed, 7 of 7 tests pass.

The fix: safe YAML

Fixedapp/deserialize/deserialize/fixed.rbRuby
def self.load_preferences(document)
  # safe_load permits only hashes, arrays, strings, numbers, booleans and nil; a !ruby/object
  # tag raises Psych::DisallowedClass, which never constructs the object.
  data = YAML.safe_load(document)
  raise ArgumentError, "preferences must be a mapping" unless data.is_a?(Hash)

  data.slice(*ALLOWED_PREFERENCE_FIELDS)
rescue Psych::DisallowedClass, Psych::SyntaxError
  raise ArgumentError, "invalid preferences document"
end

With IMPL=fixed, 7 of 7 tests pass.

A fix that looks safe but is not

Wrong fixapp/deserialize/deserialize/wrong.rbRuby
def self.restore_profile(token)
  raw = Base64.strict_decode64(token)
  # Scan the serialized bytes for known-bad class names, then load anyway.
  raise ArgumentError, "blocked class in token" if BLOCKED_TOKENS.any? { raw.include?(_1) }

  Marshal.load(raw).to_h
rescue ArgumentError, TypeError => e
  raise ArgumentError, e.message == "blocked class in token" ? e.message : "invalid token"
end

With IMPL=wrong, 2 of 7 tests fail (2 exploit tests).

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Testtest/session_test.rbRuby
# Exploit: each payload's only job is to fire the harmless marker during deserialization. The fix
# must leave the marker untouched.

test "exploit: a Marshal gadget is not executed" do
  Dir.mktmpdir do |dir|
    marker = File.join(dir, "marshal-marker.txt")
    res = restore(pickle_gadget_token(marker))
    assert_not File.exist?(marker), "insecure deserialization: the Marshal gadget ran"
    assert_equal 400, res.status
  end
end

test "exploit: a YAML object tag is not executed" do
  Dir.mktmpdir do |dir|
    marker = File.join(dir, "yaml-marker.txt")
    res = load_preferences(yaml_gadget_document(marker))
    assert_not File.exist?(marker), "insecure deserialization: the YAML object tag ran"
    assert_equal 400, res.status
  end
end

Pitfalls

  • Marshal.load runs a restored object's marshal_load and init_with hooks while rebuilding it, so the bytes decide what runs; scanning the serialized string for bad class names (this card's wrong implementation) misses any gadget class you did not name.
  • YAML.load changed in Psych 4 to be safe_load by default, but YAML.unsafe_load (and YAML.load_file with an unsafe loader) still honours !ruby/object, !ruby/hash and !ruby/struct tags, which construct arbitrary objects.
  • A blocklist of tag text in the YAML document (the wrong implementation) is defeated by any class not on the list; the exploit here uses a harmless MarkerGadget the list never mentions.
  • safe_load's permitted_classes is an allowlist: keep it to the few classes you need, and remember a permitted class's own init_with still runs, so only permit classes whose construction is safe.
  • Signing or MACing a token you must round-trip through a client (Rails' MessageVerifier / signed cookies) stops a crafted payload from being swapped in, but a signed Marshal payload is still Marshal: prefer a signed JSON value.
  • Oj, Ox and Marshal-backed caches have the same trap; Oj's default mode is safe, but Oj.load(..., mode: :object) and Oj's object mode are not.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs deserialization/ruby-rails does this for you, in Docker if you do not have Ruby).
  2. The exploit tests send a Marshal payload and a YAML !ruby/object tag whose only job is to write a marker file during restore, and require the marker never appears and the request is refused.
  3. Static analysis is strong for the obvious sinks (CodeQL and Brakeman both model Marshal.load and YAML.unsafe_load) and blind to the blocklist; each card's scannerRules records what was checked and what it found.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix insecure deserialization in Ruby on Rails
ToolRuleWhat it found here
Semgrepp/ruby, p/owasp-top-ten, p/security-auditNo findings on any implementation. These packs do not include a rule for Marshal.load or YAML.unsafe_load reaching request data, so neither the vulnerable Marshal.load/unsafe_load nor the wrong blocklist is flagged.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep:1.180.0@sha256:529ee8a277ec8adc5b534d7c74eea0a47e9de21d62852b6ba7ac6ba9566845c3); semgrep scan --config p/ruby --config p/owasp-top-ten --config p/security-audit app config (59 rules on 9 files)
CodeQLrb/unsafe-deserializationThe Ruby model marks Marshal.load and YAML.load/unsafe_load as unsafe-deserialization sinks whose input is the request. With params passed directly it would flag the vulnerable Marshal.load and YAML.unsafe_load, and the wrong code's Marshal.load and unsafe_load too (the blocklist is not a sanitizer it models); the fixed JSON.parse and YAML.safe_load are not sinks. As written the cards read params through a method and const_get, which the models do not follow, so a run is not expected to report them.Checked: github/codeql main at b5b8165; query and Ruby models read, not executed
BrakemanDeserializeBrakeman 8.1.0No deserialization warning on any implementation. Brakeman's Deserialize check reports Marshal.load, YAML.load and YAML.unsafe_load on request data within one controller or model; the cards pass the token and document from the controller to Deserialize.current, which its single-file taint does not follow. The only warning is Missing Encryption (ForceSSL), because the card has no config/environments/production.rb, which the card leaves out as the other Rails cards do.Checked: Brakeman 8.1.0 in the ruby:4.0.7 image; brakeman -A -q --no-pager -f json /card (all checks)

Libraries and versions

Libraries and versions
ComponentVersion
LanguageRuby
RuntimeRuby 4.0.7
FrameworkRuby on Rails 8.1.4 (API controller) with Marshal, Psych (YAML) and JSON
rails (railties, actionpack)8.1.4
psych (YAML.safe_load / unsafe_load, default gem)as shipped with Ruby 4.0.7
minitest6.0.6
minitest-reporters (JUnitReporter)1.8.0

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the deserialization/ruby-railsfolder.

Shell
BUNDLE_PATH=vendor/bundle BUNDLE_FROZEN=true bundle install
Shell
# Expected: every test passes
IMPL=fixed BUNDLE_PATH=vendor/bundle bundle exec ruby -Itest test/session_test.rb

# Expected: an exploit test fails
IMPL=vulnerable BUNDLE_PATH=vendor/bundle bundle exec ruby -Itest test/session_test.rb

# Expected: a test fails
IMPL=wrong BUNDLE_PATH=vendor/bundle bundle exec ruby -Itest test/session_test.rb

Without Ruby installed, run the same commands with Docker in the image the card was verified in, pinned by its digest. The named volumes keep the downloaded dependencies and the build cache between runs, so the test runs need no network.

Shell
docker run --rm --init \
  -v "$PWD":/card -w /card \
  -e BUNDLE_PATH=vendor/bundle \
  ruby:4.0.7-trixie@sha256:0192d5244fa068587325c3ef4f886fdb2d6775689757d598f9a5261d0f86d40e \
  sh -c 'BUNDLE_PATH=vendor/bundle BUNDLE_FROZEN=true bundle install'
Shell
# Expected: every test passes
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -e IMPL=fixed -e BUNDLE_PATH=vendor/bundle \
  ruby:4.0.7-trixie@sha256:0192d5244fa068587325c3ef4f886fdb2d6775689757d598f9a5261d0f86d40e \
  sh -c 'bundle exec ruby -Itest test/session_test.rb'

# Expected: an exploit test fails
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -e IMPL=vulnerable -e BUNDLE_PATH=vendor/bundle \
  ruby:4.0.7-trixie@sha256:0192d5244fa068587325c3ef4f886fdb2d6775689757d598f9a5261d0f86d40e \
  sh -c 'bundle exec ruby -Itest test/session_test.rb'

# Expected: a test fails
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -e IMPL=wrong -e BUNDLE_PATH=vendor/bundle \
  ruby:4.0.7-trixie@sha256:0192d5244fa068587325c3ef4f886fdb2d6775689757d598f9a5261d0f86d40e \
  sh -c 'bundle exec ruby -Itest test/session_test.rb'

Each run writes a JUnit report to reports/TEST-*.xml. From the top folder of the download, node run-all.mjs deserialization/ruby-rails runs all three modes and checks the expected results, in the container, so it needs Docker and Node.js but not the toolchain.

Download the project ZIP, 23 KB

Verified

Machine
Debian 13 (trixie) in Docker, on macOS 27.0 (Darwin 27.0.0, arm64)
Toolchain
  • ruby 4.0.7 (ruby:4.0.7-trixie image, linux/arm64)
  • bundler 4.0.20
  • rails 8.1.4
  • minitest 6.0.6
  • minitest-reporters 1.8.0
  • semgrep 1.180.0
  • brakeman 8.1.0
Matrix
fixed: 7 of 7 tests pass; vulnerable: 2 of 7 tests fail (2 exploit tests); wrong: 2 of 7 tests fail (2 exploit tests)
Test results
fixed 7/7 pass; vulnerable 2/7 fail (both exploit tests: the Marshal gadget and the YAML !ruby/object tag both ran during restore); wrong 2/7 fail (both exploit tests: the blocklist does not name MarkerGadget, so both gadgets still run). Each test run in a container with --network=none after install.
Dependency freshness
Same Gemfile.lock as the other Action Pack-only Rails cards: Rails 8.1.4 (2026-09-24), with uri held at 1.1.1 and prettyprint at 0.2.0 because 1.1.2 and 0.2.1 (both 2026-10-08) were three days old and are not security releases. Psych and JSON are default gems of Ruby 4.0.7 (2026-09-15), the latest 4.0 release. OSV reported no known vulnerabilities for any pin.

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. CWE-502: Deserialization of Untrusted Data
  2. OWASP Top 10:2025 A08 Software or Data Integrity Failures
  3. OWASP Deserialization Cheat Sheet
  4. Ruby psych: Psych.safe_load and the permitted_classes allowlist
  5. Ruby: Marshal security (load runs restore hooks)
  6. Ruby on Rails Security Guide: deserialization and signed cookies

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.