╔══════════════════════════════════════════════════════════════════════════════════╝
║ CRYPTO ANAKEN21SEC1 BYUCTF WRITEUP                                    [15/12/2025]
╚══════════════════════════════════════════════════════════════════════════════════╗
┏━━┓
BACK
┗━━┛
we are given the following:

a key: "orygwktcjpb"

an encrypted flag: "cnpiaytjyzggnnnktjzcvuzjexxkvnrlfzectovhfswyphjt"

a note reminding us to wrap the flag in "byuctf{}"

and an "encrypt.py" file

================================================================================

first impressions:

thought the name might imply something - but i think its just the author's name

the encryption function is as follows:
--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
		def encrypt(plaintext, key):
			plaintext += "x"*((12-len(plaintext)%12)%12)
			blocks = [plaintext[12*i:12*(i+1)] for i in range(0,len(plaintext)//12)]
			keyNums = [ord(key[i])-97 for i in range(len(key))]
			resultLetters = ""
--------------------------------------------------------------------------------
--------------------------------------------------------------------------------			
lots of clipping the encryption into 12 length pieces, maybe some sort of block cipher?
	- can confirm some sort of block cipher, the following code padds the input pt to be ≅ 0 mod(12)
		- plaintext += "x"*((12-len(plaintext)%12)%12)
		
then it splits the padded plaintext into 12 length blocks

the next piece turns each character of the key into a number, from 0 to 25, where [a = 0] and [z = 25]

interestingly, the key gen function cannot generate a key with the letter "z":
--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
		def getRandomKey():
			letters = "abcdefghijklmnopqrstuvwxy"
			key = choice(letters)
			for i in range(1,11):
				oldletter = key[i-1]
				newletter = choice(letters)
				oldletterNum = ord(oldletter)-97
				newletterNum = ord(newletter)-97
				while (newletterNum//5 == oldletterNum//5 or newletterNum%5 == oldletterNum % 5) or newletter in key:
					newletter = choice(letters)
					newletterNum = ord(newletter)-97
				key+=newletter
			return key
--------------------------------------------------------------------------------
--------------------------------------------------------------------------------			
I suspect this is in order to map well onto a 5x5 matrix, of which there are 5 of the following format:

		A = np.array([[1, 7, 13, 19, 25, 31],
					[2, 8, 14, 20, 26, 32],
					[3, 9, 15, 21, 27, 33],
					[4, 10, 16, 22, 28, 34],
					[5, 11, 17, 23, 29, 35],
					[6, 12, 18, 24, 30, 36]])
					
I think all the newkey oldkey stuff is just key formatting - which doesn't matter for us as we just have the key
unless its avoiding some sort of scenario with adjacent numbers that we will come across later

================================================================================	

steps seem to be:

key is generated of length 11
	- no two adjacent letters in the key share a row or column if they were arranged in a 5x5 grid based on their ord - 97
plaintext is sanitized:
	- all non letter chars are removed
	- uppercase are converted to lowercase
	- padding is added to ensure the len(pt) ≅ 0 mod(12)
the plaintext is now split into blocks of length 12
within each block, the characters are converted to a 6x6 matrix (called blockM)
	- each letter is converted to a number where [a = 1] and [z = 26]
	- then i think each letter-number is split into a three digit base three number (not sure though)
	- the first 6 letters occupy rows 0->2:
	- the next 6 occupy rows 3->5:
	
	A1 B1 C1 D1 E1 F1 G1
	A2 B2 C2 D2 E2 F2 G2
	A3 B3 C3 D3 E3 F3 G3
	H1 I1 J1 K1 L1 M1 N1
	H2 I2 J2 K2 L2 M2 N2
	H3 I3 J3 K3 L3 M3 N3
	
	- where A1,A2,A3 are the three base 3 digits that correspond to the character "A" in the key
the matrix is then altered
	- convert each character of the key to a digit where [a = 0] and [z = 25]
	- reorder the matrix using one of the 5 provided arrays [A, B, C, D, E]
	- perform modifications on the matrix numbers
		- increment some matrix numbers
		- add some matrix numbers to eachother
		- all these are taken modulo 3 to keep them within base 3
the matrix is then converted to characters
	- the groups of three numbers are combined into a single number using [9*a + 3*b + c]
	- this maps to a character using the number retrieved + 96, such that "a" -> ord("a")
these ciphertext characters are then rearranged according to the key - a sort of transpositional cipher
	- we reuse the keynums from before, a list of numbers corresponding to the key characters where [a = 0] and [z = 25]
	- any duplicate numbers are then removed and the remaining are stored as reducedKeyNums
	- for each item in the reducedKeyNums, a list is created and the letters of the ct are arranged among them as follows:
	- say our ct is ["abcdefghij"]
		- lets say our keynums is [3, 1, 2, 1]
		- reducedkeynums is therefore [3, 1, 2]
		- for each item in reducedKeyNums, we make an empty list, [] [] []
		- each letter in the ct is assigned to a list cyclically where you end up splitting the ct into 'columns'

		letterbox0 = ["a", "d", "g", "j"]
		letterbox1 = ["b", "e", "h"]
		letterbox2 = ["c", "f", "i"]
		
		- we get the index of the smallest item in reducedKeyNums and then set it to 27
		- as 27 is outside our search range of 26 alphabetical characters, this places marks it as "used"
			- so for [3, 1, 2]
			- we find the smallest item, 1
			- find its index, which is 1
			- then replace it with 27 -> [3, 27, 2]
		- the index we found is then used to rearrance the ciphertext characters from before
		- the letterboxes represent 'rows', and we take our index and find the row corresponding to letterbox [index]
			- in this case, letterbox[1] = ["b", "e", "h"]
		- the items in this letterbox are put together to make our final ciphertext string
	- so;
		for keynums [3, 1, 2, 1] -> reducedKeyNums [3, 1, 2]
		
		letterbox0 = ["a", "d", "g", "j"]
		letterbox1 = ["b", "e", "h"]
		letterbox2 = ["c", "f", "i"]
		
		1st index: [3, 1, 2] -> [3, 27, 2] = 1
		2nd index: [3, 27, 2] -> [3, 27, 27] = 2
		3rd index: [3, 27, 27] -> [27, 27, 27] = 0
		
		so we take letterbox[1, 2, 0] and append them
		
		ciphertext = ["behcfiadgj"]
================================================================================	

so i guess the first step is working out what our keyNums and reducedKeyNums are for our given key

ciphertext = "cnpiaytjyzggnnnktjzcvuzjexxkvnrlfzectovhfswyphjt"

key = "orygwktcjpb"
so keyNums = [14, 17, 24, 6, 22, 10, 19, 2, 9, 15, 1]

as there are no repeated numbers in the key;
reducedKeyNums = [14, 17, 24, 6, 22, 10, 19, 2, 9, 15, 1]

the final indexing step would have gotten the following values:
[10, 7, 3, 8, 5, 0, 9, 1, 6, 4, 2]

as the reducedKeyNums is length 11, there would be 11 letterboxes, and our ciphertext is length 48
48/11 = 4 remainder 4.

so, each letterbox had 4 characters minimum, and the first 4 had an extra letter in each

	letterbox0  [5 chrs]
	letterbox1  [5 chrs]
	letterbox2  [5 chrs]
	letterbox3  [5 chrs]
	letterbox4  [4 chrs]
	letterbox5  [4 chrs]
	letterbox6  [4 chrs]
	letterbox7  [4 chrs]
	letterbox8  [4 chrs]
	letterbox9  [4 chrs]
	letterbox10 [4 chrs]

the first index value is 10, so the first [4 chrs] of the ciphertext are the contents of letterbox10
aytjyzggnnnktjzcvuzjexxkvnrlfzectovhfswyphjt
index 7 -> [4 chrs]
yzggnnnktjzcvuzjexxkvnrlfzectovhfswyphjt
index 3 -> [5 chrs]
nnktjzcvuzjexxkvnrlfzectovhfswyphjt
index 8 -> [4 chrs]
jzcvuzjexxkvnrlfzectovhfswyphjt
index 5 -> [4 chrs]
uzjexxkvnrlfzectovhfswyphjt
index 0 -> [5 chrs]
xkvnrlfzectovhfswyphjt
index 9 -> [4 chrs]
rlfzectovhfswyphjt
index 1 -> [5 chrs]
ctovhfswyphjt
index 6 -> [4 chrs]
hfswyphjt
index 4 -> [4 chrs]
yphjt
index 2 -> [5 chrs]

the reconstructed letterboxes are as follows:

0  [u, z, j, e, x]
1  [r, l, f, z, e]
2  [y, p, h, j, t]
3  [y, z, g, g, n]
4  [h, f, s, w]
5  [j, z, c, v]
6  [c, t, o, v]
7  [a, y, t, j]
8  [n, n, k, t]
9  [x, k, v, n]
10 [c, n, p, i]

reconstructed ciphertext (pre transposition):

"uryyhjcanxczlpzfztynknjfhgscotkvpezjgwvvjtnixetn"

================================================================================	

im pretty confident in the de-transposition step - now to do the next piece (which i wont do by hand i think xD)

for the [permute] and [add] functions ill try make an opposite [reverse permute] and [reverse add]

		def permute(blockM, count):                                             <- takes "blockM", a numpy array and "count", which selects which permutes from the list to use
			finalBlockM = np.zeros((6,6))                                       <- creates an empty 6x6 matrix
			for i in range(6):                                                  <- iterates over all the "rows"
				for j in range(6):                                              <- iterates over all the "columns"
					index = int(permutes[count][i,j]-1)                         <- takes an index (0 -> 35 for each of the positions in the matrix)
					finalBlockM[i,j] = blockM[index//6, index%6]                <- takes index values and converts them to coordinates (using floor division and modulo)
			return finalBlockM                                                  <- returns the new block

we still use the same function inputs (we'll handle block splitting later)
the index line we can keep the same - as we want the same item in the matrix that would have undergone permutation to undergo reverse permutation

the encrypt 

		def reverse_permute(blockM, count):
			finalBlockM = np.zeros((6,6))
			for i in range(6):
				for j in range(6):
					index = int(permutes[count][i,j]-1)
					finalBlockM[index//6,index%6] = blockM[i,j]                 <- swap the previous instructions and retrieve the pre-permuted block
			return finalBlockM

now for the reverse_add function, this is the normal one:

		def add(blockM, count):                                                 <- takes as args "blockM", a 6x6 numpy array, and "count", which is the key derived value that chooses whic of the permutes to use
			if count == 0:                                                      <- if we choose the 1st permute
				for i in range(6):                                                 -> for each of the 6 rows
					for j in range(6):                                             -> and each of the 6 columns
						if (i+j)%2 == 0:                                           -> if their sum is even
							blockM[i,j] +=1                                        -> increase their value by 1
			elif count == 1:                                                    <- if count points to the 2nd permute
				blockM[3:,3:] = blockM[3:,3:]+blockM[:3,:3]                     <- add the bottom right quadrant of the matrix to the top left quadrant
			elif count == 2:                                                    <- if count points to the 3rd permute
				blockM[:3,:3] = blockM[3:,3:]+blockM[:3,:3]                     <- add the top left quadrant of the matrix to the bottom right quadrant
			elif count == 3:                                                    <- if count points to the 4th permute
				blockM[3:,:3] = blockM[3:,:3]+blockM[:3,3:]                     <- add the bottom left quadrant of the matrix to the top right quadrant
			else:                                                               <- else (count points to the 5th permute)
				blockM[:3,3:] = blockM[3:,:3]+blockM[:3,3:]                     <- add the top right quadrant of the matrix to the bottom left quadrant
			return np.mod(blockM, 3)                                            <- reduce all values to fit base 3, so [0, 1, 2]
			
hmm, actually - i dont need to reverse all of this, just the one piece that our key codes to
so the count variable is defined (took me ages to find for some reason):

		blockM = add(blockM, keyNum % 5)

the "count" variable is the [keyNum % 5] piece
given we already have our keyNum from earlier:

we know: so keyNums = [14, 17, 24, 6, 22, 10, 19, 2, 9, 15, 1]

        for keyNum in keyNums:
            blockM = permute(blockM,(keyNum//5)%5)
            blockM = add(blockM, keyNum%5)
			
ahh - nevermind, we have to do it for each num in keyNums, so we might as well reverse the whole add function
here's the reversed function (we can keep all the permute counts):

		def reverse_add(blockM, count):                                         <- same args as before
			if count == 0:                                                      <- for count 0 or 1st permute
				for i in range(6):                                                 -> for each of the 6 rows
					for j in range(6):                                             -> and for each of the 6 columns
						if (i + j) % 2 == 0:                                       -> if the sum of the column and row indicies is even
							blockM[i, j] -= 1                                      -> decrease their value by 1
			elif count == 1:                                                    <- for count 1
				blockM[3:,3:] = (blockM[3:,3:] - blockM[:3,:3]) % 3             <- subtract top left from bottom right staying in the modular space for base 3
			elif count == 2:                                                    <- for count 2
				blockM[:3,:3] = (blockM[:3,:3] - blockM[3:,3:]) % 3             <- subtract bottom right from top left in mod 3
			elif count == 3:                                                    <- for count 3
				blockM[3:,:3] = (blockM[3:,:3] - blockM[:3,3:]) % 3             <- subtract top right from bottom left mod 3
			else:                                                               <- for count 4
				blockM[:3,3:] = (blockM[:3,3:] - blockM[3:,:3]) % 3             <- subtract bottom left from top right mod 3
			return np.mod(blockM, 3)                                            <- make sure its all mod 3 (for the addition step which can yield -1)
			
so to finnish, we have to just assemble the other smaller pieces:

we have our hardcoded values;
	- the permutes from the encryption script
	- the keyNums we did earlier while figuring out the encryption
	- our de-transposed ciphertext
	- the key given in the challenge
	
we have our reversed functions;
	- reverse_add
	- reverse_permute
	- reversed scrambler/count finder
	- removing "x" (the padding character) from the end of the readout
	
the functions that stay the same;
	- base 3 to base 10 converter
	- building the blocks
	
combining all this we get:
--------------------------------------------------------------------------------
--------------------------------------------------------------------------------

			import numpy as np

			# permutes from the encryption script
			A = np.array([[1, 7, 13, 19, 25, 31],
						[2, 8, 14, 20, 26, 32],
						[3, 9, 15, 21, 27, 33],
						[4, 10, 16, 22, 28, 34],
						[5, 11, 17, 23, 29, 35],
						[6, 12, 18, 24, 30, 36]])
			B = np.array([[36, 30, 24, 18, 12, 6],
						[35, 29, 23, 17, 11, 5],
						[34, 28, 22, 16, 10, 4],
						[33, 27, 21, 15, 9, 3],
						[32, 26, 20, 14, 8, 2],
						[31, 25, 19, 13, 7, 1]])
			C = np.array([[31, 25, 19, 13, 7, 1],
						[32, 26, 20, 14, 8, 2],
						[33, 27, 21, 15, 9, 3],
						[34, 28, 22, 16, 10, 4],
						[35, 29, 23, 17, 11, 5],
						[36, 30, 24, 18, 12, 6]])
			D = np.array([[7, 1, 9, 3, 11, 5],
						[8, 2, 10, 4, 12, 6],
						[19, 13, 21, 15, 23, 17],
						[20, 14, 22, 16, 24, 18],
						[31, 25, 33, 27, 35, 29],
						[32, 26, 34, 28, 36, 30]])
			E = np.array([[2, 3, 9, 5, 6, 12],
						[1, 11, 15, 4, 29, 18],
						[7, 13, 14, 10, 16, 17],
						[20, 21, 27, 23, 24, 30],
						[19, 8, 33, 22, 26, 36],
						[25, 31, 32, 28, 34, 35]])
			permutes = [A, B, C, D, E]

			# hardcoded keyNums from ye olde manual part earlier
			keyNums = [14, 17, 24, 6, 22, 10, 19, 2, 9, 15, 1]

			# our de-transposed ciphertext
			ciphertext = "uryyhjcanxczlpzfztynknjfhgscotkvpezjgwvvjtnixetn"

			# our key
			key = "orygwktcjpb"

			# reversed matrix permute function
			def reverse_permute(blockM, count):
				inverse = np.zeros((6, 6))
				for i in range(6):
					for j in range(6):
						index = int(permutes[count][i, j] - 1)
						inverse[index // 6, index % 6] = blockM[i, j]
				return inverse

			# reversed matrix add function
			def reverse_add(blockM, count):
				if count == 0:
					for i in range(6):
						for j in range(6):
							if (i + j) % 2 == 0:
								blockM[i, j] -= 1
				elif count == 1:
					blockM[3:,3:] = (blockM[3:,3:] - blockM[:3,:3]) % 3
				elif count == 2:
					blockM[:3,:3] = (blockM[:3,:3] - blockM[3:,3:]) % 3
				elif count == 3:
					blockM[3:,:3] = (blockM[3:,:3] - blockM[:3,3:]) % 3
				else:
					blockM[:3,3:] = (blockM[:3,3:] - blockM[3:,:3]) % 3
				return np.mod(blockM, 3)

			def decrypt(ciphertext, key):
				blocks = [ciphertext[i:i+12] for i in range(0, len(ciphertext), 12)]
				plaintext = ""

			# reversed block handler
				for block in blocks:
					blockM = np.zeros((6, 6))
					for i in range(6):
						letter = block[i]
						num = 0 if letter == "0" else ord(letter) - 96
						blockM[i, 0] = num // 9
						blockM[i, 1] = (num % 9) // 3
						blockM[i, 2] = num % 3
					for i in range(6):
						letter = block[i+6]
						num = 0 if letter == "0" else ord(letter) - 96
						blockM[i, 3] = num // 9
						blockM[i, 4] = (num % 9) // 3
						blockM[i, 5] = num % 3
						
			# reversed count finder
					for keyNum in reversed(keyNums):
						blockM = reverse_add(blockM, keyNum % 5)
						blockM = reverse_permute(blockM, (keyNum // 5) % 5)
						
			# converting 36 base 3 matrix numbers to base 10 ints
					for i in range(6):
						num = int(9 * blockM[0, i] + 3 * blockM[1, i] + blockM[2, i])
						if num == 0:
							continue
						plaintext += chr(num + 96)
					for i in range(6):
						num = int(9 * blockM[3, i] + 3 * blockM[4, i] + blockM[5, i])
						if num == 0:
							continue
						plaintext += chr(num + 96)

				return plaintext.rstrip("x")

			if __name__ == "__main__":
				print(decrypt(ciphertext, key))
				
--------------------------------------------------------------------------------
--------------------------------------------------------------------------------				

when we run this we get:

revisreallythestartingpointformostcategoriesiydk

wrapping the flag we get:
**byuctf{revisreallythestartingpointformostcategoriesiydk}**

that one was tricker than I thought, my coding skills need a little work

CWW
┏━━┓
BACK
┗━━┛