Writeup — Sudokrypt (Kaspersky CTF 2026)
Category: crypto
Value: 53 points
Link: https://ctf.kaspersky.com/challenges/6
Date: 08/2026
FLAG: kaspersky{D4mn_1m_s0_c00l!!_1_c4n_d0_sud0ku_n0w_st4cy_t0t4lly_g01ng_t0_pr0m_w1th_m3}
---
## Summary
The service exposes four operations, of which only one really counts: encrypt one block. It's a chosen-plaintext encryption oracle with 96 queries available, each query a 16-byte plaintext with the corresponding ciphertext in reply. The flag is obtained with peek at Stacy's homework, which however doesn't return it in plain text but encrypted with the same scheme, so you have to reconstruct enough of the cipher to be able to invert it.
The scheme has four layers: inner_word, rotation, public_wrapper, diffuse_words. The last two are completely public and the author supplies the inverse functions directly, so they protect nothing. The interesting part is inner_word, which depends on a pseudo-random stream generated by an algebraic structure over GF(4093) with 56 spectral nodes. That stream isn't random: it's a linear recurrent sequence of order 56, so with enough consecutive samples the future behaviour is deterministic and is reconstructed with linear algebra
Chain: 96 chosen plaintexts → undiffuse_words and public_wrapper_inv to strip the public layers → recovery of the relative rotations from the q-labels → brute force of R0 and symbol_inv[0] over 256 combinations → overdetermined linear system over GF(4093) for the characteristic polynomial → 56 roots, that is the spectral nodes → Vandermonde for the lane coefficients → recovery of symbol_inv and q_perm → fold_for_flag doesn't destroy the spectral structure, so the post-fold state is computable → flag streams predicted → block-by-block inversion → PKCS#7 removed → flag.
---
## additional tools and methodology
an llm was used during the session: for interpreting raw output, detailed explanation of technical concepts, suggesting unexplored vectors when stuck and writing the solver. the execution, the operational decisions and the plaintext design were mine. the writeup was written by me and later cleaned up with the same tool.
---
## Preface
this one is classified as an easy challenge and in a sense it is, but not because there's little maths in it. it is because the three structural weaknesses can all be recognised by looking at the supplied code, and once you've seen them the rest is mechanical. the problem is seeing them
the part that took the most iterations wasn't the maths but the plaintext design: the first solvers were failing because they wasted queries, and the right way to spend the 96 available is what decides whether the challenge is solvable or not. the two operational mistakes (tar glob and TCP buffering) are at the bottom, and they're the kind of thing that makes you lose half an hour on a problem that has nothing to do with crypto.
---
## Phase 1 — The service
The server creates the instance like this:
core = SudoKrypt(
os.environ.get("CHALLENGE_SEED", '0'),
os.environ.get("FLAG", 'kaspersky{testflag}'),
int(os.environ.get("QUERY_LIMIT", "96")),
)
so the seed is presumably the default one, CHALLENGE_SEED = "0", but there's also a session_nonce = os.urandom(16), and every session generates a new instance.
the constraint that counts:
def encrypt_block(self, plaintext):
if self.flag_taken:
raise RuntimeError("encrypt oracle is closed after encrypted flag")
...
self.remaining -= 1
return self.crypt_block(plaintext)
96 queries, and they all have to be made BEFORE asking for the flag. afterwards, the oracle is closed.
the flag instead is encrypted by:
def encrypt_flag(self):
self.flag_taken = True
self.prng.fold_for_flag()
...
that fold_for_flag is the distinction that will matter at the end.
the overall structure of the encryption:
plaintext
|
v
inner_word()
|
v
rotation
|
v
public_wrapper()
|
v
diffuse_words()
|
v
ciphertext
---
## Phase 2 — Stripping the public layers
The encryption produces 16 words of 16 bits and then applies diffuse_words(). But diffuse_words is linear and undiffuse_words() exists explicitly in the code. Same story for public_wrapper, which is a 4-round Feistel and has its public_wrapper_inv already written.
so from the ciphertext you get to the inner words in two steps:
def decrypt_public_layer(ciphertext):
words = [
int.from_bytes(ciphertext[i:i + 2], "big")
for i in range(0, 32, 2)
]
words = undiffuse_words(words)
return [
public_wrapper_inv(word, rank)
for rank, word in enumerate(words)
]
this is the fundamental step: after these two inversions neither the public Feistel nor the diffusion exists any more as a problem. only inner_word is left.
---
## Phase 3 — inner_word and the structure of the word
The interesting function is:
def inner_word(self, byte, stream_value):
it takes a byte and a stream_value and builds a 16-bit word. the byte is decomposed like this:
q = byte >> 4
symbol = ((byte & 15) - q) & 15
that is:
byte = q << 4 | (symbol + q)
q = high nibble
symbol = low nibble - q mod 16
this is very useful because we can choose q and symbol independently.
the internal word is:
return (
self.q_perm[q] << 12
) | (
row_field << 8
) | (
col_field << 4
) | check
that is:
15 12 11 8 7 4 3 0
+--------------+------------+------------+-----------+
| q_label | row_field | col_field | check |
+--------------+------------+------------+-----------+
after inverting public_wrapper we get exactly these four fields.
---
## Phase 4 — Recovering stream_value
inner_word uses the stream like this:
high = stream_value >> 8
mask = (stream_value >> 4) & 15
low = stream_value & 15
so the stream is a 12-bit value, high|mask|low over 8+4+4. and then:
row_code = SBOX[low]
base_col = (self.symbol_inv[symbol] - row_code) & 15
row_field = (row_code + mask) & 15
col_field = (base_col + 2 * mask) & 15
substituting base_col inside col_field:
col_field = sinv - row_code + 2(row_field - row_code)
= sinv + 2*row_field - 3*row_code
so:
3*row_code = sinv + 2*row_field - col_field
modulo 16 the inverse of 3 is 11, because 3 * 11 = 33 = 1 mod 16. so:
row_code = 11 * (sinv + 2*row_field - col_field) mod 16
and from there low = SBOX_INV[row_code], mask = row_field - row_code, base_col = sinv - row_code, high = check ^ SBOX[...].
conclusion: from a ciphertext word we can reconstruct the stream_value, provided we know symbol_inv[symbol].
---
## Phase 5 — The two secrets: symbol_inv and the rotation
we don't know symbol_inv. in the constructor there is:
self.symbol_perm = symbol_permutation_from_model(...)
self.symbol_inv = inverse_permutation(...)
a secret permutation symbol → internal symbol. but we choose the plaintext: if we impose symbol = 0 on a byte, the only unknown value becomes symbol_inv[0], which has 16 possibilities. brute-forceable.
the second secret is the rotation. after creating the 16 words:
rotation = block_rotation(values, block_number)
ranked = [words[(rank + rotation) & 15] for rank in range(16)]
so the ciphertext doesn't keep the order of the 16 bytes. but q_label = q_perm[q] and we choose q: by sending q = 0,1,2,...,15 the 16 q_labels in the ciphertext are simply a permutation of q_perm[0..15], and the same permutation holds in every block.
calling R0 the rotation of block 0, in block b we have rotation_b = R0 + delta_b mod 16. comparing the array of q_labels of block b with that of block 0 recovers delta_b without knowing either q_perm or R0. all the relative rotations, for free. only R0 is left, another 16 possibilities.
16 x 16 = 256 attempts in total. nothing.
---
## Phase 6 — The spectral generator, the heart of the challenge
In the constructor:
self.nodes, self.coefficients = make_spectral_model(self.instance_key)
self.prng = SpectrumGenerator(self.nodes, self.coefficients)
make_spectral_model creates SPECTRUM_SIZE = 56 nodes. the stream of a lane is:
result = [sum(row) % FIELD ...]
and after every block row[j] *= node[j]. so for one lane:
S[n] = Σ c_j * node_j^n
that is a linear recurrent sequence of order 56 over GF(4093), with λ_j = node_j.
a sequence of that form satisfies a linear recurrence whose characteristic polynomial is Π(x - λ_j), of degree 56. there therefore exist coefficients C1...C56 such that:
S_n + C1*S_(n-1) + C2*S_(n-2) + ... + C56*S_(n-56) = 0 (mod 4093)
and this is the real vulnerability: the "cryptographic stream" isn't unpredictable if you have enough consecutive samples.
the number of queries is practically a hint. 56 parameters, 96 queries granted, so 96 - 56 = 40 verification equations for every lane. and the lanes are 16, independent but with the same set of nodes. the redundancy makes the recovery very robust.
---
## Phase 7 — How to spend the 96 queries
This is the point that required the correction of the first solvers, and it's the real design part of the solution.
for the first 56 blocks we want all 16 lanes to have symbol = 0:
plaintext = bytes((q << 4) | q for q in range(16))
because symbol = low - q = q - q = 0. that way all the symbol_inv[symbol] values collapse onto symbol_inv[0], the only one to brute-force.
in the remaining 40 blocks you do something cleverer: lanes 1..15 stay on symbol 0, so they keep producing clean samples of the stream up to block 95, while lane 0 alone cycles through symbols 1,2,3,...,15,1,2,3,... and is used as a probe to recover symbol_inv[1] ... symbol_inv[15].
in the code it's one line:
sym = 0 if b < 56 else 1 + ((b - 56) % 15)
the 96 queries therefore do two jobs at once: 56 blocks to learn the spectrum, 40 blocks to keep observing it and simultaneously test the 15 secret symbols. that is exactly what makes it possible to stay inside the limit.
---
## Phase 8 — Recovering the polynomial and the nodes
For every candidate pair (R0, sinv0) the stream is reconstructed, for example lane 1's. if the candidate is correct the sequence S[0..95] satisfies a recurrence of order 56.
in the final solver I avoided Berlekamp-Massey and went with direct linear algebra. for n = 56..95 every sample gives an equation:
[C1,C2,...,C56] · [S_(n-1),...,S_(n-56)] = -S_n
all in GF(4093). that's 40 equations per lane, and with 15 clean lanes it makes 15 x 40 = 600 equations for only 56 coefficients. a heavily overdetermined system, much more solid than hoping a single sequence is enough.
with the polynomial x^56 + C1*x^55 + ... + C56 obtained, the nodes are its roots. FIELD = 4093 is prime, so it's enough to try x = 1..4092 and look for P(x) = 0. the nodes are exactly 56, and the spectrum is reconstructed.
knowing the nodes, the coefficients of every lane come out of a Vandermonde system:
S_0 = c0 + c1 + ... + c55
S_1 = c0*λ0 + c1*λ1 + ...
...
S_55 = ...
solved modulo 4093 for each of the 16 lanes. at this point coefficients[lane][node] is completely reconstructed.
---
## Phase 9 — Recovering the permutations
With the streams of blocks 0..95 now predictable, the two permutations fall.
for symbol_inv: on lane 0 we know which symbol we sent in every block from 56 onwards. for every x ∈ {0..15} we compute which stream would derive from that word and compare it with the predicted stream. the match is unique, and we get symbol_inv[1] ... symbol_inv[15]. inverting gives symbol_perm.
for q_perm: from the first block we know q = 0..15 and we observe the q_labels, but the order is rotated by R0. with R0 known:
qperm[q] = base[(q - R0) & 15]
and from there qinv.
at this point we have R0, q_perm, q_inv, symbol_perm, symbol_inv, nodes, coefficients. we can predict stream[lane][block] for any future block.
---
## Phase 10 — fold_for_flag
One problem remains: the flag isn't encrypted right after block 95, fold_for_flag() is called first:
def fold_for_flag(self):
for lane, row in enumerate(self.state):
for j, node in enumerate(self.nodes):
shift = 1 + (
self.coefficients[lane][j] * (lane + 1)
+ (j + 3) * (j + 7)
) % (FIELD - 1)
row[j] = row[j] * pow(node, shift, FIELD) % FIELD
before the fold, after 96 queries, row[j] = coeff[j] * node[j]^96. after the fold:
row[j] = coeff[j] * node[j]^96 * node[j]^shift
= coeff[j] * node[j]^(96 + shift)
the fold doesn't destroy the spectral structure, it only changes it by multiplying each component by a known power of the node. and the shift is computable, because it depends only on the coefficients we have already recovered. so for block t of the flag:
stream_lane(t) = Σ folded_coeff[lane][j] * node[j]^t
all the flag's streams are directly computable.
---
## Phase 11 — Decrypting the flag and removing the padding
For every block of the flag ciphertext:
ciphertext → undiffuse → public_wrapper_inv → inner words
then the rotation is computed:
rotation = (sum(stream) + 3*stream[0] + block_number) & 15
and inner_word is inverted using q_inv, symbol_perm and the stream. every byte is reconstructed as:
byte = (q << 4) | ((symbol + q) & 15)
and put back in its original position with plaintext_index = (rank + rotation) & 15.
the flag had been encrypted with padded = pkcs7_pad(self.flag), so at the end you strip the padding. in our case the recovered plaintext ends with a valid PKCS#7 padding, which is the final confirmation that the whole chain is coherent.
---
## Phase 12 — Solver
```python
import socket,sys
HOST="tcp.sasc.tf"; PORT=31415; P=4093; N=56
SBOX=(6,4,12,5,0,7,2,14,1,15,3,13,8,10,9,11)
SI=[0]*16
for i,v in enumerate(SBOX): SI[v]=i
ML=(0,1,5,9,13,3,7,11,15,2,6,10,14,4,8,12)
MR=(7,11,3,13,5,15,9,1,14,6,12,4,10,2,8,0)
def r4(v,a):
a&=3; v&=15
return v if not a else ((v<>(4-a)))&15
def r8(v,a):
a&=7; v&=255
return v if not a else ((v<>(8-a)))&255
def r16(v,a):
a&=15; v&=65535
return v if not a else ((v<>(16-a)))&65535
def ff(v,k,r):
x=(SBOX[v>>4]<<4)|SBOX[v&15]
x=(x+k+19*r)&255
return r8(x,r+1)^((v*0x3d)&255)
def winv(w,r):
l,h=w>>8,w&255
for rnd in range(3,-1,-1):
k=(0x53+r*0x29+rnd*0x47)&255
l,h=h^ff(l,k,rnd),l
return (l<<8)|h
def undiff(w):
f=[0]*16
f[15]=w[15]
for i in range(14,-1,-1):
f[i]=w[i]^r16(w[i+1],-MR[i])
z=[f[0]]
for i in range(1,16):
z.append(f[i]^r16(f[i-1],ML[i]))
return z
def inner(ct):
w=[int.from_bytes(ct[i:i+2],"big") for i in range(0,32,2)]
w=undiff(w)
return [winv(x,i) for i,x in enumerate(w)]
def fld(w):
return ((w>>12)&15,(w>>8)&15,(w>>4)&15,w&15)
def candidate(w,q,sinv):
_,rf,cf,ch=fld(w)
rc=(11*(sinv+2*rf-cf))&15
low=SI[rc]
mask=(rf-rc)&15
bc=(sinv-rc)&15
hi=ch^SBOX[(rc^r4(bc,1)^q^low)&15]
v=(hi<<8)|(mask<<4)|low
if v>=P:return None
return v
class Client:
def __init__(self):
self.s=socket.create_connection((HOST,PORT),15)
self.s.settimeout(30)
self.b=b""
def until(self,t):
while t not in self.b:
x=self.s.recv(4096)
if not x: raise RuntimeError("server closed")
self.b+=x
i=self.b.index(t)+len(t)
z=self.b[:i]
self.b=self.b[i:]
return z
def query(self,pt):
self.until(b"> ")
self.s.sendall(b"1\n")
self.until(b"plaintext hex> ")
self.s.sendall(pt.hex().encode()+b"\n")
z=self.until(b"queries left:")
h=z.split(b"ciphertext:",1)[1].split()[0]
return bytes.fromhex(h.decode())
def getflag(self):
self.until(b"> ")
self.s.sendall(b"2\n")
z=self.until(b"encrypted flag:")
while b"\n" not in self.b:
x=self.s.recv(4096)
if not x: raise RuntimeError("server closed")
self.b+=x
line,self.b=self.b.split(b"\n",1)
return bytes.fromhex(line.split()[0].decode())
def close(self):
self.s.close()
def gauss(A,b):
m=len(A)
n=len(A[0])
a=[[(x%P) for x in A[i]]+[b[i]%P] for i in range(m)]
row=0
piv=[]
for col in range(n):
k=None
for r in range(row,m):
if a[r][col]:
k=r
break
if k is None:
continue
a[row],a[k]=a[k],a[row]
iv=pow(a[row][col],P-2,P)
for j in range(col,n+1):
a[row][j]=a[row][j]*iv%P
for r in range(m):
if r!=row and a[r][col]:
q=a[r][col]
for j in range(col,n+1):
a[r][j]=(a[r][j]-q*a[row][j])%P
piv.append(col)
row+=1
if row==m:
break
if len(piv)>4)&15
high=sv>>8
rc=SBOX[low]
bc=(cf-2*mask)&15
if rf!=((rc+mask)&15):
raise RuntimeError(("row check",t,rank))
check=(
high
^ SBOX[
(rc^r4(bc,1)^q^low)&15
]
)
if check!=ch:
raise RuntimeError(("check field",t,rank))
sym=sperm[(rc+bc)&15]
out[(rank+R)&15]=(
(q<<4)
|((sym+q)&15)
)
plain.extend(out)
pad=plain[-1]
if not(1<=pad<=16):
raise RuntimeError(("padding",pad))
if plain[-pad:]!=bytes([pad])*pad:
raise RuntimeError("invalid padding")
plain=plain[:-pad]
print()
print("="*60)
print("FLAG =",plain.decode(errors="replace"))
print("="*60)
if __name__=="__main__":
try:
main()
except Exception as e:
print("[!] ERROR:",repr(e),file=sys.stderr)
sys.exit(1)
```
---
## Phase 13 — Mistakes encountered
Three problems during the solve, and not one of them was about crypto.
**file glob.** I had written:
tar -xzf *sudocrypt*.tar.gz
but there were two files in the directory, sudocrypt_...tar.gz and sudocrypt_... (1).tar.gz. the glob produced two arguments and tar misinterpreted the second. the solution is to pass the name explicitly:
tar -xzf 'sudocrypt_08ec969ff2e58b97 (1).tar.gz'
**TCP buffering.** the server sends the > prompt together with other data in the same recv(). a client that always looks for the prompt and throws away the bytes after it loses synchronisation. solved by keeping a buffer:
self.b += chunk
...
out = self.b[:i]
self.b = self.b[i:]
it's a pattern that holds for any interactive TCP exploit, not just for this challenge.
**first plaintext design.** the first idea tried to reconstruct the recurrence using blocks that were then modified too early, and the sequence got dirty. the correct design is the one in Phase 7: lane 0 as a probe for the symbols, lanes 1..15 always on symbol 0 to have a huge quantity of clean samples.
---
## Full chain
96 chosen plaintexts
|
v
+----------------+
| encryption |
| oracle |
+----------------+
|
v
ciphertext blocks
|
v
undiffuse_words()
|
v
public_wrapper_inv()
|
v
inner words
|
+------------+------------+
| |
v v
q-labels row/col/check
| |
v v
rotation deltas stream candidates
| |
+------------+------------+
|
v
recover R0 / sinv0
|
v
linear recurrence over GF(4093)
|
v
characteristic polynomial
|
v
56 spectral nodes
|
v
Vandermonde inversion
|
v
spectral coefficients
|
+---------+---------+
| |
v v
q permutation symbol permutation
| |
+---------+---------+
|
v
full model
|
v
fold_for_flag()
|
v
predict future streams
|
v
encrypted flag
|
v
inverse wrapper + inner
|
v
PKCS#7
|
v
FLAG
---
## Lessons learned
chosen plaintext plus a stream with linear structure plus enough samples means you can reconstruct the generator, even if the challenge calls it crypto. the point isn't the quantity of maths, it's recognising that a sequence of the form S_n = Σ c_j λ_j^n isn't random but deterministic.
when the author hands you the inverse functions of a layer, that layer isn't protection. public_wrapper_inv and undiffuse_words were in the code, and as soon as you use them all that's left is 16 words of 16 bits with four readable fields. half the challenge disappears like that.
the challenge parameters are a hint. FIELD = 4093, SPECTRUM_SIZE = 56 and QUERY_LIMIT = 96 are perfectly aligned to make the recovery possible: 56 unknowns, 40 extra verification equations, a prime field small enough to enumerate the roots by brute force. when the numbers line up that well it isn't generosity, it's the design of the intended solution.
the most elegant part of the solve is a single line, sym = 0 if b < 56 else 1 + ((b-56) % 15). it isn't brute force, it's using the same 96 queries for two purposes at once: learning the spectrum and testing the 15 secret symbols. and that is exactly what makes it possible to stay inside the limit
a secret that lives in a space of 16 values isn't a secret. symbol_inv[0] and R0 had 16 possibilities each, 256 combinations in total, verifiable in a loop. the fact that two secret permutations reduce to 256 attempts is what makes all the rest practicable.
fold_for_flag looks like a protection and isn't, because it doesn't destroy the spectral structure but multiplies each component by a known power of the node. once the decomposition is recovered, the post-fold state is directly computable. a transformation that preserves the very structure you're trying to hide hides nothing
---
## Tools used
Python 3 (entirely custom solver: raw sockets, Gaussian elimination over GF(4093), root finding by enumeration, Vandermonde system), tar, netcat for manual tests against the service