HolaCTF 2025 - Writeup
Hi guys, I and my teammates from L3_u3th have joined HolaCTF 2025 and got 8th place overall. This was a good rank for us and now it is time for my writeups. Letโs go!
OSINT - HolaCTF
First, we had an image from organizers:
Based on the image, we could know the existence of HolaCTF 2023, I searched on Google and I found posts of it:
I went to the Facebook page of EHC, searched for HolaCTF 2023 and I found their post:
Searched for the newest comment, I found anhshidou comment:
We can see that there has a tag which looks like being encoded. In that part I guess a little bit and I found it using Vigenere cipher for encoding which key is HolaCTF:
Itโs an Instagram profile which takes us to another guys:
In my writeup that I submitted to organizers I donโt notice about how many times I take myself to rabbit hole, now itโs time for them ๐. First, with the username I found, I used sherlock which will use this username and search on another platform:
Beside Instagram, I tried to look at some sites in that list and I got nothing. Then I used this username and searched on Github and I found a commit:
This commit led to another account: liemaiball. I searched on his Github but all I got were checking on Instagram again. Since I have not watched video carefully, I always tried to find information through this username. Then I repeated my process, used sherlock but no result. Moreover, I extracted his email by adding .patch to the commit:
With this email I used blackbird to search how many platforms using this email and I found it on Twitter:
I went to this account andโฆ I got into rabbit hole again ๐:
Then I tried to look up on comment section and I found anhshidou comments:
Go to his profile, I found a Discord link which redirected me to a Discord server, I searched flag on that server and booyahhโฆ rabbit hole again ๐. Ok, I felt depressed at that moment, then I tried to watch video again and I realised how I was careless, I found another link when watched video:
OK ๐. From here I accessed website:
I inspected the website and I found a comment:
First, I tried to search on Wayback Machine since it could have record before, but when I searched it had no result, then I tried to look at DNS record since the website could not access anymore and even the website was down, DNS records could still be in, so I used dig to search and I got good result:
OK so it would have possibility to have another DNS record, I searched on TXT record and I found the flag ๐ (nice challenge btw):
Forensic - First step into forensic
In this challenge we have 3 files: 1 zip file, 1 kdbx file and 1 dmp file, at first glance we can guess that we will find a way to open the kdbx file. This article is a true string grep ๐๐๐ At first I used r2 with the intention of extracting the exe file for analysis, but there was no feasible result and I also found this part absurd because if the key appeared in the process like this, it would be really bad, but I still followed the concept and searched for articles, and I found an article that I thought was quite ok for my idea:
In the article, it mentioned extracting all possible masterkeys and at the same time bruteforce to detect the password, however, after using some related tools, I still couldnโt detect anything, and here I used the last step which was also the step I didnโt want to do: strings + grep ๐๐๐ I strings the entire file into another file and read from from beginning to end ๐๐๐ (sorry anhshidou ๐), and I found a rather suspicious string:
I used this string as password to open kdbx file and I opened it sucessfully ๐:
Press Ctrl + H to reveal all passwords and I got zip password:
The password is chaomungtoiholactf2025kekw and I could unzip with this password:
I stringed the file and got the flag:
Crypto - Cs2Trash and ImLosingYou
These 2 challenges I used ChatGPT for solving so I hope you will feel good for this and it might be chance to prove the power of AI ๐.
Script for Cs2Trash:
from Crypto.Util.number import long_to_bytes, inverse
from math import gcd
import random
# --- given ---
e = 65537
n1 = 106274132069853085771962684070654057294853035674691451636354054913790308627721
n2 = 73202720518342632558813895439681594395095017145510800999002057461861058762579
n3 = 58129476807669651703262865829974447479957080526429581698674448004236654958847
# >>> paste your ciphertexts here <<<
c1 = 40409669713698525444927116587938485167766997176959778633087672968720888190012
c2 = 50418608792183022472533104230595523000246213655735834753443442906871618770832
c3 = 7151799367443802424297049002310776844321501905398348074481144597918413565153
def is_probable_prime(n, k=12):
if n < 2: return False
small = [2,3,5,7,11,13,17,19,23,29,31,37]
for p in small:
if n % p == 0:
return n == p
# MillerโRabin
d = n - 1
s = 0
while d % 2 == 0:
s += 1
d //= 2
for _ in range(k):
a = random.randrange(2, n - 2)
x = pow(a, d, n)
if x == 1 or x == n - 1:
continue
for __ in range(s - 1):
x = (x * x) % n
if x == n - 1:
break
else:
return False
return True
def decrypt_if_prime(n, c):
if is_probable_prime(n):
d = inverse(e, n - 1)
m = pow(c, d, n)
return m
return None
def pollards_rho(n):
if n % 2 == 0: return 2
if is_probable_prime(n): return n
while True:
x = random.randrange(2, n-1)
y = x
c = random.randrange(1, n-1)
d = 1
f = lambda v: (v*v + c) % n
while d == 1:
x = f(x)
y = f(f(y))
d = gcd(abs(x - y), n)
if d != n:
return d
def factor_semiprime(n):
# quick try: Pollard Rho once
p = pollards_rho(n)
if p in (1, n): return None
q = n // p
if p*q == n:
return int(p), int(q)
return None
def decrypt_if_composite(n, c):
# You already checked gcds == 1, so try to factor n itself
pq = factor_semiprime(n)
if not pq:
return None
p, q = pq
phi = (p-1)*(q-1)
d = inverse(e, phi)
return pow(c, d, n)
pairs = [(n1, c1), (n2, c2), (n3, c3)]
plain_candidates = []
for (n, c) in pairs:
m = decrypt_if_prime(n, c)
if m is None:
m = decrypt_if_composite(n, c)
if m is None:
print(f"Could not decrypt with modulus n={n}")
else:
plain_candidates.append(m)
# sanity: all match?
if plain_candidates and all(x == plain_candidates[0] for x in plain_candidates):
m = plain_candidates[0]
print("m =", m)
try:
print("bytes =", long_to_bytes(m))
except Exception:
print("Could not convert to bytes cleanly.")
else:
print("Recovered plaintexts do not all agree yet (or none recovered).")
And for the last challenge, we will solve by using Coppersmith small-root:
# recover.py -- run with: sage -python recover.py
from sage.all import Integer, PolynomialRing, ZZ
# === paste your values here ===
n = 5655306554322573090396099186606396534230961323765470852969315242956396512318053585607579359989407371627321079880719083136343885009234351073645372666488587
c = 249064480176144876250402041707185886135379496538171928784862949393878232927200977890895568473400681389529997203697206006850790029940405682934025
mod_m = 499063603337435213780295973826237775412685978121823376141602090122856806
# ==============================
# variable and polynomial ring over integers
R = PolynomialRing(ZZ, 'x')
x = R.gen()
# polynomial f(x) = (mod_m + x)^2 - c
f = (mod_m + x)**2 - c
# bound on root (80 bits)
X = 2**80
# use Sage's small_roots (Coppersmith)
roots = f.small_roots(X=X) # returns a list of integer roots
if not roots:
print("No small roots found (increase X or check values).")
else:
for r in roots:
m = mod_m + Integer(r)
# sanity check: does m^2 % n == c ?
if pow(int(m), 2, int(n)) == int(c):
try:
flag_bytes = Integer(m).to_bytes((m.bit_length()+7)//8, 'big')
print("Recovered r =", r)
print("Recovered m =", m)
print("Flag bytes:", flag_bytes)
print("Flag (utf-8):", flag_bytes.decode('utf-8', errors='replace'))
except Exception as e:
print("Recovered m but failed to convert to bytes:", e)
else:
print("Root found but verification failed for r =", r)