ICO and GreyCTF Finals 2025
introduction
Over the last week of June, I participated in both the inaugural International Cybersecurity Olympiad and GreyCTF Finals. It was quite a fun experience (I spent an entire week at NUS), and I thought I’d share my experience, along with some writeups for the more interesting challenges.
ICO
The International Cybersecurity Olympiad was a seven1 day event, hosted by the NUS School of Computing. While the event wasn’t perfect2 I still rather enjoyed it overall. All eight Singaporean participants received medals for the event, with me receiving a gold.
The competition was split over two days; challenges were split between “attack style” and “defence style” (red and blue), with 9 hours of competing overall. While I don’t exactly understand how the challenges were split3, there was still a healthy mix of categories, along with a dose of challenges contributed by NUS GreyHats. ICO was also the first CTF where I actually attempted (but didn’t solve) some non-ret2win pwn challenges.
Day 1 - ICO Red
I solved four challenges and obtained 350 points. Unfortunately, I was one challenge away from FCing crypto, but that challenge had 0 solves total (I think). Most of the challenges were also solvable with ChatGPT. I will be writing about three challenges: Crypto - Funny Little Trial, Pwn - Carpark, and Rev - Complicated. Out of these three, I only managed to solve the crypto during the competition itself (and spent over half my time trying the rev); the pwn and the rev are upsolved.
Crypto - Funny Little Trial
= 2048
= 65537
, = ,
, = , # Ensure p is always greater than or equal to q
= *
= %
=
A normal RSA setup, except we are also given the value of . Taking modulo , we can see that
where the last line is given by Fermat’s Little Theorem.4 Similarly, , so by CRT . Since , the value given is equal to directly. Now, we can simply solve a quadratic to get the values of and and recover the flag.
Flag: ICO{f3rMaT5_l1L_ThM_i5_Tr1Vi4L_r1t3???}
Pwn - Carpark
Show challenge source
// g++ -g -no-pie chall.cpp -o chall
using namespace std;
using ll = long long;
void
;
int
The important part is the bounds check:
if
There is an off-by-one error here; the correct maximum value check should be slot >= CARPARK1_SPACE. This allows us to overwrite the value right after the 10th element in carpark1, which happens to be the pointer to carpark2. As we can also read and write from carpark2, we essentially have an arbitrary read and write. We can then simply read an address from GOT to obtain the libc offset, then write the address of a one_gadget at the GOT entry of a function like puts or scanf. Our gadget will then be ran when the corresponding function is called, granting us access to a shell.
Flag: ICO{h0W_m4nY_C4R5_w0UlD_4_c4rpArK_p4rK_1F_a_carp4rK_c0u1d_p4rk_car5}
Rev - Complicated
The binary appears to be a classic password checker. Decompiling the binary, we get
__int64 __fastcall
_BOOL8
__int64 __fastcall
__int64 __fastcall
__int64 __fastcall
__int64 __fastcall
__int64 __fastcall
__int64 __fastcall
...
It can be seen that the input must be 80 characters long, and must then satisfy a lot of arithmetic expressions involving each character. At first, I tried to use angr to solve it, but it ended up not working, making me waste 2 hours trying.5 About 1 hour before the end of the competition, I started trying to write my own “disassembler” to automatically extract the arithmetic operations from the ELF file, then use z3 to symbolically solve for each character, but I didn’t manage to finish in time :( In hindsight I probably should have disassembled with objdump or something and worked on that instead of the raw bytes, or asked ChatGPT to write a parser for me. Oh well.
Here is the author’s solve script:
# coding: utf-8
=
= True # Enable detailed instruction information
=
=
=
=
# Define a hook_code function to print disassembled instructions
global
global
# Read the instruction bytes from emulated memory
=
= + + 7
= - 0x80040 - 0x1337000
== :
=
=
= +++
=
== and == :
== and == :
=
=
=
=
+=
It was about the same idea as mine, but instead of statically analysing the ELF/disassembly, the binary was emulated to extract the checks. After that, z3 is used to find the characters of the input, which is then base 64 decoded to retrieve the flag.
Flag: ICO{callcallcallcallcallcallcallcallcallcallcallangrcallme}
Day 2 - ICO Blue
For some reason, the organisers decided to change the score distribution the night before the competition. Day 1 had 700 points over 8 challenges, with a welcome challenge at 50 points and most other challenges at 100 points. Day 2 had 21(!!) challenges, with not just one but two welcome challenges worth 10 points each. The point distribution was also now more spread out, with most challenges having scores between 20 and 70 points. However, a lot of points were concentrated in the digital forensics category, which had 465 points across 13 challenges (although they were all part of one “big” challenge and had to be solved in order); a lot of people I talked to after the competition were not very happy with this arrangement.
I solved 19 challenges, earning 833 points. The challenges weren’t really that interesting to talk about; most of the challenges were trivial and could be ChatGPTed (with the exception of the CSIT-set pwn, which no one solved), so I’ll just be giving a brief overview of the entire digital forensics category.
Digital Forensics
The challenges were split into three phases: Vunerability Assessment, Forensics, and AP (I don’t know what it stands for). For phase 1, we were first given an IP, and asked to scan it to find any interesting services running. Then, we were asked to determine the OS that was running on the target machine. Both of these can be done relatively easily with nmap.
Next, we were asked to access the vulnerable service (it was HTTP). Doing so brought up a to-do list, mentioning the vulnerability CVE-2024-4577. We then had to use metasploit to exploit the website to gain shell access on the remote machine, and were tasked to retrieve a packet capture file from the desktop, concluding phase 1.
In phase 2, we had to analyse the obtained packet capture, dig through it to find evidence of file transfer between a malicious attacker and the machine, and recover the malware file that was transferred from the attacker. Then, we had to analyse the malware in phase 3, and answer some relatively simple questions about its functions.
Miscellaneous Thoughts
ICO didn’t really go as smoothly as I had hoped it would. We had no team Singapore group chat until day 1 of ICO, and our only method of communication with the organisers was through email, so we had basically no way to contact other members of team Singapore unless we already knew them beforehand. We also had no team leader and no volunteer, so we often didn’t receive important information that all the other teams received (I think).
Aside from communication issues, there were also many problems on the technical side of things. A few days before ICO even started, we received a password-encrypted zip file meant to include challenge files for ICO Blue, the second day of the competition. Unfortunately, the zip file could be cracked with bkcrack, resulting in a leak of the challenge files. While this was raised up to the organisers, no action was taken.
The day before ICO Red, we were given a technical briefing, in which it was claimed that the challenge files sent were actually meant for ICO Red. This was confusing, especially because the zip file itself was named ICOBlue.zip. We tried to seek clarification, but no clear explanation was ever given. At around 7pm that day, an email was sent out containing ICOBlue.zip again, but this time with the password included.
Just 8.5 hours before ICO Red was supposed to start, I was informed that the zip file would not be used.

Apparently, the message was disseminated amongst the volunteers chat. As mentioned before, team Singapore had no team leader of volunteer, so we were not immediately made aware of this change, and it took a fellow participant to forward the message from another volunteer who sent it to them. The organisers, being present in the team Singapore group chat, read the messages but did not reply with a clarification.
About an hour before ICO Red started, I was told by a volunteer that the contest duration would be shortened from the original 5 hours to 3 hours, owing to some challenges being removed. This was not announced in the team Singapore group chat; the only time it was officially announced to team Singapore was in the briefing right before the competition. However, about 2.5 hours into the competition, it was announced that the duration would be extended by another hour, to 4 hours in total.
The night before ICO Blue, the second day of the competition, we were sent credentials to connect to a jump server and Kali VM that was required to be used for some challenges. Somewhat expectedly, there were many technical issues that arised, culminating in a 55 minute delay of the start of the competition. During the closing ceremony, this was explained to be because of safety precautions being automatically triggered within the NUS servers.
I am somewhat disappointed and sad that the inaugural ICO was not very well organised; however, given that majority of the Olympiad was organised by one person(?) in just six months, it is not very unexpected. With a proper organising committee and scientific committee set up, I hope that ICO 2026 will surely go much more smoothly.
GreyCTF finals
GreyCTF finals were held overnight across two days in NUS COM1. My team didn’t do so well for this; I think we all collectively gave up around 11pm and started fooling around.6 It also didn’t help that I stayed up until 2:30am playing mahjong the previous night.
I’ll be writing about all except one of the cryptos, althogh I only managed to solve three of them during the competition. As always, the challenges were pretty high-quality and nice to solve (…maybe except for meow log meow log e)
Safe XOR
# in case of none input
return
return
return not
return None
= b
=
= +
assert ==
An important observation to make is that the safeXor function is the same as addition mod 3, with None as 0, False as 1, and True as 2. We can construct the matrix representing the LFSR in sage under GF(3), then invert, exponentiate, and finally multiply with the final state to recover the original state and get the flag.
=
=
=
= 1
= 1
=
=
= ^*
=
Flag: grey{!safe,_!xor,_wow..,..,.,}
DLog24
= 4159930969
= ^24 + 11*^23 + 17*^22 + 4159930747*^21 + 4159930096*^20 + 974*^19 + 9643*^18 + 6555*^17 + 4159887259*^16 + 4159860403*^15 + 78895*^14 + 233115*^13 + 4159929335*^12 + 4159584268*^11 + 4159760950*^10 + 223085*^9 + 195174*^8 + 4159894366*^7 + 4159855742*^6 + 4159919343*^5 + 8317*^4 + 2638*^3 + 45*^2 + 4159930938* + 1
=
=
= ^
assert ==
A standard DLP challenge in , where is an irreducible polynomial of degree 24. I spent a grand total of around 12 hours on this challenge, trying to Pohlig-Hellman it because the order was “smooth enough”. Spoiler alert: it didn’t work. Then after about 11 hours of going back and forth between ChatGPT and VSCode I decided to be smart and gave up.
You can solve the DLP by first solving over mod , then lifting your way through all the way until through Hensel lifting, but Sage can also oneshot it with p-adics pretty easily.
My solve:
= 4159930969
=
= ^24 + 11*^23 + 17*^22 + 4159930747*^21 + 4159930096*^20 + 974*^19 + 9643*^18 + 6555*^17 + 4159887259*^16 + 4159860403*^15 + 78895*^14 + 233115*^13 + 4159929335*^12 + 4159584268*^11 + 4159760950*^10 + 223085*^9 + 195174*^7 + 4159894366*^6 + 4159855742*^5 + 4159919343*^4 + 8317*^4 + 2638*^3 + 45*^2 + 4159930938* + 1
=
= ...
=
Flag: grey{h3h3heheh3h3_1_luv_p0lynom1als_s0_much_sie_ist_me1ne_best1e!1!11!!11!!1!!!11!!11!}
Meow Log Meow Log Meow E
# e_power
=
= 2048
= 65537
, = ,
= *
= *
=
assert > ^ 0.292,
=
=
=
# Hint: HUH? WHAT'S E DOING HERE,
# I THOUGHT THIS WAS A FINITE FIELD????!?!?!?!?!?!?!
=
= ^
= ^
I am not a fan of this challenge. A non-trivial part of the difficulty comes from the fact that e_power is not made known to players. It also doesn’t help that e also represents the public exponent in RSA, leading to further confusion. About 1.5 hours into the competition, the challenge was modified to include the hint that e_power(x, y, z) calculates e^x in y; the purpose of z was still not made clear.
When considering the exponential function on non-standard structures , the first thing one should think of should be the Maclaurin series expansion of :
The third argument to e_power can then be interpreted as an indicator to how many terms should be taken from the expansion. I had originally thought that 77 would mean that the resulting polynomial would have degree 77, but it actually means that the polynomial would have 77 terms (so degree 76).
From there, we can construct two polynomials with the flag as a common root:
We can then take their GCD (using half-GCD, since normal GCD would be too slow) to get the flag. A point to note is that exponentiating the Maclaurin expansion normally would take too much memory and crash Sage; the fix is to specify the other polynomial as a modulus when exponentiating.
= ...
= ...
= ...
=
# halfgcd taken from https://github.com/jvdsn/crypto-attacks/blob/master/shared/polynomial.py
assert <
return 1, 0, 0, 1
= // 2
=
=
, , , =
= * + *
= * + *
return , , ,
, =
=
=
, , , =
return * + * , * + * , * + * , * + *
"""
Uses a divide-and-conquer algorithm (HGCD) to compute the polynomial gcd.
More information: Aho A. et al., "The Design and Analysis of Computer Algorithms" (Section 8.9)
:param a0: the first polynomial
:param a1: the second polynomial
:return: the polynomial gcd
"""
# TODO: implement extended variant of half GCD?
assert ==
return
, = , %
< :
, = ,
assert >
=
# Optimize recursive tail call.
, =
return
, , , =
= * + *
= * + *
return
, =
return
=
=
=
= ^55537 -
= -
=
This finishes in about 6 minutes, much better than the 4.5 hours that it would have taken if one tried using the Euclidean algorithm.
Flag: grey{me0w_m3ow_s0lUtioN_t0o_Sl0w_4_m3-oW!!!}
Stirrer
server.py
=
=
=
=
, =
return
= 0
=
=
=
# das crazy
+=1
encrypt.c
uint8_t key;
// 8-bit left rotation
inline uint8_t
inline void
void
int
The challenge implements a custom block cipher operating on blocks of five bytes each, with a five byte key. We will first model one round of the encryption function in z3.
=
=
=
+=
^=
=
+=
^=
=
+=
^=
=
=
return
# [
# RotateLeft(s0 + k0 + s1 + k1 ^ s2 + k2 + s4 + k4, 7) + k0,
# s1 + k1 + s2 + k2 + s4 + k4 + k1,
# s2 + k2 + s4 + k4 + k2,
# RotateLeft(s3 + k3 ^ s0 + k0 + s1 + k1, 1) + k3,
# RotateLeft(s4 + k4 ^ s1 + k1 + s2 + k2 + s4 + k4, 1) + k4
# ]
Let c be the ciphertext obtained from one round of encryption. It can be seen that c[1], c[2], and c[4] do not depend on s[0], s[3], k[0], or k[3]. We can then brute force for k[1], k[2], k[4] from a plaintext-ciphertext pair, then brute force the remaining two key bytes. This took about 40 seconds on my computer.
brute.c
// gcc brute.c -O3 -o brute
uint8_t key;
// 8-bit left rotation
inline uint8_t
inline void
void
int
With the key obtained, we can then decrypt it with itself and submit it to the server to get the flag.
Flag: grey{obligatory_dont_roll_your_own_crypto_reference_i_hope_you_didnt_just_gpt_the_soln_else_ill_be_sad_:(}
Final thoughts
Winning in ICO then bombing GreyCTF finals in such close succession was an experience. Even though both events were in person, I didn’t manage to meet many new people, and mostly interacted with ones that I knew, which was kind of sad. I also remember thinking to myself that I’ll retire from playing CTFs,7 but now that I’ve had some time to think about it, I don’t think I will; CTFs are too fun.
technically five; the first and last day were for arrival/departure
some technical issues, and communication issues especially with the Singaporean team
there were pwn and web challenges on the “defence” style day as well
and didnt solve
i just realised the challenge name is a hint
after talking with the challenge author i learnt that angr was the intended solution originally but it didnt work out; the flag still mentions angr
i am thoroughly convinced that i cannot do well for in-person ctfs
i say this after almost every ctf