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:
- Owner: Possesses complete administrative authority. Owners can invite new owners, delete the organization, configure payment credentials, manage all gems, and evict any member.
- Admin: Responsible for day-to-day organizational operations. Admins can add existing gems to the organization, invite members with the
adminormaintainerroles, and push gem releases. However, admins are strictly forbidden from creating or inviting new Owners. - 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.RubygemTransferandOrganizationInvite: 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/membersand 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_rubygemscope, neveradd_ownerorremove_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.



