In this section, we are going to cover a powerful dynamic technique called symbolic execution, how to reason about symbolic values using a SMT solver, as well as showing how to combine it with fuzzing to get very precise coverage.
The most revolutionary technology in the field of program analysis, in my opinion, is the ability to routinely and effectively solve NP-hard problems.
SMT stands for satisfiability modulo theories, and is essentially a solver of mathematical equations. And, since most of program analysis is essentially solving equations, this is a total game changer for our field.
We are not going into how SMT solvers work. Consider them black magic — they are solving NP-hard problems rutinely after all. Sometimes the black magic works and sometimes it doesn't, and even something as benign as renaming your variables might anger the shadow creatures. Now let's swing that Wand!
Let us solve the first equation we can:
Navigate to Z3 Playground and type the equivalent SMTLIB statment in the textbox:
(declare-const a Bool)
(declare-const b Bool)
(declare-const c Bool)
(assert (and (or a (not b)) (not c)))
(assert (and (or (not a) b) (or a c) (or b (not c))))
(check-sat)
(get-model)The check that the resulting model makes sense to you.
When using a SMT solver you have to do 3 steps,
Encode your problem as a proposition that the SMT solver can check.
Run the SMT solver, by checking if the proposition is satisfied.
If that is true, then a model (a truth assignment) is found, you should now turn that back into a (counter -) solution the user can understand.
While this seems simple, we often tend to over complicate the problem, making it hard for the SMT solver to check, or we get some subtle details of the problem work. It is paramount that we are precise when we defined our proposition.
Some of this hazel is solved by libraries.
Install bindings to Z3 for your favorite language (If you use JPAMB, you it is already installed.)
After which you should be able to run the following example:
$ uv run python
>>> from z3 import *
>>> s = Solver()
>>> a = Int("a")
>>> s.add(a > 10)
>>> s.check()
sat
>>> s.model()
[a = 11]Now we can encode harder problems in our programming language of choice:
Exercise taken from CSC410
Your goal is to solve the N-Queens problem. Given an N x N chessboard, place N queens so than none can take each other. There are three rules
There must be exactly one queen on each row,
There must be exactly one queen on each column, and
There can be no more than one queen on each diagonal.
First try to solve the 4-Queens problem, using your bindings to Z3. Some small hints:
Make a boolean for whether or not each square contains a queen: Qa1, Qa2,...
Write up logical constraints for the rules above using those booleans
In Python, you can write at most one element must be true
like this:
def at_most_one(props):
import itertools
conditions = []
for p, q in itertools.permutations(props, 2):
conditions.append(z3.Or(z3.Not(p), z3.Not(q)))
return z3.And(*conditions)import z3
import itertools
def at_most_one(props):
conditions = []
for p, q in itertools.permutations(props, 2):
conditions.append(z3.Or(z3.Not(p), z3.Not(q)))
return z3.And(*conditions)
columns = "abcd"
rows = "1234"
# Create a boolean for each possible queen location.
queen_on_square = [[z3.Bool(f"Q{c}{r}") for c in columns] for r in rows]
s = z3.Solver()
# There need to be exactly one queen per row.
for ir, r in enumerate(rows):
row = [queen_on_square[ic][ir] for ic, c in enumerate(columns)]
s.add(z3.Or(*row))
s.add(at_most_one(row))
# There can be at most one queen per column.
for ic, c in enumerate(columns):
column = [queen_on_square[ic][ir] for ir, r in enumerate(rows)]
s.add(at_most_one(column))
# There can be no more than one queen per diagonal.
prim, secd = [[] for r in row * 2], [[] for c in column * 2]
for ir, r in enumerate(rows):
for ic, c in enumerate(columns):
# primary diagonals are constant over subtraction
prim[ir - ic].append(queen_on_square[ic][ir])
# secondary diagonals are constant over summation
secd[ir + ic].append(queen_on_square[ic][ir])
for diag in prim + secd:
s.add(at_most_one(diag))
# This is kind of an ugly hack.
assert str(s.check()) == "sat"
model = s.model()
for ir, r in reversed(list(enumerate(rows))):
print(f" {r} ", end="")
for ic, c in enumerate(columns):
is_black = (ir + ic) % 2 == 0
if is_black:
print("\033[40m\033[37m ", end="") # ]]
else:
print("\033[47m\033[30m ", end="") # ]]
has_queen = bool(model[queen_on_square[ic][ir]])
print("♛" if has_queen else " ", end="")
print(" \033[0m", end="") # ]
print()
print(f" {' '.join(columns)}")Even though it seems inefficient to solve these kinds of problems using a general tool. It is a very cool tool.
N-Queens is a known (and hard) search problem, try to see if you can outperform Z3 with your own implementation.
The advent of SMT solvers suddenly made a new kind of analysis possible, called Symbolic Execution. Instead of running the program with concrete values, this technique builds up symbolic values. A symbolic value is a value which can contain formulas over input symbols. This is clearly very useful when analysing programs:
int incr(int a) {
int x = a; // x := a
x += 1; // x := a + 1
x += 3; // x := a + 1 + 3 = a + 4
return x; // return (a + 4)
}In the example above, we do a simple symbolic execution to see that the program is in fact just adding 4 to the input. This would have been very hard to detect using regular testing and even with some kinds of static analysis.
As mentioned above, a symbolic value is a formulae explaining how to calculate a value from inputs. We use symbols to indicate an input.
You can consider symbolic values as very simple programs or expressions, which are easy to do further analysis on.
For example, assuming the input is 42, then the concrete value of is , while the symbolic value is . We can use z3 to simplify symbolic values for us using the z3.simplify command.
>>> import z3
>>> i = z3.Int("i")
>>> e = i + 2 - 10
>>> print(e)
i + 2 - 10
>>> print(z3.simplify(e))
-8 + i This is very useful as for loops can add a lot of to your symbolic expressions:
>>> i = z3.Int("i")
>>> for r in range(10):
... i = i + 1
...
>>> print(i)
i + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1
>>> i = z3.Int("i")
>>> for r in range(10):
... i = z3.simplify(i + 1)
...
>>> print(i)
10 + iWhen analysing real program we quickly face a problem, executions have context. If we first check if a variable is less than , and it is true then we know that in this branch . This is called a path constraint, as individual values does not hold this information, but the full context of the path does. We can denote a symbolic execution like , where is the path constraints and is the symbolic state, which is often a mapping from variables to symbolic values.
The procedure is illustrated by the following program:
int incr(int a) {
int x = a; // [] |- x := a
if (x > 10) { // [a > 10] |- x := a
x =+ 1; // [a > 10] |- x := a + 1
if (x < 10) // [a > 10, a + 1 < 10] |- UNSAT
assert false; // never executed
}
return x; // returns [ a > 10 |- a + 1
// , a <= 10 |- a ]
}When we get to the second branch, we need to figure out if we would enter it and raise an assertion error. Since we already know that , we can see that that , when . This what we use our SMT solver for. In Python, we can use z3 to build up symbolic values, add the path constrains as context and then check if the current branch is satisfiable.
>>> import z3
>>> a = z3.Ints("a")[0]
>>> s = z3.Solver()
>>> path = []
>>> x = a
>>> path.append(x > 10)
>>> print(path, s.check(path), s.model())
[a > 10] sat [a = 11]
>>> x += 1
>>> print(x)
a + 1
>>> path.add(x < 10)
>>> print(path, s.check(path))
[a > 10, a + 1 < 10] unsat After iterating over all branches of the program to some depth, we get that the program can be described using two paths: and . This means that we can re-implement the program like this equivalent and much simpler program:
int incr(int a) {
if (a > 10) return a + 1;
if (a <= 10) return a;
}We can implement symbolic execution to depth , like the following Python pseudo-code. The fundamental idea is to do a depth-first search through the program down to depth . To do this we maintain a stack of paths. A path contains the next instruction to execute, a state, the path constraint, and the depth of current path.
stack : list[tuple[PC, SymState, SymPath, int]]Below, we'll assume a stepping function over symbolic states:
def step(pc: PC, state: SymState)
-> Iterable[PC, SymState, SymPath]]: ...When analysing the program, we pop the top of the stack, and then compute the steps from there. We then add a new stack entry containing all the branches that have satisfiable path constraints. We pop a stack entry when there is no more branches in it, and then end when the stack is empty.
def analyse(pc : PC, inputs : list[tuple[str, JvmType]], max_depth : int):
# assuming only integers, create symbolic vars for all input
locals = z3.Ints(" ".join(i_name for i_name, _ in inputs))
# create state.
state = SymState.from_locals(locals)
# Create a stack to do depth first search with.
stack : list[tuple[PC, SymState, SymPath]] = [(pc, state, [])]
# As long as the stack is not empty
while stack:
# pop the end of the stack
(pc, state, path, n) = stack.pop(-1)
# Check if state does something we want to warn the user about.
if interesting(state):
return "found interesting state"
# otherwise create the list of next branches
for (pc, state_, pathc) in step(pc, state):
# add new path constraints to the path
path_ = path + pathc
# check if path is reachable
if str(z3.check(path_)) != "sat":
continue
# append stack if within limits
if n < max_depth:
stack.append((pc, state_, path_, n + 1))
return f"all is fine, uptil depth {max_depth}"One big flaw in symbolic execution is that you have to be able to create a symbolic expression over the entire program. This can be problematic if we use builtin methods like malloc, one-way-functions like hashing or string manipulation methods (which are notoriously hard to model in SMT solvers).
Another big problem is memory aliasing and arrays. Since we can only access the arrays with concrete values we don't know what an access is pointing to.
One solution to the limitation above is to simulate the different builtin methods, however this mean we might over- or under-estimate the real behaviour of the code. The alternative solution is to additionally to the symbolic values also maintain the concrete values. By having a concrete value, we avoid having to simulate the builtin methods as we can simply use the concrete values instead. We might lose our symbolic value doing that, but it is better than giving up. This is concolic execution. Concolic is just the concatenation of the words Concrete and Symbolic.
To do concolic execution, we first run the program with a random and concrete input. For each concrete value we also keep the symbolic value. Every time we branch on a value, we emit the symbolic value as path constraint if the concrete value was true, else we emit the negated symbolic value.
When we are done we have a list of path constraints which we can use to generate new concrete inputs, that we can run the program with. We can see a single concolic run as a function from a program and a concrete input to a list of path constraints.
To get a new input, we can simply negate one of the path constraints:
Interestingly, we actually know that running will produce a prefix of the same path constraints , but might be extended, by new ones. This means, that if we keep track of which path constraints we have already negated (and have infinite time) we can do a depth first search through all possible paths of the program.
One big limitation of the dynamic techniques we have looked at so far is speed. Ideally, we would get all the information we needed while running the program instead of having to implement our own interpreter.
The way to get around this is by doing code instrumentation. Code instrumentation injects small instructions inside the code to track and trace what is happening.
Currently we do not support any straight forward ways to do bytecode instrumentation in this course.
Jvm2json does allow you to create class files from jsonfiles by running it in reverse. The problem is just that we currently does not support computing the type information for changes withing the bytecode of the individual methods.
To do concolic execution, you will have to instrument the code to keep track of the symbolic values of the entire memory, essentially keeping a shadow variable for each variable. And then update those accordingly. This is not trivial.
Until next time, write the best analysis you can and upload the results to Autolab, and ponder the following:
What are some good uses for SMT Solving?
What are the big limitations of Symbolic Execution?
Why would you use Concolic Execution?
Godefroid, Patrice; Klarlund, Nils; Sen, Koushik (2005). DART: directed automated random testing.
doi:10.1145/1065010.1065036 link
Kalhauge, Christian Gram; Palsberg, Jens (2018). Sound deadlock prediction.
doi:10.1145/3276516 link
Reichelt, David Georg; Bulej, Lubomír; Jung, Reiner; van Hoorn, André (2024). Overhead Comparison of Instrumentation Frameworks.
doi:10.1145/3629527.3652269 link
Sen, Koushik; Marinov, Darko; Agha, Gul (2005). CUTE: a concolic unit testing engine for C.
doi:10.1145/1095430.1081750 link