Logo
Overview
Team Italy Qualifications CTF Writeups

Team Italy Qualifications CTF Writeups

tlsbollei tlsbollei
June 7, 2025
8 min read
index

The TeamItaly Quals 2025 served as this years election process for the members of Team Italy - a prestigious CTF team full of talented people representing their country at the ECSC. Their challenges were worthy of this title - with most challenges not exceeding 10 solves, with the CTF having over 500 people officialy registered. Let’s get into it!

crypto - sombrero (500 points, 3 solves)

We are given 1 file, and that is a file called sombrero.py.

The file is source code of an application which was simulating a blockchain-like system with basic transaction support. The initialization worked as follows :

flag = os.getenv("FLAG", "TeamItaly{REDACTED}")
MAX_USERS = 5
users = {}
transactions = {}

The users inside of the source code register by providing a user_id that is 120-127 bits long, where each user is initialized with a balance of 100 and an associated hash function instance :

def register():
...
assert user_id not in users and 120 <= user_id.bit_length() <= 128
user_h = Hash(user_id)
users[user_id] = {"h": user_h, "balance": 100, "spent_transactions": []}

Users can send value to others or to the system itself using the to = 0 flag. The transaction is stored using a key derived from Hash.h(transaction_id)

def create_transaction():
...
assert users[user_from]["balance"] >= value and value >= 0
assert transaction_id.bit_length() >= 128
users[user_from]["balance"] -= value
transactions[users[user_from]['h'].h(transaction_id)] = {
'from': user_from,
'to': user_to,
'value': value
}

To spend a transaction, the user must provide a transaction ID that the system :

  1. Looks up using h(transaction_id)
  2. Checks that it has not been used before (the transaction id)
  3. If to = 0 and value >= 1500 , prints out our dear flag
def spend_transaction():
transaction = transactions[users[user_from]['h'].h(transaction_id)]
assert transaction['from'] == user_from
assert transaction_id not in users[user_from]["spent_transactions"]
users[user_from]["spent_transactions"].append(transaction_id)
if transaction["to"] == 0 and transaction["value"] >= 1500:
print(flag)
else:
users[transaction["to"]]["balance"] += transaction["value"]

Custom hash implementation

The class Hash implements a non-standard and deterministic function which is influenced by:

  1. An internal 128 bit init_state (the user ID)
  2. A fixed bit permutation bit_perm seeded with 0x1337 (Wow, how original, yolo)
  3. A state-mixing function F(a, b, bitsize) that combines permutation and XOR with constants derived from math.e
def F(self, a, b, bitsize):
for r in range(bitsize):
bit = (b >> r) & 1
tmp = 0
for i, bit_index in enumerate(self.bit_perm):
new_bit = (a >> (bit_index ^ bit)) & 1
tmp |= (new_bit << i)
a = tmp ^ self.c
return a

and the hash digest flow is as follows

tmp = F((state << 64) + b, b, 64)
state = F((state << 64) + b, tmp, 192)
state = state ^ tmp
state = state >> 64

In the above hash digest flow we can see this interesting operation

state >> 64

This truncation ensures that only the upper 64 bits are retained after each block, which means that the entire hash output is truncated to 64 bits, even though the intermediate states are 192 bits wide. This introduces the risk of hash collisions where two distinct inputs produce the same final hash.

transactions[users[user_from]['h'].h(transaction_id)] = { ... }

Because each transaction is indexed by Hash.h(transaction_id), the underlying hash value is the actual key in the transactions dictionary. Especially here, in this transaction system, the system only checks whether the raw input integer was previously used and it does not consider whether another transaction_id that hashes to the same value has already been spent

TL;DR Two different transaction IDs txid1 and txid2 can produce the same Hash.h(txidX) output, and therefore access the same transaction record. But the check transaction_id not in spent_transactions only blocks reuse of the exact same ID, not other colliding ones.

This is the root of the collision potential:

state = state >> 64

Even though state after F() may be 128-192 bits wide it is only the upper 64 bits which are kept, therefore many different input blocks can result in the same final state, which makes the nature of this entire function inherently non-collision-resistant, as it cannot ensure unique digest outputs for distinct transaction IDs.

Using the flaw described above, we can :

  1. Find two inputs such that :
Hash(user_id).h(txid1) == Hash(user_id).h(txid2)
  1. Use txid1 to create and spend a transaction
  2. Then spend txid2 - which is accepted by the system, all because it hashes to the same key in transactions, therefore it is valid, and it is also not inside of spend_transaction, so the check passes

Practice

Imagine this :

Hash(uid).h(0xAAA) == Hash(uid).h(0xBBB)
  1. create_transaction(blabla, 0xAAA) stores the data at some key, k
  2. spend_transaction(0xAAA) looks up k , processes the transaction, and marks 0xAAA as spent
  3. spend_transaction(0xBBB) also looks up the same key, k , and it also finds the same data, and passes the entire check

Exploit

def find_base(x0, x1):
return x0 >> 64, x0 & ((1<<64)-1), x1 & ((1<<64)-1)
def build_pairs(base, lo0, lo1, needed=8):
H = CustomHash(base)
b0 = lo0 << 128
b1 = lo1 << 128
pairs = []
i = 0
while len(pairs) < needed:
if H.digest(b0 + i) == H.digest(b1 + i):
pairs.append((b0 + i, b1 + i))
i += 1
return pairs
  1. We start from two 128-bit user IDs (x0, x1) that share the same upper 64 bits
  2. We construct two sets of transactions IDs
M0 = (lo0 << 128) + i, M1 = (lo1 << 128) + i
  1. We find M0, M1 such that digest(M0) == digest(M1) due to truncatation Using collision pairs (a, b) where h(a) == h(b), we perform:
for a, b in coll_pairs:
create(self.uid, self.balance, a)
spend(a)
spend(b)
self.balance *= 2

As we already mentioned, the second spend, b, is accepted becasue the system only checks that the raw transaction_id has not been used - not whether the hash key was reused. a and b hash to the same value, so both map to the same transaction in the transactions dict This loop doubles the user balance with every iteration, reaching over 256000 from the initial 100 coins in 8 iterations.

Once the balance exceeds 1500, we execute:

tx_final = (1 << 128) + 0x1337
create(0, 1500, tx_final)
spend(tx_final, final=True)

This sends 1500 coins to to = 0, triggering:

if transaction["to"] == 0 and transaction["value"] >= 1500:
print(flag)

… successfuly printing the flag.

Solve

import struct
import math
import random
from Crypto.Util.Padding import pad
from Crypto.Util.number import long_to_bytes
from pwn import remote
# zreplikovana ich vlastna custom hash implementacia ktora bola v hash.py
class CustomHash:
def __init__(self, seed):
self.state = seed
self.BSZ = 8
self.DSZ = 16
self.CONST = int.from_bytes(struct.pack('<d', math.e) * ((self.DSZ + self.BSZ) // 8), 'big')
random.seed(0x1337)
self.perm = list(range(8 * (self.DSZ + self.BSZ)))
random.shuffle(self.perm)
def _round(self, A, B, width):
X = A
for r in range(width):
bit = (B >> r) & 1
tmp = 0
for i, idx in enumerate(self.perm):
tmp |= (((X >> (idx ^ bit)) & 1) << i)
X = tmp ^ self.CONST
return X
def digest(self, m_int):
data = long_to_bytes(m_int) if isinstance(m_int, int) else m_int
data = pad(data, self.BSZ)
S = self.state
for off in range(0, len(data), self.BSZ):
block = int.from_bytes(data[off:off+self.BSZ], 'big')
t1 = self._round((S << (8*self.BSZ)) + block, block, 8*self.BSZ)
t2 = self._round((S << (8*self.BSZ)) + block, t1, 8*(self.BSZ + self.DSZ))
S = ((t2 ^ t1) >> (8*self.BSZ))
return S
def find_base(x0, x1):
return x0 >> 64, x0 & ((1<<64)-1), x1 & ((1<<64)-1)
def build_pairs(base, lo0, lo1, needed=8):
H = CustomHash(base)
b0 = lo0 << 128
b1 = lo1 << 128
pairs = []
i = 0
while len(pairs) < needed:
if H.digest(b0 + i) == H.digest(b1 + i):
pairs.append((b0 + i, b1 + i))
i += 1
return pairs
class Client:
def __init__(self, host, port, uid):
self.r = remote(host, port)
self.uid = uid
self.balance = 100
self.r.recvuntil(b'> ')
def register(self):
self.r.sendline(b'1')
self.r.recvuntil(b'User id: ')
self.r.sendline(str(self.uid).encode())
self.r.recvuntil(b'> ')
def create(self, to, amt, txid):
self.r.sendline(b'2')
self.r.recvuntil(b'From: ')
self.r.sendline(str(self.uid).encode())
self.r.recvuntil(b'To: ')
self.r.sendline(str(to).encode())
self.r.recvuntil(b'Value: ')
self.r.sendline(str(amt).encode())
self.r.recvuntil(b'Transaction id: ')
self.r.sendline(str(txid).encode())
self.r.recvuntil(b'> ')
def spend(self, txid, final=False):
self.r.sendline(b'3')
self.r.recvuntil(b'From: ')
self.r.sendline(str(self.uid).encode())
self.r.recvuntil(b'Transaction id: ')
self.r.sendline(str(txid).encode())
if final:
return self.r.recvline(timeout=5).decode().strip()
else:
self.r.recvuntil(b'> ')
return None
def exploit(self, coll_pairs):
for a, b in coll_pairs:
self.create(self.uid, self.balance, a)
self.spend(a)
self.spend(b)
self.balance *= 2
tx_final = (1 << 128) + 0x1337
self.create(0, 1500, tx_final)
flag = self.spend(tx_final, final=True)
print("Flag:", flag)
if __name__ == '__main__':
x0 = 4834255595133874435204552325720083095538520465749038972963
x1 = 4834255595133874435204552325720083095538520465749038972939
base, l0, l1 = find_base(x0, x1)
pairs = build_pairs(base, l0, l1)
c = Client('sombrero.challs.quals.teamitaly.eu', 38069, base)
c.register()
c.exploit(pairs)

Flag

TeamItaly{Infinite_money_glitch_ftw_868a388d2bdea91c}

rev - TsFuck (80 points, 35 solves)

We are given 1 file, and that is a file called TsFuck.ts. Upon initial analysis of the source code and the CTF assignment, we can easily derive that the flag is embedded statically within the source code. Proof of this is no service being provided on the website, along with the source code showing no signs of externally connecting anywhere. Our very first look at the source code shows one thing - the core concept behind this challenge is type-level computation.

Using type-level computation, the flag characters are encoded using number-based operations implemented entirely within TypeScript’s type system. Firstly, the numbers are represented as Peano numerals, a recursive form of encoding numbers:

interface Qz {
ke?: any;
nw: "true" | "false";
}
// 0
type Yh = { nw: "true" };
// Successors (1 to 9, etc.)
type Zb<T extends Qz> = { ke: T; nw: "false" };
type Bp = Zb<Yh>; // 1
type Df = Zb<Bp>; // 2
type Gt = Zb<Df>; // 3
// and so on

Reverse engineering TypeScript’s arithmetic on types

TypeScript’s recursive conditional types allow implementation of arithmetic operations, that we have seen in the source code.

Exponentation (Fx)

type Fx<T1 extends Qz, T2> = {
true: T2;
false: Zb<Fx<Lq<T1>, T2>>;
}[Wg<T1>];

The above code is the TypeScript type system equivalent of exponentation. What it does is :

  1. If T1 == 0 (base case), return T2
  2. Else, return Zb<Fx<Lq<T1>, T2 It’s effectively counting how many times to wrap Zb around T2 simulating multiplication.

Multiplication (Vn)

type Vn<T1 extends Qz, T2 extends Qz> = Kc<T1, T2, Yh>;
type Kc<T1, T2, TAcc extends Qz> = {
true: TAcc;
false: Kc<Lq<T1>, T2, Fx<TAcc, T2>>;
}[Wg<T1>];

Above code recursively accumulates T2 in the accumulator, which mimicks repeated addition == multiplication.

Modulo Expontentation (Xf)

type Xf<T2 extends Fd, T1 extends Fd> = Fx<Vn<Qp, Zr<T2>>, Zr<T1>>;

What this does is that :

  1. Zr<T maps digit T to its type (e.g., Zr<3Gt
  2. Qp = 10 so Vn<Qp Zr<T2 gives 10*T2
  3. Fx<10*T2 Zr<T1 == exponentiation This calculates T1 ^ (10 * T2 at the type level.

Complex Operation : Ona

This type models a full expression:

type Ona<TB, TE, TA, TM> = {
true: Bp;
false: Lm<Fx<On<TB, TE, TM>, TA>, TM>;
}[Wg<TE>];

Translated, this evaluates to :

(TB^TE + TA) % TM

Where :

  1. TB : base (flag value)
  2. TE : exponent
  3. TA : addend from previous stage
  4. TM : modulo, always 97 This is precisely how each character in the flag is validated by computing a chained equation.

The last type is :

type It<TC extends "true"> = "true";
var output: It<Np<Uq<Ona<...>, Xf<0,0>, Xf<9,7>>>>;

This encodes a deeply nested chain of Ona computations, each one feeding into the next. The Np<Uq<sum_t1ng_h3re>> finally compares the result of all computations to Xf<9,7 - e.g 7^9 % 97 = 4

It<Np<Uq<Ona<...>, 4>>>;

Only if the result is exactly 4 does the whole type resolve to “true”, and thus pass type-checking.

The goal is to find 38 characters such that :

(flag[i] ^ exponent[i] + prev_result) % 97 == expected_result[i]

Which we reimplemented in python as :

x = pow((g - ta + 97) % 97, modinv(te, 96), 97)

Where :

  1. g = result value
  2. ta = previous result
  3. te = exponent And finally, we converted the result to characters:
chr(x + 48)

This is precisely because each flag byte was stored as ASCII - 48 and this correctly reverts it.

Solve

import sys
def egcd(a, b):
if a == 0:
return (b, 0, 1)
else:
g, y, x = egcd(b % a, a)
return (g, x - (b // a) * y, y)
def modinv(a, m):
g, x, y = egcd(a, m)
if g != 1:
raise Exception('modular inverse does not exist')
return x % m
def solve_modular_root(target, te, tm):
if target == 0:
return 0
phi_tm = tm - 1
inv_te = modinv(te, phi_tm)
solution = pow(target, inv_te, tm)
return solution
def solve_ctf_challenge():
TM = 97
equations_in_order = [
(36, 13, 2), (7, 11, 19), (20, 13, 43), (10, 13, 33), (30, 13, 77),
(2, 5, 74), (18, 19, 72), (35, 5, 1), (3, 11, 36), (9, 17, 14),
(33, 11, 91), (11, 7, 53), (15, 17, 32), (22, 11, 51), (29, 5, 26),
(13, 5, 12), (6, 13, 1), (19, 7, 65), (16, 7, 56), (23, 11, 89),
(24, 19, 34), (0, 13, 70), (31, 13, 11), (28, 5, 61), (25, 11, 61),
(37, 13, 80), (14, 7, 47), (1, 17, 79), (21, 7, 79), (32, 19, 55),
(34, 7, 55), (4, 5, 11), (17, 13, 96), (26, 11, 35), (12, 5, 89),
(5, 11, 31), (27, 19, 29), (8, 7, 72)
]
solutions = {}
ta = 0 # spomínaný addend
for f_idx, te, g in equations_in_order:
target = (g - ta + TM) % TM # Ona<TB, TE, TA, TM> reverse
solved_val = solve_modular_root(target, te, TM) # Ona<TB, TE, TA, TM> reverse
solutions[f_idx] = solved_val
ta = g
final_flag = ""
for i in range(38):
val = solutions.get(i)
if val is None:
final_flag += "?"
continue
char_code = val + 48
# print(chr(char_code)) -> Uncomment if you want to see result of each byte after reversing
final_flag += chr(char_code) # Add each solved byte to the final flag
print(final_flag)
if __name__ == '__main__':
solve_ctf_challenge()

Flag

TeamItaly{Wh4t_h4v3_y0u_d0n3_e27fc07e}