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-14compruebaOrganizationPolicy#add_gem?(basta con ser admin) y llama atransfer!.app/controllers/rubygems/transfer/users_controller.rb:26-28acepta el parámetroinvites_attributes[id, role], con el rol que elija quien hace el traspaso.app/models/organization_invite.rb:12-20copia ese rol a unaMembershipnueva.app/models/rubygem_transfer.rb:18-21la 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 deorganization_gemy esa membresía es la única vía defounderpara publicarla.attacker_admin_account: admin confirmado de Acme y propietario directo detransfer_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 detransfer_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.
- Primero se comprueba que la vía normal está cerrada. Como
attacker_admin_account, invitar asecond_attacker_accountcomo Owner en/organizations/acme/memberships/new. Responde 404 y la cuenta no aparece en la lista de miembros.
- Como el mismo admin, intentar añadir a
second_attacker_accountcomo propietario directo deorganization_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
- Ahora el atajo. Como el admin, abrir
/gems/transfer, elegir Acme, seleccionartransfer_gemy, en el paso Owners, poner asecond_attacker_accountcomo Owner. Finalizar. En la página de miembros de Acme aparecesecond_attacker_accountcomo Owner pendiente: justo lo que se había denegado en el paso 1.
- Como
second_attacker_account, aceptar la invitación en/organizations/acme/invitation. Ya es propietario confirmado de Acme.
- Comprobar el estado inicial: publicar
organization_gemcon la clave defounderdevuelve 200.
- Como
second_attacker_account, añadirse como propietario directo deorganization_gemen/gems/organization_gem/ownersy confirmarlo con el token que llega por correo.
- Como
second_attacker_account, eliminar afounderdesde la página de miembros de Acme. Solo quedan las dos cuentas del atacante.
- Volver a publicar
organization_gemcon la clave defounderdel paso 5: ahora devuelve 403.
- Publicar
organization_gemcon la clave desecond_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
founderpasa 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
0f40f5cben el de miembros), busca todos los demás flujos que acaban creando el mismo objeto: en RubyGems.org, el traspaso también creaba unaMembershipy 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.