Reports
ALTA[AUTENTICACIÓN]#3938927

RubyGems.org: un admin de organización podía crear un propietario usando el traspaso de gemas y echar a los dueños

El flujo de traspaso de gemas a una organización de RubyGems.org solo exigía rol de admin y no aplicaba la política de membresías, así que un admin podía nombrar propietario a una cuenta suya y expulsar a los dueños legítimos.

Resumen
Resumen en castellano de un reporte público, no una traducción literal. El código y los comandos se mantienen como en el original.

Resumen

En RubyGems.org las organizaciones tienen tres niveles de miembro: propietario (owner), administrador (admin) y mantenedor. La regla es que solo un propietario puede dar el rol de propietario a otra cuenta. Esa regla está en MembershipPolicy#create? y se aplica bien en la ruta normal de invitar miembros.

El problema estaba en otro camino: el asistente de traspaso de gemas a una organización (/gems/transfer). En su último paso permite asignar roles en la organización a los propietarios de la gema que se traspasa, y para autorizar todo el proceso solo comprobaba OrganizationPolicy#add_gem?, que se conforma con el rol de admin. Nunca pasaba por MembershipPolicy. Resultado: un admin podía traspasar una gema suya, marcar como Owner a una segunda cuenta que controlase y, con ella ya dentro como propietaria, expulsar a los dueños legítimos y quitarles el derecho a publicar las gemas de la organización.

El investigador lo probó sobre una instancia local del código de RubyGems.org (commit 4e36c18deef651564e7029ad8c00594f7e207d1b, revisado otra vez contra 257b9c548449fe35c398421e160c472483e151a7), no en producción. Las rutas de traspaso no estaban detrás de ninguna opción desactivada.

Dónde faltaba la comprobación

Esta es la política que debería haberse aplicado (app/policies/membership_policy.rb:10-13):

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

En cambio, el traspaso seguía este recorrido sin consultarla:

  • app/controllers/rubygems/transfer/confirmations_controller.rb:8-14 comprueba OrganizationPolicy#add_gem? (basta con ser admin) y llama a transfer!.
  • app/controllers/rubygems/transfer/users_controller.rb:26-28 acepta el parámetro invites_attributes[id, role], con el rol que elija quien hace el traspaso.
  • app/models/organization_invite.rb:12-20 copia ese rol a una Membership nueva.
  • app/models/rubygem_transfer.rb:18-21 la añade a la organización.

El commit 0f40f5cb (22 de mayo de 2026) ya había añadido esta misma comprobación al controlador de miembros, con tests como POST /organizations/:organization_handle/members as an admin inviting an owner is forbidden. El controlador de traspasos se quedó fuera de ese arreglo. Además, la guía oficial de traspasos decía que un admin podía iniciar el traspaso y asignar el rol de propietario, mientras que la guía de gestión de miembros reservaba ese rol a los propietarios.

Pasos de reproducción

Escenario con tres cuentas:

  • founder: propietario legítimo de la organización Acme. Acme es dueña de organization_gem y esa membresía es la única vía de founder para publicarla.
  • attacker_admin_account: admin confirmado de Acme y propietario directo de transfer_gem, una gema que no pertenece a ninguna organización (por eso puede iniciar un traspaso).
  • second_attacker_account: segunda cuenta del mismo atacante, sin relación con Acme, mantenedora directa de transfer_gem.

Todas tienen una clave de API con push_rubygem; las dos del atacante tienen también add_owner y remove_owner, para descartar que los rechazos sean por falta de permisos de la clave.

  1. Primero se comprueba que la vía normal está cerrada. Como attacker_admin_account, invitar a second_attacker_account como Owner en /organizations/acme/memberships/new. Responde 404 y la cuenta no aparece en la lista de miembros.
  1. Como el mismo admin, intentar añadir a second_attacker_account como propietario directo de organization_gem. Responde 403:
curl -i -X POST -H 'Authorization: attacker-admin-key' \
  --data-urlencode 'email=second_attacker_account' \
  --data-urlencode 'role=owner' \
  http://localhost:3000/api/v1/gems/organization_gem/owners
  1. Ahora el atajo. Como el admin, abrir /gems/transfer, elegir Acme, seleccionar transfer_gem y, en el paso Owners, poner a second_attacker_account como Owner. Finalizar. En la página de miembros de Acme aparece second_attacker_account como Owner pendiente: justo lo que se había denegado en el paso 1.
  1. Como second_attacker_account, aceptar la invitación en /organizations/acme/invitation. Ya es propietario confirmado de Acme.
  1. Comprobar el estado inicial: publicar organization_gem con la clave de founder devuelve 200.
  1. Como second_attacker_account, añadirse como propietario directo de organization_gem en /gems/organization_gem/owners y confirmarlo con el token que llega por correo.
  1. Como second_attacker_account, eliminar a founder desde la página de miembros de Acme. Solo quedan las dos cuentas del atacante.
  1. Volver a publicar organization_gem con la clave de founder del paso 5: ahora devuelve 403.
  1. Publicar organization_gem con la clave de second_attacker_account: devuelve 200.

El resumen del antes y el después:

| Comprobación | Antes | Después | |---|---:|---:| | El admin crea un Owner de la organización directamente | 404 | creado a través del traspaso | | El admin añade un propietario directo a organization_gem | 403 | lo añade el nuevo Owner | | founder publica organization_gem | 200 | 403 | | second_attacker_account publica organization_gem | sin membresía ni rol | 200 |

El investigador adjuntó además un test de integración (1 test, 55 aserciones, sin fallos) que repite la secuencia con una organización más completa: un segundo propietario con membresía y propiedad directa de la gema, un admin y un mantenedor. Los cuatro acaban expulsados y pierden sus roles directos sobre organization_gem.

Impacto

Es una escalada de admin a propietario que termina en toma de control de la organización:

  • El admin, mediante su segunda cuenta convertida en propietaria, puede expulsar a todos los propietarios, admins y mantenedores legítimos.
  • Sobre las gemas de la organización puede añadirse como propietario directo y quitar los roles directos de los demás.
  • Los publicadores legítimos se quedan fuera: la clave de founder pasa de 200 a 403 al publicar, mientras el atacante sigue publicando con 200. Pierden también las vías de recuperación que da el rol de propietario.

El propio informe acota el alcance: el admin ya podía publicar y retirar (yank) gemas de la organización antes del ataque. Lo nuevo es poder excluir a los legítimos. El bloqueo se limita a la membresía de la organización y a los roles directos que el atacante elimine; una propiedad directa sobre la gema traspasada que no se toque sigue funcionando.

Qué aprender de este caso

  • Cuando una regla de permisos se arregla en un controlador (aquí, el commit 0f40f5cb en el de miembros), busca todos los demás flujos que acaban creando el mismo objeto: en RubyGems.org, el traspaso también creaba una Membership y ese arreglo no lo cubría.
  • Los asistentes de varios pasos que aceptan roles en parámetros anidados, como invites_attributes[id, role], son un buen sitio donde mirar: prueba a pedir en ellos un rol que la vía directa te deniega.
  • Pon la comprobación cerca del modelo que concede el privilegio (al crear la membresía) y no solo en el controlador de entrada, para que cualquier camino nuevo herede la regla de "solo un owner crea owners".
  • Si la documentación de dos funciones se contradice sobre quién puede hacer algo (guía de traspasos frente a guía de miembros), es una pista de que el código también puede estar desalineado.