Dynamic Analysis

Let's run it and see?

Christian Gram Kalhauge

Table of Contents
  1. What is Dynamic Analysis?Β§1
    1. Trace SelectionΒ§1.1
      1. Trace AnalysisΒ§1.2
        1. Trace Abstraction and PredictionΒ§1.3
          1. The Path Equivalence ClassΒ§1.3.1
            1. CoverageΒ§1.3.2
              1. Trace AbstractionΒ§1.3.3
            2. Running the ProgramΒ§2
              1. Why Should We Run the Program?Β§2.1
                1. Why Shouldn't We Run the Program?Β§2.2
                2. TestingΒ§3
                  1. Characterization Trace TestingΒ§3.1
                    1. AssertionsΒ§3.2
                    2. Selecting InputΒ§4
                      1. Random InputsΒ§4.1
                        1. DictionariesΒ§4.2
                          1. Coverage-Guided FuzzingΒ§4.3
                            1. Property Based TestingΒ§4.4
                              1. The Small-Scope HypothesisΒ§4.5

                              In this topic, we are going to introduce the technique of dynamic analysis. We are going to focus on the simplest version: just running the program. First, we are going to cover the basics in What is Dynamic Analysis? (Β§1), then we will discuss some limitations and advantages of Running the Program (Β§2), touch on the simplest form for dynamic analysis, namely Testing (Β§3), and, finally, we go over different techniques for Selecting Input (Β§4).

                              What is Dynamic Analysis? Β§1

                              Dynamic analysis is finding out information about a program by running it. Actually, you have been doing dynamic analysis most of your career without knowing it. When you have been writing tests for your program, you have been doing dynamic analysis. When you have inserted log statements in your program, you have been doing dynamic analysis. And, when you have searched through a log to recreate a bug in production, you have been doing dynamic analysis.

                              It might seem obvious that we can learn about a program by running it, but it is far from obvious what we learn or even how to run the program. In theory, dynamic analysis is about extracting information about all the behaviors of a program from a few executions (or traces).

                              Dynamic analysis consists of three phases, Trace Selection (Β§1.1), Trace Abstraction and Prediction (Β§1.3), and Trace Analysis (Β§1.2).

                              Fill Input State Space Prediction 1.2 Input Selection 1.1 Analysis 1.3 Repeat 2.1

                              The three steps of dynamic analysis: 1.1 We select a trace from the program. 1.2 We try to predict a class of traces from which our trace came. 1.3 We analyze the set of traces. If we do not find anything, we might repeat the analysis (e.g ., step 2.1).

                              Trace Selection Β§1.1

                              First, recall the definition of a transition system from Transition System and Traces (Β§2.4). A program can be described as a triplet βŸ¨π’π­πšπ­πžp,Ξ΄p,Ip⟩, where Ξ΄p is the transition function and Ip is the set of initial states.

                              In the first phase, we select a trace from the transition system. This consists of choosing an initial state ΟƒβˆˆIp, and then executing the program to pick a trace Ο„:

                              Ο„βˆˆπ’πžπ₯𝐞𝐜𝐭(P,Οƒ)β‰‘Ο„βˆˆSem(P)βˆ§Ο„0=Οƒ

                              If the program is deterministic, there is only one trace per input state. If the program is non-deterministic, like parallel programs, each initial state might give rise to multiple traces.

                              Selecting the initial state or even executing the program is not trivial in practice (see Running the Program (Β§2)).

                              Trace Analysis Β§1.2

                              Now that we have a trace, we can make several deductions about the behaviors of a program by analyzing it. Common analyses are (slightly abusing notation):

                              • did it end in a success? TAπš˜πš”(Ο„)β‰‘Ο„βˆ’1=πš˜πš”

                              • did any opened 𝐎 resource, not get closed 𝐂? TAπš›πšŽπšœ(Ο„)β‰‘βˆƒf,i.𝐎(Ο„i,f)βˆ§βˆ€jβ‰₯i.¬𝐂(Ο„j,f)

                              • did we execute this instruction? TAπšŒπš˜πšŸπšŽπš›(i)(Ο„)≑i∈{e.ΞΉΒ |Β eβˆˆΟ„}

                              Any question that we can answer correctly for one trace can be turned into a not-must analysis for the full program (see πŸ—’ Trace Terminology). If we find a trace that ends in a failure, the program might fail. If we find a trace where an opened resource is not closed, we know that the program must not close all resources in the program. So, essentially, for any property X we can find in one trace, we can build a not-must analysis which can detect it:

                              βˆƒΟƒβˆˆIp,Ο„βˆˆπ’πžπ₯𝐞𝐜𝐭(p,Οƒ).Β¬TAX(Ο„)⟹¬DAXπ—†π—Žπ—Œπ—(p)

                              For example, if we find a trace which does not terminate in an πš˜πš”, we know that the program must not always succeed.

                              βˆƒΟƒβˆˆIp,Ο„βˆˆπ’πžπ₯𝐞𝐜𝐭(p,Οƒ).Β¬TAπš˜πš”(Ο„)⟹¬DAπš˜πš”π—†π—Žπ—Œπ—(p)

                              Actually, if we can increase the precision of the analysis by running the program multiple times. And, in the limit (assuming that we could run all traces) a dynamic analysis precisely captures any property.

                              βˆ€ΟƒβˆˆIp,Ο„βˆˆπ’πžπ₯𝐞𝐜𝐭(P,Οƒ).TAX(Ο„)≑ℒX(p)

                              No analysis are truly sound!

                              I was convinced that we could create a sound dynamic memory analysis for a company focusing on embedded devices.

                              The idea was simple, fuzz the program while using a memory sanitizer to check if all memory that was allocated was eventually deallocated.

                              The analysis per se was sound; every trace we warned about contained a memory error, but the company in question had a coding style where they didn't clean up the initial memory allocation because it was cleaned up when the device was shut off. The warnings we produced were not a problem for this company, and were therefore considered false positives.

                              The lesson: no analysis is fire and forget; they all require some coding conventions to give good results.

                              Trace Abstraction and Prediction Β§1.3

                              The problem is, of course, that the number of traces in the semantics of most programs is infinite (or close), and it is impossible to cover them all without some trickery. Therefore, can we try to expand the number of traces we cover every time by inferring information about other traces from one trace? We call this Trace Prediction.

                              Trace prediction works by mapping each trace Ο„ $ in the trace space into a possible finite set of trace classes [Ο„]. If we can show that an analysis over a trace class is equivalent to a trace analysis over all traces in the trace class: π“π«πšπœπžX([Ο„])βŸΉβˆƒΟ„β€²βˆˆ[Ο„].π“π«πšπœπžX(Ο„β€²), we can save a lot of executions. And if we are also able to avoid a trace class in our next trace selection, we can potentially cover all traces of a program.

                              This step is especially useful when dealing with parallel programs. It can be very hard to run into deadlocks or data-races. With trace prediction, we can infer that there exist a datarace in the program even if we don't run into it (Kalhauge 2018).

                              The Path Equivalence Class Β§1.3.1

                              The most interesting trace class is the path-equivalence class: [t]Ο€. In this trace class, we say that all traces that follow the same path through the program are equivalent. This class is interesting because many errors are path-dependent. For example, consider the following buggy program, which checks the wrong part of the division operator:

                              int checkTheWrongThing(int a) { 
                                if (a != 0) { 
                                  return a / 0;
                                } else { 
                                  return 0;
                                }
                              }

                              This program contains nearly infinite (232) tracesβ€”starting from 0, 1, -1, 2, and so onβ€”but it only contains two path equivalence classes: the one ending in an error and the one that does not.

                              In our JVM language, the path equivalence class, is all traces that has has the same bytecode offset in the same order:

                              [Ο„]Ο€={Ο„β€²βˆˆSem(p)Β |Β βˆ€i∈[1,∞).(Ο„β€²i).ΞΉ=(Ο„i).ΞΉ}

                              Coverage Β§1.3.2

                              When writing a dynamic analysis, it is nice to know how much of the possible paths you have covered. We can measure coverage in many different ways. The most common are line coverage, branch coverage, and instruction coverage.

                              Instruction coverage of a set of traces is the set of instructions covered by the traces.

                              #ΞΉ(T)={Ο„i.ΞΉΒ |Β i∈[0,∞],Ο„βˆˆT}

                              The reason instruction coverage is a nice coverage metric is that we can see that the cover of the path equivalence class of a trace is the same as the cover of one element from the class:

                              #ΞΉ([Ο„]Ο€)=#ΞΉ({Ο„})

                              So, if we have uncovered instructions, either we have not found the trace class that covers them, or there is dead code in our program.

                              Trace Abstraction Β§1.3.3

                              Trace abstraction is more of a practical concern. Storing the full state at every step is not only expensive, it is also wasteful. In most cases, we only need very little of the state to find the problem. In the case of an error, we only have to emit the final error code if it happened, and in the case of code coverage, we only have to emit the instruction offset.

                              At one end of the spectrum, some trace analyses can be done completely at runtime, and even allow the system to react to the analysis; this is known as ??.

                              On the other end of the spectrum, some trace analysis might be so computationally heavy or only make sense to run after a bug (or intrusion) has been found. Then it makes a lot of sense to trace just enough to make the analysis.

                              Running the Program Β§2

                              Running the program has some advantages and disadvantages.

                              Why Should We Run the Program? Β§2.1

                              Running the program is often a good initial analysis we can do on most programs, and it has a lot of advantages:

                              • Most programming languages come with a built-in interpreter. Running the program can sometimes be easier than using syntactic analysis.

                              • The analysis can present you with a concrete trace that produces the error. This makes it very easy to debug the error.

                              • Since the goal of the dynamic analysis is to underestimate the set of valid traces, if it warns us about a problem, then the problem is real.

                              Why Shouldn't We Run the Program? Β§2.2

                              It might seem obvious that we can learn things about the program by running it; however, running the program is not always as easy as we would like.

                              • Setting up the correct environment of a program can be hard to downright impossible without running it in production.

                              • Warning that something can happen by doing it can be problematic if the program has side-effects like deleting the database or firing the missiles.

                              • Running the program will never tell you if something can't happen, like the program running forever.

                              Testing Β§3

                              The simplest form for dynamic analysis is testing. In a test, we run the program with a given input and check if it succeeds. Often, in real-life scenarios, we care about properties other than the program not crashing, and those can in most languages be encoded using assertions.

                              Turn your interpreter into a dynamic analysis

                              Your interpreter is actually already a dynamic analysis, but only for cases with no inputs. To use your interpreter as a dynamic analysis do the following:

                              1. If the method takes arguments, exit early, otherwise,

                              2. Run the case with your interpreter,

                              3. Give the case returned yes, and all others no.

                              Hint (Hover to see)

                              In the analyse function in the dynamic solution, expand the section of the queries, to be more certain if there is no inputs:

                              for query in jpamb.QUERIES:
                                  if query in behaviors:
                                      if query == "*":
                                          print(f"{query};timeout")
                                      else:
                                          print(f"{query};found")
                                  else:
                                      print(f"{query};not-found")

                              Add a condition when there is no inputs, to express that you are sure that the problem cannot happen:

                              if len(input.inputs) == 0:
                                  print(f"{query};no")
                              else:
                                  print(f"{query};not-found")

                              Characterization Trace Testing Β§3.1

                              In many cases, it is too complicated (or the assessment of correctness is subjective) to actually check that the output of a test is correct. Instead, we can save the output to a file, or trace the steps taken to produce the output. After we have checked that the trace or output is correct, we can store it in a code repository. We call that a snapshot or (golden master). Every time we re-run the test, if the file did not change, we know the output or trace has remained the same.

                              This technique is prefect at catching regressions.

                              Assertions Β§3.2

                              One specifically useful tool for improving the usefulness of testing is to use assertions to trace that the behavior of the system is correct. The cool idea here is that, by using assertions, we can catch programmer errors early, and closer to the bug, instead of as part of a complicated crash.

                              Tiger Style

                              The Tiger Beetle database uses the Tiger Style. This style explicitly requires 2 assertions per function, which test both positive space (what you expect) and negative space (what you do not expect).

                              For example, a function calculating the area of a square could check that the height and width are both non-zero before doing the calculation.

                              public static int square(int height, int width) {
                                assert height > 0; 
                                assert width > 0;
                                return height * width;
                              }

                              Negative Space Programming

                              When writing your interpreter, add an assertion at every point where you have an assumption. For example, to get started, it is perfectly acceptable to assume that all get operations result in assertionsDisabled and should return false; however, that might not be the case in the more advanced examples.

                              case Get(field=field):
                                assert field.fieldid.name == "assertionsDisabled"
                                frame.stack.push(StackInt(0)) # Push False

                              Inserting these assertions by hand can be a lot of work, and a good dynamic analysis can insert some of them automatically. Since Java is a memory safe language, most out-of-bounds memory accesses are caught by the language at runtime. You can think of this as automatically inserting assertions.

                              For other languages like C, there exist tools to extend capabilities of testing by adding memory checks at runtime. These are called sanitizers.

                              Selecting Input Β§4

                              One of the most successful dynamic analysis strategies is called Fuzzing. Many different kinds of fuzzing exist, but I'm going to use the definition from Zeller (2024). Fuzz testing is about automating software testing by automatically generating tests.

                              Essentially, fuzz testing is all about trace selection.

                              Random Inputs Β§4.1

                              The easiest way to get started with fuzzing is to simply choose an input at random at the start of the program and then see if it can make the program fail.

                              This works because most traces of a program can be approximated using relatively few inputs. The hypothesis is that the input space relatively corresponds to the path-equivalent space, and this is correct for examples like this:

                              void split(int i) { 
                                if (i > 0) { 
                                  ...
                                } else {
                                  ...
                                }
                              }

                              In this world, we can clearly see there are two path-equivalence classes: those where i>0 and i≀0. In this case, choosing i randomly gives an equal chance of hitting either branch.

                              Extend your dynamic analysis

                              Build a new analysis that uses your interpreter. To generate traces, you can choose inputs randomly from their domains and then run the interpreter.

                              Figure out a good limit for how many random inputs to use. Fuzzing more give better precission at the expense of running longer.

                              Hint (Hover to see)

                              In dynamic.py, there is already a fuzz_input method, which converts jvm.Type s into jpamb.case.Input values.

                              Try to extend it to also work for arrays and strings.

                              Dictionaries Β§4.2

                              The problem is, of course, that not all branches are like that; some check for specific values.

                              void isEqualToZero(int i) { 
                                if (i == 0) { 
                                  ...
                                } else {
                                  ...
                                }
                              }

                              In this case, we can use a trick called dictionaries. First, we use a syntactic analysis to scan the code for constants such as 1 and "test". Then we add those constants to a dictionary, which we can sample from when we choose random values.

                              A hybrid analysis

                              Can you improve your analysis by first scanning the input method for interesting values?

                              Hint (Hover to see)

                              Now you are more on your own.

                              Use the skills you got from building the syntatic analysis to scan the classfile with the method in for sample inputs to use when picking random values. You could name it syntactically_all_ints_in_class.

                              Then, instead of picking random values in input, you can first try to pick them from the dictionary:

                              ints_in_class = syntactically_all_ints_in_class(methodid.classname)
                              
                              ...
                              
                              for p in methodid.extension.params:
                                  match p:
                                      case jvm.Int():
                                        if rand.choice([True, False]):
                                          input.append(jpamb.case.Int(rand.choice(ints_in_class)))
                                        else:
                                          input.append(jpamb.case.Int(rand.randint(-(1 << 31), 1 << 31)))
                              

                              If you use this method tag your solution with random, dictionary, syntatic, and dynamic.

                              Coverage-Guided Fuzzing Β§4.3

                              The problem with the above-mentioned approaches is that they are not good at directing the execution down specific paths of the program. Consider the arraySpellsHello test case:

                              @Case("([C: 'h','e','l','l','o']) -> ok")
                              @Case("([C: 'x']) -> assertion error")
                              @Case("([C: ]) -> out of bounds")
                              @Tag({ ARRAY })
                              public static void arraySpellsHello(char[] array) {
                                assert array[0] == 'h'
                                    && array[1] == 'e'
                                    && array[2] == 'l'
                                    && array[3] == 'l'
                                    && array[4] == 'o';
                              }

                              Using random testing or the dictionary approach alone, we would have a hard time finding hello by chance. A cool approach to get around this, and which is used in real-world applications, is Coverage-Guided Fuzzing (Fioraldi 2020).

                              This approach manipulates a list of byte-strings called test cases. The user of the fuzzer is expected to provide a function that can parse those byte-strings into input of the program. Now the fun begins.

                              We start with the empty byte-string as input and then find its coverage (the bytecode offsets it executes). We add that byte-string to a list called interesting.

                              Now, while we have interesting test-cases:

                              • We pick a test-case, and manipulate it, by either changing a byte, removing a byte or appending a byte.

                              • Then we run it, and see if it add coverage compared to the previous cases.

                              • If it does add it to the interesting cases, otherwise ignore it.

                              • Repeat.

                              A Coverage-Guided Analysis

                              Can you improve your analysis by using the coverage-guided technique?

                              Hint (Hover to see)

                              To do so you have change the approach and add a corpus:

                              class Corpus:
                                parameters: jpamb.Parameters
                                interesting: list[bytes]
                                coverage: set[PC]
                               
                                def fuzz_input(self, code: bytes) -> Input:
                                    """Using an sequence of bytes, create a new input which matches 
                                    the parameters"""
                              
                                def next(self) -> Input:
                                    """Randomly pick an intersting case, and mutate by making changes to the bytes"""
                              
                                def add(self, input: Input, coverage: set[PC]):
                                    """Add the input to the interesting cases if increases coverage"""

                              To get the coverage, store all the PC program counters as the interpreter steps through the code.

                              Also take a look at this commit as it shows how to setup a compositional fuzzer, and test it using property based testing (Property Based Testing (Β§4.4)).

                              Tag your solution with random, coverage, and dynamic.

                              Property Based Testing Β§4.4

                              When using random inputs to generate inputs to tests, we also have to change the way we test. Instead of testing for equalities, we now have to test for properties. This approach is called property-based testing.

                              In Python, there is a very nice library called Hypothesis, which helps you do just that. In the following example from the website, they test that their sorting algorithm my_sort is equivalent to the built-in sort on all lists of integers and floats:

                              from hypothesis import given, strategies as st
                              
                              @given(st.lists(st.integers() | st.floats()))
                              def test_sort_correct(lst):
                                  # lst is a random list of numbers
                                  assert my_sort(lst) == sorted(lst)
                              
                              test_sort_correct()

                              Take a look at the extra-fuzzer commit to get feel for how to use hypothesis to test the creation of a fuzzer.

                              The Small-Scope Hypothesis Β§4.5

                              As an alternative to the fuzzing technique, we have the small-scope hypothesis:

                              The Small-Scope Hypothesis

                              Most bugs in a program can be found by investigating only a small section of the inputs.

                              Model-checkers like Alloy (Jackson 2006) and testing frameworks like SmallCheck (Runciman 2008) use this idea by only investigating small test-cases. Instead of testing random numbers, you test only a few small inputs. The good thing about this approach is that

                              • It is deterministic and always test the same inputs, as you can cover all small values to a depth d;

                              • If a buggy input is found, it is the smallest input that can create that error.

                              This technique excel at small cases, like checking of empty lists or stuff like that:

                              void isEqualToZero(int i) { 
                                if (i == 0) { 
                                  ...
                                } else {
                                  ...
                                }
                              }

                              However, the technique does have clear disadvantages: what if the error hides behind a big input?

                              void isEqualToAMillion(int i) { 
                                if (i == 1000000) { 
                                  ...
                                } else {
                                  ...
                                }
                              }

                              In the following activity, you can seek inspiration from the SmallCheck paper Runciman (2008).

                              Make use of the small-check idea

                              Instead of trying random inputs, create a generator that can generate list of all inputs smaller than some depth d.

                              To generate integers you can use the following python generator:

                              def gen_int(depth): 
                                yield 0
                                for i in range(depth):
                                  yield (i + 1)
                                  yield -(i + 1)

                              Now use iterative deepening to slowly increase d∈[1,2,3,…,D] until some max depth D.

                              Tag your solution with smallcheck and dynamic.

                              Create the best analysis you can.

                              Apply some or all of the different techniques for selecting inputs presented above and run your interpreter as a dynamic analysis:

                              $ jpamb -vv analyse --report report.sexp dynamic-analysis

                              Until next time!

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

                              1. Name cases where the choice of a dynamic analysis makes the most sense?

                              2. What are the primary limitation and challenges of dynamic analysis?

                              3. When is random sampling of inputs better than testing small values and vice versa?

                              Bibliography

                              1. Andoni, Alexandr; Daniliuc, Dumitru; Khurshid, Sarfraz; Marinov, Darko (2003). Evaluating the Small Scope Hypothesis. link

                              2. Fioraldi, Andrea; Maier, Dominik; Eißfeldt, Heiko; Heuse, Marc (2020). AFL++: Combining incremental steps of fuzzing research. link

                              3. Jackson, Daniel (2006). Software Abstractions: Logic, Language, and Analysis.

                              4. Kalhauge, Christian Gram; Palsberg, Jens (2018). Sound deadlock prediction. doi:10.1145/3276516 link

                              5. Prikler, Liliana Marie; Wotawa, Franz (2025). Reevaluating the Small-Scope Testing Hypothesis of Answer Set Programs. doi:10.1007/978-3-031-80889-0_6 link

                              6. Runciman, Colin; Naylor, Matthew; Lindblad, Fredrik (2008). Smallcheck and lazy smallcheck. doi:10.1145/1543134.1411292 link

                              7. Zeller, Andreas; Gopinath, Rahul; BΓΆhme, Marcel; Fraser, Gordon; Holler, Christian (2024). The Fuzzing Book. link