Symbolic Execution with angr
- Symbolic Execution with angr
- Overview
- Installation
- Core concepts
- Worked example 1 — Recover a hardcoded password (symbolic argv)
- Worked example 2 — Serial check over stdin (find by output string)
- Worked example 3 — Trigger a dormant payload (logic bomb)
- Worked example 4 — Bypass an anti-analysis check with a hook
- Worked example 5 — Solve for a checksum the program expects
- Managing path explosion
- Advanced: unpacking packed binaries with the unicorn engine
- References
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:
- What input unlocks this branch? Recover a hardcoded password or license check without reading the disassembly line by line.
- What triggers the dormant payload? Find the date, filename, or C2 response that a logic bomb is waiting for.
- What does this routine compute? Solve for the value a checksum or key-derivation function expects.
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
concreteexecution 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:
- Hook expensive functions (Example 4) so angr never executes a costly or unmodeled routine symbolically.
- Start late with
blank_state(addr=…)orcall_state(Example 5) to skip initialization you don’t care about. - Constrain inputs early (length, character class) — fewer feasible paths.
- Prefer output-string
find/avoidlambdas so you avoid stepping into irrelevant subtrees.
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:
- Heavy crypto / hashing. SHA-256 over a symbolic buffer produces constraints Z3 cannot invert in reasonable time. Symbolic execution finds control-flow triggers well, not preimages of strong hashes.
- Opaque predicates and bogus control flow. Obfuscators add branches whose outcome is constant but not statically obvious, ballooning the state space.
- Unmodeled environment. Syscalls, threads, network I/O, and GUI callbacks that angr has no SimProcedure for will stall a path or require you to write a model.
- Packing. angr analyzes the on-disk bytes; a packed sample must be unpacked
(or executed concretely up to the OEP) before symbolic analysis is meaningful.
angr’sconcrete/unicorn engine can help bridge here, but it is advanced.
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:
- 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.
- 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,
)
selfmodifying_code=Trueforces angr to read instructions from the current state’s memory on every block, regardless of page permissions — so decrypted code is executed, not the encrypted original.- This option implies
opt_level=0(no cross-block optimization/caching). If you build states by hand, pass it explicitly. Optimization assumes code is immutable, which is exactly false for a packer.
Version note: the parameter is
selfmodifying_codein current angr (9.2.x). Older scripts usesupport_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
- angr documentation — home
- angr — Installing
- angr — Core concept: Symbolic Execution
- angr — Simulation Managers (stashes, explore)
- angr — Simulation and Instrumentation (hooks, SimProcedures)
- angr — Exploration Techniques
- angr — Optimization / the unicorn engine
- angr — Symbion: interleaving symbolic and concrete execution
- angr self-unpacking example — TUMCTF 2016 “zwiebel”
- Unicorn CPU emulator
- angr — Loading a binary (CLE)
- angr — Solver engine (claripy)
- angr API reference
- angr on GitHub
- angr on PyPI
- angr_ctf — hands-on symbolic execution exercises
- angr-doc examples (worked CTF solutions)
- claripy documentation (bitvectors, solver)
- “(State of) The Art of War: Offensive Techniques in Binary Analysis” — angr paper (IEEE S&P 2016)