In this section, we are going to revisit abstract interpretation from our previous session by discussing Dependent Values (ยง1). Then, we will show how we can perform Unbounded static analysis with no limits. To do this, we have to introduce fixpoints, the widening operator, and finally, a more efficient algorithm.
A big problem, which we have glossed over so far, is dependent variables. Dependent variables are variables whose solution spaces are constrained by each other.
Consider the case in Dependent.safeDivByN:
@Case("(0) -> ok")
@Case("(1) -> ok")
public static int safeDivByN(int n) {
if (n != 0) {
return 1 / n;
}
return 0;
}If we run our sign analysis naively on the decompiled code, we get the following. The offset column shows the offset, the instruction column shows the instruction, and the abstract state column shows the abstract state right before the instruction and at which instructions the results are stored.
| Offset | Instruction | Abstract State |
|---|---|---|
000 | load:I 0 | |
001 | ifz eq 6 | |
002 | push:I 1 | |
003 | load:I 0 | |
004 | binary:I div | |
005 | return:I | |
006 | push:I 0 | |
007 | return:I |
The problem with the above execution is that we find a divide by zero error, even though it does not exist. This happens because we do not capture the dependency between the integer on the stack and in the locals during the choice at 001.
To fix this, we need to add a notion of named values and constraints. In the example above, we could add a named value with constraint indicating that values are the same. We can keep these constraints in addition to the state:
| Offset | Instruction | Abstract State |
|---|---|---|
000 | load:I 0 | |
001 | ifz eq 6 | |
002 | push:I 1 | |
003 | load:I 0 | |
004 | binary:I div | |
005 | return:I | |
006 | push:I 0 | |
007 | return:I |
Now we can see that we can constrain the state-space of the named values inside each of the branches, allowing for more precise results.
A couple of things to keep in mind when using named values are that:
They should not change. This means that if a variable, like a local, is overridden, we need to create a new named value, so that values copied out of the locals do and not change when the original variable changes.
Try to be smart about when to create named values and when to remove them again, as too many named variables might make your analysis slow.
Try to think about which instructions create dependencies between values. Some examples are dup and load, but array and field accesses do the same.
It is possible to capture more interesting relationships between values. This is especially important when they depend on each other.
@Case("(0, 0) -> ok")
@Case("(1, 1) -> ok")
public static int safeDivAByB(int a, int b) {
if (a >= 0) {
if (b > a) {
return a / b
}
}
return 0;
}The example above contains no errors. But this would be hard for our current techniques to detect. To maintain these constraints efficiently, we can use Polyhedrons (Cousot 1978) or, most of the time, faster Octagons (??).
The problem with bounded static analysis is that even if we run our static analysis for 1,000,000 iterations, we have no guarantee that our program will not exhibit bad behavior in iteration 1,000,001. Unbounded Static Analysis seeks to fix this by running the static analysis until a fixed point is reached.
Ideally, we want the smallest such fixed point, which we refer to as the Minimal Fixed Point.
Given a function , that is monotone over then the minimal fixed point is the least st.
means , where is applied times.
Not all functions have a fixed point, but if one does, we can run the function until the result no longer changes, and then we know we don't have to continue running the analysis.
For example, given our abstraction, we are guaranteed a fixed point in no more than 4 iterations, as the height of the lattice is 4.
Imagine a function adding one:
We can make it into a monotone function by joining it with the value.
Now we can see that we will reach a fixed point on the zero set in only one step.
However, not all abstractions form a lattice of finite height. One example of such abstractions is the interval abstraction.
Here, we represent a set of integers as an interval between two numbers.
The interval abstraction is a tuple of two numbers , , representing the interval of integers between , both inclusive.
Now implement the range abstraction in your language of choice. Remember to test your abstraction:
from hypothesis import given
from hypothesis.strategies import integers, sets
@given(sets(integers()))
def test_interval_abstraction_valid(xs):
r = Interval.abstract(xs)
assert all(x in r for x in xs)
@given(sets(integers()), sets(integers()))
def test_interval_abstraction_distributes(xs, ys):
assert (Interval.abstract(xs) | Interval.abstract(ys)) == Interval.abstract(xs | ys)Define the arithmetic operations you need to run your static analysis.
You can use hypothesis to test them; remember you want the operations to be an over-approximation:
@given(sets(integers()), sets(integers()))
def test_interval_abstraction_add(xs,ys):
r = Interval.abstract(xs) + Interval.abstraction(ys)
assert all(x + y in r for x in xs for y in ys)Notice that the lattice of the interval abstraction does not have a finite height; we can always add 1 to either end of the interval.
Consider the following program:
void example() {
int i = 0;
while (i >= 0) {
i = i + 1;
}
}In this case, our interval analysis will look something like this:
| State | Comment |
|---|---|
int i = 0 | |
see that i >= 0 | |
i = i + 1 | |
| loop back | |
see that i >= 0 | |
i = i + 1 | |
| loop back | |
| ... | ... |
This might continue forever, unless we find a better way.
Because we are not guaranteed to reach a fixed point with the interval abstraction, we need to force it. This is known as the widening operator; the goal of the widening operator is to dramatically over-approximate joins in a way that we are guaranteed a fixed point in a finite number of steps.
One way of adding a widening operator to the interval abstraction is to use a constant set, often referred to as . This set contains all the constants in the program or file of interest. This is like the dictionaries we used in the dynamic analysis.
To define the widening operator, we define two operators and which, given a set of integers , pick the largest and smallest, respectively, st. and .
We can see that we can only apply this operator a finite number of times until we either get or .
Now when running our analysis, we use the operator instead of the operator, and we can continue computing until we reach a fixed point.
The full abstraction is now:
The interval abstraction is a tuple of two numbers , , representing the interval of integers between , both inclusive, using a set of known boundaries .
Include a widening operator in your analysis, so that you only have to join a finite number of times.
Proving that a widening operator is correct requires some carefulness. We have to prove two properties: One, that increases in size;
and two, that we can only apply the operator a finite number of times before a fix point is reached.
Currently our algorithm is a little slow, as we essentially iterate in lockstep over the the entire program for every step.
This is, of course, silly, as we would much rather only handle unprocessed events. Consider you have a stepping function from program locations and an abstract state to a set of program locations and abstract states, which need to be joined at those points:
To extend our current implementation for this, we can modify the per_instruction method in StateSet to only emit:
def step(self) -> tuple[PC, set[str]]:
# Pop from worklist
pc = self.worklist.pop()
for res in manystep(self.bc, pc, self.states[pc]):
pc_, st = res
before = self.states.get(pc_, st)
after = before | st
if pc_ not in self.states or after != before:
self.states[pc_] = after
# Update worklist
self.worklist.append(pc_)
# ...This approach uses significantly fewer instructions than running all steps every time, and still produces the same results.
https://en.wikipedia.org/wiki/Tree_traversalHowever, there is still some work we can do to improve the algorithm. For example, when you analyze the following program:
void fn(int x) {
int i = 0;
if (x == 0) {
i += 1;
} else {
i -= 1:
}
// Many operations on i.
}There are not reason to analyse the rest to the instructions before we have covered both sides of the if statement. The correct order to do this computation is in reverse post-order (Nielson (2020) at page 63). If you do this, you are guaranteed to process the branches of the if statements before looking into the instructions after.
The reverse post-order is not enough for fast analysis of full programs; in this case, you need to do more in-depth control-flow analysis. Consider the following program.
void fn() {
int i = 0;
while (i < 100) {
i += 1;
}
// Many operations on i.
}Calculating in post-order is no longer enough; instead, you have to find the strongly connected components of the graph, merge them into one node, and then compute those nodes in reverse post-order.
The control flow graph of the example code.
A strongly connected component in a directed graph is a subgraph where every node is reachable from every other node. For example, in the control-flow graph above, we can see that the loop is strongly connected. We should therefore ensure this is calculated before we continue our analysis.
Until next time, write the best analysis you can and upload the results to Autolab, and ponder the following:
What does it mean for two variables to be dependent?
What is the primary problem when doing unbounded static analysis?
Why do we need a widening operator?
Cousot, Patrick; Halbwachs, Nicolas (1978). Automatic discovery of linear restraints among variables of a program.
doi:10.1145/512760.512770 link
Nielson, Flemming; Nielson, Hanne Riis (2020). Program Analysis (an Appetizer).
link