Jafar - FCSC 2025
2025-08-12
Overview
Jafar is a SPN with 2 main aspect a round function $R$ and a middle part $M$. The Jafar Encryption can be simply decribed as $J = R \circ M \circ R$, where $M$ is the middle part and $R$ correspond to the 20 rounds of AddKey, Sbox and Permute. Since we are given only a limited amount of queries and that we have access to both encryption and decryption, boomerang attack comes to mind pretty quickly but in boomeran we need 2 encryptions and 2 decryptions…
Analysis
The first thing to do when we have a SBox is to steal Poustouflan’s tool and run it…
SBox is not linear.
However, these equations hold with probability 100.0%:
y7 = x4
y2 = x6
y7 ⊕ y2 = x6 ⊕ x4
where y = S(x).
This can be considered as a cryptographic weakness and can lead to linear cryptanalysis.
SBox is differential! For all x,
S(x)⊕129 = S(x⊕24)
S(x)⊕7 = S(x⊕74)
S(x)⊕134 = S(x⊕82)
Since we only have 3 queries, linear attack is unlickly (3 bits of linear is not enough). So let’s look into the differential attack.
Differential for the round function
When we have a differential for the SBox, it’s pretty common to just try to get a differential for the whole round.
A very lazy but fast way to do this without fancy MILP is realise that our differential for each byte is either 0, 24, 74 or 82. So since we have 16 bytes thats $4^{16} = 2^{32}$ possibilities. Let’s BF…
We can cut any differential path that end up having a non differential as the input of one of the SBox
delta = {0: 0, 24: 129, 74: 7, 82: 134}
pos = list(delta.keys())
for diff in product(pos, repeat=16):
state = list(diff)
good = True
for rnd in range(20):
for i in range(16):
if not state[i] in pos:
good = False
break
else:
state[i] = delta[state[i]]
if not good: break
state = Permute(state)
if not good: continue
print("GOOD :", diff, "|", state)
Eventually we find the differential pair:
full_diff_in = (0, 0, 0, 0, 0, 74, 0, 0, 0, 0, 24, 0, 0, 74, 0, 0)
full_diff_out = (0, 0, 74, 0, 0, 0, 0, 0, 82, 0, 0, 24, 0, 0, 0, 0)
Full Cipher
What we are going to do is then very similar to boomerang, our goal will be to deduce a pair cleartext/ciphertext $P, C$ without asking for it.
Let’s recall some of the properties so far:
- $\forall P: R(P + \Delta) = R(P) + \Delta'$
- $\forall P: R^{-1}(P + \Delta’) = R^{-1}(P) + \Delta$
- $\forall P, Q: M(P + Q) = M(P) + M(Q)$ as $M$ is just a multiplication in a Galois field so it’s linear
- $\forall P, Q: M^{-1}(P + Q) = M^{-1}(P) + M^{-1}(Q)$ and so is it’s inverse
Let’s now consider a fixed plaintext $p$ that we know, the only two first reasonable queries we might do is either $J(p + \Delta)$ or $J^{-1}(p + \Delta’)$, let’s start with the first (I think both work by symmetry…):
$$ C_1 = J(p + \Delta) = R(M(R(p + \Delta))) = R(M(R(p) + \Delta'))$$From there again, logically the only thing we can do here is decrypt… Decrypting the same thing really doest make sense so let’s do the only sensible thing :
$C_2 = J^{-1}(C_1 + \Delta’) = R^{-1}(M^{-1}(R^{-1}(C_1 + \Delta’))) = R^{-1}(M^{-1}(R^{-1}(C_1) + \Delta)) = R^{-1}(M^{-1}(R^{-1}(C_1)) + M^{-1}(\Delta))$
and :
$C_2 = R^{-1}(M^{-1}(R^{-1}(C_1 +\Delta’))) = R^{-1}(M^{-1}(R^{-1}(C_1) +\Delta)) = R^{-1}(M^{-1}(M(R(p) + \Delta’) +\Delta))$
and…..
$$ C_2 = R^{-1}(R(p) + \Delta' + M^{-1}(\Delta))$$You get the idea, by elimination we should probably do $J(C_2 + \Delta)$
$ C_3 = J(C_2 + \Delta) = R(M(R(C_2 + \Delta))) = R(M(R(C_2) + \Delta’)) = R(M(R(p) + \Delta’ + M^{-1}(\Delta) + \Delta’)) = R(M(R(p) + M^{-1}(\Delta)))$
And good grief finally :
$$ C_3 = R(M(R(p)) + \Delta) = R(M(R(p))) + \Delta' = J(p) + \Delta'$$And boom here it is then $(p, C_3 + \Delta’)$ is a valid plaintext/ciphertext that’s not been queried before…
Wrap-up and code
Very fun challenge, knowning about boomerang really helped, the proof is longer than it needs to be but very pleasing.
from pwn import remote, process
from Crypto.Util.number import *
from sage.all import *
from itertools import product
import os
from chall import *
def xor(a, b):
return bytes(k ^ l for k, l in zip(a, b))
delta = {0: 0, 24: 129, 74: 7, 82: 134}
pos = list(delta.keys())
for diff in product(pos, repeat=16):
state = list(diff)
good = True
for rnd in range(20):
for i in range(16):
if not state[i] in pos:
good = False
if rnd > 1: print(rnd)
break
else:
state[i] = delta[state[i]]
if not good: break
state = Permute(state)
if not good: continue
print("GOOD :", diff, "|", state)
#io = process(["python", "chall.py"])
io = remote("chall.fcsc.fr", 2153)
full_diff_in = (0, 0, 0, 0, 0, 74, 0, 0, 0, 0, 24, 0, 0, 74, 0, 0)
full_diff_out = (0, 0, 74, 0, 0, 0, 0, 0, 82, 0, 0, 24, 0, 0, 0, 0)
P = os.urandom(16)
####
io.recvuntil(b">>> ")
io.sendline(b"enc")
io.recvuntil(b">>> ")
io.sendline(xor(P, full_diff_in).hex().encode())
io.recvline()
C = bytes.fromhex(io.recvline().decode().strip())
####
io.recvuntil(b">>> ")
io.sendline(b"dec")
io.recvuntil(b">>> ")
io.sendline(xor(C, full_diff_out).hex().encode())
io.recvline()
A = bytes.fromhex(io.recvline().decode().strip())
####
io.recvuntil(b">>> ")
io.sendline(b"enc")
io.recvuntil(b">>> ")
io.sendline(xor(A, full_diff_in).hex().encode())
io.recvline()
B = bytes.fromhex(io.recvline().decode().strip())
###############
io.recvuntil(b">>> ")
io.sendline(P.hex().encode())
io.recvuntil(b">>> ")
io.sendline(xor(B, full_diff_out).hex().encode())
print(io.recvline())
print(io.recvline())