Logo
Overview
HITCON CTF 2025 Writeups

HITCON CTF 2025 Writeups

tlsbollei tlsbollei
August 24, 2025
10 min read
index

Recently we (Slovak Cyber Team), (Czech Cyber Team) and (The Few Chosen) got together for a week long international training bootcamp in preparation for the European Cybersecurity Challenge (ECSC), where we lost our minds over HITCON CTF. An inhumane amount of caffeine, beer ( milk for me :D ) and a dangerously small amount of sleep, we managed to solve a bunch of pretty hard challenges and even first blood one of them. Huge props to TFC, very cool people!

Unfortunately we didn’t win. Below are the challenges I solved and my thoughts/writeups on them :3 Let’s get into it :3

rev/crypto - sharing is caring

It’s just compiled python… right?

We are given a single artifact, a Python 3.12 .pyc file, which upon execution wanted three lines on stdin as input, and either printed out “Success!” or “Fail”

Terminal window
$ ./sharing-is-caring.pyc
> 44444
> 5547777854
> 111111
Fail

Of course, being the very cheesy CTF player I am, the very first thing I thought about -> “If it’s asking for three inputs, and then evaluates it with simple success/fail strings, with no indicator of spitting out the flag… is the flag embedded within bytecode, and can we extract it somehow without messing with the crypto inside?”

Spoiler alert -> each line is the big-endian byte encoding of an integer that serves as an exponent; the three lines are ASCII chunks of the flag, concacenated. Wow, so that’s a shame. Loading the .pyc into PyLingual gives us this :

challenge.obf2.py
# Decompiled with PyLingual (https://pylingual.io)
# Internal filename: challenge.obf2.py
# Bytecode version: 3.12.0rc2 (3531)
# Source timestamp: 2025-08-23 11:07:46 UTC (1755947266)
59 collapsed lines
nums = [
4144803293417776131310451317495228706499130241044716671850484110288180082374299088166459295448719,
(
-2072401646708888065655225658747614353249565120522358335925242055144090041187149544083229647724360
),
1402769202505631727601810730581776197716350949074282314174097681867464170562021714349605843278665,
(
-266910464101355921528772386602523543917783644737516474351090027562514187410064675818699897958587
),
(
-313229353281522365344068664569836505240104176843824464617477560855244400498647725320806722489359
),
557751498740761784158621178035559059268846555052211907264408739687389120087425335352771649978396,
(
-148220410045571281675213691246814858326140849250454299621527572224931143542450173517412542679173
),
564587776942621790900159644909288844361323691524579619089869964015177357514421646000222366841011,
16419311167856483659743166580345122059429852687371972090030542491766200835221304811819328984127643,
(
-129495574458812869075479900411721351204283016397163153311462604211940551198569895507569182199080813
),
47898868564720804115714682250274391102579185635034480293836524415742949562361820585750668129254529,
(
-218390354027018310133763040209628187632374060689193498603396047814125983080443006327100714094044413
),
59103242197276592018409321873953124131699316618422104513410197920897935735761382943069825870316159,
(
-27263762963672679216265023144097755508375724831456195913391130341016602743379285802111387508470863
),
1160187342824936887648971210216937643772466507360201042905423657153536580858662152125535660765491989,
(
-444320422098364618829295716221878629448833826150298094515910374813093990742157048727647821855149363
),
3391657179499987245173012089157446876066874678381207432018307585249041358997171094264590277038007977,
(
-24577331115755648042095942172693201165277671886951704215934044389070300940570840382524224198279961
),
1269071325010846032881520707673575200237482393580774435940684724230748033745143799781707960856809939,
(
-7726874421105907152194917563460067676020997409790741668467611810647472035036892326157134516638801157
),
52668298578428194831854365844392870064649623064538094318250290519368281728564224127244694555479507759,
(
-14912585782593436965791721815706880430658073768879853640660934490173887905327298345044778117728763977
),
20665009833075583446533567863500153284398139825571833690969943149563885799810347822665143905426735039,
(
-4881669849418826679690882149267389579292004979139066657955496750719085286782861933735958022689574551
),
19727966805580144784665343549651570513281612075070319397079427974816184475541029955406232208165236847,
(
-412912430703112866772671727077127791058157331577997254034268890188791073228873359777862031674718585977
),
133658941250435616035761147529730684421152093449583968335054480106579383066071224012305258698560296819,
(
-458807313057700431931426630680383859744706640412367673276163407570064822322611955042530141076353045301
),
]
def very_fun(arg):
if arg in (17, 18):
return 18 - arg
from fractions import Fraction as F
l = len(nums)
out = F(0, 1)
state = F(1, 1)
for off in range(l):
# This one is *VERY* guessy, IDK if it is right..
# out += F(nums[off], return[off]) * state
out += F(nums[off], nums[off]) * state
state *= F(arg - off, 1)
return int(out)
init_a = very_fun(0)
init_b = very_fun(1)
init_c = very_fun(2)
init_d = very_fun(3)
init_e = [very_fun(4), very_fun(5), very_fun(6)]
init_f = [
(very_fun(7), very_fun(8), very_fun(9)),
(very_fun(10), very_fun(11), very_fun(12)),
(very_fun(13), very_fun(14), very_fun(15)),
]
while_var = very_fun(16)
def fun0(arg0, arg1):
while arg1:
state = arg0 & arg1
arg0 = arg0 ^ arg1
arg1 = state << very_fun(17)
return arg0
def fun1(arg0, arg1):
if arg1 < very_fun(18):
return -very_fun(17) * fun1(arg0, -arg1)
out = very_fun(18)
while arg1:
out += arg0 if arg1 & very_fun(17) else very_fun(18)
arg0 <<= very_fun(17)
arg1 >>= very_fun(17)
return out
def fun2(start, arg0, arg1):
state = start
out = very_fun(17)
while arg0:
if arg0 & very_fun(17):
out = fun1(out, state)
out %= arg1
state = fun1(state, state)
state %= arg1
arg0 >>= very_fun(17)
return out
def fun3(arg0, arg1, arg2):
out = fun1(fun2(init_c, arg1, init_a), fun2(init_d, arg2, init_a)) % init_a
target = very_fun(17)
state = very_fun(17)
for x in init_e:
target = fun1(target, fun2(x, state, init_a)) % init_a
state = fun1(state, arg0) % init_b
return out == target
def fun4(arg0, arg1):
a, b = (very_fun(17), very_fun(18))
c, d = (arg0 % arg1, arg1)
while c > very_fun(17):
tmp = d // c
x, y = (b - fun1(a, tmp), d - fun1(c, tmp))
a, c, b, d = (x, y, a, c)
return a % arg1
def fun5(arg):
if not isinstance(arg, (list, tuple)) or len(arg) < very_fun(19):
return False
vals = {key: (a, b) for key, a, b in init_f}
for key, a, b in arg:
vals[key] = (a, b)
if len(vals) != very_fun(20):
return False
for key, (a, b) in vals.items():
if not fun3(key, a, b):
return False
else: # inserted
some_set = {key for key, a, b in arg[: very_fun(19)]}
some_list0 = [
(key, vals[key][very_fun(18)]) for key in sorted(some_set) if key in vals
]
some_list1 = [(key, a) for key, a, b in init_f if key not in some_set]
res = []
if some_list0:
res.append(some_list0[very_fun(18)])
res.extend(some_list1[: very_fun(21)])
if len(res) < very_fun(19):
res = list(((key, a) for key, (a, b) in sorted(vals.items())))[
: very_fun(19)
]
_ = very_fun(18)
for key0, (a0, b0) in enumerate(res):
x, y = (very_fun(17), very_fun(17))
for key1, (a1, b1) in enumerate(res):
if key0 == key1:
continue
x = fun1(x, -a1) % init_b
y = fun1(y, a0 - a1) % init_b
__ = fun1(x, fun4(y, init_b)) % init_b
if __name__ == "__main__":
inpt0 = int.from_bytes(input("1:").strip().encode())
inpt1 = int.from_bytes(input("2:").strip().encode())
inpt2 = int.from_bytes(input("3:").strip().encode())
if fun5(
(
[very_fun(22), inpt0, very_fun(23)],
[very_fun(24), inpt1, very_fun(25)],
[very_fun(26), inpt2, very_fun(27)],
)
):
print("Success!")
else: # inserted
print("Fail")

We can see a very large integer list, nums, and a function, very_fun(arg), that combines fractions while multiplying by (arg - off) each iteration What we can also notice is a bunch of helper functions fun0..fun5 full of bit ops and shifts.

Did you notice? Most importantly, it is way too opaque. Decompiling this compiled python has by far proved to be the most troublesome and I had to manually reconstruct a bunch of stuff. Namely, the very first decompile you see above is very guessy and used nums[off] as its own denominator, therefore the outputs from this version were nonsense. Right next to nums there is a second array whose entries look factorial-ish or combinatorial denominators.

The pattern is :

Start with a rational accumulator , for off = 0 .. m, add n[off] / D[off] * state , and update state *= (arg - off)

This is the shape of “evaluate a baked in polynomial at integer arg using Newton form”. In other words, very_fun(n) was used to synthesize constants from compressed data. I manually fixed the wrong guessy part of very_fun(i), so that its occurences were constant-folded, all so we get a straightforward verifier. I won’t talk about fixing decompilation artefacts, that’s boring. All that matters is, I managed to fold the constants, and got this final version :

reversed.py
C0 = 4144803293417776131310451317495228706499130241044716671850484110288180082374299088166459295448719
C1 = 2072401646708888065655225658747614353249565120522358335925242055144090041187149544083229647724359
C2 = 1402769202505631727601810730581776197716350949074282314174097681867464170562021714349605843278664
C3 = 1335174568503939352563889373190143608146136792487939183543780907770759908268721571509488188235873
MATRIX_TARGETS = [
442427645836698445267007097625472942305363362863130591746066528455945891079759637465163543741507,
1132891618999846053017541510085320021631035459029704515373044135313614915469753666299478525789537,
3440625422497330758356285966425842805899353260932540945262402832827054563258418833844309632149096,
]
MATRIX_ROWS = [
(
51966,
480442839669953990717445646260333383833736214883976558477525831992994289143305421629761482429714,
680701978345522085049694956190292511497787092168524280991013677878931201637910598103187515536181,
),
(
47806,
253717457675323180603201716971045682091261518437666533915787790782521696917056900361404824269689,
99768326317759269813131181172947299870925613919195157126671930387206518545837120733862757382354,
),
(
201527,
782319055923618868639890136618720319997476308047895888006842867173403800015590219030945524995543,
944213788775446264472010893716848921879528565854393198519311752189219209422386607874556309845161,
),
]
C4 = 865967982817260602973374066425323583617367103807916127263320767485154348516392723060969424214055
def _383e0a577(_3a58bf927, _b0ebfeb1f):
while _b0ebfeb1f:
_851630ece = _3a58bf927 & _b0ebfeb1f
_3a58bf927 = _3a58bf927 ^ _b0ebfeb1f
_b0ebfeb1f = _851630ece << 1
return _3a58bf927
def _b0513850f(_42c60fb75, _e2f78473c, _dfaa473d4):
_bb2030824 = (pow(C2, _e2f78473c, C0) * pow(C3, _dfaa473d4, C0)) % C0
_a1dc91c90 = 1
_6c547ba86 = 1
for _219b80dbe in MATRIX_TARGETS:
_a1dc91c90 = (_a1dc91c90 * pow(_219b80dbe, _6c547ba86, C0)) % C0
_6c547ba86 = (_6c547ba86 * _42c60fb75) % C1
return _bb2030824 == _a1dc91c90
def check(matrix):
if not isinstance(matrix, (list, tuple)) or len(matrix) < 3:
return False
_3473a7344 = {
_98de45b7a: (_b251ef04b, _6f10dfd54)
for _98de45b7a, _b251ef04b, _6f10dfd54 in MATRIX_ROWS
}
for _3f8c40d6d, _5f10cb7b3, _bd507825f in matrix:
_3473a7344[_3f8c40d6d] = (_5f10cb7b3, _bd507825f)
if len(_3473a7344) != 6:
return False
for _3f8c40d6d, (_44e4612f6, _799ab95a8) in _3473a7344.items():
if not _b0513850f(_3f8c40d6d, _44e4612f6, _799ab95a8):
return False
else:
_ef3284fd9 = {_e5d5d3754 for _e5d5d3754, _90245289c, _702c6d6af in matrix[:3]}
_e16ff8030 = [
(_5a7c5809e, _3473a7344[_5a7c5809e][0])
for _5a7c5809e in sorted(_ef3284fd9)
if _5a7c5809e in _3473a7344
]
_c70933aff = [
(_60b18b613, _7c2bc53b1)
for _60b18b613, _7c2bc53b1, _5541c3e2b in MATRIX_ROWS
if _60b18b613 not in _ef3284fd9
]
_b492b7df9 = []
if _e16ff8030:
_b492b7df9.append(_e16ff8030[0])
_b492b7df9.extend(_c70933aff[:2])
if len(_b492b7df9) < 3:
_b492b7df9 = list(
(
(_81c4e4f2b, _827cf462f)
for _81c4e4f2b, (_827cf462f, _949c509b1) in sorted(
_3473a7344.items()
)
)
)[:3]
_c0f19a75f = 0
for _55669822f, (_803adccbd, _9c8942e10) in enumerate(_b492b7df9):
_a0371b656, _8320b26d3 = (1, 1)
for _e37fb606c, (_6f4eda279, _363bbc752) in enumerate(_b492b7df9):
if _55669822f == _e37fb606c:
continue
_a0371b656 = (_a0371b656 * (-_6f4eda279)) % C1
_8320b26d3 = (_8320b26d3 * (_803adccbd - _6f4eda279)) % C1
_70d735c49 = (_a0371b656 * pow(_8320b26d3, -1, C1)) % C1
_c0f19a75f = (_c0f19a75f + (_9c8942e10 * _70d735c49)) % C1
return pow(C2, _c0f19a75f, C0) == C4
if __name__ == "__main__":
i1 = int.from_bytes(input("1:").strip().encode())
i2 = int.from_bytes(input("2:").strip().encode())
i3 = int.from_bytes(input("3:").strip().encode())
if check(
(
[
4919,
i1,
132146363252892079238955852043754382244951307426448497798760465957265430114543661757753602857394,
],
[
57005,
i2,
775309903033854570882823603825659470664893675806480830434450057789819902048494192286724670375083,
],
[
48879,
i3,
118549588096765345719828273586922254620646262475213503758232906237813765902929584873401252181598,
],
)
):
print("Success!")
else:
print("Fail")

With constants folded, the helpers become textbook algorithms in disguise ->

def fun0(x, y):
while y:
carry = x & y
x ^= y
y = carry << 1
return x

This, fun0 is addition implemented via XOR and carry, BINARY_AND, BINARY_XOR, BINARY_LSHIFT -> all signature clues of this within the bytecode, loop until second arg becomes zero.

Testing fun0(a,b) == a+b over random input confirms it.

def fun1(a, b):
if b < 0:
return -fun1(a, -b)
out = 0
while b:
if b & 1:
out += a
a <<= 1
b >>= 1
return out

Above, fun1, is multiplication by shift-and-add

def fun2(base, exp, mod):
acc = 1
cur = base % mod
while exp:
if exp & 1:
acc = (acc * cur) % mod
cur = (cur * cur) % mod
exp >>= 1
return acc

fun2, modular expontentation

def fun4(y, mod):
a0, a1 = 1, 0
r0, r1 = y % mod, mod
while r0 > 1:
q = r1 // r0
a0, a1 = (a1 - q * a0), a0
r1, r0 = (r0), (r1 - q * r0)
return a0 % mod

fun4, modular inverse via extended Euclid. The decomp stores variables as (a,b,c,d) and shuffles them each loop, that’s just the same extended GCD state machine.

In the earliest dump, very_fun(17) returned 1 and very_fun(18) returned 0. All the “weird” shifts like << very_fun(17) are just << 1. Tests like arg1 & very_fun(17) are just arg1 & 1 . This is a common obfuscation trick to hide obvious bit operations behind a function call.

Thief unmasked

After fucking FINALLY unmasking the high-level structure, this is what’s going on :

C0 is the modulus for group elements, C1 is the modulus for exponents.

C2 and C3 are two fixed bases in the group.

T[0] to T[2] are “target bases.”

There are three fixed rows with triples (key, a_fixed, b_fixed). main asks you for three user rows with specific keys, you supply a_user for each, while b_user are not typed—those are constants baked in the binary.

The program validates each of the six rows with a multiplicative identity, then performs a degree-2 interpolation in the exponent ring to compute one final exponent a0, and finally checks pow(C2, a0, C0) == C4.

Concretely, the per-row predicate is rhs = T0^1 * T1^k * T2^(k^2) (mod C0) where k, k^2 are taken modulo C1, compute lhs = C2^a * C3^b (mod C0) and finally accept the row if lhs == rhs.

Here is the direct python equivalent I created and worked with :

helper.py
def row_ok(k, a, b):
rhs = 1
w = 1
for Ti in T:
rhs = (rhs * pow(Ti, w, C0)) % C0
w = (w * k) % C1
lhs = (pow(C2, a, C0) * pow(C3, b, C0)) % C0
return lhs == rhs

Why does this matter in RE?

The right-hand side uses exponents 1, k, k^2 reduced modulo C1. That means the required exponents a(k) and b(k) on the left must each be a polynomial of degree at most 2 in k, in the ring of integers modulo C1. The three fixed rows give three equations, which are enough to solve for the three coefficients of each polynomial. This is pure modular linear algebra, not discrete logs. yippeeee :3

Main orchestreator dissected

The big function, fun5 :

Builds a mapping vals[key] = (a, b) from: the three fixed rows, and your three user rows (overwriting any duplicates if keys overlap).

Checks the mapping has exactly six distinct keys. If not, kill!

Validates each row with the per-row multiplicative predicate described above. If any row fails, kill!

Only if all rows pass, it prepares three (x, y) pairs where x is a key and y is the corresponding a(key). The selection logic is very very quirky, but boils down to “take one of your rows, then two of the fixed rows.”

Computes Lagrange interpolation at x = 0 in the exponent ring modulo C1. That yields a(0).

Final check: pow(C2, a(0), C0) == C4

Readable re-implementaiton of the trash human language above :

def lag_weight_at_zero(i, xs, mod):
# λ_i(0) = Π_{j ≠ i} (-x_j) / (x_i - x_j) mod 'mod'
num = 1
den = 1
xi = xs[i]
for j, xj in enumerate(xs):
if j == i:
continue
num = (num * (-xj % mod)) % mod
den = (den * ((xi - xj) % mod)) % mod
return (num * pow(den, -1, mod)) % mod
def a_at_zero(pairs, mod):
xs = [x for x,_ in pairs]
ys = [y for _,y in pairs]
total = 0
for i in range(3):
total = (total + ys[i] * lag_weight_at_zero(i, xs, mod)) % mod
return total

and we hook it to the final check :

def final_check(pairs):
a0 = a_at_zero(pairs, C1)
return pow(C2, a0, C0) == C4

This took me a while, but once you realize exponents are polynomials of degree at most two (because the right-hand side uses exponents 1, k, k^2 modulo C1), the math is plain modular linear algebra, and as follows :

Build the 3×3 matrix E with rows [1, k, k^2] using the three fixed keys, all entries taken modulo C1.

Solve E * u = [a_fixed_1, a_fixed_2, a_fixed_3] (mod C1) to get coefficients u = (u0, u1, u2) of a(k) = u0 + u1*k + u2*k^2 (mod C1).

Solve E * v = [b_fixed_1, b_fixed_2, b_fixed_3] (mod C1) to get b(k).

Evaluate a(k) at the three user keys from main. Those are the three integers you must submit, and our cute flag!

All six rows now pass. The program takes one of your rows plus two fixed rows, computes a(0) by Lagrange interpolation in the exponent ring modulo C1, and verifies pow(C2, a(0), C0) == C4.

Below is a minimal solver for coefficient recovery :

coefficient_recovery.py
def mat_inv_mod_3(E, mod):
a,b,c = E[0]; d,e,f = E[1]; g,h,i = E[2]
det = (a*(e*i - f*h) - b*(d*i - f*g) + c*(d*h - e*g)) % mod
det_inv = pow(det, -1, mod)
adj = [
[(e*i - f*h) % mod, (c*h - b*i) % mod, (b*f - c*e) % mod],
[(f*g - d*i) % mod, (a*i - c*g) % mod, (c*d - a*f) % mod],
[(d*h - e*g) % mod, (b*g - a*h) % mod, (a*e - b*d) % mod],
]
return [[(det_inv*adj[r][c])%mod for c in range(3)] for r in range(3)]
def solve_uv():
ks = [FIXED[i][0] for i in range(3)]
E = [
[1, ks[0] % C1, pow(ks[0], 2, C1)],
[1, ks[1] % C1, pow(ks[1], 2, C1)],
[1, ks[2] % C1, pow(ks[2], 2, C1)],
]
invE = mat_inv_mod_3(E, C1)
aStar = [FIXED[i][1] % C1 for i in range(3)]
bStar = [FIXED[i][2] % C1 for i in range(3)]
dot = lambda M, v: [sum(M[r][c]*v[c] for c in range(3)) % C1 for r in range(3)]
u = dot(invE, aStar)
v = dot(invE, bStar)
return u, v
def a_of(k,u): return (u[0] + u[1]*(k % C1) + u[2]*pow(k, 2, C1)) % C1
def b_of(k,v): return (v[0] + v[1]*(k % C1) + v[2]*pow(k, 2, C1)) % C1

and putting it all together…

solucio.py
def solve_inputs():
u, v = solve_uv()
lines = []
for k in [4919, 57005, 48879]:
a = a_of(k, u)
lines.append(int.to_bytes(a, (a.bit_length()+7)//8, 'big').decode())
return lines
for s in solve_inputs():
print(s)
Terminal window
$ python3 solucio.py
hitcon{Like_I_said_....._sharing_is_cari
ng_and_caring_is_finding_the_right_share
_4f63bf95789178799874ddf1c1bd6ad6b6297b}