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
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 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
(_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
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
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
Vulnerability?
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
Modelling
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
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
Python1 line / static highlight
1
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
Python2 lines / static highlight
1
2
fori,vinenumerate(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.
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
Modelling permutation
Now what about the permutation? We can model it exactly how we would have calculated a permutation
Take the ith bit, put it P[i]th place in the output, just the way to deal with BitVectors vary
Python9 lines / static highlight
1
2
3
4
5
6
7
8
9
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
fori,vinenumerate(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])foriinrange(0,48,6)]
Modelling encryption
Finally after getting the required blocks to perform our symbolic encryption, we can model it
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.
Checking if our model is correct
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)
Python16 lines / static highlight
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
print("verifying our modelling")importrandomforiinrange(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)foriinrow]forrowinsus.subkeys]# BitVecVal as a symbolic constant value
forjinrange(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))assertsym_out.as_long()==real_outprint("success")
Adding input output points
Taking care of the _divide business, we will equate the 6-bit chunks of the output and our symbolic output for a given input
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
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
Moment of inspiration
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!)