Historias de hacking
blogpentestingNext.jsSupabase

Nos salteamos la suscripción paga de una startup tocando un solo campo en Supabase

·12 min·Equipo LLMraiders

Respuesta breve: una política RLS puede impedir que un usuario edite filas ajenas y, aun así, permitir que modifique columnas sensibles de su propio perfil. En este pentest confirmamos que una cuenta común podía cambiar el atributo que definía sus privilegios porque la autorización controlaba la fila, pero no las columnas editables.

Hace poco, durante un pentest autorizado, encontramos una cadena de vulnerabilidades en una plataforma de venta de entradas desarrollada con Next.js y Supabase.

Podíamos identificar componentes internos, consultar información que no debía ser pública, ejecutar funciones expuestas por la API y convertir una cuenta común en una cuenta con más privilegios.

Lo más grave no necesitó SQL injection, un bypass del login ni ejecución remota de código.

El backend confiaba en un campo que los propios usuarios podían modificar.

¿Cómo llegamos hasta ahí?

Empezamos por el navegador

Primero hicimos lo más básico: usamos la aplicación como cualquier otra persona.

Revisamos las requests de registro e inicio de sesión, las rutas, los headers, los parámetros utilizados para editar el perfil y el JavaScript que llegaba al navegador.

Así pudimos entender parte de la arquitectura.

El frontend estaba desarrollado con Next.js y utilizaba Supabase para la autenticación, la base de datos y parte de la lógica del backend. Desde el navegador se hacían requests a los endpoints de Auth y a la API REST que Supabase genera sobre PostgreSQL mediante PostgREST.

En el JavaScript público aparecían la URL del proyecto y la clave anon utilizada por el cliente:

createClient(
  "https://<PROYECTO>.supabase.co",
  "<ANON_KEY>"
)

Eso, por sí solo, no era una vulnerabilidad. Que una aplicación exponga una URL o una clave pública en el cliente puede ser completamente normal.

La seguridad dependía de otra cosa: las políticas de Row Level Security (RLS), los permisos de PostgreSQL, las columnas que cada rol podía modificar y los permisos de ejecución de las funciones RPC.

Así que dejamos de mirar solamente la interfaz y empezamos a hablar directamente con la API.

La API contaba más de lo que parecía

PostgREST respondía de forma diferente cuando consultábamos una tabla inexistente, una tabla válida sin filas visibles o una tabla con información accesible.

La prueba era parecida a esta:

GET /rest/v1/<TABLA_CANDIDATA>?select=*
apikey: <ANON_KEY>

En términos generales, podíamos distinguir entre tres casos:

404 / PGRST205  -> tabla inexistente o no expuesta
200 []           -> tabla válida sin filas visibles
200 [{...}]      -> tabla válida con información accesible

También podíamos probar nombres de columnas de forma controlada:

GET /rest/v1/<TABLA>?select=<COLUMNA_CANDIDATA>
apikey: <ANON_KEY>

Cuando una columna no existía, PostgreSQL y PostgREST devolvían errores que, en algunos casos, sugerían nombres similares.

Poco a poco armamos un mapa parcial de la aplicación sin tener acceso administrativo.

No vamos a publicar los nombres reales de las tablas, las columnas ni las entidades. Para explicar el problema alcanza con saber que pudimos identificar objetos relacionados con cuentas, publicaciones, tipos de entradas y operaciones internas. Uno de esos recursos exponía datos comerciales sin autenticación.

Después aparecieron las funciones internas

Supabase también exponía funciones de PostgreSQL a través de rutas RPC de PostgREST:

POST /rest/v1/rpc/<NOMBRE_DE_FUNCION>

Probamos nombres obtenidos del frontend y otros relacionados con el comportamiento que habíamos observado.

Pero tuvimos cuidado con algo: recibir un error PGRST202 no demuestra que una función sea vulnerable. Puede significar que no existe, que espera otros parámetros o que tiene otra firma.

Solo consideramos vulnerable una función cuando pudimos ejecutarla con datos válidos y obtuvimos una respuesta real.

Una de ellas devolvía información operativa sin que el usuario hubiera iniciado sesión.

La request, simplificada y anonimizada, era parecida a esta:

POST /rest/v1/rpc/<FUNCION_INTERNA>
apikey: <ANON_KEY>
Content-Type: application/json

{
  "identificadores": ["<ID_DE_PRUEBA>"]
}

No enviamos un header Authorization y recibimos un HTTP 200 con información comercial estructurada.

Hicimos controles negativos con una lista vacía y con un UUID inexistente. En ambos casos obtuvimos respuestas vacías. Después comparamos el resultado válido con información observable desde otros puntos de la aplicación.

Coincidía.

El problema no era conocer el nombre de la función. El problema era que el rol anon tenía permiso para ejecutarla. En términos de PostgreSQL, la configuración efectiva era equivalente a esta:

GRANT EXECUTE
ON FUNCTION <FUNCION_INTERNA>(...)
TO anon;

Otra función permitía generar valores internos

Encontramos además una función RPC que generaba códigos utilizados por la plataforma.

La request anonimizada era parecida a esta:

POST /rest/v1/rpc/<FUNCION_GENERADORA>
apikey: <ANON_KEY>
Content-Type: application/json

{}

La ejecutamos varias veces, siempre dentro del alcance autorizado.

El resultado fue:

  • Todas las respuestas fueron exitosas.
  • Cada ejecución devolvió un valor diferente.
  • No fue necesario iniciar sesión.
  • No observamos límites durante la prueba acotada.

Eso demostraba que un usuario anónimo podía invocar el generador.

No demostraba, en cambio, que esos valores pudieran utilizarse para obtener un beneficio o completar otra operación. Para afirmar eso habríamos tenido que validar otro flujo.

Y hay una diferencia importante entre lo que parece posible y lo que realmente pudimos demostrar.

El problema más grave estaba en el perfil

Después creamos una cuenta de prueba mediante Supabase Auth y comparamos lo que podía hacer un visitante con lo que podía hacer un usuario autenticado.

La configuración entregaba una sesión válida sin exigir que la dirección de correo hubiera sido confirmada previamente. Además, el endpoint de registro respondía de manera diferente cuando el correo ya existía, lo que permitía enumerar cuentas.

Primero intentamos leer perfiles ajenos.

No pudimos.

Ese control funcionaba correctamente: cada usuario solo podía consultar su propio registro.

Después modificamos datos normales de nuestro perfil. También funcionó como esperábamos.

Entonces agregamos a la misma petición un atributo interno relacionado con el nivel de acceso de la cuenta.

El ejemplo anonimizado sería algo así:

PATCH /rest/v1/<TABLA_DE_PERFILES>?id=eq.<NUESTRO_UID>
apikey: <ANON_KEY>
Authorization: Bearer <TOKEN_DE_PRUEBA>
Content-Type: application/json

{
  "<ATRIBUTO_INTERNO>": "<ESTADO_PRIVILEGIADO>"
}

La API respondió HTTP 204 No Content.

Pero una respuesta exitosa no era suficiente. Podía existir una validación posterior, un trigger o algún proceso que revirtiera silenciosamente el valor.

Así que volvimos a consultar nuestro perfil con un GET autenticado y pedimos únicamente el atributo interno que habíamos modificado.

El cambio seguía ahí.

Una cuenta común acababa de obtener un estado privilegiado modificando directamente su propio registro.

¿Qué había fallado?

La política RLS controlaba qué fila podía editar cada usuario, pero no qué columnas podía modificar dentro de esa fila.

La lógica era parecida a esta:

CREATE POLICY "usuario_actualiza_su_perfil"
ON <TABLA_DE_PERFILES>
FOR UPDATE
USING (auth.uid() = id);

El problema era que el perfil mezclaba dos tipos de información:

  • Datos que pertenecían al usuario y debían ser editables.
  • Atributos internos que definían permisos o estados sensibles.

La política evitaba que una persona modificara el perfil de otra. Pero, una vez autorizado el cambio sobre su propio registro, la API aceptaba también atributos que solo debía controlar el sistema.

No era un bypass de autenticación. El usuario había iniciado sesión correctamente.

Era un fallo de autorización.

También intentamos demostrar qué no era vulnerable

Probamos otros atributos para entender hasta dónde llegaba el problema.

Algunos cambios persistieron. Otros fueron aceptados inicialmente, pero al volver a consultar el perfil conservaban el valor anterior. Eso sugería que existía una segunda capa de protección para ciertas propiedades, aunque no se aplicaba de manera consistente.

También intentamos modificar registros ajenos y no pudimos confirmar que fuera posible.

El alcance real quedó así:

  • Escalada de privilegios sobre la propia cuenta: confirmada.
  • Persistencia del estado privilegiado: confirmada.
  • Modificación de cuentas ajenas: no confirmada.
  • Impacto posterior: validado únicamente hasta donde permitía el alcance acordado.
  • Cuenta de prueba: restaurada a su estado original.

No todo lo que probamos era vulnerable.

La aplicación tenía controles que funcionaban: restricciones sobre datos de otros usuarios, validaciones del lado del servidor, protección de operaciones sensibles, límites en el inicio de sesión y controles sobre archivos y redirecciones.

También descartamos falsos positivos después de reproducir el comportamiento del frontend y del backend.

Para nosotros, esa parte también importa. Un pentest no consiste en juntar respuestas raras y llamarlas vulnerabilidades. Consiste en demostrar qué puede hacer realmente un atacante y, con la misma claridad, qué no pudimos hacer.

¿Cómo se corrige?

Ocultar el atributo en el frontend no sirve. Cualquier persona puede modificar manualmente una request enviada desde su navegador.

Los datos editables por el usuario tienen que estar separados de los atributos que definen permisos, roles o estados internos.

La API debería aceptar una lista explícita de campos permitidos e ignorar o rechazar cualquier otro. En PostgreSQL también se pueden revocar los permisos sobre la columna sensible y conceder UPDATE únicamente sobre las columnas editables:

REVOKE UPDATE (<ATRIBUTO_INTERNO>)
ON <TABLA_DE_PERFILES>
FROM authenticated;

GRANT UPDATE (<CAMPOS_EDITABLES>)
ON <TABLA_DE_PERFILES>
TO authenticated;

Los cambios de privilegios deberían pasar por una operación separada que:

  • Exija autenticación.
  • Verifique que quien realiza el cambio tenga permisos.
  • Valide que la transición sea legítima.
  • Registre la operación.
  • Genere alertas cuando corresponda.

Las funciones RPC internas tampoco deberían poder ejecutarse por roles que no las necesitan:

REVOKE EXECUTE
ON FUNCTION <FUNCION_INTERNA>(...)
FROM anon, authenticated;

Cada función tiene que revisarse de forma individual, tanto por sus permisos como por la información que devuelve y los efectos que produce.

Lo que nos dejó este pentest

La cadena empezó mirando requests desde el navegador, siguió entendiendo cómo respondía la API y terminó modificando la lógica de autorización de una cuenta.

No hizo falta romper Next.js, Supabase, PostgREST ni PostgreSQL.

El sistema funcionaba de acuerdo con los permisos que alguien había configurado. El problema era que esos permisos no representaban correctamente las reglas del negocio.

Todo se puede resumir en una pregunta:

Si un usuario puede actualizar su propio perfil, ¿también puede modificar el atributo que define sus privilegios?

En este caso, la respuesta era sí.

Y cuando una persona puede decidir sus propios privilegios, el login puede seguir funcionando perfectamente mientras la autorización queda comprometida.

Gracias, chicos, por confiar en nosotros.

Resumen visual del pentest autorizado

¿Este caso se parece a algo que estás construyendo? Podemos revisar los flujos críticos en una auditoría de alcance concreto.

Solicitar una evaluación