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”
$ ./sharing-is-caring.pyc> 44444> 5547777854> 111111Fail
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 :
# 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
, addn[off] / D[off] * state
, and updatestate *= (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 :
C0 = 4144803293417776131310451317495228706499130241044716671850484110288180082374299088166459295448719C1 = 2072401646708888065655225658747614353249565120522358335925242055144090041187149544083229647724359C2 = 1402769202505631727601810730581776197716350949074282314174097681867464170562021714349605843278664C3 = 1335174568503939352563889373190143608146136792487939183543780907770759908268721571509488188235873MATRIX_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 :
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 :
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)) % C1def b_of(k,v): return (v[0] + v[1]*(k % C1) + v[2]*pow(k, 2, C1)) % C1
and putting it all together…
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)
$ python3 solucio.pyhitcon{Like_I_said_....._sharing_is_caring_and_caring_is_finding_the_right_share_4f63bf95789178799874ddf1c1bd6ad6b6297b}