247CTF-CRYPTOGRAPHY: PART 1

Girithar Ram Ravindran
11 min readDec 25, 2023

--

247CTF is a security Capture The Flag (CTF) learning environment. The platform contains several hacking challenges where we can test our skills across various areas of CyberSecurity by solving problems to recover flags.

Objective:

In this article, we’ll cover the First 3 challenges of Cryptography topic.

1 . MY MAGIC BYTES

For this challenge, we are given with the following description:

Can you recover the secret XOR key we used to encrypt the flag?

We are given a file named ‘my_magic_bytes.jpg.enc’. From the file’s extension, we can understand that the file is in an encrypted format.

Before starting the decryption process, we need to understand what a ‘magic byte’ for a JPEG means.

The following are the possible magic bytes for JPEG files:

FF D8 DD E0
FF D8 FF DB
FF D8 FF EE
FF D8 FF E1
FF D8 FF E0
FF D8 FF E0 00 10 4A 46 49 46 00 01

And we need to understand what XOR is.

A XOR B -> C
C XOR B -> A
A XOR C -> B

So to obtain the key, we need to XOR the file with Magic Bytes:

FILE XOR MAGIC_BYTES -> KEY

We use the below code to obtain the Key

a = bytes.fromhex("b9 14 06 45 71 e0 b5 f7 37 07 cb 85")
b = bytes.fromhex("FF D8 FF E0 00 10 4A 46 49 46 00 01")
result = bytes(x ^ y for x, y in zip(a, b))
print(result.hex())

Here we convert the hexadecimal codes into bytes and perform XOR operation on the corresponding bytes to obtain the key.

To obtain the flag, we need to XOR the File[Encoded file] with the retrieved Key.

Before doing the XOR operation, we need to dump the file into a hexadecimal string file.

xxd -p my_magic_bytes.jpg.enc | tr -d '\n' > hex

The first part of the command [before the PIPE] produces a hex dump of the encrypted file.

The second part of the command deletes ‘-d’ the newline(‘\n’) characters from the output ‘hex’.

We are going to use a Python code that XORs the file [hex dump]and the key to retrieve the original image.

from itertools import cycle
import sys

def perform_xor(key, hexdump):
hexdump = hexdump.decode('hex')
key = key.decode('hex')
return ''.join([chr(ord(a) ^ ord(b)) for a,b in zip(hexdump, cycle(key))])

hex_file = sys.argv[1]
key = sys.argv[2]

with open(hex_file, 'rb') as f:
hexdump = f.read()
f.close()

print perform_xor(key,hexdump).encode("hex")
python p.py hex 46ccf9a571f0ffb17e41cb84 | xxd -r -p > image.jpg

The above perform_xor function gets the key and hex dump value from the file arguments.

It then converts both of them into a string of bytes using decode(‘hex’)

After that, an XOR operation is performed between the binary representation of hex dump and the cyclically repeated ‘key’.

The ord() converts each bytes to its ASCII value and the char() converts the resulting XOR value back to its character

The returned value is then encoded with the hexadecimal type and it is made to be printed in the console.

The printed value is then piped with ‘xxd’ reversing (-r) the hex dump back to its binary format and specifying (-p) that the input is in plain hexdump format without line number information.

Finally, we will be getting an image that contains the flag inside.

2 . THE IMPOSSIBLE USER

For this challenge, we are given with the below description:

“This encryption service will encrypt almost any plaintext. Can you abuse the implementation to actually encrypt every plaintext?”

From the code we can understand that to obtain the flag, we need to somehow give input to the /encrypt and again give the resulting value as input to /get_flag which should match ‘impossible_flag_user’

The /encrypt page with the user parameter decodes hexadecimal input into a string.

But when the string 'impossible_flag_user' is encoded as hexadecimal and given directly as input, it will return ‘No cheating!

To break the above logic we need to first know what AES ECB is.

Here the message is divided into blocks and each block is encrypted separately.

The ECB has the disadvantage of having identical ciphertext blocks because each block is encrypted with the same key from app.config file, which makes it susceptible to replay attacks.

Now, we need to determine the block length that is being used.

To determine the block length, we are going to use the below code and run it with different inputs.

import requests

plaintext = input('Enter the value of the plaintext:')

url = 'https://109fa0866e3b8782.247ctf.com/encrypt'
params = {'user': f'{plaintext}'}

response = requests.get(url, params=params)

print('\n')
print('Plaintext:',plaintext)
print('Length Of Plaintext:',len(plaintext.encode('utf-8')))
print('Length of the block:',len(plaintext)/2)
print('Encrypted Output:',response.text)
print('Length of the Encrypted Output:',len(response.text))
print('Length of the Encrypted Output block:',len(response.text)/2)

In the above, we have given the plain text ‘11’ of length 2, which gives us the encrypted block length of 16.

Next, we give a plaintext of length 3, which throws us an error ‘Something went wrong’. This is because the input should be divisible by 2 .

From the above 2 inputs:

When we give a plaintext length of 15, we still get the encrypted output block padded with a single value returning 16.

But when the plaintext length is given with 16, as per the code’s logic we were appended(padded) with an additional 16 bytes to the next block, making it a total of 2 blocks.

From the above experiment, we understood that the block size is 16.

Now we need to pass the hexadecimal value of 'impossible_flag_user' by prepending it with some padding.

We know the length of the string 'impossible_flag_user' is 20 bytes. So this string will occupy 2 blocks i.e. 32 bytes.

So, we need to prepend it with a total of 32 bytes i.e. 2 blocks of know string as padding.

We achieve the above process by using the below code.

import requests

for i in range(3):
padding = input('Enter the 1st padding value:')

padding = padding*32

impossible_string_hex_value = '696d706f737369626c655f666c61675f75736572'

url = f'https://109fa0866e3b8782.247ctf.com/encrypt?user={padding}{impossible_string_hex_value}'

params = {'user': f'{padding}'}

response = requests.get(url, params=params)

print(response.text)

In the input, we are going to give 3 different padding values -> AA, BB, CC

From the above output, we can observe that the first 64 hex values (initial 2 blocks) differ when different values are given as padding on each iteration.

But, the remaining values(final 2 blocks) stay the same even when the padding value is changed.

This suggests that the string 'impossible_flag_user' hexadecimal value occupied the final 2 blocks.

To retrieve the flag, we can give that as the value in the user parameter of the ‘/get_flag’ page.

3 . SUBSTITUTION-FLAG-PERMUTATION NETWORK

This challenge throws us into the concept of substitution permutation network or SP network that is used in block cipher algorithms.

Above is the pictorial representation of the SP network.

Encryption:

                         Plaintext
|
XOR Key : 1
|
substitution
|
permutation
|
XOR Key : 2
|
substitution
|
permutation
|
Ciphertext

So we take a plaintext and supply it with a key, then it goes through a process called substitution. After that, it goes with a permutation operation. The operation goes on according to the number of rounds that are defined.

In the provided code:

There is an encrypted flag that went through an encryption process[according to the code]. This encryption should be decrypted to retrieve the original flag.

We could see a variable rounds = 5. This tells us how many times a single character has to go through XOR with Key, substitution, and permutation.

XOR:

To XOR the key with plaintext[ASCII] we need to have a predefined key. It is defined as:

key = [random.randrange(255),random.randrange(255)] * 4

It generates 2 random values from 0 to 255 that are repeated 4 times.

This key is passed to the ks() function that generates round keys.
For example:

key = [115, 45]

ks = [[115, 45, 115, 45, 115, 45, 115, 45],
[45, 115, 45, 115, 45, 115, 45, 115],
[115, 45, 115, 45, 115, 45, 115, 45],
[45, 115, 45, 115, 45, 115, 45, 115],
[115, 45, 115, 45, 115, 45, 115, 45]]

The encrypted flag is also made into the above format using str_split.

Then these two values were made to perform the XOR operation with the help of kx() function.

plaintext = ['2','4','7','C','T','F','{','e']
ASCII[2] -> 50
xor_value = kx(50,115)
xor_value = 65

After getting the XORed value, it is converted into a binary format using to_bin() function. The binary value is then split with bin_split().

Now, substitution is performed on each of the binary that got split. For example.

a = 4
b = 1

sa, sb = s(to_int(a), to_int(b))

sa = 1
sb = 8

After mapping the values with the substitution operation, we convert those values again to binary and perform a bin_join() which joins both of the binary.

The final step is to perform permutation. Here the retrieved binary is flipped according to the value defined in p(a) [where a is the binary].

After passing the binary to pa(a), something like below will happen.

                                  sa = 1
sb = 8

binary of sa -> 0001 binary of sa -> 1000

pe = p('00011000')

a[5] + a[2] + a[3] + a[1] + a[6] + a[0] + a[7] + a[4]

pe = '00100001'

The retrieved binary is represented as an integer using to_int(), which in this case:

to_int(00100001) -> 33

So the above operation is performed using the key: 115. And the operation is performed for only the 1st round in this instance where in:

Input: '2' -> ASCII -> 50
Output: 33

So, for the 2nd round, the output ‘33’ is XORed with 45 according to the generated value of ks.

1st round with -> 115
2nd round with -> 45
3rd round with -> 115
4th round with -> 45
5th round with -> 115

This operation is performed according to the columns.

For the second plaintext character4', the 1st round of XOR is performed on 45.

For the remaining characters, the 1st round is as below.

1st round for '2' -> 115
1st round for '4' -> 45
1st round for '7' -> 115
1st round for 'C' -> 45
1st round for 'T' -> 115
1st round for 'F' -> 45
1st round for '{' -> 115
1st round for 'e' -> 45

This operation is performed according to the rows.

Finally, the encrypted values are appended to a list named ‘encrypted’.

Please keep in mind that, for the above example we have used a custom key [115, 45] to explain the encryption algorithm.

During our decryption, we will brute-force the key to find the encrypted value.

Decryption:

                         Ciphertext
|
permutation
|
substitution
|
XOR Key : n
|
permutation
|
substitution
|
XOR Key : 1
|
Plaintext

In the above image, we can observe that we reversed the order of encryption.

  1. As a first step, we are going to do permutation on the final value’s binary according to the below logic.
def inv_p(a):
return a[5] + a[3] + a[1] + a[2] + a[7] + a[0] + a[4] + a[6]

Here, we wrote a logic that gives the original state of the binary before flipping.

2. For substitution, we have flipped the mapping from the logic that performed encryption.

inv_sa = {
15: 0,
2:1,
14:2,
0:3,
1:4,
3:5,
10:6,
6:7,
4:8,
11:9,
9:10,
7:11,
13:12,
12:13,
8:14,
5:15
}

inv_sb = {
12:0,
8:1,
13:2,
6:3,
9:4,
1:5,
11:6,
14:7,
5:8,
10:9,
3:10,
4:11,
0:12,
15:13,
7:14,
2:15
}

3. As a last step, we are XORing the ciphertext with the last key that performed encryption.

So, this process is continued according to the number of rounds that are defined.

Below is the entire code for the decryption:

import random
from secret import flag2

en_flags = [190, 245, 36, 15, 132, 103, 116, 14, 59, 38, 28, 203, 158, 245, 222, 157, 36, 100, 240, 206, 36, 205, 51, 206, 90, 212, 222, 245, 83, 14, 222, 206, 163, 38, 59, 157, 83, 203, 28, 27]
rounds = 5
block_size = 8

inv_sa = {
15: 0,
2:1,
14:2,
0:3,
1:4,
3:5,
10:6,
6:7,
4:8,
11:9,
9:10,
7:11,
13:12,
12:13,
8:14,
5:15
}

inv_sb = {
12:0,
8:1,
13:2,
6:3,
9:4,
1:5,
11:6,
14:7,
5:8,
10:9,
3:10,
4:11,
0:12,
15:13,
7:14,
2:15
}

key = [random.randrange(255), random.randrange(255)] * 4
to_bin = lambda x, n=block_size: format(x, "b").zfill(n)
to_int = lambda x: int(x, 2)
to_chr = lambda x: "".join([chr(i) for i in x])
to_ord = lambda x: [ord(i) for i in x]
bin_join = lambda x, n=int(block_size / 2): (str(x[0]).zfill(n) + str(x[1]).zfill(n))
bin_split = lambda x: (x[0 : int(block_size / 2)], x[int(block_size / 2) :])
str_split = lambda x: [x[i : i + block_size] for i in range(0, len(x), block_size)]
xor = lambda x, y: x ^ y

def inv_s(a, b):
return inv_sa[a], inv_sb[b]

def inv_p(a):
return a[5] + a[3] + a[1] + a[2] + a[7] + a[0] + a[4] + a[6]

def ks(k):
return [
k[i : i + int(block_size)] + k[0 : (i + block_size) - len(k)]
for i in range(rounds)
]

def kx(state, k):
return [xor(state[i], k[i]) for i in range(len(state))]

def de_kx(val,key):
return [xor(val,key)]

def de(state,key):
decrypted = []
count = 0
for i in state:
pe = inv_p(to_bin(i))
a, b = bin_split(pe)
sa, sb = inv_s(to_int(a), to_int(b))
step1 = bin_join((to_bin(sa, int(block_size / 2)), to_bin(sb, int(block_size / 2))))
step2 = to_int(step1)
if count%2 == 0:
re = de_kx(step2,key[0])
if count%2 == 1:
re = de_kx(step2,key[1])
count+=1
decrypted.append(re[0])
return decrypted

def r(en_flags,key):
keys = ks(key)
state = str_split(en_flags)
for b in range(len(state)):
for i in range(rounds):
state[b] = de(state[b],keys[i])
return [chr(e) for es in state for e in es]

count= 0
for i in range(256):
count+=1
for j in range(256):
key = [i,j] * 4
decrypted = r(en_flags,key)
decrypted_string = ''.join(decrypted)
print(count)
if '247CTF' in decrypted_string:
print('Decrypted CipherText:',decrypted_string)
print('True')
print('keys:',i,j)
break
else:
continue
break

In the final part of the code, were going through 65536 combinations of values to find [Brute-force] the correct keys that encrypted the value and then decrypt it using the retrieved keys.

The End”

I hope this article has given you some ideas for solving the cryptography challenges provided by 247CTF.

Finally, I thank whoever reading this, for spending your valuable time on my article.

Author: Girithar Ram Ravindran

Contact: https://www.linkedin.com/in/girithar-ram-ravindran-a4341017b/s

--

--

Girithar Ram Ravindran

Passionate Security Specialist with a versatile set of skills and experience