Share this
SAT Solvers for CTF - From Theory to Flags
by Reflare Research Team on Dec 9, 2025 5:53:22 PM
SAT solvers provide intelligent constraint-solving that can systematically explore massive search spaces. Once you learn to translate CTF problems into Boolean formulas, they become one of the most powerful tools available for competition problem-solving, and this translates into real capability development for on-the-job skills.
.jpg?width=1200&height=800&name=SAT%20Solvers%20for%20CTF%20-%20From%20Theory%20to%20Flags%20(1200).jpg)
Search spaces are infinite; correct answers are not.
Good CTFs Should Make You Think
If you've been doing CTFs for a while, you've probably encountered challenges where you've reverse-engineered the binary, understood the algorithm, and identified what the flag checker is doing, but the number of possible combinations makes brute forcing impractical. Or perhaps it's a crypto challenge where the cipher is mathematically weak but still computationally complex to break manually.
This is where SAT solvers become valuable.
What is SAT?
The Boolean Satisfiability Problem (SAT) is straightforward to state: given a Boolean formula, can you find values for the variables that make it true? Take (A OR B) AND (NOT A OR C). A SAT solver will determine that this is satisfiable and provide a solution such as A=true, B=true, C=true.
SAT is NP-complete, placing it among the most challenging classes of computational problems. However, modern SAT solvers are remarkably effective despite this theoretical complexity. Over decades of research into algorithms such as conflict-driven clause learning (CDCL), they can handle formulas with millions of variables. For CTF applications, this capacity is more than sufficient.
Basic Setup: Z3 for CTF
The most widely used SAT/SMT solver in CTF competitions is Z3 from Microsoft Research. SMT (Satisfiability Modulo Theories) extends basic SAT to handle not just Boolean logic but also integers, arrays, bit-vectors, and other data types. Install it with:
pip install z3-solver
Here's a common CTF use case. During reverse engineering, you encounter this flag checker:
bool check_flag(char *input) {
return (input[0] ^ input[1]) == 0x42 &&
(input[1] + input[2]) == 0xAE &&
(input[2] * 2) == input[3] + 10 &&
input[3] < 100;
}
Brute force? Painful. Z3? Easy.
from z3 import *
inp = [BitVec(f'input_{i}', 8) for i in range(4)]
s = Solver()
s.add(inp[0] ^ inp[1] == 0x42)
s.add(inp[1] + inp[2] == 0xAE)
s.add(inp[2] * 2 == inp[3] + 10)
s.add(inp[3] < 100)
# Add printable ASCII constraints
for i in range(4):
s.add(inp[i] >= 0x20)
s.add(inp[i] <= 0x7E)
if s.check() == sat:
m = s.model()
flag = ''.join(chr(m[inp[i]].as_long()) for i in range(4))
print(f"Flag: {flag}")
else:
print("No solution found!")
Z3 explores the constraint space and returns the solution. This pattern applies to any constraint-based flag checker, regardless of complexity.
Real CTF Example: Z3 for Complex Constraints
Consider a typical reversing challenge where the binary checks input character by character, with each check depending on previous characters. The constraints might look like this:
from z3 import *
flag = [BitVec(f'f{i}', 8) for i in range(20)]
s = Solver()
# Constraints from the binary
s.add(flag[0] == ord('f'))
s.add(flag[1] == ord('l'))
s.add(flag[2] == ord('a'))
s.add(flag[3] == ord('g'))
s.add(flag[4] == ord('{'))
s.add((flag[5] ^ 0x12) + flag[6] == 0x7F)
# For products that might exceed 8 bits, we need proper width handling
# Option 1: If the product should fit in 16 bits
prod = ZeroExt(8, flag[7]) * ZeroExt(8, flag[8])
s.add(prod == 0x1C20)
# For shift operations that exceed 8 bits
shifted = ZeroExt(8, flag[9]) << 2
s.add(shifted | ZeroExt(8, flag[10]) == 0x14A)
# Cryptographic mix
s.add((flag[11] ^ flag[12] ^ flag[13]) == 0x42)
s.add(flag[14] + flag[15] + flag[16] == 0x12C)
# Final constraint
s.add(flag[19] == ord('}'))
# Make it printable ASCII
for i in range(5, 19):
s.add(flag[i] >= 0x20)
s.add(flag[i] <= 0x7E)
if s.check() == sat:
m = s.model()
result = ''.join(chr(m[flag[i]].as_long()) for i in range(20))
print(f"Flag: {result}")
This should not take more than 10 minutes to implement once you've completed the reverse engineering. The alternative would be writing a custom brute-forcing solution or using angr, which actually employs Z3 for symbolic execution internally.
Crypto Challenges: Analysing Weak Ciphers
SAT solvers are particularly effective for crypto challenges involving weak or reduced-round ciphers. Consider this common beginner-level CTF scenario where you need to recover the key to a plaintext-ciphertext pair:
from z3 import *
# Known plaintext-ciphertext pair
plaintext = [0x41, 0x42, 0x43, 0x44] # "ABCD"
ciphertext = [0xA0, 0x30, 0xC7, 0x11] # Valid ciphertext for demonstration
# Unknown key (4 bytes)
key = [BitVec(f'k{i}', 8) for i in range(4)]
s = Solver()
# Model the cipher
def encrypt(p, k):
result = []
for i in range(4):
# XOR with key
temp = p[i] ^ k[i]
# Rotate left by 3
temp = RotateLeft(temp, 3)
# XOR with next key byte
temp = temp ^ k[(i + 1) % 4]
result.append(temp)
return result
encrypted = encrypt(plaintext, key)
# Add constraints: encrypted must equal ciphertext
for i in range(4):
s.add(encrypted[i] == ciphertext[i])
if s.check() == sat:
m = s.model()
recovered_key = [m[key[i]].as_long() for i in range(4)]
print(f"Key: {recovered_key}")
This approach generalises to any cipher that can be modelled programmatically. Weaker ciphers allow faster solving by Z3. This technique has proven effective for CTF challenges involving custom stream ciphers, weak block ciphers, and poorly implemented RSA variants.
Logic Puzzles and Sudoku Variants
Some CTF challenges include logic puzzles that conceal flags. While SAT solvers may be excessive for standard Sudoku, CTF competitions often feature complex variants. Here's a basic Sudoku solver implementation in Z3:
from z3 import *
# Create 9x9 grid of integer variables
X = [[Int(f'x_{i}_{j}') for j in range(9)] for i in range(9)]
s = Solver()
# Each cell is 1-9
for i in range(9):
for j in range(9):
s.add(And(X[i][j] >= 1, X[i][j] <= 9))
# Each row has distinct values
for i in range(9):
s.add(Distinct(X[i]))
# Each column has distinct values
for j in range(9):
s.add(Distinct([X[i][j] for i in range(9)]))
# Each 3x3 box has distinct values
for box_i in range(3):
for box_j in range(3):
cells = []
for i in range(3):
for j in range(3):
cells.append(X[box_i*3 + i][box_j*3 + j])
s.add(Distinct(cells))
# Add known values (puzzle input)
# This would come from the challenge
s.add(X[0][0] == 5)
s.add(X[0][1] == 3)
# ... more constraints
if s.check() == sat:
m = s.model()
for i in range(9):
for j in range(9):
print(m[X[i][j]], end=' ')
print()
CTF challenges often add complexity: the solved grid might encode coordinates or spell out the flag. The puzzle might follow custom constraint rules rather than standard Sudoku. Regardless of the variant, the approach remains consistent: model the constraints and allow Z3 to solve them.
Advanced: Circuit Analysis and Hash Collisions
This is where SAT solvers demonstrate particular strength. Some CTF challenges provide a logic circuit or hash function implemented as Boolean logic, requiring you to find an input that produces a specific output. This type of problem is well-suited to SAT solving.
Consider a simplified hash function presented as a circuit:
from z3 import *
def simple_hash(inp):
# Input is 32 bits - extract high and low 16-bit parts
a = Extract(15, 0, inp) # Lower 16 bits
b = Extract(31, 16, inp) # Upper 16 bits
# Mix operations
a = a ^ b
b = b + a
a = RotateLeft(a, 5)
b = b ^ 0xDEAD
# Concatenate back to 32 bits
return Concat(a, b)
# Find input that hashes to target
inp = BitVec('input', 32)
target = 0x13371337
s = Solver()
s.add(simple_hash(inp) == target)
if s.check() == sat:
m = s.model()
collision = m[inp].as_long()
print(f"Input: {hex(collision)}")
This technique scales effectively. CTF challenges featuring hash functions with hundreds of lines of bit operations can still be solved using this approach. Z3 systematically explores the solution space regardless of complexity.
Maze and Path Finding
CTF challenges sometimes conceal flags behind maze-solving problems with complex constraints. The maze might enforce rules such as only allowing left turns after two straight moves, or changing state based on your movement history. SAT solvers can handle these constraint-based pathfinding problems.
from z3 import *
# Simple maze: find path from (0,0) to (5,5)
# 0 = passable, 1 = wall
maze = [
[0, 1, 1, 1, 1, 1],
[0, 1, 0, 0, 0, 1],
[0, 0, 0, 1, 0, 1],
[0, 1, 1, 1, 0, 1],
[1, 1, 0, 0, 0, 0],
[1, 1, 1, 1, 1, 0],
]
# Path length
N = 15
# Position at each step: (x, y)
path_x = [Int(f'x_{i}') for i in range(N)]
path_y = [Int(f'y_{i}') for i in range(N)]
s = Solver()
# Start at (0,0)
s.add(path_x[0] == 0)
s.add(path_y[0] == 0)
# End at (5,5)
s.add(path_x[N-1] == 5)
s.add(path_y[N-1] == 5)
for i in range(N):
# Stay in bounds
s.add(And(path_x[i] >= 0, path_x[i] < 6))
s.add(And(path_y[i] >= 0, path_y[i] < 6))
# Don't walk through walls
for x in range(6):
for y in range(6):
if maze[x][y] == 1:
s.add(Or(path_x[i] != x, path_y[i] != y))
# Each step moves by 1 in cardinal directions
for i in range(N-1):
dx = path_x[i+1] - path_x[i]
dy = path_y[i+1] - path_y[i]
s.add(Or(
And(dx == 1, dy == 0), # right
And(dx == -1, dy == 0), # left
And(dx == 0, dy == 1), # down
And(dx == 0, dy == -1), # up
))
if s.check() == sat:
m = s.model()
# Extract the path
path = []
for i in range(N):
x = m[path_x[i]].as_long()
y = m[path_y[i]].as_long()
path.append((x, y))
print(f"Step {i}: ({x}, {y})")
print("\nMaze with path (. = path, # = wall, O = empty):")
for x in range(6):
for y in range(6):
if (x, y) in path:
print('.', end=' ')
elif maze[x][y] == 1:
print('#', end=' ')
else:
print('O', end=' ')
print()
Quick note, this model finds a path but doesn't prevent revisiting cells or loops. To enforce a simple path, add constraints ensuring each position is unique. For shortest paths, minimise N or add optimisation objectives.
Additional CTF-specific constraints (e.g., key collection, trap avoidance, timing requirements) increase model complexity, but Z3 handles these extensions effectively.
When to Use SAT Solvers
SAT solvers are most effective for CTF challenges when:
- Clear constraints exist, but the combination space is too large for practical brute forcing
- The challenge involves bit-level operations that are difficult to reverse analytically
- The problem is fundamentally about logic puzzles or constraint satisfaction
- Symbolic execution with angr is too slow or overly complex for the use case
- You need to find inputs to a known function (inverse problems)
SAT solvers are less appropriate when:
- Simple scripting or mathematical analysis provides a faster solution
- The problem primarily concerns code execution rather than constraints
- The challenge is network-based or forensics-focused
- You haven't yet fully understood the underlying constraints
Practical Tips for CTF
Incremental development: Begin with a single constraint, verify Z3 solves it correctly, then progressively add additional constraints. Avoid attempting to model the entire binary at once.
Use appropriate data types: Employ BitVec('x', 8) for bytes and BitVec('x', 32) for integers. This matches the binary's actual data representation.
Watch bit-vector widths: When operations might exceed the bit-vector width (e.g., multiplying two 8-bit values), use ZeroExt() to extend them first. Otherwise, constants larger than the bit-width get silently truncated mod 2^n, which rarely matches your intent.
Add constraint boundaries: If you know the flag consists of printable ASCII, add those constraints. This significantly reduces the search space.
Verify satisfiability regularly: If s.check() returns unsat, one of your constraints is incorrect. Comment out constraints individually to identify the problematic one.
Set timeouts: Configure a timeout to avoid indefinite waits on unsolvable constraints:
s.set('timeout', 10000) # 10 seconds
Document your solutions: When you successfully solve a challenge with Z3, save the script. You will reuse these patterns frequently across different challenges.
Beyond Basic Z3
Z3 supports capabilities beyond basic SAT solving:
- Floating point: For challenges involving floating-point operations
- Arrays: For buffer and memory constraint problems
- Strings: For string manipulation challenges
- Quantifiers: For expressing universal ("for all") or existential ("exists") properties
Most CTF challenges don't require these advanced features, but they are available when needed.
Z3 also integrates effectively with other CTF tools. Angr uses Z3 for symbolic execution. Pwntools scripts can invoke Z3 to solve constraints. Binary Ninja can export constraints for Z3 analysis. Rather than choosing between tools, you can strategically combine them.
The Bottom Line
SAT solvers are not universal solutions, but they represent powerful tools for specific problem classes. They enable you to solve problems that appear intractable by translating them into satisfiability queries. Once you develop the skill of thinking in constraints, you'll recognise SAT-solvable problems throughout CTF competitions.
The initial learning curve is substantial. Early attempts may involve incorrectly modelled constraints, unexpected unsat results when anticipating sat, or excessive solving times due to poor problem formulation. This is expected. After solving a few challenges with Z3, the approach becomes more intuitive.
While others develop custom brute forcing solutions or manually trace through assembly to reverse the mathematics, you can leverage Z3's automated solving capabilities. This efficiency advantage can make the difference in time-sensitive competition environments, making proficiency with SAT solvers a worthwhile investment for serious CTF competitors.
Share this
- November 2025 (1)
- October 2025 (1)
- September 2025 (1)
- August 2025 (1)
- July 2025 (1)
- June 2025 (1)
- May 2025 (1)
- April 2025 (1)
- March 2025 (1)
- February 2025 (1)
- January 2025 (1)
- December 2024 (1)
- November 2024 (1)
- October 2024 (1)
- September 2024 (1)
- August 2024 (1)
- July 2024 (1)
- June 2024 (1)
- April 2024 (2)
- February 2024 (1)
- January 2024 (1)
- December 2023 (1)
- November 2023 (1)
- October 2023 (1)
- September 2023 (1)
- August 2023 (1)
- July 2023 (1)
- June 2023 (2)
- May 2023 (2)
- April 2023 (3)
- March 2023 (4)
- February 2023 (3)
- January 2023 (5)
- December 2022 (1)
- November 2022 (2)
- October 2022 (1)
- September 2022 (11)
- August 2022 (5)
- July 2022 (1)
- May 2022 (3)
- April 2022 (1)
- February 2022 (4)
- January 2022 (3)
- December 2021 (2)
- November 2021 (3)
- October 2021 (2)
- September 2021 (1)
- August 2021 (1)
- June 2021 (1)
- May 2021 (14)
- February 2021 (1)
- October 2020 (1)
- September 2020 (1)
- July 2020 (1)
- June 2020 (1)
- May 2020 (1)
- April 2020 (2)
- March 2020 (1)
- February 2020 (1)
- January 2020 (3)
- December 2019 (1)
- November 2019 (2)
- October 2019 (3)
- September 2019 (5)
- August 2019 (2)
- July 2019 (3)
- June 2019 (3)
- May 2019 (2)
- April 2019 (3)
- March 2019 (2)
- February 2019 (3)
- January 2019 (1)
- December 2018 (3)
- November 2018 (5)
- October 2018 (4)
- September 2018 (3)
- August 2018 (3)
- July 2018 (4)
- June 2018 (4)
- May 2018 (2)
- April 2018 (4)
- March 2018 (5)
- February 2018 (3)
- January 2018 (3)
- December 2017 (2)
- November 2017 (4)
- October 2017 (3)
- September 2017 (5)
- August 2017 (3)
- July 2017 (3)
- June 2017 (4)
- May 2017 (4)
- April 2017 (2)
- March 2017 (4)
- February 2017 (2)
- January 2017 (1)
- December 2016 (1)
- November 2016 (4)
- October 2016 (2)
- September 2016 (4)
- August 2016 (5)
- July 2016 (3)
- June 2016 (5)
- May 2016 (3)
- April 2016 (4)
- March 2016 (5)
- February 2016 (4)


