Lesson 10.2: Z3 and SMT for Crypto
Many crypto challenges do not need advanced math. They are a pile of constraints on bits and numbers, such as “find the seed so that the first three keystream bytes equal these values” or “find x and y satisfying this modular system”. Z3 is an SMT solver. You describe the constraints in Python and it finds a solution. After this lesson you can model a custom cipher and a modular system in Z3, and use it to recover the seed of a home-made PRNG and get the flag.
Known plaintext gives keystream bytes, each byte becomes a Z3 constraint on the symbolic xorshift, and the model returns the seed.
| Part 10 (Crypto in Practice) | Time: about 60 minutes | Difficulty: medium |
Prerequisites: Lesson 1.1 (modular arithmetic), Lesson 3.1 (XOR), and Lessons 9.1 and 9.2 (PRNG, state recovery) are strongly recommended, since this lesson is another way to break a PRNG.
Tools: Python 3 + z3-solver (pip install z3-solver). This lesson ran on z3 4.13.
Goals
By the end of this lesson you understand what an SMT solver is and when it is worth using instead of designing an algorithm yourself. You can choose the right variable type: BitVec for bit constraints, Int for integers and modular arithmetic. You can model a bit shift, an XOR and a modular system in Z3. You can use Z3 to recover the seed of a custom xorshift PRNG from known plaintext and solve for the flag.
1. Theory
1.1. SAT, SMT and Z3
A SAT solver decides whether there is an assignment of true/false to the boolean variables that makes a logical formula true. SMT (Satisfiability Modulo Theories) extends SAT. Instead of only boolean variables, it works directly with theories such as integers, reals, arrays and bit-vectors (fixed-length bit vectors). You write constraints in the language of the problem and the solver finds the solution.
Z3 is Microsoft Research’s SMT solver, and it has a compact Python binding. The workflow is always three steps: create variables, add constraints (add), call check(). If it returns sat (satisfiable), read the solution from model(). If it returns unsat, there is no solution.
When does Z3 pay off in crypto CTFs? When the problem is “satisfy a pile of constraints” and not “invert a smooth function”. Typical cases: recovering PRNG state from output, breaking a custom cipher made only of bit operations, solving modular systems, and finding an input that satisfies an odd constraint that is messy to write by hand.
Know one limit up front. Z3 does not break strong crypto. Do not throw RSA-2048 or AES at it. It is strong when the solution space is tightly constrained by linear or simple bit operations, and weak against heavy non-linearity such as large multiplication or well-diffusing S-boxes.
1.2. BitVec or Int, choosing correctly is half the work
Two variable types are used most:
BitVec("x", n): an n-bit integer. Operations wrap around modulo 2^n automatically, just like machine integers. Use it for anything involving bits: XOR, AND, left and right shifts, overflow. This is the type to use when modeling a PRNG or a cipher built from bit operations.Int("x"): a mathematical integer with no size limit. Use it for modular equations, inequalities and ordinary arithmetic.
Remember one trap right away. On a BitVec, Python’s right shift >> is not a logical right shift. In Z3, >> on a BitVec is an arithmetic shift (it keeps the sign). To get an unsigned logical right shift, as >> does on unsigned numbers in C, you must use the function LShR. Mixing these up is the number one source of bugs when modeling a PRNG.
1.3. Modeling a custom cipher
The core idea is to rewrite the challenge’s algorithm, replacing concrete values with Z3 variables. The original algorithm uses ordinary Python numbers, and the symbolic version uses BitVec. Then we feed in everything we know (for example, we know the plaintext starts with flag{, so we know the first keystream bytes) and ask Z3 to find the secret (the seed).
The key is known plaintext. If ct = pt XOR keystream and we know pt at some positions, we know the keystream there. The keystream is generated from the seed by a formula, so this gives constraints tight enough to pin down the seed.
2. Demo
2.1. Warm-up: solving a modular system
Before breaking a PRNG, get used to the syntax with a two-unknown system modulo a prime. Given the system (p = 1000003):
1
2
7x + 3y ≡ 346631 (mod p)
2x + 5y ≡ 274794 (mod p)
1
2
3
4
5
6
7
8
9
10
11
12
13
# modeq.py - solve a modular system of equations with Z3
from z3 import Int, Solver, sat
p = 1000003
s = Solver()
x, y = Int("x"), Int("y")
s.add(x >= 0, x < p, y >= 0, y < p) # bound the solution domain
s.add((7*x + 3*y) % p == 346631)
s.add((2*x + 5*y) % p == 274794)
print(s.check())
if s.check() == sat:
m = s.model()
print("x =", m[x], " y =", m[y])
Output:
1
2
sat
x = 31337 y = 42424
This system can be solved by hand too (invert a 2x2 matrix mod p), but the point is that we do not need to. Describe the constraints correctly and Z3 does the rest. When the system gets larger or has extra constraints such as “x lies in the printable byte range”, solving by hand gets hard and Z3 shows its value.
2.2. Main lab: breaking a custom xorshift PRNG
This is a very common kind of challenge. The task gives a home-made stream cipher whose keystream comes from a 32-bit xorshift PRNG with a secret seed. The flag is XORed with the keystream. We only know that the flag starts with flag{. The job is to recover the seed and decrypt everything.
The PRNG and cipher (this is the challenge, used to generate the ciphertext):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# gen.py - generate the custom-PRNG stream cipher challenge
MASK = 0xFFFFFFFF
def xorshift32(x):
x ^= (x << 13) & MASK
x ^= (x >> 17)
x ^= (x << 5) & MASK
return x & MASK
def encrypt(pt, seed):
state = seed
out = bytearray()
for b in pt:
state = xorshift32(state)
ks = (state >> 24) & 0xFF # take the high byte of state as the keystream byte
out.append(b ^ ks)
return bytes(out)
Running with seed = 0xC0FFEE42 gives this ciphertext (hex):
1
fd15bc05ba11cf45476d6fd97c0b8c73c800eb2fea47975deac898e478b7d783f432f07befca0f
Now the interesting part. We do not have the seed. But we know the first 5 plaintext bytes are flag{, so we know the first 5 keystream bytes. Each keystream byte is the high byte of state after one xorshift step. We write a symbolic version of xorshift and let Z3 find the seed:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# solve.py - use Z3 to recover the seed, then decrypt everything
from z3 import BitVec, BitVecVal, LShR, Solver, sat
MASK = 0xFFFFFFFF
def xorshift32(x): # real version, to rebuild the keystream once we have the seed
x ^= (x << 13) & MASK
x ^= (x >> 17)
x ^= (x << 5) & MASK
return x & MASK
def decrypt(ct, seed):
state = seed
out = bytearray()
for b in ct:
state = xorshift32(state)
out.append(b ^ ((state >> 24) & 0xFF))
return bytes(out)
def xorshift32_sym(x): # symbolic version: identical, but uses LShR for the right shift
x = x ^ (x << 13)
x = x ^ LShR(x, 17) # do NOT use >> (arithmetic shift) here
x = x ^ (x << 5)
return x
def recover_seed(ct, known_prefix):
s = Solver()
seed = BitVec("seed", 32)
state = seed
for i, pt_byte in enumerate(known_prefix):
state = xorshift32_sym(state)
ks = pt_byte ^ ct[i] # keystream byte derived from the known plaintext
s.add(LShR(state, 24) == BitVecVal(ks, 32))
assert s.check() == sat, "seed not found"
return s.model()[seed].as_long()
ct = bytes.fromhex("fd15bc05ba11cf45476d6fd97c0b8c73c800eb2fea47975deac898e478b7d783f432f07befca0f")
seed = recover_seed(ct, b"flag{")
print("seed found :", hex(seed))
print("flag :", decrypt(ct, seed).decode())
Output:
1
2
seed found : 0xc0ffee42
flag : flag{z3_cr4ck5_cu5t0m_prng_1n_0ne_sh0t}
With only 5 bytes of known plaintext, which is 40 bits of information constraining a 32-bit seed, Z3 pins down the exact seed 0xC0FFEE42 almost instantly. Then we rebuild the keystream and decrypt the part of the flag we did not know. We never had to invert xorshift by hand (that can be done but is messy). We only had to describe each step correctly.
Two points to take from this. First, the symbolic version must follow the original operation by operation, especially LShR for the unsigned right shift. Second, you need enough constraints for the solution to be unique. If you give only 1 byte of known plaintext (8 bits) for a 32-bit seed, Z3 may return a different seed that also satisfies the constraints, and the decrypted flag will be wrong. Give enough leading bytes to fix the solution.
3. Lab
- Task:
gen.pygenerates the ciphertext (already provided inct.hex), andsolve.pyis the Z3 solver. Runpython3 solve.pyfrom the lab directory. You should see the seed0xc0ffee42and the flagflag{z3_cr4ck5_cu5t0m_prng_1n_0ne_sh0t}. - Variant to practice: change
gen.pyso that the keystream is the low bytestate & 0xFFinstead of the high byte, change the seed, and then editsolve.pyto match. The goal is to understand which line of the model corresponds to which line of the real PRNG. - Harder challenge: reduce the known plaintext to 1 byte, observe whether Z3 returns a different seed, and whether the decrypted flag is still correct. Then add the constraint “every plaintext byte must be printable” (
0x20 <= b < 0x7f) to pin the correct solution again. - Hints: (1) write the symbolic function by copying the real function and replacing
>>withLShR; (2) remember that(x << k)on a BitVec wraps modulo 2^n by itself, so& MASKis not needed; (3) each known plaintext byte is a constraintLShR(state, 24) == ks. - Done when: it prints the correct seed and a readable flag.
4. Key takeaways
- Z3 in three steps: create variables,
addconstraints,check(), then readmodel(). BitVecfor bits (XOR, shifts, overflow),Intfor modular and ordinary arithmetic.- An unsigned right shift on a BitVec must use
LShR, not>>. - Breaking a PRNG with Z3: copy the algorithm, make the variables symbolic, and add known plaintext as constraints.
- Give enough constraints for a unique solution. With too few, Z3 returns a wrong solution and still says
sat. - Z3 is strong on bits and linear constraints, and cannot handle strong crypto (RSA, AES with all rounds).
5. Common pitfalls
- Using
>>for a right shift on a BitVec and modeling it wrong without any warning. Z3 still runs and still sayssat, but gives a wrong seed. Always useLShRfor unsigned values. - Forgetting to bound the domain with
Int. A modular equation has many solutions, and without0 <= x < pZ3 returns a solution outside the range you want. - Giving too few constraints.
satdoes not mean correct, only that a solution exists. That solution may not be the real seed if the problem is not tightly pinned. - Throwing a problem that is too heavy at Z3 and waiting forever. If there is a multiplication of two large variables or a strongly diffusing S-box, Z3 will hang. That is a sign the problem does not suit Z3 and you should change approach.
- Calling
check()many times wastes time. Eachs.check()runs the solver again. Call it once, store the result, and then take the model. - Forgetting
BitVecValwhen comparing a BitVec with a Python constant in some contexts. Z3 usually coerces the type, but writeBitVecVal(k, n)explicitly to be safe when comparing against an n-bit BitVec.
6. Further reading
- The official Z3 Python tutorial on the Z3 site (the ericpony z3py-tutorial), especially the BitVec and solver parts.
- Lesson 9.2 in this series (recovering MT19937 and LCG): compare breaking a PRNG with algebra against breaking it with Z3, two routes to the same goal.
- The CryptoHack “Symmetric” section and some “Misc” challenges have custom cipher problems suited to practicing Z3.
- CTF writeups tagged “z3”: search for “z3 ctf crypto writeup” to see many modeling styles, from breaking checksums to solving keygens.
