A Concrete Example of Symbolic Execution

Introduction

In my last post, I argued that testing is a poor substitute for reasoning about our code, and provided a crude illustration of what I meant by "reason about". In this post I'm going to explore one way in which we can reason about code more deeply & show a real-world example.

Symbolic Execution– Preamble

When I was a young(er), I would sometimes sit with a paper & pencil and "work through" some code I had written, either to be sure it worked, or to figure out why it wasn't (this was 6502 assembly on an Apple II+– no symbolic debuggers!) Each variable got a line, and as I walked through my code manually, I'd scratch through the old & write down the new value for each variable as it changed. So, for instance, thinking about a function like this:

int
highly_contrived(int *A, int nA, int x, int y)
{
  if (x + 2*y > 3) {     // 1
    ++x;                 // 2
    y = x/2;             // 3
    return A[3*x - 2*y]; // 4
  } else {
    --x;                 // 5
    y += x;              // 6
    return A[y + 2*x];   // 7
  }
}

would lead to something like this:

concrete-exec-by-hand.png

We can see that for \(A = [1, 2, 3], nA = 3, x = y = 1\), this function returns \(\mathcal{A}[1] = 2\)– all well & good.

We might imagine input values for which the array indicies could be out-of-bounds…

bad-concrete-exec-by-hand.png

but what if we can't imagine input values that make our code fail? This approach works when I've got a specific set of inputs in which I'm interested & I want to see what my code does when I hand it those particular inputs. It doesn't answer questions like "do there exist any inputs for which the array indices will be out of bounds?"

There's a different approach we can take: imagine going through this exercise, but instead of assigning values like "11", or "-17" to our variables, we're going to write down things we know about them:

symex-by-hand.png

Cool. So now we can characterize our array indicies in terms of our inputs:

  • if \(x + 2y > 3\), then we're accessing \(\mathcal{A}\) at index \(2(x+1)\)
  • if \(x + 2y <= 3\), then we're accessing \(\mathcal{A}\) at index \(y + 3(x-1)\)

Now we can say precisely what we're looking for in our inputs: are there any \(x\), \(y\), and \(\mathcal{A}\) such that:

  • \(x + 2y > 3\) and either \(2(x+1) < 0\) or \(2(x+1) >= |\mathcal{A}|\)
  • or, \(x + 2y <= 3\) and either \(y + 3(x-1) < 0\) or \(y + 3(x-1) >= |\mathcal{A}|\)

In other words, are there any \(x, y, \mathcal{A}\) that will make our array index operations be out of bounds?

Satisfiability Modulo Theories

What if we had an oracle of which we could ask questions like: are there any \(x, y, |\mathcal{A}|\) such that:

\begin{equation} x + 2y > 3 \land (0 > 2(x+1) \lor |\mathcal{A}| <= 2(x+1)) \end{equation}

If we had such a thing (and could code to it), then we would be reasoning about our code. If no such values exist, we know our code is sound in a way beyond being unable to come up with negative test cases. If there are such values, then the oracle has handed us a negative test on a silver platter.

Well, there are such oracles, we can program to them & they're called "SMT Solvers". This is going to be a whirlwind introduction to them, but I hope to write more deeply on them later. In the meantime, you can read Dennis Yurichev's very accessible SAT/SMT by Example or the Z3 paper.

"SMT" stands for "Satisfiability Modulo Theories". "Satisfiability" refers to a well-known NP-complete problem: boolean satisfiabilty, or just "SAT" for short. The problem is deceptively simple. Given a set of boolean variables \(x_i, i=1,...n\) determine whether there is any assignment to those variables such that the equation

\begin{equation} (X_1 \lor X_2 \lor X_3...) \land ... \end{equation}

where each \(X_i\) is either \(x_j\) or \(\lnot x_j\) for some \(j\). Given an assignment to the \(x_i\), checking whether that assignment satisfies the given formula is trivial. But finding an assignment that satisfies the formula… well, that takes some doing. The problem is an exemplar of the reason so many researches suspect that \(P \neq NP\): it is an NP-complete problem that has been subjected to exhaustive study: if \(P\) were equal to \(NP\), surely this problem (or some other NP-complete problem) would have fallen by now, given the collective brain-power devoted to solving it. Regardless, while the worst-case performance of every known algorithm is exponential, when it comes to real-world problems we'd like to solve, it turns out we can do quite a bit better than that.

SMT builds upon SAT to extend the domain of statements about which we can reason beyond boolean variables to propositional logic: asking whether there are assignments that can satisfy statements containing not just boolean variables (and their negations), but propositions involving variables over other domains. What domains? Things like the integers or bit vectors. What propositions? Things like "greater than", or "is a power of 2".

One such SMT solver is Z3; let's ask Z3 about our array index operations:

(declare-const x Int)
(declare-const y Int)
(declare-const nA Int)
(assert (> (+ x (* 2 y)) 3))
(assert (or (< 0 (* 2 (+ x 1)))
            (>= (* 2 (+ x 1)) nA)))
(check-sat)
(get-model)

This is a little program in smt-lib. We begin by declaring three variables \(x\), \(y\), & \(nA\). Let's then check our first branch: we assert that \(x + 2y > 3\) and then assert that the resulting array index is out-of-bounds by either being less than zero or greater than the largest index. Finally, we ask Z3 to check whether our conditions are satisfiable and, if so, to give us an example.

z3 -smt2 first.smt2
sat
(
  (define-fun x () Int
    0)
  (define-fun y () Int
    2)
  (define-fun nA () Int
    0)
)

It turns out that it is: if \(x = 0\), \(y = 2\), and \(nA = 0\) then \(x + 2y = 4 > 3\) so we take the "if branch". \(x :=> 1\), \(y := 0\), \(3x - 2y = 3 \geq nA\).

But that's kind of dumb… let's constrain \(|\mathcal{A}| > 0\).

(declare-const x Int)
(declare-const y Int)
(declare-const nA Int)
(assert (> nA 0))
(assert (> (+ x (* 2 y)) 3))
(assert (or (< 0 (* 2 (+ x 1)))
            (>= (* 2 (+ x 1)) nA)))
(check-sat)
(get-model)

Well, that turns out to be trivial to satisfy, as well:

z3 -smt2 first.smt2
sat
(
  (define-fun nA () Int
    1)
  (define-fun x () Int
    0)
  (define-fun y () Int
    2)
)

Alas, it seems that our highly contrived function is not salvagable.

Symbolic Execution – Practice

This is all very interesting, but can it be made into a practical tool for reasoning about code? Perhaps it can. The idea of coding up the process of my crude illustration of "working through" your code, writing down what is known as you go, and then asking questions of an SMT solver at interesting points has been implemented. In fact there are many such "symbolic exection engines" out there.

One of the better-known is klee. Klee is a symbolic execution engine that operates on LLVM bitcode. LLVM is a toolkit for building compilers. Clang is based on it, as is rustc. You can ask LLVM to compile your code to an intermediate representation called "bitcode". Think of klee as an interpreter for LLVM bitcode. klee will begin "executing" your program, keeping track of variable values if those variables are "concrete" and noting down things we know about their values if they are "symbolic". At branch points (like an if-then-else statement), klee will use an SMT solver to see if both branches are feasible– that is, are there any inputs for which each branch might be taken? If so, klee "forks" your process. Not literally, in the Linux sense of hte word, but in terms of its interpretation: it will create a copy of the program's state for each branch, add the condition that jumps to the "if" branch to what is known about that copy, and the same, mutatis mutundis for the "else" branch.

Symbolic execution engines generally have to address two problems:

  1. the "path explosion" problem: in any non-trivial program, the number of paths through the code grows exponentially. klee addresses this by employing configurable heuristics as to which branch(es) it explores first
  2. the "environment" problem: how shall the symbolic executor model input from the ambient execution environemnt? If the program under test, for instance, calls fread: how shall the symbolic execution engine model that? That's a subject for a later post.

Klee has shown real-world results: they validated the GNU coreutils & found three bugs which had been present for over fifteen years.

So I Was Doing Some L33t Coding…

I recently changed jobs. I wanted a job that involved coding, which meant coding interviews (which I loathe) & that meant l33t coding (which I loathe even more). Here's a test question from "Cracking the Coding Interview" along with my initial solution:

// Cracking the Coding Interview problem 11.1
// Given two sorted arrays A & B, assume A has enough buffer at its end to
// store B, merge B into A (in sorted order).

// nA & nB are the sizes of A & B, resp. lastA points one-past-the end
// of A's contents:
//    0   1       lastA - 1  lastA    nA - 1
// | A_0 A_1 ... A_{lastA-1}   *    ...  * |
void
merge(int *A, int nA, int lastA, int *B, int nB)
{
  int a = lastA - 1, b = nB - 1, ins = nA - 1; // insert at `ins`
  while (ins > -1) {
    if (A[a] > B[b]) {
      A[ins--] = A[a--];
    } else {
      A[ins--] = B[b--];
    }
  }
}

My scheme was to walk \(\mathcal{A}\) backward while comparing the last un-copied elements of \(\mathcal{A}\) & \(\mathcal{B}\), copying the greater element in each comparison into place:

ctci-11.1.png

Cool, right? Tight & elegant. Employers will be drooling over me!

But.. is it right? Really right? Interviewers love to probe for corner cases, after all. Let's put klee on it.

klee only operates on a complete program, so we'll need to build up a test harness:

#include <klee/klee.h>

#include <stdlib.h>

// Cracking the Coding Interview problem 11.1
// Given two sorted arrays A & B, assume A has enough buffer at its end to
// store B, merge B into A (in sorted order).

// nA & nB are the sizes of A & B, resp. lastA points one-past-the end
// of A's contents:
//    0   1       lastA - 1  lastA    nA - 1
// | A_0 A_1 ... A_{lastA-1}   *    ...  * |
void
merge(int *A, int nA, int lastA, int *B, int nB)
{
  int a = lastA - 1, b = nB - 1, ins = nA - 1; // insert at `ins`
  while (ins > -1) {
    if (A[a] > B[b]) {
      A[ins--] = A[a--];
    } else {
      A[ins--] = B[b--];
    }
  }
}

int
main(int argc, char *argv[])
{
  int nA = 16, lastA = 8, nB = 8;
  int *A = (int*) malloc(nA*sizeof(int));
  int *B = (int*) malloc(nB*sizeof(int));

  klee_make_symbolic(A, nA*sizeof(int), "A");
  klee_make_symbolic(B, nB*sizeof(int), "B");

  for (int i = 0; i < 7; ++i) {
    klee_assume(A[i] <= A[i+1]);
    klee_assume(B[i] <= B[i+1]);
  }

  merge(A, nA, lastA, B, nB);

  for (int i = 0; i < 15; ++i) {
    klee_assert(A[i]  <= A[i+1]);
  }

  free(A);
  free(B);

  return 0;
}

By default, klee will treat our variables as "concrete"; i.e. they hold specific values, like 11, or "Hello, world!". klee_make_symbolic tells klee to treat its argument as, well, symbolic. Instead of holding a particular value like 11, klee will begin writing-down things like "a 32-bit int which is known to be > 3". Here, I'm making \(\mathcal{A}\) & \(\mathcal{B}\) be arrays of length 16 & 8 respectively, but allowing their contents to be any integers at all.

Next, I setup the preconditions via klee_assume. klee_assume lets us tell klee things it should assume to be true. In this case, I'm telling klee to assume that \(\mathcal{B}\) is sorted along with the first eight elements of \(\mathcal{A}\).

Next I call merge and tell klee to prove that the final value of \(\mathcal{A}\) is sorted.

OK– let's fire this up. I put all this code into merge-1.c & compiled to bitcode:

clang-9 -I /opt/klees/current/include -emit-llvm -c -g -O0 -Xclang -disable-O0-optnone -o merge-1.bc merge-1.c
merge-1.c:18:5: warning: implicit declaration of function '__assert_fail' is invalid in C99 [-Wimplicit-function-declaration]
    klee_assert(a >= 0 && a < nA);
    ^
/opt/klees/current/include/klee/klee.h:96:6: note: expanded from macro 'klee_assert'
... a few warnings

& then ran klee against that:

ls
merge-1.bc  merge-1.c

time klee --libc=uclibc --posix-runtime --only-output-states-covering-new --optimize merge-1.bc
KLEE: NOTE: Using POSIX model: 
KLEE: NOTE: Using klee-uclibc : 
KLEE: output directory is ".../klee-out-0"
KLEE: Using STP solver backend
KLEE: WARNING: executable has module level assembly (ignoring)
KLEE: WARNING ONCE: calling external: syscall(16, 0, 21505, 93879719616896) at runtime/POSIX/fd.c:1007 10
KLEE: WARNING ONCE: Alignment of memory from call "malloc" is not modelled. Using alignment of 8.
KLEE: ERROR: merge-1.c:18: memory error: out of bound pointer
KLEE: NOTE: now ignoring this error at this location
KLEE: ERROR: merge-1.c:18: memory error: out of bound pointer
KLEE: NOTE: now ignoring this error at this location

KLEE: done: total instructions = 836510
KLEE: done: completed paths = 0
KLEE: done: partially completed paths = 12870
KLEE: done: generated tests = 2
183.82user 87.06system 4:31.76elapsed 99%CPU

Huh… looks like we may have a problem. klee found two execution paths leading to out-of-bounds errors, and left the output in a sub-directory named klee-out-0. In general, klee will place output files in a sub-directory named klee-N where \(N\) is 0,1,2…:

ls -Alh
total ...
lrwxrwxrwx  1 mgh  users   64 Aug 11 07:17 klee-last -> .../klee-out-0
drwxr-xr-x  2 mgh  users 4.0K Aug 11 07:21 klee-out-0
-rw-r--r--  1 mgh  users  12K Aug 11 07:15 merge-1.bc
-rw-r--r--  1 mgh  users 2.1K Aug  7 07:19 merge-1.c
sp1ff: ls -Alh klee-last/
total 2.0M
-rw-r--r-- 1 mgh users 1.8M Aug 11 07:17 assembly.ll
-rw-r--r-- 1 mgh users  776 Aug 11 07:21 info
-rw-r--r-- 1 mgh users  257 Aug 11 07:17 messages.txt
-rw-r--r-- 1 mgh users 159K Aug 11 07:21 run.istats
-rw-r--r-- 1 mgh users  28K Aug 11 07:21 run.stats
-rw-r--r-- 1 mgh users  476 Aug 11 07:17 test000001.ptr.err
-rw-r--r-- 1 mgh users 1.2K Aug 11 07:17 test000001.kquery
-rw-r--r-- 1 mgh users  178 Aug 11 07:17 test000001.ktest
-rw-r--r-- 1 mgh users  478 Aug 11 07:17 test000002.ptr.err
-rw-r--r-- 1 mgh users 1.3K Aug 11 07:17 test000002.kquery
-rw-r--r-- 1 mgh users  178 Aug 11 07:17 test000002.ktest
-rw-r--r-- 1 mgh users  267 Aug 11 07:17 warnings.txt

Okay… let's have a look at the first error:

bat klee-last/test000001.ptr.err
───────┬───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
       │ File: klee-out-4/test000001.ptr.err
───────┼───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
   1   │ Error: memory error: out of bound pointer
   2   │ File: merge-1.c
   3   │ Line: 20
   4   │ assembly.ll line: 4003
   5   │ State: 1
   6   │ Stack:
   7   │     #000004003 in merge (=94489744893312, =94489772997344) at merge-1.c:20
   8   │     #100004166 in __klee_posix_wrapped_main () at merge-1.c:47
   9   │     #200002347 in __user_main (=1, =94489744554496, =94489744554512) at runtime/POSIX/klee_init_env.c:245
  10   │     #300000440 in __uClibc_main (=1, =94489744554496) at libc/misc/internals/__uClibc_main.c:401
  11   │     #400000606 in main (=1, =94489744554496)
  12   │ Info:
  13   │     address: 94489744893308
  14   │     next: object at 94489743961088 of size 58
  15   │         MO17[58] allocated at main():  tail call fastcc void @__uClibc_main(i32 %0, i8** %1)
───────┴───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────

Line 20 is where we compare the "next" elements of \(\mathcal{A}\) & \(\mathcal{B}\):

void
merge(int *A, int nA, int lastA, int *B, int nB)
{
  int a = lastA - 1, b = nB - 1, ins = nA - 1; // insert at `ins`
  while (ins > -1) {
    if (A[a] > B[b]) { <== line 20!

… but what happened? Was the error with \(a\) or \(b\)? And was it too high or too low? And why?

klee not only detected that we had an out-of-bounds error, it produced a test case for us. Unlike the "err" file, it's not plain text; it needs to be read by a dedicated program, ktest-tool:

ktest-tool klee-last/test000001.ktest
ktest file : 'klee-last/test000001.ktest'
args       : ['merge-1.bc']
num objects: 3
object 0: name: 'model_version'
object 0: size: 4
object 0: data: b'\x01\x00\x00\x00'
object 0: hex : 0x01000000
object 0: int : 1
object 0: uint: 1
object 0: text: ....
object 1: name: 'A'
object 1: size: 64
object 1: data: b'\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x01\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff'
object 1: hex : 0x0000000100000001000000010000000100000001000000010000000100000001ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
object 1: text: ................................................................
object 2: name: 'B'
object 2: size: 32
object 2: data: b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'
object 2: hex : 0x0000000000000000000000000000000000000000000000000000000000000000
object 2: text: ................................

There are \(\mathcal{A}\) & \(\mathcal{B}\): they are 64 & 32 bytes in size, respectively, but if we interpret their contents as 32-bit signed integers, they are:

\begin{equation} \mathcal{A} := [16777216, 16777216, ..(5)., 16777216, -1, -1, ..(5)., -1] \\ \mathcal{B} := [0, 0, 0, ..(5).] \end{equation}

Every element of \(\mathcal{A}\) is larger than every element of \(\mathcal{B}\). Meaning that we'll always take the "if" branch in merge, copying \(\mathcal{A}[a]\) to the next insertion point and decrementing \(a\). Oh… meaning that when we finish copying the elements of \(\mathcal{A}\) into place, \(a\) will be -1, and on the next iteration of our loop, when we should start copying the elements of \(\mathcal{B}\) into place, we'll check \(\mathcal{A}[-1]\):

bad-index-in-a.png

OK, well, I can fix that. What about the other case? When every element of \(\mathcal{B}\) is greater than every element of \(\mathcal{A}\)? That should be fine: we'll take the "else" on ever iteration of our loop, copy the elements of \(\mathcal{B}\) into place… and be done, so long as we terminate early enough to avoid evaluating \(\mathcal{B}[-1]\):

void
merge(int *A, int nA, int lastA, int *B, int nB)
{
  int a = lastA - 1, b = nB - 1, ins = nA - 1; // insert at `ins`
  while (ins > -1 && a > -1 && b > -1) {
    if (A[a] > B[b]) {
      A[ins--] = A[a--];
    } else {
      A[ins--] = B[b--];
    }
  }

  if (a < 0) {
    // all of A was greater than all of B-- copy B into A
    while (ins > -1) {
      A[ins--] = B[b--];
    }
  } /* else if (b < 0) {
    // all of B was greater than all of A-- done
  } */
}

Re-compile & re-test:

clang-9 -I /opt/klees/current/include -emit-llvm -c -g -O0 -Xclang -disable-O0-optnone -o merge-2.bc merge-2.c
  merge-2.c:19:7: warning: implicit declaration of function '__assert_fail' is invalid in C99 [-Wimplicit-function-declaration]
        klee_assert(ins >= 0 && ins < nA);
        ^
  /opt/klees/current/include/klee/klee.h:96:6: note: expanded from macro 'klee_assert'
     : __assert_fail (#expr, __FILE__, __LINE__, __PRETTY_FUNCTION__))    \
         ^
  ... some warnings

time klee --libc=uclibc --posix-runtime --only-output-states-covering-new --optimize merge-2.bc
KLEE: NOTE: Using POSIX model: ...
KLEE: NOTE: Using klee-uclibc : ...
KLEE: output directory is ".../klee-out-1"
KLEE: Using STP solver backend
KLEE: WARNING: executable has module level assembly (ignoring)
KLEE: WARNING ONCE: calling external: syscall(16, 0, 21505, 94249972569680) at runtime/POSIX/fd.c:1007 10
KLEE: WARNING ONCE: Alignment of memory from call "malloc" is not modelled. Using alignment of 8.

KLEE: done: total instructions = 3127359
KLEE: done: completed paths = 12870
KLEE: done: partially completed paths = 0
KLEE: done: generated tests = 3
1138.67user 2054.16system 53:21.29elapsed 99%CPU

This execution took considerably longer (almost 20 minutes), but completed with no errors detected & three test cases:

\begin{equation} \mathcal{A} := [0, 0, 0, 0, 0, 0, 0, 0, -1, -1, -1, -1, -1, -1, -1, -1] \\ \mathcal{B} := [0, 0, 0, 0, 0, 0, 0, 0] \end{equation}

In this case, we'll take the "else" branch in "merge" every time, until \(b\) becomes -1, at which point we're done.

\begin{equation} \mathcal{A} := [16777216, 16777216, ..(5)., 16777216, -1, -1, ...] \\ \mathcal{B} := [0, 0, 0, 0, 0, 0, 0, 0] \end{equation}

This was the case that tripped me up in my first implementation: we'll take the "if" branch every time until \(a\) is -1, and then copy \(\mathcal{B}\) into place in the "tidy-up" loop at the bottom.

\begin{equation} \mathcal{A} := [1, 1, 1, 1, 1, 1, 1, 2, -1, -1, -1, -1, -1, -1, -1, -1] \\ \mathcal{B} := [0, 0, 0, 0, 0, 0, 0, 1] \end{equation}

This is the most interesting case: we'll take both arms of the if-then-else construct, and hit the "tidy-up" loop at the bottom.

Given the variation in control paths, I'd say the three cases klee selected would make a very reasonable start to a unit test suite.

Where are we?

At this point, I've proven my algorithm works for all arrays where \(|\mathcal{A}| = 16\) and \(|\mathcal{B}| = 8\). That's not complete, but it inspires confidence.

Klee can be a bit intrusive. You have to compile your code specially (i.e. to bitcode) as well as carrying out your native build process. If your program links to other libraries, you need to compile them to bitcode as well in order to get true symbolic execution when your code calls into those libraries.

It can also be slow: unearthing my bug took about three minutes. Three minutes to run a test suite is fine, in my opinion– you can get a cup of coffee. Showing that there were none left for my limited case took twenty. Twenty minutes is a problem: you're going to context-switch. I went ahead and made the array sizes symbolic as well (with max sizes of 16 & 12) and let it run for twelve hours– it never terminated. Alright it's a big problem, but klee appeared not to take advantage of the resources available to it: I kept an eye on htop while it was running, it never came close to taking up the 32Gb of RAM I gave it, and it never seemed to take up anything more than one core, even though it had thirty-two available.

On the other hand, one could argue that it was a silly question to ask of klee. Randomly trying different sizes could work, but only up to some limit, and seems like a blunt instrument in any event. I'd like to use klee to prove something like "merge is correct for \(|\mathcal{A}| = n\) and all \(\mathcal{B}\) where \(|\mathcal{B}| < n\)", for some small value of \(n\), and then prove "assuming that, show merge is correct for \(n+1\)". At this point, however, I think we're out of the realm of symbolic execution (and into that of automated theorem provers?)

I'm going to be trying some other tools (haybale, Crux, and angr are on my list) on this, as well as putting them all on some other test problems. My goal is to build a toolkit of some kind that the working coder could use like a unit test suite: but instead of writing a unit test for a new function, they could prove statements about their new function. Just as starting with your unit test leads to "test-driven develeopment", I'm curious as to what "reasonability-driven development" would look like.

08/12/21 07:26