SusCipher 400 pts (6 solves)

authored by rbtree

I made SusCipher, which is a vulnerable block cipher so everyone can break it!

Please, try it and find a key. nc suscipher.chal.ctf.acsc.asia 13579 nc suscipher-2.chal.ctf.acsc.asia 13579 (Backup) Hint: Differential cryptanalysis is useful. SusCipher.tar.gz

```
#!/usr/bin/env python3
import hashlib
import os
import signal
class SusCipher:
S = [
43, 8, 57, 53, 48, 39, 15, 61,
7, 44, 33, 9, 19, 41, 3, 14,
42, 51, 6, 2, 49, 28, 55, 31,
0, 4, 30, 1, 59, 50, 35, 47,
25, 16, 37, 27, 10, 54, 26, 58,
62, 13, 18, 22, 21, 24, 12, 20,
29, 38, 23, 32, 60, 34, 5, 11,
45, 63, 40, 46, 52, 36, 17, 56
]
P = [
21, 8, 23, 6, 7, 15,
22, 13, 19, 16, 25, 28,
31, 32, 34, 36, 3, 39,
29, 26, 24, 1, 43, 35,
45, 12, 47, 17, 14, 11,
27, 37, 41, 38, 40, 20,
2, 0, 5, 4, 42, 18,
44, 30, 46, 33, 9, 10
]
ROUND = 3
BLOCK_NUM = 8
MASK = (1 << (6 * BLOCK_NUM)) - 1
@classmethod
def _divide(cls, v: int) -> list[int]:
l: list[int] = []
for _ in range(cls.BLOCK_NUM):
l.append(v & 0b111111)
v >>= 6
return l[::-1]
@staticmethod
def _combine(block: list[int]) -> int:
res = 0
for v in block:
res <<= 6
res |= v
return res
@classmethod
def _sub(cls, block: list[int]) -> list[int]:
return [cls.S[v] for v in block]
@classmethod
def _perm(cls, block: list[int]) -> list[int]:
bits = ""
for b in block:
bits += f"{b:06b}"
buf = ["_" for _ in range(6 * cls.BLOCK_NUM)]
for i in range(6 * cls.BLOCK_NUM):
buf[cls.P[i]] = bits[i]
permd = "".join(buf)
return [int(permd[i : i + 6], 2) for i in range(0, 6 * cls.BLOCK_NUM, 6)]
@staticmethod
def _xor(a: list[int], b: list[int]) -> list[int]:
return [x ^ y for x, y in zip(a, b)]
def __init__(self, key: int):
assert 0 <= key <= self.MASK
keys = [key]
for _ in range(self.ROUND):
v = hashlib.sha256(str(keys[-1]).encode()).digest()
v = int.from_bytes(v, "big") & self.MASK
keys.append(v)
self.subkeys = [self._divide(k) for k in keys]
def encrypt(self, inp: int) -> int:
block = self._divide(inp)
block = self._xor(block, self.subkeys[0])
for r in range(self.ROUND):
block = self._sub(block)
block = self._perm(block)
block = self._xor(block, self.subkeys[r + 1])
return self._combine(block)
# TODO: Implement decryption
def decrypt(self, inp: int) -> int:
raise NotImplementedError()
def handler(_signum, _frame):
print("Time out!")
exit(0)
def main():
signal.signal(signal.SIGALRM, handler)
signal.alarm(300)
key = int.from_bytes(os.urandom(6), "big")
cipher = SusCipher(key)
while True:
inp = input("> ")
try:
l = [int(v.strip()) for v in inp.split(",")]
except ValueError:
print("Wrong input!")
exit(0)
if len(l) > 0x100:
print("Long input!")
exit(0)
if len(l) == 1 and l[0] == key:
with open('flag', 'r') as f:
print(f.read())
print(", ".join(str(cipher.encrypt(v)) for v in l))
if __name__ == "__main__":
main()
```

Let’s take a look at the relevant parts

While True, it asks for an input which is a string of numbers separated by `,`

As long as we input `0x100`

or 256 numbers at a time, we can get as many encryptions as we like

If we only enter a single number, and if that number happens to be the secret round key, we can get the flag

Sounds easy? lets take a look into the cipher

The above construction is Substitution Permutation Network (SPN) which is essentially a repeated operation of substitution with a fixed predefined array

here which is

```
S = [
43, 8, 57, 53, 48, 39, 15, 61,
7, 44, 33, 9, 19, 41, 3, 14,
42, 51, 6, 2, 49, 28, 55, 31,
0, 4, 30, 1, 59, 50, 35, 47,
25, 16, 37, 27, 10, 54, 26, 58,
62, 13, 18, 22, 21, 24, 12, 20,
29, 38, 23, 32, 60, 34, 5, 11,
45, 63, 40, 46, 52, 36, 17, 56
]
```

I.e a number `n`

is replaced with `S[n]`

followed by a permutation of bits here defined by

```
P = [
21, 8, 23, 6, 7, 15,
22, 13, 19, 16, 25, 28,
31, 32, 34, 36, 3, 39,
29, 26, 24, 1, 43, 35,
45, 12, 47, 17, 14, 11,
27, 37, 41, 38, 40, 20,
2, 0, 5, 4, 42, 18,
44, 30, 46, 33, 9, 10
]
```

Which means `P[t]`

th bit of the output is actually the `t`

th bit of inpute.g. `P[0] = 21`

means the `21`

th bit of output is `0`

th bit of input as is

followed by a xor operation with a secret key

This process is repeated a fixed number of rounds times with a new secret key each round (which are called the round keys or subkeys)

The `encrypt`

function hence looks like as below

```
def encrypt(self, inp: int) -> int:
block = self._divide(inp)
block = self._xor(block, self.subkeys[0])
for r in range(self.ROUND):
block = self._sub(block)
block = self._perm(block)
block = self._xor(block, self.subkeys[r + 1])
return self._combine(block)
```

(`_divide`

and `_combine`

are just helper functions to make programmers life easier)

One might question, why are we `_dividing`

a good enough input of 48 bits into 8 chunks of 6 bits each?
Well, in an ideal world, we would like to have a substitution box of 48 bits, but that would eat up a whopping `2^48`

number of entries (which we are somehow fooling with `2^6`

entries here

Hence the functions `_sub`

acts as if it sees 8 different values and substitutes them and acts as if it just did 48 bits of substitution

```
@classmethod
def _sub(cls, block: list[int]) -> list[int]:
return [cls.S[v] for v in block]
```

So does `_perm`

pretend (because of our design) that it sees a big block of 48 bits which it permutes to a block of 48 bits, but what it does is to take 8 blocks of 6 bits each and create 8 blocks of 6 bits each if they were all connected

```
@classmethod
def _perm(cls, block: list[int]) -> list[int]:
bits = ""
for b in block:
bits += f"{b:06b}"
buf = ["_" for _ in range(6 * cls.BLOCK_NUM)]
for i in range(6 * cls.BLOCK_NUM):
buf[cls.P[i]] = bits[i]
permd = "".join(buf)
return [int(permd[i : i + 6], 2) for i in range(0, 6 * cls.BLOCK_NUM, 6)]
```

```
def __init__(self, key: int):
assert 0 <= key <= self.MASK
keys = [key]
for _ in range(self.ROUND):
v = hashlib.sha256(str(keys[-1]).encode()).digest()
v = int.from_bytes(v, "big") & self.MASK
keys.append(v)
self.subkeys = [self._divide(k) for k in keys]
```

As you may have observed from the init function, subkeys are “derived” from a single 48-bit key in a way that we cant recover subkey i from the knowledge of any of the subkeys j>i (to make the challenge hard so that we will definitely need to recover subkey[0] which is the original key

If you have seen some cipher constructions before, you may have observed, that the `ROUND = 3`

is really very low and `6-bit`

sboxes are still not as robust as you may imagine them to be.

Another hint as provided by the author is Differential Cryptanalysis, and since I am obsessed with SAT solvers, I will overlook the hint and cheeze it with z3

While the general methodology to solve a problem with a SAT solver is to write the output as a (symbolic) function of the inputs, and finding an input which leads to the observed output.

So what’s the symbolic input and output here?

For an input `inp`

to the `SusCipher(key)`

producing an encryption `out`

We can write `out`

as `symbolic_function(subkeys, inp)`

With `subkeys`

acting as unknown `inp`

which we aim for, we can easily get the desired outcome.

Taking heavy inspiration from the implementaion of the challenge cipher, we can similary create the z3 model of suscipher

```
class CrackSusCipher:
ROUND = 3
BLOCK_NUM = 8
def _divide(self, v):
l = []
for _ in range(self.BLOCK_NUM):
l.append(v&0b111111)
v >>=6
return l[::-1]
def _combine(self, block):
res = 0
for v in block:
res <<=6
res |= v
return res
def _xor(self, a,b):
return [x^y for x,y in zip(a,b)]
```

These functions look identical.

First hurdle most of the people face modelling a SPN network or any other cipher is to model substitution.

But z3 is equipped with powerful theories of arrays (and functions)

Thus to model substitution, we can define a symbolic function S, which takes 6-bit inputs and generates 6-bit outputs

```
self.S = Function('S', BitVecSort(6), BitVecSort(6))
```

then `self.S(i)`

would indeed be exactly what we desire

But wait, we just specified that `S`

can be *any* function, not the exact substitution function we are provided with.

Worry not, we can specify this as a constraint to the solver

```
for i,v in enumerate(S): #original S as provided in the challenge
self.solver.add(self.S(i)==v)
```

i.e we want `S(0)`

to be nothing else than 43 and so on

And we treat keys as 6 bit unknowns, so there will be `(ROUND+1)*8`

variables.

Overall our init function will look like

```
def __init__(self):
self.S = Function('S', BitVecSort(6), BitVecSort(6))
self.solver = Solver()
for i,v in enumerate(S):
self.solver.add(self.S(i)==v)
self.keys = [[BitVec(f'k_{r}_{i}',6) for i in range(8) ] for r in range(self.ROUND+1)]
```

hence `_sub`

function would be

```
def _sub(self, block):
return [self.S(simplify(i)) for i in block]
```

Note that it could have been `self.S(i)`

instead of `self.S(simplify(i))`

which I used, just to simplify the expression (if possible) before substituting to hopefully speed things up

Now what about the permutation? We can model it exactly how we would have calculated a permutation
Take the `i`

th bit, put it `P[i]`

th place in the output, just the way to deal with BitVectors vary

```
def _perm(self, block):
x = Concat(block)
# treat the 8 6-bit vectors as a single 48 bit-vector
output = [0]*48 # temporary placeholder for output
for i,v in enumerate(P):
# extract the ith bit from the MSB put it at the correct place
output[v] = Extract(47-i, 47-i, x)
# rechunk in 6 bit bitvectors
return [Concat(output[i:i+6]) for i in range(0,48,6)]
```

Finally after getting the required blocks to perform our symbolic encryption, we can model it

```
def enc(self, block):
block = self._xor(block, self.keys[0])
for r in range(self.ROUND):
block = self._sub(block)
block = self._perm(block)
block = self._xor(block, self.keys[r+1])
return block
```

Which you can see is almost like the original except we are not _dividing and _combining the 48-bits but rather assume that it operates on 8 6-bit values. And `self.keys`

here are the symbolic unknowns.

Now a CTF player will be anxious whether the efforts they put in to model the cipher were fruitful or did they mess up the model somewhere?

Worry not, we can always check our symbolic model by plugging in real values and comparing with the original cipher

We will use random values and keys just to check if they match (kinda funny that we have to informally verify a formal verifier XD)

```
print("verifying our modelling")
import random
for i in range(100):
random_key = random.randint(0,2**48-1)
sus = SusCipher(random_key)
sus_model = crack()
sus_model.solver.check() # to fill in the `S` as the original substitution function
sus_model.keys = [[BitVecVal(i,6) for i in row] for row in sus.subkeys] # BitVecVal as a symbolic constant value
for j in range(10):
inp = random.randint(0,2**48-1)
real_out = sus.encrypt(inp)
sym_out_chunks = sus_model.enc(sus_model._divide(inp))
# evaluating the symbolic output as per the symbolic model
sym_out = sus_model.solver.model().eval(Concat(sym_out_chunks))
assert sym_out.as_long() == real_out
print("success")
```

Taking care of the _divide business, we will equate the 6-bit chunks of the output and our symbolic output for a given input

```
def add_sample(self, inp, oup):
for a,b in zip(self.enc(self._divide(inp)), self._divide(oup)):
self.solver.add(a==b)
```

It’s really simple, just check if there is any satisfying model which would make our constraints possible, and get the first subkey according to that model

```
def get(self):
if self.solver.check()==sat:
model = self.solver.model()
k = [model.eval(i).as_long() for i in self.keys[0]]
return self._combine(k)
```

```
S = [
43, 8, 57, 53, 48, 39, 15, 61,
7, 44, 33, 9, 19, 41, 3, 14,
42, 51, 6, 2, 49, 28, 55, 31,
0, 4, 30, 1, 59, 50, 35, 47,
25, 16, 37, 27, 10, 54, 26, 58,
62, 13, 18, 22, 21, 24, 12, 20,
29, 38, 23, 32, 60, 34, 5, 11,
45, 63, 40, 46, 52, 36, 17, 56
]
P = [
21, 8, 23, 6, 7, 15,
22, 13, 19, 16, 25, 28,
31, 32, 34, 36, 3, 39,
29, 26, 24, 1, 43, 35,
45, 12, 47, 17, 14, 11,
27, 37, 41, 38, 40, 20,
2, 0, 5, 4, 42, 18,
44, 30, 46, 33, 9, 10
]
class crack:
ROUND = 3
BLOCK_NUM = 8
def __init__(self):
self.S = Function('S', BitVecSort(6), BitVecSort(6))
self.solver = Solver()
for i,v in enumerate(S):
self.solver.add(self.S(i)==v)
self.keys = [[BitVec(f'k_{r}_{i}',6) for i in range(8) ] for r in range(self.ROUND+1)]
def _divide(self, v):
l = []
for _ in range(self.BLOCK_NUM):
l.append(v&0b111111)
v >>=6
return l[::-1]
def _combine(self, block):
res = 0
for v in block:
res <<=6
res |= v
return res
def _xor(self, a,b):
return [x^y for x,y in zip(a,b)]
def _perm(self, block):
x = Concat(block)
output = [0]*48
for i,v in enumerate(P):
output[v] = Extract(47-i, 47-i, x)
return [Concat(output[i:i+6]) for i in range(0,48,6)]
def _sub(self, block):
return [self.S(simplify(i)) for i in block]
def enc(self, block):
block = self._xor(block, self.keys[0])
for r in range(self.ROUND):
block = self._sub(block)
block = self._perm(block)
block = self._xor(block, self.keys[r+1])
return block
def add_sample(self, inp, oup):
for a,b in zip(self.enc(self._divide(inp)), self._divide(oup)):
self.solver.add(a==b)
def get(self):
if self.solver.check()==sat:
model = self.solver.model()
k = [model.eval(i).as_long() for i in self.keys[0]]
return self._combine(k)
```

So how many input-output pairs do we need to figure out the key uniquely?

I guess atmost 256?

Let’s try it out

```
c = crack()
for i in range(256):
input = random.randint(0,2**48-1)
output = server(input) #whatever
c.add_sample(input, output)
key = c.get()
```

Hmmm, something’s not right, it seems to be stuck indefinitely.

We can get the intuition of difficulty of the solver to find key by reducing the number of constraints i.e the number of input output pairs.

By playing around, one quickly comes to the realisation that it wont workeven for 5 random samples and will time out >200s

How about we address the difficulty of the solver (by addressing the difficulty of the problem being asked to solve)

When we take a random input-output pair, what we ask the solver for `Substitution(key[i] ^ some_random)`

But if it were just `0`

instead of some_random, it would have to guess one less step.

So how about we make 7 out of 8 `0`

and only keep one `key`

place active in substitution?.

This is really easy with `input = (1<<i)`

for (0<=i<48)

And most importantly, it works!

(To an amazement that it works in around a second with 48 samples as opposed to ~5000 seconds for 5 random samples!)

```
import pwn
HOST, PORT = "suscipher.chal.ctf.acsc.asia", 13579
REM = pwn.remote(HOST, PORT)
REM.sendline(",".join(str((1<<i)) for i in range(48)))
response = list(map(int,REM.recvline()[2:].strip().split(b', ')))
c = crack()
for i,v in enumerate(response):
c.add_sample(1<<i,v)
key = c.get()
REM.sendline(str(key))
REM.interactive()
```

As expected, the author knew there might be other interesting ways like this one ;)

Memory Acceleration While everyone was asleep, you were pushing the capabilities of your technology to the max. Night after night, you frantically tried to repair the encrypted parts of your brain, reversing custom protocols implemented by your father, wanting to pinpoint exactly what damage had been done and constantly keeping notes because of your inability of forming new memories. On one of those nights, you had a flashback. Your father had always talked about a new technology and how it would change the galaxy. You realized that he had used it on you. This technology dealt with a proof of a work function and decentralized networks. Along with Virgil’s help, you had a “Eureka!” moment, but his approach, brute forcing, meant draining all your energy. Can you find a quicker way to validate new memory blocks?

From `source.py`

```
import socketserver
import signal
from pofwork import phash
DEBUG_MSG = "DEBUG MSG - "
WELCOME_MSG = """Virgil says:
Klaus I'm connecting the serial debugger to your memory.
Please stay still. We don't want anything wrong to happen.
Ok you should be able to see debug messages now..\n\n"""
with open('memories.txt', 'r') as f:
MEMORIES = [m.strip() for m in f.readlines()]
class Handler(socketserver.BaseRequestHandler):
def handle(self):
signal.alarm(0)
main(self.request)
class ReusableTCPServer(socketserver.ForkingMixIn, socketserver.TCPServer):
pass
def sendMessage(s, msg):
s.send(msg.encode())
def recieveMessage(s, msg):
sendMessage(s, msg)
return s.recv(4096).decode().strip()
def main(s):
block = ""
counter = 0
sendMessage(s, WELCOME_MSG)
while True:
block += MEMORIES[counter]
sendMessage(s, DEBUG_MSG +
f"You need to validate this memory block: {block}\n")
first_key = recieveMessage(s, DEBUG_MSG + "Enter first key: ")
second_key = recieveMessage(s, DEBUG_MSG + "Enter second key: ")
try:
first_key, second_key = int(first_key), int(second_key)
proof_of_work = phash(block, first_key, second_key)
except:
sendMessage(s, "\nVirgil says: \n"
"Be carefull Klaus!! You don't want to damage yourself.\n"
"Let's start over.")
exit()
if proof_of_work == 0:
block += f" ({first_key}, {second_key}). "
sendMessage(s, "\nVirgil says: \nWow you formed a new memory!!\n")
counter += 1
sendMessage(
s, f"Let's try again {4 - counter} times just to be sure!\n\n")
else:
sendMessage(s, DEBUG_MSG + f"Incorect proof of work\n"
"\nVirgil says: \n"
"You calculated something wrong Klaus we need to start over.")
exit()
if counter == 4:
sendMessage(s, "It seems that everything are working fine.\n"
"Wait what is that...\n"
"Klaus this is important!!\n"
"This can help you find your father!!\n"
f"{MEMORIES[-1]}")
exit()
if __name__ == '__main__':
socketserver.TCPServer.allow_reuse_address = True
server = ReusableTCPServer(("0.0.0.0", 1337), Handler)
server.serve_forever()
```

We have to provide two integers `first_key`

and `second_key`

such that

`phash(block, first_key, second_key) == 0`

. `block`

will be presented by
the challenge server. If we do it 4 times, we get our flag.

It’s roughly like how we validate blocks with Proof-of-Work in blockchains

Lets take a look at `phash`

from `pofwork.py`

```
from hashlib import md5
from Crypto.Util.number import long_to_bytes, bytes_to_long
sbox = [
0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76,
0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0, 0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0,
0xb7, 0xfd, 0x93, 0x26, 0x36, 0x3f, 0xf7, 0xcc, 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15,
0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, 0x9a, 0x07, 0x12, 0x80, 0xe2, 0xeb, 0x27, 0xb2, 0x75,
0x09, 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0, 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 0x2f, 0x84,
0x53, 0xd1, 0x00, 0xed, 0x20, 0xfc, 0xb1, 0x5b, 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf,
0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85, 0x45, 0xf9, 0x02, 0x7f, 0x50, 0x3c, 0x9f, 0xa8,
0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5, 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2,
0xcd, 0x0c, 0x13, 0xec, 0x5f, 0x97, 0x44, 0x17, 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 0x73,
0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88, 0x46, 0xee, 0xb8, 0x14, 0xde, 0x5e, 0x0b, 0xdb,
0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c, 0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79,
0xe7, 0xc8, 0x37, 0x6d, 0x8d, 0xd5, 0x4e, 0xa9, 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08,
0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, 0xb4, 0xc6, 0xe8, 0xdd, 0x74, 0x1f, 0x4b, 0xbd, 0x8b, 0x8a,
0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e, 0x61, 0x35, 0x57, 0xb9, 0x86, 0xc1, 0x1d, 0x9e,
0xe1, 0xf8, 0x98, 0x11, 0x69, 0xd9, 0x8e, 0x94, 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf,
0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68, 0x41, 0x99, 0x2d, 0x0f, 0xb0, 0x54, 0xbb, 0x16
]
def rotl(n, b):
return ((n << b) | (n >> (32 - b))) & 0xffffffff
def sub(b):
b = long_to_bytes(b)
return bytes([sbox[i] for i in b])
def phash(block, key1, key2):
block = md5(block.encode()).digest()
block = 4 * block
blocks = [bytes_to_long(block[i:i+4]) for i in range(0, len(block), 4)]
m = 0xffffffff
rv1, rv2 = 0x2423380b4d045, 0x3b30fa7ccaa83
x, y, z, u = key1, 0x39ef52e9f30b3, 0x253ea615d0215, 0x2cd1372d21d77
for i in range(13):
x, y = blocks[i] ^ x, blocks[i+1] ^ y
z, u = blocks[i+2] ^ z, blocks[i+3] ^ u
rv1 ^= (x := (x & m) * (m + (y >> 16)) ^ rotl(z, 3))
rv2 ^= (y := (y & m) * (m + (z >> 16)) ^ rotl(x, 3))
rv1, rv2 = rv2, rv1
rv1 = sub(rv1)
rv1 = bytes_to_long(rv1)
h = rv1 + 0x6276137d7 & m
key2 = sub(key2)
for i, d in enumerate(key2):
a = (h << 1) & m
b = (h << 3) & m
c = (h >> 4) & m
h ^= (a + b + c - d)
h += h
h &= m
h *= u * z
h &= m
return h
```

Few things to note here -

`rotl`

is 32-bit rotate left`sbox`

is AES sbox, so that we dont try linear/differential cryptanalysis XD- Every operation in
`phash`

can be roughly thought on working on 32 bit`uint`

s since each operation is preceded by`&m (0xffffffff)`

which makes everything operate mod $2^{32}$ - Which means
`rv1`

,`rv2`

,`x`

,`y`

,`z`

,`u`

are all 32bit values including our keys, i.e `rv1 = 0x2423380b4d045 & m = 0x80b4d045 - Insted of a block,
`md5(block)`

is hashed, so we have little to no control over the message and we have to actually expoit the keys.

Finding relevant `key1`

and `key2`

seems difficult by bare logic, dont worry

since the first block provides a subtle hint

`"You don't have to add the z3 solver to your firmware ever again. Now you can use it forever.`

We can make an SMT model in z3 and let it do its wonders!

But first let us demarcate the function so it’s easier to refer

```
def phash(block, key1, key2):
block = md5(block.encode()).digest()
block = 4 * block
blocks = [bytes_to_long(block[i:i+4]) for i in range(0, len(block), 4)]
m = 0xffffffff
rv1, rv2 = 0x2423380b4d045, 0x3b30fa7ccaa83
x, y, z, u = key1, 0x39ef52e9f30b3, 0x253ea615d0215, 0x2cd1372d21d77
```

```
for i in range(13):
x, y = blocks[i] ^ x, blocks[i+1] ^ y
z, u = blocks[i+2] ^ z, blocks[i+3] ^ u
rv1 ^= (x := (x & m) * (m + (y >> 16)) ^ rotl(z, 3))
rv2 ^= (y := (y & m) * (m + (z >> 16)) ^ rotl(x, 3))
rv1, rv2 = rv2, rv1
rv1 = sub(rv1)
rv1 = bytes_to_long(rv1)
```

```
h = rv1 + 0x6276137d7 & m
key2 = sub(key2)
```

```
for i, d in enumerate(key2):
a = (h << 1) & m
b = (h << 3) & m
c = (h >> 4) & m
h ^= (a + b + c - d)
h += h
h &= m
```

```
h *= u * z
h &= m
return h
```

Since we are dealing with 32 bits entities only, we will use theory of

bitvectors. Where each variable is simply considered a collection of

bits and all the operations are treated as symbolic computation upon

those sets of bits. Pretty much like a hardware circuit, where each

component is say a 32 bit register.

```
block = md5(message).digest()
block = 4*block
blocks = [int.from_bytes(block[i:i+4],'big') for i in range(0, len(block), 4)]
# we will let the blocks be the way they are in the real function
# or we could declare them as 32-bit constants, either suffices
# blocks = [BitVecVal(i,32) for i in blocks] is treated same as above
rv1, rv2 = BitVecVal(0x2423380b4d045,32), BitVecVal(0x3b30fa7ccaa83,32)
# note that they will automatically be truncated to 32 bits
key1 = BitVec('key1', 32)
key2 = BitVec('key2', 32)
# key1 and key2 treated as 32-bit unknowns
m = 0xffffffff #can be written as -1 as well ;)
x, y, z, u = key1, *[BitVecVal(i,32) for i in (0x39ef52e9f30b3, 0x253ea615d0215, 0x2cd1372d21d77)]
# bitvecs can be used almost like python variables! I love z3 API
```

```
for i in range(13):
x,y = blocks[i] ^ x, blocks[i+1] ^ y
# easy interop with xor
z,u = blocks[i+2] ^ z, blocks[i+3] ^ u
x,y,z,u = [simplify(i) for i in (x,y,z,u)]
# simplify tries to evaluate the current symbolic computation of a variable
# and tries to simplify as much as possible (no effect on truth, can skip)
x = x*(m + LShR(y,16)) ^ RotateLeft(z, 3)
# expanding the walrus (:=)
# note that >> is replaced with LShR, this is because in theory of bitvecs
# there are two kinds of shift rights, arithmetic and logical. logical
# shift right shifts as is whereas arithmetic shift right also retains the
# original sign. Python ints are infinite, so >> means logical shift by
# shift by default but in z3 >> is arithmetic while LShR is logical
rv1 ^= x
y = y*(m + LShR(z,16)) ^ RotateLeft(x, 3)
rv2 ^= y
rv1, rv2 = rv2, rv1
rv1 = sub(rv1)
```

Wait, how will `sub`

work?

Good question, it wont the `sub`

is a previously defined python function which

expects python `int`

to index the `SBOX`

and return a value. It wont

understand `BitVec`

as index and so wont our model understand our function!

SMT solvers are so mature, we can use multiple theories to create and solve a

model!

With theory of arrays, we can essentially declare an array with arbitrary index

and arbitrary stored value.

```
SBOX = Array('SBOX', BitVecSort(8), BitVecSort(8))
def sub(bitvec32):
vec_bytes = [Extract(8*i+7, 8*i, bitvec32) for i in range(3,-1,-1)]
# logical analogue of 32-bit int to 4-bytes in big-endian order
# Extract(hi,lo,bitvec) takes the bits [lo,hi) and creates a new
# bitvector of size hi-lo+1
return Concat([SBOX[i] for i in vec_bytes])
# i is index BitVec(8) to Array SBOX and SBOX[i] is BitVec(8) stored
# Concat concatanates the 4 8-bitvectors to an 32-bitvector like it
# is done in after the original sub in the key1 loop
```

```
h = simplify(rv1 + 0x6276137d7)
subkey2 = [Extract(8*i+7,8*i,key2) for i in range(3,-1,-1)]
# splitting to 8-bits again
subkey2 = [SBOX[i] for i in subkey2]
# note that we need to mention SBOX only once in the whole logic
subkey2 = [ZeroExt(24,i) for i in subkey2]
```

What is ZeroExt? Note that subkey2 after substitution is a list of 8-bit

entities. Now this one wouldn’t look much severe to a programmer since all

programming languages dont bother much about adding two integer values c/c++

would give type warning but just add the smaller value to a bigger value

without ranting. whereas python doesn’t bother at all. But if we think like

a hardware, you will be bothered when presented to add a 32-bit register to

a 8-bit register. Since we require this value later, we make it a 32 bit value

by Extending with 24 zeros in the front (if we were not dealing with uint32

we would have sign-extended these 8-bit values.

```
for i,d in enumerate(subkey2):
a = (h<<1)
b = (h<<3)
c = LShR(h,4)
# note the LShR again, blindly missing an operator can cost you hours :)
h ^= (a+b+c-d)
h += h
h ^= u*z
```

Now after all this bizarre symbolic computation, we are not done yet, we are

not here just to model but to ask the solver to find the values of `key1`

and

`key2`

such that this symbolic function evaluates to 0

```
solver = Solver()
solver.add(h==0) #the final h we have here should be 0
for i,v in enumerate(sbox): # the original AES sbox
solver.add(SBOX[i]==v)
# specifing the exact substitution box
if solver.check() == sat:
m = solver.model()
# a desirable model
return (m[key1].as_long(), m[key2].as_long())
# as_long converts bitvecs to python ints
```

```
def hack_proof_of_work(message):
block = md5(message).digest()
block = 4*block
blocks = [int.from_bytes(block[i:i+4],'big') for i in range(0, len(block), 4)]
rv1, rv2 = BitVecVal(0x2423380b4d045,32), BitVecVal(0x3b30fa7ccaa83,32)
key1 = BitVec('key1', 32)
key2 = BitVec('key2', 32)
m = 0xffffffff
x, y, z, u = key1, *[BitVecVal(i,32) for i in (0x39ef52e9f30b3, 0x253ea615d0215, 0x2cd1372d21d77)]
for i in range(13):
x,y = blocks[i] ^ x, blocks[i+1] ^ y
z,u = blocks[i+2] ^ z, blocks[i+3] ^ u
x,y,z,u = [simplify(i) for i in (x,y,z,u)]
x = x*(m + LShR(y,16)) ^ RotateLeft(z, 3)
rv1 ^= x
y = y*(m + LShR(z,16)) ^ RotateLeft(x, 3)
rv2 ^= y
rv1, rv2 = rv2, rv1
rv1 = sub(rv1)
SBOX = Array('SBOX', BitVecSort(8), BitVecSort(8))
def sub(bitvec32):
vec_bytes = [Extract(8*i+7, 8*i, bitvec32) for i in range(3,-1,-1)]
return Concat([SBOX[i] for i in vec_bytes])
h = simplify(rv1 + 0x6276137d7)
subkey2 = [Extract(8*i+7,8*i,key2) for i in range(3,-1,-1)]
subkey2 = [SBOX[i] for i in subkey2]
subkey2 = [ZeroExt(24,i) for i in subkey2]
for i,d in enumerate(subkey2):
a = (h<<1)
b = (h<<3)
c = LShR(h,4)
h ^= (a+b+c-d)
h += h
h ^= u*z
solver = Solver()
solver.add(h==0)
for i,v in enumerate(sbox):
solver.add(SBOX[i]==v)
if solver.check() == sat:
m = solver.model()
return (m[key1].as_long(), m[key2].as_long())
```

Lets go!

```
message_one = "You don't have to add the z3 solver to your firmware ever \
again. Now you can use it forever."
key1, key2 = hack_proof_of_work(message_one)
```

Well, no key yet?

I know, lets discuss a few problems and workarounds

- Too many multiplications. There are 13 loops and a lot of multiplications. And as one may know, factoring has never been easy.
- We dont even have a tentative time by which the solver will spew a satisfying model. This is the general drawback of SMT/SAT solvers.
- Not breaking the problem as (an actually intelligent) human

So lets analyze the problem carefully part by part.

- The final value is
`h(final) = h(part 2)*u*z (part 1)`

and we need it to be 0 final`h`

will be 0 if sum of least significant`0`

of`h`

,`u`

and`z`

exceeds -
as overflows are ignored in 32-bit multiplication.

- What if we can get
`h`

of key2 loop to 0 by its own?

```
def hack_only_key2(h,nbits=0):
# note its post substitution for less complexity and speed
# nbits is the number of nonzero most significant bits we can tolerate
h = BitVecVal(h,32)
key2 = BitVec('key2',32)
subkey2 = [Extract(8*i+7,8*i,key2) for i in range(3,-1,-1)]
subkey2 = [ZeroExt(24,i) for i in subkey2]
for i,d in enumerate(subkey2):
a = (h<<1)
b = (h<<3)
c = LShR(h,4)
h ^= (a+b+c-d)
h += h
solver = Solver()
# solver.add(Extract(31-nbits,0,h)==0)
solver.add(h==0)
if solver.check() == sat:
m = solver.model()
return m[key2].as_long()
print(try_only_key2(1337,0))
# None
print(try_only_key2(1337,1))
# None
print(try_only_key2(1337,2))
# None
print(try_only_key2(1337,4))
# None
print(try_only_key2(1337,8))
# 1311637496
print(try_only_key2(1,0))
# 75586596
print(try_only_key2(2,0))
# 276293996
print(try_only_key2(3,0))
# 283836416
```

It seems to be working but only for a limited number of values, lets see how
frequently can it work independent of `h`

from first loop.

```
from tqdm import tqdm
from random import randint
num_samples = 4096
validkey2 = [try_only_key2(randint(0,2**32,0)) for i in tqdm(range(num_samples))]
print("Number of suitable h", num_samples-validkey2.count(None))
# 5
validkey2 = [try_only_key2(randint(0,2**32,1)) for i in tqdm(range(num_samples))]
print("Number of suitable h", num_samples-validkey2.count(None))
# 11
validkey2 = [try_only_key2(randint(0,2**32,4)) for i in tqdm(range(num_samples))]
print("Number of suitable h", num_samples-validkey2.count(None))
# 72
```

It appears that if we entirely ignore `key1`

, and let `h`

be whatever it

desires to be i.e. random, we can have our luck with finding `key2`

with

roughly 1 in 400 chance (ignoring the zeros for `u*z`

entirely)

So we can bruteforce for `key1`

, try solving for `key2`

and this should

take a couple of seconds and lo we are done.

```
def zerocount(num):
count = 0
while num&1==0:
count+=1
num>>=1
return count
def form_blocks(block):
"""making bruteforce faster by removing recomputation of md5"""
block = md5(block.encode()).digest()
block = 4 * block
blocks = [bytes_to_long(block[i:i+4]) for i in range(0, len(block), 4)]
return blocks
def phashk1(blocks, key1):
"""hash state till key1 part and before key2 substitution"""
m = 0xffffffff
rv1, rv2 = 0x2423380b4d045, 0x3b30fa7ccaa83
x, y, z, u = key1, 0x39ef52e9f30b3, 0x253ea615d0215, 0x2cd1372d21d77
for i in range(13):
x, y = blocks[i] ^ x, blocks[i+1] ^ y
z, u = blocks[i+2] ^ z, blocks[i+3] ^ u
rv1 ^= (x := (x & m) * (m + (y >> 16)) ^ rotl(z, 3))
rv2 ^= (y := (y & m) * (m + (z >> 16)) ^ rotl(x, 3))
rv1, rv2 = rv2, rv1
rv1 = sub(rv1)
rv1 = bytes_to_long(rv1)
h = rv1 + 0x6276137d7 & m
# also return the number of zeros in u*z so our model finds it easier
return h,zerocount(u*z)
def desubstitute(key2):
"""reverse the substitution on the found key2"""
# we could have added the substitution to the model too, but since we
# are bruteforcing, we appreciate a bit of extra speed
return bytes([sbox.index(i) for i in int.to_bytes(key2,4,'big')])
def bruteforce_key1(block):
blocks = form_blocks(block)
for key1 in tqdm(range(2**32),total=-1):
h, nbits = phashk1(blocks, key1)
key2 = try_only_key2(h, nbits)
if key2:
return key1, desubstitute(key2)
```

```
message_one = "You don't have to add the z3 solver to your firmware ever \
again. Now you can use it forever."
key1, key2 = bruteforce_key1(message_one)
print(f"{key1=} {key2=}")
assert phash(message_one, key1, key2) == 0
# 909it [00:16, 54.92it/s]
# key1=909 key2=3711505522
```

I solved the challenge manually by prompting bruteforce 4 times. I wanted to
create a netcat script, but couldn’t as Hack The Box terminated all instances

post the CTFs so I cant access the server.

I wonder why is there an uncanny resemblance between the hash function and a hash collision challenge I created last year for zh3r0 CTF v2

```
text = [int.from_bytes(text[i:i+4],'big') for i in range(0,len(text),4)]
M = 0xffff
x,y,z,u = 0x0124fdce, 0x89ab57ea, 0xba89370a, 0xfedc45ef
A,B,C,D = 0x401ab257, 0xb7cd34e1, 0x76b3a27c, 0xf13c3adf
RV1,RV2,RV3,RV4 = 0xe12f23cd, 0xc5ab6789, 0xf1234567, 0x9a8bc7ef
for i in range(0,len(text),4):
X,Y,Z,U = text[i]^x,text[i+1]^y,text[i+2]^z,text[i+3]^u
RV1 ^= (x := (X&0xffff)*(M - (Y>>16)) ^ ROTL(Z,1) ^ ROTR(U,1) ^ A)
RV2 ^= (y := (Y&0xffff)*(M - (Z>>16)) ^ ROTL(U,2) ^ ROTR(X,2) ^ B)
RV3 ^= (z := (Z&0xffff)*(M - (U>>16)) ^ ROTL(X,3) ^ ROTR(Y,3) ^ C)
RV4 ^= (u := (U&0xffff)*(M - (X>>16)) ^ ROTL(Y,4) ^ ROTR(Z,4) ^ D)
```

Anyways, it was a fun challenge, I had a lot of fun and hope that some weird soul had its fun too reading this writeup :)

]]>CRYPTO - Hard

Tasty Crypto Roll

Bob, the genius intern at our company, invented AES-improved. It is based on AES but with layers after layers of proprietary encryption techniques on top of it.The end result is an encryption scheme that achieves both confusion and diffusion. The more layers of crypto you add, the better the security, right?

Encrypter

encrypt.py

Encrypted file

enc.bin

Note

The intended solution requires very little brute force and runs under 5 seconds on our machine.

By k3v1n

```
import os
import random
import secrets
import sys
from Crypto.Cipher import AES
ENCODING = 'utf-8'
def generate_key():
return os.getpid(), secrets.token_bytes(16)
def to_binary(b: bytes):
return ''.join(['{:08b}'.format(c) for c in b])
def from_binary(s: str):
return bytes(int(s[i:i+8], 2) for i in range(0, len(s), 8))
def encrypt(key: bytes, message: bytes):
cipher = AES.new(key, AES.MODE_ECB)
return cipher.encrypt(message)
key1, key2 = generate_key()
print(f'Using Key:\n{key1}:{key2.hex()}')
def get_flag():
flag = input('Enter the flag to encrypt: ')
if not flag.startswith('sdctf{') or not flag.endswith('}') or not flag.isascii():
print(f'{flag} is not a valid flag for this challenge')
sys.exit(1)
return flag
plaintext = get_flag()[6:-1]
data = plaintext.encode(ENCODING)
codes = list(''.join(chr(i) * 2 for i in range(0xb0, 0x1b0)))
random.seed(key1)
random.shuffle(codes)
sboxes = [''.join(codes[i*4:(i+1)*4]) for i in range(128)]
if len(set(sboxes)) < 128:
print("Bad key, try again")
sys.exit(1)
data = ''.join(sboxes[c] for c in data).encode(ENCODING)
data = encrypt(key2, to_binary(data).encode(ENCODING))
random.seed(key1)
key_final = bytes(random.randrange(256) for _ in range(16))
data_bits = list(to_binary(data))
random.shuffle(data_bits)
data = from_binary(''.join(data_bits))
ciphertext = encrypt(key_final, data)
print(f'Encrypted: {ciphertext.hex()}')
with open('enc.bin2', 'wb') as ef:
ef.write(ciphertext)
```

Here we can see mainly two parts

- There are two keys
`key1`

: pid of current process`key2`

: secure random key of 16 bytes

`key1`

is used as seed at a lot of places and is bruteforcable (< 2^15)`key_final`

and`sboxes`

are derived from`key1`

, shuffling is done using`key1`

- decrypt using
`key_final`

- convert the intermediate ciphertext
`to_binary`

- de-shuffle the bits
- generate
`from_binary`

intermediate ciphertext of the deshuffled bits - decrypt using
`key2`

???

`key1`

?Assume you have the correct `key1`

, reverse for the key, validate the results

using some validator/logical assumption.

`codes`

is a list of `2*(0x1b0-0xb0)`

= `512`

characters, utf-8 encoding of
which is 2-bytes each

`sboxes`

will have 4char strings, which encode to 8 bytes each on utf-8 (i.e
after substitution)

`data`

is now `4*2 = 8`

times each byte of the original plaintext

`data`

is converted `to_binary`

before encryption hence each byte is converted

to 8 `b"0"`

or `b"1"`

byte. Hence each character is substituted to some

`8*8 = 64`

byte string before encryption.

Hence len of flag = `len(ciphertext)//64`

= `3520//64 = 55`

bytes

Since length of flag is 55 characters, would it be reasonable to assume that

there would be repeatitions of characters. And since each flag character is

substituted to fixed 64-byte strings before encryption which is a multiple

of AES block size of 16, AES also acts like simple substitution of the flag

but we do not know the mapping.

Hence if we reverse till step 4 above, we can simply check if there are any

repeating 64-byte blocks, as incorrect shuffling of bits will result in each
block to be distinct with almost 1 probability.

```
with open('enc.bin', 'rb') as f:
ciphertext = f.read()
def to_binary(b: bytes):
return ''.join(['{:08b}'.format(c) for c in b])
def from_binary(s: str):
return bytes(int(s[i:i+8], 2) for i in range(0, len(s), 8))
def encrypt(key, message):
return AES.new(key, AES.MODE_ECB).encrypt(message)
def decrypt(key: bytes, message: bytes):
return AES.new(key, AES.MODE_ECB).decrypt(message)
def unshuffle(data_list, shuffle_order):
res = [None]*len(data_list)
for i,v in enumerate(shuffle_order):
res[v] = data_list[i]
return res
def key_final_dec(key1, ciphertext):
random.seed(key1)
key_final = bytes(random.randrange(256) for _ in range(16))
data = decrypt(key_final, ciphertext)
data_bits = list(to_binary(data))
data_bits_order = list(range(len(data_bits)))
random.shuffle(data_bits_order)
data_bits_uns = unshuffle(data_bits, data_bits_order)
data = from_binary(''.join(data_bits_uns))
return data
```

Lets add a few validation too

```
def key_final_enc(key1, data):
random.seed(key1)
key_final = bytes(random.randrange(256) for _ in range(16))
data_bits = list(to_binary(data))
random.shuffle(data_bits)
data = from_binary(''.join(data_bits))
return encrypt(key_final, data)
def test_unshuffle():
random_text = list(random.randbytes(16*1337))
random_text_shuffled = random_text.copy()
shuffle_order = list(range(len(random_text)))
random.seed(1337)
random.shuffle(random_text_shuffled)
random.seed(1337)
random.shuffle(shuffle_order)
assert unshuffle(random_text_shuffled, shuffle_order) == random_text
def test_key_final_dec():
random_text = random.randbytes(16*100)
assert key_final_dec(1337, key_final_enc(1337, random_text)) == random_text
test_unshuffle()
test_key_final_dec()
```

Looks like all the decryption functions are correct, lets proceed with

bruteforcing for `key1`

```
for key1 in tqdm(range(2**15),desc='solving for key1'):
data = key_final_dec(key1, ciphertext)
substitutions = Counter(data[i:i+64] for i in range(0,len(data),64))
if len(substitutions)!=len(data)//64:
print("pid =",key1)
break
```

After waiting for an eternity, and exhausting the search space of possible pid’s

yet not getting any `key1`

got me confused. I checked my script locally for a

test flag it seemed to work fine. There could only be one possibility

**the flag contains 55 distinct characters**

But how would I find `key1`

now?

@Utaha#6878 pointed out, that since there are only 256 distinct values in
`codes`

each repeated twice, and each character encoded to some `b"0"`

or `b"1"`

byte strings of length 16, It must be encrypted to the same block always.

Since the flag is `55*4 = 220`

such 16-byte codes and each code is used twice

for most of the characters, there will be repating 16-byte blocks even with

distinct flag characters.

```
for key1 in tqdm(range(2**15),desc='solving for key1'):
data = key_final_dec(key1, ciphertext)
substitutions = Counter(data[i:i+16] for i in range(0,len(data),16))
if len(substitutions)!=len(data)//16:
print("pid =",key1)
break
```

`pid = 83`

And we found our `key1`

!

And we can confirm that the flag is indeed 55 distinct characters.

Wait, if the flag is 55 distinct characters, how will we solve for the subs?

We have no statistical advantage and hence bye bye Mr
quipquip

Each `sbox`

entry is composed of 4 2-byte strings, which can be one of 256

possible values. Moreover, their order is fixed, which is determined by `key1`

.

If we try to solve for all valid mappings for `AES(binary(sbox(char)))`

we will
probably end up on the correct mapping and get our flag.

```
+---------------+---------------+------------------------+---------------+
|flag0 | flag1 | | flag55 |
+---------------+---------------+ .... +---------------+
| sbox | sbox | | sbox |
+---+---+---+---+---+---+---+---+------------------------+---------------+
|c1 |c2 |c3 |c4 |c5 |c6 |c7 |c8 | | |
| | | | | | | | | | |
+---+---+---+---+---+---+---+---+ .... +---------------+
| AES | AES | | |
+---+---+-------+---------------+------------------------+---------------+
| | +------+
| +--+ |
+------+-------+-------+------+
|E(c1) | E(c2) | E(c3) | E(c4)|
+------+-------+-------+------+
```

We can assume our flag to be a list of `BitVec`

of 7 bits each

And let the sboxes be a mapping from 7 bits to 64 bits each (16x4)

This can be achieved by assuming sbox to be an array which is indexed

by `BitVec(7)`

and contains elements of `BitVec(64)`

And we assume AES to be some function form `BitVec(16)`

to `BitVec(128)`

```
flag = [BitVec('flag_'+str(i),7) for i in range(len(data)//64)]
sboxmap = Array('sbox',BitVecSort(7), BitVecSort(64))
aes_encryption = Function('AES',BitVecSort(16), BitVecSort(128))
```

```
codes = list(''.join(chr(i) * 2 for i in range(0xb0, 0x1b0)))
random.seed(key1)
random.shuffle(codes)
# keeping sboxes utf encoded already
sboxes = [''.join(codes[i*4:(i+1)*4]).encode() for i in range(128)]
sbytes = b''.join(sboxes)
sboxints = list(map(lambda x:int.from_bytes(x,'big'),
set(sbytes[i:i+2] for i in range(0,len(sbytes),2))))
# integer values for 2-byte codes from sbox, will be explained shortly
sboxes = [int.from_bytes(i,'big') for i in sboxes]
data = key_final_dec(key1, ciphertext)
# converting intermediate decryption to 128 bit ints
data_int = []
for i in range(0,len(data),16):
data_int.append(int.from_bytes(data[i:i+16],'big'))
```

```
# we know the sbox already
constraints = [sboxmap[i]==sboxes[i] for i in range(128)]
for i in range(len(data)//64):
four_code = sboxmap[flag[i]]
# splitting 64 bit quantity to 16 bit individual sbox codes
four_code_parts = [Extract(16*i+15,16*i,four_code) for i in range(3,-1,-1)]
# for each code, matching aes_encryption with the observed value
for a,b in zip(data_int[4*i:4*i+4], four_code_parts):
constraints.append(aes_encryption(b)==a)
# last but not least, aes_encryption(i) is unique for each plaintext
# how would z3 know? Distinct function encodes them appropriately to
# be distinct
constraints.append(Distinct([aes_encryption(i) for i in sboxints]))
solver = Solver()
solver.add(constraints)
for m in all_smt(solver, flag):
# lets check for all satisfying flags (in case there are more than one
# possible mappings and we will rule out invalid ones in that scenario?
flag_bytes = bytes([m.eval(flag[i]).as_long() for i in range(len(flag))])
assert len(set(flag_bytes)) == len(Counter(data[i:i+64] for i in range(0,len(data),64)))
print(flag_bytes)
```

After running the script, we finally get our flag!

`b'r0l1-uR~pWn.c6yPtO_wi7h,ECB:I5*b8d!KQvJmLxgX9DsaANMFSeU'`

And it turns out to be the only satisfying assignment.

Turns out if there were repeated characters in the flag, we will get multiple

possible satisfying values. So the admins have not been so cheeky afterall

Note that it takes a couple of seconds to find the z3 model

```
import random
from Crypto.Cipher import AES
from collections import Counter
from tqdm import tqdm
from z3 import *
import sys
def all_smt(s, initial_terms):
def block_term(s, m, t):
s.add(t != m.eval(t))
def fix_term(s, m, t):
s.add(t == m.eval(t))
def all_smt_rec(terms):
if sat == s.check():
m = s.model()
yield m
for i in range(len(terms)):
s.push()
block_term(s, m, terms[i])
for j in range(i):
fix_term(s, m, terms[j])
yield from all_smt_rec(terms[i:])
s.pop()
yield from all_smt_rec(list(initial_terms))
with open('enc.bin', 'rb') as f:
ciphertext = f.read()
def to_binary(b: bytes):
return ''.join(['{:08b}'.format(c) for c in b])
def from_binary(s: str):
return bytes(int(s[i:i + 8], 2) for i in range(0, len(s), 8))
def encrypt(key, message):
return AES.new(key, AES.MODE_ECB).encrypt(message)
def decrypt(key: bytes, message: bytes):
return AES.new(key, AES.MODE_ECB).decrypt(message)
def key_final_enc(key1, data):
random.seed(key1)
key_final = bytes(random.randrange(256) for _ in range(16))
data_bits = list(to_binary(data))
random.shuffle(data_bits)
data = from_binary(''.join(data_bits))
return encrypt(key_final, data)
def unshuffle(data_list, shuffle_order):
res = [None] * len(data_list)
for i, v in enumerate(shuffle_order):
res[v] = data_list[i]
return res
def test_unshuffle():
random_text = list(random.randbytes(16 * 100))
random_text_shuffled = random_text.copy()
shuffle_order = list(range(len(random_text)))
random.seed(10)
random.shuffle(random_text_shuffled)
random.seed(10)
random.shuffle(shuffle_order)
assert unshuffle(random_text_shuffled, shuffle_order) == random_text
test_unshuffle()
def key_final_dec(key1, ciphertext):
random.seed(key1)
key_final = bytes(random.randrange(256) for _ in range(16))
data = decrypt(key_final, ciphertext)
data_bits = list(to_binary(data))
data_bits_order = list(range(len(data_bits)))
random.shuffle(data_bits_order)
data_bits_uns = unshuffle(data_bits, data_bits_order)
data = from_binary(''.join(data_bits_uns))
return data
def test_key_final_dec():
random_text = random.randbytes(16 * 100)
assert key_final_dec(10, key_final_enc(10, random_text)) == random_text
test_key_final_dec()
for key1 in tqdm(range(2**15), desc='solving for key1'):
data = key_final_dec(key1, ciphertext)
substitutions = Counter(data[i:i + 16] for i in range(0, len(data), 16))
if len(substitutions) != len(data) // 16:
print("pid =", key1)
break
codes = list(''.join(chr(i) * 2 for i in range(0xb0, 0x1b0)))
random.seed(key1)
random.shuffle(codes)
sboxes = [''.join(codes[i * 4:(i + 1) * 4]).encode() for i in range(128)]
sbytes = b''.join(sboxes)
sboxints = list(map(lambda x: int.from_bytes(x, 'big'), set(
sbytes[i:i + 2] for i in range(0, len(sbytes), 2))))
sboxes = [int.from_bytes(i, 'big') for i in sboxes]
data = key_final_dec(key1, ciphertext)
data_int = []
for i in range(0, len(data), 16):
data_int.append(int.from_bytes(data[i:i + 16], 'big'))
flag = [BitVec('flag_' + str(i), 7) for i in range(len(data) // 64)]
sboxmap = Array('sbox', BitVecSort(7), BitVecSort(64))
aes_encryption = Function('AES', BitVecSort(16), BitVecSort(128))
constraints = [sboxmap[i] == sboxes[i] for i in range(128)]
for i in range(len(data) // 64):
four_code = sboxmap[flag[i]]
four_code_parts = [Extract(16 * i + 15, 16 * i, four_code)
for i in range(3, -1, -1)]
for a, b in zip(data_int[4 * i:4 * i + 4], four_code_parts):
constraints.append(aes_encryption(b) == a)
constraints.append(Distinct([aes_encryption(i) for i in sboxints]))
solver = Solver()
solver.add(constraints)
# if solver.check() == sat:
# m = solver.model()
for m in all_smt(solver, flag):
flag_bytes = bytes([m.eval(flag[i]).as_long() for i in range(len(flag))])
assert len(set(flag_bytes)) == len(
Counter(data[i:i + 64] for i in range(0, len(data), 64)))
print(flag_bytes)
else:
print("failed to solve")
```

All due regards to him for solving the challenge while I was stuck over finding

`key1`

XD

All parts will be almost same except the substitution solving part, which he

did by manual bruteforcing i.e. recursively enumerating all mappings and
backtracking on contradictions

```
mp = dict()
codes = sum([[i, i] for i in range(256)], start=[])
# notice that the range is changed from [0xb0, 0x1b0) to [0, 256).
# It's just for relabeling.
random.seed(key1)
random.shuffle(codes)
sboxes = [codes[i*4:(i+1)*4] for i in range(128)]
def match(a, b):
"""
equate two objects elementwise ignoring if the entry is -1
"""
for x, y in zip(a, b):
if x == -1 or y == -1:
continue
if x != y:
return False
return True
answers = []
def getFlag(cip, sboxes, mp):
# get the flag based on current mapping, unknown char will be shown as '?'
res = []
for c in cip:
afterMap = [mp.get(x, -1) for x in c]
found = False
for i, s in enumerate(sboxes):
if s == afterMap:
res.append(i)
found = True
break
if not found:
res.append(ord('?'))
return bytes(res)
def brute(cip, sboxes, mp):
"""
cip and sboxes remain unchanged throughout the recursive call,
but I feel bad using global varaibles.
"""
if DEBUG:
print(getFlag(cip, sboxes, mp))
# check is finished
isFinished = True
for c in cip:
if all(x in mp for x in c):
pass
else:
isFinished = False
if isFinished:
answers.append(getFlag(cip, sboxes, mp))
print("Found an answer!!!!!!!")
return
# try matching
isContradiction = False
mp = mp.copy()
# Find the one with least possible matches.
min_pos = 256
index = -1
for idx, c in enumerate(cip):
afterMap = [mp.get(x, -1) for x in c]
if -1 not in afterMap:
continue
matches = [s for s in sboxes if match(s, afterMap)]
if len(matches) == 0:
isContradiction = True
break
if min_pos > len(matches):
index = idx
min_pos = len(matches)
if isContradiction:
return
# now bruteforce all possibilities
assert index != -1
afterMap = [mp.get(x, -1) for x in cip[index]]
matches = [s for s in sboxes if match(s, afterMap)]
for m in matches:
for x, y in zip(cip[index], m):
mp[x] = y
brute(cip, sboxes, mp)
# This is based on the repetition
for _ in [132, 197]:
mp = {35: 224, 109: 144, 4: _}
brute(cip, sboxes, mp)
print("Answers:")
answers = list(set(answers))
for x in answers:
print(b"sdctf{" + x + b"}")
# The fourth one is the actual answer
```

`Ciphertext repetition: [4, 5, 4, 6] [34, 35, 36, 35] [109, 60, 110, 109] Sbox repetition: [132, 93, 132, 211] [197, 32, 197, 248] [144, 86, 67, 144] [165, 224, 27, 224] Found an answer!!!!!!! Found an answer!!!!!!! Found an answer!!!!!!! Found an answer!!!!!!! Found an answer!!!!!!! Found an answer!!!!!!! Found an answer!!!!!!! Found an answer!!!!!!! Answers: b'sdctf{r0l1-LR~pWn.c6yPtO_wi7h,ECB:I5*b8d!KQvJmLxgX95saANMFSeU}' b'sdctf{r0l1-uR~pWn.c6yPtO_wi7h,ECB:I5*b8d!cQvJmLxgX9DsaANMFSeU}' b'sdctf{r0l1-uR~pWn.c6yPtO_wi7h,ECB:I5*b8d!KQvJmLxgX9DsaANMFSeU}' b'sdctf{r0l1-uR~pWn.c6yPtO_wi7h,ECB:I5*b8d!cQvJmLxgX95saANMFSeU}' b'sdctf{r0l1-LR~pWn.c6yPtO_wi7h,ECB:I5*b8d!KQvJmLxgX9DsaANMFSeU}' b'sdctf{r0l1-LR~pWn.c6yPtO_wi7h,ECB:I5*b8d!cQvJmLxgX9DsaANMFSeU}' b'sdctf{r0l1-uR~pWn.c6yPtO_wi7h,ECB:I5*b8d!KQvJmLxgX95saANMFSeU}' b'sdctf{r0l1-LR~pWn.c6yPtO_wi7h,ECB:I5*b8d!cQvJmLxgX95saANMFSeU}'`

full script in solve2.py

]]>Yet another oracle, but the queries are costly and limited so be frugal with them.

`pythia.2021.ctfcompetition.com 1337`

```
max_queries = 150
query_delay = 0
passwords = [bytes(''.join(random.choice(string.ascii_lowercase) \
for _ in range(3)), 'UTF-8') for _ in range(3)]
flag = open("flag.txt", "rb").read()
def menu():
print("What you wanna do?")
print("1- Set key")
print("2- Read flag")
print("3- Decrypt text")
print("4- Exit")
try:
return int(input(">>> "))
except:
return -1
print("Welcome!\n")
key_used = 0
for query in range(max_queries):
option = menu()
if option == 1:
print("Which key you want to use [0-2]?")
try:
i = int(input(">>> "))
except:
i = -1
if i >= 0 and i <= 2:
key_used = i
else:
print("Please select a valid key.")
elif option == 2:
print("Password?")
passwd = bytes(input(">>> "), 'UTF-8')
print("Checking...")
# Prevent bruteforce attacks...
time.sleep(query_delay)
if passwd == (passwords[0] + passwords[1] + passwords[2]):
print("ACCESS GRANTED: " + flag.decode('UTF-8'))
else:
print("ACCESS DENIED!")
elif option == 3:
print("Send your ciphertext ")
ct = input(">>> ")
print("Decrypting...")
# Prevent bruteforce attacks...
time.sleep(query_delay)
try:
nonce, ciphertext = ct.split(",")
nonce = b64decode(nonce)
ciphertext = b64decode(ciphertext)
except:
print("ERROR: Ciphertext has invalid format. \
Must be of the form \"nonce,ciphertext\", where \
nonce and ciphertext are base64 strings.")
continue
kdf = Scrypt(salt=b'', length=16, n=2**4, r=8, p=1, backend=default_backend())
key = kdf.derive(passwords[key_used])
try:
cipher = AESGCM(key)
plaintext = cipher.decrypt(nonce, ciphertext, associated_data=None)
except:
print("ERROR: Decryption failed. Key was not correct.")
continue
print("Decryption successful")
elif option == 4:
print("Bye!")
break
else:
print("Invalid option!")
print("You have " + str(max_queries - query) + " trials left...\n")
```

Taking a look at the source, we have a few observations

- The server generates 3 passwords of 3 lower case ASCII each and uses Scrypt (a Password based key derivation function) on it to derive a 16 byte encryption key from each of the 3 byte passwords, which can be treated as deriving 3 16-byte keys from a set of $26^3 = 17576$ known randomly generated keys.
- It provides us 3 options to work with. Option 1 to set 1 of the 3 unknown randomly generated keys
- Option 3 provides as a decryption oracle, allowing us to check whether any arbitrary ciphertext of our choice decrypts successfully. (Why would it fail? more details in How decryption works)
- Option 2 is the option we dig, give the server all three passwords (hence keys) correctly, it gives back the flag.
- There are 150 queries for all 3 options, which means we have effectively 147 decryption oracles to work with, which indicates we need an average case key recovery in <49 queries.

Since the number of keys is quite small, one might be tempted to bruteforce the keys and be optimistic that he/she gets all three keys in 150 attempts.

But the fact that guessing 3 keys consecutively within 150 attempts has probability as low as $\approx 10^{-7}$ which is already out of practical server-bruteforcing further enforced by 10 s delay between each guess taking 25 mins for each bruteforce, its clearly crossed out.

Again, one would be tempted to think that it could be some weakness of Scrypt or the given configuration, or some relation between the derived keys which renders GCM ez. This hypothesis can also countered easily looking at the amount of stuff going inside Scrypt :P

Again, someone sees AESGCM, they get cryptopals set 8 flashbacks.

- Key recovery attacks on repeated nonces
- Again this is possible if the server
*encrypts*stuff, not check its decryption. What the attacker recovers is $E(0,K)$ not the key itself, but since keyspace is small, they could recover it through. Although again, this is definitely not the challenge. - Key recovery attacks on truncated mac - Clearly, I cant see any sort of truncation. So its out.

The only information we can extract from the oracle is whether the provided ciphertext forms the given tag under the key of the server. How would this help us reduce the number of queries required from $26^3$ to say 50. If it was possible to get it in one shot, the challenge authors would not give a slack of 50 :P

How about there exists a (ciphertext, tag) which decrypts successfully for more than one key?

This would reduce the number of queries by half! We will just need to keep on asking ciphertexts for a 2 pair till the key of server happens to lie in that pair.

That’s all, if we can get a (ciphertext, tag) which is valid for $n$ keys, we can reduce the search to a binary search, requiring $log_{2}(26^3) \approx 14.1 = 15$ queries at max making 45 queries in total. Then again why 150 and not 50?

More on it later…

This little detour is for the people who may be confused about Authenticated Encryption with Associated Data, and why would an arbitrary decryption of some ciphertext would fail under a given key as opposed to some other mode of AES say CBC.

So the whole idea here is that the ciphertext will comprise of three parts

- The encrypted payload i.e the data we wish to communicate
- Associated un-encrypted data which contains any additional metadata which needs to be preserved against any sort of tempering.
- Tag which is essentially an attempt of proof that the given ciphertext was encrypted by someone who holds the secret key and the payload and additional associated data has not been tempered with.

Whenever the server receives a ciphertext to decrypt, it will first try to verify that the received tag is actually corresponding to the received ciphertext encrypted with the secret key.

If the computation of tag on the ciphertext fails, the server would reject the proposal to decrypt the message.

AES-GCM is, put simply, an authentication mechanism built upon AES in CTR mode (a stream cipher not necessary for our discussion), such that encrypted blocks of the ciphertext, additional authentication data, and lengths of those two put together in a GCM mac. This is whats all we will need to consider.
Here comes the mandatory picture from Wikipedia. Just follow the components used in the `Auth tag`

generation.

GCM is simply a polynomial (in $GF(2^{128})$) constructed using the blocks of authentication data, ciphertext, and two additional blocks, one constructed using the lengths of data and ciphertext and one using the encryption of 96 bit nonce $N$ appended with 31 bits of 0 and a single bit 1. i.e. $s = E(N||0^{31}1,K)$

This polynomial is evaluated at $h = E(0,K)$ to compute the `Auth tag`

\(T = ((((((h*A_0) \oplus A_1)*h ... \oplus A_m)*h \oplus C_0)*h ... \oplus C_{n-1})*h \oplus L)*h \oplus s\)

Since there’s no additional data in the challenge, we get

\(T = ((((h*C_0) \oplus C_1)*h ... \oplus C_{n-1})*h \oplus L)*h \oplus s\) or

\(T = C_0*h^{n+1} \oplus C_1*h^{n+1} ... \oplus C_{n-1}*h^{2} \oplus L*h \oplus s\)

Continuing and exploring the idea above one would come across a recent paper titled Partitioning Oracle Attack and what’s cherry on the top is that a quick CTRL+F for github in the paper reveals the POC demo of the same making it a lot easier to implement.

Continuing from the expression of tag, the terms dependent on key for calculation of tag are $\textbf{h, s}$ only.

\(T = C_0*\textbf{h}^{n+1} \oplus C_1*\textbf{h}^{n} ... \oplus C_{n-1}*\textbf{h}^{2} \oplus L*\textbf{h} \oplus \textbf{s}\)

\(C_0*\textbf{h}^{n+1} \oplus C_1*\textbf{h}^{n} ... \oplus C_{n-1}*\textbf{h}^{2} = T \oplus L*\textbf{h} \oplus \textbf{s}\)

\(C_0*\textbf{h}^{n-1} \oplus C_1*\textbf{h}^{n-2} ... \oplus C_{n-1} = (T \oplus L*\textbf{h} \oplus \textbf{s})*\textbf{h}^{-2}\)

writing
\((T \oplus L*\textbf{h} \oplus \textbf{s})*\textbf{h}^{-2}\) as a key dependent quantity \(\textbf{B}\) we can write it for $n$ keys \(K_0...K_{n-1}\) , we get

\(C_0*\textbf{h}^{n-1}_{0} \oplus C_1*\textbf{h}^{n-2}_{0} ... \oplus C_{n-1} = \textbf{B}_{0}\)

\(C_0*\textbf{h}^{n-1}_{1} \oplus C_1*\textbf{h}^{n-2}_{1} ... \oplus C_{n-1} = \textbf{B}_{1}\)

\(\vdots \qquad \qquad \vdots \qquad \qquad \vdots \qquad \qquad \vdots\)

\(C_0*\textbf{h}^{n-1}_{n-1} \oplus C_1*\textbf{h}^{n-2}_{n-1} ... \oplus C_{n-1} = \textbf{B}_{n-1}\)

\(\begin{bmatrix}
1 & \textbf{h}_{0} & \textbf{h}_{0}^{2} & \cdots & \textbf{h}_{0}^{n-1}\\
1 & \textbf{h}_{0} & \textbf{h}_{0}^{2} & \cdots & \textbf{h}_{0}^{n-1} \\
\vdots & \vdots &\vdots & \ddots & \vdots\\
1 & \textbf{h}_{0} & \textbf{h}_{0}^{2} & \cdots & \textbf{h}_{0}^{n-1}
\end{bmatrix} .
\begin{bmatrix}
C_{n-1} \\
C_{n-2} \\
\vdots \\
C_{0}
\end{bmatrix} =
\begin{bmatrix}
\textbf{B}_{0} \\
\textbf{B}_{1} \\
\vdots \\
\textbf{B}_{n-1}
\end{bmatrix}\)

Now that we have all the required equations set up, we can find $C_{0}, C_{1} \ldots, C_{n-1}$ through lagrange interpolation in $O(n^2)$ time and $O(n)$ space.

Theoretically 15 searches would be enough to find the key, but it would require a multicollision for ~8000 keys.

What we can do is to first check for a few groups of smaller sizes, then proceeding with binary search on a given group.

If we form groups of size $k$, the total number of calls should be roughly (for worst case number of calls)
$17576/k + \lceil log_{2}k \rceil= 49 \implies k\approx 367$
Time taken to find a multicollision for $k=367$ keys,

```
import time
import statistics
k=367
times = []
for i in range(0,26**3,k):
start_time = time.time()
multi = multicollision(derived_keys[i:i+k])
times.append(time.time()-start_time)
print(statistics.mean(times))
#4.06
```

It takes around 4 seconds on an i9 processor. For the binary search part the times for computation
take quadratically shorter duration, adding to roughly 1.5s for 9 calls.

Overall it should take average case \((10+4)*(17576/367)*1/2 + 9*10 + 1.5 \approx 426s \approx 7mins\) to find a key and worst case to around 760s ~ 12.7 minutes

Average case time to solve the challenge would be around 21 minutes and worst case to around 38 minutes :(

Borrowing logic from collide_gcm.sage, local copy here is a dirty script

```
import random
import string
import time
from base64 import b64encode, b64decode
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
from Crypto.Cipher import AES
from itertools import product
from bitstring import BitArray, Bits
import pwn
derived_keys = [] # keys derived from scrypt of password
rev_keys = {} # holds mapping from derived key to password
for k in product(string.ascii_lowercase, repeat=3):
kdf = Scrypt(salt=b'', length=16, n=16, r=8,
p=1, backend=default_backend())
derived_key = kdf.derive("".join(k).encode())
derived_keys.append(derived_key)
rev_keys[derived_key] = "".join(k)
HOST, PORT = "pythia.2021.ctfcompetition.com", 1337
REM = pwn.remote(HOST, PORT)
def bytes_to_element(val, field, a):
"""Converting a bytes object to an element in `field`"""
bits = BitArray(val)
result = field.fetch_int(0)
for i in range(len(bits)):
if bits[i]:
result += a**i
return result
P.<x> = PolynomialRing(GF(2))
p = x**128 + x**7 + x**2 + x + 1
GFghash.<a> = GF(2**128, 'x', modulus=p)
R = PolynomialRing(GFghash, 'x')
def multicollision(keyset, nonce=b'\x00' * 12, tag=b'\x01' * 16):
"""main function to find multicollisions, Tag is kept a constant
and so is nonce for all the key encryptions
x_bf corresponds byte object x transformed to the field element
"""
L_bytes = int(len(keyset) * 128).to_bytes(16, 'big')
L_bf = bytes_to_element(L_bytes, GFghash, a)
nonce_plus = nonce + bytes([0, 0, 0, 1])
tag_bf = bytes_to_element(tag, GFghash, a)
pairs = []
for k in keyset:
# compute H
aes = AES.new(k, AES.MODE_ECB)
H = aes.encrypt(b'\x00' * 16)
h_bf = bytes_to_element(H, GFghash, a)
s = aes.encrypt(nonce_plus)
s_bf = bytes_to_element(s, GFghash, a)
# assign (lens * H) + s + T to b
b = (L_bf * h_bf) + s_bf + tag_bf
# get pair (H, b*(H^-2))
y = b * h_bf**-2
pairs.append((h_bf, y))
# compute Lagrange interpolation
f = R.lagrange_polynomial(pairs)
ct = ''
for coeff in f.list()[::-1]:
ct_pad = ''.join(map(str, coeff.polynomial().list()))
ct += Bits(bin=ct_pad.ljust(128, '0'))
ct = ct.bytes
return ct + tag
def decrypt_text(text):
REM.sendline(b'3')
REM.sendline('A' * 16 + ',' + pwn.b64e(text))
data = REM.recvuntil(b'Exit\n>>> ')
return b'successful' in data
def search(size=367):
start_time = time.time()
api_count = 0
for i in range(0, 26**3, size):
print("trying range ({},{})".format(i, i + size))
api_count += 1
if decrypt_text(multicollision(derived_keys[i:i + size])):
break
lo, hi = i, i + size
while lo <= hi:
mid = (lo + hi) // 2
api_count += 1
print("trying range ({},{})".format(lo, hi))
if decrypt_text(multicollision(derived_keys[lo:mid + 1])):
hi = mid - 1
else:
lo = mid + 1
if decrypt_text(multicollision(derived_keys[lo:lo + 1])):
keyindex = lo
else:
keyindex = lo + 1
password = rev_keys[derived_keys[keyindex]]
print("key:{} found in {} calls".format(password, api_count))
print("time taken :", time.time() - start_time)
return password
REM.recvuntil(b'Exit\n>>>')
password = ""
for key_index in range(3):
REM.sendline(b'1') # option1
REM.sendline(str(key_index))
REM.recvuntil(b'Exit\n>>>')
password += search()
REM.sendline(b'2')
REM.sendline(password)
print(REM.recvregex(b'CTF{.*}')
# CTF{gCm_1s_n0t_v3ry_r0bust_4nd_1_sh0uld_us3_s0m3th1ng_els3_h3r3}
```

And we get our flag!

```
I wrote my own AES! Can you break it?
hQWYogqLXUO+rePyWkNlBlaAX47/2dCeLFMLrmPKcYRLYZgFuqRC7EtwX4DRtG31XY4az+yOvJJ/pwWR0/J9gg==
~qpwoeirut#5057
```

```
from base64 import b64encode
BLOCK_SIZE = 8
ROUNDS = 8
sbox = [111, 161, 71, 136, 68, 69, 31, 0, 145, 237, 169, 115, 16, 20, 22, 82, 138, 183, 232, 95, 244, 163, 64, 229, 224, 104, 231, 61, 121, 152, 97, 50, 74, 96, 247, 144, 194, 86, 186, 234, 99, 122, 46, 18, 215, 168, 173, 188, 41, 243, 219, 203, 141, 21, 171, 57, 116, 178, 233, 210, 184, 253, 151, 48, 206, 250, 133, 44, 59, 147, 137, 66, 52, 75, 187, 129, 225, 209, 191, 92, 238, 127, 241, 25, 160, 9, 170, 13, 157, 45, 205, 196, 28, 146, 142, 150, 17, 39, 24, 80, 118, 6, 32, 93, 11, 216, 220, 100, 85, 112, 222, 226, 126, 197, 180, 34, 182, 37, 148, 70, 78, 201, 236, 81, 62, 42, 193, 67, 8, 164, 43, 252, 166, 221, 208, 176, 235, 149, 109, 63, 103, 223, 65, 56, 140, 255, 218, 54, 153, 2, 228, 1, 240, 248, 246, 110, 156, 60, 227, 207, 254, 51, 174, 79, 128, 155, 251, 242, 177, 135, 230, 154, 179, 15, 189, 143, 130, 27, 107, 211, 30, 105, 19, 134, 124, 125, 245, 76, 204, 12, 26, 38, 40, 131, 117, 87, 114, 213, 212, 102, 195, 101, 55, 10, 47, 120, 200, 217, 88, 83, 36, 198, 249, 192, 23, 94, 181, 73, 185, 172, 165, 58, 53, 202, 106, 5, 7, 175, 89, 72, 90, 14, 162, 158, 119, 139, 77, 108, 190, 91, 29, 49, 159, 33, 113, 214, 4, 123, 199, 167, 35, 239, 84, 3, 132, 98]
pbox = [39, 20, 18, 62, 4, 60, 19, 43, 33, 6, 51, 61, 40, 35, 47, 16, 23, 58, 31, 53, 28, 55, 54, 30, 17, 42, 34, 45, 49, 13, 46, 0, 26, 2, 8, 3, 11, 48, 63, 36, 37, 7, 32, 5, 27, 59, 29, 44, 14, 56, 21, 22, 12, 52, 57, 41, 10, 1, 24, 38, 50, 15, 9, 25]
def pad(block):
return block + chr(BLOCK_SIZE - len(block)).encode() * (BLOCK_SIZE - len(block))
def to_blocks(in_bytes: bytes) -> list:
return [in_bytes[i:i + BLOCK_SIZE] for i in range(0, len(in_bytes), BLOCK_SIZE)]
def enc_sub(in_bytes: bytes) -> bytes:
return bytes([sbox[b] for b in in_bytes])
def enc_perm(in_bytes: bytes) -> bytes:
num = int.from_bytes(in_bytes, 'big')
binary = bin(num)[2:].rjust(BLOCK_SIZE * 8, '0')
permuted = ''.join([binary[pbox[i]] for i in range(BLOCK_SIZE * 8)])
out = bytes([int(permuted[i:i + 8], 2) for i in range(0, BLOCK_SIZE * 8, 8)])
return out
def expand_key(key: bytes, key_len: int) -> bytes:
expanded = bytearray()
cur = 0
for byte in key:
cur = (cur + byte) & ((1 << 8) - 1)
expanded.append(cur)
for num in [key[i % len(key)] * 2 for i in range(key_len)]:
cur = pow(cur, num, 256)
expanded.append(cur)
return bytes(expanded)
def encrypt(plain: bytes, key: bytes) -> bytes:
blocks = to_blocks(plain)
out = bytearray()
key = expand_key(key, len(blocks))
for idx, block in enumerate(blocks):
block = pad(block)
assert len(block) == BLOCK_SIZE
for _ in range(ROUNDS):
block = enc_sub(block)
block = enc_perm(block)
block = bytearray(block)
for i in range(len(block)):
block[i] ^= key[idx]
out.extend(block)
return bytes(out)
if __name__ == '__main__':
with open("flag", 'rb') as flag_file:
flag = flag_file.read()
with open("key", 'rb') as key_file:
key = key_file.read()
print(b64encode(encrypt(flag, key)).decode())
```

This seems like a custom AES implementation. Note that we are not provided a decryption routine, so lets simply write one.

Implementing one is not too complicated, one just need to reverse the `encrypt`

function step by step.

```
def decrypt(cipher: bytes, key:bytes) -> bytes:
blocks = to_blocks(cipher)
out = bytearray()
key = expand_key(key, len(blocks))
for idx, block in enumerate(blocks):
for _ in range(ROUNDS):
block = bytearray(block)
for i in range(len(block)):
block[i] ^= key[idx]
block = dec_perm(block)
block = dec_sub(block)
out.extend(block)
return bytes(out)
```

Its essentially the encrypt function in reverse, in `encrypt`

, key is xored at last in the for loop, we do it first.

Then we do reverse of permutation `dec_perm`

and reverse of substitution `dec_sub`

in the following functions.

For reversing `enc_sub`

, we just need to find the index of corresponding byte in the `sbox`

```
def enc_sub(in_bytes: bytes) -> bytes:
return bytes([sbox[b] for b in in_bytes])
def dec_sub(in_bytes: bytes) -> bytes:
return bytes([sbox.index(b) for b in in_bytes ])
```

To reverse the permutation,

```
def enc_perm(in_bytes: bytes) -> bytes:
num = int.from_bytes(in_bytes, 'big')
binary = bin(num)[2:].rjust(BLOCK_SIZE * 8, '0')
permuted = ''.join([binary[pbox[i]] for i in range(BLOCK_SIZE * 8)])
out = bytes([int(permuted[i:i + 8], 2) for i in range(0, BLOCK_SIZE * 8,
8)])
return out
def dec_perm(in_bytes: bytes) -> bytes:
out = bytearray(64)
permuted = bin(int.from_bytes(in_bytes, 'big'))[2:].zfill(64) #just converting to binary
for i in range(64):
out[pbox[i]] = ord(permuted[i]) # should be ones and zeros but using ord as bytearrays are directly convertible to int
out_bytes = int.to_bytes(int(out,2),8,byteorder='big') #converting to bytes again
return out_bytes
```

Once we have decryption function set up, we can start exploring the challenge :)

The devil at work here is the `expand_key`

function. One could easily verify that without using much brain :)

```
for i in range(20):
print(expand_key(bytes([i]),8))
# 8 since we know the flag is 8*BLOCK_LENGTH bytes
#b'\x00\x01\x01\x01\x01\x01\x01\x01\x01'
#b'\x01\x01\x01\x01\x01\x01\x01\x01\x01'
#b'\x02\x10\x00\x00\x00\x00\x00\x00\x00'
#b'\x03\xd9\xd1\xe1A\x81\x01\x01\x01'
#b'\x04\x00\x00\x00\x00\x00\x00\x00\x00'
#b'\x05\xf9\xf1a\xc1\x81\x01\x01\x01'
#b'\x06\x00\x00\x00\x00\x00\x00\x00\x00'
#b'\x07QaA\x81\x01\x01\x01\x01'
#b'\x08\x00\x00\x00\x00\x00\x00\x00\x00'
#b'\t\xd1\xa1A\x81\x01\x01\x01\x01'
#b'\n\x00\x00\x00\x00\x00\x00\x00\x00'
#b'\x0b\xe9\xb1!\xc1\x81\x01\x01\x01'
#b'\x0c\x00\x00\x00\x00\x00\x00\x00\x00'
#b'\r\t\x11\xa1A\x81\x01\x01\x01'
#b'\x0e\x00\x00\x00\x00\x00\x00\x00\x00'
#b'\x0f!\xc1\x81\x01\x01\x01\x01\x01'
#b'\x10\x00\x00\x00\x00\x00\x00\x00\x00'
#b'\x11!A\x81\x01\x01\x01\x01\x01'
#b'\x12\x00\x00\x00\x00\x00\x00\x00\x00'
#b'\x13y\x91aA\x81\x01\x01\x01'
```

Without even looking at the key_expansion, one could say the keys it expands to are quite bad and possibly quite repetitive.

Voila, lets try randomly decrypting with a key.

```
flag_enc = b64decode(b'hQWYogqLXUO+rePyWkNlBlaAX47/2dCeLFMLrmPKcYRLYZgFuqRC7EtwX4DRtG31XY4az+yOvJJ/pwWR0/J9gg==')
print(decyrpt(flag_enc, b'\x00'))
print(decyrpt(flag_enc, b'\x01'))
#b'\xe2\xaa/\xb8}\xb2\xe1\x9d\xbe\xf0\xad\x1c\xe4)\xa77c3_is_4LW4YS_th3_4nsw3r(but_with_0ptimiz4ti0ns)}'
#b'\x15\xa84N\xff\x83\x00{\xbe\xf0\xad\x1c\xe4)\xa77c3_is_4LW4YS_th3_4nsw3r(but_with_0ptimiz4ti0ns)}'
```

One could aready read a lot of the flag! We only lack the first two blocks of the flag.

Why can we read the rest of the flag by decrypting with some non-sense key?

Since in `encrypt`

function each block is xored with the key byte at the corresponding position, we luckily end up encrypting it with byte `b'\x01'`

for the last 6 bytes.

And xoring with `b'\x01'`

would be the same byte again hehe.

But wouldnt it be lost amidst all the permutation and substitution??

No, since we are exactly reversing the permutation and substitution since the xor part dies out!

Why do we have so many 0’s and 1’s in the expanded key?

It is evident from this part

```
for num in [key[i % len(key)] * 2 for i in range(key_len)]:
cur = pow(cur, num, 256)
expanded.append(cur)
```

As `cur`

is repeteadly raised to the power `num`

, once `cur`

hits 0 or 1, it will stay 0 or 1 out of its misery.

So all we need to figure out is the first two bytes, which should be quite easy!

```
for i in range(65536):
key = long_to_bytes(i)
if (a := decrypt(flag_enc, key)).startswith(b'rgbCTF'):
print(a,key)
```

Ugly solution in solve.py

And boom! we have our flag

```
What if I encrypt something with AES multiple times? nc challenge.rgbsec.xyz 34567
~qpwoeirut#5057
```

```
import binascii
from base64 import b64encode, b64decode
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
from os import urandom
from random import seed, randint
BLOCK_SIZE = 16
def rand_block(key_seed=urandom(1)):
seed(key_seed)
return bytes([randint(0, 255) for _ in range(BLOCK_SIZE)])
def encrypt(plaintext, seed_bytes):
ciphertext = pad(b64decode(plaintext), BLOCK_SIZE)
seed_bytes = b64decode(seed_bytes)
assert len(seed_bytes) >= 8
for seed in seed_bytes:
ciphertext = AES.new(rand_block(seed), AES.MODE_ECB).encrypt(ciphertext)
return b64encode(ciphertext)
def decrypt(ciphertext, seed_bytes):
plaintext = b64decode(ciphertext"rgbCTF 2020 Crypto - N-AES" seed_bytes = b64decode(seed_bytes)
for byte in reversed(seed_bytes):
plaintext = AES.new(rand_block(byte), AES.MODE_ECB).decrypt(plaintext)
return b64encode(unpad(plaintext, BLOCK_SIZE))
def gen_chall(text):
text = pad(text, BLOCK_SIZE)
for i in range(128):
text = AES.new(rand_block(), AES.MODE_ECB).encrypt(text)
return b64encode(text)
def main():
challenge = b64encode(urandom(64))
print(gen_chall(challenge).decode())
while True:
print("[1] Encrypt")
print("[2] Decrypt")
print("[3] Solve challenge")
print("[4] Give up")
command = input("> ")
try:
if command == '1':
text = input("Enter text to encrypt, in base64: ")
seed_bytes = input("Enter key, in base64: ")
print(encrypt(text, seed_bytes))
elif command == '2':
text = input("Enter text to decrypt, in base64: ")
seed_bytes = input("Enter key, in base64: ")
print(decrypt(text, seed_bytes))
elif command == '3':
answer = input("Enter the decrypted challenge, in base64: ")
if b64decode(answer) == challenge:
print("Correct!")
print("Here's your flag:")
with open("flag", 'r') as f:
print(f.read())
else:
print("Incorrect!")
break
elif command == '4':
break
else:
print("Invalid command!")
except binascii.Error:
print("Base64 error!")
except Exception:
print("Error!")
print("Bye!")
if __name__ == '__main__':
main()
```

On netcatting, we get get a base64 encoded encryption of a base64 encoded random string of 64 bytes.

```
challenge = b64encode(urandom(64))
print(gen_chall(challenge).decode())
```

Taking a look at `gen_chall`

```
def gen_chall(text):
text = pad(text, BLOCK_SIZE)
for i in range(128):
text = AES.new(rand_block(), AES.MODE_ECB).encrypt(text)
return b64encode(text)
```

And

```
def rand_block(key_seed=urandom(1)):
seed(key_seed)
return bytes([randint(0, 255) for _ in range(BLOCK_SIZE)])
```

This seems quite tricky, since `rand_block`

will be presenting some random key and `gen_chall`

is encrypting with some random key 128 times! right?

**WRONG**, There are some few caveats which we might exploit ;)

- Since no
`key_seed`

is specified in the`gen_chall`

call to`rand_block`

, it should be taking`key_seed`

to be`urandom(1)`

which is simply one byte :) - More importantly, once it gets called,
`key_seed`

is fixed! So all the random blocks would essentially be the same! One may test it out.

```
from os import urandom
from random import seed, randint
BLOCK_SIZE = 16
def rand_block(key_seed=urandom(1)):
seed(key_seed)
return bytes([randint(0, 255) for _ in range(BLOCK_SIZE)])
for i in range(10):
print(rand_block())
#b'h\xf5\x81o*\xce\x97\x90^9O\x96T9~w'
#b'h\xf5\x81o*\xce\x97\x90^9O\x96T9~w'
#b'h\xf5\x81o*\xce\x97\x90^9O\x96T9~w'
#b'h\xf5\x81o*\xce\x97\x90^9O\x96T9~w'
#b'h\xf5\x81o*\xce\x97\x90^9O\x96T9~w'
#b'h\xf5\x81o*\xce\x97\x90^9O\x96T9~w'
#b'h\xf5\x81o*\xce\x97\x90^9O\x96T9~w'
#b'h\xf5\x81o*\xce\x97\x90^9O\x96T9~w'
#b'h\xf5\x81o*\xce\x97\x90^9O\x96T9~w'
#b'h\xf5\x81o*\xce\x97\x90^9O\x96T9~w'
```

So all that needs to be done is find out that random byte with which seed was initialised, and we will know the key, just decrypt our way out of the flag.

```
from pwn import remote
from base64 import b64encode, b64decode
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
from random import seed, randint
import re
HOST, PORT = "challenge.rgbsec.xyz", 34567
REM = remote(HOST, PORT)
CHALL = b64decode(REM.recvline().strip())
def rand_block(byte):
"""random block for given seed byte"""
seed(byte)
return bytes([randint(0,255) for _ in range(16) ])
REM.recvuntil(b'\n>')
def dec_serv(ciphertext, seed_bytes):
"""Requests decryption from the server"""
REM.sendline(b'2')
REM.sendline(b64encode(ciphertext))
REM.sendline(b64encode(seed_bytes))
data = REM.recvuntil(b'\n>')
if b'Error' not in data:
decd = re.search(b'b\'([a-zA-Z0-9\+/]+)\'',data)[1]
return b64decode(decd)
for i in range(256):
decryption = dec_serv(CHALL, bytes([i]*128))
if decryption:
print(decryption)
break
REM.sendline(b'3')
REM.sendline(b64encode(decrypt(CHALL)))
print(REM.recvregex(b'rgbCTF{.*}').decode())
```

Tbh, I expected that to work but it didnt! Why?

Because server uses this decryption routine

```
def decrypt(ciphertext, seed_bytes):
plaintext = b64decode(ciphertext)
seed_bytes = b64decode(seed_bytes)
for byte in reversed(seed_bytes):
plaintext = AES.new(rand_block(byte), AES.MODE_ECB).decrypt(plaintext)
return b64encode(unpad(plaintext, BLOCK_SIZE))
```

Still cant spot it out?

All the devil is in `rand_block(byte)`

. How? Because when byte objects are iterated upon, all the individual bytes are returned as int.

```
for i in b'a':
print(i,type(i))
#97 <class 'int'>
```

Hmm, very interesting. But how does that make a difference?

Because `rand_block(i)`

and `rand_block(byte([i])`

are completly different for an int `i`

! Why?

Because internally `seed(key_seed)`

is used to initialize, and `seed(byte([i]))`

and `seed(i)`

are different! WTF!!

This implies the server would ~~not~~ never be able to decrypt using its own decryption routine!

To fix this, all we need to do is to write our own!

We know the decryption is correct just by looking at correct padding, since len(b64encode(64 random bytes)) = 64*4/3 = 85 and we have a ciphertext of len 96.

1 in AES initialization suggests

`AES.MODE_ECB`

`def decrypt(ct): ct_orig = ct for i in range(256): ct = ct_orig for _ in range(128): ct = AES.new(rand_block(bytes([i])),1).decrypt(ct) try: return unpad(ct,16) except: continue`

Putting the final script in solve.py

```
from pwn import remote
from base64 import b64encode, b64decode
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
from random import seed, randint
import re
HOST, PORT = "challenge.rgbsec.xyz", 34567
REM = remote(HOST, PORT)
CHALL = b64decode(REM.recvline().strip())
def rand_block(byte):
"""random block for given seed byte"""
seed(byte)
return bytes([randint(0,255) for _ in range(16) ])
REM.recvuntil(b'\n>')
def decrypt(ct):
ct_orig = ct
for i in range(256):
ct = ct_orig
for _ in range(128):
ct = AES.new(rand_block(bytes([i])),1).decrypt(ct)
try:
return unpad(ct,16)
except:
continue
REM.sendline(b'3')
REM.sendline(b64encode(decrypt(CHALL)))
print(REM.recvregex(b'rgbCTF{.*}').decode())
```

```
nc pwnable.org 10001
```

task.py reads (trimming most part)

```
#!/usr/bin/python2
import os,random,sys,string
from hashlib import sha256
from struct import pack, unpack
import SocketServer
from Crypto.Cipher import ARC4
from flag import flag
K = 64
def gen():
from Crypto.Util.number import getStrongPrime
e = 65537
Ns = []
for i in range(K):
p = getStrongPrime(2048)
q = getStrongPrime(2048)
Ns.append(p*q)
return e,Ns
e,Ns = 65537,#list trimmed[...]
class Task(SocketServer.BaseRequestHandler):
def proof_of_work(self):
proof = ''.join([random.choice(string.ascii_letters+string.digits) for _ in xrange(20)])
digest = sha256(proof).hexdigest()
self.request.send("sha256(XXXX+%s) == %s\n" % (proof[4:],digest))
self.request.send('Give me XXXX:')
x = self.request.recv(10)
x = x.strip()
if len(x) != 4 or sha256(x+proof[4:]).hexdigest() != digest:
return False
return True
def handle(self):
if not self.proof_of_work():
return
self.request.settimeout(3)
try:
self.request.sendall("message: ")
msg = self.request.recv(0x40).strip()
ys = []
for i in range(K):
self.request.sendall("x%d: " % i)
x = int(self.request.recv(0x40).strip())
ys.append(pow(x,e,Ns[i]))
self.request.sendall("v: ")
v = int(self.request.recv(0x40).strip())
key = sha256(msg).digest()[:16]
E = ARC4.new(key)
cur = v
for i in range(K):
pt = (ys[i]^cur)%(1<<64)
ct = unpack('Q', E.encrypt(pack('Q',pt)))[0]
cur = ct
if cur == v:
self.request.sendall("%s\n" % flag)
self.request.sendall("fin\n")
finally:
self.request.close()
class ThreadedServer(SocketServer.ThreadingMixIn, SocketServer.TCPServer):
pass
if __name__ == "__main__":
HOST, PORT = '0.0.0.0', 10001
server = ThreadedServer((HOST, PORT), Task)
server.allow_reuse_address = True
server.serve_forever()
```

The first part is obviously proof of work, in which we have to find 4 bytes `XXXX`

such that
`sha256(XXXX + 16-char-val) = sha256_hash`

for provided `16-char-val`

postfix and `sha256_hash`

Which is easy to solve

using permutations may not always work (in case of repeated characters), earlier I used combinations_with_replacement which had some weird issues which I could not debug ```python from hashlib import sha256 import string from itertools import permutations as take CHARSET_SHA = string.printable[:62].encode() #0-9a-zA-Z as in challenge

def pow_sha(postfix, hash_val): for prefix in take(CHARSET_SHA, 4): prefix_bytes = bytes(prefix) shaa = sha256(prefix_bytes+postfix).hexdigest() if shaa == SHA_256_HASH: return prefix_bytes

HOST, PORT = “pwnable.org”, 10001 REM = remote(HOST, PORT) SHA_CHALL = REM.recvuntil(b’XXXX:’) #print(SHA_CHALL.decode()) SHA_256_HASH = re.search(b”[0-9a-f]{64}”,SHA_CHALL).group(0).decode() POSTFIX_STR = re.search(b”[0-9a-zA-Z]{16}”,SHA_CHALL).group(0) PREFIX_CHALL = pow_sha(POSTFIX_STR, SHA_256_HASH) REM.send(PREFIX_CHALL)

```
Now comes the main part of the challenge, in which we have to provide 64 `xi` values a message `msg` and a value `v`
The `msg` is sha256 hashed and first 16 bytes are taken to form the `key` for `ARC4`
The value `v` is XORed with last 64 bits of `pow(x[i], e, Ns[i])` and then `ARC4` encrypted. The goal is to produce the final value equal to the input `v`.
Since `ARC4` is simply a stream cipher, and encryption is just XORing the plaintext with a keystream, our final value `cur` is essentially `v^ys[0]^...ys[63]^xors[0]^xors[1]...^xors[63]`, where `ys[0..63]` are the last 64 bits of the respective `y[0..63]` and `xors[0]^xors[1]...^xors[63]` part is essentially dependent on `key` and an invariant for a given `key`, lets call it `invariant(key)` (bye bye `ARC4`).
All we need to do is to find `x[0..63]` such that `ys[0]^ys[1]...^ys[63] == invariant(key)` and we will have `cur==v` for all `v` as a consequence.
```python
from Crypto.Cipher import ARC4
from hashlib import sha256
from struct import pack, unpack
def encrypt_64(v,key,y):
E = ARC4.new(key)
cur = v
for i in range(64):
pt = (cur^y[i])%(1<<64)
ct = unpack('Q',E.encrypt(pack('Q',pt)))[0]
cur = ct
return cur
def invariant(key):
key_val = sha256(key).digest()[:16]
return encrypt_64(0,key_val,[0 for i in range(64)])
print(invariant(b'aaa'))
# 911494890333775973
```

One could simply put `x[i]`

as some value such that `ys[i] == invariant`

and all other `xs == 0`

but only if one could solve ANY of the RSA by factoring 4096 bit `Ns`

, which is obviously not feasible!

Not knowing much linear algebra, I found this stackexchange post and this showing all that needs to be done is to have a set of 64 64-bit vectors, and we can represent any 64 bit value using xor of a subset of the vectors. Taking the corresponding `ys`

for `xs = 2`

for all `i`

, and solving the subset for given invariant, we will only set `x[i] = 2`

in the `i`

in subset else `x[i]=0`

(to have no effect).

Sagemath ftw! CoCalc for the poor

```
#Ns not shown here
last_64 = [pow(2,e,i)%(1<<64) for i in Ns]
invariant = 911494890333775973 #for msg = b'aaa'
I = GF(2**64)
last_64_mat = [ list(map(int, bin(i)[2:].zfill(64))) for i in last_64 ]
mat = matrix(I,last_64_mat)
invariant_vec = list(map(int, bin(invariant)[2:].zfill(64)))
invariant_vec = matrix(I,invariant_vec)
op = mat.solve_left(invariant_vec)
print(op[0])
```

Awesome! we have
`(0, 1, 0, 1, 0, 1, 0, 0, 0, 1, 1, 0, 0, 0, 1, 1, 0, 1, 0, 1, 0, 0, 1, 1, 0, 0, 1, 1, 1, 0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 1, 0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 1, 0, 0, 1)`

as our output vector, we just have to return `xi`

as `2*op`

and we are done ;)

```
xs = (0, 1, 0, 1, 0, 1, 0, 0, 0, 1, 1, 0, 0, 0, 1, 1, 0, 1, 0, 1, 0, 0, 1, 1, 0, 0, 1, 1, 1, 0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 1, 0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 1, 0, 0, 1)
xs = [2*i for i in xs]
REM.recv()
REM.send(b'aaa') #message
for i in range(64):
REM.send(str(xs[i]).encode()) #xi's
REM.recv()
REM.send(b'0') #v any v would do the job ;)
REM.recv()
#flag{babbbcbdbebfbgbhbibjbkblbmbnbobpbqbrbsbtbubvbwbxby}
```

Unorganized code in files solve.py, part2.sage and test.py

```
Everyone's favorite guess god Tux just sent me a flag that he somehow encrypted with a color wheel!
I don't even know where to start, the wheel looks more like a clock than a cipher... can you help me crack the code?
```

- ciphertext.jpg “Text” XD

Lets think like a clock, and start numbering colors from `0-11`

And if we map the corresponding numbers, we get

```
86 90 81 87 a3 49 99 43 97 97 41 92 49 7b 41 97 7b 44 92 7b 44 96 98 a5
```

Now, we know that the flag begins with the prefix `flag{`

, which helps us easily guess what it is, since `'f'`

and `'l'`

differ by `4`

, here the ciphertext also differs by `4`

i.e `90-86`

. Also, `'l'`

and `'a'`

differ by 11, which confirms, that it is base 12 encoding.

Voila, here we go

```
EXTRACTED = '86 90 81 87 a3 49 99 43 97 97 41 92 49 7b 41 97 7b 44 92 7b 44 96 98 a5'
flag = ''.join([chr(int(i,12)) for i in EXTRACTED.split()])
print(flag)
```

```
Only n00bz use 2048-bit RSA. True gamers use keys that are at least 4k bits long, no matter how many primes it takes...
```

4k-rsa-public-key.txt which contains a `n, e, c`

triple

Seems like there are a lot of primes in the factorization of `n`

, since the factorization process is influenced directly by the size of prime factors and not the size of the number being factored itself, it should be fairly doable by alpetron.ar

It took about half an hour to factor, one may engage to other activities or alternatively try if the factors are available on factordb.

Anyways, once finished factoring, alpetron produces both the factors and the Euler’s totient `phi`

which will be used to compute `d`

```
d = pow(e,-1,phi) # on python3.8
# or gmpy2.invert(e,phi)
m = pow(c,d,n)
print(bytes.fromhex(hex(m)[2:]).decode())
```

And hurray, we have our flag

```
The aliens are at it again! We've discovered that their communications are in base 512 and have transcribed them in base 10. However, it seems like they used XOR encryption twice with two different keys! We do have some information:
* This alien language consists of words delimitated by the character represented as 481
* The two keys appear to be of length 21 and 19
* The value of each character in these keys does not exceed 255
Find these two keys for me; concatenate their ASCII encodings and wrap it in the flag format.
```

- encrypted.txt (HUGE number of characters)

- Hint 1, the delimitated character in plaintext is represented as 481. How does it help? Because in any language delimiter of words (space in our case) is the most frequent character (although we cant be sure about the aliens, what if they have really long words??), we can assume that the plaintext character 481 is the most frequent.
- Hint 2 states that it is XORed twice, once with the key of length 21 and again by length of key 19. If one ponders more, the two keys would act as a combined key of length of LCM of the two lengths 19 and 21, which is 21x19 = 399. And given the huge number of words (1100151), we can recover the key atleast pretty accurately.

The plaintexts at a difference of keylength are always encrypted by the same key letter, so splitting the ciphertext in keylength number of chunks, we can get a whole chunk, which has been encrypted by the same key byte. And since the character frequency of each chunk would resemble the character frequency distribution of the whole text owing to the huge size of ciphertext.

Just find the character with maximum frequency in each chunk, that would be 481 XOR key, XOR it with 481, to get the corresponding compound key character

```
with open('encrypted.txt','r') as encrypted:
data = list(map(int, encrypted.readlines()))
key = bytearray()
for i in range(21*19):
data_slice = data[i::21*19]
key_char_val = 481 ^ max(data_slice, key=data_slice.count)
key.append(key_char_val)
print(key)
```

It takes a couple of seconds to find the compound key

```
key = bytearray(b'7G\x1a\x00x\x00l\x17X];9\x00Gj\x007Y\x013\x12\x00\x00-\x06\x14Vo\x1a\x0clnSn\x06]Ej7@\x04U7\x06AP\x17[;+Y\x06\x00\x12YC\x00++\x00\x073S[PB]CjnA7G7W\x04-A\x1cl7\x01_\x05X]\x16l\x16\x00\x00\x00\x00\x02j9E\x1a\x06+j[W\\\x08\x0clC\x06xA7E]l+\x0e\x02-\x00G<XZ\x089Y\x06-GO\x04j+\x1c[l9\x04AVD1\x0ck]S7G\x1a\x02\x12j+\x1c[ljURB[\x10\x00Y\x013\x12\x00\x02GlS]l+]\x00<V_\x16jEj7@\x04W]l\x06[\x14jjG\x0b\x031\x02nC\x00++\x00\x05Y9\x1c[Al\x12\x06<D\x06W\x00W\x04-A\x1cn]k\x18\x0e[lG\x00D\x051\x107\x02j9E\x1a\x04A\x00\x1c\\_9]\x00\x11\x03IQ\x00E]l+\x0e\x00Gj\x007[kYU\x0b\x03\x1cWx\x04j+\x1c[nSn\x06]G\x00]\x07\x0fV\x06W-\x02\x12j+\x1cY\x06\x00\x12YAjAl\x0b\x04\x02\x027\x02GlS]nA7G7UnG\x06\x17o\x06P3W]l\x06[\x16\x00\x00\x00\x00\x00\x00S\x02\x11\x05\x1a;7\x05Y9\x1c[C\x06xA7G7\x06l\x05\x01\x1cQ+n]k\x18\x0eY\x06-GO\x06\x00A[Po\x08U-\x04A\x00\x1c\\]S')
```

Now, we have the XOR of key1 and key2, we need to recover key1 and key2 from it. We have all possible bytes, of key1 XORed with key2 and vice-versa.

First thought, Z3 go brrrrrr….

Although Z3 produces a feasible solution(model), a quick stackOverflow post gave me the implementation of finding n models.

```
from z3 import *
def get_models(F, M):
"""
Returns a list of M models (if possible) from the list of constraints F
"""
result = []
s = Solver()
s.add(F)
while len(result) < M and s.check() == sat:
m = s.model()
result.append(m)
# Create a new constraint the blocks the current model
block = []
for d in m:
# d is a declaration
if d.arity() > 0:
raise Z3Exception("uninterpreted functions are not supported")
# create a constant from declaration
c = d()
if is_array(c) or c.sort().kind() == Z3_UNINTERPRETED_SORT:
raise Z3Exception("arrays and uninterpreted sorts are not supported")
block.append(c != m[d])
s.add(Or(block))
return result
```

All we need to do is to form constraints and checking if the generated model produces ASCII printable strings.

```
key1 = [z3.BitVec("k1{}".format(i),8) for i in range(21)]
key2 = [z3.BitVec("k2{}".format(i),8) for i in range(19)]
F = []
for i in range(19*21):
F.append(key1[i%21]^key2[i%19]==key[i])
# Valid flag characters
VALID_CHARS = string.printable[0:62]+"_,.'?!@$<>*:-]*\\"
for model in get_models(F,256):
# 256 models to get all possible relative strings
KEY1 = "".join( chr(model[key1[i]].as_long()) for i in range(21))
KEY2 = "".join( chr(model[key2[i]].as_long()) for i in range(19))
flag = KEY1+KEY2
if all(i in VALID_CHARS for i in flag):
print(flag)
```

Turns out there was only one model with all characters printable and which is our flag