[BSides Algiers 2025] Very Cool Cryptosystem

[의미없는 썸네일]

 

간만에 휴식기에 풀었던 CTF 암호학 문제 라이트업을 아주 간단하게만 올려본다.


1. Investigation

> chall.sage

from flag import flag
assert len(flag) == 110 

p = random_prime(2**100)
q = random_prime(2**100)
n = p * q


Rn = Zmod(n)
SIZE = 4 
M = GL(SIZE, Rn)

def matrix_random():
    while True:
        try :
            rands = [randint(0, n-1) for _ in range(SIZE ** 2)]
            Mat = M(rands)
            return Mat
        except : 
            continue


#Making My Private KEY Not So Important
def priv_key(flag1) :
    m = Rn(int.from_bytes(flag1.encode(),"big"))  
    a = Rn(randint(0, n-1))
    coeffs = SIZE - 1 
    bound = coeffs * (coeffs + 1) // 2
    b_s = [Rn(randint(0, n)) for _ in range(bound)]  
    c = m ** bound
    c_s = []
    for i in range(coeffs):
        t = i * (i + 1) // 2
        o = ((c +  b_s[t]) if t else c ) ** 5 
        if not i:
            c_s.append((a * o + b_s[t]))
            continue
        for j in range(0, i):
            o *= a 
            o += b_s[t + j + 1]
        c_s.append(o)

    diag = [a**i + c for i in range(1,5)]
    Mat = Matrix(Rn, [ i * [0] + [diag[i]] +  ([c_s[i] ] if i < len(c_s) else []) + (2-i) * [ ((b_s[i] + b_s[i+1])*a)**20 ]  for i in range(4)] )
    return M(Mat) , b_s  

def gen_pub(derived):
    r = 5
    while True:
        A = matrix_random()
        D , b_s  = priv_key(derived)
        try:
            if D * A != A * D:
                Dr = D^r
                B = (~D) * (~A) * D
                pubkey = ( A , B , Dr)
                return pubkey , b_s ,D
        except:
            continue


def encrypt(pub , flag2):
    s = randint(1 , 2**10)
    gam = pub[-1] ^ s
    eps = ~gam * pub[0] * gam 
    k = ~gam * pub[1] * gam 
    length = len(flag2) // 16 
    U = M(Matrix(Rn , 4 , 4 , [ int.from_bytes(flag2[i*length : (i+1)*length].encode() , "big") for i in range(16) ]))
    U_ = k * U * k 
    return U_ , eps

pub , b_s , D   = gen_pub(flag[:30])
U_ , eps = encrypt(pub , flag[30:])



# Output  ..........
with open("out.txt" , "w") as f :    
    Dr = Matrix(pub[-1])
    eps = eps.list()
    U_ = U_.list()
    A = pub[0].list()
    f.write(f"{n = }\n")
    f.write(f"{b_s = }\n")
    for i in range(3):
        f.write(f"Dr[{i}][{i+1}] = { Dr[i][i+1] }\n")
    f.write(f"{A = }\n")
    f.write(f"{eps = }\n")
    f.write(f"{U_ = }\n")

 

pub , b_s , D   = gen_pub(flag[:30])
U_ , eps = encrypt(pub , flag[30:])

위에서 중요한 부분은 이 두 라인이다.

플래그를 앞 30바이트, 뒤 30바이트 이후로 나누어서 어떠한 작업을 해주고 있다.

먼저 gen_pub 부터 살펴보자.

 

def gen_pub(derived):
    r = 5
    while True:
        A = matrix_random()
        D , b_s  = priv_key(derived)
        try:
            if D * A != A * D:
                Dr = D^r
                B = (~D) * (~A) * D
                pubkey = ( A , B , Dr)
                return pubkey , b_s ,D
        except:
            continue

어떠한 조건(D * A != A * D)을 만족할 때 까지, 즉 교환 법칙이 성립하지 않을 때 까지 반복문을 돈다.

A = Z/Zn 위의 4x4 사이즈의 랜덤한 행렬.

D, b_s = priv_key(derived == flag[:30])

priv_key가 뭔지 살펴보자.

 

#Making My Private KEY Not So Important
def priv_key(flag1) :
    m = Rn(int.from_bytes(flag1.encode(),"big"))  
    a = Rn(randint(0, n-1))
    coeffs = SIZE - 1 
    bound = coeffs * (coeffs + 1) // 2
    b_s = [Rn(randint(0, n)) for _ in range(bound)]  
    c = m ** bound
    c_s = []
    for i in range(coeffs):
        t = i * (i + 1) // 2
        o = ((c +  b_s[t]) if t else c ) ** 5 
        if not i:
            c_s.append((a * o + b_s[t]))
            continue
        for j in range(0, i):
            o *= a 
            o += b_s[t + j + 1]
        c_s.append(o)

    diag = [a**i + c for i in range(1,5)]
    Mat = Matrix(Rn, [ i * [0] + [diag[i]] +  ([c_s[i] ] if i < len(c_s) else []) + (2-i) * [ ((b_s[i] + b_s[i+1])*a)**20 ]  for i in range(4)] )
    return M(Mat) , b_s

뭔가 다항식 형태의 대각행렬을 구성해서 이를 개인키 Mat 변수로, 다항식에 포함되는 랜덤 정수 리스트 b_s를 만들어 반환해준다.

그리고 m == flag1 == flag[:30] 을 bound(=6) 만큼 제곱하여 암호문 c 로 만들어 다항식에 사용한다.

결론적으로 D, b_s = priv_key(derived == flag[:30]) 코드에 의해 gen_pub 함수 내에서 D = Mat 이 된다.

정확한 구조 파악을 위해서 AI를 이용해 코드 분석 후 D의 구조를 알려달라고 했다.

 

D = [[a+c,    c_s[0],  ((b_s[0]+b_s[1])*a)^20,  ((b_s[0]+b_s[1])*a)^20],
     [0,      a²+c,    c_s[1],                   ((b_s[1]+b_s[2])*a)^20],
     [0,      0,       a³+c,                     c_s[2]                 ],
     [0,      0,       0,                        a⁴+c                   ]]

뭔가 Lattice Reduction 을 활용해서 풀기 좋게 생겼다.

이제 다시 gen_pub 로 돌아가보자.

 

def gen_pub(derived):
    r = 5
    while True:
        A = matrix_random()
        D , b_s  = priv_key(derived)
        try:
            if D * A != A * D:
                Dr = D^r
                B = (~D) * (~A) * D
                pubkey = ( A , B , Dr)
                return pubkey , b_s ,D
        except:
            continue

랜덤한 행렬 A와 교환 법칙이 성립하지 않으면 Dr = D^5, 그리고 B = (D 역원) * (A 역원) * D 를 계산해서 pubkey = (A, B, Dr) 로 만들고, (pubkey, b_s, D) 를 반환한다.

결국 flag[:30] 부분을 얻기 위해서는 priv_key 함수에서 나온 개인키 D 행렬을 완전히 복원해야 어떻게 해볼 수 있을 것 같다.

다음으로는 encrypt 에 대해서 알아보자.

 

pub , b_s , D   = gen_pub(flag[:30])
U_ , eps = encrypt(pub , flag[30:])

def encrypt(pub , flag2):
    s = randint(1 , 2**10)
    gam = pub[-1] ^ s
    eps = ~gam * pub[0] * gam 
    k = ~gam * pub[1] * gam 
    length = len(flag2) // 16 
    U = M(Matrix(Rn , 4 , 4 , [ int.from_bytes(flag2[i*length : (i+1)*length].encode() , "big") for i in range(16) ]))
    U_ = k * U * k 
    return U_ , eps

뭔가 복잡한 계산을 하고 있는데 결론적으로는 4x4 사이즈 U 행렬에 flag[30:] 부분을 5바이트씩 끊어서 넣고 U_ = k * U * k 를 계산하여 eps 값과 함께 반환한다.

그런데 s 값이 1 ~ 1024 이내의 굉장히 작은 수라 Brute-forcing이 아주 쉽게 가능해보이고, 나머지 값들에 대해 pubkey를 활용해서 계산을 하고 있는데 pub[-1] == D, pub[0] == A 는 output.txt 에서 알려주는 값, pub[1] == B 는 (~D) * (~A) * D 이기 때문에 D 행렬만 복원된다면 쉽게쉽게 k 에 대해 계산할 수 있을 것 같다.

k 를 구했으면 U = (~k) * U_ * (~k) 로 U 행렬을 구하고 5바이트씩 평문 복원이 가능하다.

 

> output.txt

n = 445274615059837074703074129112359915057174632831812151523967
b_s = [394630328574245837271964995407645525864174759236154380643123, 181576505553348136346747373172404084614838614588476774583011, 47077957947512963105149976221929200145993567056781606375095, 89512069817356782446065533722663239350804126107686725128410, 373205572983197742790495284583000076470807977675517077486772, 23498650172584653123097600569326533466683470443976638497395]
Dr[0][1] = 65121623752613515130879120572450892437787062242339581021972
Dr[1][2] = 309119002962212926538831287621396239072826023374419794264806
Dr[2][3] = 347470190991251003796270760779117025940129782467301096539733
A = [[267488503328751734811097261113084423911438725622299714310906, 241117396863105959600754534694070834476850425262709687269036, 214258198847456276524419017061190255640640610747513321186897, 243908647245747968722106141408796681516825496314767254191757], [296319709221599349271057047062188865965114280077923836145107, 71529348777992013834622878352061278318971600308100689319273, 21591870354281474475646302816674067646780297577032674955185, 329768812477769930550184723256646545904725913270398836212842], [393454646582482661433381653516719745488323555925451955187902, 194746794540595232682055052374882607738478749014201463302499, 334558373377320943753331767654378770755387190446496860928551, 56161017168485902852067920208271199031653238414912499757534], [266977137695022785306720925811441529661962591963589182595205, 16753429262712563178096867265259745231766743278052644485337, 253490251626334837867786572868935770318733416332589112030380, 22145971392163310763756103453873785465919607151396748455019]]
eps = [[192586917014788390326312911581471749597377333033259137675184, 129168876551996260342874367540015182845788848550489794697324, 57831749904731123118404758141603855187866531365624335427344, 195022502697866726972189757984030560180344345109946796156325], [135781469480802094592765423575750583422293592849340942790362, 194570494824611028514594474010420293134634552797088093148718, 40197041087045646329648689121888972810337539641412154942753, 171488839407647249066021945353574939028890470126691251018950], [321574053868131893139085098301380237612683258740173520412344, 52517421891213975251433410788690537724250142992816345255227, 333466758500180143561934683481914639597381284613553905277784, 352008560551548268582889491138232791723183260602131415827535], [251489871649117676162415757114107100160422729328280616605867, 103056811001222772929081702577039570831201708883461825815960, 431531126172591487922735317410797819329355211209825121991402, 420372641596485515463040070611951491179498585916205028436030]]
U_ = [[284748677963482855560647580975430987796843309477842402790596, 242337483266671138784804936005948727710979699483520088294751, 222789040283490430347316580802423043491056060923158051369962, 191112897515604587611641191854510105473086622421743591204621], [238170838594983786682252829258413429179238271948287974513470, 277493478941503842166499052157318771559824990881681931185164, 175870003968434125688348973363665346473569841390671788315745, 298283886414644176621219824782985590557056515812318580574622], [224658814356409335319395178072646711675997551719107602968739, 262450329784813814501873286313684579762178308313152721306551, 127386310370756304739779430076739347844666760625671518036044, 114206800882087686184914497540226589380365955487371216456391], [116661993725296521336028467679022554919505922084876672576011, 299846461555326343107174129245788821779256198458100923490486, 169617077441479678088743611019939106784337042750354690307174, 382330202935869270742131324475593286027520744344616893665932]]

참고로 output 맨 위를 보면 Z/Zn 에 사용된 200비트 크기의 n 값이 있는데 크기가 굉장히 작아서 금방 소인수분해 된다.

p = 460787053387729396804743714197
q = 966334908470530797510120137411
 
위 공개된 정보들을 가지고 어떻게 D를 복원할 수 있나 고민해보자.
 

2. Digging

D = [[a+c,    c_s[0],  ((b_s[0]+b_s[1])*a)^20,  ((b_s[0]+b_s[1])*a)^20],
     [0,      a²+c,    c_s[1],                   ((b_s[1]+b_s[2])*a)^20],
     [0,      0,       a³+c,                     c_s[2]                 ],
     [0,      0,       0,                        a⁴+c                   ]]

위 구조대로 a, c 값만 미지수로 두고 아래처럼 코드를 구성한다.

코드로 Symbolic한 D 행렬을 만들어봤다.

############################################################
# 1. Symbolic D construction (over Zn[a,c])
############################################################

R.<a,c> = PolynomialRing(Zn)

# b_s extraction
b0, b1, b2, b3, b4, b5 = b_s

# c_s definition
c_s0 = a * c^5 + b0
c_s1 = (c + b1)^5 * a + b2
c_s2 = (c + b3)^5 * a^2 + b4 * a + b5

# D_sym matrix
D_sym = Matrix(R, 4, 4, 0)

# Diagonal entries
D_sym[0, 0] = a + c
D_sym[1, 1] = a^2 + c
D_sym[2, 2] = a^3 + c
D_sym[3, 3] = a^4 + c

# Remaining entries of Upper triangle
D_sym[0, 1] = c_s0
D_sym[0, 2] = ((b0 + b1) * a)^20
D_sym[0, 3] = ((b0 + b1) * a)^20

D_sym[1, 2] = c_s1
D_sym[1, 3] = ((b1 + b2) * a)^20

D_sym[2, 3] = c_s2

print("[+] D_sym built")

b_s는 output에서 알려주는 값이기 때문에 추출해서 사용한다.

 

############################################################
# 2. Compute D^5 and extract needed entries: (0,1), (1,2), (2,3)
############################################################

print("[+] Computing D_sym^5 (may be slow)...")
D5_sym = D_sym^5
print("[+] Done")

D5_01 = D5_sym[0, 1]
D5_12 = D5_sym[1, 2]
D5_23 = D5_sym[2, 3]

다음으로 D_sym^5 를 계산한다. 왜냐하면 output에서 Dr[0][1], Dr[1][2], Dr[2][3] 값을 알려주고 있기 때문에 이 값들을 이용해서 a, c를 복원할 수 있기 때문이다.

 

############################################################
# 3. Solve for (a, c) modulo p and q via Groebner basis
############################################################

F01 = D5_01 - Dr01
F12 = D5_12 - Dr12
F23 = D5_23 - Dr23

primes = [p, q]
solutions = []

for prime in primes:
    print(f"\n[+] Solving (a,c) mod {prime}")
    Rp.<aprime, cprime> = PolynomialRing(GF(prime))
    
    print("  Computing Groebner basis...")
    I = Rp.ideal([F01, F12, F23])
    gb = I.groebner_basis()
    print(f"  Groebner basis size: {len(gb)}")
    print(f"  gb: {gb}")
    
    # Expect linear forms [aprime + const, cprime + const]
    if len(gb) == 2:
        g0, g1 = gb
        a_sol = -g0.constant_coefficient()
        c_sol = -g1.constant_coefficient()
        solutions.append([a_sol, c_sol])
        print(f"  Solution: aprime={a_sol}, cprime={c_sol}")
    else:
        print(f"  Failed to solve mod {prime}")

Symbolic한 D^5 행렬의 [0, 1] 번째 원소에서 output 에서 알려주는 실제 D^5 행렬의 [0, 1] 번째 원소 값을 빼 결과가 0이 되는 함수 F01을 만든다. 똑같이 F12, F23도 만든다. 이는 f(x) = 0 구조를 만들어 근을 구할 수 있도록 한다.

Z/Zn 의 n 값은 소인수분해가 되어있기 때문에 더 작은 그룹인 p, q로 나누어 [F01, F12, F23] 이데알의 Groebner Basis 를 계산하면, 높은 확률로 a%p, c%p 그리고 a%q, c%q 값을 구할 수 있다.

 

############################################################
# 4. Reconstruct (a,c) via CRT and build D
############################################################

a_vals = [ZZ(sol[0]) for sol in solutions]
c_vals = [ZZ(sol[1]) for sol in solutions]

a_val = crt(a_vals, primes)
c_val = crt(c_vals, primes)
print(f"\n[+] Valid (a,c) found:")
print(f"    a = {a_val}")
print(f"    c = {c_val}")

D = D_sym(a=a_val, c=c_val)
print("\n[+] Matrix D reconstructed")

이를 다시 CRT를 이용해 Z/Zn 에서의 원래 a, c 값을 복원한다.

그리고 D_sym의 미지수 a, c에 복원된 값을 넣어 원래 D를 복원한다.

 

############################################################
# 5. Recover m from c_val = m^6 (mod n) using known partial prefix
############################################################

prefix = b"shellmates{"
prefix_int = int.from_bytes(prefix, 'big')
shift_bits = (30 - len(prefix)) * 8

P.<x> = PolynomialRing(Zn)
f = (prefix_int * 2^shift_bits + x)^6 - c_val

for m_cand in f.roots(multiplicities=False):
    m_cand = int(m_cand)   # prevent Sage to compute in Zmod
    m_cand = (prefix_int * 2^shift_bits + m_cand).to_bytes(30, "big")
    if m_cand.startswith(prefix):
        flag_head_bytes = m_cand
        print(f"\n[+] Found flag[:30]: {flag_head_bytes}")
        break

원래 a, c 값을 복원했다면 c = m ^ 6 (mod n) 에서 m 을 복원할 수 있다.

다만 m은 flag[:30] 에 의해 30바이트 == 240비트 크기이기 때문에 200비트인 n의 나머지 연산에 의해 40비트가 사라져 원래 m 값을 복원할 수 없게 되어 있다.

때문에 사용한 방법이 CTF에서는 플래그 형식이 정해져 있기 때문에 해당 CTF의 플래그 형식인 shellmates{ 를 활용하여 알려진 prefix로 두면 나머지 19바이트(30 - 11 = 19)만 구하면 되고 이는 152비트이기 때문에 n의 나머지 연산에 의해 변형이 되지 않는다. 이를 이용해 f(x) = 0 이 되는 다항식을 구성한 뒤 근을 구하고 바이트 변환했을 때에 prefix 로 시작하는지 확인하면 flag[:30] 부분을 복원할 수 있다.

 

############################################################
# 6. Find s (1..1024) and decrypt U_
############################################################

print("\n[+] Brute-forcing s in [1..1024]")
for s in range(1, 2^10 + 1):
    if s % 100 == 0:
        print(f"  s = {s} ...")
        
    gam = D^(5 * s)
    if not gam.is_invertible():
        continue
        
    check_eps = (~gam) * A * gam
    if check_eps == eps:
        print(f"[+] s found: {s}")
        break

# B is not given explicitly, but from gen_pub: B = (~D) * (~A) * D
B = (~D) * (~A) * D
gam = D^(5 * s)
k = (~gam) * B * gam

if k.is_invertible():
    U_plain = (~k) * U_ * (~k)
    print("\n[+] U_plain matrix:")
    print(U_plain)
    
    flag_tail_bytes = b""
    for x in U_plain.list():
        v = int(x)
        flag_tail_bytes += v.to_bytes(5, "big")
    
    print("\n[+] Decrypted flag[30:] bytes:")
    print(flag_tail_bytes)
else:
    print("[-] k is not invertible")

다음으로는 나머지 부분인 flag[30:] 을 복원하기 위해 먼저 1~1024의 작은 범위의 값인 s를 Brute-forcing을 통해 찾아낸다. (s=65)

이를 이용해 gam, B, k 행렬을 모두 알아내고 U_plain = (~k) * U_ * (~k) 계산을 통해 5바이트씩 저장된 4x4 사이즈의 행렬에서 평문을 복원해낸다.

마지막은 알아낸 flag[:30], flag[30:] 을 조합하면 끝.


3. Solution

> solve.sage

############################################################
# 0. Setup
############################################################

n = 445274615059837074703074129112359915057174632831812151523967
Zn = Zmod(n)

p = 460787053387729396804743714197
q = 966334908470530797510120137411
assert p*q == n

b_s = [394630328574245837271964995407645525864174759236154380643123, 181576505553348136346747373172404084614838614588476774583011, 47077957947512963105149976221929200145993567056781606375095, 89512069817356782446065533722663239350804126107686725128410, 373205572983197742790495284583000076470807977675517077486772, 23498650172584653123097600569326533466683470443976638497395]

Dr01 = 65121623752613515130879120572450892437787062242339581021972  # Dr[0][1]
Dr12 = 309119002962212926538831287621396239072826023374419794264806  # Dr[1][2]
Dr23 = 347470190991251003796270760779117025940129782467301096539733  # Dr[2][3]

A_list   = [[267488503328751734811097261113084423911438725622299714310906, 241117396863105959600754534694070834476850425262709687269036, 214258198847456276524419017061190255640640610747513321186897, 243908647245747968722106141408796681516825496314767254191757], [296319709221599349271057047062188865965114280077923836145107, 71529348777992013834622878352061278318971600308100689319273, 21591870354281474475646302816674067646780297577032674955185, 329768812477769930550184723256646545904725913270398836212842], [393454646582482661433381653516719745488323555925451955187902, 194746794540595232682055052374882607738478749014201463302499, 334558373377320943753331767654378770755387190446496860928551, 56161017168485902852067920208271199031653238414912499757534], [266977137695022785306720925811441529661962591963589182595205, 16753429262712563178096867265259745231766743278052644485337, 253490251626334837867786572868935770318733416332589112030380, 22145971392163310763756103453873785465919607151396748455019]]
eps_list = [[192586917014788390326312911581471749597377333033259137675184, 129168876551996260342874367540015182845788848550489794697324, 57831749904731123118404758141603855187866531365624335427344, 195022502697866726972189757984030560180344345109946796156325], [135781469480802094592765423575750583422293592849340942790362, 194570494824611028514594474010420293134634552797088093148718, 40197041087045646329648689121888972810337539641412154942753, 171488839407647249066021945353574939028890470126691251018950], [321574053868131893139085098301380237612683258740173520412344, 52517421891213975251433410788690537724250142992816345255227, 333466758500180143561934683481914639597381284613553905277784, 352008560551548268582889491138232791723183260602131415827535], [251489871649117676162415757114107100160422729328280616605867, 103056811001222772929081702577039570831201708883461825815960, 431531126172591487922735317410797819329355211209825121991402, 420372641596485515463040070611951491179498585916205028436030]]
U__list  = [[284748677963482855560647580975430987796843309477842402790596, 242337483266671138784804936005948727710979699483520088294751, 222789040283490430347316580802423043491056060923158051369962, 191112897515604587611641191854510105473086622421743591204621], [238170838594983786682252829258413429179238271948287974513470, 277493478941503842166499052157318771559824990881681931185164, 175870003968434125688348973363665346473569841390671788315745, 298283886414644176621219824782985590557056515812318580574622], [224658814356409335319395178072646711675997551719107602968739, 262450329784813814501873286313684579762178308313152721306551, 127386310370756304739779430076739347844666760625671518036044, 114206800882087686184914497540226589380365955487371216456391], [116661993725296521336028467679022554919505922084876672576011, 299846461555326343107174129245788821779256198458100923490486, 169617077441479678088743611019939106784337042750354690307174, 382330202935869270742131324475593286027520744344616893665932]]

A   = Matrix(Zn, 4, 4, A_list)
eps = Matrix(Zn, 4, 4, eps_list)
U_  = Matrix(Zn, 4, 4, U__list)

############################################################
# 1. Symbolic D construction (over Zn[a,c])
############################################################

R.<a,c> = PolynomialRing(Zn)

# b_s extraction
b0, b1, b2, b3, b4, b5 = b_s

# c_s definition
c_s0 = a * c^5 + b0
c_s1 = (c + b1)^5 * a + b2
c_s2 = (c + b3)^5 * a^2 + b4 * a + b5

# D_sym matrix
D_sym = Matrix(R, 4, 4, 0)

# Diagonal entries
D_sym[0, 0] = a + c
D_sym[1, 1] = a^2 + c
D_sym[2, 2] = a^3 + c
D_sym[3, 3] = a^4 + c

# Remaining entries of Upper triangle
D_sym[0, 1] = c_s0
D_sym[0, 2] = ((b0 + b1) * a)^20
D_sym[0, 3] = ((b0 + b1) * a)^20

D_sym[1, 2] = c_s1
D_sym[1, 3] = ((b1 + b2) * a)^20

D_sym[2, 3] = c_s2

print("[+] D_sym built")

############################################################
# 2. Compute D^5 and extract needed entries: (0,1), (1,2), (2,3)
############################################################

print("[+] Computing D_sym^5 (may be slow)...")
D5_sym = D_sym^5
print("[+] Done")

D5_01 = D5_sym[0, 1]
D5_12 = D5_sym[1, 2]
D5_23 = D5_sym[2, 3]

############################################################
# 3. Solve for (a, c) modulo p and q via Groebner basis
############################################################

F01 = D5_01 - Dr01
F12 = D5_12 - Dr12
F23 = D5_23 - Dr23

primes = [p, q]
solutions = []

for prime in primes:
    print(f"\n[+] Solving (a,c) mod {prime}")
    Rp.<aprime, cprime> = PolynomialRing(GF(prime))
    
    print("  Computing Groebner basis...")
    I = Rp.ideal([F01, F12, F23])
    gb = I.groebner_basis()
    print(f"  Groebner basis size: {len(gb)}")
    print(f"  gb: {gb}")
    
    # Expect linear forms [aprime + const, cprime + const]
    if len(gb) == 2:
        g0, g1 = gb
        a_sol = -g0.constant_coefficient()
        c_sol = -g1.constant_coefficient()
        solutions.append([a_sol, c_sol])
        print(f"  Solution: aprime={a_sol}, cprime={c_sol}")
    else:
        print(f"  Failed to solve mod {prime}")

############################################################
# 4. Reconstruct (a,c) via CRT and build D
############################################################

a_vals = [ZZ(sol[0]) for sol in solutions]
c_vals = [ZZ(sol[1]) for sol in solutions]

a_val = crt(a_vals, primes)
c_val = crt(c_vals, primes)
print(f"\n[+] Valid (a,c) found:")
print(f"    a = {a_val}")
print(f"    c = {c_val}")

D = D_sym(a=a_val, c=c_val)
print("\n[+] Matrix D reconstructed")

############################################################
# 5. Recover m from c_val = m^6 (mod n) using known partial prefix
############################################################

prefix = b"shellmates{"
prefix_int = int.from_bytes(prefix, 'big')
shift_bits = (30 - len(prefix)) * 8

P.<x> = PolynomialRing(Zn)
f = (prefix_int * 2^shift_bits + x)^6 - c_val

for m_cand in f.roots(multiplicities=False):
    m_cand = int(m_cand)   # prevent Sage to compute in Zmod
    m_cand = (prefix_int * 2^shift_bits + m_cand).to_bytes(30, "big")
    if m_cand.startswith(prefix):
        flag_head_bytes = m_cand
        print(f"\n[+] Found flag[:30]: {flag_head_bytes}")
        break

############################################################
# 6. Find s (1..1024) and decrypt U_
############################################################

print("\n[+] Brute-forcing s in [1..1024]")
for s in range(1, 2^10 + 1):
    if s % 100 == 0:
        print(f"  s = {s} ...")
        
    gam = D^(5 * s)
    if not gam.is_invertible():
        continue
        
    check_eps = (~gam) * A * gam
    if check_eps == eps:
        print(f"[+] s found: {s}")
        break

# B is not given explicitly, but from gen_pub: B = (~D) * (~A) * D
B = (~D) * (~A) * D
gam = D^(5 * s)
k = (~gam) * B * gam

if k.is_invertible():
    U_plain = (~k) * U_ * (~k)
    print("\n[+] U_plain matrix:")
    print(U_plain)
    
    flag_tail_bytes = b""
    for x in U_plain.list():
        v = int(x)
        flag_tail_bytes += v.to_bytes(5, "big")
    
    print("\n[+] Decrypted flag[30:] bytes:")
    print(flag_tail_bytes)
else:
    print("[-] k is not invertible")

### print final FLAG
print(f"\n[+] FLAG: {flag_head_bytes + flag_tail_bytes}")

 

> output

[+] D_sym built
[+] Computing D_sym^5 (may be slow)...
[+] Done

[+] Solving (a,c) mod 460787053387729396804743714197
  Computing Groebner basis...
  Groebner basis size: 2
  gb: [aprime + 280298606918854739350729747621, cprime + 61756496956266078424452139909]
  Solution: aprime=180488446468874657454013966576, cprime=399030556431463318380291574288

[+] Solving (a,c) mod 966334908470530797510120137411
  Computing Groebner basis...
  Groebner basis size: 2
  gb: [aprime + 505635856485732946428464125503, cprime + 4369968139429109021813532246]
  Solution: aprime=460699051984797851081656011908, cprime=961964940331101688488306605165

[+] Valid (a,c) found:
    a = 6984562228976164308186288352048638049134096820033710941052
    c = 423794457208599236789546473350747937877027098188338507803264

[+] Matrix D reconstructed

[+] Found flag1: b'shellmates{CayleyPurser_withou'

[+] Brute-forcing s in [1..1024]
[+] s found: 65

[+] U_plain matrix:
[499817411938 409823705407 409757445986 474148388703]
[418465281889 521291722608 435393224565 495622315892]
[448377352809 444134088557 478594166121 418430021490]
[431198726510 443982377844 448445769070 478711081085]

[+] Decrypted flag[30:] bytes:
b't_pub_key?_grobner?_anyway_hope_u_used_the_right_monomial_ordering_isthisenough}'

[+] FLAG: b'shellmates{CayleyPurser_without_pub_key?_grobner?_anyway_hope_u_used_the_right_monomial_ordering_isthisenough}'