Inside The Matrix
7 minutes to read
We are given the source code of the server in Python:
from sage.all_cmdline import *
# from utils import ascii_print
import os
FLAG = b"HTB{????????????????????}"
assert len(FLAG) == 25
class Book:
def __init__(self):
self.size = 5
self.prime = None
def parse(self, pt: bytes):
pt = [b for b in pt]
return matrix(GF(self.prime), self.size, self.size, pt)
def generate(self):
key = os.urandom(self.size**2)
return self.parse(key)
def rotate(self):
self.prime = random_prime(2**6, False, 2**4)
def encrypt(self, message: bytes):
self.rotate()
key = self.generate()
message = self.parse(message)
ciphertext = message * key
return ciphertext, key
def menu():
print("Options:\n")
print("[L]ook at page")
print("[T]urn page")
print("[C]heat\n")
option = input("> ")
return option
def main():
book = Book()
ciphertext, key = book.encrypt(FLAG)
page_number = 1
while True:
option = menu()
if option == "L":
# ascii_print(ciphertext, key, page_number)
print(ciphertext, key, page_number)
elif option == "T":
ciphertext, key = book.encrypt(FLAG)
page_number += 2
print()
elif option == "C":
print(f"\n{list(ciphertext)}\n{list(key)}\n")
else:
print("\nInvalid option!\n")
if __name__ == "__main__":
try:
main()
except Exception as e:
print(f"An error occurred: {e}")
Encryption analysis
The server uses a custom function named ascii_print
that is not available (option L
), but the only thing that does is print numbers like if they were inside a book:
$ nc 159.65.81.51 31917
Options:
[L]ook at page
[T]urn page
[C]heat
> L
_________ _________
______/ 1\ / 2 \_______
/| --------------- | --------------- |\
|| Ciphertext:--- - | Key:------------ |||
|| ---------------- | ------ -------- |||
|| ---------- ----- | ---------------- |||
|| [17,35,24,15,25] | [20,5,29,1,24]-- |||
|| [5,12,6,9,35]--- | [19,0,31,18,35]- |||
|| [36,6,20,2,2]--- | [31,35,2,1,24]-- |||
|| [29,11,10,31,16] | [10,24,2,0,13]-- |||
|| [36,6,29,10,20]- | [14,0,17,27,31]- |||
|| ---------------- | ------ ----- --- |||
|| --- - ---------- | ---------------- |||
||______________ _ | ________________|||
L/______/---------\\_//W--------\_______\J
Not having this function is not a problem because we have option C
, which prints just the relevant information:
Options:
[L]ook at page
[T]urn page
[C]heat
> C
[(17, 35, 24, 15, 25), (5, 12, 6, 9, 35), (36, 6, 20, 2, 2), (29, 11, 10, 31, 16), (36, 6, 29, 10, 20)]
[(20, 5, 29, 1, 24), (19, 0, 31, 18, 35), (31, 35, 2, 1, 24), (10, 24, 2, 0, 13), (14, 0, 17, 27, 31)]
To encrypt information, the server uses parse
for that. Also, the flag contains exactly 25 bytes due to an assertion at the beginning):
def parse(self, pt: bytes):
pt = [b for b in pt]
return matrix(GF(self.prime), self.size, self.size, pt)
The key is generated randomly on each encryption:
def generate(self):
key = os.urandom(self.size**2)
return self.parse(key)
And the subsequent operations are performed modulo a prime number
def rotate(self):
self.prime = random_prime(2**6, False, 2**4)
This prime number satisfies random_number
documentation in SageMath for more information). Therefore, there are only a few possible prime numbers:
$ sage -q
sage: { random_prime(2 ** 6, False, 2 ** 4) for _ in range(100) }
{17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61}
The way to encrypt is the following:
def encrypt(self, message: bytes):
self.rotate()
key = self.generate()
message = self.parse(message)
ciphertext = message * key
return ciphertext, key
So, in mathematical terms:
Where all matrices are
Decryption
Notice that we are given both
Notice that we don’t know the prime number
[(17, 35, 24, 15, 25), (5, 12, 6, 9, 35), (36, 6, 20, 2, 2), (29, 11, 10, 31, 16), (36, 6, 29, 10, 20)]
[(20, 5, 29, 1, 24), (19, 0, 31, 18, 35), (31, 35, 2, 1, 24), (10, 24, 2, 0, 13), (14, 0, 17, 27, 31)]
Once we have
But notice that the server is always encrypting the same flag, although using different keys and prime moduli. This is perfect to apply the Chinese Remainder Theorem (CRT). Considering only one character (
Where
The CRT states that if
SageMath implementation
So, let’s take two pairs of ciphertexts and keys to apply the above theory:
$ nc 159.65.81.51 31917
Options:
[L]ook at page
[T]urn page
[C]heat
> C
[(17, 37, 13, 25, 54), (37, 52, 50, 24, 52), (38, 32, 37, 40, 12), (45, 30, 52, 14, 34), (14, 42, 48, 21, 37)]
[(17, 0, 4, 31, 33), (54, 19, 49, 24, 54), (4, 17, 45, 30, 16), (21, 9, 54, 34, 46), (33, 13, 2, 6, 12)]
Options:
[L]ook at page
[T]urn page
[C]heat
> T
Options:
[L]ook at page
[T]urn page
[C]heat
> C
[(13, 13, 9, 9, 18), (12, 22, 15, 13, 9), (18, 9, 6, 13, 19), (21, 19, 17, 4, 19), (22, 7, 9, 5, 19)]
[(17, 6, 6, 15, 19), (2, 5, 4, 22, 6), (5, 11, 13, 5, 20), (16, 0, 21, 11, 7), (16, 10, 2, 20, 8)]
Alright. Looking at the numbers, it looks like
$ sage -q
sage: p1 = 59
sage: p2 = 23
sage:
sage: C1 = matrix(GF(p1), [(17, 37, 13, 25, 54), (37, 52, 50, 24, 52), (38, 32, 37, 40, 12), (45, 30, 52, 14, 34), (14, 42, 48, 21, 37)])
sage: K1 = matrix(GF(p1), [(17, 0, 4, 31, 33), (54, 19, 49, 24, 54), (4, 17, 45, 30, 16), (21, 9, 54, 34, 46), (33, 13, 2, 6, 12)])
sage:
sage: C2 = matrix(GF(p2), [(13, 13, 9, 9, 18), (12, 22, 15, 13, 9), (18, 9, 6, 13, 19), (21, 19, 17, 4, 19), (22, 7, 9, 5, 19)])
sage: K2 = matrix(GF(p2), [(17, 6, 6, 15, 19), (2, 5, 4, 22, 6), (5, 11, 13, 5, 20), (16, 0, 21, 11, 7), (16, 10, 2, 20, 8)])
sage:
sage: F1 = C1 * K1.inverse()
sage: F2 = C2 * K2.inverse()
sage:
sage: F1
[13 25 7 5 49]
[48 48 48 36 5]
[57 36 55 45 51]
[36 56 57 52 55]
[56 33 33 33 7]
sage: F2
[ 3 15 20 8 16]
[ 2 2 15 3 18]
[ 1 3 9 12 5]
[ 3 0 1 6 22]
[ 0 10 10 10 10]
Now it’s time to apply the CRT to each pair of entries of the matrices:
sage: flag = []
sage:
sage: for r1, r2 in zip(F1.rows(), F2.rows()):
....: for f1, f2 in zip(r1, r2):
....: flag.append(crt([int(f1), int(f2)], [p1, p2]) % 256)
Flag
And here’s the flag:
sage: bytes(flag)
b'HTB{l00k_@t_7h3_st4rs!!!}'