Symbolic Execution for Equivalence Checking
Overview
Symbolic execution explores program paths using symbolic values instead of concrete inputs, enabling systematic discovery of behavioral differences between code artifacts.
Core Concepts
Symbolic Values
Replace concrete inputs with symbolic variables:
- Concrete:
x = 5→ compute with 5 - Symbolic:
x = α→ track constraints on α
Path Conditions
Accumulate constraints along execution paths:
if (x > 0):
y = x + 1 # Path condition: x > 0
else:
y = x - 1 # Path condition: x <= 0
Constraint Solving
Use SMT solvers to:
- Check path feasibility
- Generate concrete test inputs
- Find counterexamples to equivalence
Equivalence Checking Workflow
Step 1: Symbolic Execution of Both Artifacts
Execute both artifacts symbolically with same symbolic inputs:
# Artifact A
def artifact_a(x):
if x > 0:
return x * 2
else:
return 0
# Artifact B
def artifact_b(x):
if x > 0:
return x + x
else:
return -x
Symbolic execution:
- Path 1 (x > 0): A returns 2x, B returns 2x → Equivalent
- Path 2 (x ≤ 0): A returns 0, B returns -x → Not equivalent when x < 0
Step 2: Path Exploration Strategies
Depth-First Search (DFS):
- Explore one path completely before backtracking
- Memory efficient
- May miss shallow bugs
Breadth-First Search (BFS):
- Explore all paths at depth k before depth k+1
- Finds shallow bugs quickly
- Higher memory usage
Heuristic-Guided:
- Prioritize paths likely to reveal differences
- Use coverage metrics or complexity heuristics
- Balance exploration efficiency
Step 3: Constraint Collection
For each path pair (path_A, path_B):
PC_A: path condition for Artifact A
PC_B: path condition for Artifact B
Output_A: symbolic output expression for A
Output_B: symbolic output expression for B
Check: PC_A ∧ PC_B ∧ (Output_A ≠ Output_B)
If satisfiable → Found counterexample
If unsatisfiable → Paths are equivalent
Step 4: Counterexample Generation
When difference found:
- Extract satisfying assignment from solver
- Convert symbolic values to concrete test case
- Verify difference with concrete execution
- Report input demonstrating non-equivalence
Tools and Frameworks
KLEE
Symbolic execution engine for C/LLVM:
#include <klee/klee.h>
int artifact_a(int x) {
if (x > 0) return x * 2;
return 0;
}
int artifact_b(int x) {
if (x > 0) return x + x;
return -x;
}
int main() {
int x;
klee_make_symbolic(&x, sizeof(x), "x");
int result_a = artifact_a(x);
int result_b = artifact_b(x);
klee_assert(result_a == result_b);
return 0;
}
Run: klee --emit-all-errors program.bc
SymDiff
Differential symbolic execution tool:
- Compares two program versions
- Identifies semantic differences
- Generates difference summary
Angr
Python framework for binary analysis:
import angr
# Load both binaries
proj_a = angr.Project('artifact_a.bin')
proj_b = angr.Project('artifact_b.bin')
# Create symbolic state
state_a = proj_a.factory.entry_state()
state_b = proj_b.factory.entry_state()
# Add symbolic input
x = state_a.solver.BVS('x', 32)
state_a.memory.store(0x1000, x)
state_b.memory.store(0x1000, x)
# Explore paths
simgr_a = proj_a.factory.simulation_manager(state_a)
simgr_b = proj_b.factory.simulation_manager(state_b)
simgr_a.explore()
simgr_b.explore()
# Compare outputs
for state_a in simgr_a.deadended:
for state_b in simgr_b.deadended:
if state_a.solver.satisfiable(extra_constraints=[
state_a.regs.rax != state_b.regs.rax
]):
print("Found difference:", state_a.solver.eval(x))
Java PathFinder (JPF)
Symbolic execution for Java:
import gov.nasa.jpf.symbc.Debug;
public class Equivalence {
public static int artifactA(int x) {
if (x > 0) return x * 2;
return 0;
}
public static int artifactB(int x) {
if (x > 0) return x + x;
return -x;
}
public static void main(String[] args) {
int x = Debug.makeSymbolicInteger("x");
int resultA = artifactA(x);
int resultB = artifactB(x);
assert resultA == resultB : "Not equivalent";
}
}
Handling Complexity
Path Explosion
Problem: Exponential growth in number of paths
Solutions:
- Path merging: Combine similar paths using disjunctions
- Loop bounds: Limit loop iterations (k-bounded exploration)
- Function summaries: Precompute summaries for called functions
- Compositional analysis: Verify components separately
Constraint Complexity
Problem: Complex constraints slow down solver
Solutions:
- Incremental solving: Reuse solver state across paths
- Constraint simplification: Apply algebraic simplifications
- Theory-specific optimizations: Use specialized solvers
- Concretization: Replace symbolic values with concrete ones when beneficial
Memory Modeling
Problem: Symbolic pointers and heap operations
Solutions:
- Lazy initialization: Create objects on-demand
- Address concretization: Use concrete addresses when possible
- Shape analysis: Abstract heap structure
- Bounded heap: Limit heap size for analysis
Differential Symbolic Execution
Optimize for equivalence checking:
Key insight: Only explore paths where artifacts diverge
Algorithm:
- Execute both artifacts in lockstep
- When control flow diverges, fork exploration
- When control flow reconverges, check output equivalence
- Prune equivalent paths early
Benefits:
- Fewer paths to explore
- Faster counterexample discovery
- Scales better than independent analysis
Practical Guidelines
When to use symbolic execution:
- Moderate-sized functions (< 1000 LOC)
- Programs with complex branching logic
- When concrete test generation is difficult
- Security-critical code paths
Limitations:
- Path explosion for large programs
- Constraint solver timeouts
- External dependencies (I/O, network)
- Native code and system calls
Best practices:
- Set reasonable time/path bounds
- Focus on critical code sections
- Combine with concrete testing
- Use function summaries for libraries
- Monitor solver performance
Example: Complete Workflow
# Using Z3 for simple symbolic execution
from z3 import *
def symbolic_execute_equivalence(artifact_a, artifact_b, input_vars):
"""
Check equivalence using symbolic execution
Returns: (is_equivalent, counterexample)
"""
solver = Solver()
# Execute both artifacts symbolically
result_a = artifact_a(*input_vars)
result_b = artifact_b(*input_vars)
# Check if outputs can differ
solver.add(result_a != result_b)
if solver.check() == sat:
# Found counterexample
model = solver.model()
counterexample = {str(v): model[v] for v in input_vars}
return False, counterexample
else:
# Proven equivalent
return True, None
# Example usage
x = Int('x')
y = Int('y')
def artifact_a(x, y):
return If(x > y, x - y, y - x)
def artifact_b(x, y):
return If(x > y, x - y, x - y) # Bug: should be y - x
is_equiv, counter = symbolic_execute_equivalence(artifact_a, artifact_b, [x, y])
if is_equiv:
print("Artifacts are equivalent")
else:
print(f"Not equivalent. Counterexample: {counter}")