rubygems CVE-2022-29176 explained

06 May 2022

There was a security advisory released for Rubygems.org yesterday.

The advisory was about a bug, which allowed a malicious user to yank certain gems, and to upload different files with the same name, same version number, and different platform.

Let's have a deeper look to see what went wrong by going through the yank process. As a pretext, let's imagine a scenario, where we created a gem called "rails-html" with the intent to gain unauthorised access to the widely used "rails-html-sanitizer" gem.

When a gem is yanked, rubygems creates a deletion record in Api::V1::DeletionsController(link).
This controller has a few before_actions. First of all, it authenticates the user based on the API key, we can pass this check, since we have a valid API key.

Then it fetches the gem based on the gem name provided in the params: link

We set this param to "rails-html", so our gem is found. Then, another before_action verifies that the gem is owned by the authenticated user. So far so good. rails-html is owned by us, we can proceed. And here comes the fun part, because the version needs to be fetched and the way rubygems did that had an interesting opportunity:

def self.find_from_slug!(rubygem_id, slug)
    rubygem = rubygem_id.is_a?(Rubygem) ? rubygem_id : Rubygem.find(rubygem_id)
    find_by!(full_name: "#{rubygem.name}-#{slug}")
end

Slug is coming from a request param, so we could just set it to "sanitizer-1.4.2", and the "rails-html-sanitizer-1.4.2" version would've been fetched from the database and we would've been able to yank it.

This is a really nice find from the researcher and it demonstrates how easy is to shoot ourself in the leg by a small oversight. To mitigate the issue, there is a secondary check introduced to verify that the gem of the fetched version is the same as the one the user is authorised to access.

I also think this code should be refactored to use the association to make it cleaner and easier to follow. Something along these lines:

def self.find_from_slug!(rubygem_id, slug)
    rubygem = rubygem_id.is_a?(Rubygem) ? rubygem_id : Rubygem.find(rubygem_id)
    rubygem.versions.find_by!(full_name: "#{rubygem.name}-#{slug}")
end

Did you enjoy reading this? Follow me on Twitter or sign up to my newsletter for more content like this!

Related posts