courses

Symbolic Execution with angr

Overview

Symbolic execution runs a program with symbolic inputs instead of concrete values. Rather than feeding the program one password and seeing if it works, the engine treats the input as a mathematical variable. As execution reaches each branch, it records the constraint that branch imposes (input[0] == 'M', sum(input) == 0x539, …). When execution reaches a target — “Access granted”, a hidden payload, a decryption routine — the accumulated constraints are handed to an SMT solver (Z3), which produces a concrete input satisfying all of them at once. That input is the answer: the password, the license key, the trigger condition.

For malware analysis this is powerful because it answers questions that are tedious to brute-force:

angr is the de-facto open-source binary analysis platform built around symbolic execution. It loads almost any architecture (x86/x86-64, ARM, MIPS, PPC, …) via its CLE loader and VEX/pyvex lifter, runs symbolic execution through its SimEngine, and solves constraints with the claripy front-end over Z3. It works on stripped, static binaries with no source.

Version note (flag for staleness): angr moves quickly — the current line is the 9.2.x series (e.g. 9.2.192, late 2025), requiring Python 3.10+. APIs are mostly stable across 9.2.x, but pin a version in coursework (pip install angr==9.2.192) so scripts stay reproducible across terms.


Installation

angr ships as prebuilt wheels on PyPI for common platforms. Always install into an isolated environment — it pulls a large dependency tree (pyvex, claripy, cle, z3-solver, unicorn).

python3 -m venv ~/venv/angr        # Python 3.10+ required
source ~/venv/angr/bin/activate
pip install angr                   # or: pip install angr==9.2.192

Verify:

python -c "import angr, claripy; print(angr.__version__)"

On REMnux the venv approach above is the cleanest; do not install angr system-wide. A Docker image (angr/angr) is also published if you prefer a disposable container for running untrusted binaries.

Safety: angr emulates code — it does not natively execute the target’s instructions on your CPU. That makes it far safer than dynamic analysis for studying malware. But you still have the sample on disk, and any SimProcedures, hooks, or concrete execution you add can change that. Do this work in your isolated analysis VM, not on your host.


Core concepts

A typical angr workflow uses five objects. Understanding these makes every example below readable.

Object Created by Role
Project angr.Project('./bin') The loaded binary + architecture metadata
State proj.factory.entry_state() A snapshot of registers, memory, and constraints
Symbolic value claripy.BVS('name', bits) An unknown bitvector (the input we solve for)
SimulationManager proj.factory.simgr(state) Drives execution; organizes states into stashes
Solver state.solver Adds constraints and evaluates symbolic values

Loading a binary

import angr

proj = angr.Project('./target', auto_load_libs=False)

auto_load_libs=False tells CLE not to load the real libc and friends. This is almost always what you want: loading real libraries explodes the state space and slows everything down. angr substitutes SimProcedures — Python models of common library functions (printf, strcmp, scanf, …) — instead.

Creating an initial state

Factory method Use when
proj.factory.entry_state() Start at the program’s entry point (most common)
proj.factory.full_init_state() Like entry_state, but runs libc initializers first
proj.factory.blank_state(addr=…) Start mid-program at an arbitrary address
proj.factory.call_state(addr, arg1, …) Start as if calling one specific function

Stashes

The SimulationManager sorts states into stashes as they execute:

Stash Meaning
active States still being stepped
deadended States that hit a normal exit/return — nowhere left to go
found States that satisfied the find condition
avoid States that hit the avoid condition
errored States that raised an error (access via record.state, record.error)

The explore pattern

The single most common operation: step every active state until one reaches the target address (find), pruning any that wander into a failure address (avoid).

simgr = proj.factory.simgr(state)
simgr.explore(find=0x400620, avoid=0x400644)

if simgr.found:
    solution_state = simgr.found[0]
    print(solution_state.posix.dumps(0))   # the bytes sent to stdin

find and avoid accept an address, a list of addresses, or a lambda over the state (e.g. lambda s: b"Correct" in s.posix.dumps(1)). posix.dumps(0) dumps stdin; posix.dumps(1) dumps stdout.


Worked example 1 — Recover a hardcoded password (symbolic argv)

The canonical first target: a program that compares its first argument against a hardcoded password. We’ll build it so the example is fully reproducible, then solve it without reading the password out of the source.

pwcheck.c:

#include <stdio.h>
#include <string.h>

int main(int argc, char **argv) {
    if (argc != 2) {
        printf("usage: %s <password>\n", argv[0]);
        return 1;
    }
    if (strcmp(argv[1], "h4cktheplan3t") == 0)
        printf("Access granted\n");
    else
        printf("Access denied\n");
    return 0;
}

Compile it (disable PIE so addresses are stable and easy to read):

gcc -no-pie -fno-stack-protector -o pwcheck pwcheck.c

Find the addresses of the two printf calls with objdump -d pwcheck (or r2/Ghidra). Say “Access granted” is reached at 0x401190 and “Access denied” at 0x4011a4. Now solve:

solve_pwcheck.py:

import angr
import claripy

proj = angr.Project('./pwcheck', auto_load_libs=False)

# A 16-byte symbolic argument. Each byte is a symbolic 8-bit value.
arg_len = 16
password = claripy.BVS('password', arg_len * 8)

# Start at entry with argv[1] = our symbolic bitvector.
state = proj.factory.entry_state(args=['./pwcheck', password])

# Constrain every byte to printable ASCII so the answer is human-readable.
for i in range(arg_len):
    byte = password.get_byte(i)
    state.solver.add(byte >= 0x20, byte <= 0x7e)

simgr = proj.factory.simgr(state)
simgr.explore(find=0x401190, avoid=0x4011a4)

if simgr.found:
    found = simgr.found[0]
    solution = found.solver.eval(password, cast_to=bytes)
    print("Password:", solution.rstrip(b'\x00').decode(errors='replace'))
else:
    print("No solution found.")
$ python solve_pwcheck.py
Password: h4cktheplan3t

angr never knew the password. It tracked the byte-by-byte constraints that strcmp imposes on the way to 0x401190, then asked Z3 for a string that meets them. This is exactly how you’d recover an undocumented activation string from a real sample — point find at the “success” branch instead of reading the comparison by hand.


Worked example 2 — Serial check over stdin (find by output string)

Real programs usually read from stdin, and you often don’t want to hunt for the exact success address. Instead, find on the output text. This target reads a serial and validates a per-character arithmetic relationship.

serial.c:

#include <stdio.h>
#include <string.h>

int main(void) {
    char buf[32];
    printf("Enter serial: ");
    if (!fgets(buf, sizeof(buf), stdin)) return 1;
    buf[strcspn(buf, "\n")] = 0;          // strip newline

    if (strlen(buf) != 8) { printf("Wrong\n"); return 1; }

    // Each char must be 3 more than the previous, starting at 'A'.
    for (int i = 0; i < 8; i++)
        if (buf[i] != 'A' + i * 3) { printf("Wrong\n"); return 1; }

    printf("Correct! Welcome.\n");
    return 0;
}
gcc -no-pie -o serial serial.c

solve_serial.py:

import angr
import claripy

proj = angr.Project('./serial', auto_load_libs=False)

# Build an 8-byte symbolic serial + a newline (fgets keeps the '\n').
serial = [claripy.BVS(f'b{i}', 8) for i in range(8)]
stdin_bv = claripy.Concat(*serial, claripy.BVV(b'\n'))

state = proj.factory.entry_state(stdin=stdin_bv)
for b in serial:
    state.solver.add(b >= 0x20, b <= 0x7e)   # printable

simgr = proj.factory.simgr(state)
simgr.explore(
    find=lambda s: b"Correct" in s.posix.dumps(1),
    avoid=lambda s: b"Wrong"  in s.posix.dumps(1),
)

if simgr.found:
    answer = simgr.found[0].solver.eval(
        claripy.Concat(*serial), cast_to=bytes)
    print("Serial:", answer.decode())
$ python solve_serial.py
Serial: ADGJMPSV

Two things to note. First, we passed stdin=stdin_bv so angr feeds our symbolic bitvector when the program reads. Second, the find/avoid lambdas inspect posix.dumps(1) (stdout) — no address hunting required. This output-string pattern is the most robust way to drive angr against unfamiliar binaries.


Worked example 3 — Trigger a dormant payload (logic bomb)

A logic bomb stays inert until some condition is met — a magic command code, a specific date, an environment value. Symbolic execution finds the trigger directly. This target hides a “payload” branch behind an opaque arithmetic check, the way malware gates a destructive routine.

bomb.c:

#include <stdio.h>
#include <stdlib.h>

void detonate(void) {
    printf("PAYLOAD: wiping disk... (simulated)\n");
}

int main(void) {
    unsigned int code;
    printf("activation code: ");
    if (scanf("%u", &code) != 1) return 1;

    // Dormant unless a non-obvious relationship holds.
    if (((code ^ 0xdeadbeef) % 1337) == 0 && code > 1000000) {
        detonate();
    } else {
        printf("Nothing happens.\n");
    }
    return 0;
}
gcc -no-pie -o bomb bomb.c

Rather than reason about (code ^ 0xdeadbeef) % 1337 == 0, ask angr for a value that reaches detonate. We locate the payload by its output string:

solve_bomb.py:

import angr

proj = angr.Project('./bomb', auto_load_libs=False)
state = proj.factory.entry_state()

simgr = proj.factory.simgr(state)
simgr.explore(find=lambda s: b"PAYLOAD" in s.posix.dumps(1))

if simgr.found:
    trigger = simgr.found[0].posix.dumps(0)
    print("Activation input that detonates:", trigger.strip())
$ python solve_bomb.py
Activation input that detonates: b'1049433'
$ echo 1049433 | ./bomb
activation code: PAYLOAD: wiping disk... (simulated)

angr produced a concrete code satisfying both gating conditions. Against real malware this is how you confirm what a sample is waiting for — a specific mutex name, a campaign ID in a C2 reply, or a date — without letting it run.


Worked example 4 — Bypass an anti-analysis check with a hook

Malware frequently refuses to proceed under analysis: it calls ptrace, IsDebuggerPresent, checks /proc, or times itself. With angr you don’t patch the binary — you replace the offending function with a SimProcedure that returns whatever you want, so symbolic execution sails past the guard.

guarded.c (a ptrace(PTRACE_TRACEME) anti-debug gate):

#include <stdio.h>
#include <sys/ptrace.h>

int main(void) {
    if (ptrace(PTRACE_TRACEME, 0, 0, 0) == -1) {
        printf("Debugger detected. Exiting.\n");
        return 1;
    }
    printf("Real logic runs here.\n");
    return 0;
}
gcc -no-pie -o guarded guarded.c

Hook ptrace so it always reports success (0):

solve_guarded.py:

import angr

proj = angr.Project('./guarded', auto_load_libs=False)

class FakePtrace(angr.SimProcedure):
    def run(self, request, pid, addr, data):
        return 0          # pretend "not being traced"

# Replace the imported ptrace with our model.
proj.hook_symbol('ptrace', FakePtrace())

state = proj.factory.entry_state()
simgr = proj.factory.simgr(state)
simgr.explore(find=lambda s: b"Real logic" in s.posix.dumps(1))

print("Bypassed anti-debug:" , bool(simgr.found))
$ python solve_guarded.py
Bypassed anti-debug: True

You can hook by symbol (proj.hook_symbol('ptrace', …)) or by address for inline checks:

@proj.hook(0x401234, length=5)   # skip 5 bytes of an inline timing check
def skip_check(state):
    state.regs.eax = 0

This is the symbolic analogue of patching out an anti-analysis check — but it costs no edits to the sample and is trivially reversible.


Worked example 5 — Solve for a checksum the program expects

Sometimes the question is “what value makes this routine return success?” Here a function folds the input into a checksum and demands a specific result — a stand -in for the integrity checks malware runs on its own config or unpack buffer. We start angr at the function with call_state instead of running main.

checksum.c:

#include <stdio.h>
#include <string.h>

int verify(const char *s) {
    unsigned int acc = 0x1505;            // djb2-ish seed
    for (size_t i = 0; i < strlen(s); i++)
        acc = ((acc << 5) + acc) + (unsigned char)s[i];
    return acc == 0x7c9d4f33u;
}

int main(int argc, char **argv) {
    if (argc == 2 && verify(argv[1]))
        printf("VALID\n");
    else
        printf("INVALID\n");
    return 0;
}
gcc -no-pie -o checksum checksum.c

Find verify’s address (objdump -d checksum | grep '<verify>:'), then call it directly with a symbolic 6-byte string and constrain the return value to 1:

solve_checksum.py:

import angr
import claripy

proj = angr.Project('./checksum', auto_load_libs=False)
verify_addr = proj.loader.find_symbol('verify').rebased_addr

length = 6
chars = [claripy.BVS(f'c{i}', 8) for i in range(length)]
buf   = claripy.Concat(*chars, claripy.BVV(0, 8))   # NUL-terminated

# Place the string in memory and start as if calling verify(buf).
state = proj.factory.blank_state()
buf_addr = 0x500000
state.memory.store(buf_addr, buf)
for c in chars:
    state.solver.add(c >= 0x20, c <= 0x7e)

call = proj.factory.call_state(verify_addr, buf_addr, base_state=state)
simgr = proj.factory.simgr(call)

# Run to the function's return, then demand return value == 1.
simgr.run()
for s in simgr.deadended:
    if s.solver.satisfiable(extra_constraints=[s.regs.eax == 1]):
        s.solver.add(s.regs.eax == 1)
        print("Input:", s.solver.eval(
            claripy.Concat(*chars), cast_to=bytes))
        break
$ python solve_checksum.py
Input: b'...'        # a 6-byte string whose djb2 hash == 0x7c9d4f33

call_state lets you isolate a single routine — invaluable when running the whole program is too expensive or the function is reached only after heavy setup.


Managing path explosion

The reason symbolic execution isn’t a magic “solve any binary” button is path explosion: every symbolic branch can double the number of states, and loops over symbolic data multiply them without bound. On real malware this is the wall you hit first. angr ships exploration techniques to contain it:

Technique Applied with Effect
DFS simgr.use_technique(angr.exploration_techniques.DFS()) Explore one path deeply; keeps memory flat
LoopSeer …LoopSeer(bound=10) Cap how many times any loop is symbolically unrolled
LengthLimiter …LengthLimiter(max_length=1000) Kill paths longer than N basic blocks
Veritesting proj.factory.simgr(state, veritesting=True) Merge diamond-shaped branches statically to cut state count
Spiller …Spiller() Page idle states to disk to survive large explorations
simgr = proj.factory.simgr(state)
simgr.use_technique(angr.exploration_techniques.DFS())
simgr.use_technique(angr.exploration_techniques.LoopSeer(bound=20))
simgr.explore(find=target)

Other practical levers:

When symbolic execution is the wrong tool

Be honest about the limits — and note these are exactly the conditions malware authors engineer to defeat analysis:


Advanced: unpacking packed binaries with the unicorn engine

Most real-world malware is packed: the on-disk bytes are a small unpacking stub plus a blob of compressed or encrypted code. The stub decrypts the real payload into freshly allocated memory at runtime, then jumps to it — the Original Entry Point (OEP). Vanilla symbolic execution stalls on packers for two reasons:

  1. The real code isn’t on disk. angr lifts and analyzes the stub, not the payload. The interesting logic doesn’t exist until the stub runs.
  2. The unpacking loop is enormous. Decompressing a few hundred KB can be millions of concrete instructions. Executing those symbolically, one basic block per step, is hopelessly slow.

angr solves both with two features that are designed to be used together: the unicorn engine (raw speed) and self-modifying-code support (correctness).

Why the unicorn engine

angr embeds the Unicorn CPU emulator (the same QEMU-derived core used by many tools). When a state’s data is fully concrete — exactly the situation inside an unpacking loop — angr hands execution to Unicorn, which runs at near-native speed across thousands of blocks at once, and only falls back to slow symbolic execution when it touches symbolic data. For packers this is the difference between minutes and never finishing.

Enable it by adding the angr.options.unicorn option set to the state:

state = proj.factory.entry_state(add_options=angr.options.unicorn)
# or for a running state:
state.options.update(angr.options.unicorn)

Why self-modifying-code support

By default angr lifts each basic block from the binary’s original on-disk bytes and caches the translation. A packer writes new code into memory and executes it — so angr would happily run the stale, still-encrypted bytes. Two settings fix this:

proj = angr.Project(
    './packed.bin',
    selfmodifying_code=True,   # re-lift code from current memory, not disk
    auto_load_libs=False,
)

Version note: the parameter is selfmodifying_code in current angr (9.2.x). Older scripts use support_selfmodifying_code=True, which still works but emits a deprecation warning and will be removed.

The unpacking workflow

The end-to-end pattern, modeled on angr’s own self-unpacking CTF solutions:

import angr

proj = angr.Project('./packed.bin',
                    selfmodifying_code=True,
                    auto_load_libs=False)

# Speed: run the unpacking stub concretely under Unicorn.
state = proj.factory.full_init_state(add_options=angr.options.unicorn)

# Anti-analysis the stub may check before unpacking — model it away.
proj.hook_symbol('ptrace',
    angr.SIM_PROCEDURES['stubs']['ReturnUnconstrained'](return_value=0))

simgr = proj.factory.simulation_manager(state)

# Step in batches and prune dead/errored states so memory stays flat.
while simgr.active:
    simgr.run(n=20)
    simgr.stashes['deadended'] = simgr.deadended[-20:]
    simgr.stashes['errored']   = simgr.errored[-20:]

Because the heavy unpacking loop is concrete, Unicorn chews through it quickly; angr only “wakes up” into symbolic mode once the unpacked payload starts touching your symbolic input.

Detecting the OEP and dumping the payload

To analyze the unpacked code you need to know when control reaches it. The generic, packer-agnostic heuristic is write-then-execute: the OEP is the first instruction that runs from a page the stub wrote to during this run. Use an inspect breakpoint to record dirtied pages, then watch for execution entering one:

PAGE = 0x1000
written_pages = set()

def on_write(state):
    addr = state.solver.eval(state.inspect.mem_write_address)
    written_pages.add(addr & ~(PAGE - 1))

state.inspect.b('mem_write', when=angr.BP_AFTER, action=on_write)

# Single-block stepping so we can test the program counter each block.
simgr = proj.factory.simulation_manager(state)
while simgr.active:
    pc = simgr.active[0].addr
    if (pc & ~(PAGE - 1)) in written_pages:
        oep_state = simgr.active[0]
        print(f"OEP reached at {pc:#x}")
        # Dump the unpacked region to disk for static analysis.
        blob = oep_state.memory.load(pc & ~(PAGE - 1), 0x4000)
        data = oep_state.solver.eval(blob, cast_to=bytes)
        open('unpacked.bin', 'wb').write(data)
        break
    simgr.step()

From the OEP state you can pivot straight back into the techniques earlier on this page — explore(find=…) to reach a payload branch, hooks to skip checks — now operating on the unpacked code. Or just take unpacked.bin into Ghidra/IDA for normal static analysis.

Tuning Unicorn for stubborn packers

When a packer mixes symbolic data into the unpacking loop, Unicorn keeps bailing back to slow symbolic execution. The UNICORN_THRESHOLD_CONCRETIZATION option lets angr concretize such values to stay in fast mode:

state.options.add(angr.options.UNICORN_THRESHOLD_CONCRETIZATION)
state.unicorn.concretization_threshold_memory = 256   # tune per sample
state.unicorn.never_concretize.add('user_input')      # protect real inputs
Option / attribute Effect
angr.options.unicorn The base set that turns the Unicorn engine on
UNICORN_THRESHOLD_CONCRETIZATION Concretize symbolic values that keep kicking execution out of Unicorn
unicorn.concretization_threshold_memory How many symbolic memory accesses to tolerate before concretizing
unicorn.always_concretize / never_concretize Force/forbid concretization of named variables — keep your real input symbolic
UNICORN_TRACK_BBL_ADDRS / UNICORN_TRACK_STACK_POINTERS Bookkeeping you can disable to save memory on long runs

Trade-off: aggressive concretization makes unpacking finish, but every value you concretize is a constraint you’ve thrown away. Protect the bytes you actually want to solve for with never_concretize.

When emulation isn’t enough: Symbion

Some packers defeat any emulator — they call unusual syscalls, read hardware details, or detect timing that angr/Unicorn doesn’t model. For those, angr’s Symbion engine (SimEngineConcrete, built on avatar2 + a GDB stub) runs the real binary concretely in a debugger through the unpacking stub, breakpoints at the OEP, then imports the live process state into angr for symbolic analysis. It is heavier to set up (you need a controlled sandbox to run the sample for real) and outside the scope of this page, but it is the escape hatch when pure emulation stalls. Treat any sample you run under Symbion as live malware and confine it accordingly.


References