Unbounded Static Analysis

Christian Gram Kalhauge

Table of Contents
  1. Dependent Valuesยง1
    1. Dependencies between different values.ยง1.1
    2. Unbounded Static Analysisยง2
      1. Fixed Pointsยง2.1
        1. The Interval Abstractionยง2.2
          1. The Widening Operatorยง2.3
            1. Proving Wideningยง2.3.1
            2. The Worklist Algorithmยง2.4
              1. Control-Flow Analysis and Optimal Orderยง2.4.1

            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.

            Dependent Values ยง1

            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.

            Safe Div By N

            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.

            OffsetInstructionAbstract State
            000load:I 0โŸจ{0โ†ฆ{+,โˆ’,0}},ฯต,0โŸฉโ†’{1}
            001ifz eq 6โŸจ{0โ†ฆ{+,โˆ’,0}},{+,โˆ’,0},1โŸฉโ†’{2,6}
            002push:I 1โŸจ{0โ†ฆ{+,โˆ’,0}},ฯต,2โŸฉโ†’{3}
            003load:I 0โŸจ{0โ†ฆ{+,โˆ’,0}},{+},3โŸฉโ†’{4}
            004binary:I divโŸจ{0โ†ฆ{+,โˆ’,0}},{+}{+,โˆ’,0},4โŸฉโ†’{5,err(โ€˜๐š๐š’๐šŸ ๐š‹๐šข ๐šฃ๐šŽ๐š›๐š˜โ€™)}
            005return:IโŸจ{0โ†ฆ{+,โˆ’,0}},{+,โˆ’},5โŸฉโ†’{ok}
            006push:I 0โŸจ{0โ†ฆ{+,โˆ’,0}},ฯต,6โŸฉโ†’{7}
            007return:IโŸจ{0โ†ฆ{+,โˆ’,0}},{0},7โŸฉโ†’{ok}

            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 n with constraint n={+,โˆ’,0} indicating that values are the same. We can keep these constraints in addition to the state:

            OffsetInstructionAbstract State
            000load:I 0{nโ†ฆ{+,โˆ’,0}}/โŸจ{0โ†ฆn},ฯต,0โŸฉโ†’{1}
            001ifz eq 6{nโ†ฆ{+,โˆ’,0}}/โŸจ{0โ†ฆn},n,1โŸฉโ†’{2,6}
            002push:I 1{nโ†ฆ{+,โˆ’}}/โŸจ{0โ†ฆn},ฯต,2โŸฉโ†’{3}
            003load:I 0{nโ†ฆ{+,โˆ’}}/โŸจ{0โ†ฆn},{+},3โŸฉโ†’{4}
            004binary:I div{nโ†ฆ{+,โˆ’}}/โŸจ{0โ†ฆn},{+}n,4โŸฉโ†’{5}
            005return:I{nโ†ฆ{+,โˆ’}}/โŸจ{0โ†ฆn},{+,โˆ’},5โŸฉโ†’{ok}
            006push:I 0{nโ†ฆ{0}}/โŸจ{0โ†ฆn},ฯต,6โŸฉโ†’{7}
            007return:I{nโ†ฆ{0}}/โŸจ{0โ†ฆn},{0},7โŸฉโ†’{ok}

            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:

            Dependencies between different values. ยง1.1

            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 (??).

            Unbounded Static Analysis ยง2

            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.

            Fixed Points ยง2.1

            Ideally, we want the smallest such fixed point, which we refer to as the Minimal Fixed Point.

            Minimal Fixed Point

            Given a function f:Aโ†’A, that is monotone over (A,โŠ‘A) (โˆ€aโˆˆA.aโŠ‘Af(a)) then the minimal fixed point is the least n st. fn(โŠฅ)=fn+1(โŠฅ).

            fn(โŠฅ) means f(f(โ€ฆf(โŠฅ)โ€ฆ)), where f is applied n 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.

            Adding One

            Imagine a function adding one:

            a(x)=x+๐’๐ข๐ ๐งฮฑ({1})

            We can make it into a monotone function by joining it with the value.

            f(x)=xโŠ”a(x)

            Now we can see that we will reach a fixed point on the zero set in only one step.

            f0({0})={0}f1({0})=f({0})={0}โŠ”{+}={0,+}f2({0})=f(f1({0}))={0,+}โŠ”{+}={0,+}

            The Interval Abstraction ยง2.2

            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.

            Interval Abstraction

            The interval abstraction ๐ˆ๐ญ๐ฏ๐š๐ฅ is a tuple of two numbers i, j, representing the interval of integers between [i,j], both inclusive.

            ฮฑ(S)=[minS,maxS]ฮณ([i,j])={nโˆˆโ„ค|jโ‰ฅnโ‰คi}[i,j]โŠ‘๐ˆ๐ญ๐ฏ๐š๐ฅ[k,h]โ‰กkโ‰คiโˆงjโ‰คh[i,j]โŠ”๐ˆ๐ญ๐ฏ๐š๐ฅ[k,h]โ‰ก[min{i,k},max{j,h}][i,j]โŠ“๐ˆ๐ญ๐ฏ๐š๐ฅ[k,h]โ‰ก[max{i,k},min{j,h}]โŠค๐ˆ๐ญ๐ฏ๐š๐ฅ=[โˆ’โˆž,โˆž]โŠฅ๐ˆ๐ญ๐ฏ๐š๐ฅ=[โˆž,โˆ’โˆž]

            Implement the Interval abstraction.

            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)

            Implement Operations on Interval Abstraction.

            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.

            Runs forever

            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:

            StateComment
            {}int i = 0
            {iโ†ฆ[0,0]}see that i >= 0
            {iโ†ฆ[0,0]}i = i + 1
            {iโ†ฆ[0,0]โŠ”[1,1]}loop back
            {iโ†ฆ[0,1]}see that i >= 0
            {iโ†ฆ[0,1]+[1,1]}i = i + 1
            {iโ†ฆ[0,1]โŠ”[1,2]}loop back
            ......

            This might continue forever, unless we find a better way.

            The Widening Operator ยง2.3

            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 min⁡KJ and max⁡KJ which, given a set of integers J, pick the largest and smallest, respectively, kโˆˆKโˆช{โˆž,โˆ’โˆž} st. kโ‰คmin⁡J and kโ‰ฅmax⁡J.

            [i,j]โˆ‡[k,h]=[minK{i,k},maxK{j,h}]

            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:

            K-Interval Abstraction

            The interval abstraction ๐ˆ๐ญ๐ฏ๐š๐ฅK is a tuple of two numbers i, j, representing the interval of integers between [i,j], both inclusive, using a set of known boundaries K.

            ฮฑ(S)=[minS,maxS]ฮณ([i,j])={nโˆˆโ„ค|jโ‰ฅnโ‰คi}[i,j]โŠ‘๐ˆ๐ญ๐ฏ๐š๐ฅK[k,h]โ‰กkโ‰คiโˆงjโ‰คh[i,j]โŠ”๐ˆ๐ญ๐ฏ๐š๐ฅK[k,h]โ‰ก[min{i,k},max{j,h}][i,j]โŠ“๐ˆ๐ญ๐ฏ๐š๐ฅK[k,h]โ‰ก[max{i,k},min{j,h}]โŠค๐ˆ๐ญ๐ฏ๐š๐ฅK=[โˆ’โˆž,โˆž]โŠฅ๐ˆ๐ญ๐ฏ๐š๐ฅK=[โˆž,โˆ’โˆž][i,j]โˆ‡๐ˆ๐ญ๐ฏ๐š๐ฅK[k,h]=[minK{i,k},maxK{j,h}]

            Define a widening operator

            Include a widening operator in your analysis, so that you only have to join a finite number of times.

            Proving Widening ยง2.3.1

            Proving that a widening operator is correct requires some carefulness. We have to prove two properties: One, that โˆ‡ increases in size;

            โˆ€a,bโˆˆA.aโŠ‘(aโˆ‡b)โˆงbโŠ‘(aโˆ‡b)

            and two, that we can only apply the โˆ‡ operator a finite number of times before a fix point is reached.

            โˆƒnโˆˆโ„•.โˆ€xโˆˆAn+1,aโˆˆA.(a0โˆ‡a1)โˆ‡a2)โ‹ฏโˆ‡an=((a0โˆ‡a1)โˆ‡a2)โ‹ฏโˆ‡an+1

            The Worklist Algorithm ยง2.4

            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: ๐šœ๐š๐šŽ๐š™:๐๐‚ร—๐’๐ญ๐š๐ญ๐žฬ‚โ†’2๐๐‚ร—๐’๐ญ๐š๐ญ๐žฬ‚.

            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.

            Control-Flow Analysis and Optimal Order ยง2.4.1

            However, 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.

            i = 0 i < 100? i += 1 Many operations depedents on i true false

            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!

            Until next time, write the best analysis you can and upload the results to Autolab, and ponder the following:

            1. What does it mean for two variables to be dependent?

            2. What is the primary problem when doing unbounded static analysis?

            3. Why do we need a widening operator?

            Bibliography

            1. Cousot, Patrick; Halbwachs, Nicolas (1978). Automatic discovery of linear restraints among variables of a program. doi:10.1145/512760.512770 link

            2. Nielson, Flemming; Nielson, Hanne Riis (2020). Program Analysis (an Appetizer). link