A high-severity improper access control flaw on RubyGems.org allowed organization administrators to bypass role-based permission boundaries, elevate an account under their control to full Organization Owner, and permanently evict legitimate founders and maintainers from the organization's gem ecosystem. The vulnerability, uncovered by security researcher Tim Haines and coordinated through HackerOne, broke a foundational invariant of RubyGems' organization model: that an administrator can never create or appoint an owner.

Organizations on RubyGems.org represent the primary governance structure for enterprise engineering teams, open-source foundations, and commercial software vendors distributing critical Ruby libraries. Under RubyGems' intended security architecture, organizations enforce a strict separation between Owners (who hold unilateral control over organization membership, gem namespace transfers, and billing) and Admins (who can manage daily package updates and maintainer roles but are strictly barred from granting the Owner role). By weaponizing the multi-step Gem Transfer workflow, an administrator could circumvent these policy controls entirely, achieving a complete, silent takeover of the organization and locking out the legitimate publishers.

Vulnerability & Disclosure Telemetry

  • Target Platform: RubyGems.org (rubygems/rubygems.org)
  • Disclosure Coordination: HackerOne Report #3938927 (Disclosed October 15, 2026)
  • Vulnerability Classification: Improper Access Control (CWE-284) / Privilege Escalation
  • Severity Rating: High (CVSS 4.0 Base Score: 7.0 — CVSS:4.0/AV:N/AC:L/AT:N/PR:H/UI:N/VC:N/VI:H/VA:H/SC:N/SI:N/SA:N)
  • Remediation Commit: Merged in upstream commit f6f674e7c3 (September 15, 2026)
  • Reporter Credit: Tim Haines (timhaines)

The RBAC Architecture of RubyGems.org Organizations

Modern package registries operate as high-value apex targets in the software supply chain. A single compromised package can propagate malicious code downstream to millions of web servers, continuous integration pipelines, and production environments. To mitigate individual account takeovers, RubyGems.org introduced Organizations, allowing enterprises to manage shared package ownership without relying on single-developer accounts.

The RubyGems access control model establishes three tiered roles within an organization:

  1. Owner: Possesses complete administrative authority. Owners can invite new owners, delete the organization, configure payment credentials, manage all gems, and evict any member.
  2. Admin: Responsible for day-to-day organizational operations. Admins can add existing gems to the organization, invite members with the admin or maintainer roles, and push gem releases. However, admins are strictly forbidden from creating or inviting new Owners.
  3. Maintainer: Scoped exclusively to publishing, updating, and yanking specific gems assigned to their membership.

To enforce this boundary across direct membership invitations, RubyGems implemented declarative authorization policies through Pundit. Specifically, app/policies/membership_policy.rb defines the boundary for role creation:

def create?
  minimum_required_role = membership.owner? ? :owner : :admin
  organization_member_with_role?(user, minimum_required_role) || deny(t(:forbidden))
end

When an administrator navigates to /organizations/:organization_handle/memberships/new and attempts to submit an invitation granting the Owner role, MembershipPolicy#create? evaluates the current user's role. Because the user is merely an admin and not an owner, the policy denies the request, returning an HTTP 404 or 403 Forbidden. Similarly, attempting to add an owner directly via the REST API (POST /api/v1/gems/:gem/owners) rejects the transaction with HTTP 403.

The Gem Transfer Workflow: How the Missing Check Emerged

While the direct membership controllers correctly consulted MembershipPolicy, a security gap existed in an adjacent administrative workflow: the Gem Transfer engine.

To enable developers to migrate standalone gems into an organization, RubyGems provides a dedicated transfer wizard accessible at /gems/transfer. When transferring a gem into an organization, existing individual co-owners or collaborators who held direct access to the gem must be mapped into the target organization so they do not abruptly lose publishing permissions.

The transfer pipeline was governed by two dedicated controllers and background models:

  • Rubygems::Transfer::UsersController: Handled the multi-step form where the transferrer selected roles for invited collaborators.
  • Rubygems::Transfer::ConfirmationsController: Processed the final confirmation and invoked the transfer transaction.
  • RubygemTransfer and OrganizationInvite: ActiveRecord models managing the database state and pending membership invites.

The architectural failure occurred because the transfer path did not consult MembershipPolicy at all. Instead, ConfirmationsController#update authorized the action against OrganizationPolicy#add_gem?:

class Rubygems::Transfer::ConfirmationsController < Rubygems::Transfer::BaseController
  def update
    authorize @rubygem_transfer.organization, :add_gem?
    if @rubygem_transfer.transfer!
      redirect_to rubygem_path(@rubygem), notice: t(".success")
    else
      render :show, status: :unprocessable_entity
    end
  end
end

Because an organization Admin has legitimate authority to add gems to their organization, OrganizationPolicy#add_gem? evaluated to true. The controller then invoked @rubygem_transfer.transfer!, which executed the following sequence in app/models/rubygem_transfer.rb:

def transfer!
  transaction do
    memberships = approved_invites.filter_map { it.to_membership(actor: created_by) }
    organization.memberships << memberships
    # ... transfer gem ownership records ...
  end
end

In app/controllers/rubygems/transfer/users_controller.rb, the strong parameters method permitted invites_attributes: [:id, :role]. When rendering the transfer form, the dropdown populated options directly from OrganizationInvite.roles, offering owner alongside admin and maintainer. Because no validation checked whether the transferrer had sufficient privileges to grant the selected role, an admin could simply specify role: "owner" for any invited collaborator.

When the transfer completed, OrganizationInvite#to_membership copied the unvetted owner role directly into an active Membership record, bypassing the MembershipPolicy#create? guard entirely.

Step-by-Step Attack Walkthrough: From Admin to Total Takeover

In his coordinated disclosure to RubyGems, researcher Tim Haines provided an end-to-end integration test demonstrating how a malicious administrator could exploit this gap to achieve total organizational lockout. The exploit scenario involved three distinct accounts within a target organization named Acme:

  • Founder (Account A): The legitimate organization owner and original author. Acme owns the core production gem organization_gem. The founder's publishing rights depend entirely on their organization membership.
  • Attacker Admin (Account B): A confirmed organization administrator in Acme. Account B also directly owns an unrelated, unorganized package named transfer_gem.
  • Second Attacker Account (Account C): An external accomplice account controlled by the attacker with no prior affiliation with Acme. It is added as a collaborator on transfer_gem.

The exploitation chain unfolded across nine sequential phases:

Phase Action Executed Standard Route Behavior Transfer Exploit Result
1. Direct Invite Probe Admin invites Account C as Owner at /organizations/acme/memberships/new 404 Forbidden MembershipPolicy blocks the grant
2. Direct API Probe Admin attempts direct owner addition via API on organization_gem 403 Forbidden Gems ownership controller blocks request
3. Transfer Initiation Admin starts transfer of transfer_gem into Acme at /gems/transfer — 200 OK (Allowed for Admin)
4. Rogue Role Assignment On Users step, Admin assigns Account C the Owner role — Accepted without validation
5. Invite Acceptance Account C accepts invitation at /organizations/acme/invitation — Account C becomes confirmed Acme Owner
6. Direct Ownership Capture Account C adds itself as direct owner of organization_gem — Allowed: Account C holds full Owner rights
7. Founder Eviction Account C deletes Founder from Acme members directory — Founder removed; publishing rights revoked
8. Lockout Verification Founder attempts gem push organization_gem with API key 200 OK (Baseline) 403 Forbidden (Total Lockout)
9. Attacker Control Account C pushes package release with attacker API key — 200 OK (Exclusive Publishing Authority)

The demonstrated outcome was catastrophic: a low-privileged organization admin turned a routine package ingestion workflow into an unrecoverable organization hostile takeover. The legitimate founders were stripped of their governance roles, while the attacker gained unrestricted authority to publish malicious releases under established, highly trusted gem names.

The Upstream Remediation: Commit f6f674e7c3 Dissected

Following the report, RubyGems security maintainers engineered a multi-layered defense to enforce authorization checks at the controller, view, and model levels. The fix was committed in f6f674e7c3 and deployed across RubyGems.org production.

1. Dynamic Role Filtering in the Wizard Controller

In app/controllers/rubygems/transfer/users_controller.rb, the controller now inspects the current user's organization permissions when populating the role selection options. If the actor is not an organization owner, the owner role is strictly stripped from the view:

def edit
  authorize @rubygem_transfer.organization, :add_gem?
end

def update
  authorize @rubygem_transfer.organization, :add_gem?
  if @rubygem_transfer.update(rubygem_transfer_params)
    redirect_to confirm_transfer_rubygems_path
  else
    render :edit, status: :unprocessable_entity
  end
end

private

def role_options
  @role_options ||= OrganizationInvite.roles.then do |roles|
    roles.delete(:owner) unless policy(@rubygem_transfer.organization).manage_members?
    roles.map { |k, _| [Membership.human_attribute_name("role.#{k}"), k] }
  end
end

2. Model-Level Permission Validation

To defend against parameter tampering where an attacker bypasses the HTML dropdown and posts raw JSON or form parameters containing role: "owner", RubyGems added a mandatory validation hook to app/models/rubygem_transfer.rb:

class RubygemTransfer < ApplicationRecord
  accepts_nested_attributes_for :invites

  validate :rubygems_owned_by_transferrer,
           :created_by_organization_ownership,
           :created_by_invite_permissions,
           :rubygem_existing_organization

  def transfer!
    transaction do
      organization.memberships.lock.find_by!(user: created_by)
      raise ActiveRecord::RecordInvalid, self unless valid?

      memberships = approved_invites.filter_map { it.to_membership(actor: created_by) }
      organization.memberships << memberships
      # ...
    end
  end

  private

  def created_by_invite_permissions
    return unless organization
    actor_membership = organization.memberships.find_by(user: created_by)
    return unless actor_membership

    invites.each do |invite|
      if invite.role == "owner" && !actor_membership.owner?
        errors.add(:invites, "contain a role the transferrer does not have permission to grant")
      end
    end
  end
end

3. Database Concurrency Control & Row Locking

Notice the inclusion of organization.memberships.lock.find_by!(user: created_by) within the database transaction block. By executing a SELECT ... FOR UPDATE lock on the actor's membership record, RubyGems prevents race conditions where an admin might initiate a transfer while simultaneously being demoted or removed by an owner.

Supply Chain Security Implications for Developer Ecosystems

The RubyGems privilege escalation highlights an evolving threat vector facing open-source registries: workflow composition vulnerabilities. In modern web applications, individual API endpoints often have rigorous access controls, but when features span multi-step wizards, state machines, and secondary asynchronous controllers, authorization boundaries frequently break down.

In centralized registries like RubyGems, npm, PyPI, and crates.io, organizational boundaries protect critical infrastructure. If an attacker compromises an individual developer's API token, they can publish a malicious version of that developer's specific packages. But if an attacker escalates privileges to an Organization Owner, they gain access to the organization's entire package portfolio, the ability to modify build pipelines, alter security settings, and evict the legitimate team before an incident response protocol can be activated.

Furthermore, because the attacker in this scenario had legitimate organization admin credentials, traditional threat detection mechanisms (such as IP anomalies or unexpected geolocations) would not raise alarms during the initial transfer request.

Hardening Recommendations for Ruby Engineering Teams

Following the public disclosure of report #3938927, enterprise security teams and open-source maintainers utilizing RubyGems.org should review their organizational posture against the following defensive checklist:

  • Audit Organization Rosters: Navigate to /organizations/:handle/members and verify that every account holding the Owner role corresponds to authorized, active senior personnel. Ensure that all former employees or contractors are completely removed.
  • Enforce Mandatory Hardware MFA: Require all organization owners and administrators to configure FIDO2/WebAuthn hardware security keys. RubyGems supports WebAuthn for account login, gem publishing, and sensitive administrative actions.
  • Adopt Least-Privilege Scoped API Keys: Avoid issuing API tokens with wildcard administrative permissions. Continuous integration and automated deployment runners should only hold the push_rubygem scope, never add_owner or remove_owner.
  • Track Transfer Events: Ensure that internal security information and event management (SIEM) systems monitor RubyGems webhook notifications for unexpected gem ownership transfers or collaborator modifications.