Siempre procuramos dar lo mejor de nosotros. Después de cuatro años compitiendo como equipo, ya no se trata solo de resolver retos, sino de hacerlo rápido y de forma consistente. La automatización de procesos (no, no es magia ni IA, es simplemente entender bien lo que estás haciendo), junto con una mentalidad creativa y la optimización de tareas repetitivas, nos permitió asegurar varios first bloods durante la competencia.

Más allá del resultado, este tipo de eventos se disfrutan cuando el equipo está alineado: todos empujando en la misma dirección, compartiendo ideas y "rompiendo" cosas de la forma más eficiente posible. Al final, tener un buen team se nota y una de las mejores sensaciones es hacerlo en lo posible con conocimiento en tiempo real y no solo siendo asistentes de IA que copian y pegan respuestas, pues no es emocionante…

De aproximadamente 50 retos en categorías como web, pwn, reversing, crypto, OSINT, forense, mobile y hardware hacking, logramos resolver 44. Un resultado sólido, aunque siempre con margen de mejora. La motivación viene de esa mezcla entre competencia y la constante sensación de que aún queda mucho por aprender.

Muchos de los retos resultaron familiares. Varias temáticas ya las habíamos visto antes en entrenamientos, en nuestro Discord o en otros CTFs, lo que hizo que varios challenges se sintieran relativamente sencillos , no porque lo fueran en sí, sino porque ya habíamos recorrido ese camino varias veces.

En general, el evento estuvo bastante bien organizado: buenas charlas, ambiente agradable y excelente nivel de profesionales. El viaje, las calcomanías y el networking… todo suma a la experiencia.

None
None

A nivel técnico, fue un CTF decente y entretenido. Quizás no el más desafiante comparado con otros que hemos jugado, pero sí lo suficientemente sólido como para disfrutarlo y mantener el ritmo competitivo.

Nota: No cubrimos todos los retos (eso ya es otro trabajo), solo algunos de los más interesantes o representativos.

Osint

Para el team de OSINT, los retos estuvieron bastante light: la mayoría cayó con el clásico combo de Google Lens + Maps. Nada muy profundo; en varios casos solo era ubicar el lugar, revisar detalles en el mapa y sacar la flag del nombre, la descripción o algún correo visible.

En nuestro servidor de Bugs B0unt3r, tenemos mas de 40 retos de OSINT, todos pueden ayudarle a mejorar sus criterios de busquedas.

Hardware Hacking

es curioso ver retos de este tipo en un ctf de Jeopardy, pero disfrutamos ver los retos.

Isabelle: bsidesco{f1b_1s4!}

El programa básicamente construye 8 bytes en memoria a partir de operaciones simples (shifts y ORs). Primero posiciona el puntero en mem[200] y luego va escribiendo byte por byte incrementando Y.

Si seguimos las operaciones:

mem[200] = 100 mem[201] = 50 mem[202] = 103 mem[203] = 87 mem[204] = 60 mem[205] = 102 mem[206] = 99 mem[207] = 22

Nada extraño: solo reconstrucción manual de los valores evaluando cada instrucción. Una vez calculados, ya tienes el bloque completo listo para usar.

Signal noise: bsidesco{gl1tch}

El reto consiste en encontrar una key de 64 bits que haga que el circuito no devuelva cero. Al revisar challenge.v, vemos que el módulo top envía la misma key a dos stages (x7f y x8e), los cuales aplican exactamente la misma serie de transformaciones y comparan el resultado final con 64'hA55AA55AA55AA55A. Si no coincide, la salida es 0; si coincide, cada uno devuelve parte de la flag.

Las transformaciones (XORs, rotaciones y un swap de nibbles) son completamente reversibles, así que en lugar de adivinar la key, simplemente invertimos toda la cadena desde el valor esperado hacia atrás.

Deshaciendo paso a paso cada operación, se recupera la key correcta: 45302b9b8cd81c06

Se coloca en el testbench key = 64'h45302b9b8cd81c06;

Y al simular: iverilog -o sim challenge.v tb_challenge.v && vvp sim

El circuito ahora devuelve datos válidos. Los dos stages generan cada mitad de la flag:

x7f → bsidesco x8e → {gl1tch}

Logic Flow: bsidesco{l0w_l0g1c_c0py_but_Tr4ans1st0rs_4r3_4maz1ng}

Este reto era de lógica digital/transistores. Al analizar el circuito se podía reconstruir la tabla de verdad para cada nibble de entrada:

0000 -> 0 0001 -> 1 0010 -> 1 0011 -> 1 0100 -> 0 0101 -> 1 0110 -> 1 0111 -> 1 1000 -> 0 1001 -> 1 1010 -> 1 1011 -> 1 1100 -> 1 1101 -> 0 1110 -> 0 1111 -> 0

Con esa tabla, la solución era aplicar el mapeo a los bits del output hasta reconstruir la flag. La forma "bonita" era entender la lógica del circuito, pero la forma más rápida y honestamente la más CTF , fue bruteforcear las posibles combinaciones de nibbles, orden de bits y endianess hasta obtener texto legible.

se uso un script para probar permutaciones/reversos y puntuar resultados imprimibles. Después de filtrar las salidas con formato de flag.

Misc Challs

Dessin: BSIDESCO{EASY_CHIPHER_SEARCH_JUST_FOR_FUN}

Solo usar: https://www.dcode.fr/circular-glyphs-alphabet

Ambiguity bsidesco{gr4mm4r_1s_4mb1gu0us_4nd_s0_4r3_y0u}

None

PDFception: bsidesco{ading2210_Ju5t_pUt_L1nUx_0n_4_PDF!#}

Transformación final: bsidesco{una_flag_en_mi_salsa_b26}

Forense

Bits everywhere: bsidesco{th33_4rrtt_0fff__bb11ts_1i1i1iss_4m4z111ng}

La pista clave estaba en el nombre: todo son bits. Al analizar el PCAP, se extrae el payload con:

tshark -r bits.pcap -T fields -e data > data.txt

Revisando los datos, se ve un patrón repetitivo:

aa aa aa … 55 55 55 …

Esto no es casualidad:

0xAA → 10101010 0x55 → 01010101

El truco del reto era mapear:

AA → 1 55 → 0

Con eso, se reconstruye un stream de bits:

1010100010110…

Luego se agrupa en bytes (8 bits) y se convierte a ASCII:

01100010 → b 01110011 → s …

Bsides Sedoso: bsidesco{b1g_c0mp4n13s_h4v3_c2s_0n_tH31r_n3tW0rK5_4nD_tH3Y_d0nT_kn0w_1t!#}

En la captura se observa toda la cadena de compromiso bastante directa. El atacante inicia con enumeración usando gobuster desde 192.168.1.44 contra la víctima 192.168.1.7, encontrando endpoints interesantes.

Luego accede a /login.php con credenciales débiles (admin:admin) y logra entrar al panel. Desde /admin.php sube un php-reverse-shell.php, obteniendo una shell reversa hacia 192.168.1.44:1234.

Una vez dentro, escala privilegios a root usando un clásico:

find . -exec /bin/bash -p \;

Con acceso total, descarga y ejecuta a.py, que actúa como un agente cifrado (AES-CBC) conectándose a 192.168.1.44:9999, estableciendo un canal C2.

GTA VI

El reto simula una cadena de infección bastante "realista". En /html/index.html se muestra una falsa verificación tipo Cloudflare que, al interactuar, lanza un search-ms: apuntando a un recurso WebDAV (http://18.119.10.151:6661/dav/) con nombre llamativo: GTA_VI_Beta_Verification_Files.

Dentro de ese WebDAV se sirve un .lnk (README GTA VI.pdf.lnk) que ejecuta cmd.exe, abre Edge hacia un tinyurl, descarga installer.exe desde el mismo servidor y lo ejecuta. Todo bastante clásico: ingeniería social + ejecución remota.

El comentario oculto en la web deja clara la intención: robar tokens del launcher de Rockstar y enviarlos a un C2 (con posibilidad de rotar la IP si hay mucho tráfico).

El installer.exe está empaquetado con Nuitka. Al extraerlo, el payload revela la lógica:

  • Verifica la ruta %LOCALAPPDATA%\Rockstar Games\Launcher
  • Obtiene el hostname
  • Construye un JSON con Status, Credentials y Token
  • Exfiltra la info vía HTTP a http://stgbu2e5gr.com/

La flag no está en red ni en ejecución, sino embebida en el propio payload del malware como un token en doble Base64. Solo se saca y se decodifica dos veces para obtener la flag.

Crypto

Squirreled: bsidesco{b14s3d_n0nc3s_turn_h1dd3n_numb3rs_into_l4ttice_f00d}

import json, base64, hashlib

obj = json.load(open("challenge.json"))

p = int(obj["pub"]["p"], 16)
q = int(obj["pub"]["q"], 16)
g = int(obj["pub"]["g"], 16)
y = int(obj["pub"]["y"], 16)

def H(msg):
    return int.from_bytes(hashlib.sha256(msg).digest(), "big") % q

def known_nonce_prefix(msg):
    d = hashlib.sha256(b"txn|" + msg).digest()
    return int.from_bytes(d[:28], "big") << 16

records = []

for rec in obj["records"]:
    msg = base64.b64decode(rec["m"])
    r = int(rec["sig"]["a"], 16)
    s = int.from_bytes(base64.b64decode(rec["sig"]["z"]), "big")
    K = known_nonce_prefix(msg)
    h = H(msg)

    records.append((msg, r, s, K, h))

msg1, r1, s1, K1, h1 = records[0]
msg2, r2, s2, K2, h2 = records[1]

k_i = K_i + e_i, donde e_i < 2^16
s_i * k_i - h_i = x * r_i mod q
Eliminando x entre dos firmas:
r2s1e1 - r1s2e2 =
r1s2K2 - r2s1K1 + r2h1 - r1h2 mod q
A = (r2 * s1) % q
B = (r1 * s2) % q
C = (r1 * s2 * K2 - r2 * s1 * K1 + r2 * h1 - r1 * h2) % q

invB = pow(B, -1, q)

found = None

for e1 in range(1 << 16):
    e2 = ((A * e1 - C) * invB) % q

    if e2 < (1 << 16):
        found = (e1, e2)
        break

e1, e2 = found

k1 = K1 + e1
x = ((s1 * k1 - h1) * pow(r1, -1, q)) % q

assert pow(g, x, p) == y

def int_to_q_bytes(n):
    return n.to_bytes((q.bit_length() + 7) // 8, "big")

def xor_stream(seed, data):
    acc = bytearray()
    i = 0

    while len(acc) < len(data):
        acc.extend(hashlib.sha256(seed + i.to_bytes(4, "big")).digest())
        i += 1

    return bytes(a ^ b for a, b in zip(data, acc[:len(data)]))

seed = hashlib.sha256(b"seal|" + int_to_q_bytes(x)).digest()
blob = base64.b64decode(obj["sealed"]["blob"])
flag = xor_stream(seed, blob)

print(flag.decode())

Alice is not an Admin:

#!/usr/bin/env python3
import base64
import struct
import requests

URL = "http://HOST:5000"   # cambia esto

K = [
    0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
    0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
    0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
    0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
    0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
    0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
    0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
    0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2
]

def ror(x, n):
    return ((x >> n) | (x << (32 - n))) & 0xffffffff

def sha256_pad(msg_len):
    pad = b"\x80"
    pad += b"\x00" * ((56 - (msg_len + 1) % 64) % 64)
    pad += struct.pack(">Q", msg_len * 8)
    return pad

def compress(chunk, h):
    w = list(struct.unpack(">16I", chunk)) + [0] * 48

    for i in range(16, 64):
        s0 = ror(w[i-15], 7) ^ ror(w[i-15], 18) ^ (w[i-15] >> 3)
        s1 = ror(w[i-2], 17) ^ ror(w[i-2], 19) ^ (w[i-2] >> 10)
        w[i] = (w[i-16] + s0 + w[i-7] + s1) & 0xffffffff

    a, b, c, d, e, f, g, hh = h

    for i in range(64):
        S1 = ror(e, 6) ^ ror(e, 11) ^ ror(e, 25)
        ch = (e & f) ^ ((~e) & g)
        temp1 = (hh + S1 + ch + K[i] + w[i]) & 0xffffffff

        S0 = ror(a, 2) ^ ror(a, 13) ^ ror(a, 22)
        maj = (a & b) ^ (a & c) ^ (b & c)
        temp2 = (S0 + maj) & 0xffffffff

        hh, g, f, e, d, c, b, a = (
            g,
            f,
            e,
            (d + temp1) & 0xffffffff,
            c,
            b,
            a,
            (temp1 + temp2) & 0xffffffff
        )

    return [(x + y) & 0xffffffff for x, y in zip(h, [a, b, c, d, e, f, g, hh])]

def sha256_continue(extra, state_hex, processed_len):
    h = list(struct.unpack(">8I", bytes.fromhex(state_hex)))

    data = extra + sha256_pad(processed_len + len(extra))
    assert len(data) % 64 == 0

    for i in range(0, len(data), 64):
        h = compress(data[i:i+64], h)

    return "".join(f"{x:08x}" for x in h)

def forge(orig_msg, orig_tag, secret_len, append):
    glue = sha256_pad(secret_len + len(orig_msg))

    forged_msg = orig_msg + glue + append

    processed_len = secret_len + len(orig_msg) + len(glue)
    forged_tag = sha256_continue(append, orig_tag, processed_len)

    return forged_msg, forged_tag

def main():
    s = requests.Session()

    # 1. Pedimos sesión legítima
    r = s.get(URL + "/session")
    j = r.json()

    msg = base64.b64decode(j["message"])
    tag = j["tag"]

    print("[+] original msg:", msg)
    print("[+] original tag:", tag)

    # 2. Length extension
    forged_msg, forged_tag = forge(
        orig_msg=msg,
        orig_tag=tag,
        secret_len=16,
        append=b"&admin=true"
    )

    print("[+] forged raw msg:", forged_msg)
    print("[+] forged tag:", forged_tag)

    # 3. Enviamos mensaje forjado
    payload = {
        "message": base64.b64encode(forged_msg).decode(),
        "tag": forged_tag
    }

    r = s.post(URL + "/query", json=payload)
    print("[+] status:", r.status_code)
    print(r.text)

if __name__ == "__main__":
    main()

Security: bsidesco{lcg_w4s_n3v3r_s3cur3_f0r_crypto!}

El reto mezcla un LCG con AES, pero ni hizo falta analizar el generador.

El fallo es claro: la key y el IV vienen en el JSON:

_k, _iv

Así que solo toca desencriptar el ct con AES-CBC.

from Crypto.Cipher import AES from binascii import unhexlify

key = unhexlify("69d2a189680ca224cd69da735d864f26") iv = unhexlify("77bfb42db41067b8899ca137eefc845a") ct = unhexlify("36751a8c…")

print(AES.new(key, AES.MODE_CBC, iv).decrypt(ct))

Mobile

Cache: bsidesco{3xp0rt3d_4ct1v1ty_byp4ss}

adb shell am start -a com.ctf.SECRET

Randoir: bsidesco{s33d3d_r4nd0m_15_n0t_s3cur3}

El APK trae un ENCODED_FLAG y una lógica de OTP basada en java.util.Random.

Al analizar:

La lib nativa devuelve 0x400 | 0x39 = 1081 Se suma 256 → seed = 1337 Con eso: Random(1337) genera el OTP → 688421

Luego el FlagDecoder hace un XOR entre el array y bytes del OTP.

Nada fancy: seed fija → OTP predecible → flag recuperable.

Web

0xCommunity: bsidesco{jw7_n0n3_15_n07_$3cur3!}

En el source code aparecían las credenciales de johnfy, pero lo importante era el token de cookie del admin:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIn0.Y9XvBsseB17pimYygdlZciQCB7xe4ZByscvp6eR0xhs

Como el JWT usa HS256, la firma depende de una clave secreta. Solo tocaba crackearla con hashcat usando un wordlist:

hashcat -m 16500 token.txt rockyou.txt

La clave encontrada fue:

bsides

Con eso ya se podía firmar un token válido como admin.

0xCommunity 2: bsidesco{crC32!=$ign47ur3!!}

El endpoint /mod/panel validaba sessiondata usando CRC32, no HMAC ni una firma real.

Desde el perfil de 4lv4r0t se podía ver el formato:

v1|username=X&role=Y&uid=Z&ts=T|<crc32>

Como CRC32 no es criptográfico, simplemente modifiqué el payload a role=admin, recalculé el CRC32 y lo envié en el POST:

POST /mod/panel
Content-Type: application/json
{
  "username": "admin",
  "event": "login",
  "timestamp": "2026-04-24T00:00:00",
  "session_data": "<payload_forjado>"
}

Ch1pTunes #1: bsidesco{ctrl+u_y_pa_d3ntro}

None

Ch1pTunes #2: bsidesco{ad1vina_adiv1n4dor}

Un IDOR

Ch1p-tunes 4: bsidesco{3l_client3_s1empre_t1en3_l4_r4z0n}

harcodeada en endpoint y aprovechando fallo jeje

PWN — Full PWN

Bsides Shop: bsidesco{Jumm_4_1nt3ger_0verfl0w_w1th_4_bof?}

Algo que podia no ocurrir, pero sucedio

None

Zanahoriasssssssss

Este fue uno de esos casos donde el reto se resolvió sin jugar el reto. Había una mala configuración por parte de los creadores y varios challenges estaban expuestos directamente por rutas.

Básicamente fue pensar un poco fuera del flujo esperado, encontrar los endpoints abiertos… y listo. Solo fue entrar, tomar lo necesario y sonreír

/var/www/html/nota.txt (world-readable, initial access): bsidesco{Un4_r34cc10n_1n35p3r4d4}

/home/davidch09/.flag2.txt (oculta , requires davidch09 group or root): bsidesco{0cult0_n0_t4n_ocult0}

/home/ch1p/flag3.txt (ch1p access): bsidesco{d3b0_est4r_c1eg0}

/home/4LV4R0T/flag4.txt (4LV4R0T access): bsidesco{3st0_es_un_s3cu3str0}

/root/flag5.txt (requires root): bsidesco{l4_v4c4_mu_l4_m1sm4_v4ca}

FeedBack: bsidesco{Ezy_Rop_0r_SigROP_for_my_friends}

sHellCode: bsidesco{d0nt_us33_r3g1st3rsss_w1th_64_b1ts}

Reversing

Shuffled: bsidesco{H00k_1s_S0_fun}

El binario genera el sufijo de la flag haciendo XOR de 14 bytes en .data con rand() & 0xff, usando como seed:

time() + ptrace_result + 5036

El truco está en que el output correcto solo aparece en un timestamp específico embebido en el binario: 1762603200 → 2025–11–08 12:00:00 UTC

Al usar ese valor como time(), el PRNG produce la secuencia correcta y el XOR revela la flag.

de los otros retos no tenemos writeup, olvidamos guardar evidencias y scripts para estos challs…

Este writeup cubre solo algunos retos; varios otros quedaron sin documentar (sí… no guardamos todo).

Al final, lo que marca la diferencia no es un reto en particular, sino el proceso: entrenar constantemente, compartir conocimiento y jugar como equipo.

Porque sí… muchos retos se sintieron "fáciles", pero eso suele ser resultado de haberlos visto antes, haberlos fallado antes… y haber aprendido.

Nos vemos en el siguiente CTF.