Ruby gem installations can expose you to lockfile injection attacks
2022年8月17日
0 分で読めますIn this post, we’ll look at the security blindspots of lockfile injection that a Ruby gem might expose via its Gemfile.lock
. As a prelude to that, we will open up with a brief introduction to Ruby and third-party dependencies management around RubyGems and Bundler.
Web developers often work on Ruby projects, but are mostly referring to them as the popular open source web application framework Ruby on Rails. Ruby itself is the underlying open source and dynamic language that powers the project.
To make Ruby code modular and reusable, they are packaged using the RubyGems project, which is often interacted with via the command line interface as the gem executable. These packages are referred to as gems and are referenced in a package manifest file called a Gemfile
.
RubyGems are now bundled with the Ruby language itself and are not required to be installed separately. While a developer could install a gem as simple as gem install <gem-name>
, the overall management of transitive dependencies, pinning, and versioning is much more complex.
This is where the Bundler project comes in — the community's favorite Ruby package manager.
Ruby's Gemfile package manifest and transitive dependencies
Ruby projects manage their third-party dependencies by defining a Gemfile
file. This file includes project metadata and also lists all the dependencies that are used to build the project.
Here's an example for a Ruby project with a Gemfile:
1# Gemfile
2
3source 'https://rubygems.org'
4
5gem 'rails', '~> 5.2.0'
6gem 'nokogiri', '~> 1.6.1'
7gem 'rspec'
The source
definition declares where the bundler
tool can find these gem libraries. The repository at https://rubygems.org is the default — and open source — Rubygems registry.
Each dependency is then declared in this file as a gem definition, which may include a version constraint, or not at all.
Using Ruby's bundler to install and manage gems
Bundler — the open source gem management project — is then tasked with installing and resolving dependencies. To get started with bundler to manage your Ruby dependencies, first, you’ll need to have both Ruby and RubyGems installed in your environment. Then, it's as easy as installing the bundler
tool as follows:
1gem install bundler
Installing Ruby dependencies and Gemfile.lock
Once the bundler tool is installed, dependencies that have been declared in the Gemfile
manifest can be installed by running the following command:
1bundle install
This is also equivalent to running bundle
with no extra arguments, such as:
1bundle
To keep track of the dependencies that have been installed, a Gemfile.lock
file is consulted in order to recreate the exact state of dependency versions across the tree of nested dependencies. If no such file exists, then bundler will proceed to install dependencies and then write the Gemfile.lock
file.
Note that in some cases, you'll need to specify the --path
option to the bundle install
to allow for files to be created.
Once executed, a bundler install log will look as follows:
1$ bundle install --path vendor/bundle
2
3Fetching gem metadata from https://rubygems.org/...........
4Fetching gem metadata from https://rubygems.org/.
5Resolving dependencies...
6Fetching rake 13.0.6
7Installing rake 13.0.6
8Fetching concurrent-ruby 1.1.10
9Installing concurrent-ruby 1.1.10
10Fetching i18n 1.12.0
11Installing i18n 1.12.0
12Fetching minitest 5.16.2
13Installing minitest 5.16.2
14
15[... lines were removed for brevity ...]
16
17Fetching rspec 3.11.0
18Installing rspec 3.11.0
19Bundle complete! 3 Gemfile dependencies, 47 gems now installed.
20Bundled gems are installed into `./vendor/bundle`
Here are the two primary resulting outcomes from the bundler install:
Third-party gem dependencies are installed into the
vendor/bundle
directory.The
Gemfile.lock
file is created to record the exact state of the dependencies across the tree of nested dependencies. Assuming this file was not previously created.
Inspecting the dependencies lockfile, Gemfile.lock
, we see the following information:
1GEM
2 remote: https://rubygems.org/
3 specs:
4 actioncable (5.2.8.1)
5 actionpack (= 5.2.8.1)
6 nio4r (~> 2.0)
7 websocket-driver (>= 0.6.1)
8 actionmailer (5.2.8.1)
9 actionpack (= 5.2.8.1)
10 actionview (= 5.2.8.1)
11 activejob (= 5.2.8.1)
12 mail (~> 2.5, >= 2.5.4)
13 rails-dom-testing (~> 2.0)
14
15 [... lines were removed for brevity ...]
16
17 rails-dom-testing (2.0.3)
18 activesupport (>= 4.2.0)
19 nokogiri (>= 1.6)
20 rails-html-sanitizer (1.4.3)
21 loofah (~> 2.3)
22 railties (5.2.8.1)
23 actionpack (= 5.2.8.1)
24 activesupport (= 5.2.8.1)
25 method_source
26 rake (>= 0.8.7)
27 thor (>= 0.19.0, < 2.0)
28 rspec (3.11.0)
29 rspec-core (~> 3.11.0)
30 rspec-expectations (~> 3.11.0)
31 rspec-mocks (~> 3.11.0)
32 rspec-assertions (~> 2.9.0)
33 rspec-core (3.11.0)
34 rspec-support (~> 3.11.0)
35 rspec-expectations (3.11.0)
36 diff-lcs (>= 1.2.0, < 2.0)
37 rspec-support (~> 3.11.0)
38 rspec-mocks (3.11.1)
39 diff-lcs (>= 1.2.0, < 2.0)
40 rspec-support (~> 3.11.0)
41 rspec-support (3.11.0)
42
43PLATFORMS
44 ruby
45
46DEPENDENCIES
47 nokogiri
48 rails (~> 5.2.0)
49 rspec
50
51BUNDLED WITH
52 1.17.2
RubyGems lockfile injection of Gemfile.lock
Lockfile injection is an attack vector in which a malicious user modifies the Gemfile.lock
file to contain a different set of dependencies than the Gemfile
manifest. Due to the nature of the machine-generated format of lockfiles, such as Gemfile.lock
, they are not often reviewed by human eyes during code reviews. To further add to the problem at hand, a pull request view of code changes relating to the file will result in a collapsed view of the lockfile, when large amounts of code changes have been made.
To prove the severity and commonality of this attack, the above-provided code snippet of a Gemfile.lock
file is modified to contain a different set of dependencies. Can you find which malicious packages were added? Not as easy to spot as you'd expect! I planted the made-up Ruby gem called rspec-assertions (~> 2.9.0)
as a dependency in the rspec
tree. This dependency doesn't exist at all, but it could if I created a malicious ruby gem that I wanted to slip in as part of the dependencies you install.
The attack is carried out by modifying the Gemfile.lock
file and adding a new nested dependency that is in the tree of another one. For example, consider the following lockfile contents (showing just the beginning of it):
1GEM
2 remote: https://rubygems.org/
3 specs:
4 actioncable (5.2.8.1)
5 actionpack (= 5.2.8.1)
6 nio4r (~> 2.0)
7 websocket-driver (>= 0.6.1)
The above Gemfile.lock
is a valid dependency tree resolution. However, a malicious user can update this bundler-generated dependency tree so that it adds the legitimate gem called digest
as follows:
1GEM
2 remote: https://rubygems.org/
3 specs:
4 actioncable (5.2.8.1)
5 actionpack (= 5.2.8.1)
6 digest (3.1.0)
7 nio4r (~> 2.0)
8 websocket-driver (>= 0.6.1)
If you'd now run bundle
in the command line prompt in order to keep up to date with package updates, bundler would install the digest
gem. We can take a further look at the vendor/
directory to verify the new gem:
1ls -alh vendor/bundle/ruby/2.6.0/gems/digest-3.1.0
2total 16
3drwxr-xr-x 6 lirantal staff 192B Jul 18 01:24 .
4drwxr-xr-x 49 lirantal staff 1.5K Jul 18 01:24 ..
5-rw-r--r-- 1 lirantal staff 1.3K Jul 18 01:24 LICENSE.txt
6-rw-r--r-- 1 lirantal staff 3.0K Jul 18 01:24 README.md
7drwxr-xr-x 3 lirantal staff 96B Jul 18 01:24 ext
8drwxr-xr-x 5 lirantal staff 160B Jul 18 01:24 lib
The attack surface in this case allows unsuspecting developers, who may receive package update contributions from the community or other unvetted sources, to inject new malicious gem dependencies into the Gemfile.lock
, hence poison the lockfile, and thereby target project maintainers.
The attack surface of Ruby gems lockfile injection
So what’s the impact of having arbitrary ruby gems that are controlled by an attacker, as part of a Ruby application's dependency tree?
Since the newly added gem dependency is handcrafted for malicious purposes, it isn't likely to be required by source code and called at runtime by another dependency. But, we could exploit a similar attack vector that exists in the JavaScript npm package manager which allows for arbitrary commands to be run during the install time of dependencies.
Dating all the way back to 2011, the Ruby ecosystem has made a smart decision in which it doesn't allow package maintainers to hook into the installation process of their dependencies, in order to execute arbitrary commands. References to that discussion are available in a GitHub issue as they iterate why an npm-like postinstall
script is a bad idea, and rightfully so.
That said, Ruby gems allow bundling native C code that would get compiled during install time. This is the opening we are looking for in order to leverage the lockfile injection method towards arbitrary code execution. In fact, this method has been used in the wild since 2008 and is still a functional way to hook into Ruby's gem installation process.
Let’s build a Ruby gem for malicious purposes
Let's get started with building a Ruby gem in which we will add instructions to work around the built-in mechanism of the Gemfile spec that doesn't natively allow for code execution during install.
Naming things is hard in software, but not for us. We'll go with naming our Ruby gem woof
. The following is our Ruby gem's gemspec file called woof.gemspec
:
1Gem::Specification.new do |s|
2 s.name = "woof"
3 s.version = "1.0.0"
4 s.summary = "Woof"
5 s.description = "A hello world gem, used for testing"
6 s.authors = ["Liran Tal"]
7 s.email = "liran@snyk.io"
8 s.files = ["lib/woof.rb"]
9 s.homepage =
10 "https://rubygems.org/gems/woof"
11 s.license = "MIT"
12 s.extensions << 'ext/woof/extconf.rb'
13 end
14
Then create the primary source file for our Ruby gem, lib/woof.rb
:
1class Woof
2 def self.hi
3 puts "woof woof"
4 end
5 end
Then, we can create Ruby's native extconf.rb
extension file which allows us to declare information needed to create makefiles, provide instructions related to extending Ruby with native C extensions, and generally execute any Ruby code.
Create the file ext/woof/extconf.rb
with the following contents:
1# Use the Ruby languague API to create an empty file during install /tmp/woof
2File.write("/tmp/woof", "")
3
4require 'mkmf'
5create_makefile('hello_c')
The extension code snippet above is a simple example of how to create a native C extension for Ruby. The create_makefile
method is a Ruby method that is used to create a makefile for a native C extension. The create_makefile
method takes a single argument, the name of the extension. The name of the extension should be the same as the name of the Ruby gem.
The very first line of the extension code is a call to the File.write
method, which is a Ruby method that writes a file to the file system. The first argument to File.write
is the path to the file to write to, and the second argument is the content to write to the file.
In our effort to simulate a (benign) malicious package, we see that upon installing our woof Ruby gem, a new file will be created in the file system. The file will be named /tmp/woof
and will be empty.
For the Ruby gem to successfully install, we do need to provide a valid hello.c
file. It can be a simple *hello world* program from Ruby's stock docs as follows:
1#include <stdio.h>
2#include "ruby.h"
3
4VALUE world(VALUE self) {
5 printf("Hello World!\n");
6 return Qnil;
7}
8
9// The initialization method for this module
10void Init_hello_c() {
11 printf("Hello World!\n");
12 VALUE HelloC = rb_define_module("HelloC");
13 rb_define_singleton_method(HelloC, "world", world, 0);
14}
Finally, we can build this Ruby gem. In the root directory of this project, run the following command:
1gem build woof.gemspec
Once successful, a new gem will be created in the root directory of this project, named woof-1.0.0.gem
.
Now, to test that this Ruby gem indeed works as expected, we can install it and observe that a new file /tmp/woof
has been created (you can verify that it doesn't exist prior to installing the package). Proceed as follows to install the Ruby gem to a local folder:
Create a new temporary directory, such as
mkdir ~/ruby-gem-test
.In this new directory, create a new directory named
vendor
.Instruct Ruby to use the new
vendor
directory as the gem directory and installwoof
gem into it as follows:gem install --local /path/to/woof-1.0.0.gem --install-dir 'vendor'
To confirm that our arbitrary code execution attack has been employed successfully as a Ruby gem installation method, ensure that a new file /tmp/woof
has been created.
Ruby Gem lockfile injection prevention
As shown throughout this article, the Ruby gem installation process allows package maintainers to execute arbitrary code during the install time of their dependencies. This is a powerful attack surface that can be used in conjunction with the lockfile injection attack of a Ruby application, and also more generally in the space of supply chain security in the Ruby ecosystem.
Unfortunately, unlike the npm package manager, which includes a --ignore-script
command line flag, Rub's own gem
CLI does not allow for opting out of the native C extension compilation process during install time, leaving users vulnerable to third-party package maintainers ability to execute arbitrary code.
To help mitigate against this type of attack we recommend the following:
Only manage your Ruby gem dependencies and updates to them, along with changes to the lockfile
Gemfile.lock
using an automated bot to upgrade your Ruby gem versions as needed and provide you with security fixes where possible.If you are accepting contributions from developers to your
Gemfile
andGemfile.lock
files, ensure you are carefully reviewing these code changes and scrutinizing every line of change.Favor installing dependencies with
bundle install --deployment
which calculates the complete dependency tree of all Ruby gems and will halt the install process when theGemfile.lock
differs from the exact gems that should be installed by theGemfile
.
This research is based upon prior original work by me (Liran Tal) in 2019 for disclosing risks of lockfile injection in the JavaScript ecosystem.
More Ruby security resources: