Not a Permutation Matrix
Not a Permutation Matrix
- Solver
- Author
- JuliaPoo
- Category
-
Crypto - Points
- 1000
- Solves
- 1
- Flag
-
grey{Y0o0o0!_Gr4tZz_gr0uP_TH30rY_M4s73R!_JuliaLovesYouBye}
I got a permute, I got a matrix, insert questionable sound permute matrix.
Introduction to the Permutation Group
This challenge used a specific group denoted with
which is a p-group, metabelian, nilpotent (class 2), monomial.
You would say huh?? so did I.
The basic meaning of this is that means a semidirect product () of two groups: (the quaternion group of order ) and (the Dhihedral group of order ). The subscript indicates a particular way these groups are combined. Which it signifies a specific action of on that determines how the semidirect product is formed. It’s the 1st semidriect product out of several possible ways to combine and . This was found in this link, which was provided in our source code.
Analysis of the Challenge Script
Going back to our challenge, we were given a Sage script that included a function called hash_msg. This function took an input, base64-encoded it, and converted the characters into elements of a specific permutation group G. It then iteratively applied a matrix-vector-like multiplication using group operations. Finally, the resulting group elements were converted back into a base64 string.
The hash_msg function is built upon a non-abelian group G. Where we got this from the comment of the code “Since G is non-abelian, this is a pretty good hash function!”
import random
from base64 import b64encode, b64decode
from functools import reduce
SEED = int(0xcaffe *3* 0xc0ffee)
# https://people.maths.bris.ac.uk/~matyd/GroupNames/61/Q8s5D4.html
G = PermutationGroup([
[(1,2,3,4),(5,6,7,8),(9,10,11,12),(13,14,15,16),(17,18,19,20),(21,22,23,24),(25,26,27,28),(29,30,31,32)],
[(1,24,3,22),(2,23,4,21),(5,14,7,16),(6,13,8,15),(9,27,11,25),(10,26,12,28),(17,31,19,29),(18,30,20,32)],
[(1,5,12,30),(2,6,9,31),(3,7,10,32),(4,8,11,29),(13,25,19,21),(14,26,20,22),(15,27,17,23),(16,28,18,24)],
[(1,26),(2,27),(3,28),(4,25),(5,14),(6,15),(7,16),(8,13),(9,23),(10,24),(11,21),(12,22),(17,31),(18,32),(19,29),(20,30)]
])
num2e = [*G]
e2num = {y:x for x,y in enumerate(num2e)}
b64encode_map = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0987654321+/"
b64decode_map = {c:i for i,c in enumerate(b64encode_map)}
def gen_random_mat(seed: int, size: int):
random.seed(seed)
return matrix(size, size, random.choices([0,1], k=size*size))
def mat_vec_mul(mat, vec):
return [reduce(lambda x,y: x*y, (a^b for a,b in zip(vec, m))) for m in mat]
def hash_msg(msg: bytes):
emsg = b64encode(msg).decode().strip("=")
sz = len(emsg)
vec = [num2e[b64decode_map[c]] for c in emsg]
mat = gen_random_mat(SEED, sz)
for _ in range(10):
# Since G is non-abelian, this is a pretty good hash function!
vec = mat_vec_mul(mat, vec)
return ''.join((b64encode_map[e2num[c]] for c in vec))
if __name__ == "__main__":
from flag import flag # secret!!
import re
assert re.match(r"^grey\{.+\}$", flag.decode())
ct = hash_msg(flag)
print(ct)
# Program output:
# aO3qDbHFoittWTN6MoUYw9CZiC9jdfftFGw1ipES89ugOwk2xCUzDpPdpBWtBP3yarjNOPLXjrMODDThis might be true, that non-abelian operations can be difficult to reverse. But there is a plot twist.
While G itself is non-ablian, we can consider its structure “modulo its center.” The center of a group, . It consists of all elements that commute with every element in . To understand the trick, we need to look at the quotient group .
For the specific group G used in this challenge is isomorphic to (the Klein four-group, or as a vector space). More importantly, the quotient group turns out to be isomorphic to (the elementary abelian group of order 16, or as a 4-dimensional vector space over the field of two elements, ). This is good news for us because is abelian, and its operations are simple vector additions (XORs).
sage: ZG = G.center()
sage: ZG
Group([ (1,17)(2,18)(3,19)(4,20)(5,21)(6,22)(7,23)(8,24)(9,25)(10,26)(11,27)(12,28)
(13,29)(14,30)(15,31)(16,32), (1,22)(2,21)(3,24)(4,23)(5,17)(6,18)(7,19)(8,20)(9,30)
(10,29)(11,32)(12,31)(13,25)(14,26)(15,27)(16,28) ])
sage: ZG.order()
4
sage: ZG.structure_description()
'C2 x C2'The center is isomorphic to , the Klein four-group.
If we look into quotient group :
sage: Q, phi_G_to_Q = G.quotient(ZG, "homomorphism") |-> g*ZG
sage: Q.order()
16
sage: Q.is_abelian()
True
sage: Q.structure_description()
'C2 x C2 x C2 x C2'The quotient group has order of = 16. Crucially, it is abelian and isomorphic to , which can be viewed as a 4-dimensional vector space over the field . Operations modulo the center are simple XOR operations.
Formulating Our Solver
The challenge asks us to reverse the hash_msg function. The core idea is to leverage the structure of the group to break down the non-abelian problem into manageable, linear subproblems.
1. Group-Based Transformation
Each round of the mat_vec_mul function applies the following transformation:
If is the input vector of group elements and is the output vector, then for each element is
Let:
- be the initial vector of plaintext group elements (derived from the base64-encoded flag).
- be the final vector of ciphertext group elements.
- be the binary matrix
matgenerated bygen_random_mat.
The hash_msg function applies this transformation 10 times. So, if is the initial plaintext vector and is the final ciphertext vector, the overall operation can be written as:
where the “multiplication” is interpreted as a sequence of group product operations as defined above, governed by the matrix . Our goal is to find given and .
2. Linearization via Quotient Group
The group is non-abelian, this makes direct reversal difficult. However, we can exploit its structure by considering the quotient group , where is the center of .
This was mentioned above at the “Analysis of the Challenge Script” section, (or ) and the quotient group: (or ). and crucially, is abelian.
We define a natural homomorphism:
By identifying with the vector space , can be seen as a 4-bit vector. Since is a homomorphism and its codomain is abelian (where the group operation becomes vector addition over , i.e., XOR), the transformation rule becomes linear:
where the sum denotes component-wise XOR (addition in ).
Let:
- be the vector in formed by concatenating all for .
- be the vector in formed by concatenating all for .
- be the effective matrix after 10 rounds.
- be the identity matrix over .
The relationship between the (mapped) plaintext and ciphertext after 10 rounds becomes a linear system over :
where is the Kronecker product. This system can be solved for using techniques like Gaussian elimination. Solving this gives us for each . This means we know the coset for each plaintext element . Let be a chosen representative from this coset, so can be written as for some unknown .
3. Solving for Central Elements
From the previous step, we have determined coset representatives such that , where each is an unknown element from the center . Substitute this form of back into the original group transformation (using ):
Since elements commute with all elements in (including all and other ), we can rearrange the terms:
Let . This part consists of known values (the are known from solving the first linear system). We can then isolate the product of the unknown central elements:
For this to be consistent, each must be an element of . Now we have a new system where all terms are in . Since is isomorphic to , we can define another homomorphism:
This maps elements of the center to 2-bit vectors, and the group operation in (which is commutative) to vector addition (XOR) in . Applying to the equation for :
This is another system of linear equations over . Let be the vector in formed by concatenating all , and be the vector formed by concatenating all . The system is:
(Or, more simply, if we consider each as a pair of equations, the matrix for each component is .) Solving this system yields , which uniquely determines each .
If either of the linear systems ( or ) is under-determined (i.e., has free variables), we might have multiple candidate solutions. The known flag prefix grey{ can be crucial here. The first few characters of the flag, after base64 encoding, provide the true values for the initial . These known also mean their and components are known, which can help resolve ambiguities in the linear systems or verify solutions. In this challenge, the script handles potential ambiguities by exploring choices for free variables.
4. Reconstruct Plaintext
Once both components are determined for each :
- The coset representative (derived from solving for )
- The central element (derived from solving for )
We can reconstruct the original plaintext group elements:
Finally, the vector of group elements is converted back from group elements to their corresponding indices, then to a base64 string. This base64 string is padded with = if necessary, then decoded to bytes. This candidate flag is validated by re-applying the hash_msg function and comparing its output with the given ciphertext ct.
Summarization
Our approach breaks the problem into two linear systems:
-
Linear system in the quotient group (to find coset representatives): Let be the concatenation of and be the concatenation of .
Solving this gives (representatives for ).
-
Linear system in the center (to find central elements ):
Define: Let be the concatenation of and be the concatenation of .
(Alternatively, for each component of the vectors.) Solving this gives each .
By doing this, it sidesteps the complexities of the non-abelian group by working within its more structured abelian components.
Implementation
Well, I knew how i can approach the problem mathematically, but I have encountered the “Skill Issue” moment in my Sage knowledge.
I finally realized what i did wrong on my solver script after reading through Soon-hari’s solver script. Still messed up the tqdm lol
import random
import base64
from functools import reduce
from tqdm import tqdm
# Constants
SEED = int(0xcaffe * 3 * 0xc0ffee)
out = "aO3qDbHFoittWTN6MoUYw9CZiC9jdfftFGw1ipES89ugOwk2xCUzDpPdpBWtBP3yarjNOPLXjrMODD"
b64encode_map = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0987654321+/"
TARGET_PREFIX = b"grey"
def initialize_group():
"""Initialize the permutation group and precompute elements"""
generators = [
[(1,2,3,4),(5,6,7,8),(9,10,11,12),(13,14,15,16),(17,18,19,20),(21,22,23,24),(25,26,27,28),(29,30,31,32)],
[(1,24,3,22),(2,23,4,21),(5,14,7,16),(6,13,8,15),(9,27,11,25),(10,26,12,28),(17,31,19,29),(18,30,20,32)],
[(1,5,12,30),(2,6,9,31),(3,7,10,32),(4,8,11,29),(13,25,19,21),(14,26,20,22),(15,27,17,23),(16,28,18,24)],
[(1,26),(2,27),(3,28),(4,25),(5,14),(6,15),(7,16),(8,13),(9,23),(10,24),(11,21),(12,22),(17,31),(18,32),(19,29),(20,30)]
]
group = PermutationGroup(generators)
elements = list(group)
element_to_index = {element: idx for idx, element in enumerate(elements)}
squares = set(g^2 for g in group)
identity = group.identity()
return group, elements, element_to_index, squares, identity
def create_decode_map(encode_map):
"""Create decode mapping for custom base64"""
return {char: idx for idx, char in enumerate(encode_map)}
def gen_matrix(seed, size):
"""Generate random binary matrix with given seed"""
random.seed(seed)
random_data = random.choices([0, 1], k=size*size)
return matrix(GF(2), size, size, random_data)
def vec_mul(matrix, vector):
"""Multiply matrix by vector over the permutation group"""
return [reduce(lambda x, y: x * y, (elem^bit for elem, bit in zip(vector, row)))
for row in matrix]
def gaussian_eliminiation(matrix_gf2, vector_perm):
size = matrix_gf2.nrows()
# Create a copy using matrix construct
mat_copy = Matrix(GF(2), matrix_gf2)
vec_copy = vector_perm[:]
for i in range(size - 1):
# Find pivot row
pivot_row = None
for j in range(i, size):
if mat_copy[j, i] == 1:
pivot_row = j
break
if pivot_row is None:
print(f"Gaussian elimination failed you at column {i}")
exit()
# Swap rows
if pivot_row != i:
mat_copy.swap_rows(i, pivot_row)
vec_copy[i], vec_copy[pivot_row] = vec_copy[pivot_row], vec_copy[i]
# Get pivot element and normalize
pivot_element = mat_copy[i, i]
mat_copy.rescale_row(i, pivot_element)
vec_copy[i] = vec_copy[i]^pivot_element
# KILL DA other rows
for j in range(size):
if j != i:
multiplier = mat_copy[j, i]
if multiplier != 0:
mat_copy.add_multiple_of_row(j, i, -multiplier)
vec_copy[j] = vec_copy[j] * vec_copy[i]^(4 - int(multiplier))
return mat_copy, vec_copy
def solve():
group, elements, element_to_index, squares, identity = initialize_group()
decode_map = create_decode_map(b64encode_map)
ciphertext_elements = [elements[decode_map[char]] for char in out]
vector_length = len(ciphertext_elements)
base_matrix = gen_matrix(SEED, vector_length)
matrix_power_10 = base_matrix^10
reduced_matrix, reduced_vector = gaussian_eliminiation(
matrix_power_10, ciphertext_elements[:]
)
print("Testing da candidates...")
base_matrix_list = [[int(base_matrix[i, j]) for j in range(vector_length)]
for i in range(vector_length)]
for candidate_idx in tqdm(range(17), desc="Testing da candidates"):
# Generate candidate by setting free variable
candidate = reduced_vector[:]
free_variable = elements[candidate_idx]
for j in range(vector_length):
if reduced_matrix[j, vector_length - 1] or j == vector_length - 1:
candidate[j] *= free_variable
# Matrix Mutiply
current_vector = candidate[:]
for _ in range(10):
current_vector = vec_mul(base_matrix_list, current_vector)
# Check if differences are all squares
differences = [ciphertext_elements[i] / current_vector[i]
for i in range(vector_length)]
if not all(diff^2 == identity for diff in differences):
continue
# Solve for differences
diff_matrix, diff_vector = gaussian_eliminiation(
matrix_power_10, differences[:]
)
if diff_vector[-1] != identity:
continue
# Generate using squares
for square_perm in squares:
final_candidate = diff_vector[:]
for j in range(vector_length):
if diff_matrix[j, vector_length - 1] or j == vector_length - 1:
final_candidate[j] *= square_perm
# Combine with original candidate
answer_candidate = [final_candidate[i] * candidate[i]
for i in range(vector_length)]
# Verification
verification_vector = answer_candidate[:]
for _ in range(10):
verification_vector = vec_mul(
base_matrix_list, verification_vector
)
if verification_vector != ciphertext_elements:
continue
# Decode it
try:
encoded_answer = ''.join(b64encode_map[element_to_index[elem]]
for elem in answer_candidate)
decoded_bytes = base64.b64decode(encoded_answer + "==")
if decoded_bytes.startswith(TARGET_PREFIX):
return decoded_bytes
except (KeyError, ValueError):
continue
return None
def main():
try:
result = solve()
if result:
print(f"Solution found: {result}")
else:
print("Bah~ No flagz for ya mate")
except Exception as e:
print(f"Error: {e}")
raiseSide Note
I hope you enjoyed my write up :) First time publishing it and shared a link in public
When I was working on this challenge, I was reviewing group theory again, which I was able to learn more and enlighten myself.
This oppertunity also gave me to solve few more problems in Putnam exam that had problems related to group theory in the aspects of chemistry, and also binary operations, its really fun problems, maybe i will make a blogpost about it 👀