Algunos Writeups del CTF de JCUN 2025
Recientemente tuvimos la oportunidad de jugar el CTF de el evento JCUN en su edición del año 2025 de manera remota, el cual disfrutamos , las casi 8 horas que lo jugamos.
Como es costumbre de vez en cuando y si hay las capacidades el equipo juega y hacemos un post, esta es una de las ocasiones, donde pondremos como solucionamos la mayoría de los retos de este CTF que nos entretuvo la mayoría del día, hasta que decidimos abortar, como lo mencionamos en la parte Fin del evento para nosotros de este post.
El primer reto que resolvimos fue
ROT
Para cuando estábamos haciendo este post , este reto solo tenia 6 soluciones, por lo cual nos pareció interesante compartir, nos daban un archivo.txt con la siguiente string, en donde se podía ver el formato de bandera.
después de dar un poco de vueltas con ROT13, ROT47, pensamos diferente como lo hemos dicho en otras ocasiones https://medium.com/@osint4fun/pensamiento-lateral-y-su-importancia-en-resoluci%C3%B3n-de-retos-desaf%C3%ADos-o-ctfs-2635794dc793
así que si los strings los organizamos de arriba hacia abajo en columnas se puede leer la flag
El siguiente reto que desarrollamos fue huevo de pascua.
En el sitio web de los organizadores en el github, en los commits de hace 2 semanas era visible la flag y un señuelo
Posteriormente pasamos a Light Stars Bunny, para lo cual identifico que estaban almacenadas en bits plane y al usar stegsolve logro obtener la flag
JCUN25{d0_y0u_L1k3_RGB_L1gHt5?#!}
Para el reto de uqbar origins , logramos obtener la first blood
para este desafío identifico la foto en el “hangar” y la flag, nos divirtió mucho este reto
para el reto de osint, nuestro querido amigo, tambien logramos una first blood, este reto al momento del post solo tenia 3 respuestas por lo que compartimos tambien la solucion
En el discord de los organizadores aparecía este usuario borges
y al buscarlo en twitter tenia un comentario con una flag
el siguiente reto fue Alfa Centauri, de categoría forense
para este había un zip con el cual se uso foremost para no hacerlo manual, pero este tenia una pass, esa pass al revisar las peticiones html estaban unas strings codeadas, usando el mapeo que sale del título (⌇⟟⌇⏁⟒⋔⏃ ⎅⟒ ⋔⏃⟟⌰⌇ ⌰⍜☊⏃⌰ → SISTEMA DE MAILS LOCAL), se obtiene por ejemplo: Primer mensaje (Paul → Alf): HOLA ALF, ESTA REGION DEL UNIVERSO ES VACIA, NADIE NOS ESPIA, ES SEGURO Respuesta de Alf (cuando manda el ZIP): HOLA PAUL, ENTIENDO, ENTONCES TE PASO EL ARCHIVO Y el mensaje clave: LA CLAVE ES: XERXES34NOVA , EN MAYUSCULAS
Logic
en este se uso binwalk, los logs que hay dentro del .rs
y ahi estaba
[BOOT] Bootloader v1.2.3 build 2025-09-05 [BOOT] Starting POST... [DBG] NOTE=JCUN25{eM-b3dd3d-UART-s1d3}; [SENS] TEMP=?C HUM=63% PRESS=1021hPa [FS] mount / (littlefs) rw, crc=0x3043D001 OK [CLK] HSE=8MHz PLL=ON SYSCLK=16MHz LSE=OK [DHCP] lease 192.168.231.22 t1=201s t2=512s ... JCUN25{eM-b3dd3d-UART-s1d3}
los siguientes retos , los hicimos en paralelo y en tiempo record, lo cual motivo mucho mas al equipo.
Soooo Easy
Magic Spells
Agentes espaciales
este era un fallo de SQLi insertando un union SELECT en el user agent
en la respuesta se podía observar la flag
el siguiente fue de reversing
Nave OwO
para esto se uso ghidra y habia un xor para encontrar la passwrod b1 = (0x560e0948654f0a43).to_bytes(8, “little”)
b’C\nOeH\t\x0eV’ → hex: 43 0a 4f 65 48 09 0e 56
0x4f ^ key = 0x75 → key = 0x4f ^ 0x75 = 0x3a y0u_r34lly_n33d_th1s?
y al usar esto, el equipo obtuvo la flag
el siguiente desafío fue Superliminal, de OSINT.
para este, en palabras de , nos la fumamos verde, en el superponiendo las dos imágenes la de la bóveda y la de la portada de Twitter de uno de los retos, se lograba ver la flag.
El siguiente que queremos compartir es the lost astronaut
by :
Este reto fue muy interesante porque no se resolvía con un simple reverse image search en Lens, Yandex o cualquier otra herramienta. Primero tenía que identificar en qué país estaba tomada la foto. Para eso usé el blog de herramientas de geolocalización de imágenes
https://medium.com/@n0r37urn/zoom-in-and-analyze-reverse-image-tools-for-geoint-you-need-to-know-a5804c4966cb y, tras algunas pruebas, concluí que estábamos en Taiwán.
En las imágenes del reto se veía claramente un puente ferroviario (tipo metro o tren), unas bodegas, un puente pequeño y, sobre todo, un poste con información: ese detalle fue la clave. Haciendo un poco más de OSINT encontré una referencia (imagen) que me permitió acotar una región aproximada donde buscar.
Con esa zona en mente, empecé a jugar con la pista del “42% x 80% en la zona K de Taiwán”, haciendo los cálculos mentalmente para estimar un área de búsqueda. Mi primer recorrido en Street View fue cerca de Dounan, en el condado de Yunlin
(https://www.google.com/maps/place/Dounan,+Dounan+Township,+Condado+de+Yunlin,+Taiwán+630/@23.7131365,120.5020464,3a,15y,161.99h,91.23t/data=!3m7!1e1!3m5!1sQT3OSCE43cmcMZPXphsjdA!2e0!6shttps:%2F%2Fstreetviewpixels-pa.googleapis.com%2Fv1%2Fthumbnail%3Fcb_client%3Dmaps_sv.tactile%26w%3D900%26h%3D600%26pitch%3D-1.2284961916875403%26panoid%3DQT3OSCE43cmcMZPXphsjdA%26yaw%3D161.9872384444539!7i16384!8i8192!4m6!3m5!1s0x346eb9cbfcca350f:0x6618c5845d95a4b3!8m2!3d23.679436!4d120.4747975!16s%2Fm%2F02qwrtn?entry=ttu&g_ep=EgoyMDI1MTEwNC4xIKXMDSoASAFQAw%3D%3D
), pero todavía estaba lejos del punto correcto.
A partir de ahí decidí usar el puente del tren como referencia principal y seguir la línea férrea aplicando las proporciones que ya conocíamos. Con esa combinación de pistas, porcentajes y recorrido sistemático en Street View, terminé llegando a la ubicación exacta:
https://www.google.com/maps/place/23°46'44.1"N+120°33'35.1"E/@23.8645777,120.5554146,3a,76.6y,164.63h,82.5t/data=!3m7!1e1!3m5!1sylA2BojMElkqYm5VinIvmw!2e0!6shttps:%2F%2Fstreetviewpixels-pa.googleapis.com%2Fv1%2Fthumbnail%3Fcb_client%3Dmaps_sv.tactile%26w%3D900%26h%3D600%26pitch%3D7.503240817894678%26panoid%3DylA2BojMElkqYm5VinIvmw%26yaw%3D164.6312196580986!7i16384!8i8192!4m4!3m3!8m2!3d23.7789133!4d120.5597419!5m1!1e2?entry=ttu&g_ep=EgoyMDI1MTEwNC4xIKXMDSoASAFQAw%3D%3D
Así fue como pasé de algo que no se podía resolver con un simple reverse a una búsqueda guiada por pistas, porcentajes y análisis visual, hasta llegar a la ubicación exacta.
JCUN25{23.86457,120.55541}
Durante el evento el equipo también resolvió los retos de programación que se presentaron en https://codeforces.com/gym/649137
para lo cual los dejamos los 4 juntos para el final
Los cuatro problemas giran alrededor de conceptos fundamentales en programación competitiva:
manipulación de conjuntos y MEX,razonamiento sobre intervalos y dígitos, análisis de componentes fuertemente conexas (SCCs),propiedades multiplicativas en árboles y factorización eficiente.
Cada ejercicio representa una evolución lógica de complejidad:
de lo discreto a lo algebraico, y finalmente a estructuras jerárquicas grandes.
Problema A — El paquete perdido
Enunciado
Dada una lista de identificadores de paquetes (enteros no negativos), algunos repetidos y otros faltantes, se pide encontrar el MEX (Minimum EXcluded number): el entero no negativo más pequeño que no aparece en la secuencia.
Idea principal
El MEX es el primer entero x ≥ 0 que no pertenece al conjunto.
Usamos un set() para búsqueda O(1) promedio.
Código
import sys
input = sys.stdin.readline
T = int(input())
for _ in range(T):
n = int(input())
arr = list(map(int, input().split()))
s = set(arr)
mex = 0
while mex in s:
mex += 1
print(mex)Complejidad
- Tiempo: O(N) por test (lineal en el tamaño del arreglo)
- Memoria: O(N)
- Notas: Escalable hasta ∑N ≤ 1⁰⁶.
Concepto reforzado
Búsqueda de elementos faltantes mediante estructura de conjunto hash.
Problema B — El pico de energía
Enunciado
Dado un rango [la, ra] y otro [lb, rb], la máquina calcula a+b
y el "impulso" es el dígito más alto en a+b.
Se pide el máximo impulso posible dentro de las sumas permitidas.
Observación clave
No basta con evaluar ra+rb: un 9 puede aparecer en una suma intermedia.
Por eso, verificamos si existe algún número dentro de [Smin, Smax] que contenga el dígito d.
Se usa aritmética por bloques decimales:
para cada posición decimal p = 10^pos, el bloque válido donde el dígito d aparece es:
[k*10*p + d*p, k*10*p + d*p + p - 1]Si ese bloque intersecta el rango de sumas, entonces ese dígito d puede aparecerCódigo
import sys
input = sys.stdin.readlinedef exists_digit_in_range(Smin, Smax, d):
for pos in range(19):
p = 10 ** pos
block = 10 * p
k0 = (Smin - d * p) // block
for k in (k0, k0 + 1):
start = k * block + d * p
end = start + p - 1
if start <= Smax and end >= Smin:
return True
return FalseT = int(input())
for _ in range(T):
la, ra, lb, rb = map(int, input().split())
Smin = la + lb
Smax = ra + rb
for d in range(9, -1, -1):
if exists_digit_in_range(Smin, Smax, d):
print(d)
break
Complejidad
- Tiempo: O(10 × 19) ≈ O(1) por caso.
- Memoria: Constante.
Concepto reforzado
Razonamiento sobre intervalos y representación decimal.
Ejemplo de búsqueda por existencia de patrón numérico en rango.
Problema C — Machine learning or Bob learning?
Enunciado
Dado un grafo dirigido con n nodos y m aristas,
¿cuántas nuevas aristas se deben agregar para que todas las ciudades sean alcanzables desde la capital s?
Idea principal
Este es un problema clásico de Componentes Fuertemente Conexas (SCC).
Cada SCC puede verse como un nodo en un DAG condensado.
La solución:
Ejecutar Tarjan (O(V + E)).
Construir el grafo de componentes.
Contar cuántas SCC:
- No tienen entrada (
in_degree = 0). - No son alcanzables desde la capital.
El número de esas SCCs es el número mínimo de carreteras nuevas.
Código
import sys
sys.setrecursionlimit(10**7)
input = sys.stdin.readlinen, m, s = map(int, input().split())
s -= 1
graph = [[] for _ in range(n)]
for _ in range(m):
u, v = map(int, input().split())
graph[u - 1].append(v - 1)index = [None] * n
lowlink = [0] * n
on_stack = [False] * n
stack = []
comp_id = [-1] * n
current_index = comp_count = 0def tarjan(u):
global current_index, comp_count
index[u] = lowlink[u] = current_index
current_index += 1
stack.append(u)
on_stack[u] = True
for v in graph[u]:
if index[v] is None:
tarjan(v)
lowlink[u] = min(lowlink[u], lowlink[v])
elif on_stack[v]:
lowlink[u] = min(lowlink[u], index[v])
if lowlink[u] == index[u]:
while True:
v = stack.pop()
on_stack[v] = False
comp_id[v] = comp_count
if v == u: break
comp_count += 1for i in range(n):
if index[i] is None:
tarjan(i)dag = [set() for _ in range(comp_count)]
in_degree = [0] * comp_count
for u in range(n):
for v in graph[u]:
if comp_id[u] != comp_id[v]:
if comp_id[v] not in dag[comp_id[u]]:
dag[comp_id[u]].add(comp_id[v])
in_degree[comp_id[v]] += 1visited = [False] * comp_count
def dfs(u):
visited[u] = True
for v in dag[u]:
if not visited[v]:
dfs(v)dfs(comp_id[s])
ans = sum(1 for i in range(comp_count) if in_degree[i] == 0 and not visited[i])
print(ans)
Complejidad
- Tiempo: O(V + E)
- Memoria: O(V + E)
Algoritmo: Tarjan + DAG condensation
Concepto reforzado
Aplicación práctica de SCC + análisis de accesibilidad (reachability) en grafos dirigidos.
Problema D — Caminos estables
Enunciado
Tenemos un árbol con n nodos y valores aᵢ.
El camino entre u y v es estable si el producto de los valores del camino es un cuadrado perfecto.Observación fundamental
Un producto es cuadrado perfecto si y solo si:
Todas las potencias primas en su factorización tienen exponente par.
El resultado es no negativo.
Representamos cada número por su “kernel square-free”:
conjunto de primos que aparecen con exponente impar.
Transformación:
- Asignamos a cada nodo un hash XOR de sus primos con exponente impar.
- Propagamos desde la raíz acumulando XOR (prefijos).
- Para dos nodos
u,v, el producto en el camino =
pref[u] XOR pref[v] XOR node_hash[LCA(u,v)].
También se controla:
- paridad del signo (
neg), - existencia de ceros (
zero).
Si el XOR es 0, el signo es positivo y no hay ceros → camino estable .
Código (C++)
#include <bits/stdc++.h>
using namespace std;
using ull = unsigned long long;const int MAXA = 200000;
const int MAXN = 200000;
int spf[MAXA + 1];void build_spf() {
iota(spf, spf + MAXA + 1, 0);
for (int i = 2; i * i <= MAXA; i++)
if (spf[i] == i)
for (int j = i * i; j <= MAXA; j += i)
if (spf[j] == j) spf[j] = i;
}inline ull mix64(ull x) {
x ^= (x >> 33);
x *= 0xff51afd7ed558ccdULL;
x ^= (x >> 33);
x *= 0xc4ceb9fe1a85ec53ULL;
x ^= (x >> 33);
return x;
}ull squarefree_hash(long long a, int &neg, int &zero) {
if (a == 0) { zero = 1; return 0ULL; }
neg = (a < 0);
if (a < 0) a = -a;
ull h = 0ULL;
while (a > 1) {
int p = spf[a], cnt = 0;
while (a % p == 0) { a /= p; cnt ^= 1; }
if (cnt) h ^= mix64(p);
}
return h;
}vector<int> g[MAXN];
int n, q;
vector<long long> A;
const int LOG = 19;
int up[LOG][MAXN], depth_[MAXN];
ull pref_hash[MAXN];
int pref_neg[MAXN], pref_zero[MAXN];int lca(int a, int b) {
if (depth_[a] < depth_[b]) swap(a,b);
int diff = depth_[a]-depth_[b];
for (int k=0; diff; k++, diff>>=1)
if (diff&1) a = up[k][a];
if (a==b) return a;
for (int k=LOG-1; k>=0; k--)
if (up[k][a]!=up[k][b]) {
a = up[k][a]; b = up[k][b];
}
return up[0][a];
}int main(){
ios::sync_with_stdio(false);
cin.tie(nullptr);
build_spf(); cin >> n;
A.resize(n);
for (int i=0;i<n;i++) cin >> A[i];
for (int i=0;i<n-1;i++){
int u,v; cin>>u>>v; --u;--v;
g[u].push_back(v);
g[v].push_back(u);
} int neg=0,zero=0;
ull h = squarefree_hash(A[0],neg,zero);
pref_hash[0]=h; pref_neg[0]=neg; pref_zero[0]=zero; vector<int> order{0};
for (int i=0;i<(int)order.size();i++){
int u=order[i];
for(int v:g[u]) if(v!=up[0][u]){
up[0][v]=u;
depth_[v]=depth_[u]+1;
order.push_back(v);
}
}
for(int k=1;k<LOG;k++)
for(int v=0;v<n;v++)
up[k][v]=(up[k-1][v]==-1)?-1:up[k-1][up[k-1][v]]; queue<int> qn; qn.push(0);
while(!qn.empty()){
int u=qn.front(); qn.pop();
for(int v:g[u]){
if(v==up[0][u]) continue;
int nneg=0,nzero=0;
ull nh=squarefree_hash(A[v],nneg,nzero);
pref_hash[v]=pref_hash[u]^nh;
pref_neg[v]=pref_neg[u]^nneg;
pref_zero[v]=pref_zero[u]+nzero;
qn.push(v);
}
} cin >> q;
while(q--){
int u,v; cin>>u>>v; --u;--v;
int w=lca(u,v);
int zeros=pref_zero[u]+pref_zero[v]-2*pref_zero[w]+(A[w]==0);
if(zeros>0){ cout<<"Yes\n"; continue; }
int neg_par=pref_neg[u]^pref_neg[v]^((A[w]<0)?1:0);
if(neg_par){ cout<<"No\n"; continue; }
ull hh=pref_hash[u]^pref_hash[v]^squarefree_hash(A[w],neg,zero);
cout<<(hh==0?"Yes\n":"No\n");
}
}
Complejidad
- Preprocesamiento SPF: O(A log log A
- DFS + prefijos: O(n)
- LCA: O(log n) por consulta
- Total: O((n + q) log n)
Concepto reforzado
Representar propiedades multiplicativas de caminos mediante hashing XOR de primos.
Uso de LCA y propagación de paridad en árboles grandes.
Fin del evento para nosotros
Sin embargo como todo en la vida, algunos imprevistos pasaron, a las 5:24 del 7 de noviembre se libero un anuncio que bajo un poco los ánimos del equipo.
Este reto daba 5000 puntos, por lo cual garantizaba que los equipos que lo obtuvieran sin duda iban a quedar en un score superior al nuestro.
Después de esa noticia , el equipo decidió parar y descansar para jugar el CTF próximo a liberarse de buckeyeCTF , del cual próximamente compartiríamos un writeup/post .
Finalizando este post, lo hacemos con un screenshot cuando logramos tener una ventaja abismal de otros equipos, con un sabor amargo a victoria pero mas importante aun…. con nuevos aprendizajes de los retos diseñados por el equipo de uqbar a quienes agradecemos el CTF tan entretenido que nos brindaron y que sin duda volveremos a jugar y a aprender.
Además de compartir la linda mención honorifica que ganamos
Hasta un próximo post !